[SIEM] Import timeline fix (#65448)

* fix import timeline and clean up

fix unit tests

apply failure checker

clean up error message

fix update template

* add unit tests

* clean up common libs

* rename variables

* add unit tests

* fix types

* Fix imports

* rename file

* poc

* fix unit test

* review

* cleanup fallback values

* cleanup

* check if title exists

* fix unit test

* add unit test

* lint error

* put the flag for disableTemplate into common

* add immutiable

* fix unit

* check templateTimelineVersion only when update via import

* update template timeline via import with response

* add template filter

* add filter count

* add filter numbers

* rename

* enable pin events and note under active status

* disable comment and pinnedEvents for template timelines

* add timelineType for openTimeline

* enable note icon for template

* add timeline type for propertyLeft

* fix types

* duplicate elastic template

* update schema

* fix status check

* fix import

* add templateTimelineType

* disable note for immutable timeline

* fix unit

* fix error message

* fix update

* fix types

* rollback change

* rollback change

* fix create template timeline

* add i18n for error message

* fix unit test

* fix wording and disable delete btn for immutable timeline

* fix unit test provider

* fix types

* fix toaster

* fix notes and pins

* add i18n

* fix selected items

* set disableTemplateto true

* move templateInfo to helper

* review + imporvement

* fix review

* fix types

* fix types

Co-authored-by: Patryk Kopycinski <contact@patrykkopycinski.com>
Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com>
This commit is contained in:
Angela Chuang 2020-06-27 04:53:53 +01:00 committed by GitHub
parent 684289d6e3
commit f4e7f14ffe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
126 changed files with 4543 additions and 1372 deletions

View file

@ -158,6 +158,12 @@ export const showAllOthersBucket: string[] = [
/** /**
* CreateTemplateTimelineBtn * CreateTemplateTimelineBtn
* https://github.com/elastic/kibana/pull/66613
* Remove the comment here to enable template timeline * Remove the comment here to enable template timeline
*/ */
export const disableTemplate = true; export const disableTemplate = false;
/*
* This should be set to true after https://github.com/elastic/kibana/pull/67496 is merged
*/
export const enableElasticFilter = false;

View file

@ -137,11 +137,13 @@ const SavedSortRuntimeType = runtimeTypes.partial({
export enum TimelineStatus { export enum TimelineStatus {
active = 'active', active = 'active',
draft = 'draft', draft = 'draft',
immutable = 'immutable',
} }
export const TimelineStatusLiteralRt = runtimeTypes.union([ export const TimelineStatusLiteralRt = runtimeTypes.union([
runtimeTypes.literal(TimelineStatus.active), runtimeTypes.literal(TimelineStatus.active),
runtimeTypes.literal(TimelineStatus.draft), runtimeTypes.literal(TimelineStatus.draft),
runtimeTypes.literal(TimelineStatus.immutable),
]); ]);
const TimelineStatusLiteralWithNullRt = unionWithNullType(TimelineStatusLiteralRt); const TimelineStatusLiteralWithNullRt = unionWithNullType(TimelineStatusLiteralRt);
@ -151,6 +153,29 @@ export type TimelineStatusLiteralWithNull = runtimeTypes.TypeOf<
typeof TimelineStatusLiteralWithNullRt typeof TimelineStatusLiteralWithNullRt
>; >;
/**
* Template timeline type
*/
export enum TemplateTimelineType {
elastic = 'elastic',
custom = 'custom',
}
export const TemplateTimelineTypeLiteralRt = runtimeTypes.union([
runtimeTypes.literal(TemplateTimelineType.elastic),
runtimeTypes.literal(TemplateTimelineType.custom),
]);
export const TemplateTimelineTypeLiteralWithNullRt = unionWithNullType(
TemplateTimelineTypeLiteralRt
);
export type TemplateTimelineTypeLiteral = runtimeTypes.TypeOf<typeof TemplateTimelineTypeLiteralRt>;
export type TemplateTimelineTypeLiteralWithNull = runtimeTypes.TypeOf<
typeof TemplateTimelineTypeLiteralWithNullRt
>;
/* /*
* Timeline Types * Timeline Types
*/ */
@ -273,6 +298,13 @@ export const TimelineResponseType = runtimeTypes.type({
}), }),
}); });
export const TimelineErrorResponseType = runtimeTypes.type({
status_code: runtimeTypes.number,
message: runtimeTypes.string,
});
export interface TimelineErrorResponse
extends runtimeTypes.TypeOf<typeof TimelineErrorResponseType> {}
export interface TimelineResponse extends runtimeTypes.TypeOf<typeof TimelineResponseType> {} export interface TimelineResponse extends runtimeTypes.TypeOf<typeof TimelineResponseType> {}
/** /**

View file

@ -215,8 +215,8 @@ describe('alert actions', () => {
columnId: '@timestamp', columnId: '@timestamp',
sortDirection: 'desc', sortDirection: 'desc',
}, },
status: TimelineStatus.draft, status: TimelineStatus.active,
title: '', title: 'Test rule - Duplicate',
timelineType: TimelineType.default, timelineType: TimelineType.default,
templateTimelineId: null, templateTimelineId: null,
templateTimelineVersion: null, templateTimelineVersion: null,

View file

@ -12,6 +12,7 @@ import {
mockGlobalState, mockGlobalState,
TestProviders, TestProviders,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../mock'; } from '../../mock';
import { createStore, State } from '../../store'; import { createStore, State } from '../../store';
@ -35,10 +36,22 @@ jest.mock('../../lib/kibana', () => ({
describe('AddFilterToGlobalSearchBar Component', () => { describe('AddFilterToGlobalSearchBar Component', () => {
const state: State = mockGlobalState; const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => { beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
mockAddFilters.mockClear(); mockAddFilters.mockClear();
}); });

View file

@ -12,6 +12,7 @@ import {
apolloClientObservable, apolloClientObservable,
mockGlobalState, mockGlobalState,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../mock'; } from '../../mock';
import { createStore } from '../../store/store'; import { createStore } from '../../store/store';
@ -22,10 +23,22 @@ import { State } from '../../store/types';
describe('Error Toast Dispatcher', () => { describe('Error Toast Dispatcher', () => {
const state: State = mockGlobalState; const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => { beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
}); });
describe('rendering', () => { describe('rendering', () => {

View file

@ -14,6 +14,7 @@ import {
mockGlobalState, mockGlobalState,
apolloClientObservable, apolloClientObservable,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../mock'; } from '../../mock';
import { createStore, State } from '../../store'; import { createStore, State } from '../../store';
@ -36,13 +37,25 @@ describe('Inspect Button', () => {
state: state.inputs, state: state.inputs,
}; };
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
describe('Render', () => { describe('Render', () => {
beforeEach(() => { beforeEach(() => {
const myState = cloneDeep(state); const myState = cloneDeep(state);
myState.inputs = upsertQuery(newQuery); myState.inputs = upsertQuery(newQuery);
store = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); store = createStore(
myState,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
}); });
test('Eui Empty Button', () => { test('Eui Empty Button', () => {
const wrapper = mount( const wrapper = mount(
@ -146,7 +159,13 @@ describe('Inspect Button', () => {
response: ['my response'], response: ['my response'],
}; };
myState.inputs = upsertQuery(myQuery); myState.inputs = upsertQuery(myQuery);
store = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); store = createStore(
myState,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
}); });
test('Open Inspect Modal', () => { test('Open Inspect Modal', () => {
const wrapper = mount( const wrapper = mount(

View file

@ -34,6 +34,7 @@ import {
mockGlobalState, mockGlobalState,
apolloClientObservable, apolloClientObservable,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../mock'; } from '../../mock';
import { State, createStore } from '../../store'; import { State, createStore } from '../../store';
@ -55,7 +56,13 @@ describe('Stat Items Component', () => {
const theme = () => ({ eui: euiDarkVars, darkMode: true }); const theme = () => ({ eui: euiDarkVars, darkMode: true });
const state: State = mockGlobalState; const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
const store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); const store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
describe.each([ describe.each([
[ [

View file

@ -14,6 +14,7 @@ import {
apolloClientObservable, apolloClientObservable,
mockGlobalState, mockGlobalState,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../mock'; } from '../../mock';
import { createUseUiSetting$Mock } from '../../mock/kibana_react'; import { createUseUiSetting$Mock } from '../../mock/kibana_react';
@ -81,11 +82,23 @@ describe('SIEM Super Date Picker', () => {
describe('#SuperDatePicker', () => { describe('#SuperDatePicker', () => {
const state: State = mockGlobalState; const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
mockUseUiSetting$.mockImplementation((key, defaultValue) => { mockUseUiSetting$.mockImplementation((key, defaultValue) => {
const useUiSetting$Mock = createUseUiSetting$Mock(); const useUiSetting$Mock = createUseUiSetting$Mock();

View file

@ -13,6 +13,7 @@ import {
mockGlobalState, mockGlobalState,
TestProviders, TestProviders,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../mock'; } from '../../mock';
import { createKibanaCoreStartMock } from '../../mock/kibana_core'; import { createKibanaCoreStartMock } from '../../mock/kibana_core';
@ -156,7 +157,13 @@ const state: State = {
}; };
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
const store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); const store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
describe('StatefulTopN', () => { describe('StatefulTopN', () => {
// Suppress warnings about "react-beautiful-dnd" // Suppress warnings about "react-beautiful-dnd"

View file

@ -9,10 +9,10 @@ import ApolloClient from 'apollo-client';
import { ApolloLink } from 'apollo-link'; import { ApolloLink } from 'apollo-link';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths // eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { CoreStart } from '../../../../../../../src/core/public';
import introspectionQueryResultData from '../../../graphql/introspection.json'; import introspectionQueryResultData from '../../../graphql/introspection.json';
import { AppFrontendLibs } from '../lib'; import { AppFrontendLibs } from '../lib';
import { getLinks } from './helpers'; import { getLinks } from './helpers';
import { CoreStart } from '../../../../../../../src/core/public';
export function composeLibs(core: CoreStart): AppFrontendLibs { export function composeLibs(core: CoreStart): AppFrontendLibs {
const cache = new InMemoryCache({ const cache = new InMemoryCache({

View file

@ -13,7 +13,7 @@ import { coreMock } from '../../../../../../../src/core/public/mocks';
import { StartPlugins } from '../../../types'; import { StartPlugins } from '../../../types';
import { depsStartMock } from './dependencies_start_mock'; import { depsStartMock } from './dependencies_start_mock';
import { MiddlewareActionSpyHelper, createSpyMiddleware } from '../../store/test_utils'; import { MiddlewareActionSpyHelper, createSpyMiddleware } from '../../store/test_utils';
import { apolloClientObservable } from '../test_providers'; import { apolloClientObservable, kibanaObservable } from '../test_providers';
import { createStore, State, substateMiddlewareFactory } from '../../store'; import { createStore, State, substateMiddlewareFactory } from '../../store';
import { alertMiddlewareFactory } from '../../../endpoint_alerts/store/middleware'; import { alertMiddlewareFactory } from '../../../endpoint_alerts/store/middleware';
import { AppRootProvider } from './app_root_provider'; import { AppRootProvider } from './app_root_provider';
@ -58,14 +58,21 @@ export const createAppRootMockRenderer = (): AppContextTestRender => {
const middlewareSpy = createSpyMiddleware(); const middlewareSpy = createSpyMiddleware();
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage, [ const store = createStore(
substateMiddlewareFactory( mockGlobalState,
(globalState) => globalState.alertList, SUB_PLUGINS_REDUCER,
alertMiddlewareFactory(coreStart, depsStart) apolloClientObservable,
), kibanaObservable,
...managementMiddlewareFactory(coreStart, depsStart), storage,
middlewareSpy.actionSpyMiddleware, [
]); substateMiddlewareFactory(
(globalState) => globalState.alertList,
alertMiddlewareFactory(coreStart, depsStart)
),
...managementMiddlewareFactory(coreStart, depsStart),
middlewareSpy.actionSpyMiddleware,
]
);
const MockKibanaContextProvider = createKibanaContextProviderMock(); const MockKibanaContextProvider = createKibanaContextProviderMock();

View file

@ -26,6 +26,7 @@ import {
DEFAULT_INDEX_PATTERN, DEFAULT_INDEX_PATTERN,
} from '../../../common/constants'; } from '../../../common/constants';
import { createKibanaCoreStartMock, createKibanaPluginsStartMock } from './kibana_core'; import { createKibanaCoreStartMock, createKibanaPluginsStartMock } from './kibana_core';
import { StartServices } from '../../types';
import { createSecuritySolutionStorageMock } from './mock_local_storage'; import { createSecuritySolutionStorageMock } from './mock_local_storage';
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -71,6 +72,8 @@ export const createUseUiSetting$Mock = () => {
): [T, () => void] | undefined => [useUiSettingMock(key, defaultValue), jest.fn()]; ): [T, () => void] | undefined => [useUiSettingMock(key, defaultValue), jest.fn()];
}; };
export const createKibanaObservable$Mock = createKibanaCoreStartMock;
export const createUseKibanaMock = () => { export const createUseKibanaMock = () => {
const core = createKibanaCoreStartMock(); const core = createKibanaCoreStartMock();
const plugins = createKibanaPluginsStartMock(); const plugins = createKibanaPluginsStartMock();
@ -90,6 +93,36 @@ export const createUseKibanaMock = () => {
return () => ({ services }); return () => ({ services });
}; };
export const createStartServices = () => {
const core = createKibanaCoreStartMock();
const plugins = createKibanaPluginsStartMock();
const security = {
authc: {
getCurrentUser: jest.fn(),
areAPIKeysEnabled: jest.fn(),
},
sessionTimeout: {
start: jest.fn(),
stop: jest.fn(),
extend: jest.fn(),
},
license: {
isEnabled: jest.fn(),
getFeatures: jest.fn(),
features$: jest.fn(),
},
__legacyCompat: { logoutUrl: 'logoutUrl', tenant: 'tenant' },
};
const services = ({
...core,
...plugins,
security,
} as unknown) as StartServices;
return services;
};
export const createWithKibanaMock = () => { export const createWithKibanaMock = () => {
const kibana = createUseKibanaMock()(); const kibana = createUseKibanaMock()();

View file

@ -19,7 +19,7 @@ import { ThemeProvider } from 'styled-components';
import { createStore, State } from '../store'; import { createStore, State } from '../store';
import { mockGlobalState } from './global_state'; import { mockGlobalState } from './global_state';
import { createKibanaContextProviderMock } from './kibana_react'; import { createKibanaContextProviderMock, createStartServices } from './kibana_react';
import { FieldHook, useForm } from '../../shared_imports'; import { FieldHook, useForm } from '../../shared_imports';
import { SUB_PLUGINS_REDUCER } from './utils'; import { SUB_PLUGINS_REDUCER } from './utils';
import { createSecuritySolutionStorageMock, localStorageMock } from './mock_local_storage'; import { createSecuritySolutionStorageMock, localStorageMock } from './mock_local_storage';
@ -38,6 +38,7 @@ export const apolloClient = new ApolloClient({
}); });
export const apolloClientObservable = new BehaviorSubject(apolloClient); export const apolloClientObservable = new BehaviorSubject(apolloClient);
export const kibanaObservable = new BehaviorSubject(createStartServices());
Object.defineProperty(window, 'localStorage', { Object.defineProperty(window, 'localStorage', {
value: localStorageMock(), value: localStorageMock(),
@ -49,7 +50,13 @@ const { storage } = createSecuritySolutionStorageMock();
/** A utility for wrapping children in the providers required to run most tests */ /** A utility for wrapping children in the providers required to run most tests */
const TestProvidersComponent: React.FC<Props> = ({ const TestProvidersComponent: React.FC<Props> = ({
children, children,
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage), store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
),
onDragEnd = jest.fn(), onDragEnd = jest.fn(),
}) => ( }) => (
<I18nProvider> <I18nProvider>
@ -69,7 +76,13 @@ export const TestProviders = React.memo(TestProvidersComponent);
const TestProviderWithoutDragAndDropComponent: React.FC<Props> = ({ const TestProviderWithoutDragAndDropComponent: React.FC<Props> = ({
children, children,
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage), store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
),
}) => ( }) => (
<I18nProvider> <I18nProvider>
<ReduxStoreProvider store={store}>{children}</ReduxStoreProvider> <ReduxStoreProvider store={store}>{children}</ReduxStoreProvider>

View file

@ -29,6 +29,7 @@ import { AppAction } from './actions';
import { Immutable } from '../../../common/endpoint/types'; import { Immutable } from '../../../common/endpoint/types';
import { State } from './types'; import { State } from './types';
import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; import { Storage } from '../../../../../../src/plugins/kibana_utils/public';
import { CoreStart } from '../../../../../../src/core/public';
type ComposeType = typeof compose; type ComposeType = typeof compose;
declare global { declare global {
@ -49,6 +50,7 @@ export const createStore = (
state: PreloadedState<State>, state: PreloadedState<State>,
pluginsReducer: SubPluginsInitReducer, pluginsReducer: SubPluginsInitReducer,
apolloClient: Observable<AppApolloClient>, apolloClient: Observable<AppApolloClient>,
kibana: Observable<CoreStart>,
storage: Storage, storage: Storage,
additionalMiddleware?: Array<Middleware<{}, State, Dispatch<AppAction | Immutable<AppAction>>>> additionalMiddleware?: Array<Middleware<{}, State, Dispatch<AppAction | Immutable<AppAction>>>>
): Store<State, Action> => { ): Store<State, Action> => {
@ -56,6 +58,7 @@ export const createStore = (
const middlewareDependencies = { const middlewareDependencies = {
apolloClient$: apolloClient, apolloClient$: apolloClient,
kibana$: kibana,
selectAllTimelineQuery: inputsSelectors.globalQueryByIdSelector, selectAllTimelineQuery: inputsSelectors.globalQueryByIdSelector,
selectNotesByIdSelector: appSelectors.selectNotesByIdSelector, selectNotesByIdSelector: appSelectors.selectNotesByIdSelector,
timelineByIdSelector: timelineSelectors.timelineByIdSelector, timelineByIdSelector: timelineSelectors.timelineByIdSelector,

View file

@ -19,23 +19,22 @@ import { NetworkPluginState } from '../../network/store';
import { EndpointAlertsPluginState } from '../../endpoint_alerts'; import { EndpointAlertsPluginState } from '../../endpoint_alerts';
import { ManagementPluginState } from '../../management'; import { ManagementPluginState } from '../../management';
export type StoreState = HostsPluginState &
NetworkPluginState &
TimelinePluginState &
EndpointAlertsPluginState &
ManagementPluginState & {
app: AppState;
dragAndDrop: DragAndDropState;
inputs: InputsState;
};
/** /**
* The redux `State` type for the Security App. * The redux `State` type for the Security App.
* We use `CombinedState` to wrap our shape because we create our reducer using `combineReducers`. * We use `CombinedState` to wrap our shape because we create our reducer using `combineReducers`.
* `combineReducers` returns a type wrapped in `CombinedState`. * `combineReducers` returns a type wrapped in `CombinedState`.
* `CombinedState` is required for redux to know what keys to make optional when preloaded state into a store. * `CombinedState` is required for redux to know what keys to make optional when preloaded state into a store.
*/ */
export type State = CombinedState< export type State = CombinedState<StoreState>;
HostsPluginState &
NetworkPluginState &
TimelinePluginState &
EndpointAlertsPluginState &
ManagementPluginState & {
app: AppState;
dragAndDrop: DragAndDropState;
inputs: InputsState;
}
>;
export type KueryFilterQueryKind = 'kuery' | 'lucene'; export type KueryFilterQueryKind = 'kuery' | 'lucene';

View file

@ -19,6 +19,7 @@ import {
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
mockGlobalState, mockGlobalState,
apolloClientObservable, apolloClientObservable,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../../common/mock'; } from '../../../common/mock';
@ -31,7 +32,13 @@ export const alertPageTestRender = () => {
* Create a store, with the middleware disabled. We don't want side effects being created by our code in this test. * Create a store, with the middleware disabled. We don't want side effects being created by our code in this test.
*/ */
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); const store = createStore(
mockGlobalState,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
const depsStart = depsStartMock(); const depsStart = depsStartMock();
depsStart.data.ui.SearchBar.mockImplementation(() => <div />); depsStart.data.ui.SearchBar.mockImplementation(() => <div />);

View file

@ -255,6 +255,18 @@
"description": "", "description": "",
"type": { "kind": "ENUM", "name": "TimelineType", "ofType": null }, "type": { "kind": "ENUM", "name": "TimelineType", "ofType": null },
"defaultValue": null "defaultValue": null
},
{
"name": "templateTimelineType",
"description": "",
"type": { "kind": "ENUM", "name": "TemplateTimelineType", "ofType": null },
"defaultValue": null
},
{
"name": "status",
"description": "",
"type": { "kind": "ENUM", "name": "TimelineStatus", "ofType": null },
"defaultValue": null
} }
], ],
"type": { "type": {
@ -10405,7 +10417,13 @@
"interfaces": null, "interfaces": null,
"enumValues": [ "enumValues": [
{ "name": "active", "description": "", "isDeprecated": false, "deprecationReason": null }, { "name": "active", "description": "", "isDeprecated": false, "deprecationReason": null },
{ "name": "draft", "description": "", "isDeprecated": false, "deprecationReason": null } { "name": "draft", "description": "", "isDeprecated": false, "deprecationReason": null },
{
"name": "immutable",
"description": "",
"isDeprecated": false,
"deprecationReason": null
}
], ],
"possibleTypes": null "possibleTypes": null
}, },
@ -10529,6 +10547,24 @@
], ],
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "ENUM",
"name": "TemplateTimelineType",
"description": "",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "elastic",
"description": "",
"isDeprecated": false,
"deprecationReason": null
},
{ "name": "custom", "description": "", "isDeprecated": false, "deprecationReason": null }
],
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "ResponseTimelines", "name": "ResponseTimelines",
@ -10557,6 +10593,46 @@
"type": { "kind": "SCALAR", "name": "Float", "ofType": null }, "type": { "kind": "SCALAR", "name": "Float", "ofType": null },
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
},
{
"name": "defaultTimelineCount",
"description": "",
"args": [],
"type": { "kind": "SCALAR", "name": "Float", "ofType": null },
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "templateTimelineCount",
"description": "",
"args": [],
"type": { "kind": "SCALAR", "name": "Float", "ofType": null },
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "elasticTemplateTimelineCount",
"description": "",
"args": [],
"type": { "kind": "SCALAR", "name": "Float", "ofType": null },
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "customTemplateTimelineCount",
"description": "",
"args": [],
"type": { "kind": "SCALAR", "name": "Float", "ofType": null },
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "favoriteCount",
"description": "",
"args": [],
"type": { "kind": "SCALAR", "name": "Float", "ofType": null },
"isDeprecated": false,
"deprecationReason": null
} }
], ],
"inputFields": null, "inputFields": null,

View file

