diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts
index d04d1f2c91b97..7a2b531346ac3 100644
--- a/x-pack/plugins/security_solution/common/constants.ts
+++ b/x-pack/plugins/security_solution/common/constants.ts
@@ -143,3 +143,9 @@ export const showAllOthersBucket: string[] = [
'destination.ip',
'user.name',
];
+
+/**
+ * CreateTemplateTimelineBtn
+ * Remove the comment here to enable template timeline
+ */
+export const disableTemplate = true;
diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/__snapshots__/index.test.tsx.snap
deleted file mode 100644
index c0e0988168384..0000000000000
--- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/__snapshots__/index.test.tsx.snap
+++ /dev/null
@@ -1,5 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Matrix Histogram Component not initial load it renders no MatrixLoader 1`] = `"
"`;
-
-exports[`Matrix Histogram Component on initial load it renders MatrixLoader 1`] = `""`;
diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx
index b45207ab47c7a..52a97e26550ce 100644
--- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx
@@ -82,7 +82,6 @@ describe('Matrix Histogram Component', () => {
});
describe('on initial load', () => {
test('it renders MatrixLoader', () => {
- expect(wrapper.html()).toMatchSnapshot();
expect(wrapper.find('MatrixLoader').exists()).toBe(true);
});
});
@@ -117,7 +116,6 @@ describe('Matrix Histogram Component', () => {
wrapper.update();
});
test('it renders no MatrixLoader', () => {
- expect(wrapper.html()).toMatchSnapshot();
expect(wrapper.find(`MatrixLoader`).exists()).toBe(false);
});
diff --git a/x-pack/plugins/security_solution/public/common/components/stat_items/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/stat_items/__snapshots__/index.test.tsx.snap
deleted file mode 100644
index 0d006d18cc49b..0000000000000
--- a/x-pack/plugins/security_solution/public/common/components/stat_items/__snapshots__/index.test.tsx.snap
+++ /dev/null
@@ -1,958 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Stat Items Component disable charts it renders the default widget 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- HOSTS
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- —
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Stat Items Component disable charts it renders the default widget 2`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- HOSTS
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 0
-
-
-
-
-
-
-
-
- —
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Stat Items Component rendering kpis with charts it renders the default widget 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- UNIQUE_PRIVATE_IPS
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 1,714
-
- Source
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 2,359
-
- Dest.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
diff --git a/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx
index f46697834d0e3..d81d23438bfd2 100644
--- a/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx
@@ -91,10 +91,6 @@ describe('Stat Items Component', () => {
),
],
])('disable charts', (wrapper) => {
- test('it renders the default widget', () => {
- expect(wrapper).toMatchSnapshot();
- });
-
test('should render titles', () => {
expect(wrapper.find('[data-test-subj="stat-title"]')).toBeTruthy();
});
@@ -180,9 +176,6 @@ describe('Stat Items Component', () => {
);
});
- test('it renders the default widget', () => {
- expect(wrapper).toMatchSnapshot();
- });
test('should handle multiple titles', () => {
expect(wrapper.find('[data-test-subj="stat-title"]')).toHaveLength(2);
diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/index.test.tsx
index 9a52e9cf4e538..34a20e7215906 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/index.test.tsx
@@ -10,6 +10,23 @@ import React from 'react';
import { TestProviders } from '../../../../common/mock';
import { FlyoutHeaderWithCloseButton } from '.';
+jest.mock('../../../../common/lib/kibana', () => {
+ return {
+ useKibana: jest.fn().mockReturnValue({
+ services: {
+ application: {
+ capabilities: {
+ securitySolution: {
+ crud: true,
+ },
+ },
+ },
+ },
+ }),
+ useUiSetting$: jest.fn().mockReturnValue([]),
+ };
+});
+
describe('FlyoutHeaderWithCloseButton', () => {
test('renders correctly against snapshot', () => {
const EmptyComponent = shallow(
diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx
index f9c9d28ad89e1..06ce73720450b 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx
@@ -42,6 +42,7 @@ import {
} from './types';
import { DEFAULT_SORT_FIELD, DEFAULT_SORT_DIRECTION } from './constants';
import { useTimelineTypes } from './use_timeline_types';
+import { disableTemplate } from '../../../../common/constants';
interface OwnProps {
apolloClient: ApolloClient;
@@ -52,12 +53,6 @@ interface OwnProps {
onOpenTimeline?: (timeline: TimelineModel) => void;
}
-/**
- * CreateTemplateTimelineBtn
- * Remove the comment here to enable template timeline
- */
-export const disableTemplate = true;
-
export type OpenTimelineOwnProps = OwnProps &
Pick<
OpenTimelineProps,
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx
index 581fa125d21e2..931623d080198 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx
@@ -27,13 +27,13 @@ import { StatefulTimeline, Props as StatefulTimelineProps } from './index';
import { Timeline } from './timeline';
jest.mock('../../../common/lib/kibana');
-
const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock;
jest.mock('use-resize-observer/polyfilled');
mockUseResizeObserver.mockImplementation(() => ({}));
const mockUseSignalIndex: jest.Mock = useSignalIndex as jest.Mock;
jest.mock('../../../alerts/containers/detection_engine/alerts/use_signal_index');
+jest.mock('../flyout/header_with_close_button');
describe('StatefulTimeline', () => {
let props = {} as StatefulTimelineProps;
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx
new file mode 100644
index 0000000000000..fb91bbd5a1124
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx
@@ -0,0 +1,84 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+import React from 'react';
+import { mount, shallow } from 'enzyme';
+import { NewTimeline, NewTimelineProps } from './helpers';
+import { useCreateTimelineButton } from './use_create_timeline';
+
+jest.mock('./use_create_timeline', () => ({
+ useCreateTimelineButton: jest.fn(),
+}));
+
+jest.mock('../../../../common/lib/kibana', () => {
+ return {
+ useKibana: jest.fn().mockReturnValue({
+ services: {
+ application: {
+ capabilities: {
+ securitySolution: {
+ crud: true,
+ },
+ },
+ },
+ },
+ }),
+ };
+});
+
+describe('NewTimeline', () => {
+ const mockGetButton = jest.fn();
+
+ const props: NewTimelineProps = {
+ closeGearMenu: jest.fn(),
+ timelineId: 'mockTimelineId',
+ title: 'mockTitle',
+ };
+
+ describe('render', () => {
+ describe('default', () => {
+ beforeAll(() => {
+ (useCreateTimelineButton as jest.Mock).mockReturnValue({ getButton: mockGetButton });
+ shallow();
+ });
+
+ afterAll(() => {
+ jest.clearAllMocks();
+ });
+
+ test('it should not render outline', () => {
+ expect(mockGetButton.mock.calls[0][0].outline).toEqual(false);
+ });
+
+ test('it should render title', () => {
+ expect(mockGetButton.mock.calls[0][0].title).toEqual(props.title);
+ });
+ });
+
+ describe('show outline', () => {
+ beforeAll(() => {
+ (useCreateTimelineButton as jest.Mock).mockReturnValue({ getButton: mockGetButton });
+
+ const enableOutline = {
+ ...props,
+ outline: true,
+ };
+ mount();
+ });
+
+ afterAll(() => {
+ jest.clearAllMocks();
+ });
+
+ test('it should render outline', () => {
+ expect(mockGetButton.mock.calls[0][0].outline).toEqual(true);
+ });
+
+ test('it should render title', () => {
+ expect(mockGetButton.mock.calls[0][0].title).toEqual(props.title);
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx
index 3ef10d394bc7b..15ad805cc28a6 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx
@@ -23,16 +23,25 @@ import styled from 'styled-components';
import { useHistory } from 'react-router-dom';
import { useSelector } from 'react-redux';
-import { TimelineStatus } from '../../../../../common/types/timeline';
+import {
+ TimelineTypeLiteral,
+ TimelineStatus,
+ TimelineType,
+} from '../../../../../common/types/timeline';
+
+import { SiemPageName } from '../../../../app/types';
+import { timelineSelectors } from '../../../../timelines/store/timeline';
+import { State } from '../../../../common/store';
+import { useKibana } from '../../../../common/lib/kibana';
import { Note } from '../../../../common/lib/note';
+
import { Notes } from '../../notes';
import { AssociateNote, UpdateNote } from '../../notes/helpers';
+
import { NOTES_PANEL_WIDTH } from './notes_size';
import { ButtonContainer, DescriptionContainer, LabelText, NameField, StyledStar } from './styles';
import * as i18n from './translations';
-import { SiemPageName } from '../../../../app/types';
-import { timelineSelectors } from '../../../../timelines/store/timeline';
-import { State } from '../../../../common/store';
+import { useCreateTimelineButton } from './use_create_timeline';
export const historyToolTip = 'The chronological history of actions related to this timeline';
export const streamLiveToolTip = 'Update the Timeline as new data arrives';
@@ -44,7 +53,15 @@ const NotesCountBadge = (styled(EuiBadge)`
NotesCountBadge.displayName = 'NotesCountBadge';
-type CreateTimeline = ({ id, show }: { id: string; show?: boolean }) => void;
+type CreateTimeline = ({
+ id,
+ show,
+ timelineType,
+}: {
+ id: string;
+ show?: boolean;
+ timelineType?: TimelineTypeLiteral;
+}) => void;
type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void;
type UpdateTitle = ({ id, title }: { id: string; title: string }) => void;
type UpdateDescription = ({ id, description }: { id: string; description: string }) => void;
@@ -160,30 +177,27 @@ export const NewCase = React.memo(
);
NewCase.displayName = 'NewCase';
-interface NewTimelineProps {
- createTimeline: CreateTimeline;
- onClosePopover: () => void;
+export interface NewTimelineProps {
+ createTimeline?: CreateTimeline;
+ closeGearMenu?: () => void;
+ outline?: boolean;
timelineId: string;
+ title?: string;
}
export const NewTimeline = React.memo(
- ({ createTimeline, onClosePopover, timelineId }) => {
- const handleClick = useCallback(() => {
- createTimeline({ id: timelineId, show: true });
- onClosePopover();
- }, [createTimeline, timelineId, onClosePopover]);
+ ({ closeGearMenu, outline = false, timelineId, title = i18n.NEW_TIMELINE }) => {
+ const uiCapabilities = useKibana().services.application.capabilities;
+ const capabilitiesCanUserCRUD: boolean = !!uiCapabilities.securitySolution.crud;
- return (
-
- {i18n.NEW_TIMELINE}
-
- );
+ const { getButton } = useCreateTimelineButton({
+ timelineId,
+ timelineType: TimelineType.default,
+ closeGearMenu,
+ });
+ const button = getButton({ outline, title });
+
+ return capabilitiesCanUserCRUD ? button : null;
}
);
NewTimeline.displayName = 'NewTimeline';
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx
index 8bdec78ec8da2..952a7c104e19e 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx
@@ -18,14 +18,27 @@ import { createStore, State } from '../../../../common/store';
import { useThrottledResizeObserver } from '../../../../common/components/utils';
import { Properties, showDescriptionThreshold, showNotesThreshold } from '.';
-jest.mock('../../../../common/lib/kibana');
-
-let mockedWidth = 1000;
-jest.mock('../../../../common/components/utils');
-(useThrottledResizeObserver as jest.Mock).mockImplementation(() => ({
- width: mockedWidth,
+jest.mock('../../../../common/lib/kibana', () => ({
+ useKibana: jest.fn().mockReturnValue({
+ services: {
+ application: {
+ capabilities: {
+ securitySolution: {
+ crud: true,
+ },
+ },
+ },
+ },
+ }),
+ useUiSetting$: jest.fn().mockReturnValue([]),
}));
+jest.mock('../../../../common/components/utils', () => {
+ return {
+ useThrottledResizeObserver: jest.fn(),
+ };
+});
+
jest.mock('react-redux', () => {
const originalModule = jest.requireActual('react-redux');
@@ -44,16 +57,20 @@ jest.mock('react-router-dom', () => {
};
});
+jest.mock('./use_create_timeline', () => ({
+ useCreateTimelineButton: jest.fn().mockReturnValue({ getButton: jest.fn() }),
+}));
+
describe('Properties', () => {
const usersViewing = ['elastic'];
-
const state: State = mockGlobalState;
+ let mockedWidth = 1000;
let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable);
beforeEach(() => {
jest.clearAllMocks();
store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable);
- mockedWidth = 1000;
+ (useThrottledResizeObserver as jest.Mock).mockReturnValue({ width: mockedWidth });
});
test('renders correctly', () => {
@@ -306,7 +323,9 @@ describe('Properties', () => {
test('it renders a description on the left when the width is at least as wide as the threshold', () => {
const description = 'strange';
- mockedWidth = showDescriptionThreshold;
+
+ (useThrottledResizeObserver as jest.Mock).mockReset();
+ (useThrottledResizeObserver as jest.Mock).mockReturnValue({ width: showDescriptionThreshold });
const wrapper = mount(
@@ -343,7 +362,11 @@ describe('Properties', () => {
test('it does NOT render a description on the left when the width is less than the threshold', () => {
const description = 'strange';
- mockedWidth = showDescriptionThreshold - 1;
+
+ (useThrottledResizeObserver as jest.Mock).mockReset();
+ (useThrottledResizeObserver as jest.Mock).mockReturnValue({
+ width: showDescriptionThreshold - 1,
+ });
const wrapper = mount(
@@ -413,7 +436,10 @@ describe('Properties', () => {
});
test('it does NOT render a a notes button on the left when the width is less than the threshold', () => {
- mockedWidth = showNotesThreshold - 1;
+ (useThrottledResizeObserver as jest.Mock).mockReset();
+ (useThrottledResizeObserver as jest.Mock).mockReturnValue({
+ width: showNotesThreshold - 1,
+ });
const wrapper = mount(
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx
index d8966a58748ed..d3cb11ca1a465 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx
@@ -6,17 +6,26 @@
import React, { useState, useCallback, useMemo } from 'react';
-import { TimelineStatus } from '../../../../../common/types/timeline';
+import { TimelineStatus, TimelineTypeLiteral } from '../../../../../common/types/timeline';
import { useThrottledResizeObserver } from '../../../../common/components/utils';
import { Note } from '../../../../common/lib/note';
import { InputsModelId } from '../../../../common/store/inputs/constants';
+
import { AssociateNote, UpdateNote } from '../../notes/helpers';
import { TimelineProperties } from './styles';
import { PropertiesRight } from './properties_right';
import { PropertiesLeft } from './properties_left';
-type CreateTimeline = ({ id, show }: { id: string; show?: boolean }) => void;
+type CreateTimeline = ({
+ id,
+ show,
+ timelineType,
+}: {
+ id: string;
+ show?: boolean;
+ timelineType?: TimelineTypeLiteral;
+}) => void;
type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void;
type UpdateTitle = ({ id, title }: { id: string; title: string }) => void;
type UpdateDescription = ({ id, description }: { id: string; description: string }) => void;
@@ -127,7 +136,6 @@ export const Properties = React.memo(
/>
{
+ return {
+ useKibana: jest.fn(),
+ };
+});
+
+describe('NewTemplateTimeline', () => {
+ const state: State = mockGlobalState;
+ const store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable);
+ const mockClosePopover = jest.fn();
+ const mockTitle = 'NEW_TIMELINE';
+ let wrapper: ReactWrapper;
+
+ describe('render if CRUD', () => {
+ beforeAll(() => {
+ (useKibana as jest.Mock).mockReturnValue({
+ services: {
+ application: {
+ capabilities: {
+ securitySolution: {
+ crud: true,
+ },
+ },
+ },
+ },
+ });
+
+ afterAll(() => {
+ (useKibana as jest.Mock).mockReset();
+ });
+
+ wrapper = mount(
+
+
+
+ );
+ });
+
+ test('render with iconType', () => {
+ expect(
+ wrapper
+ .find('[data-test-subj="template-timeline-new-with-border"]')
+ .first()
+ .prop('iconType')
+ ).toEqual('plusInCircle');
+ });
+
+ test('render with onClick', () => {
+ expect(
+ wrapper.find('[data-test-subj="template-timeline-new-with-border"]').first().prop('onClick')
+ ).toBeTruthy();
+ });
+ });
+
+ describe('If no CRUD', () => {
+ beforeAll(() => {
+ (useKibana as jest.Mock).mockReturnValue({
+ services: {
+ application: {
+ capabilities: {
+ securitySolution: {
+ crud: false,
+ },
+ },
+ },
+ },
+ });
+
+ wrapper = mount(
+
+
+
+ );
+ });
+
+ afterAll(() => {
+ (useKibana as jest.Mock).mockReset();
+ });
+
+ test('no render', () => {
+ expect(
+ wrapper.find('[data-test-subj="template-timeline-new-with-border"]').exists()
+ ).toBeFalsy();
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.tsx
new file mode 100644
index 0000000000000..45b2ce62fb85b
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.tsx
@@ -0,0 +1,42 @@
+/*
+ * 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 from 'react';
+
+import { TimelineType } from '../../../../../common/types/timeline';
+
+import { useKibana } from '../../../../common/lib/kibana';
+import { useCreateTimelineButton } from './use_create_timeline';
+
+interface OwnProps {
+ closeGearMenu?: () => void;
+ outline?: boolean;
+ title?: string;
+ timelineId?: string;
+}
+
+export const NewTemplateTimelineComponent: React.FC = ({
+ closeGearMenu,
+ outline,
+ title,
+ timelineId = 'timeline-1',
+}) => {
+ const uiCapabilities = useKibana().services.application.capabilities;
+ const capabilitiesCanUserCRUD: boolean = !!uiCapabilities.securitySolution.crud;
+
+ const { getButton } = useCreateTimelineButton({
+ timelineId,
+ timelineType: TimelineType.template,
+ closeGearMenu,
+ });
+
+ const button = getButton({ outline, title });
+
+ return capabilitiesCanUserCRUD ? button : null;
+};
+
+export const NewTemplateTimeline = React.memo(NewTemplateTimelineComponent);
+NewTemplateTimeline.displayName = 'NewTemplateTimeline';
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx
new file mode 100644
index 0000000000000..58927e7b236e7
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx
@@ -0,0 +1,289 @@
+/*
+ * 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 { mount, ReactWrapper } from 'enzyme';
+import React from 'react';
+
+import { PropertiesRight } from './properties_right';
+import { useKibana } from '../../../../common/lib/kibana';
+import { TimelineStatus } from '../../../../../common/types/timeline';
+import { disableTemplate } from '../../../../../common/constants';
+
+jest.mock('../../../../common/lib/kibana', () => {
+ return {
+ useKibana: jest.fn(),
+ useUiSetting$: jest.fn().mockReturnValue([]),
+ };
+});
+
+jest.mock('./new_template_timeline', () => {
+ return {
+ NewTemplateTimeline: jest.fn(() => ),
+ };
+});
+
+jest.mock('./helpers', () => {
+ return {
+ Description: jest.fn().mockReturnValue(),
+ NotesButton: jest.fn().mockReturnValue(),
+ NewCase: jest.fn().mockReturnValue(),
+ NewTimeline: jest.fn().mockReturnValue(),
+ };
+});
+
+jest.mock('../../../../common/components/inspect', () => {
+ return {
+ InspectButton: jest.fn().mockReturnValue(),
+ InspectButtonContainer: jest.fn(({ children }) => {children}
),
+ };
+});
+
+describe('Properties Right', () => {
+ let wrapper: ReactWrapper;
+ const props = {
+ onButtonClick: jest.fn(),
+ onClosePopover: jest.fn(),
+ showActions: true,
+ createTimeline: jest.fn(),
+ timelineId: 'timelineId',
+ isDataInTimeline: false,
+ showNotes: false,
+ showNotesFromWidth: false,
+ showDescription: false,
+ showUsersView: false,
+ usersViewing: [],
+ description: 'desc',
+ updateDescription: jest.fn(),
+ associateNote: jest.fn(),
+ getNotesByIds: jest.fn(),
+ noteIds: [],
+ onToggleShowNotes: jest.fn(),
+ onCloseTimelineModal: jest.fn(),
+ onOpenTimelineModal: jest.fn(),
+ status: TimelineStatus.active,
+ showTimelineModal: false,
+ title: 'title',
+ updateNote: jest.fn(),
+ };
+
+ describe('with crud', () => {
+ describe('render', () => {
+ beforeAll(() => {
+ (useKibana as jest.Mock).mockReturnValue({
+ services: {
+ application: {
+ capabilities: {
+ securitySolution: {
+ crud: true,
+ },
+ },
+ },
+ },
+ });
+ wrapper = mount();
+ });
+
+ afterAll(() => {
+ (useKibana as jest.Mock).mockReset();
+ });
+
+ test('it renders settings-gear', () => {
+ expect(wrapper.find('[data-test-subj="settings-gear"]').exists()).toBeTruthy();
+ });
+
+ test('it renders create timelin btn', () => {
+ expect(wrapper.find('[data-test-subj="create-default-btn"]').exists()).toBeTruthy();
+ });
+
+ /*
+ * CreateTemplateTimelineBtn
+ * Remove the comment here to enable CreateTemplateTimelineBtn
+ */
+ test('it renders no create template timelin btn', () => {
+ expect(wrapper.find('[data-test-subj="create-template-btn"]').exists()).toEqual(
+ !disableTemplate
+ );
+ });
+
+ test('it renders create attach timeline to a case btn', () => {
+ expect(wrapper.find('[data-test-subj="NewCase"]').exists()).toBeTruthy();
+ });
+
+ test('it renders no NotesButton', () => {
+ expect(wrapper.find('[data-test-subj="NotesButton"]').exists()).not.toBeTruthy();
+ });
+
+ test('it renders no Description', () => {
+ expect(wrapper.find('[data-test-subj="Description"]').exists()).not.toBeTruthy();
+ });
+ });
+
+ describe('render with notes button', () => {
+ beforeAll(() => {
+ (useKibana as jest.Mock).mockReturnValue({
+ services: {
+ application: {
+ capabilities: {
+ securitySolution: {
+ crud: true,
+ },
+ },
+ },
+ },
+ });
+ const propsWithshowNotes = {
+ ...props,
+ showNotesFromWidth: true,
+ };
+ wrapper = mount();
+ });
+
+ afterAll(() => {
+ (useKibana as jest.Mock).mockReset();
+ });
+
+ test('it renders NotesButton', () => {
+ expect(wrapper.find('[data-test-subj="NotesButton"]').exists()).toBeTruthy();
+ });
+ });
+
+ describe('render with description', () => {
+ beforeAll(() => {
+ (useKibana as jest.Mock).mockReturnValue({
+ services: {
+ application: {
+ capabilities: {
+ securitySolution: {
+ crud: true,
+ },
+ },
+ },
+ },
+ });
+ const propsWithshowDescription = {
+ ...props,
+ showDescription: true,
+ };
+ wrapper = mount();
+ });
+
+ afterAll(() => {
+ (useKibana as jest.Mock).mockReset();
+ });
+
+ test('it renders Description', () => {
+ expect(wrapper.find('[data-test-subj="Description"]').exists()).toBeTruthy();
+ });
+ });
+ });
+
+ describe('with no crud', () => {
+ describe('render', () => {
+ beforeAll(() => {
+ (useKibana as jest.Mock).mockReturnValue({
+ services: {
+ application: {
+ capabilities: {
+ securitySolution: {
+ crud: false,
+ },
+ },
+ },
+ },
+ });
+ wrapper = mount();
+ });
+
+ afterAll(() => {
+ (useKibana as jest.Mock).mockReset();
+ });
+
+ test('it renders settings-gear', () => {
+ expect(wrapper.find('[data-test-subj="settings-gear"]').exists()).toBeTruthy();
+ });
+
+ test('it renders no create timelin btn', () => {
+ expect(wrapper.find('[data-test-subj="create-default-btn"]').exists()).not.toBeTruthy();
+ });
+
+ test('it renders create template timelin btn if it is enabled', () => {
+ expect(wrapper.find('[data-test-subj="create-template-btn"]').exists()).toEqual(
+ !disableTemplate
+ );
+ });
+
+ test('it renders create attach timeline to a case btn', () => {
+ expect(wrapper.find('[data-test-subj="NewCase"]').exists()).toBeTruthy();
+ });
+
+ test('it renders no NotesButton', () => {
+ expect(wrapper.find('[data-test-subj="NotesButton"]').exists()).not.toBeTruthy();
+ });
+
+ test('it renders no Description', () => {
+ expect(wrapper.find('[data-test-subj="Description"]').exists()).not.toBeTruthy();
+ });
+ });
+
+ describe('render with notes button', () => {
+ beforeAll(() => {
+ (useKibana as jest.Mock).mockReturnValue({
+ services: {
+ application: {
+ capabilities: {
+ securitySolution: {
+ crud: false,
+ },
+ },
+ },
+ },
+ });
+ const propsWithshowNotes = {
+ ...props,
+ showNotesFromWidth: true,
+ };
+ wrapper = mount();
+ });
+
+ afterAll(() => {
+ (useKibana as jest.Mock).mockReset();
+ });
+
+ test('it renders NotesButton', () => {
+ expect(wrapper.find('[data-test-subj="NotesButton"]').exists()).toBeTruthy();
+ });
+ });
+
+ describe('render with description', () => {
+ beforeAll(() => {
+ (useKibana as jest.Mock).mockReturnValue({
+ services: {
+ application: {
+ capabilities: {
+ securitySolution: {
+ crud: false,
+ },
+ },
+ },
+ },
+ });
+ const propsWithshowDescription = {
+ ...props,
+ showDescription: true,
+ };
+ wrapper = mount();
+ });
+
+ afterAll(() => {
+ (useKibana as jest.Mock).mockReset();
+ });
+
+ test('it renders Description', () => {
+ expect(wrapper.find('[data-test-subj="Description"]').exists()).toBeTruthy();
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx
index 963b67838e811..f9ab7fb2e69ae 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx
@@ -14,15 +14,21 @@ import {
EuiToolTip,
EuiAvatar,
} from '@elastic/eui';
-import { NewTimeline, Description, NotesButton, NewCase } from './helpers';
+
+import { disableTemplate } from '../../../../../common/constants';
+import { TimelineStatus } from '../../../../../common/types/timeline';
+
+import { InspectButton, InspectButtonContainer } from '../../../../common/components/inspect';
+import { useKibana } from '../../../../common/lib/kibana';
+import { Note } from '../../../../common/lib/note';
+
+import { AssociateNote } from '../../notes/helpers';
import { OpenTimelineModalButton } from '../../open_timeline/open_timeline_modal/open_timeline_modal_button';
import { OpenTimelineModal } from '../../open_timeline/open_timeline_modal';
-import { InspectButton, InspectButtonContainer } from '../../../../common/components/inspect';
import * as i18n from './translations';
-import { AssociateNote } from '../../notes/helpers';
-import { Note } from '../../../../common/lib/note';
-import { TimelineStatus } from '../../../../../common/types/timeline';
+import { Description, NotesButton, NewCase, NewTimeline } from './helpers';
+import { NewTemplateTimeline } from './new_template_timeline';
export const PropertiesRightStyle = styled(EuiFlexGroup)`
margin-right: 5px;
@@ -55,15 +61,13 @@ const Avatar = styled(EuiAvatar)`
Avatar.displayName = 'Avatar';
-type CreateTimeline = ({ id, show }: { id: string; show?: boolean }) => void;
type UpdateDescription = ({ id, description }: { id: string; description: string }) => void;
export type UpdateNote = (note: Note) => void;
-interface Props {
+export interface PropertiesRightComponentProps {
onButtonClick: () => void;
onClosePopover: () => void;
showActions: boolean;
- createTimeline: CreateTimeline;
timelineId: string;
isDataInTimeline: boolean;
showNotes: boolean;
@@ -85,11 +89,10 @@ interface Props {
updateNote: UpdateNote;
}
-const PropertiesRightComponent: React.FC = ({
+const PropertiesRightComponent: React.FC = ({
onButtonClick,
showActions,
onClosePopover,
- createTimeline,
timelineId,
isDataInTimeline,
showNotesFromWidth,
@@ -105,111 +108,131 @@ const PropertiesRightComponent: React.FC = ({
onToggleShowNotes,
updateNote,
showTimelineModal,
+ status,
onCloseTimelineModal,
onOpenTimelineModal,
title,
- status,
-}) => (
-
-
-
-
- }
- id="timelineSettingsPopover"
- isOpen={showActions}
- closePopover={onClosePopover}
- >
-
-
-
-
-
-
-
-
-
-
- {
+ const uiCapabilities = useKibana().services.application.capabilities;
+ const capabilitiesCanUserCRUD: boolean = !!uiCapabilities.securitySolution.crud;
+ return (
+
+
+
+
-
-
-
-
-
+ }
+ id="timelineSettingsPopover"
+ isOpen={showActions}
+ closePopover={onClosePopover}
+ >
+
+ {capabilitiesCanUserCRUD && (
+
+
+
+ )}
+
+ {/*
+ * CreateTemplateTimelineBtn
+ * Remove the comment here to enable CreateTemplateTimelineBtn
+ */}
+ {!disableTemplate && (
+
+
+
+ )}
- {showNotesFromWidth ? (
-
+
+
+
+
- ) : null}
- {showDescription ? (
-
-
-
+
- ) : null}
-
-
-
-
-
- {showUsersView
- ? usersViewing.map((user) => (
- // Hide the hard-coded elastic user avatar as the 7.2 release does not implement
- // support for multi-user-collaboration as proposed in elastic/ingest-dev#395
-
-
-
-
-
- ))
- : null}
-
- {showTimelineModal ? : null}
-
-);
+
+ {showNotesFromWidth ? (
+
+
+
+ ) : null}
+
+ {showDescription ? (
+
+
+
+
+
+ ) : null}
+
+
+
+
+
+ {showUsersView
+ ? usersViewing.map((user) => (
+ // Hide the hard-coded elastic user avatar as the 7.2 release does not implement
+ // support for multi-user-collaboration as proposed in elastic/ingest-dev#395
+
+
+
+
+
+ ))
+ : null}
+
+ {showTimelineModal ? : null}
+
+ );
+};
export const PropertiesRight = React.memo(PropertiesRightComponent);
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts
index 88cbd3b1503f6..20569cc044be3 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts
@@ -109,6 +109,13 @@ export const NEW_TIMELINE = i18n.translate(
}
);
+export const NEW_TEMPLATE_TIMELINE = i18n.translate(
+ 'xpack.securitySolution.timeline.properties.newTemplateTimelineButtonLabel',
+ {
+ defaultMessage: 'Create template timeline',
+ }
+);
+
export const ATTACH_TIMELINE_TO_NEW_CASE = i18n.translate(
'xpack.securitySolution.timeline.properties.newCaseButtonLabel',
{
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx
new file mode 100644
index 0000000000000..68a3362b721d8
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx
@@ -0,0 +1,60 @@
+/*
+ * 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 { renderHook, act } from '@testing-library/react-hooks';
+import { shallow } from 'enzyme';
+
+import { TimelineType } from '../../../../../common/types/timeline';
+import { useCreateTimelineButton } from './use_create_timeline';
+
+jest.mock('react-redux', () => {
+ const actual = jest.requireActual('react-redux');
+ return {
+ ...actual,
+ useDispatch: jest.fn(),
+ };
+});
+
+describe('useCreateTimelineButton', () => {
+ const mockId = 'mockId';
+ const timelineType = TimelineType.default;
+
+ test('return getButton', async () => {
+ await act(async () => {
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useCreateTimelineButton({ timelineId: mockId, timelineType })
+ );
+ await waitForNextUpdate();
+
+ expect(result.current.getButton).toBeTruthy();
+ });
+ });
+
+ test('getButton renders correct outline - EuiButton', async () => {
+ await act(async () => {
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useCreateTimelineButton({ timelineId: mockId, timelineType })
+ );
+ await waitForNextUpdate();
+
+ const button = result.current.getButton({ outline: true, title: 'mock title' });
+ const wrapper = shallow(button);
+ expect(wrapper.find('[data-test-subj="timeline-new-with-border"]').exists()).toBeTruthy();
+ });
+ });
+
+ test('getButton renders correct outline - EuiButtonEmpty', async () => {
+ await act(async () => {
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useCreateTimelineButton({ timelineId: mockId, timelineType })
+ );
+ await waitForNextUpdate();
+
+ const button = result.current.getButton({ outline: false, title: 'mock title' });
+ const wrapper = shallow(button);
+ expect(wrapper.find('[data-test-subj="timeline-new"]').exists()).toBeTruthy();
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx
new file mode 100644
index 0000000000000..fb05b056cdf82
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx
@@ -0,0 +1,66 @@
+/*
+ * 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, { useCallback } from 'react';
+import { useDispatch } from 'react-redux';
+import { EuiButton, EuiButtonEmpty } from '@elastic/eui';
+import { defaultHeaders } from '../body/column_headers/default_headers';
+import { timelineActions } from '../../../store/timeline';
+import { TimelineTypeLiteral, TimelineType } from '../../../../../common/types/timeline';
+
+export const useCreateTimelineButton = ({
+ timelineId,
+ timelineType,
+ closeGearMenu,
+}: {
+ timelineId?: string;
+ timelineType: TimelineTypeLiteral;
+ closeGearMenu?: () => void;
+}) => {
+ const dispatch = useDispatch();
+
+ const createTimeline = useCallback(
+ ({ id, show }) =>
+ dispatch(
+ timelineActions.createTimeline({
+ id,
+ columns: defaultHeaders,
+ show,
+ timelineType,
+ })
+ ),
+ [dispatch, timelineType]
+ );
+
+ const handleButtonClick = useCallback(() => {
+ createTimeline({ id: timelineId, show: true, timelineType });
+ if (typeof closeGearMenu === 'function') {
+ closeGearMenu();
+ }
+ }, [createTimeline, timelineId, timelineType, closeGearMenu]);
+
+ const getButton = useCallback(
+ ({ outline, title }: { outline?: boolean; title?: string }) => {
+ const buttonProps = {
+ iconType: 'plusInCircle',
+ onClick: handleButtonClick,
+ };
+ const dataTestSubjPrefix =
+ timelineType === TimelineType.template ? `template-timeline-new` : `timeline-new`;
+ return outline ? (
+
+ {title}
+
+ ) : (
+
+ {title}
+
+ );
+ },
+ [handleButtonClick, timelineType]
+ );
+
+ return { getButton };
+};
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx
index b07be4a471a70..74659cb17b0e5 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx
@@ -26,9 +26,10 @@ import { mockDataProviders } from './data_providers/mock/mock_data_providers';
import { useMountAppended } from '../../../common/utils/use_mount_appended';
jest.mock('../../../common/lib/kibana');
-
+jest.mock('./properties/properties_right');
const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock;
jest.mock('use-resize-observer/polyfilled');
+
mockUseResizeObserver.mockImplementation(() => ({}));
describe('Timeline', () => {
diff --git a/x-pack/plugins/security_solution/public/timelines/containers/all/index.gql_query.ts b/x-pack/plugins/security_solution/public/timelines/containers/all/index.gql_query.ts
index cdbf3e768581b..60d000fe78184 100644
--- a/x-pack/plugins/security_solution/public/timelines/containers/all/index.gql_query.ts
+++ b/x-pack/plugins/security_solution/public/timelines/containers/all/index.gql_query.ts
@@ -56,6 +56,7 @@ export const allTimelinesQuery = gql`
}
noteIds
pinnedEventIds
+ status
title
timelineType
templateTimelineId
diff --git a/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts b/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts
new file mode 100644
index 0000000000000..26373fa1a825d
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts
@@ -0,0 +1,503 @@
+/*
+ * 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 * as api from './api';
+import { KibanaServices } from '../../common/lib/kibana';
+import { TimelineType, TimelineStatus } from '../../../common/types/timeline';
+import { TIMELINE_DRAFT_URL, TIMELINE_URL } from '../../../common/constants';
+
+jest.mock('../../common/lib/kibana', () => {
+ return {
+ KibanaServices: { get: jest.fn() },
+ };
+});
+
+describe('persistTimeline', () => {
+ describe('create draft timeline', () => {
+ const timelineId = null;
+ const initialDraftTimeline = {
+ columns: [
+ {
+ columnHeaderType: 'not-filtered',
+ id: '@timestamp',
+ },
+ {
+ columnHeaderType: 'not-filtered',
+ id: 'message',
+ },
+ {
+ columnHeaderType: 'not-filtered',
+ id: 'event.category',
+ },
+ {
+ columnHeaderType: 'not-filtered',
+ id: 'event.action',
+ },
+ {
+ columnHeaderType: 'not-filtered',
+ id: 'host.name',
+ },
+ {
+ columnHeaderType: 'not-filtered',
+ id: 'source.ip',
+ },
+ {
+ columnHeaderType: 'not-filtered',
+ id: 'destination.ip',
+ },
+ {
+ columnHeaderType: 'not-filtered',
+ id: 'user.name',
+ },
+ ],
+ dataProviders: [],
+ description: 'x',
+ eventType: 'all',
+ filters: [],
+ kqlMode: 'filter',
+ kqlQuery: {
+ filterQuery: null,
+ },
+ title: '',
+ timelineType: TimelineType.default,
+ templateTimelineVersion: null,
+ templateTimelineId: null,
+ dateRange: {
+ start: 1590998565409,
+ end: 1591084965409,
+ },
+ savedQueryId: null,
+ sort: {
+ columnId: '@timestamp',
+ sortDirection: 'desc',
+ },
+ status: TimelineStatus.draft,
+ };
+ const mockDraftResponse = {
+ data: {
+ persistTimeline: {
+ timeline: {
+ savedObjectId: '9d5693e0-a42a-11ea-b8f4-c5434162742a',
+ version: 'WzMzMiwxXQ==',
+ columns: [
+ { columnHeaderType: 'not-filtered', id: '@timestamp' },
+ { columnHeaderType: 'not-filtered', id: 'message' },
+ { columnHeaderType: 'not-filtered', id: 'event.category' },
+ { columnHeaderType: 'not-filtered', id: 'event.action' },
+ { columnHeaderType: 'not-filtered', id: 'host.name' },
+ { columnHeaderType: 'not-filtered', id: 'source.ip' },
+ { columnHeaderType: 'not-filtered', id: 'destination.ip' },
+ { columnHeaderType: 'not-filtered', id: 'user.name' },
+ ],
+ dataProviders: [],
+ description: '',
+ eventType: 'all',
+ filters: [],
+ kqlMode: 'filter',
+ timelineType: 'default',
+ kqlQuery: { filterQuery: null },
+ title: '',
+ sort: { columnId: '@timestamp', sortDirection: 'desc' },
+ status: 'draft',
+ created: 1591091394733,
+ createdBy: 'angela',
+ updated: 1591091394733,
+ updatedBy: 'angela',
+ templateTimelineId: null,
+ templateTimelineVersion: null,
+ dateRange: { start: 1590998565409, end: 1591084965409 },
+ savedQueryId: null,
+ favorite: [],
+ eventIdToNoteIds: [],
+ noteIds: [],
+ notes: [],
+ pinnedEventIds: [],
+ pinnedEventsSaveObject: [],
+ },
+ },
+ },
+ };
+ const mockPatchTimelineResponse = {
+ data: {
+ persistTimeline: {
+ code: 200,
+ message: 'success',
+ timeline: {
+ savedObjectId: '9d5693e0-a42a-11ea-b8f4-c5434162742a',
+ version: 'WzM0NSwxXQ==',
+ columns: [
+ { columnHeaderType: 'not-filtered', id: '@timestamp' },
+ { columnHeaderType: 'not-filtered', id: 'message' },
+ { columnHeaderType: 'not-filtered', id: 'event.category' },
+ { columnHeaderType: 'not-filtered', id: 'event.action' },
+ { columnHeaderType: 'not-filtered', id: 'host.name' },
+ { columnHeaderType: 'not-filtered', id: 'source.ip' },
+ { columnHeaderType: 'not-filtered', id: 'destination.ip' },
+ { columnHeaderType: 'not-filtered', id: 'user.name' },
+ ],
+ dataProviders: [],
+ description: 'x',
+ eventType: 'all',
+ filters: [],
+ kqlMode: 'filter',
+ timelineType: 'default',
+ kqlQuery: { filterQuery: null },
+ title: '',
+ sort: { columnId: '@timestamp', sortDirection: 'desc' },
+ status: 'draft',
+ created: 1591092702804,
+ createdBy: 'angela',
+ updated: 1591092705206,
+ updatedBy: 'angela',
+ templateTimelineId: null,
+ templateTimelineVersion: null,
+ dateRange: { start: 1590998565409, end: 1591084965409 },
+ savedQueryId: null,
+ favorite: [],
+ eventIdToNoteIds: [],
+ noteIds: [],
+ notes: [],
+ pinnedEventIds: [],
+ pinnedEventsSaveObject: [],
+ },
+ },
+ },
+ };
+ const version = null;
+ const fetchMock = jest.fn();
+ const postMock = jest.fn();
+ const patchMock = jest.fn();
+
+ beforeAll(() => {
+ jest.resetAllMocks();
+
+ (KibanaServices.get as jest.Mock).mockReturnValue({
+ http: {
+ fetch: fetchMock,
+ post: postMock.mockReturnValue(mockDraftResponse),
+ patch: patchMock.mockReturnValue(mockPatchTimelineResponse),
+ },
+ });
+ api.persistTimeline({ timelineId, timeline: initialDraftTimeline, version });
+ });
+
+ afterAll(() => {
+ jest.resetAllMocks();
+ });
+
+ test('it should create a draft timeline if given status is draft and timelineId is null', () => {
+ expect(postMock).toHaveBeenCalledWith(TIMELINE_DRAFT_URL, {
+ body: JSON.stringify({
+ timelineType: initialDraftTimeline.timelineType,
+ }),
+ });
+ });
+
+ test('it should update timeline', () => {
+ expect(patchMock.mock.calls[0][0]).toEqual(TIMELINE_URL);
+ });
+
+ test('it should update timeline with patch', () => {
+ expect(patchMock.mock.calls[0][1].method).toEqual('PATCH');
+ });
+
+ test("it should update timeline from clean draft timeline's response", () => {
+ expect(JSON.parse(patchMock.mock.calls[0][1].body)).toEqual({
+ timelineId: mockDraftResponse.data.persistTimeline.timeline.savedObjectId,
+ timeline: {
+ ...initialDraftTimeline,
+ templateTimelineId: mockDraftResponse.data.persistTimeline.timeline.templateTimelineId,
+ templateTimelineVersion:
+ mockDraftResponse.data.persistTimeline.timeline.templateTimelineVersion,
+ },
+ version: mockDraftResponse.data.persistTimeline.timeline.version ?? '',
+ });
+ });
+ });
+
+ describe('create active timeline (import)', () => {
+ const timelineId = null;
+ const importTimeline = {
+ columns: [
+ {
+ columnHeaderType: 'not-filtered',
+ id: '@timestamp',
+ },
+ {
+ columnHeaderType: 'not-filtered',
+ id: 'message',
+ },
+ {
+ columnHeaderType: 'not-filtered',
+ id: 'event.category',
+ },
+ {
+ columnHeaderType: 'not-filtered',
+ id: 'event.action',
+ },
+ {
+ columnHeaderType: 'not-filtered',
+ id: 'host.name',
+ },
+ {
+ columnHeaderType: 'not-filtered',
+ id: 'source.ip',
+ },
+ {
+ columnHeaderType: 'not-filtered',
+ id: 'destination.ip',
+ },
+ {
+ columnHeaderType: 'not-filtered',
+ id: 'user.name',
+ },
+ ],
+ dataProviders: [],
+ description: 'x',
+ eventType: 'all',
+ filters: [],
+ kqlMode: 'filter',
+ kqlQuery: {
+ filterQuery: null,
+ },
+ title: '',
+ timelineType: TimelineType.default,
+ templateTimelineVersion: null,
+ templateTimelineId: null,
+ dateRange: {
+ start: 1590998565409,
+ end: 1591084965409,
+ },
+ savedQueryId: null,
+ sort: {
+ columnId: '@timestamp',
+ sortDirection: 'desc',
+ },
+ status: TimelineStatus.active,
+ };
+ const mockPostTimelineResponse = {
+ data: {
+ persistTimeline: {
+ timeline: {
+ savedObjectId: '9d5693e0-a42a-11ea-b8f4-c5434162742a',
+ version: 'WzMzMiwxXQ==',
+ columns: [
+ { columnHeaderType: 'not-filtered', id: '@timestamp' },
+ { columnHeaderType: 'not-filtered', id: 'message' },
+ { columnHeaderType: 'not-filtered', id: 'event.category' },
+ { columnHeaderType: 'not-filtered', id: 'event.action' },
+ { columnHeaderType: 'not-filtered', id: 'host.name' },
+ { columnHeaderType: 'not-filtered', id: 'source.ip' },
+ { columnHeaderType: 'not-filtered', id: 'destination.ip' },
+ { columnHeaderType: 'not-filtered', id: 'user.name' },
+ ],
+ dataProviders: [],
+ description: '',
+ eventType: 'all',
+ filters: [],
+ kqlMode: 'filter',
+ timelineType: 'default',
+ kqlQuery: { filterQuery: null },
+ title: '',
+ sort: { columnId: '@timestamp', sortDirection: 'desc' },
+ status: 'draft',
+ created: 1591091394733,
+ createdBy: 'angela',
+ updated: 1591091394733,
+ updatedBy: 'angela',
+ templateTimelineId: null,
+ templateTimelineVersion: null,
+ dateRange: { start: 1590998565409, end: 1591084965409 },
+ savedQueryId: null,
+ favorite: [],
+ eventIdToNoteIds: [],
+ noteIds: [],
+ notes: [],
+ pinnedEventIds: [],
+ pinnedEventsSaveObject: [],
+ },
+ },
+ },
+ };
+
+ const version = null;
+ const fetchMock = jest.fn();
+ const postMock = jest.fn();
+ const patchMock = jest.fn();
+
+ beforeAll(() => {
+ jest.resetAllMocks();
+
+ (KibanaServices.get as jest.Mock).mockReturnValue({
+ http: {
+ fetch: fetchMock,
+ post: postMock.mockReturnValue(mockPostTimelineResponse),
+ patch: patchMock,
+ },
+ });
+ api.persistTimeline({ timelineId, timeline: importTimeline, version });
+ });
+
+ afterAll(() => {
+ jest.resetAllMocks();
+ });
+
+ test('it should update timeline', () => {
+ expect(postMock.mock.calls[0][0]).toEqual(TIMELINE_URL);
+ });
+
+ test('it should update timeline with patch', () => {
+ expect(postMock.mock.calls[0][1].method).toEqual('POST');
+ });
+
+ test('should call create timeline', () => {
+ expect(JSON.parse(postMock.mock.calls[0][1].body)).toEqual({ timeline: importTimeline });
+ });
+ });
+
+ describe('update active timeline', () => {
+ const timelineId = '9d5693e0-a42a-11ea-b8f4-c5434162742a';
+ const inputTimeline = {
+ columns: [
+ {
+ columnHeaderType: 'not-filtered',
+ id: '@timestamp',
+ },
+ {
+ columnHeaderType: 'not-filtered',
+ id: 'message',
+ },
+ {
+ columnHeaderType: 'not-filtered',
+ id: 'event.category',
+ },
+ {
+ columnHeaderType: 'not-filtered',
+ id: 'event.action',
+ },
+ {
+ columnHeaderType: 'not-filtered',
+ id: 'host.name',
+ },
+ {
+ columnHeaderType: 'not-filtered',
+ id: 'source.ip',
+ },
+ {
+ columnHeaderType: 'not-filtered',
+ id: 'destination.ip',
+ },
+ {
+ columnHeaderType: 'not-filtered',
+ id: 'user.name',
+ },
+ ],
+ dataProviders: [],
+ description: 'x',
+ eventType: 'all',
+ filters: [],
+ kqlMode: 'filter',
+ kqlQuery: {
+ filterQuery: null,
+ },
+ title: '',
+ timelineType: TimelineType.default,
+ templateTimelineVersion: null,
+ templateTimelineId: null,
+ dateRange: {
+ start: 1590998565409,
+ end: 1591084965409,
+ },
+ savedQueryId: null,
+ sort: {
+ columnId: '@timestamp',
+ sortDirection: 'desc',
+ },
+ status: TimelineStatus.active,
+ };
+ const mockPatchTimelineResponse = {
+ data: {
+ persistTimeline: {
+ timeline: {
+ savedObjectId: '9d5693e0-a42a-11ea-b8f4-c5434162742a',
+ version: 'WzMzMiwxXQ==',
+ columns: [
+ { columnHeaderType: 'not-filtered', id: '@timestamp' },
+ { columnHeaderType: 'not-filtered', id: 'message' },
+ { columnHeaderType: 'not-filtered', id: 'event.category' },
+ { columnHeaderType: 'not-filtered', id: 'event.action' },
+ { columnHeaderType: 'not-filtered', id: 'host.name' },
+ { columnHeaderType: 'not-filtered', id: 'source.ip' },
+ { columnHeaderType: 'not-filtered', id: 'destination.ip' },
+ { columnHeaderType: 'not-filtered', id: 'user.name' },
+ ],
+ dataProviders: [],
+ description: '',
+ eventType: 'all',
+ filters: [],
+ kqlMode: 'filter',
+ timelineType: 'default',
+ kqlQuery: { filterQuery: null },
+ title: '',
+ sort: { columnId: '@timestamp', sortDirection: 'desc' },
+ status: 'draft',
+ created: 1591091394733,
+ createdBy: 'angela',
+ updated: 1591091394733,
+ updatedBy: 'angela',
+ templateTimelineId: null,
+ templateTimelineVersion: null,
+ dateRange: { start: 1590998565409, end: 1591084965409 },
+ savedQueryId: null,
+ favorite: [],
+ eventIdToNoteIds: [],
+ noteIds: [],
+ notes: [],
+ pinnedEventIds: [],
+ pinnedEventsSaveObject: [],
+ },
+ },
+ },
+ };
+
+ const version = 'initial version';
+ const fetchMock = jest.fn();
+ const postMock = jest.fn();
+ const patchMock = jest.fn();
+
+ beforeAll(() => {
+ jest.resetAllMocks();
+
+ (KibanaServices.get as jest.Mock).mockReturnValue({
+ http: {
+ fetch: fetchMock,
+ post: postMock,
+ patch: patchMock.mockReturnValue(mockPatchTimelineResponse),
+ },
+ });
+ api.persistTimeline({ timelineId, timeline: inputTimeline, version });
+ });
+
+ afterAll(() => {
+ jest.resetAllMocks();
+ });
+
+ test('it should update timeline', () => {
+ expect(patchMock.mock.calls[0][0]).toEqual(TIMELINE_URL);
+ });
+
+ test('it should update timeline with patch', () => {
+ expect(patchMock.mock.calls[0][1].method).toEqual('PATCH');
+ });
+
+ test('should call patch timeline', () => {
+ expect(JSON.parse(patchMock.mock.calls[0][1].body)).toEqual({
+ timeline: inputTimeline,
+ timelineId,
+ version,
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/timelines/containers/api.ts b/x-pack/plugins/security_solution/public/timelines/containers/api.ts
index 9f5e65e0fc5af..10893feccfed4 100644
--- a/x-pack/plugins/security_solution/public/timelines/containers/api.ts
+++ b/x-pack/plugins/security_solution/public/timelines/containers/api.ts
@@ -77,9 +77,15 @@ export const persistTimeline = async ({
}: RequestPersistTimeline): Promise => {
if (timelineId == null && timeline.status === TimelineStatus.draft) {
const draftTimeline = await cleanDraftTimeline({ timelineType: timeline.timelineType! });
+
return patchTimeline({
timelineId: draftTimeline.data.persistTimeline.timeline.savedObjectId,
- timeline,
+ timeline: {
+ ...timeline,
+ templateTimelineId: draftTimeline.data.persistTimeline.timeline.templateTimelineId,
+ templateTimelineVersion:
+ draftTimeline.data.persistTimeline.timeline.templateTimelineVersion,
+ },
version: draftTimeline.data.persistTimeline.timeline.version ?? '',
});
}
diff --git a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.test.tsx b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.test.tsx
index 1fc3a33fbca08..e7b66c4e8addb 100644
--- a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.test.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.test.tsx
@@ -10,6 +10,7 @@ import React from 'react';
import { useKibana } from '../../common/lib/kibana';
import { TimelinesPageComponent } from './timelines_page';
+import { disableTemplate } from '../../../common/constants';
jest.mock('../../overview/components/events_by_dataset');
@@ -18,6 +19,7 @@ jest.mock('../../common/lib/kibana', () => {
useKibana: jest.fn(),
};
});
+
describe('TimelinesPageComponent', () => {
const mockAppollloClient = {} as ApolloClient