[Security Solution] Discover-Timeline Integration saved search (#165596)

This PR is next step completing discover timeline integration. All
previous/nest steps have been defined here:
https://github.com/elastic/security-team/issues/6677

## Summary
This PR implements the integration between timeline State v/s Discover
State. The purpose of this PR is to add functionality related to the
persistence of saved search which will always be linked to the timeline
user is working in.

Below diagram shows briefly how saved search is working with timeline.


```mermaid
graph TD;
    DS(Discover State) -. user updates .-> SS(Saved Search);
    SS(Saved Search) -. updates savedSearchId .-> TS(Timeline State) ;
    TS(Timeline State) -. restores Saved Search to App state .->DS(Discover State);

```


Primarily, this PR implements below technical components:

1. `DiscoverInTimleineContext` : provides the ability across security
solution to manipulate discover state.
2. `useDiscoverInTimelineActions`: acts as a helper to provide
short-hand actions to manipulate discover state. For eg.
`resetDiscoverAppState` or `restoreAppStateFromSavedSearch`.



Here is the small demo video:





006465ba-19ce-4209-ac46-21dbb746508d

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Michael Olorunnisola <michael.olorunnisola@elastic.co>
This commit is contained in:
Jatin Kathuria 2023-09-28 20:20:47 +02:00 committed by GitHub
parent d4defbd980
commit fbccec8fdd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
93 changed files with 1730 additions and 114 deletions

View file

@ -2933,6 +2933,9 @@
},
"updatedBy": {
"type": "text"
},
"savedSearchId": {
"type": "text"
}
}
},

View file

@ -137,7 +137,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"security-rule": "07abb4d7e707d91675ec0495c73816394c7b521f",
"security-solution-signals-migration": "9d99715fe5246f19de2273ba77debd2446c36bb1",
"siem-detection-engine-rule-actions": "54f08e23887b20da7c805fab7c60bc67c428aff9",
"siem-ui-timeline": "820b5a7c478cd4d5ae9cd92ce0d05ac988fee69c",
"siem-ui-timeline": "2d9925f7286a9e947a008eff8e61118dadd8229b",
"siem-ui-timeline-note": "0a32fb776907f596bedca292b8c646496ae9c57b",
"siem-ui-timeline-pinned-event": "082daa3ce647b33873f6abccf340bdfa32057c8d",
"slo": "2048ab6791df2e1ae0936f29c20765cb8d2fcfaa",

View file

@ -6,7 +6,10 @@
* Side Public License, v 1.
*/
import { createSearchSourceMock } from '@kbn/data-plugin/public/mocks';
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public';
import type { Filter } from '@kbn/es-query';
import { History } from 'history';
import { savedSearchMock } from '../../../__mocks__/saved_search';
@ -15,6 +18,7 @@ import {
DiscoverAppStateContainer,
getDiscoverAppStateContainer,
} from './discover_app_state_container';
import { SavedSearch } from '@kbn/saved-search-plugin/common';
let history: History;
let state: DiscoverAppStateContainer;
@ -48,4 +52,109 @@ describe('Test discover app state container', () => {
state.set({ index: 'second' });
expect(state.getPrevious()).toEqual(stateA);
});
describe('getAppStateFromSavedSearch', () => {
const customQuery = {
language: 'kuery',
query: '_id: *',
};
const defaultQuery = {
query: '*',
language: 'kuery',
};
const customFilter = {
$state: {
store: 'appState',
},
meta: {
alias: null,
disabled: false,
field: 'ecs.version',
index: 'kibana-event-log-data-view',
key: 'ecs.version',
negate: false,
params: {
query: '1.8.0',
},
type: 'phrase',
},
query: {
match_phrase: {
'ecs.version': '1.8.0',
},
},
} as Filter;
const localSavedSearchMock = {
id: 'the-saved-search-id',
title: 'A saved search',
breakdownField: 'customBreakDownField',
searchSource: createSearchSourceMock({
index: dataViewMock,
filter: [customFilter],
query: customQuery,
}),
hideChart: true,
rowsPerPage: 250,
hideAggregatedPreview: true,
} as SavedSearch;
test('should return correct output', () => {
const appState = state.getAppStateFromSavedSearch(localSavedSearchMock);
expect(appState).toMatchObject(
expect.objectContaining({
breakdownField: 'customBreakDownField',
columns: ['default_column'],
filters: [customFilter],
grid: undefined,
hideChart: true,
index: 'the-data-view-id',
interval: 'auto',
query: customQuery,
rowHeight: undefined,
rowsPerPage: 250,
hideAggregatedPreview: true,
savedQuery: undefined,
sort: [],
viewMode: undefined,
})
);
});
test('should return default query if query is undefined', () => {
discoverServiceMock.data.query.queryString.getDefaultQuery = jest
.fn()
.mockReturnValue(defaultQuery);
const newSavedSearchMock = {
id: 'new-saved-search-id',
title: 'A saved search',
searchSource: createSearchSourceMock({
index: dataViewMock,
filter: [customFilter],
query: undefined,
}),
};
const appState = state.getAppStateFromSavedSearch(newSavedSearchMock);
expect(appState).toMatchObject(
expect.objectContaining({
breakdownField: undefined,
columns: ['default_column'],
filters: [customFilter],
grid: undefined,
hideChart: undefined,
index: 'the-data-view-id',
interval: 'auto',
query: defaultQuery,
rowHeight: undefined,
rowsPerPage: undefined,
hideAggregatedPreview: undefined,
savedQuery: undefined,
sort: [],
viewMode: undefined,
})
);
});
});
});

View file