@ -345,6 +345,7 @@ export enum TlsFields {
export enum TimelineStatus { export enum TimelineStatus {
active = 'active', active = 'active',
draft = 'draft', draft = 'draft',
immutable = 'immutable',
} }
export enum TimelineType { export enum TimelineType {
@ -359,6 +360,11 @@ export enum SortFieldTimeline {
created = 'created', created = 'created',
} }
export enum TemplateTimelineType {
elastic = 'elastic',
custom = 'custom',
}
export enum NetworkDirectionEcs { export enum NetworkDirectionEcs {
inbound = 'inbound', inbound = 'inbound',
outbound = 'outbound', outbound = 'outbound',
@ -2117,6 +2123,16 @@ export interface ResponseTimelines {
timeline: (Maybe<TimelineResult>)[]; timeline: (Maybe<TimelineResult>)[];
totalCount?: Maybe<number>; totalCount?: Maybe<number>;
defaultTimelineCount?: Maybe<number>;
templateTimelineCount?: Maybe<number>;
elasticTemplateTimelineCount?: Maybe<number>;
customTemplateTimelineCount?: Maybe<number>;
favoriteCount?: Maybe<number>;
} }
export interface Mutation { export interface Mutation {
@ -2254,6 +2270,10 @@ export interface GetAllTimelineQueryArgs {
onlyUserFavorite?: Maybe<boolean>; onlyUserFavorite?: Maybe<boolean>;
timelineType?: Maybe<TimelineType>; timelineType?: Maybe<TimelineType>;
templateTimelineType?: Maybe<TemplateTimelineType>;
status?: Maybe<TimelineStatus>;
} }
export interface AuthenticationsSourceArgs { export interface AuthenticationsSourceArgs {
timerange: TimerangeInput; timerange: TimerangeInput;
@ -4315,6 +4335,8 @@ export namespace GetAllTimeline {
sort?: Maybe<SortTimeline>; sort?: Maybe<SortTimeline>;
onlyUserFavorite?: Maybe<boolean>; onlyUserFavorite?: Maybe<boolean>;
timelineType?: Maybe<TimelineType>; timelineType?: Maybe<TimelineType>;
templateTimelineType?: Maybe<TemplateTimelineType>;
status?: Maybe<TimelineStatus>;
}; };
export type Query = { export type Query = {
@ -4328,6 +4350,16 @@ export namespace GetAllTimeline {
totalCount: Maybe<number>; totalCount: Maybe<number>;
defaultTimelineCount: Maybe<number>;
templateTimelineCount: Maybe<number>;
elasticTemplateTimelineCount: Maybe<number>;
customTemplateTimelineCount: Maybe<number>;
favoriteCount: Maybe<number>;
timeline: (Maybe<Timeline>)[]; timeline: (Maybe<Timeline>)[];
}; };

View file

@ -13,6 +13,7 @@ import {
apolloClientObservable, apolloClientObservable,
mockGlobalState, mockGlobalState,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../../common/mock'; } from '../../../common/mock';
import { createStore, State } from '../../../common/store'; import { createStore, State } from '../../../common/store';
@ -26,10 +27,22 @@ describe('Authentication Table Component', () => {
const state: State = mockGlobalState; const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => { beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
}); });
describe('rendering', () => { describe('rendering', () => {

View file

@ -15,6 +15,7 @@ import {
mockGlobalState, mockGlobalState,
TestProviders, TestProviders,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../../common/mock'; } from '../../../common/mock';
import { useMountAppended } from '../../../common/utils/use_mount_appended'; import { useMountAppended } from '../../../common/utils/use_mount_appended';
@ -40,11 +41,23 @@ describe('Hosts Table', () => {
const state: State = mockGlobalState; const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
const mount = useMountAppended(); const mount = useMountAppended();
beforeEach(() => { beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
}); });
describe('rendering', () => { describe('rendering', () => {

View file

@ -16,6 +16,7 @@ import {
TestProviders, TestProviders,
mockGlobalState, mockGlobalState,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../common/mock'; } from '../../common/mock';
import { SiemNavigation } from '../../common/components/navigation'; import { SiemNavigation } from '../../common/components/navigation';
@ -154,7 +155,13 @@ describe('Hosts - rendering', () => {
}); });
const myState: State = mockGlobalState; const myState: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
const myStore = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); const myStore = createStore(
myState,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
const wrapper = mount( const wrapper = mount(
<TestProviders store={myStore}> <TestProviders store={myStore}>
<Router history={mockHistory}> <Router history={mockHistory}>

View file

@ -14,6 +14,7 @@ import {
mockGlobalState, mockGlobalState,
TestProviders, TestProviders,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../../common/mock'; } from '../../../common/mock';
import { createStore, State } from '../../../common/store'; import { createStore, State } from '../../../common/store';
@ -28,10 +29,22 @@ describe('IP Overview Component', () => {
const state: State = mockGlobalState; const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => { beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
}); });
describe('rendering', () => { describe('rendering', () => {

View file

@ -12,6 +12,7 @@ import {
apolloClientObservable, apolloClientObservable,
mockGlobalState, mockGlobalState,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../../common/mock'; } from '../../../common/mock';
import { createStore, State } from '../../../common/store'; import { createStore, State } from '../../../common/store';
@ -25,10 +26,22 @@ describe('KpiNetwork Component', () => {
const narrowDateRange = jest.fn(); const narrowDateRange = jest.fn();
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => { beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
}); });
describe('rendering', () => { describe('rendering', () => {

View file

@ -15,6 +15,7 @@ import {
mockGlobalState, mockGlobalState,
TestProviders, TestProviders,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../../common/mock'; } from '../../../common/mock';
import { State, createStore } from '../../../common/store'; import { State, createStore } from '../../../common/store';
@ -28,11 +29,23 @@ describe('NetworkTopNFlow Table Component', () => {
const loadPage = jest.fn(); const loadPage = jest.fn();
const state: State = mockGlobalState; const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
const mount = useMountAppended(); const mount = useMountAppended();
beforeEach(() => { beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
}); });
describe('rendering', () => { describe('rendering', () => {

View file

@ -15,6 +15,7 @@ import {
mockGlobalState, mockGlobalState,
TestProviders, TestProviders,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../../common/mock'; } from '../../../common/mock';
import { useMountAppended } from '../../../common/utils/use_mount_appended'; import { useMountAppended } from '../../../common/utils/use_mount_appended';
@ -31,11 +32,23 @@ describe('NetworkHttp Table Component', () => {
const state: State = mockGlobalState; const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
const mount = useMountAppended(); const mount = useMountAppended();
beforeEach(() => { beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
}); });
describe('rendering', () => { describe('rendering', () => {

View file

@ -17,6 +17,7 @@ import {
mockIndexPattern, mockIndexPattern,
TestProviders, TestProviders,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../../common/mock'; } from '../../../common/mock';
import { useMountAppended } from '../../../common/utils/use_mount_appended'; import { useMountAppended } from '../../../common/utils/use_mount_appended';
@ -32,10 +33,22 @@ describe('NetworkTopCountries Table Component', () => {
const mount = useMountAppended(); const mount = useMountAppended();
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => { beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
}); });
describe('rendering', () => { describe('rendering', () => {

View file

@ -16,6 +16,7 @@ import {
mockGlobalState, mockGlobalState,
TestProviders, TestProviders,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../../common/mock'; } from '../../../common/mock';
import { useMountAppended } from '../../../common/utils/use_mount_appended'; import { useMountAppended } from '../../../common/utils/use_mount_appended';
@ -31,11 +32,23 @@ describe('NetworkTopNFlow Table Component', () => {
const state: State = mockGlobalState; const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
const mount = useMountAppended(); const mount = useMountAppended();
beforeEach(() => { beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
}); });
describe('rendering', () => { describe('rendering', () => {

View file

@ -15,6 +15,7 @@ import {
mockGlobalState, mockGlobalState,
TestProviders, TestProviders,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../../common/mock'; } from '../../../common/mock';
import { useMountAppended } from '../../../common/utils/use_mount_appended'; import { useMountAppended } from '../../../common/utils/use_mount_appended';
@ -28,11 +29,23 @@ describe('Tls Table Component', () => {
const state: State = mockGlobalState; const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
const mount = useMountAppended(); const mount = useMountAppended();
beforeEach(() => { beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
}); });
describe('Rendering', () => { describe('Rendering', () => {

View file

@ -16,6 +16,7 @@ import {
mockGlobalState, mockGlobalState,
TestProviders, TestProviders,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../../common/mock'; } from '../../../common/mock';
import { useMountAppended } from '../../../common/utils/use_mount_appended'; import { useMountAppended } from '../../../common/utils/use_mount_appended';
@ -30,11 +31,23 @@ describe('Users Table Component', () => {
const state: State = mockGlobalState; const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
const mount = useMountAppended(); const mount = useMountAppended();
beforeEach(() => { beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
}); });
describe('Rendering', () => { describe('Rendering', () => {

View file

@ -18,6 +18,7 @@ import {
mockGlobalState, mockGlobalState,
TestProviders, TestProviders,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../../common/mock'; } from '../../../common/mock';
import { useMountAppended } from '../../../common/utils/use_mount_appended'; import { useMountAppended } from '../../../common/utils/use_mount_appended';
@ -90,7 +91,6 @@ const getMockProps = (ip: string) => ({
describe('Ip Details', () => { describe('Ip Details', () => {
const mount = useMountAppended(); const mount = useMountAppended();
beforeAll(() => { beforeAll(() => {
(useWithSource as jest.Mock).mockReturnValue({ (useWithSource as jest.Mock).mockReturnValue({
indicesExist: false, indicesExist: false,
@ -107,15 +107,27 @@ describe('Ip Details', () => {
}); });
afterAll(() => { afterAll(() => {
delete (global as GlobalWithFetch).fetch; jest.resetAllMocks();
}); });
const state: State = mockGlobalState; const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => { beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
}); });
test('it renders', () => { test('it renders', () => {

View file

@ -16,6 +16,7 @@ import {
mockGlobalState, mockGlobalState,
apolloClientObservable, apolloClientObservable,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../common/mock'; } from '../../common/mock';
import { State, createStore } from '../../common/store'; import { State, createStore } from '../../common/store';
@ -139,7 +140,13 @@ describe('rendering - rendering', () => {
}); });
const myState: State = mockGlobalState; const myState: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
const myStore = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); const myStore = createStore(
myState,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
const wrapper = mount( const wrapper = mount(
<TestProviders store={myStore}> <TestProviders store={myStore}>
<Router history={mockHistory}> <Router history={mockHistory}>

View file

@ -14,6 +14,7 @@ import {
mockGlobalState, mockGlobalState,
TestProviders, TestProviders,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../../common/mock'; } from '../../../common/mock';
@ -95,11 +96,23 @@ describe('OverviewHost', () => {
const state: State = mockGlobalState; const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => { beforeEach(() => {
const myState = cloneDeep(state); const myState = cloneDeep(state);
store = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); store = createStore(
myState,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
}); });
test('it renders the expected widget title', () => { test('it renders the expected widget title', () => {

View file

@ -14,6 +14,7 @@ import {
TestProviders, TestProviders,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
kibanaObservable,
} from '../../../common/mock'; } from '../../../common/mock';
import { OverviewNetwork } from '.'; import { OverviewNetwork } from '.';
@ -86,11 +87,23 @@ describe('OverviewNetwork', () => {
const state: State = mockGlobalState; const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => { beforeEach(() => {
const myState = cloneDeep(state); const myState = cloneDeep(state);
store = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); store = createStore(
myState,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
}); });
test('it renders the expected widget title', () => { test('it renders the expected widget title', () => {

View file

@ -24,6 +24,7 @@ import { RecentTimelines } from './recent_timelines';
import * as i18n from './translations'; import * as i18n from './translations';
import { FilterMode } from './types'; import { FilterMode } from './types';
import { LoadingPlaceholders } from '../loading_placeholders'; import { LoadingPlaceholders } from '../loading_placeholders';
import { useTimelineStatus } from '../../../timelines/components/open_timeline/use_timeline_status';
import { useKibana } from '../../../common/lib/kibana'; import { useKibana } from '../../../common/lib/kibana';
import { SecurityPageName } from '../../../app/types'; import { SecurityPageName } from '../../../app/types';
import { APP_ID } from '../../../../common/constants'; import { APP_ID } from '../../../../common/constants';
@ -83,25 +84,25 @@ const StatefulRecentTimelinesComponent = React.memo<Props>(
); );
const { fetchAllTimeline, timelines, loading } = useGetAllTimeline(); const { fetchAllTimeline, timelines, loading } = useGetAllTimeline();
const timelineType = TimelineType.default;
useEffect( const { templateTimelineType, timelineStatus } = useTimelineStatus({ timelineType });
() => useEffect(() => {
fetchAllTimeline({ fetchAllTimeline({
pageInfo: { pageInfo: {
pageIndex: 1, pageIndex: 1,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
}, },
search: '', search: '',
sort: { sort: {
sortField: SortFieldTimeline.updated, sortField: SortFieldTimeline.updated,
sortOrder: Direction.desc, sortOrder: Direction.desc,
}, },
onlyUserFavorite: filterBy === 'favorites', onlyUserFavorite: filterBy === 'favorites',
timelineType: TimelineType.default, status: timelineStatus,
}), timelineType,
// eslint-disable-next-line react-hooks/exhaustive-deps templateTimelineType,
[filterBy] });
); }, [fetchAllTimeline, filterBy, timelineStatus, timelineType, templateTimelineType]);
return ( return (
<> <>

View file

@ -23,7 +23,14 @@ import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public';
import { initTelemetry } from './common/lib/telemetry'; import { initTelemetry } from './common/lib/telemetry';
import { KibanaServices } from './common/lib/kibana/services'; import { KibanaServices } from './common/lib/kibana/services';
import { serviceNowActionType, jiraActionType } from './common/lib/connectors'; import { serviceNowActionType, jiraActionType } from './common/lib/connectors';
import { PluginSetup, PluginStart, SetupPlugins, StartPlugins, StartServices } from './types'; import {
PluginSetup,
PluginStart,
SetupPlugins,
StartPlugins,
StartServices,
AppObservableLibs,
} from './types';
import { import {
APP_ID, APP_ID,
APP_ICON, APP_ICON,
@ -120,6 +127,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
this.downloadAssets(), this.downloadAssets(),
this.downloadSubPlugins(), this.downloadSubPlugins(),
]); ]);
return renderApp({ return renderApp({
...composeLibs(coreStart), ...composeLibs(coreStart),
...params, ...params,
@ -396,8 +404,9 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
endpointAlertsSubPlugin, endpointAlertsSubPlugin,
managementSubPlugin, managementSubPlugin,
} = await this.downloadSubPlugins(); } = await this.downloadSubPlugins();
const { apolloClient } = composeLibs(coreStart);
const libs$ = new BehaviorSubject(composeLibs(coreStart)); const appLibs: AppObservableLibs = { apolloClient, kibana: coreStart };
const libs$ = new BehaviorSubject(appLibs);
const alertsStart = alertsSubPlugin.start(storage); const alertsStart = alertsSubPlugin.start(storage);
const hostsStart = hostsSubPlugin.start(storage); const hostsStart = hostsSubPlugin.start(storage);
@ -434,6 +443,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
...managementSubPluginStart.store.reducer, ...managementSubPluginStart.store.reducer,
}, },
libs$.pipe(pluck('apolloClient')), libs$.pipe(pluck('apolloClient')),
libs$.pipe(pluck('kibana')),
storage, storage,
[ [
...(endpointAlertsStart.store.middleware ?? []), ...(endpointAlertsStart.store.middleware ?? []),

View file

@ -41,6 +41,7 @@ const StatefulFlyoutHeader = React.memo<Props>(
notesById, notesById,
status, status,
timelineId, timelineId,
timelineType,
title, title,
toggleLock, toggleLock,
updateDescription, updateDescription,
@ -66,6 +67,7 @@ const StatefulFlyoutHeader = React.memo<Props>(
noteIds={noteIds} noteIds={noteIds}
status={status} status={status}
timelineId={timelineId} timelineId={timelineId}
timelineType={timelineType}
title={title} title={title}
toggleLock={toggleLock} toggleLock={toggleLock}
updateDescription={updateDescription} updateDescription={updateDescription}
@ -100,6 +102,7 @@ const makeMapStateToProps = () => {
title = '', title = '',
noteIds = emptyNotesId, noteIds = emptyNotesId,
status, status,
timelineType,
} = timeline; } = timeline;
const history = emptyHistory; // TODO: get history from store via selector const history = emptyHistory; // TODO: get history from store via selector
@ -116,6 +119,7 @@ const makeMapStateToProps = () => {
notesById: getNotesByIds(state), notesById: getNotesByIds(state),
status, status,
title, title,
timelineType,
}; };
}; };
return mapStateToProps; return mapStateToProps;

View file

@ -4,6 +4,7 @@ exports[`FlyoutHeaderWithCloseButton renders correctly against snapshot 1`] = `
<FlyoutHeaderWithCloseButton <FlyoutHeaderWithCloseButton
onClose={[MockFunction]} onClose={[MockFunction]}
timelineId="test" timelineId="test"
timelineType="default"
usersViewing={ usersViewing={
Array [ Array [
"elastic", "elastic",

View file

@ -7,6 +7,7 @@
import { mount, shallow } from 'enzyme'; import { mount, shallow } from 'enzyme';
import React from 'react'; import React from 'react';
import { TimelineType } from '../../../../../common/types/timeline';
import { TestProviders } from '../../../../common/mock'; import { TestProviders } from '../../../../common/mock';
import { FlyoutHeaderWithCloseButton } from '.'; import { FlyoutHeaderWithCloseButton } from '.';
@ -40,14 +41,16 @@ jest.mock('../../../../common/lib/kibana', () => {
}); });
describe('FlyoutHeaderWithCloseButton', () => { describe('FlyoutHeaderWithCloseButton', () => {
const props = {
onClose: jest.fn(),
timelineId: 'test',
timelineType: TimelineType.default,
usersViewing: ['elastic'],
};
test('renders correctly against snapshot', () => { test('renders correctly against snapshot', () => {
const EmptyComponent = shallow( const EmptyComponent = shallow(
<TestProviders> <TestProviders>
<FlyoutHeaderWithCloseButton <FlyoutHeaderWithCloseButton {...props} />
onClose={jest.fn()}
timelineId={'test'}
usersViewing={['elastic']}
/>
</TestProviders> </TestProviders>
); );
expect(EmptyComponent.find('FlyoutHeaderWithCloseButton')).toMatchSnapshot(); expect(EmptyComponent.find('FlyoutHeaderWithCloseButton')).toMatchSnapshot();
@ -55,13 +58,13 @@ describe('FlyoutHeaderWithCloseButton', () => {
test('it should invoke onClose when the close button is clicked', () => { test('it should invoke onClose when the close button is clicked', () => {
const closeMock = jest.fn(); const closeMock = jest.fn();
const testProps = {
...props,
onClose: closeMock,
};
const wrapper = mount( const wrapper = mount(
<TestProviders> <TestProviders>
<FlyoutHeaderWithCloseButton <FlyoutHeaderWithCloseButton {...testProps} />
onClose={closeMock}
timelineId={'test'}
usersViewing={['elastic']}
/>
</TestProviders> </TestProviders>
); );
wrapper.find('[data-test-subj="close-timeline"] button').first().simulate('click'); wrapper.find('[data-test-subj="close-timeline"] button').first().simulate('click');

View file

@ -14,6 +14,7 @@ import {
mockGlobalState, mockGlobalState,
TestProviders, TestProviders,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../../common/mock'; } from '../../../common/mock';
import { createStore, State } from '../../../common/store'; import { createStore, State } from '../../../common/store';
@ -62,6 +63,7 @@ describe('Flyout', () => {
stateShowIsTrue, stateShowIsTrue,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
apolloClientObservable, apolloClientObservable,
kibanaObservable,
storage storage
); );
@ -86,6 +88,7 @@ describe('Flyout', () => {
stateWithDataProviders, stateWithDataProviders,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
apolloClientObservable, apolloClientObservable,
kibanaObservable,
storage storage
); );
@ -108,6 +111,7 @@ describe('Flyout', () => {
stateWithDataProviders, stateWithDataProviders,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
apolloClientObservable, apolloClientObservable,
kibanaObservable,
storage storage
); );
@ -142,6 +146,7 @@ describe('Flyout', () => {
stateWithDataProviders, stateWithDataProviders,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
apolloClientObservable, apolloClientObservable,
kibanaObservable,
storage storage
); );

View file

@ -8,52 +8,39 @@ import { mount, shallow } from 'enzyme';
import React from 'react'; import React from 'react';
import { AddNote } from '.'; import { AddNote } from '.';
import { TimelineStatus } from '../../../../../common/types/timeline';
describe('AddNote', () => { describe('AddNote', () => {
const note = 'The contents of a new note'; const note = 'The contents of a new note';
const props = {
associateNote: jest.fn(),
getNewNoteId: jest.fn(),
newNote: note,
onCancelAddNote: jest.fn(),
updateNewNote: jest.fn(),
updateNote: jest.fn(),
status: TimelineStatus.active,
};
test('renders correctly', () => { test('renders correctly', () => {
const wrapper = shallow( const wrapper = shallow(<AddNote {...props} />);
<AddNote
associateNote={jest.fn()}
getNewNoteId={jest.fn()}
newNote={note}
onCancelAddNote={jest.fn()}
updateNewNote={jest.fn()}
updateNote={jest.fn()}
/>
);
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
test('it renders the Cancel button when onCancelAddNote is provided', () => { test('it renders the Cancel button when onCancelAddNote is provided', () => {
const wrapper = mount( const wrapper = mount(<AddNote {...props} />);
<AddNote
associateNote={jest.fn()}
getNewNoteId={jest.fn()}
newNote={note}
onCancelAddNote={jest.fn()}
updateNewNote={jest.fn()}
updateNote={jest.fn()}
/>
);
expect(wrapper.find('[data-test-subj="cancel"]').exists()).toEqual(true); expect(wrapper.find('[data-test-subj="cancel"]').exists()).toEqual(true);
}); });
test('it invokes onCancelAddNote when the Cancel button is clicked', () => { test('it invokes onCancelAddNote when the Cancel button is clicked', () => {
const onCancelAddNote = jest.fn(); const onCancelAddNote = jest.fn();
const testProps = {
...props,
onCancelAddNote,
};
const wrapper = mount( const wrapper = mount(<AddNote {...testProps} />);
<AddNote
associateNote={jest.fn()}
getNewNoteId={jest.fn()}
newNote={note}
onCancelAddNote={onCancelAddNote}
updateNewNote={jest.fn()}
updateNote={jest.fn()}
/>
);
wrapper.find('[data-test-subj="cancel"]').first().simulate('click'); wrapper.find('[data-test-subj="cancel"]').first().simulate('click');
@ -62,17 +49,12 @@ describe('AddNote', () => {
test('it does NOT invoke associateNote when the Cancel button is clicked', () => { test('it does NOT invoke associateNote when the Cancel button is clicked', () => {
const associateNote = jest.fn(); const associateNote = jest.fn();
const testProps = {
...props,
associateNote,
};
const wrapper = mount( const wrapper = mount(<AddNote {...testProps} />);
<AddNote
associateNote={associateNote}
getNewNoteId={jest.fn()}
newNote={note}
onCancelAddNote={jest.fn()}
updateNewNote={jest.fn()}
updateNote={jest.fn()}
/>
);
wrapper.find('[data-test-subj="cancel"]').first().simulate('click'); wrapper.find('[data-test-subj="cancel"]').first().simulate('click');
@ -80,47 +62,29 @@ describe('AddNote', () => {
}); });
test('it does NOT render the Cancel button when onCancelAddNote is NOT provided', () => { test('it does NOT render the Cancel button when onCancelAddNote is NOT provided', () => {
const wrapper = mount( const testProps = {
<AddNote ...props,
associateNote={jest.fn()} onCancelAddNote: undefined,
getNewNoteId={jest.fn()} };
newNote={note} const wrapper = mount(<AddNote {...testProps} />);
updateNewNote={jest.fn()}
updateNote={jest.fn()}
/>
);
expect(wrapper.find('[data-test-subj="cancel"]').exists()).toEqual(false); expect(wrapper.find('[data-test-subj="cancel"]').exists()).toEqual(false);
}); });
test('it renders the contents of the note', () => { test('it renders the contents of the note', () => {
const wrapper = mount( const wrapper = mount(<AddNote {...props} />);
<AddNote
associateNote={jest.fn()}
getNewNoteId={jest.fn()}
newNote={note}
onCancelAddNote={jest.fn()}
updateNewNote={jest.fn()}
updateNote={jest.fn()}
/>
);
expect(wrapper.find('[data-test-subj="add-a-note"]').first().text()).toEqual(note); expect(wrapper.find('[data-test-subj="add-a-note"]').first().text()).toEqual(note);
}); });
test('it invokes associateNote when the Add Note button is clicked', () => { test('it invokes associateNote when the Add Note button is clicked', () => {
const associateNote = jest.fn(); const associateNote = jest.fn();
const testProps = {
const wrapper = mount( ...props,
<AddNote newNote: note,
associateNote={associateNote} associateNote,
getNewNoteId={jest.fn()} };
newNote={note} const wrapper = mount(<AddNote {...testProps} />);
onCancelAddNote={jest.fn()}
updateNewNote={jest.fn()}
updateNote={jest.fn()}
/>
);
wrapper.find('[data-test-subj="add-note"]').first().simulate('click'); wrapper.find('[data-test-subj="add-note"]').first().simulate('click');
@ -129,17 +93,12 @@ describe('AddNote', () => {
test('it invokes getNewNoteId when the Add Note button is clicked', () => { test('it invokes getNewNoteId when the Add Note button is clicked', () => {
const getNewNoteId = jest.fn(); const getNewNoteId = jest.fn();
const testProps = {
...props,
getNewNoteId,
};
const wrapper = mount( const wrapper = mount(<AddNote {...testProps} />);
<AddNote
associateNote={jest.fn()}
getNewNoteId={getNewNoteId}
newNote={note}
onCancelAddNote={jest.fn()}
updateNewNote={jest.fn()}
updateNote={jest.fn()}
/>
);
wrapper.find('[data-test-subj="add-note"]').first().simulate('click'); wrapper.find('[data-test-subj="add-note"]').first().simulate('click');
@ -148,17 +107,12 @@ describe('AddNote', () => {
test('it invokes updateNewNote when the Add Note button is clicked', () => { test('it invokes updateNewNote when the Add Note button is clicked', () => {
const updateNewNote = jest.fn(); const updateNewNote = jest.fn();
const testProps = {
...props,
updateNewNote,
};
const wrapper = mount( const wrapper = mount(<AddNote {...testProps} />);
<AddNote
associateNote={jest.fn()}
getNewNoteId={jest.fn()}
newNote={note}
onCancelAddNote={jest.fn()}
updateNewNote={updateNewNote}
updateNote={jest.fn()}
/>
);
wrapper.find('[data-test-subj="add-note"]').first().simulate('click'); wrapper.find('[data-test-subj="add-note"]').first().simulate('click');
@ -167,17 +121,11 @@ describe('AddNote', () => {
test('it invokes updateNote when the Add Note button is clicked', () => { test('it invokes updateNote when the Add Note button is clicked', () => {
const updateNote = jest.fn(); const updateNote = jest.fn();
const testProps = {
const wrapper = mount( ...props,
<AddNote updateNote,
associateNote={jest.fn()} };
getNewNoteId={jest.fn()} const wrapper = mount(<AddNote {...testProps} />);
newNote={note}
onCancelAddNote={jest.fn()}
updateNewNote={jest.fn()}
updateNote={updateNote}
/>
);
wrapper.find('[data-test-subj="add-note"]').first().simulate('click'); wrapper.find('[data-test-subj="add-note"]').first().simulate('click');
@ -185,16 +133,11 @@ describe('AddNote', () => {
}); });
test('it does NOT display the markdown formatting hint when a note has NOT been entered', () => { test('it does NOT display the markdown formatting hint when a note has NOT been entered', () => {
const wrapper = mount( const testProps = {
<AddNote ...props,
associateNote={jest.fn()} newNote: '',
getNewNoteId={jest.fn()} };
newNote={''} const wrapper = mount(<AddNote {...testProps} />);
onCancelAddNote={jest.fn()}
updateNewNote={jest.fn()}
updateNote={jest.fn()}
/>
);
expect(wrapper.find('[data-test-subj="markdown-hint"]').first()).toHaveStyleRule( expect(wrapper.find('[data-test-subj="markdown-hint"]').first()).toHaveStyleRule(
'visibility', 'visibility',
@ -203,16 +146,11 @@ describe('AddNote', () => {
}); });
test('it displays the markdown formatting hint when a note has been entered', () => { test('it displays the markdown formatting hint when a note has been entered', () => {
const wrapper = mount( const testProps = {
<AddNote ...props,
associateNote={jest.fn()} newNote: 'We should see a formatting hint now',
getNewNoteId={jest.fn()} };
newNote={'We should see a formatting hint now'} const wrapper = mount(<AddNote {...testProps} />);
onCancelAddNote={jest.fn()}
updateNewNote={jest.fn()}
updateNote={jest.fn()}
/>
);
expect(wrapper.find('[data-test-subj="markdown-hint"]').first()).toHaveStyleRule( expect(wrapper.find('[data-test-subj="markdown-hint"]').first()).toHaveStyleRule(
'visibility', 'visibility',

View file

@ -61,7 +61,6 @@ export const AddNote = React.memo<{
}), }),
[associateNote, getNewNoteId, newNote, updateNewNote, updateNote] [associateNote, getNewNoteId, newNote, updateNewNote, updateNote]
); );
return ( return (
<AddNotesContainer alignItems="flexEnd" direction="column" gutterSize="none"> <AddNotesContainer alignItems="flexEnd" direction="column" gutterSize="none">
<NewNote note={newNote} noteInputHeight={200} updateNewNote={updateNewNote} /> <NewNote note={newNote} noteInputHeight={200} updateNewNote={updateNewNote} />

View file

@ -21,12 +21,14 @@ import { AddNote } from './add_note';
import { columns } from './columns'; import { columns } from './columns';
import { AssociateNote, GetNewNoteId, NotesCount, search, UpdateNote } from './helpers'; import { AssociateNote, GetNewNoteId, NotesCount, search, UpdateNote } from './helpers';
import { NOTES_PANEL_WIDTH, NOTES_PANEL_HEIGHT } from '../timeline/properties/notes_size'; import { NOTES_PANEL_WIDTH, NOTES_PANEL_HEIGHT } from '../timeline/properties/notes_size';
import { TimelineStatusLiteral, TimelineStatus } from '../../../../common/types/timeline';
interface Props { interface Props {
associateNote: AssociateNote; associateNote: AssociateNote;
getNotesByIds: (noteIds: string[]) => Note[]; getNotesByIds: (noteIds: string[]) => Note[];
getNewNoteId: GetNewNoteId; getNewNoteId: GetNewNoteId;
noteIds: string[]; noteIds: string[];
status: TimelineStatusLiteral;
updateNote: UpdateNote; updateNote: UpdateNote;
} }
@ -53,8 +55,9 @@ InMemoryTable.displayName = 'InMemoryTable';
/** A view for entering and reviewing notes */ /** A view for entering and reviewing notes */
export const Notes = React.memo<Props>( export const Notes = React.memo<Props>(
({ associateNote, getNotesByIds, getNewNoteId, noteIds, updateNote }) => { ({ associateNote, getNotesByIds, getNewNoteId, noteIds, status, updateNote }) => {
const [newNote, setNewNote] = useState(''); const [newNote, setNewNote] = useState('');
const isImmutable = status === TimelineStatus.immutable;
return ( return (
<NotesPanel> <NotesPanel>
@ -63,13 +66,15 @@ export const Notes = React.memo<Props>(
</EuiModalHeader> </EuiModalHeader>
<EuiModalBody> <EuiModalBody>
<AddNote {!isImmutable && (
associateNote={associateNote} <AddNote
getNewNoteId={getNewNoteId} associateNote={associateNote}
newNote={newNote} getNewNoteId={getNewNoteId}
updateNewNote={setNewNote} newNote={newNote}
updateNote={updateNote} updateNewNote={setNewNote}
/> updateNote={updateNote}
/>
)}
<EuiSpacer size="s" /> <EuiSpacer size="s" />
<InMemoryTable <InMemoryTable
data-test-subj="notes-table" data-test-subj="notes-table"

View file

@ -12,6 +12,7 @@ import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
import { Note } from '../../../../common/lib/note'; import { Note } from '../../../../common/lib/note';
import { NoteCards } from '.'; import { NoteCards } from '.';
import { TimelineStatus } from '../../../../../common/types/timeline';
describe('NoteCards', () => { describe('NoteCards', () => {
const noteIds = ['abc', 'def']; const noteIds = ['abc', 'def'];
@ -38,18 +39,21 @@ describe('NoteCards', () => {
}, },
]; ];
const props = {
associateNote: jest.fn(),
getNotesByIds,
getNewNoteId: jest.fn(),
noteIds,
showAddNote: true,
status: TimelineStatus.active,
toggleShowAddNote: jest.fn(),
updateNote: jest.fn(),
};
test('it renders the notes column when noteIds are specified', () => { test('it renders the notes column when noteIds are specified', () => {
const wrapper = mountWithIntl( const wrapper = mountWithIntl(
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<NoteCards <NoteCards {...props} />
associateNote={jest.fn()}
getNotesByIds={getNotesByIds}
getNewNoteId={jest.fn()}
noteIds={noteIds}
showAddNote={true}
toggleShowAddNote={jest.fn()}
updateNote={jest.fn()}
/>
</ThemeProvider> </ThemeProvider>
); );
@ -57,17 +61,10 @@ describe('NoteCards', () => {
}); });
test('it does NOT render the notes column when noteIds are NOT specified', () => { test('it does NOT render the notes column when noteIds are NOT specified', () => {
const testProps = { ...props, noteIds: [] };
const wrapper = mountWithIntl( const wrapper = mountWithIntl(
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<NoteCards <NoteCards {...testProps} />
associateNote={jest.fn()}
getNotesByIds={getNotesByIds}
getNewNoteId={jest.fn()}
noteIds={[]}
showAddNote={true}
toggleShowAddNote={jest.fn()}
updateNote={jest.fn()}
/>
</ThemeProvider> </ThemeProvider>
); );
@ -77,15 +74,7 @@ describe('NoteCards', () => {
test('renders note cards', () => { test('renders note cards', () => {
const wrapper = mountWithIntl( const wrapper = mountWithIntl(
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<NoteCards <NoteCards {...props} />
associateNote={jest.fn()}
getNotesByIds={getNotesByIds}
getNewNoteId={jest.fn()}
noteIds={noteIds}
showAddNote={true}
toggleShowAddNote={jest.fn()}
updateNote={jest.fn()}
/>
</ThemeProvider> </ThemeProvider>
); );
@ -102,15 +91,7 @@ describe('NoteCards', () => {
test('it shows controls for adding notes when showAddNote is true', () => { test('it shows controls for adding notes when showAddNote is true', () => {
const wrapper = mountWithIntl( const wrapper = mountWithIntl(
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<NoteCards <NoteCards {...props} />
associateNote={jest.fn()}
getNotesByIds={getNotesByIds}
getNewNoteId={jest.fn()}
noteIds={noteIds}
showAddNote={true}
toggleShowAddNote={jest.fn()}
updateNote={jest.fn()}
/>
</ThemeProvider> </ThemeProvider>
); );
@ -118,17 +99,11 @@ describe('NoteCards', () => {
}); });
test('it does NOT show controls for adding notes when showAddNote is false', () => { test('it does NOT show controls for adding notes when showAddNote is false', () => {
const testProps = { ...props, showAddNote: false };
const wrapper = mountWithIntl( const wrapper = mountWithIntl(
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<NoteCards <NoteCards {...testProps} />
associateNote={jest.fn()}
getNotesByIds={getNotesByIds}
getNewNoteId={jest.fn()}
noteIds={noteIds}
showAddNote={false}
toggleShowAddNote={jest.fn()}
updateNote={jest.fn()}
/>
</ThemeProvider> </ThemeProvider>
); );

View file

@ -12,6 +12,7 @@ import { Note } from '../../../../common/lib/note';
import { AddNote } from '../add_note'; import { AddNote } from '../add_note';
import { AssociateNote, GetNewNoteId, UpdateNote } from '../helpers'; import { AssociateNote, GetNewNoteId, UpdateNote } from '../helpers';
import { NoteCard } from '../note_card'; import { NoteCard } from '../note_card';
import { TimelineStatusLiteral } from '../../../../../common/types/timeline';
const AddNoteContainer = styled.div``; const AddNoteContainer = styled.div``;
AddNoteContainer.displayName = 'AddNoteContainer'; AddNoteContainer.displayName = 'AddNoteContainer';
@ -49,6 +50,7 @@ interface Props {
getNewNoteId: GetNewNoteId; getNewNoteId: GetNewNoteId;
noteIds: string[]; noteIds: string[];
showAddNote: boolean; showAddNote: boolean;
status: TimelineStatusLiteral;
toggleShowAddNote: () => void; toggleShowAddNote: () => void;
updateNote: UpdateNote; updateNote: UpdateNote;
} }
@ -61,6 +63,7 @@ export const NoteCards = React.memo<Props>(
getNewNoteId, getNewNoteId,
noteIds, noteIds,
showAddNote, showAddNote,
status,
toggleShowAddNote, toggleShowAddNote,
updateNote, updateNote,
}) => { }) => {

View file

@ -6,7 +6,9 @@
import { EuiContextMenuPanel, EuiContextMenuItem, EuiBasicTable } from '@elastic/eui'; import { EuiContextMenuPanel, EuiContextMenuItem, EuiBasicTable } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { isEmpty } from 'lodash/fp';
import { TimelineStatus } from '../../../../common/types/timeline';
import * as i18n from './translations'; import * as i18n from './translations';
import { DeleteTimelines, OpenTimelineResult } from './types'; import { DeleteTimelines, OpenTimelineResult } from './types';
import { EditTimelineActions } from './export_timeline'; import { EditTimelineActions } from './export_timeline';
@ -63,7 +65,7 @@ export const useEditTimelineBatchActions = ({
const getBatchItemsPopoverContent = useCallback( const getBatchItemsPopoverContent = useCallback(
(closePopover: () => void) => { (closePopover: () => void) => {
const isDisabled = isEmpty(selectedItems); const disabled = selectedItems?.some((item) => item.status === TimelineStatus.immutable);
return ( return (
<> <>
<EditTimelineActions <EditTimelineActions
@ -82,7 +84,7 @@ export const useEditTimelineBatchActions = ({
<EuiContextMenuPanel <EuiContextMenuPanel
items={[ items={[
<EuiContextMenuItem <EuiContextMenuItem
disabled={isDisabled} disabled={disabled}
icon="exportAction" icon="exportAction"
key="ExportItemKey" key="ExportItemKey"
onClick={handleEnableExportTimelineDownloader} onClick={handleEnableExportTimelineDownloader}
@ -90,7 +92,7 @@ export const useEditTimelineBatchActions = ({
{i18n.EXPORT_SELECTED} {i18n.EXPORT_SELECTED}
</EuiContextMenuItem>, </EuiContextMenuItem>,
<EuiContextMenuItem <EuiContextMenuItem
disabled={isDisabled} disabled={disabled}
icon="trash" icon="trash"
key="DeleteItemKey" key="DeleteItemKey"
onClick={handleOnOpenDeleteTimelineModal} onClick={handleOnOpenDeleteTimelineModal}
@ -102,6 +104,7 @@ export const useEditTimelineBatchActions = ({
</> </>
); );
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps
[ [
deleteTimelines, deleteTimelines,
isEnableDownloader, isEnableDownloader,

View file

@ -8,7 +8,6 @@ import React from 'react';
import { TimelineDownloader } from './export_timeline'; import { TimelineDownloader } from './export_timeline';
import { mockSelectedTimeline } from './mocks'; import { mockSelectedTimeline } from './mocks';
import { ReactWrapper, mount } from 'enzyme'; import { ReactWrapper, mount } from 'enzyme';
import { useExportTimeline } from '.';
jest.mock('../translations', () => { jest.mock('../translations', () => {
return { return {
@ -32,19 +31,6 @@ describe('TimelineDownloader', () => {
onComplete: jest.fn(), onComplete: jest.fn(),
}; };
describe('should not render a downloader', () => { describe('should not render a downloader', () => {
beforeAll(() => {
((useExportTimeline as unknown) as jest.Mock).mockReturnValue({
enableDownloader: false,
setEnableDownloader: jest.fn(),
exportedIds: {},
getExportedData: jest.fn(),
});
});
afterAll(() => {
((useExportTimeline as unknown) as jest.Mock).mockReset();
});
test('Without exportedIds', () => { test('Without exportedIds', () => {
const testProps = { const testProps = {
...defaultTestProps, ...defaultTestProps,
@ -65,19 +51,6 @@ describe('TimelineDownloader', () => {
}); });
describe('should render a downloader', () => { describe('should render a downloader', () => {
beforeAll(() => {
((useExportTimeline as unknown) as jest.Mock).mockReturnValue({
enableDownloader: false,
setEnableDownloader: jest.fn(),
exportedIds: {},
getExportedData: jest.fn(),
});
});
afterAll(() => {
((useExportTimeline as unknown) as jest.Mock).mockReset();
});
test('With selectedItems and exportedIds is given and isEnableDownloader is true', () => { test('With selectedItems and exportedIds is given and isEnableDownloader is true', () => {
const testProps = { const testProps = {
...defaultTestProps, ...defaultTestProps,

View file

@ -5,31 +5,41 @@
*/ */
import React from 'react'; import React from 'react';
import { mount } from 'enzyme'; import { shallow } from 'enzyme';
import { useExportTimeline, ExportTimeline } from '.'; import { EditTimelineActionsComponent } from '.';
describe('useExportTimeline', () => { describe('EditTimelineActionsComponent', () => {
describe('call with selected timelines', () => { describe('render', () => {
let exportTimelineRes: ExportTimeline; const props = {
const TestHook = () => { deleteTimelines: jest.fn(),
exportTimelineRes = useExportTimeline(); ids: ['id1'],
return <div />; isEnableDownloader: false,
isDeleteTimelineModalOpen: false,
onComplete: jest.fn(),
title: 'mockTitle',
}; };
beforeAll(() => { test('should render timelineDownloader', () => {
mount(<TestHook />); const wrapper = shallow(<EditTimelineActionsComponent {...props} />);
expect(wrapper.find('[data-test-subj="TimelineDownloader"]').exists()).toBeTruthy();
}); });
test('Downloader should be disabled by default', () => { test('Should render DeleteTimelineModalOverlay if deleteTimelines is given', () => {
expect(exportTimelineRes.isEnableDownloader).toBeFalsy(); const wrapper = shallow(<EditTimelineActionsComponent {...props} />);
expect(wrapper.find('[data-test-subj="DeleteTimelineModalOverlay"]').exists()).toBeTruthy();
}); });
test('Should include disableExportTimelineDownloader in return value', () => { test('Should not render DeleteTimelineModalOverlay if deleteTimelines is not given', () => {
expect(exportTimelineRes).toHaveProperty('disableExportTimelineDownloader'); const newProps = {
}); ...props,
deleteTimelines: undefined,
test('Should include enableExportTimelineDownloader in return value', () => { };
expect(exportTimelineRes).toHaveProperty('enableExportTimelineDownloader'); const wrapper = shallow(<EditTimelineActionsComponent {...newProps} />);
expect(
wrapper.find('[data-test-subj="DeleteTimelineModalOverlay"]').exists()
).not.toBeTruthy();
}); });
}); });
}); });

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import React, { useState, useCallback } from 'react'; import React from 'react';
import { DeleteTimelines } from '../types'; import { DeleteTimelines } from '../types';
import { TimelineDownloader } from './export_timeline'; import { TimelineDownloader } from './export_timeline';
@ -17,25 +17,7 @@ export interface ExportTimeline {
isEnableDownloader: boolean; isEnableDownloader: boolean;
} }
export const useExportTimeline = (): ExportTimeline => { export const EditTimelineActionsComponent: React.FC<{
const [isEnableDownloader, setIsEnableDownloader] = useState(false);
const enableExportTimelineDownloader = useCallback(() => {
setIsEnableDownloader(true);
}, []);
const disableExportTimelineDownloader = useCallback(() => {
setIsEnableDownloader(false);
}, []);
return {
disableExportTimelineDownloader,
enableExportTimelineDownloader,
isEnableDownloader,
};
};
const EditTimelineActionsComponent: React.FC<{
deleteTimelines: DeleteTimelines | undefined; deleteTimelines: DeleteTimelines | undefined;
ids: string[]; ids: string[];
isEnableDownloader: boolean; isEnableDownloader: boolean;
@ -52,6 +34,7 @@ const EditTimelineActionsComponent: React.FC<{
}) => ( }) => (
<> <>
<TimelineDownloader <TimelineDownloader
data-test-subj="TimelineDownloader"
exportedIds={ids} exportedIds={ids}
getExportedData={exportSelectedTimeline} getExportedData={exportSelectedTimeline}
isEnableDownloader={isEnableDownloader} isEnableDownloader={isEnableDownloader}
@ -59,6 +42,7 @@ const EditTimelineActionsComponent: React.FC<{
/> />
{deleteTimelines != null && ( {deleteTimelines != null && (
<DeleteTimelineModalOverlay <DeleteTimelineModalOverlay
data-test-subj="DeleteTimelineModalOverlay"
deleteTimelines={deleteTimelines} deleteTimelines={deleteTimelines}
isModalOpen={isDeleteTimelineModalOpen} isModalOpen={isDeleteTimelineModalOpen}
onComplete={onComplete} onComplete={onComplete}

View file

@ -10,7 +10,17 @@ import { Action } from 'typescript-fsa';
import uuid from 'uuid'; import uuid from 'uuid';
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
import { oneTimelineQuery } from '../../containers/one/index.gql_query'; import { oneTimelineQuery } from '../../containers/one/index.gql_query';
import { TimelineResult, GetOneTimeline, NoteResult } from '../../../graphql/types'; import {
TimelineResult,
GetOneTimeline,
NoteResult,
FilterTimelineResult,
ColumnHeaderResult,
PinnedEvent,
} from '../../../graphql/types';
import { TimelineStatus, TimelineType } from '../../../../common/types/timeline';
import { import {
addNotes as dispatchAddNotes, addNotes as dispatchAddNotes,
updateNote as dispatchUpdateNote, updateNote as dispatchUpdateNote,
@ -22,9 +32,9 @@ import {
addTimeline as dispatchAddTimeline, addTimeline as dispatchAddTimeline,
addNote as dispatchAddGlobalTimelineNote, addNote as dispatchAddGlobalTimelineNote,
} from '../../../timelines/store/timeline/actions'; } from '../../../timelines/store/timeline/actions';
import { ColumnHeaderOptions, TimelineModel } from '../../../timelines/store/timeline/model'; import { ColumnHeaderOptions, TimelineModel } from '../../../timelines/store/timeline/model';
import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults';
import { import {
defaultColumnHeaderType, defaultColumnHeaderType,
defaultHeaders, defaultHeaders,
@ -77,103 +87,115 @@ const parseString = (params: string) => {
} }
}; };
const setTimelineColumn = (col: ColumnHeaderResult) => {
const timelineCols: ColumnHeaderOptions = {
...col,
columnHeaderType: defaultColumnHeaderType,
id: col.id != null ? col.id : 'unknown',
placeholder: col.placeholder != null ? col.placeholder : undefined,
category: col.category != null ? col.category : undefined,
description: col.description != null ? col.description : undefined,
example: col.example != null ? col.example : undefined,
type: col.type != null ? col.type : undefined,
aggregatable: col.aggregatable != null ? col.aggregatable : undefined,
width: col.id === '@timestamp' ? DEFAULT_DATE_COLUMN_MIN_WIDTH : DEFAULT_COLUMN_MIN_WIDTH,
};
return timelineCols;
};
const setTimelineFilters = (filter: FilterTimelineResult) => ({
$state: {
store: 'appState',
},
meta: {
...filter.meta,
...(filter.meta && filter.meta.field != null ? { params: parseString(filter.meta.field) } : {}),
...(filter.meta && filter.meta.params != null
? { params: parseString(filter.meta.params) }
: {}),
...(filter.meta && filter.meta.value != null ? { value: parseString(filter.meta.value) } : {}),
},
...(filter.exists != null ? { exists: parseString(filter.exists) } : {}),
...(filter.match_all != null ? { exists: parseString(filter.match_all) } : {}),
...(filter.missing != null ? { exists: parseString(filter.missing) } : {}),
...(filter.query != null ? { query: parseString(filter.query) } : {}),
...(filter.range != null ? { range: parseString(filter.range) } : {}),
...(filter.script != null ? { exists: parseString(filter.script) } : {}),
});
const setEventIdToNoteIds = (
duplicate: boolean,
eventIdToNoteIds: NoteResult[] | null | undefined
) =>
duplicate
? {}
: eventIdToNoteIds != null
? eventIdToNoteIds.reduce((acc, note) => {
if (note.eventId != null) {
const eventNotes = getOr([], note.eventId, acc);
return { ...acc, [note.eventId]: [...eventNotes, note.noteId] };
}
return acc;
}, {})
: {};
const setPinnedEventsSaveObject = (
duplicate: boolean,
pinnedEventsSaveObject: PinnedEvent[] | null | undefined
) =>
duplicate
? {}
: pinnedEventsSaveObject != null
? pinnedEventsSaveObject.reduce(
(acc, pinnedEvent) => ({
...acc,
...(pinnedEvent.eventId != null ? { [pinnedEvent.eventId]: pinnedEvent } : {}),
}),
{}
)
: {};
const setPinnedEventIds = (duplicate: boolean, pinnedEventIds: string[] | null | undefined) =>
duplicate
? {}
: pinnedEventIds != null
? pinnedEventIds.reduce((acc, pinnedEventId) => ({ ...acc, [pinnedEventId]: true }), {})
: {};
// eslint-disable-next-line complexity
export const defaultTimelineToTimelineModel = ( export const defaultTimelineToTimelineModel = (
timeline: TimelineResult, timeline: TimelineResult,
duplicate: boolean duplicate: boolean
): TimelineModel => { ): TimelineModel => {
return Object.entries({ const isTemplate = timeline.timelineType === TimelineType.template;
const timelineEntries = {
...timeline, ...timeline,
columns: columns: timeline.columns != null ? timeline.columns.map(setTimelineColumn) : defaultHeaders,
timeline.columns != null eventIdToNoteIds: setEventIdToNoteIds(duplicate, timeline.eventIdToNoteIds),
? timeline.columns.map((col) => { filters: timeline.filters != null ? timeline.filters.map(setTimelineFilters) : [],
const timelineCols: ColumnHeaderOptions = {
...col,
columnHeaderType: defaultColumnHeaderType,
id: col.id != null ? col.id : 'unknown',
placeholder: col.placeholder != null ? col.placeholder : undefined,
category: col.category != null ? col.category : undefined,
description: col.description != null ? col.description : undefined,
example: col.example != null ? col.example : undefined,
type: col.type != null ? col.type : undefined,
aggregatable: col.aggregatable != null ? col.aggregatable : undefined,
width:
col.id === '@timestamp' ? DEFAULT_DATE_COLUMN_MIN_WIDTH : DEFAULT_COLUMN_MIN_WIDTH,
};
return timelineCols;
})
: defaultHeaders,
eventIdToNoteIds: duplicate
? {}
: timeline.eventIdToNoteIds != null
? timeline.eventIdToNoteIds.reduce((acc, note) => {
if (note.eventId != null) {
const eventNotes = getOr([], note.eventId, acc);
return { ...acc, [note.eventId]: [...eventNotes, note.noteId] };
}
return acc;
}, {})
: {},
filters:
timeline.filters != null
? timeline.filters.map((filter) => ({
$state: {
store: 'appState',
},
meta: {
...filter.meta,
...(filter.meta && filter.meta.field != null
? { params: parseString(filter.meta.field) }
: {}),
...(filter.meta && filter.meta.params != null
? { params: parseString(filter.meta.params) }
: {}),
...(filter.meta && filter.meta.value != null
? { value: parseString(filter.meta.value) }
: {}),
},
...(filter.exists != null ? { exists: parseString(filter.exists) } : {}),
...(filter.match_all != null ? { exists: parseString(filter.match_all) } : {}),
...(filter.missing != null ? { exists: parseString(filter.missing) } : {}),
...(filter.query != null ? { query: parseString(filter.query) } : {}),
...(filter.range != null ? { range: parseString(filter.range) } : {}),
...(filter.script != null ? { exists: parseString(filter.script) } : {}),
}))
: [],
isFavorite: duplicate isFavorite: duplicate
? false ? false
: timeline.favorite != null : timeline.favorite != null
? timeline.favorite.length > 0 ? timeline.favorite.length > 0
: false, : false,
noteIds: duplicate ? [] : timeline.noteIds != null ? timeline.noteIds : [], noteIds: duplicate ? [] : timeline.noteIds != null ? timeline.noteIds : [],
pinnedEventIds: duplicate pinnedEventIds: setPinnedEventIds(duplicate, timeline.pinnedEventIds),
? {} pinnedEventsSaveObject: setPinnedEventsSaveObject(duplicate, timeline.pinnedEventsSaveObject),
: timeline.pinnedEventIds != null
? timeline.pinnedEventIds.reduce(
(acc, pinnedEventId) => ({ ...acc, [pinnedEventId]: true }),
{}
)
: {},
pinnedEventsSaveObject: duplicate
? {}
: timeline.pinnedEventsSaveObject != null
? timeline.pinnedEventsSaveObject.reduce(
(acc, pinnedEvent) => ({
...acc,
...(pinnedEvent.eventId != null ? { [pinnedEvent.eventId]: pinnedEvent } : {}),
}),
{}
)
: {},
id: duplicate ? '' : timeline.savedObjectId, id: duplicate ? '' : timeline.savedObjectId,
status: duplicate ? TimelineStatus.active : timeline.status,
savedObjectId: duplicate ? null : timeline.savedObjectId, savedObjectId: duplicate ? null : timeline.savedObjectId,
version: duplicate ? null : timeline.version, version: duplicate ? null : timeline.version,
title: duplicate ? '' : timeline.title || '', title: duplicate ? `${timeline.title} - Duplicate` : timeline.title || '',
templateTimelineId: duplicate ? null : timeline.templateTimelineId, templateTimelineId: duplicate && isTemplate ? uuid.v4() : timeline.templateTimelineId,
templateTimelineVersion: duplicate ? null : timeline.templateTimelineVersion, templateTimelineVersion: duplicate && isTemplate ? 1 : timeline.templateTimelineVersion,
}).reduce((acc: TimelineModel, [key, value]) => (value != null ? set(key, value, acc) : acc), { };
...timelineDefaults, return Object.entries(timelineEntries).reduce(
id: '', (acc: TimelineModel, [key, value]) => (value != null ? set(key, value, acc) : acc),
}); {
...timelineDefaults,
id: '',
}
);
}; };
export const formatTimelineResultToModel = ( export const formatTimelineResultToModel = (

View file

@ -9,9 +9,9 @@ import React, { useEffect, useState, useCallback } from 'react';
import { connect, ConnectedProps } from 'react-redux'; import { connect, ConnectedProps } from 'react-redux';
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
import { defaultHeaders } from '../timeline/body/column_headers/default_headers';
import { deleteTimelineMutation } from '../../containers/delete/persist.gql_query'; import { disableTemplate } from '../../../../common/constants';
import { useGetAllTimeline } from '../../containers/all';
import { DeleteTimelineMutation, SortFieldTimeline, Direction } from '../../../graphql/types'; import { DeleteTimelineMutation, SortFieldTimeline, Direction } from '../../../graphql/types';
import { State } from '../../../common/store'; import { State } from '../../../common/store';
import { ColumnHeaderOptions, TimelineModel } from '../../../timelines/store/timeline/model'; import { ColumnHeaderOptions, TimelineModel } from '../../../timelines/store/timeline/model';
@ -21,6 +21,12 @@ import {
createTimeline as dispatchCreateNewTimeline, createTimeline as dispatchCreateNewTimeline,
updateIsLoading as dispatchUpdateIsLoading, updateIsLoading as dispatchUpdateIsLoading,
} from '../../../timelines/store/timeline/actions'; } from '../../../timelines/store/timeline/actions';
import { deleteTimelineMutation } from '../../containers/delete/persist.gql_query';
import { useGetAllTimeline } from '../../containers/all';
import { defaultHeaders } from '../timeline/body/column_headers/default_headers';
import { OpenTimeline } from './open_timeline'; import { OpenTimeline } from './open_timeline';
import { OPEN_TIMELINE_CLASS_NAME, queryTimelineById, dispatchUpdateTimeline } from './helpers'; import { OPEN_TIMELINE_CLASS_NAME, queryTimelineById, dispatchUpdateTimeline } from './helpers';
import { OpenTimelineModalBody } from './open_timeline_modal/open_timeline_modal_body'; import { OpenTimelineModalBody } from './open_timeline_modal/open_timeline_modal_body';
@ -42,7 +48,7 @@ import {
} from './types'; } from './types';
import { DEFAULT_SORT_FIELD, DEFAULT_SORT_DIRECTION } from './constants'; import { DEFAULT_SORT_FIELD, DEFAULT_SORT_DIRECTION } from './constants';
import { useTimelineTypes } from './use_timeline_types'; import { useTimelineTypes } from './use_timeline_types';
import { disableTemplate } from '../../../../common/constants'; import { useTimelineStatus } from './use_timeline_status';
interface OwnProps<TCache = object> { interface OwnProps<TCache = object> {
apolloClient: ApolloClient<TCache>; apolloClient: ApolloClient<TCache>;
@ -106,28 +112,54 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>(
/** The requested field to sort on */ /** The requested field to sort on */
const [sortField, setSortField] = useState(DEFAULT_SORT_FIELD); const [sortField, setSortField] = useState(DEFAULT_SORT_FIELD);
const { timelineType, timelineTabs, timelineFilters } = useTimelineTypes(); const {
const { fetchAllTimeline, timelines, loading, totalCount } = useGetAllTimeline(); customTemplateTimelineCount,
defaultTimelineCount,
const refetch = useCallback( elasticTemplateTimelineCount,
() => favoriteCount,
fetchAllTimeline({ fetchAllTimeline,
pageInfo: { timelines,
pageIndex: pageIndex + 1, loading,
pageSize, totalCount,
}, templateTimelineCount,
search, } = useGetAllTimeline();
sort: { const { timelineType, timelineTabs, timelineFilters } = useTimelineTypes({
sortField: sortField as SortFieldTimeline, defaultTimelineCount,
sortOrder: sortDirection as Direction, templateTimelineCount,
}, });
onlyUserFavorite: onlyFavorites, const { timelineStatus, templateTimelineType, templateTimelineFilter } = useTimelineStatus({
timelineType, timelineType,
}), customTemplateTimelineCount,
elasticTemplateTimelineCount,
// eslint-disable-next-line react-hooks/exhaustive-deps });
[pageIndex, pageSize, search, sortField, sortDirection, timelineType, onlyFavorites] const refetch = useCallback(() => {
); fetchAllTimeline({
pageInfo: {
pageIndex: pageIndex + 1,
pageSize,
},
search,
sort: {
sortField: sortField as SortFieldTimeline,
sortOrder: sortDirection as Direction,
},
onlyUserFavorite: onlyFavorites,
timelineType,
templateTimelineType,
status: timelineStatus,
});
}, [
fetchAllTimeline,
pageIndex,
pageSize,
search,
sortField,
sortDirection,
timelineType,
timelineStatus,
templateTimelineType,
onlyFavorites,
]);
/** Invoked when the user presses enters to submit the text in the search input */ /** Invoked when the user presses enters to submit the text in the search input */
const onQueryChange: OnQueryChange = useCallback((query: EuiSearchBarQuery) => { const onQueryChange: OnQueryChange = useCallback((query: EuiSearchBarQuery) => {
@ -264,6 +296,7 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>(
data-test-subj={'open-timeline'} data-test-subj={'open-timeline'}
deleteTimelines={onDeleteOneTimeline} deleteTimelines={onDeleteOneTimeline}
defaultPageSize={defaultPageSize} defaultPageSize={defaultPageSize}
favoriteCount={favoriteCount}
isLoading={loading} isLoading={loading}
itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap} itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap}
importDataModalToggle={importDataModalToggle} importDataModalToggle={importDataModalToggle}
@ -285,7 +318,9 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>(
selectedItems={selectedItems} selectedItems={selectedItems}
sortDirection={sortDirection} sortDirection={sortDirection}
sortField={sortField} sortField={sortField}
tabs={!disableTemplate ? timelineTabs : undefined} templateTimelineFilter={!disableTemplate ? templateTimelineFilter : null}
timelineType={timelineType}
timelineFilter={!disableTemplate ? timelineTabs : null}
title={title} title={title}
totalSearchResultsCount={totalCount} totalSearchResultsCount={totalCount}
/> />
@ -294,6 +329,7 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>(
data-test-subj={'open-timeline-modal'} data-test-subj={'open-timeline-modal'}
deleteTimelines={onDeleteOneTimeline} deleteTimelines={onDeleteOneTimeline}
defaultPageSize={defaultPageSize} defaultPageSize={defaultPageSize}
favoriteCount={favoriteCount}
hideActions={hideActions} hideActions={hideActions}
isLoading={loading} isLoading={loading}
itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap} itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap}
@ -312,7 +348,9 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>(
selectedItems={selectedItems} selectedItems={selectedItems}
sortDirection={sortDirection} sortDirection={sortDirection}
sortField={sortField} sortField={sortField}
tabs={!disableTemplate ? timelineFilters : undefined} templateTimelineFilter={!disableTemplate ? templateTimelineFilter : null}
timelineType={timelineType}
timelineFilter={!disableTemplate ? timelineFilters : null}
title={title} title={title}
totalSearchResultsCount={totalCount} totalSearchResultsCount={totalCount}
/> />

View file

@ -16,6 +16,7 @@ import { TimelinesTableProps } from './timelines_table';
import { mockTimelineResults } from '../../../common/mock/timeline_results'; import { mockTimelineResults } from '../../../common/mock/timeline_results';
import { OpenTimeline } from './open_timeline'; import { OpenTimeline } from './open_timeline';
import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from './constants'; import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from './constants';
import { TimelineType } from '../../../../common/types/timeline';
jest.mock('../../../common/lib/kibana'); jest.mock('../../../common/lib/kibana');
@ -46,8 +47,9 @@ describe('OpenTimeline', () => {
selectedItems: [], selectedItems: [],
sortDirection: DEFAULT_SORT_DIRECTION, sortDirection: DEFAULT_SORT_DIRECTION,
sortField: DEFAULT_SORT_FIELD, sortField: DEFAULT_SORT_FIELD,
tabs: <div />,
title, title,
timelineType: TimelineType.default,
templateTimelineFilter: [<div />],
totalSearchResultsCount: mockSearchResults.length, totalSearchResultsCount: mockSearchResults.length,
}); });

View file

@ -4,17 +4,11 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import { EuiPanel, EuiBasicTable } from '@elastic/eui'; import { EuiPanel, EuiBasicTable, EuiCallOut, EuiSpacer } from '@elastic/eui';
import React, { useCallback, useMemo, useRef } from 'react'; import React, { useCallback, useMemo, useRef } from 'react';
import { FormattedMessage } from '@kbn/i18n/react'; import { FormattedMessage } from '@kbn/i18n/react';
import { OPEN_TIMELINE_CLASS_NAME } from './helpers';
import { OpenTimelineProps, OpenTimelineResult, ActionTimelineToShow } from './types';
import { SearchRow } from './search_row';
import { TimelinesTable } from './timelines_table';
import { ImportDataModal } from '../../../common/components/import_data_modal';
import * as i18n from './translations';
import { importTimelines } from '../../containers/api';
import { ImportDataModal } from '../../../common/components/import_data_modal';
import { import {
UtilityBarGroup, UtilityBarGroup,
UtilityBarText, UtilityBarText,
@ -22,14 +16,23 @@ import {
UtilityBarSection, UtilityBarSection,
UtilityBarAction, UtilityBarAction,
} from '../../../common/components/utility_bar'; } from '../../../common/components/utility_bar';
import { importTimelines } from '../../containers/api';
import { useEditTimelineBatchActions } from './edit_timeline_batch_actions'; import { useEditTimelineBatchActions } from './edit_timeline_batch_actions';
import { useEditTimelineActions } from './edit_timeline_actions'; import { useEditTimelineActions } from './edit_timeline_actions';
import { EditOneTimelineAction } from './export_timeline'; import { EditOneTimelineAction } from './export_timeline';
import { SearchRow } from './search_row';
import { TimelinesTable } from './timelines_table';
import * as i18n from './translations';
import { OPEN_TIMELINE_CLASS_NAME } from './helpers';
import { OpenTimelineProps, OpenTimelineResult, ActionTimelineToShow } from './types';
export const OpenTimeline = React.memo<OpenTimelineProps>( export const OpenTimeline = React.memo<OpenTimelineProps>(
({ ({
deleteTimelines, deleteTimelines,
defaultPageSize, defaultPageSize,
favoriteCount,
isLoading, isLoading,
itemIdToExpandedNotesRowMap, itemIdToExpandedNotesRowMap,
importDataModalToggle, importDataModalToggle,
@ -51,11 +54,12 @@ export const OpenTimeline = React.memo<OpenTimelineProps>(
sortDirection, sortDirection,
setImportDataModalToggle, setImportDataModalToggle,
sortField, sortField,
tabs, timelineType,
timelineFilter,
templateTimelineFilter,
totalSearchResultsCount, totalSearchResultsCount,
}) => { }) => {
const tableRef = useRef<EuiBasicTable<OpenTimelineResult>>(); const tableRef = useRef<EuiBasicTable<OpenTimelineResult>>();
const { const {
actionItem, actionItem,
enableExportTimelineDownloader, enableExportTimelineDownloader,
@ -124,6 +128,8 @@ export const OpenTimeline = React.memo<OpenTimelineProps>(
[onDeleteSelected, deleteTimelines] [onDeleteSelected, deleteTimelines]
); );
const SearchRowContent = useMemo(() => <>{templateTimelineFilter}</>, [templateTimelineFilter]);
return ( return (
<> <>
<EditOneTimelineAction <EditOneTimelineAction
@ -151,15 +157,20 @@ export const OpenTimeline = React.memo<OpenTimelineProps>(
/> />
<EuiPanel className={OPEN_TIMELINE_CLASS_NAME}> <EuiPanel className={OPEN_TIMELINE_CLASS_NAME}>
{!!tabs && tabs} <EuiCallOut size="s" title={i18n.TEMPLATE_CALL_OUT_MESSAGE} />
<EuiSpacer size="m" />
{!!timelineFilter && timelineFilter}
<SearchRow <SearchRow
data-test-subj="search-row" data-test-subj="search-row"
favoriteCount={favoriteCount}
onlyFavorites={onlyFavorites} onlyFavorites={onlyFavorites}
onQueryChange={onQueryChange} onQueryChange={onQueryChange}
onToggleOnlyFavorites={onToggleOnlyFavorites} onToggleOnlyFavorites={onToggleOnlyFavorites}
query={query} query={query}
totalSearchResultsCount={totalSearchResultsCount} totalSearchResultsCount={totalSearchResultsCount}
/> >
{SearchRowContent}
</SearchRow>
<UtilityBar border> <UtilityBar border>
<UtilityBarSection> <UtilityBarSection>
@ -206,6 +217,7 @@ export const OpenTimeline = React.memo<OpenTimelineProps>(
showExtendedColumns={true} showExtendedColumns={true}
sortDirection={sortDirection} sortDirection={sortDirection}
sortField={sortField} sortField={sortField}
timelineType={timelineType}
tableRef={tableRef} tableRef={tableRef}
totalSearchResultsCount={totalSearchResultsCount} totalSearchResultsCount={totalSearchResultsCount}
/> />

View file

@ -16,6 +16,7 @@ import { TimelinesTableProps } from '../timelines_table';
import { mockTimelineResults } from '../../../../common/mock/timeline_results'; import { mockTimelineResults } from '../../../../common/mock/timeline_results';
import { OpenTimelineModalBody } from './open_timeline_modal_body'; import { OpenTimelineModalBody } from './open_timeline_modal_body';
import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants';
import { TimelineType } from '../../../../../common/types/timeline';
jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../common/lib/kibana');
@ -45,7 +46,8 @@ describe('OpenTimelineModal', () => {
selectedItems: [], selectedItems: [],
sortDirection: DEFAULT_SORT_DIRECTION, sortDirection: DEFAULT_SORT_DIRECTION,
sortField: DEFAULT_SORT_FIELD, sortField: DEFAULT_SORT_FIELD,
tabs: <div />, timelineType: TimelineType.default,
templateTimelineFilter: [<div />],
title, title,
totalSearchResultsCount: mockSearchResults.length, totalSearchResultsCount: mockSearchResults.length,
}); });

View file

@ -23,6 +23,7 @@ export const OpenTimelineModalBody = memo<OpenTimelineProps>(
({ ({
deleteTimelines, deleteTimelines,
defaultPageSize, defaultPageSize,
favoriteCount,
hideActions = [], hideActions = [],
isLoading, isLoading,
itemIdToExpandedNotesRowMap, itemIdToExpandedNotesRowMap,
@ -42,7 +43,9 @@ export const OpenTimelineModalBody = memo<OpenTimelineProps>(
selectedItems, selectedItems,
sortDirection, sortDirection,
sortField, sortField,
tabs, timelineFilter,
timelineType,
templateTimelineFilter,
title, title,
totalSearchResultsCount, totalSearchResultsCount,
}) => { }) => {
@ -54,6 +57,16 @@ export const OpenTimelineModalBody = memo<OpenTimelineProps>(
return actions.filter((action) => !hideActions.includes(action)); return actions.filter((action) => !hideActions.includes(action));
}, [onDeleteSelected, deleteTimelines, hideActions]); }, [onDeleteSelected, deleteTimelines, hideActions]);
const SearchRowContent = useMemo(
() => (
<>
{!!timelineFilter && timelineFilter}
{!!templateTimelineFilter && templateTimelineFilter}
</>
),
[timelineFilter, templateTimelineFilter]
);
return ( return (
<> <>
<EuiModalHeader> <EuiModalHeader>
@ -67,13 +80,15 @@ export const OpenTimelineModalBody = memo<OpenTimelineProps>(
<> <>
<SearchRow <SearchRow
data-test-subj="search-row" data-test-subj="search-row"
favoriteCount={favoriteCount}
onlyFavorites={onlyFavorites} onlyFavorites={onlyFavorites}
onQueryChange={onQueryChange} onQueryChange={onQueryChange}
onToggleOnlyFavorites={onToggleOnlyFavorites} onToggleOnlyFavorites={onToggleOnlyFavorites}
query={query} query={query}
tabs={tabs}
totalSearchResultsCount={totalSearchResultsCount} totalSearchResultsCount={totalSearchResultsCount}
/> >
{SearchRowContent}
</SearchRow>
</> </>
</HeaderContainer> </HeaderContainer>
</EuiModalHeader> </EuiModalHeader>
@ -96,6 +111,7 @@ export const OpenTimelineModalBody = memo<OpenTimelineProps>(
showExtendedColumns={false} showExtendedColumns={false}
sortDirection={sortDirection} sortDirection={sortDirection}
sortField={sortField} sortField={sortField}
timelineType={timelineType}
totalSearchResultsCount={totalSearchResultsCount} totalSearchResultsCount={totalSearchResultsCount}
/> />
</EuiModalBody> </EuiModalBody>

View file

@ -34,8 +34,13 @@ SearchRowFlexGroup.displayName = 'SearchRowFlexGroup';
type Props = Pick< type Props = Pick<
OpenTimelineProps, OpenTimelineProps,
'onlyFavorites' | 'onQueryChange' | 'onToggleOnlyFavorites' | 'query' | 'totalSearchResultsCount' | 'favoriteCount'
> & { tabs?: JSX.Element }; | 'onlyFavorites'
| 'onQueryChange'
| 'onToggleOnlyFavorites'
| 'query'
| 'totalSearchResultsCount'
> & { children?: JSX.Element | null };
const searchBox = { const searchBox = {
placeholder: i18n.SEARCH_PLACEHOLDER, placeholder: i18n.SEARCH_PLACEHOLDER,
@ -47,12 +52,13 @@ const searchBox = {
*/ */
export const SearchRow = React.memo<Props>( export const SearchRow = React.memo<Props>(
({ ({
favoriteCount,
onlyFavorites, onlyFavorites,
onQueryChange, onQueryChange,
onToggleOnlyFavorites, onToggleOnlyFavorites,
query, query,
totalSearchResultsCount, totalSearchResultsCount,
tabs, children,
}) => { }) => {
return ( return (
<SearchRowContainer> <SearchRowContainer>
@ -68,10 +74,11 @@ export const SearchRow = React.memo<Props>(
data-test-subj="only-favorites-toggle" data-test-subj="only-favorites-toggle"
hasActiveFilters={onlyFavorites} hasActiveFilters={onlyFavorites}
onClick={onToggleOnlyFavorites} onClick={onToggleOnlyFavorites}
numFilters={favoriteCount ?? undefined}
> >
{i18n.ONLY_FAVORITES} {i18n.ONLY_FAVORITES}
</EuiFilterButton> </EuiFilterButton>
{tabs} {!!children && children}
</> </>
</EuiFilterGroup> </EuiFilterGroup>
</EuiFlexItem> </EuiFlexItem>

View file

@ -16,6 +16,7 @@ import {
TimelineActionsOverflowColumns, TimelineActionsOverflowColumns,
} from '../types'; } from '../types';
import * as i18n from '../translations'; import * as i18n from '../translations';
import { TimelineStatus } from '../../../../../common/types/timeline';
/** /**
* Returns the action columns (e.g. delete, open duplicate timeline) * Returns the action columns (e.g. delete, open duplicate timeline)
@ -54,7 +55,9 @@ export const getActionsColumns = ({
onClick: (selectedTimeline: OpenTimelineResult) => { onClick: (selectedTimeline: OpenTimelineResult) => {
if (enableExportTimelineDownloader != null) enableExportTimelineDownloader(selectedTimeline); if (enableExportTimelineDownloader != null) enableExportTimelineDownloader(selectedTimeline);
}, },
enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null, enabled: (timeline: OpenTimelineResult) => {
return timeline.savedObjectId != null && timeline.status !== TimelineStatus.immutable;
},
description: i18n.EXPORT_SELECTED, description: i18n.EXPORT_SELECTED,
'data-test-subj': 'export-timeline', 'data-test-subj': 'export-timeline',
}; };
@ -65,7 +68,8 @@ export const getActionsColumns = ({
onClick: (selectedTimeline: OpenTimelineResult) => { onClick: (selectedTimeline: OpenTimelineResult) => {
if (onOpenDeleteTimelineModal != null) onOpenDeleteTimelineModal(selectedTimeline); if (onOpenDeleteTimelineModal != null) onOpenDeleteTimelineModal(selectedTimeline);
}, },
enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null, enabled: ({ savedObjectId, status }: OpenTimelineResult) =>
savedObjectId != null && status !== TimelineStatus.immutable,
description: i18n.DELETE_SELECTED, description: i18n.DELETE_SELECTED,
'data-test-subj': 'delete-timeline', 'data-test-subj': 'delete-timeline',
}; };

View file

@ -13,55 +13,68 @@ import { ACTION_COLUMN_WIDTH } from './common_styles';
import { getNotesCount, getPinnedEventCount } from '../helpers'; import { getNotesCount, getPinnedEventCount } from '../helpers';
import * as i18n from '../translations'; import * as i18n from '../translations';
import { FavoriteTimelineResult, OpenTimelineResult } from '../types'; import { FavoriteTimelineResult, OpenTimelineResult } from '../types';
import { TimelineTypeLiteralWithNull, TimelineType } from '../../../../../common/types/timeline';
/** /**
* Returns the columns that have icon headers * Returns the columns that have icon headers
*/ */
export const getIconHeaderColumns = () => [ export const getIconHeaderColumns = ({
{ timelineType,
align: 'center', }: {
field: 'pinnedEventIds', timelineType: TimelineTypeLiteralWithNull;
name: ( }) => {
<EuiToolTip content={i18n.PINNED_EVENTS}> const columns = {
<EuiIcon data-test-subj="pinned-event-header-icon" size="m" type="pin" /> note: {
</EuiToolTip> align: 'center',
), field: 'eventIdToNoteIds',
render: (_: Record<string, boolean> | null | undefined, timelineResult: OpenTimelineResult) => ( name: (
<span data-test-subj="pinned-event-count">{`${getPinnedEventCount(timelineResult)}`}</span> <EuiToolTip content={i18n.NOTES}>
), <EuiIcon data-test-subj="notes-count-header-icon" size="m" type="editorComment" />
sortable: false, </EuiToolTip>
width: ACTION_COLUMN_WIDTH, ),
}, render: (
{ _: Record<string, string[]> | null | undefined,
align: 'center', timelineResult: OpenTimelineResult
field: 'eventIdToNoteIds', ) => <span data-test-subj="notes-count">{getNotesCount(timelineResult)}</span>,
name: ( sortable: false,
<EuiToolTip content={i18n.NOTES}> width: ACTION_COLUMN_WIDTH,
<EuiIcon data-test-subj="notes-count-header-icon" size="m" type="editorComment" />
</EuiToolTip>
),
render: (
_: Record<string, string[]> | null | undefined,
timelineResult: OpenTimelineResult
) => <span data-test-subj="notes-count">{getNotesCount(timelineResult)}</span>,
sortable: false,
width: ACTION_COLUMN_WIDTH,
},
{
align: 'center',
field: 'favorite',
name: (
<EuiToolTip content={i18n.FAVORITES}>
<EuiIcon data-test-subj="favorites-header-icon" size="m" type="starEmpty" />
</EuiToolTip>
),
render: (favorite: FavoriteTimelineResult[] | null | undefined) => {
const isFavorite = favorite != null && favorite.length > 0;
const fill = isFavorite ? 'starFilled' : 'starEmpty';
return <EuiIcon data-test-subj={`favorite-${fill}-star`} type={fill} size="m" />;
}, },
sortable: false, pinnedEvent: {
width: ACTION_COLUMN_WIDTH, align: 'center',
}, field: 'pinnedEventIds',
]; name: (
<EuiToolTip content={i18n.PINNED_EVENTS}>
<EuiIcon data-test-subj="pinned-event-header-icon" size="m" type="pin" />
</EuiToolTip>
),
render: (
_: Record<string, boolean> | null | undefined,
timelineResult: OpenTimelineResult
) => (
<span data-test-subj="pinned-event-count">{`${getPinnedEventCount(timelineResult)}`}</span>
),
sortable: false,
width: ACTION_COLUMN_WIDTH,
},
favorite: {
align: 'center',
field: 'favorite',
name: (
<EuiToolTip content={i18n.FAVORITES}>
<EuiIcon data-test-subj="favorites-header-icon" size="m" type="starEmpty" />
</EuiToolTip>
),
render: (favorite: FavoriteTimelineResult[] | null | undefined) => {
const isFavorite = favorite != null && favorite.length > 0;
const fill = isFavorite ? 'starFilled' : 'starEmpty';
return <EuiIcon data-test-subj={`favorite-${fill}-star`} type={fill} size="m" />;
},
sortable: false,
width: ACTION_COLUMN_WIDTH,
},
};
const templateColumns = [columns.note, columns.favorite];
const defaultColumns = [columns.pinnedEvent, columns.note, columns.favorite];
return timelineType === TimelineType.template ? templateColumns : defaultColumns;
};

View file

@ -24,6 +24,7 @@ import { getActionsColumns } from './actions_columns';
import { getCommonColumns } from './common_columns'; import { getCommonColumns } from './common_columns';
import { getExtendedColumns } from './extended_columns'; import { getExtendedColumns } from './extended_columns';
import { getIconHeaderColumns } from './icon_header_columns'; import { getIconHeaderColumns } from './icon_header_columns';
import { TimelineTypeLiteralWithNull } from '../../../../../common/types/timeline';
// there are a number of type mismatches across this file // there are a number of type mismatches across this file
const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any
@ -58,6 +59,7 @@ export const getTimelinesTableColumns = ({
onOpenTimeline, onOpenTimeline,
onToggleShowNotes, onToggleShowNotes,
showExtendedColumns, showExtendedColumns,
timelineType,
}: { }: {
actionTimelineToShow: ActionTimelineToShow[]; actionTimelineToShow: ActionTimelineToShow[];
deleteTimelines?: DeleteTimelines; deleteTimelines?: DeleteTimelines;
@ -68,6 +70,7 @@ export const getTimelinesTableColumns = ({
onSelectionChange: OnSelectionChange; onSelectionChange: OnSelectionChange;
onToggleShowNotes: OnToggleShowNotes; onToggleShowNotes: OnToggleShowNotes;
showExtendedColumns: boolean; showExtendedColumns: boolean;
timelineType: TimelineTypeLiteralWithNull;
}) => { }) => {
return [ return [
...getCommonColumns({ ...getCommonColumns({
@ -76,7 +79,7 @@ export const getTimelinesTableColumns = ({
onToggleShowNotes, onToggleShowNotes,
}), }),
...getExtendedColumnsIfEnabled(showExtendedColumns), ...getExtendedColumnsIfEnabled(showExtendedColumns),
...getIconHeaderColumns(), ...getIconHeaderColumns({ timelineType }),
...getActionsColumns({ ...getActionsColumns({
actionTimelineToShow, actionTimelineToShow,
deleteTimelines, deleteTimelines,
@ -105,6 +108,7 @@ export interface TimelinesTableProps {
showExtendedColumns: boolean; showExtendedColumns: boolean;
sortDirection: 'asc' | 'desc'; sortDirection: 'asc' | 'desc';
sortField: string; sortField: string;
timelineType: TimelineTypeLiteralWithNull;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
tableRef?: React.MutableRefObject<_EuiBasicTable<any> | undefined>; tableRef?: React.MutableRefObject<_EuiBasicTable<any> | undefined>;
totalSearchResultsCount: number; totalSearchResultsCount: number;
@ -134,6 +138,7 @@ export const TimelinesTable = React.memo<TimelinesTableProps>(
sortField, sortField,
sortDirection, sortDirection,
tableRef, tableRef,
timelineType,
totalSearchResultsCount, totalSearchResultsCount,
}) => { }) => {
const pagination = { const pagination = {
@ -174,6 +179,7 @@ export const TimelinesTable = React.memo<TimelinesTableProps>(
onSelectionChange, onSelectionChange,
onToggleShowNotes, onToggleShowNotes,
showExtendedColumns, showExtendedColumns,
timelineType,
})} })}
compressed compressed
data-test-subj="timelines-table" data-test-subj="timelines-table"

View file

@ -7,6 +7,7 @@ import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines_page';
import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants';
import { OpenTimelineResult } from '../types'; import { OpenTimelineResult } from '../types';
import { TimelinesTableProps } from '.'; import { TimelinesTableProps } from '.';
import { TimelineType } from '../../../../../common/types/timeline';
export const getMockTimelinesTableProps = ( export const getMockTimelinesTableProps = (
mockOpenTimelineResults: OpenTimelineResult[] mockOpenTimelineResults: OpenTimelineResult[]
@ -28,5 +29,6 @@ export const getMockTimelinesTableProps = (
showExtendedColumns: true, showExtendedColumns: true,
sortDirection: DEFAULT_SORT_DIRECTION, sortDirection: DEFAULT_SORT_DIRECTION,
sortField: DEFAULT_SORT_FIELD, sortField: DEFAULT_SORT_FIELD,
timelineType: TimelineType.default,
totalSearchResultsCount: mockOpenTimelineResults.length, totalSearchResultsCount: mockOpenTimelineResults.length,
}); });

View file

@ -220,6 +220,20 @@ export const TAB_TEMPLATES = i18n.translate(
} }
); );
export const FILTER_ELASTIC_TIMELINES = i18n.translate(
'xpack.securitySolution.timelines.components.templateFilter.elasticTitle',
{
defaultMessage: 'Elastic templates',
}
);
export const FILTER_CUSTOM_TIMELINES = i18n.translate(
'xpack.securitySolution.timelines.components.templateFilter.customizedTitle',
{
defaultMessage: 'Custom templates',
}
);
export const IMPORT_TIMELINE_BTN_TITLE = i18n.translate( export const IMPORT_TIMELINE_BTN_TITLE = i18n.translate(
'xpack.securitySolution.timelines.components.importTimelineModal.importTimelineTitle', 'xpack.securitySolution.timelines.components.importTimelineModal.importTimelineTitle',
{ {
@ -230,7 +244,7 @@ export const IMPORT_TIMELINE_BTN_TITLE = i18n.translate(
export const SELECT_TIMELINE = i18n.translate( export const SELECT_TIMELINE = i18n.translate(
'xpack.securitySolution.timelines.components.importTimelineModal.selectTimelineDescription', 'xpack.securitySolution.timelines.components.importTimelineModal.selectTimelineDescription',
{ {
defaultMessage: 'Select a SIEM timeline (as exported from the Timeline view) to import', defaultMessage: 'Select a Security timeline (as exported from the Timeline view) to import',
} }
); );
@ -280,3 +294,10 @@ export const IMPORT_FAILED_DETAILED = (id: string, statusCode: number, message:
defaultMessage: 'Timeline ID: {id}\n Status Code: {statusCode}\n Message: {message}', defaultMessage: 'Timeline ID: {id}\n Status Code: {statusCode}\n Message: {message}',
} }
); );
export const TEMPLATE_CALL_OUT_MESSAGE = i18n.translate(
'xpack.securitySolution.timelines.components.templateCallOutMessageTitle',
{
defaultMessage: 'Now you can add timeline templates and link it to rules.',
}
);

View file

@ -8,7 +8,12 @@ import { SetStateAction, Dispatch } from 'react';
import { AllTimelinesVariables } from '../../containers/all'; import { AllTimelinesVariables } from '../../containers/all';
import { TimelineModel } from '../../store/timeline/model'; import { TimelineModel } from '../../store/timeline/model';
import { NoteResult } from '../../../graphql/types'; import { NoteResult } from '../../../graphql/types';
import { TimelineTypeLiteral } from '../../../../common/types/timeline'; import {
TimelineTypeLiteral,
TimelineTypeLiteralWithNull,
TimelineStatus,
TemplateTimelineTypeLiteral,
} from '../../../../common/types/timeline';
/** The users who added a timeline to favorites */ /** The users who added a timeline to favorites */
export interface FavoriteTimelineResult { export interface FavoriteTimelineResult {
@ -46,6 +51,7 @@ export interface OpenTimelineResult {
notes?: TimelineResultNote[] | null; notes?: TimelineResultNote[] | null;
pinnedEventIds?: Readonly<Record<string, boolean>> | null; pinnedEventIds?: Readonly<Record<string, boolean>> | null;
savedObjectId?: string | null; savedObjectId?: string | null;
status?: TimelineStatus | null;
title?: string | null; title?: string | null;
templateTimelineId?: string | null; templateTimelineId?: string | null;
type?: TimelineTypeLiteral; type?: TimelineTypeLiteral;
@ -118,6 +124,8 @@ export interface OpenTimelineProps {
deleteTimelines?: DeleteTimelines; deleteTimelines?: DeleteTimelines;
/** The default requested size of each page of search results */ /** The default requested size of each page of search results */
defaultPageSize: number; defaultPageSize: number;
/** The number of favorite timeline*/
favoriteCount?: number | null | undefined;
/** Displays an indicator that data is loading when true */ /** Displays an indicator that data is loading when true */
isLoading: boolean; isLoading: boolean;
/** Required by EuiTable for expandable rows: a map of `TimelineResult.savedObjectId` to rendered notes */ /** Required by EuiTable for expandable rows: a map of `TimelineResult.savedObjectId` to rendered notes */
@ -160,8 +168,12 @@ export interface OpenTimelineProps {
sortDirection: 'asc' | 'desc'; sortDirection: 'asc' | 'desc';
/** the requested field to sort on */ /** the requested field to sort on */
sortField: string; sortField: string;
/** this affects timeline's behaviour like editable / duplicatible */
timelineType: TimelineTypeLiteralWithNull;
/** when timelineType === template, templatetimelineFilter is a JSX.Element */
templateTimelineFilter: JSX.Element[] | null;
/** timeline / template timeline */ /** timeline / template timeline */
tabs?: JSX.Element; timelineFilter?: JSX.Element | JSX.Element[] | null;
/** The title of the Open Timeline component */ /** The title of the Open Timeline component */
title: string; title: string;
/** The total (server-side) count of the search results */ /** The total (server-side) count of the search results */
@ -196,9 +208,19 @@ export enum TimelineTabsStyle {
} }
export interface TimelineTab { export interface TimelineTab {
id: TimelineTypeLiteral; count: number | undefined;
name: string;
disabled: boolean; disabled: boolean;
href: string; href: string;
id: TimelineTypeLiteral;
name: string;
onClick: (ev: { preventDefault: () => void }) => void; onClick: (ev: { preventDefault: () => void }) => void;
withNext: boolean;
}
export interface TemplateTimelineFilter {
id: TemplateTimelineTypeLiteral;
name: string;
disabled: boolean;
withNext: boolean;
count: number | undefined;
} }

View file

@ -0,0 +1,110 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState, useCallback, useMemo } from 'react';
import { EuiFilterButton } from '@elastic/eui';
import {
TimelineStatus,
TimelineType,
TimelineTypeLiteralWithNull,
TemplateTimelineType,
TemplateTimelineTypeLiteralWithNull,
TimelineStatusLiteralWithNull,
} from '../../../../common/types/timeline';
import * as i18n from './translations';
import { TemplateTimelineFilter } from './types';
import { disableTemplate } from '../../../../common/constants';
export const useTimelineStatus = ({
timelineType,
elasticTemplateTimelineCount,
customTemplateTimelineCount,
}: {
timelineType: TimelineTypeLiteralWithNull;
elasticTemplateTimelineCount?: number | null;
customTemplateTimelineCount?: number | null;
}): {
timelineStatus: TimelineStatusLiteralWithNull;
templateTimelineType: TemplateTimelineTypeLiteralWithNull;
templateTimelineFilter: JSX.Element[] | null;
} => {
const [selectedTab, setSelectedTab] = useState<TemplateTimelineTypeLiteralWithNull>(
disableTemplate ? null : TemplateTimelineType.elastic
);
const isTemplateFilterEnabled = useMemo(() => timelineType === TimelineType.template, [
timelineType,
]);
const templateTimelineType = useMemo(
() => (disableTemplate || !isTemplateFilterEnabled ? null : selectedTab),
[selectedTab, isTemplateFilterEnabled]
);
const timelineStatus = useMemo(
() =>
templateTimelineType == null
? null
: templateTimelineType === TemplateTimelineType.elastic
? TimelineStatus.immutable
: TimelineStatus.active,
[templateTimelineType]
);
const filters = useMemo(
() => [
{
id: TemplateTimelineType.elastic,
name: i18n.FILTER_ELASTIC_TIMELINES,
disabled: !isTemplateFilterEnabled,
withNext: true,
count: elasticTemplateTimelineCount ?? undefined,
},
{
id: TemplateTimelineType.custom,
name: i18n.FILTER_CUSTOM_TIMELINES,
disabled: !isTemplateFilterEnabled,
withNext: false,
count: customTemplateTimelineCount ?? undefined,
},
],
[customTemplateTimelineCount, elasticTemplateTimelineCount, isTemplateFilterEnabled]
);
const onFilterClicked = useCallback(
(tabId) => {
if (selectedTab === tabId) {
setSelectedTab(null);
} else {
setSelectedTab(tabId);
}
},
[setSelectedTab, selectedTab]
);
const templateTimelineFilter = useMemo(() => {
return isTemplateFilterEnabled
? filters.map((tab: TemplateTimelineFilter) => (
<EuiFilterButton
hasActiveFilters={tab.id === templateTimelineType}
key={`template-timeline-filter-${tab.id}`}
numFilters={tab.count}
onClick={onFilterClicked.bind(null, tab.id)}
withNext={tab.withNext}
isDisabled={tab.disabled}
>
{tab.name}
</EuiFilterButton>
))
: null;
}, [templateTimelineType, filters, isTemplateFilterEnabled, onFilterClicked]);
return {
timelineStatus,
templateTimelineType,
templateTimelineFilter,
};
};

View file

@ -13,10 +13,16 @@ import { getTimelineTabsUrl, useFormatUrl } from '../../../common/components/lin
import * as i18n from './translations'; import * as i18n from './translations';
import { TimelineTabsStyle, TimelineTab } from './types'; import { TimelineTabsStyle, TimelineTab } from './types';
export const useTimelineTypes = (): { export const useTimelineTypes = ({
defaultTimelineCount,
templateTimelineCount,
}: {
defaultTimelineCount?: number | null;
templateTimelineCount?: number | null;
}): {
timelineType: TimelineTypeLiteralWithNull; timelineType: TimelineTypeLiteralWithNull;
timelineTabs: JSX.Element; timelineTabs: JSX.Element;
timelineFilters: JSX.Element; timelineFilters: JSX.Element[];
} => { } => {
const history = useHistory(); const history = useHistory();
const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.timelines); const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.timelines);
@ -40,35 +46,52 @@ export const useTimelineTypes = (): {
}, },
[history, urlSearch] [history, urlSearch]
); );
const getFilterOrTabs: (timelineTabsStyle: TimelineTabsStyle) => TimelineTab[] = useCallback(
const getFilterOrTabs: (timelineTabsStyle: TimelineTabsStyle) => TimelineTab[] = ( (timelineTabsStyle: TimelineTabsStyle) => [
timelineTabsStyle: TimelineTabsStyle {
) => [ id: TimelineType.default,
{ name:
id: TimelineType.default, timelineTabsStyle === TimelineTabsStyle.filter
name: ? i18n.FILTER_TIMELINES(i18n.TAB_TIMELINES)
timelineTabsStyle === TimelineTabsStyle.filter : i18n.TAB_TIMELINES,
? i18n.FILTER_TIMELINES(i18n.TAB_TIMELINES) href: formatUrl(getTimelineTabsUrl(TimelineType.default, urlSearch)),
: i18n.TAB_TIMELINES, disabled: false,
href: formatUrl(getTimelineTabsUrl(TimelineType.default, urlSearch)), withNext: true,
disabled: false, count:
onClick: goToTimeline, timelineTabsStyle === TimelineTabsStyle.filter
}, ? defaultTimelineCount ?? undefined
{ : undefined,
id: TimelineType.template, onClick: goToTimeline,
name: },
timelineTabsStyle === TimelineTabsStyle.filter {
? i18n.FILTER_TIMELINES(i18n.TAB_TEMPLATES) id: TimelineType.template,
: i18n.TAB_TEMPLATES, name:
href: formatUrl(getTimelineTabsUrl(TimelineType.template, urlSearch)), timelineTabsStyle === TimelineTabsStyle.filter
disabled: false, ? i18n.FILTER_TIMELINES(i18n.TAB_TEMPLATES)
onClick: goToTemplateTimeline, : i18n.TAB_TEMPLATES,
}, href: formatUrl(getTimelineTabsUrl(TimelineType.template, urlSearch)),
]; disabled: false,
withNext: false,
count:
timelineTabsStyle === TimelineTabsStyle.filter
? templateTimelineCount ?? undefined
: undefined,
onClick: goToTemplateTimeline,
},
],
[
defaultTimelineCount,
templateTimelineCount,
urlSearch,
formatUrl,
goToTimeline,
goToTemplateTimeline,
]
);
const onFilterClicked = useCallback( const onFilterClicked = useCallback(
(timelineTabsStyle, tabId) => { (tabId) => {
if (timelineTabsStyle === TimelineTabsStyle.filter && tabId === timelineType) { if (tabId === timelineType) {
setTimelineTypes(null); setTimelineTypes(null);
} else { } else {
setTimelineTypes(tabId); setTimelineTypes(tabId);
@ -89,7 +112,7 @@ export const useTimelineTypes = (): {
href={tab.href} href={tab.href}
onClick={(ev) => { onClick={(ev) => {
tab.onClick(ev); tab.onClick(ev);
onFilterClicked(TimelineTabsStyle.tab, tab.id); onFilterClicked(tab.id);
}} }}
> >
{tab.name} {tab.name}
@ -103,24 +126,21 @@ export const useTimelineTypes = (): {
}, [tabName]); }, [tabName]);
const timelineFilters = useMemo(() => { const timelineFilters = useMemo(() => {
return ( return getFilterOrTabs(TimelineTabsStyle.filter).map((tab: TimelineTab) => (
<> <EuiFilterButton
{getFilterOrTabs(TimelineTabsStyle.tab).map((tab: TimelineTab) => ( hasActiveFilters={tab.id === timelineType}
<EuiFilterButton key={`timeline-${TimelineTabsStyle.filter}-${tab.id}`}
hasActiveFilters={tab.id === timelineType} numFilters={tab.count}
key={`timeline-${TimelineTabsStyle.filter}-${tab.id}`} onClick={(ev: { preventDefault: () => void }) => {
onClick={(ev: { preventDefault: () => void }) => { tab.onClick(ev);
tab.onClick(ev); onFilterClicked(tab.id);
onFilterClicked.bind(null, TimelineTabsStyle.filter, tab.id); }}
}} withNext={tab.withNext}
> >
{tab.name} {tab.name}
</EuiFilterButton> </EuiFilterButton>
))} ));
</> }, [timelineType, getFilterOrTabs, onFilterClicked]);
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timelineType]);
return { return {
timelineType, timelineType,

View file

@ -926,6 +926,7 @@ In other use cases the message field can be used to concatenate different values
} }
} }
start={1521830963132} start={1521830963132}
status="active"
toggleColumn={[MockFunction]} toggleColumn={[MockFunction]}
usersViewing={ usersViewing={
Array [ Array [

View file

@ -5,13 +5,24 @@
*/ */
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux';
import { TestProviders } from '../../../../../common/mock'; import { TestProviders, mockTimelineModel } from '../../../../../common/mock';
import { DEFAULT_ACTIONS_COLUMN_WIDTH } from '../constants'; import { DEFAULT_ACTIONS_COLUMN_WIDTH } from '../constants';
import { Actions } from '.'; import { Actions } from '.';
jest.mock('react-redux', () => {
const origin = jest.requireActual('react-redux');
return {
...origin,
useSelector: jest.fn(),
};
});
describe('Actions', () => { describe('Actions', () => {
(useSelector as jest.Mock).mockReturnValue(mockTimelineModel);
test('it renders a checkbox for selecting the event when `showCheckboxes` is `true`', () => { test('it renders a checkbox for selecting the event when `showCheckboxes` is `true`', () => {
const wrapper = mount( const wrapper = mount(
<TestProviders> <TestProviders>

View file

@ -3,10 +3,15 @@
* or more contributor license agreements. Licensed under the Elastic License; * or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import { EuiButtonIcon, EuiCheckbox, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui';
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux';
import { EuiButtonIcon, EuiCheckbox, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui';
import { Note } from '../../../../../common/lib/note'; import { Note } from '../../../../../common/lib/note';
import { StoreState } from '../../../../../common/store/types';
import { TimelineModel } from '../../../../store/timeline/model';
import { AssociateNote, UpdateNote } from '../../../notes/helpers'; import { AssociateNote, UpdateNote } from '../../../notes/helpers';
import { Pin } from '../../pin'; import { Pin } from '../../pin';
import { NotesButton } from '../../properties/helpers'; import { NotesButton } from '../../properties/helpers';
@ -79,92 +84,101 @@ export const Actions = React.memo<Props>(
showNotes, showNotes,
toggleShowNotes, toggleShowNotes,
updateNote, updateNote,
}) => ( }) => {
<EventsTdGroupActions const timeline = useSelector<StoreState, TimelineModel>((state) => {
actionsColumnWidth={actionsColumnWidth} return state.timeline.timelineById['timeline-1'];
data-test-subj="event-actions-container" });
> return (
{showCheckboxes && ( <EventsTdGroupActions
<EventsTd data-test-subj="select-event-container"> actionsColumnWidth={actionsColumnWidth}
data-test-subj="event-actions-container"
>
{showCheckboxes && (
<EventsTd data-test-subj="select-event-container">
<EventsTdContent textAlign="center" width={DEFAULT_ICON_BUTTON_WIDTH}>
{loadingEventIds.includes(eventId) ? (
<EuiLoadingSpinner size="m" data-test-subj="event-loader" />
) : (
<EuiCheckbox
data-test-subj="select-event"
id={eventId}
checked={checked}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
onRowSelected({
eventIds: [eventId],
isSelected: event.currentTarget.checked,
});
}}
/>
)}
</EventsTdContent>
</EventsTd>
)}
<EventsTd>
<EventsTdContent textAlign="center" width={DEFAULT_ICON_BUTTON_WIDTH}> <EventsTdContent textAlign="center" width={DEFAULT_ICON_BUTTON_WIDTH}>
{loadingEventIds.includes(eventId) ? ( {loading && <EventsLoading />}
<EuiLoadingSpinner size="m" data-test-subj="event-loader" />
) : ( {!loading && (
<EuiCheckbox <EuiButtonIcon
data-test-subj="select-event" aria-label={expanded ? i18n.COLLAPSE : i18n.EXPAND}
data-test-subj="expand-event"
iconType={expanded ? 'arrowDown' : 'arrowRight'}
id={eventId} id={eventId}
checked={checked} onClick={onEventToggled}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
onRowSelected({
eventIds: [eventId],
isSelected: event.currentTarget.checked,
});
}}
/> />
)} )}
</EventsTdContent> </EventsTdContent>
</EventsTd> </EventsTd>
)}
<EventsTd> <>{additionalActions}</>
<EventsTdContent textAlign="center" width={DEFAULT_ICON_BUTTON_WIDTH}>
{loading && <EventsLoading />}
{!loading && ( {!isEventViewer && (
<EuiButtonIcon <>
aria-label={expanded ? i18n.COLLAPSE : i18n.EXPAND} <EventsTd>
data-test-subj="expand-event" <EventsTdContent textAlign="center" width={DEFAULT_ICON_BUTTON_WIDTH}>
iconType={expanded ? 'arrowDown' : 'arrowRight'} <EuiToolTip
id={eventId} data-test-subj="timeline-action-pin-tool-tip"
onClick={onEventToggled} content={getPinTooltip({
/> isPinned: eventIsPinned,
)} eventHasNotes: eventHasNotes(noteIds),
</EventsTdContent> timelineType: timeline.timelineType,
</EventsTd> })}
>
<Pin
allowUnpinning={!eventHasNotes(noteIds)}
data-test-subj="pin-event"
onClick={onPinClicked}
pinned={eventIsPinned}
timelineType={timeline.timelineType}
/>
</EuiToolTip>
</EventsTdContent>
</EventsTd>
<>{additionalActions}</> <EventsTd>
<EventsTdContent textAlign="center" width={DEFAULT_ICON_BUTTON_WIDTH}>
{!isEventViewer && ( <NotesButton
<> animate={false}
<EventsTd> associateNote={associateNote}
<EventsTdContent textAlign="center" width={DEFAULT_ICON_BUTTON_WIDTH}> data-test-subj="add-note"
<EuiToolTip getNotesByIds={getNotesByIds}
data-test-subj="timeline-action-pin-tool-tip" noteIds={noteIds || emptyNotes}
content={getPinTooltip({ showNotes={showNotes}
isPinned: eventIsPinned, size="s"
eventHasNotes: eventHasNotes(noteIds), status={timeline.status}
})} timelineType={timeline.timelineType}
> toggleShowNotes={toggleShowNotes}
<Pin toolTip={timeline.timelineType ? i18n.NOTES_DISABLE_TOOLTIP : i18n.NOTES_TOOLTIP}
allowUnpinning={!eventHasNotes(noteIds)} updateNote={updateNote}
data-test-subj="pin-event"
onClick={onPinClicked}
pinned={eventIsPinned}
/> />
</EuiToolTip> </EventsTdContent>
</EventsTdContent> </EventsTd>
</EventsTd> </>
)}
<EventsTd> </EventsTdGroupActions>
<EventsTdContent textAlign="center" width={DEFAULT_ICON_BUTTON_WIDTH}> );
<NotesButton },
animate={false}
associateNote={associateNote}
data-test-subj="add-note"
getNotesByIds={getNotesByIds}
noteIds={noteIds || emptyNotes}
showNotes={showNotes}
size="s"
toggleShowNotes={toggleShowNotes}
toolTip={i18n.NOTES_TOOLTIP}
updateNote={updateNote}
/>
</EventsTdContent>
</EventsTd>
</>
)}
</EventsTdGroupActions>
),
(nextProps, prevProps) => { (nextProps, prevProps) => {
return ( return (
prevProps.actionsColumnWidth === nextProps.actionsColumnWidth && prevProps.actionsColumnWidth === nextProps.actionsColumnWidth &&

View file

@ -5,6 +5,7 @@
*/ */
import React, { useEffect, useRef, useState, useCallback } from 'react'; import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useSelector } from 'react-redux';
import uuid from 'uuid'; import uuid from 'uuid';
import VisibilitySensor from 'react-visibility-sensor'; import VisibilitySensor from 'react-visibility-sensor';
@ -13,7 +14,7 @@ import { TimelineDetailsQuery } from '../../../../containers/details';
import { TimelineItem, DetailItem, TimelineNonEcsData } from '../../../../../graphql/types'; import { TimelineItem, DetailItem, TimelineNonEcsData } from '../../../../../graphql/types';
import { requestIdleCallbackViaScheduler } from '../../../../../common/lib/helpers/scheduler'; import { requestIdleCallbackViaScheduler } from '../../../../../common/lib/helpers/scheduler';
import { Note } from '../../../../../common/lib/note'; import { Note } from '../../../../../common/lib/note';
import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; import { ColumnHeaderOptions, TimelineModel } from '../../../../../timelines/store/timeline/model';
import { AddNoteToEvent, UpdateNote } from '../../../notes/helpers'; import { AddNoteToEvent, UpdateNote } from '../../../notes/helpers';
import { SkeletonRow } from '../../skeleton_row'; import { SkeletonRow } from '../../skeleton_row';
import { import {
@ -33,6 +34,7 @@ import { getEventType } from '../helpers';
import { NoteCards } from '../../../notes/note_cards'; import { NoteCards } from '../../../notes/note_cards';
import { useEventDetailsWidthContext } from '../../../../../common/components/events_viewer/event_details_width_context'; import { useEventDetailsWidthContext } from '../../../../../common/components/events_viewer/event_details_width_context';
import { EventColumnView } from './event_column_view'; import { EventColumnView } from './event_column_view';
import { StoreState } from '../../../../../common/store';
interface Props { interface Props {
actionsColumnWidth: number; actionsColumnWidth: number;
@ -128,7 +130,9 @@ const StatefulEventComponent: React.FC<Props> = ({
const [expanded, setExpanded] = useState<{ [eventId: string]: boolean }>({}); const [expanded, setExpanded] = useState<{ [eventId: string]: boolean }>({});
const [initialRender, setInitialRender] = useState(false); const [initialRender, setInitialRender] = useState(false);
const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({}); const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({});
const timeline = useSelector<StoreState, TimelineModel>((state) => {
return state.timeline.timelineById['timeline-1'];
});
const divElement = useRef<HTMLDivElement | null>(null); const divElement = useRef<HTMLDivElement | null>(null);
const onToggleShowNotes = useCallback(() => { const onToggleShowNotes = useCallback(() => {
@ -251,6 +255,7 @@ const StatefulEventComponent: React.FC<Props> = ({
getNotesByIds={getNotesByIds} getNotesByIds={getNotesByIds}
noteIds={eventIdToNoteIds[event._id] || emptyNotes} noteIds={eventIdToNoteIds[event._id] || emptyNotes}
showAddNote={!!showNotes[event._id]} showAddNote={!!showNotes[event._id]}
status={timeline.status}
toggleShowAddNote={onToggleShowNotes} toggleShowAddNote={onToggleShowNotes}
updateNote={updateNote} updateNote={updateNote}
/> />

View file

@ -7,6 +7,7 @@
import { Ecs } from '../../../../graphql/types'; import { Ecs } from '../../../../graphql/types';
import { eventHasNotes, eventIsPinned, getPinTooltip, stringifyEvent } from './helpers'; import { eventHasNotes, eventIsPinned, getPinTooltip, stringifyEvent } from './helpers';
import { TimelineType } from '../../../../../common/types/timeline';
describe('helpers', () => { describe('helpers', () => {
describe('stringifyEvent', () => { describe('stringifyEvent', () => {
@ -192,21 +193,37 @@ describe('helpers', () => {
describe('getPinTooltip', () => { describe('getPinTooltip', () => {
test('it indicates the event may NOT be unpinned when `isPinned` is `true` and the event has notes', () => { test('it indicates the event may NOT be unpinned when `isPinned` is `true` and the event has notes', () => {
expect(getPinTooltip({ isPinned: true, eventHasNotes: true })).toEqual( expect(
'This event cannot be unpinned because it has notes' getPinTooltip({ isPinned: true, eventHasNotes: true, timelineType: TimelineType.default })
); ).toEqual('This event cannot be unpinned because it has notes');
}); });
test('it indicates the event is pinned when `isPinned` is `true` and the event does NOT have notes', () => { test('it indicates the event is pinned when `isPinned` is `true` and the event does NOT have notes', () => {
expect(getPinTooltip({ isPinned: true, eventHasNotes: false })).toEqual('Pinned event'); expect(
getPinTooltip({ isPinned: true, eventHasNotes: false, timelineType: TimelineType.default })
).toEqual('Pinned event');
}); });
test('it indicates the event is NOT pinned when `isPinned` is `false` and the event has notes', () => { test('it indicates the event is NOT pinned when `isPinned` is `false` and the event has notes', () => {
expect(getPinTooltip({ isPinned: false, eventHasNotes: true })).toEqual('Unpinned event'); expect(
getPinTooltip({ isPinned: false, eventHasNotes: true, timelineType: TimelineType.default })
).toEqual('Unpinned event');
}); });
test('it indicates the event is NOT pinned when `isPinned` is `false` and the event does NOT have notes', () => { test('it indicates the event is NOT pinned when `isPinned` is `false` and the event does NOT have notes', () => {
expect(getPinTooltip({ isPinned: false, eventHasNotes: false })).toEqual('Unpinned event'); expect(
getPinTooltip({ isPinned: false, eventHasNotes: false, timelineType: TimelineType.default })
).toEqual('Unpinned event');
});
test('it indicates the event is disabled if timelineType is template', () => {
expect(
getPinTooltip({
isPinned: false,
eventHasNotes: false,
timelineType: TimelineType.template,
})
).toEqual('This event cannot be pinned because it is filtered by a timeline template');
}); });
}); });

View file

@ -15,6 +15,7 @@ import { OnPinEvent, OnUnPinEvent } from '../events';
import { TimelineRowAction, TimelineRowActionOnClick } from './actions'; import { TimelineRowAction, TimelineRowActionOnClick } from './actions';
import * as i18n from './translations'; import * as i18n from './translations';
import { TimelineTypeLiteral, TimelineType } from '../../../../../common/types/timeline';
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export const omitTypenameAndEmpty = (k: string, v: any): any | undefined => export const omitTypenameAndEmpty = (k: string, v: any): any | undefined =>
@ -28,10 +29,19 @@ export const getPinTooltip = ({
isPinned, isPinned,
// eslint-disable-next-line no-shadow // eslint-disable-next-line no-shadow
eventHasNotes, eventHasNotes,
timelineType,
}: { }: {
isPinned: boolean; isPinned: boolean;
eventHasNotes: boolean; eventHasNotes: boolean;
}) => (isPinned && eventHasNotes ? i18n.PINNED_WITH_NOTES : isPinned ? i18n.PINNED : i18n.UNPINNED); timelineType: TimelineTypeLiteral;
}) =>
timelineType === TimelineType.template
? i18n.DISABLE_PIN
: isPinned && eventHasNotes
? i18n.PINNED_WITH_NOTES
: isPinned
? i18n.PINNED
: i18n.UNPINNED;
export interface IsPinnedParams { export interface IsPinnedParams {
eventId: string; eventId: string;

View file

@ -5,10 +5,11 @@
*/ */
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux';
import { mockBrowserFields } from '../../../../common/containers/source/mock'; import { mockBrowserFields } from '../../../../common/containers/source/mock';
import { Direction } from '../../../../graphql/types'; import { Direction } from '../../../../graphql/types';
import { defaultHeaders, mockTimelineData } from '../../../../common/mock'; import { defaultHeaders, mockTimelineData, mockTimelineModel } from '../../../../common/mock';
import { TestProviders } from '../../../../common/mock/test_providers'; import { TestProviders } from '../../../../common/mock/test_providers';
import { Body, BodyProps } from '.'; import { Body, BodyProps } from '.';
@ -24,6 +25,13 @@ const mockSort: Sort = {
sortDirection: Direction.desc, sortDirection: Direction.desc,
}; };
jest.mock('react-redux', () => {
const origin = jest.requireActual('react-redux');
return {
...origin,
useSelector: jest.fn(),
};
});
jest.mock('../../../../common/components/link_to'); jest.mock('../../../../common/components/link_to');
jest.mock( jest.mock(
@ -41,41 +49,43 @@ jest.mock('../../../../common/lib/helpers/scheduler', () => ({
describe('Body', () => { describe('Body', () => {
const mount = useMountAppended(); const mount = useMountAppended();
const props: BodyProps = {
addNoteToEvent: jest.fn(),
browserFields: mockBrowserFields,
columnHeaders: defaultHeaders,
columnRenderers,
data: mockTimelineData,
eventIdToNoteIds: {},
height: testBodyHeight,
id: 'timeline-test',
isSelectAllChecked: false,
getNotesByIds: mockGetNotesByIds,
loadingEventIds: [],
onColumnRemoved: jest.fn(),
onColumnResized: jest.fn(),
onColumnSorted: jest.fn(),
onFilterChange: jest.fn(),
onPinEvent: jest.fn(),
onRowSelected: jest.fn(),
onSelectAll: jest.fn(),
onUnPinEvent: jest.fn(),
onUpdateColumns: jest.fn(),
pinnedEventIds: {},
rowRenderers,
selectedEventIds: {},
show: true,
sort: mockSort,
showCheckboxes: false,
toggleColumn: jest.fn(),
updateNote: jest.fn(),
};
(useSelector as jest.Mock).mockReturnValue(mockTimelineModel);
describe('rendering', () => { describe('rendering', () => {
test('it renders the column headers', () => { test('it renders the column headers', () => {
const wrapper = mount( const wrapper = mount(
<TestProviders> <TestProviders>
<Body <Body {...props} />
addNoteToEvent={jest.fn()}
browserFields={mockBrowserFields}
columnHeaders={defaultHeaders}
columnRenderers={columnRenderers}
data={mockTimelineData}
eventIdToNoteIds={{}}
height={testBodyHeight}
id={'timeline-test'}
isSelectAllChecked={false}
getNotesByIds={mockGetNotesByIds}
loadingEventIds={[]}
onColumnRemoved={jest.fn()}
onColumnResized={jest.fn()}
onColumnSorted={jest.fn()}
onFilterChange={jest.fn()}
onPinEvent={jest.fn()}
onRowSelected={jest.fn()}
onSelectAll={jest.fn()}
onUnPinEvent={jest.fn()}
onUpdateColumns={jest.fn()}
pinnedEventIds={{}}
rowRenderers={rowRenderers}
selectedEventIds={{}}
show={true}
sort={mockSort}
showCheckboxes={false}
toggleColumn={jest.fn()}
updateNote={jest.fn()}
/>
</TestProviders> </TestProviders>
); );
@ -85,36 +95,7 @@ describe('Body', () => {
test('it renders the scroll container', () => { test('it renders the scroll container', () => {
const wrapper = mount( const wrapper = mount(
<TestProviders> <TestProviders>
<Body <Body {...props} />
addNoteToEvent={jest.fn()}
browserFields={mockBrowserFields}
columnHeaders={defaultHeaders}
columnRenderers={columnRenderers}
data={mockTimelineData}
eventIdToNoteIds={{}}
height={testBodyHeight}
id={'timeline-test'}
isSelectAllChecked={false}
getNotesByIds={mockGetNotesByIds}
loadingEventIds={[]}
onColumnRemoved={jest.fn()}
onColumnResized={jest.fn()}
onColumnSorted={jest.fn()}
onFilterChange={jest.fn()}
onPinEvent={jest.fn()}
onRowSelected={jest.fn()}
onSelectAll={jest.fn()}
onUnPinEvent={jest.fn()}
onUpdateColumns={jest.fn()}
pinnedEventIds={{}}
rowRenderers={rowRenderers}
selectedEventIds={{}}
show={true}
sort={mockSort}
showCheckboxes={false}
toggleColumn={jest.fn()}
updateNote={jest.fn()}
/>
</TestProviders> </TestProviders>
); );
@ -124,36 +105,7 @@ describe('Body', () => {
test('it renders events', () => { test('it renders events', () => {
const wrapper = mount( const wrapper = mount(
<TestProviders> <TestProviders>
<Body <Body {...props} />
addNoteToEvent={jest.fn()}
browserFields={mockBrowserFields}
columnHeaders={defaultHeaders}
columnRenderers={columnRenderers}
data={mockTimelineData}
eventIdToNoteIds={{}}
height={testBodyHeight}
id={'timeline-test'}
isSelectAllChecked={false}
getNotesByIds={mockGetNotesByIds}
loadingEventIds={[]}
onColumnRemoved={jest.fn()}
onColumnResized={jest.fn()}
onColumnSorted={jest.fn()}
onFilterChange={jest.fn()}
onPinEvent={jest.fn()}
onRowSelected={jest.fn()}
onSelectAll={jest.fn()}
onUnPinEvent={jest.fn()}
onUpdateColumns={jest.fn()}
pinnedEventIds={{}}
rowRenderers={rowRenderers}
selectedEventIds={{}}
show={true}
sort={mockSort}
showCheckboxes={false}
toggleColumn={jest.fn()}
updateNote={jest.fn()}
/>
</TestProviders> </TestProviders>
); );
@ -162,39 +114,10 @@ describe('Body', () => {
test('it renders a tooltip for timestamp', async () => { test('it renders a tooltip for timestamp', async () => {
const headersJustTimestamp = defaultHeaders.filter((h) => h.id === '@timestamp'); const headersJustTimestamp = defaultHeaders.filter((h) => h.id === '@timestamp');
const testProps = { ...props, columnHeaders: headersJustTimestamp };
const wrapper = mount( const wrapper = mount(
<TestProviders> <TestProviders>
<Body <Body {...testProps} />
addNoteToEvent={jest.fn()}
browserFields={mockBrowserFields}
columnHeaders={headersJustTimestamp}
columnRenderers={columnRenderers}
data={mockTimelineData}
eventIdToNoteIds={{}}
height={testBodyHeight}
id={'timeline-test'}
isSelectAllChecked={false}
getNotesByIds={mockGetNotesByIds}
loadingEventIds={[]}
onColumnRemoved={jest.fn()}
onColumnResized={jest.fn()}
onColumnSorted={jest.fn()}
onFilterChange={jest.fn()}
onPinEvent={jest.fn()}
onRowSelected={jest.fn()}
onSelectAll={jest.fn()}
onUnPinEvent={jest.fn()}
onUpdateColumns={jest.fn()}
pinnedEventIds={{}}
rowRenderers={rowRenderers}
selectedEventIds={{}}
show={true}
sort={mockSort}
showCheckboxes={false}
toggleColumn={jest.fn()}
updateNote={jest.fn()}
/>
</TestProviders> </TestProviders>
); );
wrapper.update(); wrapper.update();
@ -215,6 +138,11 @@ describe('Body', () => {
describe('action on event', () => { describe('action on event', () => {
const dispatchAddNoteToEvent = jest.fn(); const dispatchAddNoteToEvent = jest.fn();
const dispatchOnPinEvent = jest.fn(); const dispatchOnPinEvent = jest.fn();
const testProps = {
...props,
addNoteToEvent: dispatchAddNoteToEvent,
onPinEvent: dispatchOnPinEvent,
};
const addaNoteToEvent = (wrapper: ReturnType<typeof mount>, note: string) => { const addaNoteToEvent = (wrapper: ReturnType<typeof mount>, note: string) => {
wrapper.find('[data-test-subj="add-note"]').first().find('button').simulate('click'); wrapper.find('[data-test-subj="add-note"]').first().find('button').simulate('click');
@ -251,36 +179,7 @@ describe('Body', () => {
test('Add a Note to an event', () => { test('Add a Note to an event', () => {
const wrapper = mount( const wrapper = mount(
<TestProviders> <TestProviders>
<Body <Body {...testProps} />
addNoteToEvent={dispatchAddNoteToEvent}
browserFields={mockBrowserFields}
columnHeaders={defaultHeaders}
columnRenderers={columnRenderers}
data={mockTimelineData}
eventIdToNoteIds={{}}
height={testBodyHeight}
id={'timeline-test'}
isSelectAllChecked={false}
getNotesByIds={mockGetNotesByIds}
loadingEventIds={[]}
onColumnRemoved={jest.fn()}
onColumnResized={jest.fn()}
onColumnSorted={jest.fn()}
onFilterChange={jest.fn()}
onPinEvent={dispatchOnPinEvent}
onRowSelected={jest.fn()}
onSelectAll={jest.fn()}
onUnPinEvent={jest.fn()}
onUpdateColumns={jest.fn()}
pinnedEventIds={{}}
rowRenderers={rowRenderers}
selectedEventIds={{}}
show={true}
sort={mockSort}
showCheckboxes={false}
toggleColumn={jest.fn()}
updateNote={jest.fn()}
/>
</TestProviders> </TestProviders>
); );
addaNoteToEvent(wrapper, 'hello world'); addaNoteToEvent(wrapper, 'hello world');
@ -290,44 +189,13 @@ describe('Body', () => {
}); });
test('Add two Note to an event', () => { test('Add two Note to an event', () => {
const Proxy = (props: BodyProps) => ( const Proxy = (proxyProps: BodyProps) => (
<TestProviders> <TestProviders>
<Body {...props} /> <Body {...proxyProps} />
</TestProviders> </TestProviders>
); );
const wrapper = mount( const wrapper = mount(<Proxy {...testProps} />);
<Proxy
addNoteToEvent={dispatchAddNoteToEvent}
browserFields={mockBrowserFields}
columnHeaders={defaultHeaders}
columnRenderers={columnRenderers}
data={mockTimelineData}
eventIdToNoteIds={{}}
height={testBodyHeight}
id={'timeline-test'}
isSelectAllChecked={false}
getNotesByIds={mockGetNotesByIds}
loadingEventIds={[]}
onColumnRemoved={jest.fn()}
onColumnResized={jest.fn()}
onColumnSorted={jest.fn()}
onFilterChange={jest.fn()}
onPinEvent={dispatchOnPinEvent}
onRowSelected={jest.fn()}
onSelectAll={jest.fn()}
onUnPinEvent={jest.fn()}
onUpdateColumns={jest.fn()}
pinnedEventIds={{}}
rowRenderers={rowRenderers}
selectedEventIds={{}}
show={true}
sort={mockSort}
showCheckboxes={false}
toggleColumn={jest.fn()}
updateNote={jest.fn()}
/>
);
addaNoteToEvent(wrapper, 'hello world'); addaNoteToEvent(wrapper, 'hello world');
dispatchAddNoteToEvent.mockClear(); dispatchAddNoteToEvent.mockClear();
dispatchOnPinEvent.mockClear(); dispatchOnPinEvent.mockClear();

View file

@ -13,6 +13,13 @@ export const NOTES_TOOLTIP = i18n.translate(
} }
); );
export const NOTES_DISABLE_TOOLTIP = i18n.translate(
'xpack.securitySolution.timeline.body.notes.disableEventTooltip',
{
defaultMessage: 'Add notes for event filtered by a timeline template is not allowed',
}
);
export const COPY_TO_CLIPBOARD = i18n.translate( export const COPY_TO_CLIPBOARD = i18n.translate(
'xpack.securitySolution.timeline.body.copyToClipboardButtonLabel', 'xpack.securitySolution.timeline.body.copyToClipboardButtonLabel',
{ {
@ -38,6 +45,13 @@ export const PINNED_WITH_NOTES = i18n.translate(
} }
); );
export const DISABLE_PIN = i18n.translate(
'xpack.securitySolution.timeline.body.pinning.disablePinnnedTooltip',
{
defaultMessage: 'This event cannot be pinned because it is filtered by a timeline template',
}
);
export const EXPAND = i18n.translate( export const EXPAND = i18n.translate(
'xpack.securitySolution.timeline.body.actions.expandAriaLabel', 'xpack.securitySolution.timeline.body.actions.expandAriaLabel',
{ {

View file

@ -15,6 +15,7 @@ import { mockDataProviders } from '../data_providers/mock/mock_data_providers';
import { useMountAppended } from '../../../../common/utils/use_mount_appended'; import { useMountAppended } from '../../../../common/utils/use_mount_appended';
import { TimelineHeader } from '.'; import { TimelineHeader } from '.';
import { TimelineStatus } from '../../../../../common/types/timeline';
const mockUiSettingsForFilterManager = createKibanaCoreStartMock().uiSettings; const mockUiSettingsForFilterManager = createKibanaCoreStartMock().uiSettings;
@ -23,43 +24,32 @@ jest.mock('../../../../common/lib/kibana');
describe('Header', () => { describe('Header', () => {
const indexPattern = mockIndexPattern; const indexPattern = mockIndexPattern;
const mount = useMountAppended(); const mount = useMountAppended();
const props = {
browserFields: {},
dataProviders: mockDataProviders,
filterManager: new FilterManager(mockUiSettingsForFilterManager),
id: 'foo',
indexPattern,
onDataProviderEdited: jest.fn(),
onDataProviderRemoved: jest.fn(),
onToggleDataProviderEnabled: jest.fn(),
onToggleDataProviderExcluded: jest.fn(),
show: true,
showCallOutUnauthorizedMsg: false,
status: TimelineStatus.active,
};
describe('rendering', () => { describe('rendering', () => {
test('renders correctly against snapshot', () => { test('renders correctly against snapshot', () => {
const wrapper = shallow( const wrapper = shallow(<TimelineHeader {...props} />);
<TimelineHeader
browserFields={{}}
dataProviders={mockDataProviders}
filterManager={new FilterManager(mockUiSettingsForFilterManager)}
id="foo"
indexPattern={indexPattern}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}
onToggleDataProviderExcluded={jest.fn()}
show={true}
showCallOutUnauthorizedMsg={false}
/>
);
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
test('it renders the data providers when show is true', () => { test('it renders the data providers when show is true', () => {
const testProps = { ...props, show: true };
const wrapper = mount( const wrapper = mount(
<TestProviders> <TestProviders>
<TimelineHeader <TimelineHeader {...testProps} />
browserFields={{}}
dataProviders={mockDataProviders}
filterManager={new FilterManager(mockUiSettingsForFilterManager)}
id="foo"
indexPattern={indexPattern}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}
onToggleDataProviderExcluded={jest.fn()}
show={true}
showCallOutUnauthorizedMsg={false}
/>
</TestProviders> </TestProviders>
); );
@ -67,21 +57,11 @@ describe('Header', () => {
}); });
test('it does NOT render the data providers when show is false', () => { test('it does NOT render the data providers when show is false', () => {
const testProps = { ...props, show: false };
const wrapper = mount( const wrapper = mount(
<TestProviders> <TestProviders>
<TimelineHeader <TimelineHeader {...testProps} />
browserFields={{}}
dataProviders={mockDataProviders}
filterManager={new FilterManager(mockUiSettingsForFilterManager)}
id="foo"
indexPattern={indexPattern}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}
onToggleDataProviderExcluded={jest.fn()}
show={false}
showCallOutUnauthorizedMsg={false}
/>
</TestProviders> </TestProviders>
); );
@ -89,21 +69,15 @@ describe('Header', () => {
}); });
test('it renders the unauthorized call out providers', () => { test('it renders the unauthorized call out providers', () => {
const testProps = {
...props,
filterManager: new FilterManager(mockUiSettingsForFilterManager),
showCallOutUnauthorizedMsg: true,
};
const wrapper = mount( const wrapper = mount(
<TestProviders> <TestProviders>
<TimelineHeader <TimelineHeader {...testProps} />
browserFields={{}}
dataProviders={mockDataProviders}
filterManager={new FilterManager(mockUiSettingsForFilterManager)}
id="foo"
indexPattern={indexPattern}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}
onToggleDataProviderExcluded={jest.fn()}
show={true}
showCallOutUnauthorizedMsg={true}
/>
</TestProviders> </TestProviders>
); );

View file

@ -22,6 +22,10 @@ import { StatefulSearchOrFilter } from '../search_or_filter';
import { BrowserFields } from '../../../../common/containers/source'; import { BrowserFields } from '../../../../common/containers/source';
import * as i18n from './translations'; import * as i18n from './translations';
import {
TimelineStatus,
TimelineStatusLiteralWithNull,
} from '../../../../../common/types/timeline';
interface Props { interface Props {
browserFields: BrowserFields; browserFields: BrowserFields;
@ -36,6 +40,7 @@ interface Props {
onToggleDataProviderExcluded: OnToggleDataProviderExcluded; onToggleDataProviderExcluded: OnToggleDataProviderExcluded;
show: boolean; show: boolean;
showCallOutUnauthorizedMsg: boolean; showCallOutUnauthorizedMsg: boolean;
status: TimelineStatusLiteralWithNull;
} }
const TimelineHeaderComponent: React.FC<Props> = ({ const TimelineHeaderComponent: React.FC<Props> = ({
@ -51,6 +56,7 @@ const TimelineHeaderComponent: React.FC<Props> = ({
onToggleDataProviderExcluded, onToggleDataProviderExcluded,
show, show,
showCallOutUnauthorizedMsg, showCallOutUnauthorizedMsg,
status,
}) => ( }) => (
<> <>
{showCallOutUnauthorizedMsg && ( {showCallOutUnauthorizedMsg && (
@ -62,7 +68,15 @@ const TimelineHeaderComponent: React.FC<Props> = ({
size="s" size="s"
/> />
)} )}
{status === TimelineStatus.immutable && (
<EuiCallOut
data-test-subj="timelineImmutableCallOut"
title={i18n.CALL_OUT_IMMUTIABLE}
color="primary"
iconType="info"
size="s"
/>
)}
{show && !showGraphView(graphEventId) && ( {show && !showGraphView(graphEventId) && (
<> <>
<DataProviders <DataProviders
@ -100,5 +114,6 @@ export const TimelineHeader = React.memo(
prevProps.onToggleDataProviderEnabled === nextProps.onToggleDataProviderEnabled && prevProps.onToggleDataProviderEnabled === nextProps.onToggleDataProviderEnabled &&
prevProps.onToggleDataProviderExcluded === nextProps.onToggleDataProviderExcluded && prevProps.onToggleDataProviderExcluded === nextProps.onToggleDataProviderExcluded &&
prevProps.show === nextProps.show && prevProps.show === nextProps.show &&
prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg &&
prevProps.status === nextProps.status
); );

View file

@ -13,3 +13,11 @@ export const CALL_OUT_UNAUTHORIZED_MSG = i18n.translate(
'You can use Timeline to investigate events, but you do not have the required permissions to save timelines for future use. If you need to save timelines, contact your Kibana administrator.', 'You can use Timeline to investigate events, but you do not have the required permissions to save timelines for future use. If you need to save timelines, contact your Kibana administrator.',
} }
); );
export const CALL_OUT_IMMUTIABLE = i18n.translate(
'xpack.securitySolution.timeline.callOut.immutable.message.description',
{
defaultMessage:
'This timeline is immutable, therefore not allowed to save it within the security application, though you may continue to use the timeline to search and filter security events',
}
);

View file

@ -25,6 +25,7 @@ import { Sort } from './body/sort';
import { mockDataProviders } from './data_providers/mock/mock_data_providers'; import { mockDataProviders } from './data_providers/mock/mock_data_providers';
import { StatefulTimeline, Props as StatefulTimelineProps } from './index'; import { StatefulTimeline, Props as StatefulTimelineProps } from './index';
import { Timeline } from './timeline'; import { Timeline } from './timeline';
import { TimelineType, TimelineStatus } from '../../../../common/types/timeline';
jest.mock('../../../common/lib/kibana', () => { jest.mock('../../../common/lib/kibana', () => {
const originalModule = jest.requireActual('../../../common/lib/kibana'); const originalModule = jest.requireActual('../../../common/lib/kibana');
@ -88,6 +89,8 @@ describe('StatefulTimeline', () => {
showCallOutUnauthorizedMsg: false, showCallOutUnauthorizedMsg: false,
sort, sort,
start: startDate, start: startDate,
status: TimelineStatus.active,
timelineType: TimelineType.default,
updateColumns: timelineActions.updateColumns, updateColumns: timelineActions.updateColumns,
updateDataProviderEnabled: timelineActions.updateDataProviderEnabled, updateDataProviderEnabled: timelineActions.updateDataProviderEnabled,
updateDataProviderExcluded: timelineActions.updateDataProviderExcluded, updateDataProviderExcluded: timelineActions.updateDataProviderExcluded,

View file

@ -57,6 +57,8 @@ const StatefulTimelineComponent = React.memo<Props>(
showCallOutUnauthorizedMsg, showCallOutUnauthorizedMsg,
sort, sort,
start, start,
status,
timelineType,
updateDataProviderEnabled, updateDataProviderEnabled,
updateDataProviderExcluded, updateDataProviderExcluded,
updateItemsPerPage, updateItemsPerPage,
@ -189,6 +191,7 @@ const StatefulTimelineComponent = React.memo<Props>(
showCallOutUnauthorizedMsg={showCallOutUnauthorizedMsg} showCallOutUnauthorizedMsg={showCallOutUnauthorizedMsg}
sort={sort!} sort={sort!}
start={start} start={start}
status={status}
toggleColumn={toggleColumn} toggleColumn={toggleColumn}
usersViewing={usersViewing} usersViewing={usersViewing}
/> />
@ -207,6 +210,8 @@ const StatefulTimelineComponent = React.memo<Props>(
prevProps.show === nextProps.show && prevProps.show === nextProps.show &&
prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg && prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg &&
prevProps.start === nextProps.start && prevProps.start === nextProps.start &&
prevProps.timelineType === nextProps.timelineType &&
prevProps.status === nextProps.status &&
deepEqual(prevProps.columns, nextProps.columns) && deepEqual(prevProps.columns, nextProps.columns) &&
deepEqual(prevProps.dataProviders, nextProps.dataProviders) && deepEqual(prevProps.dataProviders, nextProps.dataProviders) &&
deepEqual(prevProps.filters, nextProps.filters) && deepEqual(prevProps.filters, nextProps.filters) &&
@ -238,11 +243,12 @@ const makeMapStateToProps = () => {
kqlMode, kqlMode,
show, show,
sort, sort,
status,
timelineType,
} = timeline; } = timeline;
const kqlQueryExpression = getKqlQueryTimeline(state, id)!; const kqlQueryExpression = getKqlQueryTimeline(state, id)!;
const timelineFilter = kqlMode === 'filter' ? filters || [] : []; const timelineFilter = kqlMode === 'filter' ? filters || [] : [];
return { return {
columns, columns,
dataProviders, dataProviders,
@ -261,6 +267,8 @@ const makeMapStateToProps = () => {
showCallOutUnauthorizedMsg: getShowCallOutUnauthorizedMsg(state), showCallOutUnauthorizedMsg: getShowCallOutUnauthorizedMsg(state),
sort, sort,
start: input.timerange.from, start: input.timerange.from,
status,
timelineType,
}; };
}; };
return mapStateToProps; return mapStateToProps;

View file

@ -8,6 +8,8 @@ import { EuiButtonIcon, IconSize } from '@elastic/eui';
import { noop } from 'lodash/fp'; import { noop } from 'lodash/fp';
import React from 'react'; import React from 'react';
import { TimelineType, TimelineTypeLiteral } from '../../../../../common/types/timeline';
import * as i18n from '../body/translations'; import * as i18n from '../body/translations';
export type PinIcon = 'pin' | 'pinFilled'; export type PinIcon = 'pin' | 'pinFilled';
@ -17,21 +19,25 @@ export const getPinIcon = (pinned: boolean): PinIcon => (pinned ? 'pinFilled' :
interface Props { interface Props {
allowUnpinning: boolean; allowUnpinning: boolean;
iconSize?: IconSize; iconSize?: IconSize;
timelineType?: TimelineTypeLiteral;
onClick?: () => void; onClick?: () => void;
pinned: boolean; pinned: boolean;
} }
export const Pin = React.memo<Props>( export const Pin = React.memo<Props>(
({ allowUnpinning, iconSize = 'm', onClick = noop, pinned }) => ( ({ allowUnpinning, iconSize = 'm', onClick = noop, pinned, timelineType }) => {
<EuiButtonIcon const isTemplate = timelineType === TimelineType.template;
aria-label={pinned ? i18n.PINNED : i18n.UNPINNED} return (
data-test-subj="pin" <EuiButtonIcon
iconSize={iconSize} aria-label={pinned ? i18n.PINNED : i18n.UNPINNED}
iconType={getPinIcon(pinned)} data-test-subj="pin"
isDisabled={allowUnpinning ? false : true} iconSize={iconSize}
onClick={onClick} iconType={getPinIcon(pinned)}
/> onClick={onClick}
) isDisabled={isTemplate}
/>
);
}
); );
Pin.displayName = 'Pin'; Pin.displayName = 'Pin';

View file

@ -27,6 +27,7 @@ import {
TimelineTypeLiteral, TimelineTypeLiteral,
TimelineStatus, TimelineStatus,
TimelineType, TimelineType,
TimelineStatusLiteral,
TimelineId, TimelineId,
} from '../../../../../common/types/timeline'; } from '../../../../../common/types/timeline';
import { SecurityPageName } from '../../../../app/types'; import { SecurityPageName } from '../../../../app/types';
@ -262,11 +263,13 @@ interface NotesButtonProps {
getNotesByIds: (noteIds: string[]) => Note[]; getNotesByIds: (noteIds: string[]) => Note[];
noteIds: string[]; noteIds: string[];
size: 's' | 'l'; size: 's' | 'l';
status: TimelineStatusLiteral;
showNotes: boolean; showNotes: boolean;
toggleShowNotes: () => void; toggleShowNotes: () => void;
text?: string; text?: string;
toolTip?: string; toolTip?: string;
updateNote: UpdateNote; updateNote: UpdateNote;
timelineType: TimelineTypeLiteral;
} }
const getNewNoteId = (): string => uuid.v4(); const getNewNoteId = (): string => uuid.v4();
@ -303,16 +306,24 @@ LargeNotesButton.displayName = 'LargeNotesButton';
interface SmallNotesButtonProps { interface SmallNotesButtonProps {
noteIds: string[]; noteIds: string[];
toggleShowNotes: () => void; toggleShowNotes: () => void;
timelineType: TimelineTypeLiteral;
} }
const SmallNotesButton = React.memo<SmallNotesButtonProps>(({ noteIds, toggleShowNotes }) => ( const SmallNotesButton = React.memo<SmallNotesButtonProps>(
<EuiButtonIcon ({ noteIds, toggleShowNotes, timelineType }) => {
aria-label={i18n.NOTES} const isTemplate = timelineType === TimelineType.template;
data-test-subj="timeline-notes-button-small"
iconType="editorComment" return (
onClick={() => toggleShowNotes()} <EuiButtonIcon
/> aria-label={i18n.NOTES}
)); data-test-subj="timeline-notes-button-small"
iconType="editorComment"
onClick={() => toggleShowNotes()}
isDisabled={isTemplate}
/>
);
}
);
SmallNotesButton.displayName = 'SmallNotesButton'; SmallNotesButton.displayName = 'SmallNotesButton';
/** /**
@ -326,25 +337,32 @@ const NotesButtonComponent = React.memo<NotesButtonProps>(
noteIds, noteIds,
showNotes, showNotes,
size, size,
status,
toggleShowNotes, toggleShowNotes,
text, text,
updateNote, updateNote,
timelineType,
}) => ( }) => (
<ButtonContainer animate={animate} data-test-subj="timeline-notes-button-container"> <ButtonContainer animate={animate} data-test-subj="timeline-notes-button-container">
<> <>
{size === 'l' ? ( {size === 'l' ? (
<LargeNotesButton noteIds={noteIds} text={text} toggleShowNotes={toggleShowNotes} /> <LargeNotesButton noteIds={noteIds} text={text} toggleShowNotes={toggleShowNotes} />
) : ( ) : (
<SmallNotesButton noteIds={noteIds} toggleShowNotes={toggleShowNotes} /> <SmallNotesButton
noteIds={noteIds}
toggleShowNotes={toggleShowNotes}
timelineType={timelineType}
/>
)} )}
{size === 'l' && showNotes ? ( {size === 'l' && showNotes ? (
<EuiOverlayMask> <EuiOverlayMask>
<EuiModal maxWidth={NOTES_PANEL_WIDTH} onClose={toggleShowNotes}> <EuiModal maxWidth={NOTES_PANEL_WIDTH} onClose={toggleShowNotes}>
<Notes <Notes
associateNote={associateNote} associateNote={associateNote}
getNotesByIds={getNotesByIds}
noteIds={noteIds}
getNewNoteId={getNewNoteId} getNewNoteId={getNewNoteId}
getNotesByIds={getNotesByIds}
status={status}
noteIds={noteIds}
updateNote={updateNote} updateNote={updateNote}
/> />
</EuiModal> </EuiModal>
@ -364,6 +382,8 @@ export const NotesButton = React.memo<NotesButtonProps>(
noteIds, noteIds,
showNotes, showNotes,
size, size,
status,
timelineType,
toggleShowNotes, toggleShowNotes,
toolTip, toolTip,
text, text,
@ -377,9 +397,11 @@ export const NotesButton = React.memo<NotesButtonProps>(
noteIds={noteIds} noteIds={noteIds}
showNotes={showNotes} showNotes={showNotes}
size={size} size={size}
status={status}
toggleShowNotes={toggleShowNotes} toggleShowNotes={toggleShowNotes}
text={text} text={text}
updateNote={updateNote} updateNote={updateNote}
timelineType={timelineType}
/> />
) : ( ) : (
<EuiToolTip content={toolTip || ''} data-test-subj="timeline-notes-tool-tip"> <EuiToolTip content={toolTip || ''} data-test-subj="timeline-notes-tool-tip">
@ -390,9 +412,11 @@ export const NotesButton = React.memo<NotesButtonProps>(
noteIds={noteIds} noteIds={noteIds}
showNotes={showNotes} showNotes={showNotes}
size={size} size={size}
status={status}
toggleShowNotes={toggleShowNotes} toggleShowNotes={toggleShowNotes}
text={text} text={text}
updateNote={updateNote} updateNote={updateNote}
timelineType={timelineType}
/> />
</EuiToolTip> </EuiToolTip>
) )

View file

@ -6,13 +6,14 @@
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import React from 'react'; import React from 'react';
import { TimelineStatus } from '../../../../../common/types/timeline'; import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline';
import { import {
mockGlobalState, mockGlobalState,
apolloClientObservable, apolloClientObservable,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
TestProviders, TestProviders,
kibanaObservable,
} from '../../../../common/mock'; } from '../../../../common/mock';
import { createStore, State } from '../../../../common/store'; import { createStore, State } from '../../../../common/store';
import { useThrottledResizeObserver } from '../../../../common/components/utils'; import { useThrottledResizeObserver } from '../../../../common/components/utils';
@ -86,6 +87,7 @@ const defaultProps = {
isDatepickerLocked: false, isDatepickerLocked: false,
isFavorite: false, isFavorite: false,
title: '', title: '',
timelineType: TimelineType.default,
description: '', description: '',
getNotesByIds: jest.fn(), getNotesByIds: jest.fn(),
noteIds: [], noteIds: [],
@ -103,11 +105,23 @@ describe('Properties', () => {
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
let mockedWidth = 1000; let mockedWidth = 1000;
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
(useThrottledResizeObserver as jest.Mock).mockReturnValue({ width: mockedWidth }); (useThrottledResizeObserver as jest.Mock).mockReturnValue({ width: mockedWidth });
}); });
@ -130,9 +144,10 @@ describe('Properties', () => {
}); });
test('renders correctly draft timeline', () => { test('renders correctly draft timeline', () => {
const testProps = { ...defaultProps, status: TimelineStatus.draft };
const wrapper = mount( const wrapper = mount(
<TestProviders store={store}> <TestProviders store={store}>
<Properties {...{ ...defaultProps, status: TimelineStatus.draft }} /> <Properties {...testProps} />
</TestProviders> </TestProviders>
); );
@ -157,9 +172,11 @@ describe('Properties', () => {
}); });
test('it renders a filled star icon when it is a favorite', () => { test('it renders a filled star icon when it is a favorite', () => {
const testProps = { ...defaultProps, isFavorite: true };
const wrapper = mount( const wrapper = mount(
<TestProviders store={store}> <TestProviders store={store}>
<Properties {...{ ...defaultProps, isFavorite: true }} /> <Properties {...testProps} />
</TestProviders> </TestProviders>
); );
@ -168,10 +185,10 @@ describe('Properties', () => {
test('it renders the title of the timeline', () => { test('it renders the title of the timeline', () => {
const title = 'foozle'; const title = 'foozle';
const testProps = { ...defaultProps, title };
const wrapper = mount( const wrapper = mount(
<TestProviders store={store}> <TestProviders store={store}>
<Properties {...{ ...defaultProps, title }} /> <Properties {...testProps} />
</TestProviders> </TestProviders>
); );
@ -194,9 +211,11 @@ describe('Properties', () => {
}); });
test('it renders the lock icon when isDatepickerLocked is true', () => { test('it renders the lock icon when isDatepickerLocked is true', () => {
const testProps = { ...defaultProps, isDatepickerLocked: true };
const wrapper = mount( const wrapper = mount(
<TestProviders store={store}> <TestProviders store={store}>
<Properties {...{ ...defaultProps, isDatepickerLocked: true }} /> <Properties {...testProps} />
</TestProviders> </TestProviders>
); );
expect( expect(
@ -223,13 +242,16 @@ describe('Properties', () => {
test('it renders a description on the left when the width is at least as wide as the threshold', () => { test('it renders a description on the left when the width is at least as wide as the threshold', () => {
const description = 'strange'; const description = 'strange';
const testProps = { ...defaultProps, description };
// mockedWidth = showDescriptionThreshold;
(useThrottledResizeObserver as jest.Mock).mockReset(); (useThrottledResizeObserver as jest.Mock).mockReset();
(useThrottledResizeObserver as jest.Mock).mockReturnValue({ width: showDescriptionThreshold }); (useThrottledResizeObserver as jest.Mock).mockReturnValue({ width: showDescriptionThreshold });
const wrapper = mount( const wrapper = mount(
<TestProviders store={store}> <TestProviders store={store}>
<Properties {...{ ...defaultProps, description }} /> <Properties {...testProps} />
</TestProviders> </TestProviders>
); );
@ -244,6 +266,9 @@ describe('Properties', () => {
test('it does NOT render a description on the left when the width is less than the threshold', () => { test('it does NOT render a description on the left when the width is less than the threshold', () => {
const description = 'strange'; const description = 'strange';
const testProps = { ...defaultProps, description };
// mockedWidth = showDescriptionThreshold - 1;
(useThrottledResizeObserver as jest.Mock).mockReset(); (useThrottledResizeObserver as jest.Mock).mockReset();
(useThrottledResizeObserver as jest.Mock).mockReturnValue({ (useThrottledResizeObserver as jest.Mock).mockReturnValue({
@ -252,7 +277,7 @@ describe('Properties', () => {
const wrapper = mount( const wrapper = mount(
<TestProviders store={store}> <TestProviders store={store}>
<Properties {...{ ...defaultProps, description }} /> <Properties {...testProps} />
</TestProviders> </TestProviders>
); );
@ -313,10 +338,11 @@ describe('Properties', () => {
test('it renders an avatar for the current user viewing the timeline when it has a title', () => { test('it renders an avatar for the current user viewing the timeline when it has a title', () => {
const title = 'port scan'; const title = 'port scan';
const testProps = { ...defaultProps, title };
const wrapper = mount( const wrapper = mount(
<TestProviders store={store}> <TestProviders store={store}>
<Properties {...{ ...defaultProps, title }} /> <Properties {...testProps} />
</TestProviders> </TestProviders>
); );
@ -334,9 +360,11 @@ describe('Properties', () => {
}); });
test('insert timeline - new case', async () => { test('insert timeline - new case', async () => {
const testProps = { ...defaultProps, title: 'coolness' };
const wrapper = mount( const wrapper = mount(
<TestProviders store={store}> <TestProviders store={store}>
<Properties {...{ ...defaultProps, title: 'coolness' }} /> <Properties {...testProps} />
</TestProviders> </TestProviders>
); );
wrapper.find('[data-test-subj="settings-gear"]').at(0).simulate('click'); wrapper.find('[data-test-subj="settings-gear"]').at(0).simulate('click');
@ -352,9 +380,11 @@ describe('Properties', () => {
}); });
test('insert timeline - existing case', async () => { test('insert timeline - existing case', async () => {
const testProps = { ...defaultProps, title: 'coolness' };
const wrapper = mount( const wrapper = mount(
<TestProviders store={store}> <TestProviders store={store}>
<Properties {...{ ...defaultProps, title: 'coolness' }} /> <Properties {...testProps} />
</TestProviders> </TestProviders>
); );
wrapper.find('[data-test-subj="settings-gear"]').at(0).simulate('click'); wrapper.find('[data-test-subj="settings-gear"]').at(0).simulate('click');

View file

@ -7,7 +7,7 @@
import React, { useState, useCallback, useMemo } from 'react'; import React, { useState, useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { TimelineStatus, TimelineTypeLiteral } from '../../../../../common/types/timeline'; import { TimelineStatusLiteral, TimelineTypeLiteral } from '../../../../../common/types/timeline';
import { useThrottledResizeObserver } from '../../../../common/components/utils'; import { useThrottledResizeObserver } from '../../../../common/components/utils';
import { Note } from '../../../../common/lib/note'; import { Note } from '../../../../common/lib/note';
import { InputsModelId } from '../../../../common/store/inputs/constants'; import { InputsModelId } from '../../../../common/store/inputs/constants';
@ -52,7 +52,8 @@ interface Props {
isFavorite: boolean; isFavorite: boolean;
noteIds: string[]; noteIds: string[];
timelineId: string; timelineId: string;
status: TimelineStatus; timelineType: TimelineTypeLiteral;
status: TimelineStatusLiteral;
title: string; title: string;
toggleLock: ToggleLock; toggleLock: ToggleLock;
updateDescription: UpdateDescription; updateDescription: UpdateDescription;
@ -87,6 +88,7 @@ export const Properties = React.memo<Props>(
noteIds, noteIds,
status, status,
timelineId, timelineId,
timelineType,
title, title,
toggleLock, toggleLock,
updateDescription, updateDescription,
@ -164,10 +166,12 @@ export const Properties = React.memo<Props>(
isFavorite={isFavorite} isFavorite={isFavorite}
noteIds={noteIds} noteIds={noteIds}
onToggleShowNotes={onToggleShowNotes} onToggleShowNotes={onToggleShowNotes}
status={status}
showDescription={width >= showDescriptionThreshold} showDescription={width >= showDescriptionThreshold}
showNotes={showNotes} showNotes={showNotes}
showNotesFromWidth={width >= showNotesThreshold} showNotesFromWidth={width >= showNotesThreshold}
timelineId={timelineId} timelineId={timelineId}
timelineType={timelineType}
title={title} title={title}
toggleLock={onToggleLock} toggleLock={onToggleLock}
updateDescription={updateDescription} updateDescription={updateDescription}
@ -196,6 +200,7 @@ export const Properties = React.memo<Props>(
showUsersView={title.length > 0} showUsersView={title.length > 0}
status={status} status={status}
timelineId={timelineId} timelineId={timelineId}
timelineType={timelineType}
title={title} title={title}
updateDescription={updateDescription} updateDescription={updateDescription}
updateNote={updateNote} updateNote={updateNote}

View file

@ -11,6 +11,7 @@ import {
mockGlobalState, mockGlobalState,
apolloClientObservable, apolloClientObservable,
SUB_PLUGINS_REDUCER, SUB_PLUGINS_REDUCER,
kibanaObservable,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
} from '../../../../common/mock'; } from '../../../../common/mock';
import { createStore, State } from '../../../../common/store'; import { createStore, State } from '../../../../common/store';
@ -26,7 +27,13 @@ jest.mock('../../../../common/lib/kibana', () => {
describe('NewTemplateTimeline', () => { describe('NewTemplateTimeline', () => {
const state: State = mockGlobalState; const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
const store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); const store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
const mockClosePopover = jest.fn(); const mockClosePopover = jest.fn();
const mockTitle = 'NEW_TIMELINE'; const mockTitle = 'NEW_TIMELINE';
let wrapper: ReactWrapper; let wrapper: ReactWrapper;

View file

@ -10,9 +10,12 @@ import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { Description, Name, NotesButton, StarIcon } from './helpers'; import { Description, Name, NotesButton, StarIcon } from './helpers';
import { AssociateNote, UpdateNote } from '../../notes/helpers'; import { AssociateNote, UpdateNote } from '../../notes/helpers';
import { Note } from '../../../../common/lib/note'; import { Note } from '../../../../common/lib/note';
import { SuperDatePicker } from '../../../../common/components/super_date_picker'; import { SuperDatePicker } from '../../../../common/components/super_date_picker';
import { TimelineTypeLiteral, TimelineStatusLiteral } from '../../../../../common/types/timeline';
import * as i18n from './translations'; import * as i18n from './translations';
type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void; type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void;
@ -22,6 +25,7 @@ type UpdateDescription = ({ id, description }: { id: string; description: string
interface Props { interface Props {
isFavorite: boolean; isFavorite: boolean;
timelineId: string; timelineId: string;
timelineType: TimelineTypeLiteral;
updateIsFavorite: UpdateIsFavorite; updateIsFavorite: UpdateIsFavorite;
showDescription: boolean; showDescription: boolean;
description: string; description: string;
@ -29,6 +33,7 @@ interface Props {
updateTitle: UpdateTitle; updateTitle: UpdateTitle;
updateDescription: UpdateDescription; updateDescription: UpdateDescription;
showNotes: boolean; showNotes: boolean;
status: TimelineStatusLiteral;
associateNote: AssociateNote; associateNote: AssociateNote;
showNotesFromWidth: boolean; showNotesFromWidth: boolean;
getNotesByIds: (noteIds: string[]) => Note[]; getNotesByIds: (noteIds: string[]) => Note[];
@ -77,8 +82,10 @@ export const PropertiesLeft = React.memo<Props>(
showDescription, showDescription,
description, description,
title, title,
timelineType,
updateTitle, updateTitle,
updateDescription, updateDescription,
status,
showNotes, showNotes,
showNotesFromWidth, showNotesFromWidth,
associateNote, associateNote,
@ -120,10 +127,12 @@ export const PropertiesLeft = React.memo<Props>(
noteIds={noteIds} noteIds={noteIds}
showNotes={showNotes} showNotes={showNotes}
size="l" size="l"
status={status}
text={i18n.NOTES} text={i18n.NOTES}
toggleShowNotes={onToggleShowNotes} toggleShowNotes={onToggleShowNotes}
toolTip={i18n.NOTES_TOOL_TIP} toolTip={i18n.NOTES_TOOL_TIP}
updateNote={updateNote} updateNote={updateNote}
timelineType={timelineType}
/> />
</EuiFlexItem> </EuiFlexItem>
) : null} ) : null}

View file

@ -9,7 +9,7 @@ import React from 'react';
import { PropertiesRight } from './properties_right'; import { PropertiesRight } from './properties_right';
import { useKibana } from '../../../../common/lib/kibana'; import { useKibana } from '../../../../common/lib/kibana';
import { TimelineStatus } from '../../../../../common/types/timeline'; import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline';
import { disableTemplate } from '../../../../../common/constants'; import { disableTemplate } from '../../../../../common/constants';
jest.mock('../../../../common/lib/kibana', () => { jest.mock('../../../../common/lib/kibana', () => {
@ -67,6 +67,7 @@ describe('Properties Right', () => {
onOpenTimelineModal: jest.fn(), onOpenTimelineModal: jest.fn(),
status: TimelineStatus.active, status: TimelineStatus.active,
showTimelineModal: false, showTimelineModal: false,
timelineType: TimelineType.default,
title: 'title', title: 'title',
updateNote: jest.fn(), updateNote: jest.fn(),
}; };

View file

@ -17,7 +17,7 @@ import {
import { NewTimeline, Description, NotesButton, NewCase, ExistingCase } from './helpers'; import { NewTimeline, Description, NotesButton, NewCase, ExistingCase } from './helpers';
import { disableTemplate } from '../../../../../common/constants'; import { disableTemplate } from '../../../../../common/constants';
import { TimelineStatus } from '../../../../../common/types/timeline'; import { TimelineStatusLiteral, TimelineTypeLiteral } from '../../../../../common/types/timeline';
import { InspectButton, InspectButtonContainer } from '../../../../common/components/inspect'; import { InspectButton, InspectButtonContainer } from '../../../../common/components/inspect';
import { useKibana } from '../../../../common/lib/kibana'; import { useKibana } from '../../../../common/lib/kibana';
@ -83,9 +83,10 @@ interface PropertiesRightComponentProps {
showNotesFromWidth: boolean; showNotesFromWidth: boolean;
showTimelineModal: boolean; showTimelineModal: boolean;
showUsersView: boolean; showUsersView: boolean;
status: TimelineStatus; status: TimelineStatusLiteral;
timelineId: string; timelineId: string;
title: string; title: string;
timelineType: TimelineTypeLiteral;
updateDescription: UpdateDescription; updateDescription: UpdateDescription;
updateNote: UpdateNote; updateNote: UpdateNote;
usersViewing: string[]; usersViewing: string[];
@ -111,6 +112,7 @@ const PropertiesRightComponent: React.FC<PropertiesRightComponentProps> = ({
showTimelineModal, showTimelineModal,
showUsersView, showUsersView,
status, status,
timelineType,
timelineId, timelineId,
title, title,
updateDescription, updateDescription,
@ -203,6 +205,8 @@ const PropertiesRightComponent: React.FC<PropertiesRightComponentProps> = ({
noteIds={noteIds} noteIds={noteIds}
showNotes={showNotes} showNotes={showNotes}
size="l" size="l"
status={status}
timelineType={timelineType}
text={i18n.NOTES} text={i18n.NOTES}
toggleShowNotes={onToggleShowNotes} toggleShowNotes={onToggleShowNotes}
toolTip={i18n.NOTES_TOOL_TIP} toolTip={i18n.NOTES_TOOL_TIP}

View file

@ -112,7 +112,7 @@ export const NEW_TIMELINE = i18n.translate(
export const NEW_TEMPLATE_TIMELINE = i18n.translate( export const NEW_TEMPLATE_TIMELINE = i18n.translate(
'xpack.securitySolution.timeline.properties.newTemplateTimelineButtonLabel', 'xpack.securitySolution.timeline.properties.newTemplateTimelineButtonLabel',
{ {
defaultMessage: 'Create template timeline', defaultMessage: 'Create new timeline template',
} }
); );

View file

@ -30,11 +30,7 @@ describe('SelectableTimeline', () => {
}; };
}); });
const { const { SelectableTimeline, ORIGINAL_PAGE_SIZE } = jest.requireActual('./');
SelectableTimeline,
ORIGINAL_PAGE_SIZE,
} = jest.requireActual('./');
const props = { const props = {
hideUntitled: false, hideUntitled: false,
@ -94,8 +90,10 @@ describe('SelectableTimeline', () => {
sortField: SortFieldTimeline.updated, sortField: SortFieldTimeline.updated,
sortOrder: Direction.desc, sortOrder: Direction.desc,
}, },
status: null,
onlyUserFavorite: false, onlyUserFavorite: false,
timelineType: TimelineType.default, timelineType: TimelineType.default,
templateTimelineType: null,
}; };
beforeAll(() => { beforeAll(() => {
mount(<SelectableTimeline {...props} />); mount(<SelectableTimeline {...props} />);

View file

@ -33,6 +33,7 @@ import * as i18nTimeline from '../../open_timeline/translations';
import { OpenTimelineResult } from '../../open_timeline/types'; import { OpenTimelineResult } from '../../open_timeline/types';
import { getEmptyTagValue } from '../../../../common/components/empty_value'; import { getEmptyTagValue } from '../../../../common/components/empty_value';
import * as i18n from '../translations'; import * as i18n from '../translations';
import { useTimelineStatus } from '../../open_timeline/use_timeline_status';
const MyEuiFlexItem = styled(EuiFlexItem)` const MyEuiFlexItem = styled(EuiFlexItem)`
display: inline-block; display: inline-block;
@ -118,6 +119,7 @@ const SelectableTimelineComponent: React.FC<SelectableTimelineProps> = ({
const [onlyFavorites, setOnlyFavorites] = useState(false); const [onlyFavorites, setOnlyFavorites] = useState(false);
const [searchRef, setSearchRef] = useState<HTMLElement | null>(null); const [searchRef, setSearchRef] = useState<HTMLElement | null>(null);
const { fetchAllTimeline, timelines, loading, totalCount: timelineCount } = useGetAllTimeline(); const { fetchAllTimeline, timelines, loading, totalCount: timelineCount } = useGetAllTimeline();
const { timelineStatus, templateTimelineType } = useTimelineStatus({ timelineType });
const onSearchTimeline = useCallback((val) => { const onSearchTimeline = useCallback((val) => {
setSearchTimelineValue(val); setSearchTimelineValue(val);
@ -249,24 +251,31 @@ const SelectableTimelineComponent: React.FC<SelectableTimelineProps> = ({
}, },
}; };
useEffect( useEffect(() => {
() => fetchAllTimeline({
fetchAllTimeline({ pageInfo: {
pageInfo: { pageIndex: 1,
pageIndex: 1, pageSize,
pageSize, },
}, search: searchTimelineValue,
search: searchTimelineValue, sort: {
sort: { sortField: SortFieldTimeline.updated,
sortField: SortFieldTimeline.updated, sortOrder: Direction.desc,
sortOrder: Direction.desc, },
}, onlyUserFavorite: onlyFavorites,
onlyUserFavorite: onlyFavorites, status: timelineStatus,
timelineType, timelineType,
}), templateTimelineType,
// eslint-disable-next-line react-hooks/exhaustive-deps });
[onlyFavorites, pageSize, searchTimelineValue, timelineType] }, [
); fetchAllTimeline,
onlyFavorites,
pageSize,
searchTimelineValue,
timelineType,
timelineStatus,
templateTimelineType,
]);
return ( return (
<EuiSelectableContainer isLoading={loading}> <EuiSelectableContainer isLoading={loading}>

View file

@ -24,6 +24,7 @@ import { TimelineComponent, Props as TimelineComponentProps } from './timeline';
import { Sort } from './body/sort'; import { Sort } from './body/sort';
import { mockDataProviders } from './data_providers/mock/mock_data_providers'; import { mockDataProviders } from './data_providers/mock/mock_data_providers';
import { useMountAppended } from '../../../common/utils/use_mount_appended'; import { useMountAppended } from '../../../common/utils/use_mount_appended';
import { TimelineStatus } from '../../../../common/types/timeline';
jest.mock('../../../common/lib/kibana'); jest.mock('../../../common/lib/kibana');
jest.mock('./properties/properties_right'); jest.mock('./properties/properties_right');
@ -96,6 +97,7 @@ describe('Timeline', () => {
showCallOutUnauthorizedMsg: false, showCallOutUnauthorizedMsg: false,
start: startDate, start: startDate,
sort, sort,
status: TimelineStatus.active,
toggleColumn: jest.fn(), toggleColumn: jest.fn(),
usersViewing: ['elastic'], usersViewing: ['elastic'],
}; };

View file

@ -40,6 +40,7 @@ import {
IIndexPattern, IIndexPattern,
} from '../../../../../../../src/plugins/data/public'; } from '../../../../../../../src/plugins/data/public';
import { useManageTimeline } from '../manage_timeline'; import { useManageTimeline } from '../manage_timeline';
import { TimelineStatusLiteral } from '../../../../common/types/timeline';
const TimelineContainer = styled.div` const TimelineContainer = styled.div`
height: 100%; height: 100%;
@ -110,6 +111,7 @@ export interface Props {
showCallOutUnauthorizedMsg: boolean; showCallOutUnauthorizedMsg: boolean;
start: number; start: number;
sort: Sort; sort: Sort;
status: TimelineStatusLiteral;
toggleColumn: (column: ColumnHeaderOptions) => void; toggleColumn: (column: ColumnHeaderOptions) => void;
usersViewing: string[]; usersViewing: string[];
} }
@ -141,6 +143,7 @@ export const TimelineComponent: React.FC<Props> = ({
show, show,
showCallOutUnauthorizedMsg, showCallOutUnauthorizedMsg,
start, start,
status,
sort, sort,
toggleColumn, toggleColumn,
usersViewing, usersViewing,
@ -214,6 +217,7 @@ export const TimelineComponent: React.FC<Props> = ({
onToggleDataProviderExcluded={onToggleDataProviderExcluded} onToggleDataProviderExcluded={onToggleDataProviderExcluded}
show={show} show={show}
showCallOutUnauthorizedMsg={showCallOutUnauthorizedMsg} showCallOutUnauthorizedMsg={showCallOutUnauthorizedMsg}
status={status}
/> />
</TimelineHeaderContainer> </TimelineHeaderContainer>
</StyledEuiFlyoutHeader> </StyledEuiFlyoutHeader>

View file

@ -13,6 +13,8 @@ export const allTimelinesQuery = gql`
$sort: SortTimeline $sort: SortTimeline
$onlyUserFavorite: Boolean $onlyUserFavorite: Boolean
$timelineType: TimelineType $timelineType: TimelineType
$templateTimelineType: TemplateTimelineType
$status: TimelineStatus
) { ) {
getAllTimeline( getAllTimeline(
pageInfo: $pageInfo pageInfo: $pageInfo
@ -20,8 +22,15 @@ export const allTimelinesQuery = gql`
sort: $sort sort: $sort
onlyUserFavorite: $onlyUserFavorite onlyUserFavorite: $onlyUserFavorite
timelineType: $timelineType timelineType: $timelineType
templateTimelineType: $templateTimelineType
status: $status
) { ) {
totalCount totalCount
defaultTimelineCount
templateTimelineCount
elasticTemplateTimelineCount
customTemplateTimelineCount
favoriteCount
timeline { timeline {
savedObjectId savedObjectId
description description

View file

@ -22,7 +22,11 @@ import { useApolloClient } from '../../../common/utils/apollo_context';
import { allTimelinesQuery } from './index.gql_query'; import { allTimelinesQuery } from './index.gql_query';
import * as i18n from '../../pages/translations'; import * as i18n from '../../pages/translations';
import { TimelineTypeLiteralWithNull } from '../../../../common/types/timeline'; import {
TimelineTypeLiteralWithNull,
TimelineStatusLiteralWithNull,
TemplateTimelineTypeLiteralWithNull,
} from '../../../../common/types/timeline';
export interface AllTimelinesArgs { export interface AllTimelinesArgs {
fetchAllTimeline: ({ fetchAllTimeline: ({
@ -30,11 +34,17 @@ export interface AllTimelinesArgs {
pageInfo, pageInfo,
search, search,
sort, sort,
status,
timelineType, timelineType,
}: AllTimelinesVariables) => void; }: AllTimelinesVariables) => void;
timelines: OpenTimelineResult[]; timelines: OpenTimelineResult[];
loading: boolean; loading: boolean;
totalCount: number; totalCount: number;
customTemplateTimelineCount: number;
defaultTimelineCount: number;
elasticTemplateTimelineCount: number;
templateTimelineCount: number;
favoriteCount: number;
} }
export interface AllTimelinesVariables { export interface AllTimelinesVariables {
@ -42,7 +52,9 @@ export interface AllTimelinesVariables {
pageInfo: PageInfoTimeline; pageInfo: PageInfoTimeline;
search: string; search: string;
sort: SortTimeline; sort: SortTimeline;
status: TimelineStatusLiteralWithNull;
timelineType: TimelineTypeLiteralWithNull; timelineType: TimelineTypeLiteralWithNull;
templateTimelineType: TemplateTimelineTypeLiteralWithNull;
} }
export const ALL_TIMELINE_QUERY_ID = 'FETCH_ALL_TIMELINES'; export const ALL_TIMELINE_QUERY_ID = 'FETCH_ALL_TIMELINES';
@ -76,6 +88,7 @@ export const getAllTimeline = memoizeOne(
) )
: null, : null,
savedObjectId: timeline.savedObjectId, savedObjectId: timeline.savedObjectId,
status: timeline.status,
title: timeline.title, title: timeline.title,
updated: timeline.updated, updated: timeline.updated,
updatedBy: timeline.updatedBy, updatedBy: timeline.updatedBy,
@ -90,27 +103,39 @@ export const useGetAllTimeline = (): AllTimelinesArgs => {
loading: false, loading: false,
totalCount: 0, totalCount: 0,
timelines: [], timelines: [],
customTemplateTimelineCount: 0,
defaultTimelineCount: 0,
elasticTemplateTimelineCount: 0,
templateTimelineCount: 0,
favoriteCount: 0,
}); });
const fetchAllTimeline = useCallback( const fetchAllTimeline = useCallback(
({ onlyUserFavorite, pageInfo, search, sort, timelineType }: AllTimelinesVariables) => { async ({
onlyUserFavorite,
pageInfo,
search,
sort,
status,
timelineType,
templateTimelineType,
}: AllTimelinesVariables) => {
let didCancel = false; let didCancel = false;
const abortCtrl = new AbortController(); const abortCtrl = new AbortController();
const fetchData = async () => { const fetchData = async () => {
try { try {
if (apolloClient != null) { if (apolloClient != null) {
setAllTimelines({ setAllTimelines((prevState) => ({ ...prevState, loading: true }));
...allTimelines,
loading: true,
});
const variables: GetAllTimeline.Variables = { const variables: GetAllTimeline.Variables = {
onlyUserFavorite, onlyUserFavorite,
pageInfo, pageInfo,
search, search,
sort, sort,
status,
timelineType, timelineType,
templateTimelineType,
}; };
const response = await apolloClient.query< const response = await apolloClient.query<
GetAllTimeline.Query, GetAllTimeline.Query,
@ -125,8 +150,16 @@ export const useGetAllTimeline = (): AllTimelinesArgs => {
}, },
}, },
}); });
const totalCount = response?.data?.getAllTimeline?.totalCount ?? 0; const getAllTimelineResponse = response?.data?.getAllTimeline;
const timelines = response?.data?.getAllTimeline?.timeline ?? []; const totalCount = getAllTimelineResponse?.totalCount ?? 0;
const timelines = getAllTimelineResponse?.timeline ?? [];
const customTemplateTimelineCount =
getAllTimelineResponse?.customTemplateTimelineCount ?? 0;
const defaultTimelineCount = getAllTimelineResponse?.defaultTimelineCount ?? 0;
const elasticTemplateTimelineCount =
getAllTimelineResponse?.elasticTemplateTimelineCount ?? 0;
const templateTimelineCount = getAllTimelineResponse?.templateTimelineCount ?? 0;
const favoriteCount = getAllTimelineResponse?.favoriteCount ?? 0;
if (!didCancel) { if (!didCancel) {
dispatch( dispatch(
inputsActions.setQuery({ inputsActions.setQuery({
@ -141,6 +174,11 @@ export const useGetAllTimeline = (): AllTimelinesArgs => {
loading: false, loading: false,
totalCount, totalCount,
timelines: getAllTimeline(JSON.stringify(variables), timelines as TimelineResult[]), timelines: getAllTimeline(JSON.stringify(variables), timelines as TimelineResult[]),
customTemplateTimelineCount,
defaultTimelineCount,
elasticTemplateTimelineCount,
templateTimelineCount,
favoriteCount,
}); });
} }
} }
@ -155,6 +193,11 @@ export const useGetAllTimeline = (): AllTimelinesArgs => {
loading: false, loading: false,
totalCount: 0, totalCount: 0,
timelines: [], timelines: [],
customTemplateTimelineCount: 0,
defaultTimelineCount: 0,
elasticTemplateTimelineCount: 0,
templateTimelineCount: 0,
favoriteCount: 0,
}); });
} }
} }
@ -165,7 +208,7 @@ export const useGetAllTimeline = (): AllTimelinesArgs => {
abortCtrl.abort(); abortCtrl.abort();
}; };
}, },
[apolloClient, allTimelines, dispatch, dispatchToaster] [apolloClient, dispatch, dispatchToaster]
); );
useEffect(() => { useEffect(() => {

View file

@ -165,6 +165,7 @@ describe('persistTimeline', () => {
}, },
}, },
}; };
const version = null; const version = null;
const fetchMock = jest.fn(); const fetchMock = jest.fn();
const postMock = jest.fn(); const postMock = jest.fn();
@ -180,7 +181,11 @@ describe('persistTimeline', () => {
patch: patchMock.mockReturnValue(mockPatchTimelineResponse), patch: patchMock.mockReturnValue(mockPatchTimelineResponse),
}, },
}); });
api.persistTimeline({ timelineId, timeline: initialDraftTimeline, version }); api.persistTimeline({
timelineId,
timeline: initialDraftTimeline,
version,
});
}); });
afterAll(() => { afterAll(() => {

View file

@ -12,6 +12,8 @@ import {
TimelineResponse, TimelineResponse,
TimelineResponseType, TimelineResponseType,
TimelineStatus, TimelineStatus,
TimelineErrorResponseType,
TimelineErrorResponse,
} from '../../../common/types/timeline'; } from '../../../common/types/timeline';
import { TimelineInput, TimelineType } from '../../graphql/types'; import { TimelineInput, TimelineType } from '../../graphql/types';
import { import {
@ -48,6 +50,12 @@ const decodeTimelineResponse = (respTimeline?: TimelineResponse) =>
fold(throwErrors(createToasterPlainError), identity) fold(throwErrors(createToasterPlainError), identity)
); );
const decodeTimelineErrorResponse = (respTimeline?: TimelineErrorResponse) =>
pipe(
TimelineErrorResponseType.decode(respTimeline),
fold(throwErrors(createToasterPlainError), identity)
);
const postTimeline = async ({ timeline }: RequestPostTimeline): Promise<TimelineResponse> => { const postTimeline = async ({ timeline }: RequestPostTimeline): Promise<TimelineResponse> => {
const response = await KibanaServices.get().http.post<TimelineResponse>(TIMELINE_URL, { const response = await KibanaServices.get().http.post<TimelineResponse>(TIMELINE_URL, {
method: 'POST', method: 'POST',
@ -61,12 +69,19 @@ const patchTimeline = async ({
timelineId, timelineId,
timeline, timeline,
version, version,
}: RequestPatchTimeline): Promise<TimelineResponse> => { }: RequestPatchTimeline): Promise<TimelineResponse | TimelineErrorResponse> => {
const response = await KibanaServices.get().http.patch<TimelineResponse>(TIMELINE_URL, { let response = null;
method: 'PATCH', try {
body: JSON.stringify({ timeline, timelineId, version }), response = await KibanaServices.get().http.patch<TimelineResponse>(TIMELINE_URL, {
}); method: 'PATCH',
body: JSON.stringify({ timeline, timelineId, version }),
});
} catch (err) {
// For Future developer
// We are not rejecting our promise here because we had issue with our RXJS epic
// the issue we were not able to pass the right object to it so we did manage the error in the success
return Promise.resolve(decodeTimelineErrorResponse(err.body));
}
return decodeTimelineResponse(response); return decodeTimelineResponse(response);
}; };
@ -74,17 +89,31 @@ export const persistTimeline = async ({
timelineId, timelineId,
timeline, timeline,
version, version,
}: RequestPersistTimeline): Promise<TimelineResponse> => { }: RequestPersistTimeline): Promise<TimelineResponse | TimelineErrorResponse> => {
if (timelineId == null && timeline.status === TimelineStatus.draft) { if (timelineId == null && timeline.status === TimelineStatus.draft && timeline) {
const draftTimeline = await cleanDraftTimeline({ timelineType: timeline.timelineType! }); const draftTimeline = await cleanDraftTimeline({
timelineType: timeline.timelineType!,
templateTimelineId: timeline.templateTimelineId ?? undefined,
templateTimelineVersion: timeline.templateTimelineVersion ?? undefined,
});
const templateTimelineInfo =
timeline.timelineType! === TimelineType.template
? {
templateTimelineId:
draftTimeline.data.persistTimeline.timeline.templateTimelineId ??
timeline.templateTimelineId,
templateTimelineVersion:
draftTimeline.data.persistTimeline.timeline.templateTimelineVersion ??
timeline.templateTimelineVersion,
}
: {};
return patchTimeline({ return patchTimeline({
timelineId: draftTimeline.data.persistTimeline.timeline.savedObjectId, timelineId: draftTimeline.data.persistTimeline.timeline.savedObjectId,
timeline: { timeline: {
...timeline, ...timeline,
templateTimelineId: draftTimeline.data.persistTimeline.timeline.templateTimelineId, ...templateTimelineInfo,
templateTimelineVersion:
draftTimeline.data.persistTimeline.timeline.templateTimelineVersion,
}, },
version: draftTimeline.data.persistTimeline.timeline.version ?? '', version: draftTimeline.data.persistTimeline.timeline.version ?? '',
}); });
@ -147,12 +176,24 @@ export const getDraftTimeline = async ({
export const cleanDraftTimeline = async ({ export const cleanDraftTimeline = async ({
timelineType, timelineType,
templateTimelineId,
templateTimelineVersion,
}: { }: {
timelineType: TimelineType; timelineType: TimelineType;
templateTimelineId?: string;
templateTimelineVersion?: number;
}): Promise<TimelineResponse> => { }): Promise<TimelineResponse> => {
const templateTimelineInfo =
timelineType === TimelineType.template
? {
templateTimelineId,
templateTimelineVersion,
}
: {};
const response = await KibanaServices.get().http.post<TimelineResponse>(TIMELINE_DRAFT_URL, { const response = await KibanaServices.get().http.post<TimelineResponse>(TIMELINE_DRAFT_URL, {
body: JSON.stringify({ body: JSON.stringify({
timelineType, timelineType,
...templateTimelineInfo,
}), }),
}); });

View file

@ -30,3 +30,17 @@ export const ERROR_FETCHING_TIMELINES_TITLE = i18n.translate(
defaultMessage: 'Failed to query all timelines data', defaultMessage: 'Failed to query all timelines data',
} }
); );
export const UPDATE_TIMELINE_ERROR_TITLE = i18n.translate(
'xpack.securitySolution.timelines.updateTimelineErrorTitle',
{
defaultMessage: 'Timeline error',
}
);
export const UPDATE_TIMELINE_ERROR_TEXT = i18n.translate(
'xpack.securitySolution.timelines.updateTimelineErrorText',
{
defaultMessage: 'Something went wrong',
}
);

View file

@ -70,6 +70,8 @@ export const createTimeline = actionCreator<{
showCheckboxes?: boolean; showCheckboxes?: boolean;
showRowRenderers?: boolean; showRowRenderers?: boolean;
timelineType?: TimelineTypeLiteral; timelineType?: TimelineTypeLiteral;
templateTimelineId?: string;
templateTimelineVersion?: number;
}>('CREATE_TIMELINE'); }>('CREATE_TIMELINE');
export const pinEvent = actionCreator<{ id: string; eventId: string }>('PIN_EVENT'); export const pinEvent = actionCreator<{ id: string; eventId: string }>('PIN_EVENT');

View file

@ -33,7 +33,7 @@ import {
Filter, Filter,
MatchAllFilter, MatchAllFilter,
} from '../../../../../../.../../../src/plugins/data/public'; } from '../../../../../../.../../../src/plugins/data/public';
import { TimelineStatus } from '../../../../common/types/timeline'; import { TimelineStatus, TimelineErrorResponse } from '../../../../common/types/timeline';
import { inputsModel } from '../../../common/store/inputs'; import { inputsModel } from '../../../common/store/inputs';
import { import {
TimelineType, TimelineType,
@ -43,6 +43,10 @@ import {
} from '../../../graphql/types'; } from '../../../graphql/types';
import { addError } from '../../../common/store/app/actions'; import { addError } from '../../../common/store/app/actions';
import { persistTimeline } from '../../containers/api';
import { ALL_TIMELINE_QUERY_ID } from '../../containers/all';
import * as i18n from '../../pages/translations';
import { import {
applyKqlFilterQuery, applyKqlFilterQuery,
addProvider, addProvider,
@ -79,8 +83,6 @@ import { isNotNull } from './helpers';
import { dispatcherTimelinePersistQueue } from './epic_dispatcher_timeline_persistence_queue'; import { dispatcherTimelinePersistQueue } from './epic_dispatcher_timeline_persistence_queue';
import { myEpicTimelineId } from './my_epic_timeline_id'; import { myEpicTimelineId } from './my_epic_timeline_id';
import { ActionTimeline, TimelineEpicDependencies } from './types'; import { ActionTimeline, TimelineEpicDependencies } from './types';
import { persistTimeline } from '../../containers/api';
import { ALL_TIMELINE_QUERY_ID } from '../../containers/all';
const timelineActionsType = [ const timelineActionsType = [
applyKqlFilterQuery.type, applyKqlFilterQuery.type,
@ -121,6 +123,7 @@ export const createTimelineEpic = <State>(): Epic<
timelineByIdSelector, timelineByIdSelector,
timelineTimeRangeSelector, timelineTimeRangeSelector,
apolloClient$, apolloClient$,
kibana$,
} }
) => { ) => {
const timeline$ = state$.pipe(map(timelineByIdSelector), filter(isNotNull)); const timeline$ = state$.pipe(map(timelineByIdSelector), filter(isNotNull));
@ -146,13 +149,24 @@ export const createTimelineEpic = <State>(): Epic<
if (action.type === addError.type) { if (action.type === addError.type) {
return true; return true;
} }
if (action.type === createTimeline.type && isItAtimelineAction(timelineId)) { if (
isItAtimelineAction(timelineId) &&
timelineObj != null &&
timelineObj.status != null &&
TimelineStatus.immutable === timelineObj.status
) {
return false;
} else if (action.type === createTimeline.type && isItAtimelineAction(timelineId)) {
myEpicTimelineId.setTimelineVersion(null); myEpicTimelineId.setTimelineVersion(null);
myEpicTimelineId.setTimelineId(null); myEpicTimelineId.setTimelineId(null);
myEpicTimelineId.setTemplateTimelineId(null);
myEpicTimelineId.setTemplateTimelineVersion(null);
} else if (action.type === addTimeline.type && isItAtimelineAction(timelineId)) { } else if (action.type === addTimeline.type && isItAtimelineAction(timelineId)) {
const addNewTimeline: TimelineModel = get('payload.timeline', action); const addNewTimeline: TimelineModel = get('payload.timeline', action);
myEpicTimelineId.setTimelineId(addNewTimeline.savedObjectId); myEpicTimelineId.setTimelineId(addNewTimeline.savedObjectId);
myEpicTimelineId.setTimelineVersion(addNewTimeline.version); myEpicTimelineId.setTimelineVersion(addNewTimeline.version);
myEpicTimelineId.setTemplateTimelineId(addNewTimeline.templateTimelineId);
myEpicTimelineId.setTemplateTimelineVersion(addNewTimeline.templateTimelineVersion);
return true; return true;
} else if ( } else if (
timelineActionsType.includes(action.type) && timelineActionsType.includes(action.type) &&
@ -176,6 +190,8 @@ export const createTimelineEpic = <State>(): Epic<
const action: ActionTimeline = get('action', objAction); const action: ActionTimeline = get('action', objAction);
const timelineId = myEpicTimelineId.getTimelineId(); const timelineId = myEpicTimelineId.getTimelineId();
const version = myEpicTimelineId.getTimelineVersion(); const version = myEpicTimelineId.getTimelineVersion();
const templateTimelineId = myEpicTimelineId.getTemplateTimelineId();
const templateTimelineVersion = myEpicTimelineId.getTemplateTimelineVersion();
if (timelineNoteActionsType.includes(action.type)) { if (timelineNoteActionsType.includes(action.type)) {
return epicPersistNote( return epicPersistNote(
@ -211,13 +227,37 @@ export const createTimelineEpic = <State>(): Epic<
persistTimeline({ persistTimeline({
timelineId, timelineId,
version, version,
timeline: convertTimelineAsInput(timeline[action.payload.id], timelineTimeRange), timeline: {
...convertTimelineAsInput(timeline[action.payload.id], timelineTimeRange),
templateTimelineId,
templateTimelineVersion,
},
}) })
).pipe( ).pipe(
withLatestFrom(timeline$, allTimelineQuery$), withLatestFrom(timeline$, allTimelineQuery$, kibana$),
mergeMap(([result, recentTimeline, allTimelineQuery]) => { mergeMap(([result, recentTimeline, allTimelineQuery, kibana]) => {
const error = result as TimelineErrorResponse;
if (error.status_code != null && error.status_code === 405) {
kibana.notifications!.toasts.addDanger({
title: i18n.UPDATE_TIMELINE_ERROR_TITLE,
text: error.message ?? i18n.UPDATE_TIMELINE_ERROR_TEXT,
});
return [
endTimelineSaving({
id: action.payload.id,
}),
];
}
const savedTimeline = recentTimeline[action.payload.id]; const savedTimeline = recentTimeline[action.payload.id];
const response: ResponseTimeline = get('data.persistTimeline', result); const response: ResponseTimeline = get('data.persistTimeline', result);
if (response == null) {
return [
endTimelineSaving({
id: action.payload.id,
}),
];
}
const callOutMsg = response.code === 403 ? [showCallOutUnauthorizedMsg()] : []; const callOutMsg = response.code === 403 ? [showCallOutUnauthorizedMsg()] : [];
if (allTimelineQuery.refetch != null) { if (allTimelineQuery.refetch != null) {
@ -264,6 +304,12 @@ export const createTimelineEpic = <State>(): Epic<
myEpicTimelineId.setTimelineVersion( myEpicTimelineId.setTimelineVersion(
updatedTimeline[get('payload.id', checkAction)].version updatedTimeline[get('payload.id', checkAction)].version
); );
myEpicTimelineId.setTemplateTimelineId(
updatedTimeline[get('payload.id', checkAction)].templateTimelineId
);
myEpicTimelineId.setTemplateTimelineVersion(
updatedTimeline[get('payload.id', checkAction)].templateTimelineVersion
);
return true; return true;
} }
return false; return false;

View file

@ -15,6 +15,7 @@ import {
defaultHeaders, defaultHeaders,
createSecuritySolutionStorageMock, createSecuritySolutionStorageMock,
mockIndexPattern, mockIndexPattern,
kibanaObservable,
} from '../../../common/mock'; } from '../../../common/mock';
import { createStore, State } from '../../../common/store'; import { createStore, State } from '../../../common/store';
@ -38,6 +39,7 @@ import { Direction } from '../../../graphql/types';
import { addTimelineInStorage } from '../../containers/local_storage'; import { addTimelineInStorage } from '../../containers/local_storage';
import { isPageTimeline } from './epic_local_storage'; import { isPageTimeline } from './epic_local_storage';
import { TimelineStatus } from '../../../../common/types/timeline';
jest.mock('../../containers/local_storage'); jest.mock('../../containers/local_storage');
@ -50,7 +52,13 @@ const addTimelineInStorageMock = addTimelineInStorage as jest.Mock;
describe('epicLocalStorage', () => { describe('epicLocalStorage', () => {
const state: State = mockGlobalState; const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock(); const { storage } = createSecuritySolutionStorageMock();
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); let store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
let props = {} as TimelineComponentProps; let props = {} as TimelineComponentProps;
const sort: Sort = { const sort: Sort = {
@ -63,7 +71,13 @@ describe('epicLocalStorage', () => {
const indexPattern = mockIndexPattern; const indexPattern = mockIndexPattern;
beforeEach(() => { beforeEach(() => {
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); store = createStore(
state,
SUB_PLUGINS_REDUCER,
apolloClientObservable,
kibanaObservable,
storage
);
props = { props = {
browserFields: mockBrowserFields, browserFields: mockBrowserFields,
columns: defaultHeaders, columns: defaultHeaders,
@ -89,6 +103,7 @@ describe('epicLocalStorage', () => {
show: true, show: true,
showCallOutUnauthorizedMsg: false, showCallOutUnauthorizedMsg: false,
start: startDate, start: startDate,
status: TimelineStatus.active,
sort, sort,
toggleColumn: jest.fn(), toggleColumn: jest.fn(),
usersViewing: ['elastic'], usersViewing: ['elastic'],

View file

@ -6,6 +6,7 @@
import { getOr, omit, uniq, isEmpty, isEqualWith, union } from 'lodash/fp'; import { getOr, omit, uniq, isEmpty, isEqualWith, union } from 'lodash/fp';
import uuid from 'uuid';
import { Filter } from '../../../../../../../src/plugins/data/public'; import { Filter } from '../../../../../../../src/plugins/data/public';
import { disableTemplate } from '../../../../common/constants'; import { disableTemplate } from '../../../../common/constants';
@ -19,7 +20,7 @@ import {
} from '../../../timelines/components/timeline/data_providers/data_provider'; } from '../../../timelines/components/timeline/data_providers/data_provider';
import { KueryFilterQuery, SerializedFilterQuery } from '../../../common/store/model'; import { KueryFilterQuery, SerializedFilterQuery } from '../../../common/store/model';
import { TimelineNonEcsData } from '../../../graphql/types'; import { TimelineNonEcsData } from '../../../graphql/types';
import { TimelineTypeLiteral } from '../../../../common/types/timeline'; import { TimelineTypeLiteral, TimelineType } from '../../../../common/types/timeline';
import { timelineDefaults } from './defaults'; import { timelineDefaults } from './defaults';
import { ColumnHeaderOptions, KqlMode, TimelineModel, EventType } from './model'; import { ColumnHeaderOptions, KqlMode, TimelineModel, EventType } from './model';
@ -158,28 +159,38 @@ export const addNewTimeline = ({
showRowRenderers = true, showRowRenderers = true,
timelineById, timelineById,
timelineType, timelineType,
}: AddNewTimelineParams): TimelineById => ({ }: AddNewTimelineParams): TimelineById => {
...timelineById, const templateTimelineInfo =
[id]: { !disableTemplate && timelineType === TimelineType.template
id, ? {
...timelineDefaults, templateTimelineId: uuid.v4(),
columns, templateTimelineVersion: 1,
dataProviders, }
dateRange, : {};
filters, return {
itemsPerPage, ...timelineById,
kqlQuery, [id]: {
sort, id,
show, ...timelineDefaults,
savedObjectId: null, columns,
version: null, dataProviders,
isSaving: false, dateRange,
isLoading: false, filters,
showCheckboxes, itemsPerPage,
showRowRenderers, kqlQuery,
timelineType: !disableTemplate ? timelineType : timelineDefaults.timelineType, sort,
}, show,
}); savedObjectId: null,
version: null,
isSaving: false,
isLoading: false,
showCheckboxes,
showRowRenderers,
timelineType: !disableTemplate ? timelineType : timelineDefaults.timelineType,
...templateTimelineInfo,
},
};
};
interface PinTimelineEventParams { interface PinTimelineEventParams {
id: string; id: string;

View file

@ -7,6 +7,8 @@
export class ManageEpicTimelineId { export class ManageEpicTimelineId {
private timelineId: string | null = null; private timelineId: string | null = null;
private version: string | null = null; private version: string | null = null;
private templateTimelineId: string | null = null;
private templateVersion: number | null = null;
public getTimelineId(): string | null { public getTimelineId(): string | null {
return this.timelineId; return this.timelineId;
@ -16,6 +18,14 @@ export class ManageEpicTimelineId {
return this.version; return this.version;
} }
public getTemplateTimelineId(): string | null {
return this.templateTimelineId;
}
public getTemplateTimelineVersion(): number | null {
return this.templateVersion;
}
public setTimelineId(timelineId: string | null) { public setTimelineId(timelineId: string | null) {
this.timelineId = timelineId; this.timelineId = timelineId;
} }
@ -23,4 +33,12 @@ export class ManageEpicTimelineId {
public setTimelineVersion(version: string | null) { public setTimelineVersion(version: string | null) {
this.version = version; this.version = version;
} }
public setTemplateTimelineId(templateTimelineId: string | null) {
this.templateTimelineId = templateTimelineId;
}
public setTemplateTimelineVersion(templateVersion: number | null) {
this.templateVersion = templateVersion;
}
} }

View file

@ -160,7 +160,6 @@ export type SubsetTimelineModel = Readonly<
| 'isLoading' | 'isLoading'
| 'savedObjectId' | 'savedObjectId'
| 'version' | 'version'
| 'timelineType'
| 'status' | 'status'
> >
>; >;

Some files were not shown because too many files have changed in this diff Show more