@ -73,6 +73,12 @@ export interface DiscoverAppStateContainer extends ReduxLikeStateContainer<Disco
* @param replace
*/
update: (newPartial: DiscoverAppState, replace?: boolean) => void;
/*
* Get updated AppState when given a saved search
*
* */
getAppStateFromSavedSearch: (newSavedSearch: SavedSearch) => DiscoverAppState;
}
export interface DiscoverAppState {
@ -170,6 +176,13 @@ export const getDiscoverAppStateContainer = ({
return !isEqualState(initialState, appStateContainer.getState());
};
const getAppStateFromSavedSearch = (newSavedSearch: SavedSearch) => {
return getStateDefaults({
savedSearch: newSavedSearch,
services,
});
};
const resetToState = (state: DiscoverAppState) => {
addLog('[appState] reset state to', state);
previousState = state;
@ -260,6 +273,7 @@ export const getDiscoverAppStateContainer = ({
replaceUrlState,
syncState: startAppStateUrlSync,
update,
getAppStateFromSavedSearch,
};
};

View file

@ -50,7 +50,10 @@ import {
DiscoverSavedSearchContainer,
} from './discover_saved_search_container';
import { updateFiltersReferences } from '../utils/update_filter_references';
import { getDiscoverGlobalStateContainer } from './discover_global_state_container';
import {
getDiscoverGlobalStateContainer,
DiscoverGlobalStateContainer,
} from './discover_global_state_container';
interface DiscoverStateContainerParams {
/**
* Browser history
@ -87,6 +90,11 @@ export interface LoadParams {
}
export interface DiscoverStateContainer {
/**
* Global State, the _g part of the URL
*/
globalState: DiscoverGlobalStateContainer;
/**
* App state, the _a part of the URL
*/
@ -460,6 +468,7 @@ export function getDiscoverStateContainer({
};
return {
globalState: globalStateContainer,
appState: appStateContainer,
internalState: internalStateContainer,
dataState: dataStateContainer,

View file

@ -9,6 +9,7 @@
import React from 'react';
import { sharePluginMock } from '@kbn/share-plugin/public/mocks';
import { DiscoverSetup, DiscoverStart } from '.';
import { getDiscoverStateMock } from './__mocks__/discover_state.mock';
export type Setup = jest.Mocked<DiscoverSetup>;
export type Start = jest.Mocked<DiscoverStart>;
@ -32,4 +33,5 @@ const createStartContract = (): Start => {
export const discoverPluginMock = {
createSetupContract,
createStartContract,
getDiscoverStateMock,
};

View file

@ -336,6 +336,7 @@ export const SavedTimelineRuntimeType = runtimeTypes.partial({
createdBy: unionWithNullType(runtimeTypes.string),
updated: unionWithNullType(runtimeTypes.number),
updatedBy: unionWithNullType(runtimeTypes.string),
savedSearchId: unionWithNullType(runtimeTypes.string),
});
export type SavedTimeline = runtimeTypes.TypeOf<typeof SavedTimelineRuntimeType>;
@ -666,6 +667,7 @@ export interface TimelineResult {
updated?: Maybe<number>;
updatedBy?: Maybe<string>;
version: string;
savedSearchId?: Maybe<string>;
}
export interface ResponseTimeline {

View file

@ -133,6 +133,7 @@ export interface TimelineInput {
savedQueryId?: Maybe<string>;
sort?: Maybe<SortTimelineInput[]>;
status?: Maybe<TimelineStatus>;
savedSearchId: Maybe<string>;
}
export enum FlowDirection {

View file

@ -246,6 +246,7 @@ export const SavedObjectTimelineRuntimeType = runtimeTypes.partial({
createdBy: unionWithNullType(runtimeTypes.string),
updated: unionWithNullType(runtimeTypes.number),
updatedBy: unionWithNullType(runtimeTypes.string),
savedSearchId: unionWithNullType(runtimeTypes.string),
});
type SavedObjectTimeline = runtimeTypes.TypeOf<typeof SavedObjectTimelineRuntimeType>;

View file

@ -58,6 +58,8 @@ export interface TimelinePersistInput {
templateTimelineId?: string | null;
templateTimelineVersion?: number | null;
title?: string;
/* used to saved discover Saved search Id */
savedSearchId?: string | null;
}
/** Invoked when a column is sorted */

View file

@ -49,7 +49,9 @@
"dataViewEditor",
"stackConnectors",
"discover",
"notifications"
"notifications",
"savedObjects",
"savedSearch"
],
"optionalPlugins": [
"cloudExperiments",

View file

@ -31,6 +31,7 @@ import type { StartServices } from '../types';
import { PageRouter } from './routes';
import { UserPrivilegesProvider } from '../common/components/user_privileges/user_privileges_context';
import { ReactQueryClientProvider } from '../common/containers/query_client/query_client_provider';
import { DiscoverInTimelineContextProvider } from '../common/components/discover_in_timeline/provider';
import { AssistantProvider } from '../assistant/provider';
interface StartAppComponent {
@ -77,13 +78,15 @@ const StartAppComponent: FC<StartAppComponent> = ({
getTriggerCompatibleActions={uiActions.getTriggerCompatibleActions}
>
<UpsellingProvider upsellingService={upselling}>
<PageRouter
history={history}
onAppLeave={onAppLeave}
setHeaderActionMenu={setHeaderActionMenu}
>
{children}
</PageRouter>
<DiscoverInTimelineContextProvider>
<PageRouter
history={history}
onAppLeave={onAppLeave}
setHeaderActionMenu={setHeaderActionMenu}
>
{children}
</PageRouter>
</DiscoverInTimelineContextProvider>
</UpsellingProvider>
</CellActionsProvider>
</ReactQueryClientProvider>

View file

@ -29,7 +29,7 @@ describe('callout', () => {
});
afterEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
test('renders the callout data-test-subj from the given id', () => {

View file

@ -37,7 +37,7 @@ describe('useChartSettingsPopoverConfiguration', () => {
<TestProviders store={store}>{children}</TestProviders>
);
beforeEach(() => jest.resetAllMocks());
beforeEach(() => jest.clearAllMocks());
test('it returns the expected defaultInitialPanelId', () => {
const { result } = renderHook(

View file

@ -0,0 +1,28 @@
/*
* 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.
*/
export const useDiscoverInTimelineActions = () => {
return {
resetDiscoverAppState: jest.fn(),
restoreDiscoverAppStateFromSavedSearch: jest.fn(),
updateSavedSearch: jest.fn(),
getAppStateFromSavedSearch: jest.fn(),
defaultDiscoverAppState: {
query: {
query: '',
language: 'kuery',
},
sort: [['@timestamp', 'desc']],
columns: [],
index: 'security-solution-default',
interval: 'auto',
filters: [],
hideChart: true,
grid: {},
},
};
};

View file

@ -0,0 +1,19 @@
/*
* 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 type { DiscoverStateContainer } from '@kbn/discover-plugin/public';
import type { RefObject } from 'react';
import { createContext } from 'react';
import type { useDiscoverInTimelineActions } from './use_discover_in_timeline_actions';
export interface DiscoverInTimelineContextType
extends ReturnType<typeof useDiscoverInTimelineActions> {
discoverStateContainer: RefObject<DiscoverStateContainer | undefined>;
setDiscoverStateContainer: (stateContainer: DiscoverStateContainer) => void;
}
export const DiscoverInTimelineContext = createContext<DiscoverInTimelineContextType | null>(null);

View file

@ -0,0 +1,39 @@
/*
* 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 type { DiscoverStateContainer } from '@kbn/discover-plugin/public';
import { discoverPluginMock } from '@kbn/discover-plugin/public/mocks';
import React, { useRef, useCallback } from 'react';
import type { FC, PropsWithChildren } from 'react';
import { DiscoverInTimelineContext } from '../context';
import { useDiscoverInTimelineActions } from '../use_discover_in_timeline_actions';
type Props = PropsWithChildren<{}>;
jest.mock('../use_discover_in_timeline_actions');
export const MockDiscoverInTimelineContext: FC<Props> = ({ children }) => {
const discoverStateContainer = useRef(discoverPluginMock.getDiscoverStateMock({}));
const setDiscoverStateContainer = useCallback((stateContainer: DiscoverStateContainer) => {
discoverStateContainer.current = stateContainer;
}, []);
const actions = useDiscoverInTimelineActions(discoverStateContainer);
return (
<DiscoverInTimelineContext.Provider
value={{
discoverStateContainer,
setDiscoverStateContainer,
...actions,
}}
>
{children}
</DiscoverInTimelineContext.Provider>
);
};

View file

@ -0,0 +1,38 @@
/*
* 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 type { DiscoverStateContainer } from '@kbn/discover-plugin/public';
import type { PropsWithChildren, FC } from 'react';
import React, { useCallback, useRef } from 'react';
import { DiscoverInTimelineContext } from './context';
import { useDiscoverInTimelineActions } from './use_discover_in_timeline_actions';
type DiscoverInTimelineContextProviderProps = PropsWithChildren<{}>;
export const DiscoverInTimelineContextProvider: FC<DiscoverInTimelineContextProviderProps> = (
props
) => {
const discoverStateContainer = useRef<DiscoverStateContainer>();
const actions = useDiscoverInTimelineActions(discoverStateContainer);
const setDiscoverStateContainer = useCallback((stateContainer: DiscoverStateContainer) => {
discoverStateContainer.current = stateContainer;
}, []);
return (
<DiscoverInTimelineContext.Provider
value={{
discoverStateContainer,
setDiscoverStateContainer,
...actions,
}}
>
{props.children}
</DiscoverInTimelineContext.Provider>
);
};

View file

@ -0,0 +1,22 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const DISCOVER_SEARCH_SAVE_ERROR_TITLE = i18n.translate(
'xpack.securitySolution.timelines.discoverInTimeline.save_saved_search_error',
{
defaultMessage: 'Error while saving the Discover search',
}
);
export const DISCOVER_SEARCH_SAVE_ERROR_UNKNOWN = i18n.translate(
'xpack.securitySolution.timelines.discoverInTimeline.save_saved_search_unknown_error',
{
defaultMessage: 'Unknown error occurred while saving Discover search',
}
);

View file

@ -0,0 +1,265 @@
/*
* 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 { createSearchSourceMock } from '@kbn/data-plugin/public/mocks';
import { discoverPluginMock } from '@kbn/discover-plugin/public/mocks';
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
import type { SavedSearch } from '@kbn/saved-search-plugin/common';
import { renderHook } from '@testing-library/react-hooks';
import {
createSecuritySolutionStorageMock,
kibanaObservable,
mockGlobalState,
SUB_PLUGINS_REDUCER,
TestProviders,
} from '../../mock';
import { useDiscoverInTimelineActions } from './use_discover_in_timeline_actions';
import type { Filter } from '@kbn/es-query';
import { createStartServicesMock } from '../../lib/kibana/kibana_react.mock';
import { useKibana } from '../../lib/kibana';
import type { State } from '../../store';
import { createStore } from '../../store';
import { TimelineId } from '../../../../common/types';
import type { ComponentType, FC, PropsWithChildren } from 'react';
import React from 'react';
const mockDiscoverStateContainerRef = {
current: discoverPluginMock.getDiscoverStateMock({}),
};
jest.mock('../../lib/kibana');
const mockState: State = {
...mockGlobalState,
timeline: {
...mockGlobalState.timeline,
timelineById: {
...mockGlobalState.timeline.timelineById,
[TimelineId.active]: {
...mockGlobalState.timeline.timelineById[TimelineId.active],
title: 'Active Timeline',
description: 'Active Timeline Description',
},
},
},
};
jest.mock('./use_discover_in_timeline_actions', () => {
const actual = jest.requireActual('./use_discover_in_timeline_actions');
return actual;
});
const { storage } = createSecuritySolutionStorageMock();
const getTestProviderWithCustomState = (state: State = mockState) => {
const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const MockTestProvider: FC<PropsWithChildren<{}>> = ({ children }) => (
<TestProviders store={store}> {children}</TestProviders>
);
return MockTestProvider;
};
const renderTestHook = (customWrapper: ComponentType = getTestProviderWithCustomState()) => {
return renderHook(() => useDiscoverInTimelineActions(mockDiscoverStateContainerRef), {
wrapper: customWrapper,
});
};
const customQuery = {
language: 'kuery',
query: '_id: *',
};
const customFilter = {
$state: {
store: 'appState',
},
meta: {
alias: null,
disabled: false,
field: 'ecs.version',
index: 'kibana-event-log-data-view',
key: 'ecs.version',
negate: false,
params: {
query: '1.8.0',
},
type: 'phrase',
},
query: {
match_phrase: {
'ecs.version': '1.8.0',
},
},
} as Filter;
const originalSavedSearchMock = {
id: 'the-saved-search-id',
title: 'A saved search',
breakdownField: 'customBreakDownField',
searchSource: createSearchSourceMock({
index: dataViewMock,
filter: [customFilter],
query: customQuery,
}),
};
export const savedSearchMock = {
...originalSavedSearchMock,
hideChart: true,
sort: ['@timestamp', 'desc'],
timeRange: {
from: 'now-20d',
to: 'now',
},
} as unknown as SavedSearch;
const startServicesMock = createStartServicesMock();
describe('useDiscoverInTimelineActions', () => {
beforeEach(() => {
(useKibana as jest.Mock).mockImplementation(() => ({
services: startServicesMock,
}));
});
afterEach(() => {
jest.clearAllMocks();
});
describe('getAppStateFromSavedSearch', () => {
it('should reach out to discover to convert app state from saved search', async () => {
const { result, waitFor } = renderTestHook();
const { appState } = result.current.getAppStateFromSavedSearch(savedSearchMock);
await waitFor(() => {
expect(appState).toMatchObject(
expect.objectContaining({
breakdownField: 'customBreakDownField',
columns: ['default_column'],
filters: [customFilter],
grid: undefined,
hideAggregatedPreview: undefined,
hideChart: true,
index: 'the-data-view-id',
interval: 'auto',
query: customQuery,
rowHeight: undefined,
rowsPerPage: undefined,
savedQuery: undefined,
sort: [['@timestamp', 'desc']],
viewMode: undefined,
})
);
});
});
});
describe('restoreDiscoverAppStateFromSavedSearch', () => {
it('should restore basic discover app state and timeRange from a given saved Search', async () => {
const { result, waitFor } = renderTestHook();
result.current.restoreDiscoverAppStateFromSavedSearch(savedSearchMock);
await waitFor(() => {
const appState = mockDiscoverStateContainerRef.current.appState.getState();
const globalState = mockDiscoverStateContainerRef.current.globalState.get();
expect(appState).toMatchObject({
breakdownField: 'customBreakDownField',
columns: ['default_column'],
filters: [customFilter],
grid: undefined,
hideAggregatedPreview: undefined,
hideChart: true,
index: 'the-data-view-id',
interval: 'auto',
query: customQuery,
rowHeight: undefined,
rowsPerPage: undefined,
savedQuery: undefined,
sort: [['@timestamp', 'desc']],
viewMode: undefined,
});
expect(globalState).toMatchObject({ time: { from: 'now-20d', to: 'now' } });
});
});
});
describe('resetDiscoverAppState', () => {
it('should reset Discover AppState to a default state', async () => {
const { result, waitFor } = renderTestHook();
result.current.resetDiscoverAppState();
await waitFor(() => {
const appState = mockDiscoverStateContainerRef.current.appState.getState();
expect(appState).toMatchObject(result.current.defaultDiscoverAppState);
});
});
it('should reset Discover time to a default state', async () => {
const { result, waitFor } = renderTestHook();
result.current.resetDiscoverAppState();
await waitFor(() => {
const globalState = mockDiscoverStateContainerRef.current.globalState.get();
expect(globalState).toMatchObject({ time: { from: 'now-15m', to: 'now' } });
});
});
});
describe('updateSavedSearch', () => {
it('should add defaults to the savedSearch before updating saved search', async () => {
const { result } = renderTestHook();
await result.current.updateSavedSearch(savedSearchMock, TimelineId.active);
expect(startServicesMock.savedSearch.save).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
timeRestore: true,
timeRange: {
from: 'now-20d',
to: 'now',
},
tags: ['security-solution-default'],
}),
expect.objectContaining({
copyOnSave: true,
})
);
});
it('should send update request when savedSearchId is already available', async () => {
const localMockState: State = {
...mockGlobalState,
timeline: {
...mockGlobalState.timeline,
timelineById: {
...mockGlobalState.timeline.timelineById,
[TimelineId.active]: {
...mockGlobalState.timeline.timelineById[TimelineId.active],
title: 'Active Timeline',
description: 'Active Timeline Description',
savedSearchId: 'saved_search_id',
},
},
},
};
const LocalTestProvider = getTestProviderWithCustomState(localMockState);
const { result } = renderTestHook(LocalTestProvider);
await result.current.updateSavedSearch(savedSearchMock, TimelineId.active);
expect(startServicesMock.savedSearch.save).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
timeRestore: true,
timeRange: {
from: 'now-20d',
to: 'now',
},
tags: ['security-solution-default'],
id: 'saved_search_id',
}),
expect.objectContaining({
copyOnSave: false,
})
);
});
it('should raise appropriate notification in case of any error in saving discover saved search', () => {});
});
});

View file

@ -0,0 +1,235 @@
/*
* 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 type { DiscoverStateContainer } from '@kbn/discover-plugin/public';
import type { SaveSavedSearchOptions } from '@kbn/saved-search-plugin/public';
import type { RefObject } from 'react';
import { useMemo, useCallback } from 'react';
import { useDispatch } from 'react-redux';
import type { SavedSearch } from '@kbn/saved-search-plugin/common';
import type { DiscoverAppState } from '@kbn/discover-plugin/public/application/main/services/discover_app_state_container';
import type { TimeRange } from '@kbn/es-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { endTimelineSaving, startTimelineSaving } from '../../../timelines/store/timeline/actions';
import { timelineDefaults } from '../../../timelines/store/timeline/defaults';
import { TimelineId } from '../../../../common/types';
import { timelineActions, timelineSelectors } from '../../../timelines/store/timeline';
import { useAppToasts } from '../../hooks/use_app_toasts';
import { useShallowEqualSelector } from '../../hooks/use_selector';
import { useKibana } from '../../lib/kibana';
import { useSourcererDataView } from '../../containers/sourcerer';
import { SourcererScopeName } from '../../store/sourcerer/model';
import {
DISCOVER_SEARCH_SAVE_ERROR_TITLE,
DISCOVER_SEARCH_SAVE_ERROR_UNKNOWN,
} from './translations';
export const defaultDiscoverTimeRange: TimeRange = {
from: 'now-15m',
to: 'now',
mode: 'relative',
};
export const useDiscoverInTimelineActions = (
discoverStateContainer: RefObject<DiscoverStateContainer | undefined>
) => {
const { addError } = useAppToasts();
const {
services: { customDataService: discoverDataService, savedSearch: savedSearchService },
} = useKibana();
const dispatch = useDispatch();
const { dataViewId } = useSourcererDataView(SourcererScopeName.detections);
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const timeline = useShallowEqualSelector(
(state) => getTimeline(state, TimelineId.active) ?? timelineDefaults
);
const { savedSearchId } = timeline;
const queryClient = useQueryClient();
const { mutateAsync: saveSavedSearch } = useMutation({
mutationFn: ({
savedSearch,
savedSearchOptions,
}: {
savedSearch: SavedSearch;
savedSearchOptions: SaveSavedSearchOptions;
}) => savedSearchService.save(savedSearch, savedSearchOptions),
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['savedSearchById', savedSearchId] });
},
});
const defaultDiscoverAppState: DiscoverAppState = useMemo(() => {
return {
query: discoverDataService.query.queryString.getDefaultQuery(),
sort: [['@timestamp', 'desc']],
columns: [],
index: dataViewId ?? 'security-solution-default',
interval: 'auto',
filters: [],
hideChart: true,
grid: {},
};
}, [discoverDataService, dataViewId]);
/*
* generates Appstate from a given saved Search object
*
* @param savedSearch
*
* */
const getAppStateFromSavedSearch = useCallback(
(savedSearch: SavedSearch) => {
const appState =
discoverStateContainer.current?.appState.getAppStateFromSavedSearch(savedSearch);
return {
savedSearch,
appState,
};
},
[discoverStateContainer]
);
/*
* restores the url state of discover in timeline
*
* @param savedSearch
* */
const restoreDiscoverAppStateFromSavedSearch = useCallback(
(savedSearch: SavedSearch) => {
const { appState } = getAppStateFromSavedSearch(savedSearch);
if (!appState) return;
discoverStateContainer.current?.appState.set(appState);
const timeRangeFromSavedSearch = savedSearch.timeRange;
discoverStateContainer.current?.globalState.set({
...discoverStateContainer.current?.globalState.get(),
time: timeRangeFromSavedSearch ?? defaultDiscoverTimeRange,
});
},
[getAppStateFromSavedSearch, discoverStateContainer]
);
/*
* resets discover state to a default value
*
* */
const resetDiscoverAppState = useCallback(() => {
discoverStateContainer.current?.appState.set(defaultDiscoverAppState);
discoverStateContainer.current?.globalState.set({
...discoverStateContainer.current?.globalState.get(),
time: defaultDiscoverTimeRange,
});
}, [defaultDiscoverAppState, discoverStateContainer]);
const persistSavedSearch = useCallback(
async (savedSearch: SavedSearch, savedSearchOption: SaveSavedSearchOptions) => {
if (!discoverStateContainer) {
// eslint-disable-next-line no-console
console.log(`Saved search is not open since state container is null`);
return;
}
if (!savedSearch) return;
function onError(error: Error) {
addError(error, { title: DISCOVER_SEARCH_SAVE_ERROR_TITLE });
}
try {
const id = await saveSavedSearch({
savedSearch,
savedSearchOptions: savedSearchOption,
});
if (id) {
return { id };
} else {
addError(DISCOVER_SEARCH_SAVE_ERROR_UNKNOWN, { title: DISCOVER_SEARCH_SAVE_ERROR_TITLE });
}
} catch (err) {
onError(err);
}
},
[addError, discoverStateContainer, saveSavedSearch]
);
/*
* persists the given savedSearch
*
* */
const updateSavedSearch = useCallback(
async (savedSearch: SavedSearch, timelineId: string) => {
dispatch(
startTimelineSaving({
id: timelineId,
})
);
savedSearch.timeRestore = true;
savedSearch.timeRange =
savedSearch.timeRange ?? discoverDataService.query.timefilter.timefilter.getTime();
savedSearch.tags = ['security-solution-default'];
if (savedSearchId) {
savedSearch.id = savedSearchId;
}
try {
const response = await persistSavedSearch(savedSearch, {
onTitleDuplicate: () => {},
copyOnSave: !savedSearchId,
});
if (!response || !response.id) {
throw new Error('Unknown Error occured');
}
if (!savedSearchId) {
dispatch(
timelineActions.updateSavedSearchId({
id: TimelineId.active,
savedSearchId: response.id,
})
);
}
} catch (err) {
addError(DISCOVER_SEARCH_SAVE_ERROR_TITLE, {
title: DISCOVER_SEARCH_SAVE_ERROR_TITLE,
toastMessage: String(err),
});
} finally {
dispatch(
endTimelineSaving({
id: timelineId,
})
);
}
},
[persistSavedSearch, savedSearchId, addError, dispatch, discoverDataService]
);
const actions = useMemo(
() => ({
resetDiscoverAppState,
restoreDiscoverAppStateFromSavedSearch,
updateSavedSearch,
getAppStateFromSavedSearch,
defaultDiscoverAppState,
}),
[
resetDiscoverAppState,
restoreDiscoverAppStateFromSavedSearch,
updateSavedSearch,
getAppStateFromSavedSearch,
defaultDiscoverAppState,
]
);
return actions;
};

View file

@ -0,0 +1,19 @@
/*
* 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 { useContext } from 'react';
import { DiscoverInTimelineContext } from './context';
export const useDiscoverInTimelineContext = () => {
const discoverContext = useContext(DiscoverInTimelineContext);
if (!discoverContext) {
const errMessage = `useDiscoverInTimelineContext should only used within a tree with parent as DiscoverInTimelineContextProvider`;
throw new Error(errMessage);
}
return discoverContext;
};

View file

@ -49,7 +49,7 @@ const props = {
};
describe('RelatedAlertsByProcessAncestry', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
it('shows an accordion and does not fetch data right away', () => {

View file

@ -19,7 +19,7 @@ const useUserPrivilegesMock = useUserPrivileges as jest.Mock;
describe('AddEventNoteAction', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
describe('isDisabled', () => {

View file

@ -19,7 +19,7 @@ const useUserPrivilegesMock = useUserPrivileges as jest.Mock;
describe('PinEventAction', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
describe('isDisabled', () => {

View file

@ -39,7 +39,7 @@ jest.mock('../../lib/kibana', () => {
describe('useLocalStorage', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
test('it returns the expected value from local storage', async () => {

View file

@ -86,7 +86,7 @@ describe('SearchBarComponent', () => {
const pollForSignalIndex = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
it('calls pollForSignalIndex on Refresh button click', () => {

View file

@ -33,6 +33,7 @@ export const useInitTimelineFromUrlParam = () => {
updateIsLoading: (status: { id: string; isLoading: boolean }) =>
dispatch(timelineActions.updateIsLoading(status)),
updateTimeline: dispatchUpdateTimeline(dispatch),
savedSearchId: initialState.savedSearchId,
});
}
},

View file

@ -17,7 +17,7 @@ import { URL_PARAM_KEY } from '../use_url_state';
export const useSyncTimelineUrlParam = () => {
const updateUrlParam = useUpdateUrlParam<TimelineUrl>(URL_PARAM_KEY.timeline);
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const { activeTab, graphEventId, show, savedObjectId } = useShallowEqualSelector(
const { activeTab, graphEventId, show, savedObjectId, savedSearchId } = useShallowEqualSelector(
(state) => getTimeline(state, TimelineId.active) ?? {}
);
@ -27,7 +27,8 @@ export const useSyncTimelineUrlParam = () => {
isOpen: show,
activeTab,
graphEventId: graphEventId ?? '',
savedSearchId: savedSearchId ? savedSearchId : undefined,
};
updateUrlParam(params);
}, [activeTab, graphEventId, savedObjectId, show, updateUrlParam]);
}, [activeTab, graphEventId, savedObjectId, show, updateUrlParam, savedSearchId]);
};

View file

@ -29,7 +29,7 @@ jest.mock('../../timelines/store/timeline', () => ({
describe('useResolveConflict', () => {
const mockGetLegacyUrlConflict = jest.fn().mockReturnValue('Test!');
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
// Mock rison format in actual url
(useLocation as jest.Mock).mockReturnValue({
pathname: 'my/cool/path',

View file

@ -30,7 +30,7 @@ jest.mock('../../timelines/store/timeline', () => ({
describe('useResolveRedirect', () => {
const mockRedirectLegacyUrl = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
// Mock rison format in actual url
(useLocation as jest.Mock).mockReturnValue({
pathname: 'my/cool/path',

View file

@ -52,6 +52,7 @@ import { UpsellingService } from '@kbn/security-solution-upselling/service';
import { cloudMock } from '@kbn/cloud-plugin/public/mocks';
import { NavigationProvider } from '@kbn/security-solution-navigation';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { savedSearchPluginMock } from '@kbn/saved-search-plugin/public/mocks';
const mockUiSettings: Record<string, unknown> = {
[DEFAULT_TIME_RANGE]: { from: 'now-15m', to: 'now', mode: 'quick' },
@ -220,6 +221,7 @@ export const createStartServicesMock = (
upselling: new UpsellingService(),
customDataService,
uiActions: uiActionsPluginMock.createStartContract(),
savedSearch: savedSearchPluginMock.createStartContract(),
} as unknown as StartServices;
};

View file

@ -374,6 +374,8 @@ export const mockGlobalState: State = {
filters: [],
isSaving: false,
itemsPerPageOptions: [10, 25, 50, 100],
savedSearchId: null,
isDiscoverSavedSearchLoaded: false,
},
},
insertTimeline: null,

View file

@ -38,6 +38,7 @@ import { SUB_PLUGINS_REDUCER } from './utils';
import { createSecuritySolutionStorageMock, localStorageMock } from './mock_local_storage';
import { ASSISTANT_FEATURE_ID, CASES_FEATURE_ID } from '../../../common/constants';
import { UserPrivilegesProvider } from '../components/user_privileges/user_privileges_context';
import { MockDiscoverInTimelineContext } from '../components/discover_in_timeline/mocks/discover_in_timeline_provider';
const state: State = mockGlobalState;
@ -79,19 +80,21 @@ export const TestProvidersComponent: React.FC<Props> = ({
<UpsellingProviderMock>
<ReduxStoreProvider store={store}>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<MockAssistantProvider>
<QueryClientProvider client={queryClient}>
<ExpandableFlyoutProvider>
<ConsoleManager>
<CellActionsProvider
getTriggerCompatibleActions={() => Promise.resolve(cellActions)}
>
<DragDropContext onDragEnd={onDragEnd}>{children}</DragDropContext>
</CellActionsProvider>
</ConsoleManager>
</ExpandableFlyoutProvider>
</QueryClientProvider>
</MockAssistantProvider>
<QueryClientProvider client={queryClient}>
<MockDiscoverInTimelineContext>
<MockAssistantProvider>
<ExpandableFlyoutProvider>
<ConsoleManager>
<CellActionsProvider
getTriggerCompatibleActions={() => Promise.resolve(cellActions)}
>
<DragDropContext onDragEnd={onDragEnd}>{children}</DragDropContext>
</CellActionsProvider>
</ConsoleManager>
</ExpandableFlyoutProvider>
</MockAssistantProvider>
</MockDiscoverInTimelineContext>
</QueryClientProvider>
</ThemeProvider>
</ReduxStoreProvider>
</UpsellingProviderMock>
@ -117,29 +120,40 @@ const TestProvidersWithPrivilegesComponent: React.FC<Props> = ({
onDragEnd = jest.fn(),
cellActions = [],
}) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return (
<I18nProvider>
<MockKibanaContextProvider>
<MockSubscriptionTrackingProvider>
<ReduxStoreProvider store={store}>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<MockAssistantProvider>
<UserPrivilegesProvider
kibanaCapabilities={
{
siem: { show: true, crud: true },
[CASES_FEATURE_ID]: { read_cases: true, crud_cases: false },
[ASSISTANT_FEATURE_ID]: { 'ai-assistant': true },
} as unknown as Capabilities
}
>
<CellActionsProvider
getTriggerCompatibleActions={() => Promise.resolve(cellActions)}
>
<DragDropContext onDragEnd={onDragEnd}>{children}</DragDropContext>
</CellActionsProvider>
</UserPrivilegesProvider>
</MockAssistantProvider>
<QueryClientProvider client={queryClient}>
<MockDiscoverInTimelineContext>
<MockAssistantProvider>
<UserPrivilegesProvider
kibanaCapabilities={
{
siem: { show: true, crud: true },
[CASES_FEATURE_ID]: { read_cases: true, crud_cases: false },
[ASSISTANT_FEATURE_ID]: { 'ai-assistant': true },
} as unknown as Capabilities
}
>
<CellActionsProvider
getTriggerCompatibleActions={() => Promise.resolve(cellActions)}
>
<DragDropContext onDragEnd={onDragEnd}>{children}</DragDropContext>
</CellActionsProvider>
</UserPrivilegesProvider>
</MockAssistantProvider>
</MockDiscoverInTimelineContext>
</QueryClientProvider>
</ThemeProvider>
</ReduxStoreProvider>
</MockSubscriptionTrackingProvider>

View file

@ -2026,6 +2026,7 @@ export const mockTimelineModel: TimelineModel = {
templateTimelineId: null,
templateTimelineVersion: null,
version: '1',
savedSearchId: null,
};
export const mockDataTableModel: DataTableModel = {
@ -2205,6 +2206,8 @@ export const defaultTimelineProps: CreateTimelineProps = {
templateTimelineVersion: null,
templateTimelineId: null,
version: null,
savedSearchId: null,
isDiscoverSavedSearchLoaded: false,
},
to: '2018-11-05T19:03:25.937Z',
notes: null,

View file

@ -54,7 +54,7 @@ describe.skip('ExceptionsAddToListsTable', () => {
});
afterEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
it('it displays loading state while fetching data', () => {

View file

@ -265,7 +265,6 @@ describe('alert actions', () => {
// jest carries state between mocked implementations when using
// spyOn. So now we're doing all three of these.
// https://github.com/facebook/jest/issues/7136#issuecomment-565976599
jest.resetAllMocks();
jest.clearAllMocks();
mockGetExceptionFilter = jest.fn().mockResolvedValue(undefined);
@ -452,6 +451,8 @@ describe('alert actions', () => {
templateTimelineId: null,
templateTimelineVersion: null,
version: null,
savedSearchId: null,
isDiscoverSavedSearchLoaded: false,
},
to: '2018-11-05T19:03:25.937Z',
resolveTimelineConfig: undefined,

View file

@ -13,7 +13,7 @@ import * as userInfo from '../../user_info';
describe('need_admin_for_update_callout', () => {
afterEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
describe('hasIndexManage is "null"', () => {

View file

@ -26,7 +26,7 @@ jest.mock('../../../../common/lib/kibana', () => {
describe('EQL footer', () => {
describe('EQL Settings', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
it('EQL settings button is enable when popover is NOT open', () => {

View file

@ -138,7 +138,7 @@ describe('QueryBarDefineRule', () => {
getByTestId('open-timeline-modal').click();
await act(async () => {
fireEvent.click(getByTestId('title-10849df0-7b44-11e9-a608-ab3d811609'));
fireEvent.click(getByTestId('timeline-title-10849df0-7b44-11e9-a608-ab3d811609'));
});
expect(onOpenTimeline).toHaveBeenCalled();
});

View file

@ -45,7 +45,7 @@ describe('RuleActionsOverflow', () => {
jest.clearAllMocks();
});
afterAll(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
describe('rules details menu panel', () => {

View file

@ -100,7 +100,7 @@ describe('useAlertsPrivileges', () => {
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
appToastsMock = useAppToastsMock.create();
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
useUserPrivilegesMock.mockReturnValue(userPrivilegesInitial);

View file

@ -18,7 +18,7 @@ jest.mock('../../../../common/hooks/use_app_toasts');
describe('useCasesFromAlerts hook', () => {
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
appToastsMock = useAppToastsMock.create();
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
});

View file

@ -15,13 +15,13 @@ import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
jest.mock('./api');
jest.mock('../../../../common/hooks/use_app_toasts');
jest.mock('../../../../common/components/user_privileges/endpoint/use_endpoint_privileges');
jest.mock('../../../../timelines/components/timeline/discover_tab_content');
describe('useSignalIndex', () => {
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
appToastsMock = useAppToastsMock.create();
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
});

View file

@ -21,7 +21,7 @@ import { ChartContextMenu } from '.';
describe('ChartContextMenu', () => {
const queryId = 'abcd';
beforeEach(() => jest.resetAllMocks());
beforeEach(() => jest.clearAllMocks());
test('it renders the chart context menu button', () => {
render(

View file

@ -18,7 +18,7 @@ import {
import * as i18n from './translations';
describe('helpers', () => {
beforeEach(() => jest.resetAllMocks());
beforeEach(() => jest.clearAllMocks());
describe('getButtonProperties', () => {
test('it returns the expected properties when alertViewSelection is Trend', () => {

View file

@ -83,7 +83,7 @@ describe('ExceptionsListCard', () => {
(useListDetailsView as jest.Mock).mockReturnValue(getMockUseListDetailsView());
});
afterEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
it('should display expired exception confirmation modal when "showIncludeExpiredExceptionsModal" is "true"', () => {

View file

@ -148,7 +148,7 @@ describe('Network Details', () => {
});
afterAll(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
const state: State = mockGlobalState;

View file

@ -34,7 +34,7 @@ const NO_DATA_MESSAGE = 'An error is preventing this alert from being analyzed.'
describe('<AnalyzerPreview />', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
it('shows analyzer preview correctly when documentId and index are present', () => {

View file

@ -62,7 +62,7 @@ const renderAnalyzerPreview = () =>
describe('AnalyzerPreviewContainer', () => {
afterEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
it('should render component and link in header', () => {

View file

@ -39,7 +39,7 @@ const renderSessionPreview = () =>
describe('SessionPreview', () => {
afterEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
it('renders session preview with all data', () => {

View file

@ -51,7 +51,7 @@ const renderSessionPreview = () =>
describe('SessionPreviewContainer', () => {
afterEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
it('should render component and link in header', () => {

View file

@ -4,7 +4,6 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { PluginInitializerContext } from '@kbn/core/public';
import { Plugin } from './plugin';

View file

@ -82,7 +82,7 @@ describe('DataQuality', () => {
const defaultIlmPhases = `${HOT}${WARM}${UNMANAGED}`;
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
mockUseSourcererDataView.mockReturnValue(defaultUseSourcererReturn);
mockUseSignalIndex.mockReturnValue(defaultUseSignalIndexReturn);

View file

@ -327,6 +327,7 @@ export interface QueryTimelineById<TCache> {
isLoading: boolean;
}) => Action<{ id: string; isLoading: boolean }>;
updateTimeline: DispatchUpdateTimeline;
savedSearchId?: string;
}
export const queryTimelineById = <TCache>({
@ -340,6 +341,7 @@ export const queryTimelineById = <TCache>({
openTimeline = true,
updateIsLoading,
updateTimeline,
savedSearchId,
}: QueryTimelineById<TCache>) => {
updateIsLoading({ id: TimelineId.active, isLoading: true });
if (timelineId == null) {
@ -355,6 +357,7 @@ export const queryTimelineById = <TCache>({
activeTab: activeTimelineTab,
show: openTimeline,
initialized: true,
savedSearchId: savedSearchId ?? null,
},
})();
updateIsLoading({ id: TimelineId.active, isLoading: false });
@ -395,6 +398,7 @@ export const queryTimelineById = <TCache>({
graphEventId,
show: openTimeline,
dateRange: { start: from, end: to },
savedSearchId: timeline.savedSearchId,
},
to,
})();

View file

@ -642,7 +642,9 @@ describe('StatefulOpenTimeline', () => {
await waitFor(() => {
wrapper
.find(`[data-test-subj="title-${mockOpenTimelineQueryResults.timeline[0].savedObjectId}"]`)
.find(
`[data-test-subj="timeline-title-${mockOpenTimelineQueryResults.timeline[0].savedObjectId}"]`
)
.last()
.simulate('click');

View file

@ -271,7 +271,10 @@ describe('#getCommonColumns', () => {
);
expect(
wrapper.find(`[data-test-subj="title-${mockResults[0].savedObjectId}"]`).first().text()
wrapper
.find(`[data-test-subj="timeline-title-${mockResults[0].savedObjectId}"]`)
.first()
.text()
).toEqual(mockResults[0].title);
});
@ -314,7 +317,10 @@ describe('#getCommonColumns', () => {
);
expect(
wrapper.find(`[data-test-subj="title-${missingTitle[0].savedObjectId}"]`).first().text()
wrapper
.find(`[data-test-subj="timeline-title-${missingTitle[0].savedObjectId}"]`)
.first()
.text()
).toEqual(i18n.UNTITLED_TIMELINE);
});
@ -357,7 +363,7 @@ describe('#getCommonColumns', () => {
expect(
wrapper
.find(`[data-test-subj="title-${withJustWhitespaceTitle[0].savedObjectId}"]`)
.find(`[data-test-subj="timeline-title-${withJustWhitespaceTitle[0].savedObjectId}"]`)
.first()
.text()
).toEqual(i18n.UNTITLED_TIMELINE);
@ -397,7 +403,10 @@ describe('#getCommonColumns', () => {
);
expect(
wrapper.find(`[data-test-subj="title-${mockResults[0].savedObjectId}"]`).first().exists()
wrapper
.find(`[data-test-subj="timeline-title-${mockResults[0].savedObjectId}"]`)
.first()
.exists()
).toBe(true);
});
@ -418,7 +427,10 @@ describe('#getCommonColumns', () => {
);
expect(
wrapper.find(`[data-test-subj="title-${mockResults[0].savedObjectId}"]`).first().exists()
wrapper
.find(`[data-test-subj="timeline-title-${mockResults[0].savedObjectId}"]`)
.first()
.exists()
).toBe(false);
});
@ -438,7 +450,7 @@ describe('#getCommonColumns', () => {
);
wrapper
.find(`[data-test-subj="title-${mockResults[0].savedObjectId}"]`)
.find(`[data-test-subj="timeline-title-${mockResults[0].savedObjectId}"]`)
.last()
.simulate('click');

View file

@ -69,7 +69,7 @@ export const getCommonColumns = ({
render: (title: string, timelineResult: OpenTimelineResult) =>
timelineResult.savedObjectId != null ? (
<EuiLink
data-test-subj={`title-${timelineResult.savedObjectId}`}
data-test-subj={`timeline-title-${timelineResult.savedObjectId}`}
onClick={() =>
onOpenTimeline({
duplicate: false,

View file

@ -58,7 +58,7 @@ const defaultProps = {
describe('reasonColumnRenderer', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
describe('isIntance', () => {

View file

@ -0,0 +1,13 @@
/*
* 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 { MockDiscoverTabContent } from '../mocks/discover_tab_content';
export const DiscoverTabContent = MockDiscoverTabContent;
// eslint-disable-next-line import/no-default-export
export default DiscoverTabContent;

View file

@ -9,21 +9,29 @@ import React from 'react';
import { TestProviders } from '../../../../common/mock';
import DiscoverTabContent from '.';
import { render, screen, waitFor } from '@testing-library/react';
import { TimelineId } from '../../../../../common/types';
const TestComponent = () => {
return (
<TestProviders>
<DiscoverTabContent />
<DiscoverTabContent timelineId={TimelineId.test} />
</TestProviders>
);
};
describe('Discover Tab Content', () => {
it('renders', async () => {
it('should render', async () => {
render(<TestComponent />);
await waitFor(() => {
expect(screen.getByTestId('timeline-embedded-discover')).toBeInTheDocument();
});
});
// issue for enabling below tests: https://github.com/elastic/kibana/issues/165913
it.skip('should load saved search when a saved timeline is restored', () => {});
it.skip('should reset the discover state when new timeline is created', () => {});
it.skip('should update saved search if timeline title and description are updated', () => {});
it.skip('should should not update saved search if the fetched saved search is same as discover updated saved search', () => {});
it.skip('should update saved search if discover time is update', () => {});
});

View file

@ -5,20 +5,32 @@
* 2.0.
*/
import type { FC } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useHistory } from 'react-router-dom';
import type { CustomizationCallback } from '@kbn/discover-plugin/public/customizations/types';
import { createGlobalStyle } from 'styled-components';
import type { ScopedHistory } from '@kbn/core/public';
import type { DiscoverStateContainer } from '@kbn/discover-plugin/public';
import type { Subscription } from 'rxjs';
import type { DataView } from '@kbn/data-views-plugin/common';
import { useQuery } from '@tanstack/react-query';
import { debounce, isEqualWith } from 'lodash';
import type { SavedSearch } from '@kbn/saved-search-plugin/common';
import type { TimeRange } from '@kbn/es-query';
import { useDispatch } from 'react-redux';
import { useDiscoverInTimelineContext } from '../../../../common/components/discover_in_timeline/use_discover_in_timeline_context';
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
import { useKibana } from '../../../../common/lib/kibana';
import { useDiscoverState } from './use_discover_state';
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
import { useSetDiscoverCustomizationCallbacks } from './customizations/use_set_discover_customizations';
import { EmbeddedDiscoverContainer } from './styles';
import { timelineSelectors } from '../../../store/timeline';
import { useShallowEqualSelector } from '../../../../common/hooks/use_selector';
import { timelineDefaults } from '../../../store/timeline/defaults';
import { savedSearchComparator } from './utils';
import { setIsDiscoverSavedSearchLoaded } from '../../../store/timeline/actions';
import { GET_TIMELINE_DISCOVER_SAVED_SEARCH_TITLE } from './translations';
const HideSearchSessionIndicatorBreadcrumbIcon = createGlobalStyle`
[data-test-subj='searchSessionIndicator'] {
@ -26,33 +38,166 @@ const HideSearchSessionIndicatorBreadcrumbIcon = createGlobalStyle`
}
`;
export const DiscoverTabContent = () => {
interface DiscoverTabContentProps {
timelineId: string;
}
export const DiscoverTabContent: FC<DiscoverTabContentProps> = ({ timelineId }) => {
const history = useHistory();
const {
services: { customDataService: discoverDataService, discover, dataViews: dataViewService },
services: {
customDataService: discoverDataService,
discover,
dataViews: dataViewService,
savedSearch: savedSearchService,
},
} = useKibana();
const dispatch = useDispatch();
const { dataViewId } = useSourcererDataView(SourcererScopeName.detections);
const [dataView, setDataView] = useState<DataView | undefined>();
const stateContainerRef = useRef<DiscoverStateContainer>();
const [discoverTimerange, setDiscoverTimerange] = useState<TimeRange>();
const discoverAppStateSubscription = useRef<Subscription>();
const discoverInternalStateSubscription = useRef<Subscription>();
const discoverSavedSearchStateSubscription = useRef<Subscription>();
const discoverTimerangeSubscription = useRef<Subscription>();
const discoverCustomizationCallbacks = useSetDiscoverCustomizationCallbacks();
const {
discoverStateContainer,
setDiscoverStateContainer,
getAppStateFromSavedSearch,
updateSavedSearch,
restoreDiscoverAppStateFromSavedSearch,
resetDiscoverAppState,
} = useDiscoverInTimelineContext();
const {
discoverAppState,
discoverInternalState,
discoverSavedSearchState,
setDiscoverSavedSearchState,
setDiscoverInternalState,
setDiscoverAppState,
} = useDiscoverState();
const discoverCustomizationCallbacks = useSetDiscoverCustomizationCallbacks();
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const timeline = useShallowEqualSelector(
(state) => getTimeline(state, timelineId) ?? timelineDefaults
);
const {
status,
savedSearchId,
activeTab,
savedObjectId,
title,
description,
isDiscoverSavedSearchLoaded = false,
} = timeline;
const setSavedSearchLoaded = useCallback(
(value: boolean) => {
dispatch(
setIsDiscoverSavedSearchLoaded({
id: timelineId,
isDiscoverSavedSearchLoaded: value,
})
);
},
[dispatch, timelineId]
);
const { data: savedSearchById, isFetching } = useQuery({
queryKey: ['savedSearchById', savedSearchId ?? ''],
queryFn: () => (savedSearchId ? savedSearchService.get(savedSearchId) : Promise.resolve(null)),
});
useEffect(() => {
if (!savedObjectId) return;
setSavedSearchLoaded(false);
}, [savedObjectId, setSavedSearchLoaded]);
useEffect(() => {
if (isFetching) return; // no-op is fetch is in progress
if (isDiscoverSavedSearchLoaded) return; // no-op if saved search has been already loaded
if (!savedSearchById) {
// nothing to restore if savedSearchById is null
if (status === 'draft') {
resetDiscoverAppState();
}
setSavedSearchLoaded(true);
return;
}
restoreDiscoverAppStateFromSavedSearch(savedSearchById);
setSavedSearchLoaded(true);
}, [
discoverStateContainer,
savedSearchId,
isDiscoverSavedSearchLoaded,
status,
activeTab,
resetDiscoverAppState,
savedSearchById,
getAppStateFromSavedSearch,
restoreDiscoverAppStateFromSavedSearch,
isFetching,
setSavedSearchLoaded,
]);
const getCombinedDiscoverSavedSearchState: () => SavedSearch | undefined = useCallback(() => {
if (!discoverSavedSearchState) return;
return {
...(discoverStateContainer.current?.savedSearchState.getState() ?? discoverSavedSearchState),
timeRange: discoverDataService.query.timefilter.timefilter.getTime(),
refreshInterval: discoverStateContainer.current?.globalState.get()?.refreshInterval,
breakdownField: discoverStateContainer.current?.appState.getState().breakdownField,
rowsPerPage: discoverStateContainer.current?.appState.getState().rowsPerPage,
title: GET_TIMELINE_DISCOVER_SAVED_SEARCH_TITLE(title),
description,
};
}, [
discoverSavedSearchState,
discoverStateContainer,
discoverDataService.query.timefilter.timefilter,
title,
description,
]);
const combinedDiscoverSavedSearchStateRef = useRef<SavedSearch | undefined>();
const debouncedUpdateSavedSearch = useMemo(
() => debounce(updateSavedSearch, 300),
[updateSavedSearch]
);
useEffect(() => {
if (isFetching) return;
if (!isDiscoverSavedSearchLoaded) return;
if (!savedObjectId) return;
if (!status || status === 'draft') return;
const latestState = getCombinedDiscoverSavedSearchState();
if (!latestState || combinedDiscoverSavedSearchStateRef.current === latestState) return;
if (isEqualWith(latestState, savedSearchById, savedSearchComparator)) return;
debouncedUpdateSavedSearch(latestState, timelineId);
combinedDiscoverSavedSearchStateRef.current = latestState;
}, [
getCombinedDiscoverSavedSearchState,
debouncedUpdateSavedSearch,
savedSearchById,
updateSavedSearch,
isDiscoverSavedSearchLoaded,
activeTab,
status,
discoverTimerange,
savedObjectId,
isFetching,
timelineId,
dispatch,
]);
useEffect(() => {
if (!dataViewId) return;
dataViewService.get(dataViewId).then(setDataView);
@ -64,21 +209,29 @@ export const DiscoverTabContent = () => {
discoverAppStateSubscription.current,
discoverInternalStateSubscription.current,
discoverSavedSearchStateSubscription.current,
discoverTimerangeSubscription.current,
].forEach((sub) => {
if (sub) sub.unsubscribe();
});
};
return unSubscribeAll;
}, []);
}, [discoverStateContainer]);
const initialDiscoverCustomizationCallback: CustomizationCallback = useCallback(
async ({ stateContainer }) => {
stateContainerRef.current = stateContainer;
setDiscoverStateContainer(stateContainer);
let savedSearchAppState;
if (savedSearchId) {
const localSavedSearch = await savedSearchService.get(savedSearchId);
savedSearchAppState = getAppStateFromSavedSearch(localSavedSearch);
}
if (discoverAppState && discoverInternalState && discoverSavedSearchState) {
stateContainer.appState.set(discoverAppState);
await stateContainer.appState.replaceUrlState(discoverAppState);
const finalAppState = savedSearchAppState?.appState ?? discoverAppState;
if (finalAppState) {
stateContainer.appState.set(finalAppState);
await stateContainer.appState.replaceUrlState(finalAppState);
} else {
// set initial dataView Id
if (dataView) stateContainer.actions.setDataView(dataView);
@ -101,18 +254,30 @@ export const DiscoverTabContent = () => {
},
});
const timeRangeSub = discoverDataService.query.timefilter.timefilter
.getTimeUpdate$()
.subscribe({
next: () => {
setDiscoverTimerange(discoverDataService.query.timefilter.timefilter.getTime());
},
});
discoverAppStateSubscription.current = unsubscribeState;
discoverInternalStateSubscription.current = internalStateSubscription;
discoverSavedSearchStateSubscription.current = savedSearchStateSub;
discoverTimerangeSubscription.current = timeRangeSub;
},
[
discoverAppState,
discoverInternalState,
discoverSavedSearchState,
setDiscoverSavedSearchState,
setDiscoverInternalState,
setDiscoverAppState,
dataView,
setDiscoverStateContainer,
getAppStateFromSavedSearch,
discoverDataService.query.timefilter.timefilter,
savedSearchId,
savedSearchService,
]
);
@ -125,13 +290,14 @@ export const DiscoverTabContent = () => {
() => ({
data: discoverDataService,
filterManager: discoverDataService.query.filterManager,
timefilter: discoverDataService.query.timefilter.timefilter,
}),
[discoverDataService]
);
const DiscoverContainer = discover.DiscoverContainer;
const isLoading = !dataView;
const isLoading = Boolean(!dataView) || !isDiscoverSavedSearchLoaded;
return (
<EmbeddedDiscoverContainer data-test-subj="timeline-embedded-discover">

View file

@ -0,0 +1,14 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const GET_TIMELINE_DISCOVER_SAVED_SEARCH_TITLE = (title: string) =>
i18n.translate('xpack.securitySolution.timelines.discoverInTimeline.savedSearchTitle', {
defaultMessage: 'Saved search for timeline - {title}',
values: { title },
});

View file

@ -0,0 +1,89 @@
/*
* 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 { createSearchSourceMock } from '@kbn/data-plugin/public/mocks';
import { buildDataViewMock, shallowMockedFields } from '@kbn/discover-utils/src/__mocks__';
import { savedSearchComparator } from '.';
const customQuery = {
language: 'kuery',
query: '_id: *',
};
const firstDataViewMock = buildDataViewMock({
name: 'first-data-view',
fields: shallowMockedFields,
});
const secondDataViewMock = buildDataViewMock({
name: 'second-data-view',
fields: shallowMockedFields,
});
describe('savedSearchComparator', () => {
const firstMockSavedSearch = {
id: 'first',
title: 'first title',
breakdownField: 'firstBreakdown Field',
searchSource: createSearchSourceMock({
index: firstDataViewMock,
query: customQuery,
}),
};
const secondMockSavedSearch = {
id: 'second',
title: 'second title',
breakdownField: 'second Breakdown Field',
searchSource: createSearchSourceMock({
index: secondDataViewMock,
query: customQuery,
}),
};
it('should result true when saved search is same', () => {
const result = savedSearchComparator(firstMockSavedSearch, { ...firstMockSavedSearch });
expect(result).toBe(true);
});
it('should return false index is different', () => {
const newMockedSavedSearch = {
...firstMockSavedSearch,
searchSource: secondMockSavedSearch.searchSource,
};
const result = savedSearchComparator(firstMockSavedSearch, newMockedSavedSearch);
expect(result).toBe(false);
});
it('should return false when query is different', () => {
const newMockedSavedSearch = {
...firstMockSavedSearch,
searchSource: createSearchSourceMock({
index: firstDataViewMock,
query: {
...customQuery,
query: '*',
},
}),
};
const result = savedSearchComparator(firstMockSavedSearch, newMockedSavedSearch);
expect(result).toBe(false);
});
it('should result false when title is different', () => {
const newMockedSavedSearch = {
...firstMockSavedSearch,
title: 'new-title',
};
const result = savedSearchComparator(firstMockSavedSearch, newMockedSavedSearch);
expect(result).toBe(false);
});
});

View file

@ -0,0 +1,43 @@
/*
* 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 type { SavedSearch } from '@kbn/saved-search-plugin/common';
import { isEqual, pick } from 'lodash';
export const savedSearchComparator = (
inputSavedSearch: SavedSearch | null,
existingSavedSearch: SavedSearch | null
) => {
const inputSavedSearchWithFields = {
...inputSavedSearch,
fields: inputSavedSearch?.searchSource?.getFields(),
};
const existingSavedSearchWithFields = {
...existingSavedSearch,
fields: existingSavedSearch?.searchSource?.getFields(),
};
const keysToSelect = [
'columns',
'grid',
'hideChart',
'sort',
'timeRange',
'fields.filter',
'fields.index.id',
'fields.query',
'title',
'description',
];
const modifiedInputSavedSearch = pick(inputSavedSearchWithFields, keysToSelect);
const modifiedExistingSavedSearch = pick(existingSavedSearchWithFields, keysToSelect);
const result = isEqual(modifiedInputSavedSearch, modifiedExistingSavedSearch);
return result;
};

View file

@ -8,13 +8,13 @@
import type { ReactWrapper } from 'enzyme';
import { mount } from 'enzyme';
import React from 'react';
import { Provider as ReduxStoreProvider } from 'react-redux';
import {
mockGlobalState,
SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock,
TestProviders,
} from '../../../../common/mock';
import type { State } from '../../../../common/store';
import { createStore } from '../../../../common/store';
@ -54,9 +54,9 @@ describe('NewTemplateTimeline', () => {
});
wrapper = mount(
<ReduxStoreProvider store={store}>
<TestProviders store={store}>
<NewTemplateTimeline outline={true} closeGearMenu={mockClosePopover} title={mockTitle} />
</ReduxStoreProvider>
</TestProviders>
);
});
@ -91,9 +91,9 @@ describe('NewTemplateTimeline', () => {
});
wrapper = mount(
<ReduxStoreProvider store={store}>
<TestProviders store={store}>
<NewTemplateTimeline outline={true} closeGearMenu={mockClosePopover} title={mockTitle} />
</ReduxStoreProvider>
</TestProviders>
);
});

View file

@ -22,6 +22,7 @@ import { sourcererActions, sourcererSelectors } from '../../../../common/store/s
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
import { appActions } from '../../../../common/store/app';
import type { TimeRange } from '../../../../common/store/inputs/model';
import { useDiscoverInTimelineContext } from '../../../../common/components/discover_in_timeline/use_discover_in_timeline_context';
interface Props {
timelineId?: string;
@ -39,6 +40,8 @@ export const useCreateTimeline = ({ timelineId, timelineType, closeGearMenu }: P
const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen();
const globalTimeRange = useDeepEqualSelector(inputsSelectors.globalTimeRangeSelector);
const { resetDiscoverAppState } = useDiscoverInTimelineContext();
const createTimeline = useCallback(
({ id, show, timeRange: timeRangeParam }) => {
const timerange = timeRangeParam ?? globalTimeRange;
@ -110,8 +113,9 @@ export const useCreateTimeline = ({ timelineId, timelineType, closeGearMenu }: P
if (typeof closeGearMenu === 'function') {
closeGearMenu();
}
resetDiscoverAppState();
},
[createTimeline, timelineId, timelineType, closeGearMenu]
[createTimeline, timelineId, timelineType, closeGearMenu, resetDiscoverAppState]
);
return handleCreateNewTimeline;

View file

@ -234,7 +234,7 @@ const ActiveTimelineTab = memo<ActiveTimelineTabProps>(
$isVisible={TimelineTabs.discover === activeTimelineTab}
data-test-subj={`timeline-tab-content-${TimelineTabs.discover}`}
>
<DiscoverTab />
<DiscoverTab timelineId={timelineId} />
</HideShowContainer>
)}
</>

View file

@ -82,6 +82,7 @@ const timelineData = {
},
],
status: TimelineStatus.active,
savedSearchId: null,
};
const mockPatchTimelineResponse = {
data: {

View file

@ -271,3 +271,13 @@ export const clearEventsDeleted = actionCreator<{
export const updateTotalCount = actionCreator<{ id: string; totalCount: number }>(
'UPDATE_TOTAL_COUNT'
);
export const updateSavedSearchId = actionCreator<{
id: string;
savedSearchId: string;
}>('UPDATE_DISCOVER_SAVED_SEARCH_ID');
export const setIsDiscoverSavedSearchLoaded = actionCreator<{
id: string;
isDiscoverSavedSearchLoaded: boolean;
}>('SET_IS_DISCOVER_SAVED_SEARCH_LOADED');

View file

@ -78,6 +78,8 @@ export const timelineDefaults: SubsetTimelineModel &
selectedEventIds: {},
isSelectAllChecked: false,
filters: [],
savedSearchId: null,
isDiscoverSavedSearchLoaded: false,
};
export const getTimelineManageDefaults = (id: string) => ({

View file

@ -175,6 +175,7 @@ describe('Epic Timeline', () => {
version: 'WzM4LDFd',
id: '11169110-fc22-11e9-8ca9-072f15ce2685',
savedQueryId: 'my endgame timeline query',
savedSearchId: null,
};
expect(
@ -309,6 +310,7 @@ describe('Epic Timeline', () => {
},
},
savedQueryId: 'my endgame timeline query',
savedSearchId: null,
sort: [
{
columnId: '@timestamp',

View file

@ -83,6 +83,7 @@ import {
addTimeline,
showCallOutUnauthorizedMsg,
saveTimeline,
updateSavedSearchId,
} from './actions';
import type { TimelineModel } from './model';
import { epicPersistNote, timelineNoteActionsType } from './epic_note';
@ -118,6 +119,8 @@ const timelineActionsType = [
updateSort.type,
updateRange.type,
upsertColumn.type,
updateSavedSearchId.type,
];
const isItAtimelineAction = (timelineId: string | undefined) =>
@ -346,6 +349,7 @@ const timelineInput: TimelineInput = {
savedQueryId: null,
sort: null,
status: null,
savedSearchId: null,
};
export const convertTimelineAsInput = (

View file

@ -133,6 +133,9 @@ export interface TimelineModel {
isSelectAllChecked: boolean;
isLoading: boolean;
selectAll: boolean;
/* discover saved search Id */
savedSearchId: string | null;
isDiscoverSavedSearchLoaded?: boolean;
}
export type SubsetTimelineModel = Readonly<
@ -186,6 +189,8 @@ export type SubsetTimelineModel = Readonly<
| 'status'
| 'filters'
| 'filterManager'
| 'savedSearchId'
| 'isDiscoverSavedSearchLoaded'
>
>;
@ -194,4 +199,5 @@ export interface TimelineUrl {
id?: string;
isOpen: boolean;
graphEventId?: string;
savedSearchId?: string;
}

View file

@ -137,6 +137,7 @@ const basicTimeline: TimelineModel = {
timelineType: TimelineType.default,
title: '',
version: null,
savedSearchId: null,
};
const timelineByIdMock: TimelineById = {
foo: { ...basicTimeline },
@ -223,6 +224,7 @@ describe('Timeline', () => {
indexNames: [],
timelineById: timelineByIdMock,
timelineType: TimelineType.default,
savedSearchId: null,
});
expect(update).not.toBe(timelineByIdMock);
});
@ -235,6 +237,7 @@ describe('Timeline', () => {
indexNames: [],
timelineById: timelineByIdMock,
timelineType: TimelineType.default,
savedSearchId: null,
});
expect(update).toEqual({
foo: basicTimeline,
@ -253,6 +256,7 @@ describe('Timeline', () => {
indexNames: [],
timelineById: timelineByIdMock,
timelineType: TimelineType.default,
savedSearchId: null,
});
expect(update).toEqual({
foo: basicTimeline,

View file

@ -59,6 +59,8 @@ import {
applyDeltaToColumnWidth,
clearEventsDeleted,
clearEventsLoading,
updateSavedSearchId,
setIsDiscoverSavedSearchLoaded,
} from './actions';
import {
@ -530,4 +532,24 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState)
},
},
}))
.case(updateSavedSearchId, (state, { id, savedSearchId }) => ({
...state,
timelineById: {
...state.timelineById,
[id]: {
...state.timelineById[id],
savedSearchId,
},
},
}))
.case(setIsDiscoverSavedSearchLoaded, (state, { id, isDiscoverSavedSearchLoaded }) => ({
...state,
timelineById: {
...state.timelineById,
[id]: {
...state.timelineById[id],
isDiscoverSavedSearchLoaded,
},
},
}))
.build();

View file

@ -55,6 +55,7 @@ import type { DiscoverStart } from '@kbn/discover-plugin/public';
import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public';
import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public';
import type { UpsellingService } from '@kbn/security-solution-upselling/service';
import type { SavedSearchPublicPluginStart } from '@kbn/saved-search-plugin/public';
import type { ResolverPluginSetup } from './resolver/types';
import type { Inspect } from '../common/search_strategy';
import type { Detections } from './detections';
@ -130,6 +131,7 @@ export interface StartPlugins {
discover: DiscoverStart;
navigation: NavigationPublicPluginStart;
dataViewEditor: DataViewEditorStart;
savedSearch: SavedSearchPublicPluginStart;
}
export interface StartPluginsDependencies extends StartPlugins {

View file

@ -81,6 +81,7 @@ export const convertSavedObjectToSavedTimeline = (savedObject: unknown): Timelin
? savedTimeline.attributes.sort
: [savedTimeline.attributes.sort]
: [],
savedSearchId: savedTimeline.attributes.savedSearchId,
};
return {

View file

@ -316,6 +316,9 @@ export const timelineSavedObjectMappings: SavedObjectsType['mappings'] = {
updatedBy: {
type: 'text',
},
savedSearchId: {
type: 'text',
},
},
};

View file

@ -171,6 +171,7 @@
"@kbn/core-lifecycle-browser",
"@kbn/security-solution-features",
"@kbn/content-management-plugin",
"@kbn/discover-utils",
"@kbn/subscription-tracking",
"@kbn/openapi-generator"
]

View file

@ -89,8 +89,8 @@ describe(
navigateFromHeaderTo(ALERTS);
openActiveTimeline();
gotToDiscoverTab();
cy.get(GET_DISCOVER_DATA_GRID_CELL_HEADER('host.name')).should('be.visible');
cy.get(GET_DISCOVER_DATA_GRID_CELL_HEADER('user.name')).should('be.visible');
cy.get(GET_DISCOVER_DATA_GRID_CELL_HEADER('host.name')).should('exist');
cy.get(GET_DISCOVER_DATA_GRID_CELL_HEADER('user.name')).should('exist');
});
}
);

View file

@ -0,0 +1,293 @@
/*
* 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 { visitWithTimeRange } from '../../../../tasks/navigation';
import { TIMELINE_TITLE } from '../../../../screens/timeline';
import { BASIC_TABLE_LOADING } from '../../../../screens/common';
import { goToSavedObjectSettings } from '../../../../tasks/stack_management';
import {
navigateFromKibanaCollapsibleTo,
openKibanaNavigation,
} from '../../../../tasks/kibana_navigation';
import { fillAddFilterForm } from '../../../../tasks/search_bar';
import {
addDiscoverKqlQuery,
addFieldToTable,
openAddDiscoverFilterPopover,
switchDataViewTo,
switchDataViewToESQL,
} from '../../../../tasks/discover';
import {
GET_LOCAL_DATE_PICKER_START_DATE_POPOVER_BUTTON,
GET_LOCAL_SHOW_DATES_BUTTON,
} from '../../../../screens/date_picker';
import { ALERTS_URL } from '../../../../urls/navigation';
import {
DISCOVER_CONTAINER,
DISCOVER_DATA_VIEW_SWITCHER,
DISCOVER_FILTER_BADGES,
DISCOVER_QUERY_INPUT,
GET_DISCOVER_DATA_GRID_CELL_HEADER,
} from '../../../../screens/discover';
import { updateDateRangeInLocalDatePickers } from '../../../../tasks/date_picker';
import { login } from '../../../../tasks/login';
import {
addDescriptionToTimeline,
addNameToTimeline,
createNewTimeline,
gotToDiscoverTab,
openTimelineById,
openTimelineFromSettings,
waitForTimelineChanges,
} from '../../../../tasks/timeline';
import { LOADING_INDICATOR } from '../../../../screens/security_header';
import { STACK_MANAGEMENT_PAGE } from '../../../../screens/kibana_navigation';
import {
GET_SAVED_OBJECTS_TAGS_OPTION,
SAVED_OBJECTS_ROW_TITLES,
SAVED_OBJECTS_TAGS_FILTER,
} from '../../../../screens/common/stack_management';
const INITIAL_START_DATE = 'Jan 18, 2021 @ 20:33:29.186';
const INITIAL_END_DATE = 'Jan 19, 2024 @ 20:33:29.186';
const SAVED_SEARCH_UPDATE_REQ = 'SAVED_SEARCH_UPDATE_REQ';
const SAVED_SEARCH_UPDATE_WITH_DESCRIPTION = 'SAVED_SEARCH_UPDATE_WITH_DESCRIPTION';
const SAVED_SEARCH_CREATE_REQ = 'SAVED_SEARCH_CREATE_REQ';
const SAVED_SEARCH_GET_REQ = 'SAVED_SEARCH_GET_REQ';
const TIMELINE_REQ_WITH_SAVED_SEARCH = 'TIMELINE_REQ_WITH_SAVED_SEARCH';
const TIMELINE_PATCH_REQ = 'TIMELINE_PATCH_REQ';
const TIMELINE_RESPONSE_SAVED_OBJECT_ID_PATH =
'response.body.data.persistTimeline.timeline.savedObjectId';
describe(
'Discover Timeline State Integration',
{
env: { ftrConfig: { enableExperimental: ['discoverInTimeline'] } },
tags: ['@ess', '@brokenInServerless'],
// ESQL and test involving STACK_MANAGEMENT_PAGE are broken in serverless
},
() => {
beforeEach(() => {
cy.intercept('PATCH', '/api/timeline', (req) => {
if (req.body.hasOwnProperty('timeline') && req.body.timeline.savedSearchId === null) {
req.alias = TIMELINE_PATCH_REQ;
}
});
cy.intercept('PATCH', '/api/timeline', (req) => {
if (req.body.hasOwnProperty('timeline') && req.body.timeline.savedSearchId !== null) {
req.alias = TIMELINE_REQ_WITH_SAVED_SEARCH;
}
});
cy.intercept('POST', '/api/content_management/rpc/get', (req) => {
if (req.body.hasOwnProperty('contentTypeId') && req.body.contentTypeId === 'search') {
req.alias = SAVED_SEARCH_GET_REQ;
}
});
cy.intercept('POST', '/api/content_management/rpc/create', (req) => {
if (req.body.hasOwnProperty('contentTypeId') && req.body.contentTypeId === 'search') {
req.alias = SAVED_SEARCH_CREATE_REQ;
}
});
cy.intercept('POST', '/api/content_management/rpc/update', (req) => {
if (req.body.hasOwnProperty('contentTypeId') && req.body.contentTypeId === 'search') {
req.alias = SAVED_SEARCH_UPDATE_REQ;
}
});
cy.intercept('POST', '/api/content_management/rpc/update', (req) => {
if (
req.body.hasOwnProperty('data') &&
req.body.data.hasOwnProperty('description') &&
req.body.data.description.length > 0
) {
req.alias = SAVED_SEARCH_UPDATE_WITH_DESCRIPTION;
}
});
login();
visitWithTimeRange(ALERTS_URL);
createNewTimeline();
gotToDiscoverTab();
updateDateRangeInLocalDatePickers(DISCOVER_CONTAINER, INITIAL_START_DATE, INITIAL_END_DATE);
});
context('save/restore', () => {
it('should be able create an empty timeline with default discover state', () => {
addNameToTimeline('Timerange timeline');
createNewTimeline();
gotToDiscoverTab();
cy.get(GET_LOCAL_SHOW_DATES_BUTTON(DISCOVER_CONTAINER)).should(
'contain.text',
`Last 15 minutes`
);
});
it('should save/restore discover dataview/timerange/filter/query/columns when saving/resoring timeline', () => {
const dataviewName = '.kibana-event-log';
const timelineSuffix = Date.now();
const timelineName = `DataView timeline-${timelineSuffix}`;
const kqlQuery = '_id:*';
const column1 = 'event.category';
const column2 = 'ecs.version';
switchDataViewTo(dataviewName);
addDiscoverKqlQuery(kqlQuery);
openAddDiscoverFilterPopover();
fillAddFilterForm({
key: 'ecs.version',
value: '1.8.0',
});
addFieldToTable(column1);
addFieldToTable(column2);
// create a custom timeline
addNameToTimeline(timelineName);
cy.wait(`@${TIMELINE_PATCH_REQ}`)
.its(TIMELINE_RESPONSE_SAVED_OBJECT_ID_PATH)
.then((timelineId) => {
cy.wait(`@${TIMELINE_REQ_WITH_SAVED_SEARCH}`);
// create an empty timeline
createNewTimeline();
// switch to old timeline
openTimelineFromSettings();
openTimelineById(timelineId);
cy.get(LOADING_INDICATOR).should('not.exist');
gotToDiscoverTab();
cy.get(DISCOVER_DATA_VIEW_SWITCHER.BTN).should('contain.text', dataviewName);
cy.get(DISCOVER_QUERY_INPUT).should('have.text', kqlQuery);
cy.get(DISCOVER_FILTER_BADGES).should('have.length', 1);
cy.get(DISCOVER_FILTER_BADGES).should('contain.text', 'ecs.version: 1.8.0');
cy.get(GET_DISCOVER_DATA_GRID_CELL_HEADER(column1)).should('exist');
cy.get(GET_DISCOVER_DATA_GRID_CELL_HEADER(column2)).should('exist');
cy.get(GET_LOCAL_DATE_PICKER_START_DATE_POPOVER_BUTTON(DISCOVER_CONTAINER)).should(
'have.text',
INITIAL_START_DATE
);
});
});
it('should save/restore discover dataview/timerange/filter/query/columns when timeline is opened via url', () => {
const dataviewName = '.kibana-event-log';
const timelineSuffix = Date.now();
const timelineName = `DataView timeline-${timelineSuffix}`;
const kqlQuery = '_id:*';
const column1 = 'event.category';
const column2 = 'ecs.version';
switchDataViewTo(dataviewName);
addDiscoverKqlQuery(kqlQuery);
openAddDiscoverFilterPopover();
fillAddFilterForm({
key: 'ecs.version',
value: '1.8.0',
});
addFieldToTable(column1);
addFieldToTable(column2);
// create a custom timeline
addNameToTimeline(timelineName);
cy.wait(`@${TIMELINE_PATCH_REQ}`)
.its(TIMELINE_RESPONSE_SAVED_OBJECT_ID_PATH)
.then((timelineId) => {
cy.wait(`@${TIMELINE_REQ_WITH_SAVED_SEARCH}`);
// reload the page with the exact url
cy.reload();
cy.get(DISCOVER_DATA_VIEW_SWITCHER.BTN).should('contain.text', dataviewName);
cy.get(DISCOVER_QUERY_INPUT).should('have.text', kqlQuery);
cy.get(DISCOVER_FILTER_BADGES).should('have.length', 1);
cy.get(DISCOVER_FILTER_BADGES).should('contain.text', 'ecs.version: 1.8.0');
cy.get(GET_DISCOVER_DATA_GRID_CELL_HEADER(column1)).should('exist');
cy.get(GET_DISCOVER_DATA_GRID_CELL_HEADER(column2)).should('exist');
cy.get(GET_LOCAL_DATE_PICKER_START_DATE_POPOVER_BUTTON(DISCOVER_CONTAINER)).should(
'have.text',
INITIAL_START_DATE
);
});
});
it('should save/restore discover ES|QL when saving timeline', () => {
const timelineSuffix = Date.now();
const timelineName = `ES|QL timeline-${timelineSuffix}`;
switchDataViewToESQL();
addNameToTimeline(timelineName);
cy.wait(`@${TIMELINE_PATCH_REQ}`)
.its(TIMELINE_RESPONSE_SAVED_OBJECT_ID_PATH)
.then((timelineId) => {
cy.wait(`@${TIMELINE_REQ_WITH_SAVED_SEARCH}`);
// create an empty timeline
createNewTimeline();
// switch to old timeline
openTimelineFromSettings();
openTimelineById(timelineId);
cy.get(LOADING_INDICATOR).should('not.exist');
gotToDiscoverTab();
cy.get(DISCOVER_DATA_VIEW_SWITCHER.BTN).should('contain.text', 'ES|QL');
});
});
});
/*
* skipping because it is @brokenInServerless and this cypress tag was somehow not working
* so skipping this test both in ess and serverless.
*
* Raised issue: https://github.com/elastic/kibana/issues/165913
*
* */
context.skip('saved search tags', () => {
it('should save discover saved search with `Security Solution` tag', () => {
const timelineSuffix = Date.now();
const timelineName = `SavedObject timeline-${timelineSuffix}`;
const kqlQuery = '_id: *';
addDiscoverKqlQuery(kqlQuery);
addNameToTimeline(timelineName);
cy.wait(`@${TIMELINE_REQ_WITH_SAVED_SEARCH}`);
openKibanaNavigation();
navigateFromKibanaCollapsibleTo(STACK_MANAGEMENT_PAGE);
cy.get(LOADING_INDICATOR).should('not.exist');
goToSavedObjectSettings();
cy.get(LOADING_INDICATOR).should('not.exist');
cy.get(SAVED_OBJECTS_TAGS_FILTER).trigger('click');
cy.get(GET_SAVED_OBJECTS_TAGS_OPTION('Security_Solution')).trigger('click');
cy.get(BASIC_TABLE_LOADING).should('not.exist');
cy.get(SAVED_OBJECTS_ROW_TITLES).should(
'contain.text',
`Saved Search for timeline - ${timelineName}`
);
});
});
context('saved search', () => {
it('should rename the saved search on timeline rename', () => {
const timelineSuffix = Date.now();
const timelineName = `Rename timeline-${timelineSuffix}`;
const kqlQuery = '_id: *';
addDiscoverKqlQuery(kqlQuery);
addNameToTimeline(timelineName);
cy.wait(`@${TIMELINE_PATCH_REQ}`)
.its(TIMELINE_RESPONSE_SAVED_OBJECT_ID_PATH)
.then((timelineId) => {
cy.wait(`@${SAVED_SEARCH_UPDATE_REQ}`);
cy.wait(`@${TIMELINE_REQ_WITH_SAVED_SEARCH}`);
// create an empty timeline
createNewTimeline();
// switch to old timeline
openTimelineFromSettings();
openTimelineById(timelineId);
cy.get(TIMELINE_TITLE).should('have.text', timelineName);
const timelineDesc = 'Timeline Description with Saved Seach';
waitForTimelineChanges();
addDescriptionToTimeline(timelineDesc);
cy.wait(`@${SAVED_SEARCH_UPDATE_WITH_DESCRIPTION}`, {
timeout: 30000,
}).then((interception) => {
expect(interception.request.body.data.description).eq(timelineDesc);
});
});
});
});
// Issue for enabling below tests: https://github.com/elastic/kibana/issues/165913
context.skip('Advanced Settings', () => {
it('rows per page in saved search should be according to the user selected number of pages', () => {});
it('rows per page in new search should be according to the value selected in advanced settings', () => {});
});
}
);

View file

@ -0,0 +1,21 @@
/*
* 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 { getDataTestSubjectSelector } from '../../helpers/common';
export const STACK_MANAGEMENT_HOME = getDataTestSubjectSelector('managementHome');
export const SAVED_OBJECTS_SETTINGS = `${getDataTestSubjectSelector('objects')}`;
export const SAVED_OBJECTS_TAGS_FILTER = '[data-text="Tags"][title="Tags"]';
export const GET_SAVED_OBJECTS_TAGS_OPTION = (optionId: string) =>
getDataTestSubjectSelector(`tag-searchbar-option-${optionId}`);
export const SAVED_OBJECTS_SEARCH_BAR = getDataTestSubjectSelector('savedObjectSearchBar');
export const SAVED_OBJECTS_ROW_TITLES = getDataTestSubjectSelector('savedObjectsTableRowTitle');

View file

@ -15,6 +15,7 @@ export const DISCOVER_DATA_VIEW_SWITCHER = {
INPUT: getDataTestSubjectSelector('indexPattern-switcher--input'),
GET_DATA_VIEW: (title: string) => `.euiSelectableListItem[role=option][title^="${title}"]`,
CREATE_NEW: getDataTestSubjectSelector('dataview-create-new'),
TEXT_BASE_LANG_SWICTHER: getDataTestSubjectSelector('select-text-based-language-panel'),
};
export const DISCOVER_DATA_VIEW_EDITOR_FLYOUT = {

View file

@ -38,3 +38,6 @@ export const SPACES_BUTTON = '[data-test-subj="spacesNavSelector"]';
export const APP_LEAVE_CONFIRM_MODAL = '[data-test-subj="appLeaveConfirmModal"]';
export const getGoToSpaceMenuItem = (space: string) => `[data-test-subj="space-avatar-${space}"]`;
export const STACK_MANAGEMENT_PAGE =
'[data-test-subj="collapsibleNavAppLink"] [title="Stack Management"]';

View file

@ -6,7 +6,7 @@
*/
import type { TimelineFilter } from '../objects/timeline';
import { getDataTestSubjectSelector } from '../helpers/common';
import { getDataTestSubjectSelector, getDataTestSubjectSelectorStartWith } from '../helpers/common';
export const ADD_NOTE_BUTTON = '[data-test-subj="add-note"]';
@ -209,7 +209,7 @@ export const TIMELINE_FILTER = (filter: TimelineFilter) =>
export const TIMELINE_FILTER_FIELD = '[data-test-subj="filterFieldSuggestionList"]';
export const TIMELINE_TITLE_BY_ID = (id: string) => `[data-test-subj="title-${id}"]`;
export const TIMELINE_TITLE_BY_ID = (id: string) => `[data-test-subj="timeline-title-${id}"]`;
export const TIMELINE_FILTER_OPERATOR = '[data-test-subj="filterOperatorList"]';
@ -346,3 +346,11 @@ export const DISCOVER_TAB = getDataTestSubjectSelector('timelineTabs-discover');
export const TIMELINE_DATE_PICKER_CONTAINER = getDataTestSubjectSelector(
'timeline-date-picker-container'
);
export const OPEN_TIMELINE_MODAL_SEARCH_BAR = `${OPEN_TIMELINE_MODAL} ${getDataTestSubjectSelector(
'search-bar'
)}`;
export const OPEN_TIMELINE_MODAL_TIMELINE_NAMES = `${OPEN_TIMELINE_MODAL} ${getDataTestSubjectSelectorStartWith(
'timeline-title-'
)}`;

View file

@ -23,7 +23,7 @@ export const TIMELINE = (id: string | undefined) => {
if (id == null) {
throw new TypeError('id should never be null or undefined');
}
return `[data-test-subj="title-${id}"]`;
return `[data-test-subj="timeline-title-${id}"]`;
};
export const TIMELINE_CHECKBOX = (id: string) => {
@ -36,7 +36,7 @@ export const TIMELINE_ITEM_ACTION_BTN = (id: string) => {
export const EXPORT_TIMELINE = '[data-test-subj="export-timeline"]';
export const TIMELINE_NAME = '[data-test-subj^=title]';
export const TIMELINE_NAME = '[data-test-subj^=timeline-title-]';
export const TIMELINES_FAVORITE = '[data-test-subj="favorite-starFilled-star"]';

View file

@ -21,10 +21,16 @@ import { GET_LOCAL_SEARCH_BAR_SUBMIT_BUTTON } from '../screens/search_bar';
export const switchDataViewTo = (dataviewName: string) => {
openDataViewSwitcher();
cy.get(DISCOVER_DATA_VIEW_SWITCHER.GET_DATA_VIEW(dataviewName)).trigger('click');
cy.get(DISCOVER_DATA_VIEW_SWITCHER.INPUT).should('not.be.visible');
cy.get(DISCOVER_DATA_VIEW_SWITCHER.INPUT).should('not.exist');
cy.get(DISCOVER_DATA_VIEW_SWITCHER.BTN).should('contain.text', dataviewName);
};
export const switchDataViewToESQL = () => {
openDataViewSwitcher();
cy.get(DISCOVER_DATA_VIEW_SWITCHER.TEXT_BASE_LANG_SWICTHER).trigger('click');
cy.get(DISCOVER_DATA_VIEW_SWITCHER.BTN).should('contain.text', 'ES|QL');
};
export const openDataViewSwitcher = () => {
cy.get(DISCOVER_DATA_VIEW_SWITCHER.BTN).click();
cy.get(DISCOVER_DATA_VIEW_SWITCHER.INPUT).should('be.visible');
@ -38,7 +44,7 @@ export const waitForDiscoverGridToLoad = () => {
};
export const addDiscoverKqlQuery = (kqlQuery: string) => {
cy.get(DISCOVER_QUERY_INPUT).type(kqlQuery);
cy.get(DISCOVER_QUERY_INPUT).type(`${kqlQuery}{enter}`);
};
export const submitDiscoverSearchBar = () => {

View file

@ -0,0 +1,14 @@
/*
* 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 { SAVED_OBJECTS_SETTINGS } from '../screens/common/stack_management';
export const goToSavedObjectSettings = () => {
cy.get(SAVED_OBJECTS_SETTINGS).scrollIntoView();
cy.get(SAVED_OBJECTS_SETTINGS).should('be.visible').focus();
cy.get(SAVED_OBJECTS_SETTINGS).should('be.visible').click();
};

View file

@ -9,6 +9,7 @@ import { recurse } from 'cypress-recurse';
import type { Timeline, TimelineFilter } from '../objects/timeline';
import { ALL_CASES_CREATE_NEW_CASE_TABLE_BTN } from '../screens/all_cases';
import { BASIC_TABLE_LOADING } from '../screens/common';
import { FIELDS_BROWSER_CHECKBOX } from '../screens/fields_browser';
import { LOADING_INDICATOR } from '../screens/security_header';
@ -83,6 +84,9 @@ import {
PROVIDER_BADGE,
PROVIDER_BADGE_DELETE,
DISCOVER_TAB,
OPEN_TIMELINE_MODAL_TIMELINE_NAMES,
OPEN_TIMELINE_MODAL_SEARCH_BAR,
OPEN_TIMELINE_MODAL,
} from '../screens/timeline';
import { REFRESH_BUTTON, TIMELINE } from '../screens/timelines';
import { drag, drop } from './common';
@ -137,8 +141,13 @@ export const goToNotesTab = (): Cypress.Chainable<JQuery<HTMLElement>> => {
};
export const gotToDiscoverTab = () => {
cy.get(DISCOVER_TAB).click();
cy.get(DISCOVER_TAB).should('have.class', 'euiTab-isSelected');
recurse(
() => cy.get(DISCOVER_TAB).click(),
($el) => expect($el).to.have.class('euiTab-isSelected'),
{
delay: 500,
}
);
};
export const goToCorrelationTab = () => {
@ -487,3 +496,12 @@ export const setKibanaTimezoneToUTC = () =>
.then(() => {
cy.reload();
});
export const openTimelineFromOpenTimelineModal = (timelineName: string) => {
cy.get(OPEN_TIMELINE_MODAL_TIMELINE_NAMES).should('have.lengthOf.gt', 0);
cy.get(BASIC_TABLE_LOADING).should('not.exist');
cy.get(OPEN_TIMELINE_MODAL_SEARCH_BAR).type(`${timelineName}{enter}`);
cy.get(OPEN_TIMELINE_MODAL_TIMELINE_NAMES).should('have.lengthOf', 1);
cy.get(OPEN_TIMELINE_MODAL).should('contain.text', timelineName);
cy.get(OPEN_TIMELINE_MODAL_TIMELINE_NAMES).first().click();
};

View file

@ -142,6 +142,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
serializedQuery: getEndpointAlertsQueryForAgentId(endpointAgentId).$stringify(),
},
},
savedSearchId: null,
},
timeline.data.persistTimeline.timeline.version
);

View file

@ -57,7 +57,7 @@ export class TimelinePageObject extends FtrService {
await this.showOpenTimelinePopupFromBottomBar();
await this.testSubjects.click('open-timeline-button');
await this.testSubjects.findService.clickByCssSelector(
`${testSubjSelector('open-timeline-modal')} ${testSubjSelector(`title-${id}`)}`
`${testSubjSelector('open-timeline-modal')} ${testSubjSelector(`timeline-title-${id}`)}`
);
await this.ensureTimelineIsOpen();

View file

@ -90,6 +90,7 @@ export class TimelineTestService extends FtrService {
eventCategoryField: 'event.category',
timestampField: '@timestamp',
},
savedSearchId: null,
};
// Update the timeline
@ -187,6 +188,7 @@ export class TimelineTestService extends FtrService {
serializedQuery: JSON.stringify(esQuery),
},
},
savedSearchId: null,
},
newTimeline.data.persistTimeline.timeline.version
);