From 622470cbf0b050d80f75c6cff4745fc92150df4a Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Tue, 9 Jun 2020 13:36:39 +0100 Subject: [PATCH] [SIEM] Add create template button (#66613) (#68635) * add template btn * rename file * unit test * replace connector with useDispatch * comments * add disableTemplate * rename flag * fix types * remove snapshot * fix types * add fix action * move disableTemplate flag into constants * fix types * Fix timelineType * button style * unit test * unit test * fix types * Update x-pack/plugins/siem/public/timelines/components/timeline/properties/create_timeline_btn.tsx Co-authored-by: patrykkopycinski * fix unit test * add unit test * add unit test * fix types * fix tests * fix unit * fix i18n key * remove snapshot * fix crud * fix crud * fix unit * fix tag * fix unit * disable template timeline * Update use_create_timeline.tsx Co-authored-by: Elastic Machine Co-authored-by: Patryk Kopycinski Co-authored-by: Elastic Machine Co-authored-by: Patryk Kopycinski --- .../security_solution/common/constants.ts | 6 + .../__snapshots__/index.test.tsx.snap | 5 - .../matrix_histogram/index.test.tsx | 2 - .../__snapshots__/index.test.tsx.snap | 958 ------------------ .../components/stat_items/index.test.tsx | 7 - .../header_with_close_button/index.test.tsx | 17 + .../components/open_timeline/index.tsx | 7 +- .../components/timeline/index.test.tsx | 2 +- .../timeline/properties/helpers.test.tsx | 84 ++ .../timeline/properties/helpers.tsx | 62 +- .../timeline/properties/index.test.tsx | 48 +- .../components/timeline/properties/index.tsx | 14 +- .../properties/new_template_timeline.test.tsx | 104 ++ .../properties/new_template_timeline.tsx | 42 + .../properties/properties_right.test.tsx | 289 ++++++ .../timeline/properties/properties_right.tsx | 233 +++-- .../timeline/properties/translations.ts | 7 + .../properties/use_create_timeline.test.tsx | 60 ++ .../properties/use_create_timeline.tsx | 66 ++ .../components/timeline/timeline.test.tsx | 3 +- .../containers/all/index.gql_query.ts | 1 + .../public/timelines/containers/api.test.ts | 503 +++++++++ .../public/timelines/containers/api.ts | 8 +- .../timelines/pages/timelines_page.test.tsx | 16 + .../public/timelines/pages/timelines_page.tsx | 59 +- .../timelines/store/timeline/actions.ts | 2 + .../timelines/store/timeline/helpers.ts | 9 +- .../timelines/store/timeline/reducer.test.ts | 3 + .../timelines/store/timeline/reducer.ts | 3 + .../lib/timeline/pick_saved_timeline.ts | 10 +- .../clean_draft_timelines_route.test.ts | 11 + .../routes/clean_draft_timelines_route.ts | 16 +- .../routes/get_draft_timelines_route.test.ts | 13 +- .../routes/get_draft_timelines_route.ts | 10 +- .../timeline/routes/update_timelines_route.ts | 1 + .../server/lib/timeline/saved_object.ts | 13 +- 36 files changed, 1526 insertions(+), 1168 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/common/components/matrix_histogram/__snapshots__/index.test.tsx.snap delete mode 100644 x-pack/plugins/security_solution/public/common/components/stat_items/__snapshots__/index.test.tsx.snap create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/containers/api.test.ts 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; let wrapper: ShallowWrapper; @@ -58,6 +60,20 @@ describe('TimelinesPageComponent', () => { wrapper.find('[data-test-subj="stateful-open-timeline"]').prop('importDataModalToggle') ).toEqual(true); }); + + 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 + ); + }); }); describe('If the user is not authorised', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx index fd734d10ecba0..2c692d850cd16 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx @@ -4,15 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButton } from '@elastic/eui'; +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import ApolloClient from 'apollo-client'; import React, { useCallback, useState } from 'react'; import styled from 'styled-components'; + +import { disableTemplate } from '../../../common/constants'; + import { HeaderPage } from '../../common/components/header_page'; import { WrapperPage } from '../../common/components/wrapper_page'; import { useKibana } from '../../common/lib/kibana'; import { SpyRoute } from '../../common/utils/route/spy_routes'; + import { StatefulOpenTimeline } from '../components/open_timeline'; +import { NEW_TEMPLATE_TIMELINE } from '../components/timeline/properties/translations'; +import { NewTemplateTimeline } from '../components/timeline/properties/new_template_timeline'; +import { NewTimeline } from '../components/timeline/properties/helpers'; + import * as i18n from './translations'; const TimelinesContainer = styled.div` @@ -34,24 +42,47 @@ export const TimelinesPageComponent: React.FC = ({ apolloClient }) => }, [setImportDataModalToggle]); const uiCapabilities = useKibana().services.application.capabilities; - const capabilitiesCanUserCRUD: boolean = - typeof uiCapabilities.securitySolution.crud === 'boolean' - ? uiCapabilities.securitySolution.crud - : false; + const capabilitiesCanUserCRUD: boolean = !!uiCapabilities.securitySolution.crud; return ( <> - {capabilitiesCanUserCRUD && ( - - {i18n.ALL_TIMELINES_IMPORT_TIMELINE_TITLE} - - )} + + + {capabilitiesCanUserCRUD && ( + + {i18n.ALL_TIMELINES_IMPORT_TIMELINE_TITLE} + + )} + + + {capabilitiesCanUserCRUD && ( + + )} + + {/** + * CreateTemplateTimelineBtn + * Remove the comment here to enable CreateTemplateTimelineBtn + */} + {!disableTemplate && ( + + + + )} + diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts index e11d1bcc72e09..e8b5ba68eecdf 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts @@ -16,6 +16,7 @@ import { KueryFilterQuery, SerializedFilterQuery } from '../../../common/store/t import { EventType, KqlMode, TimelineModel, ColumnHeaderOptions } from './model'; import { TimelineNonEcsData } from '../../../graphql/types'; +import { TimelineTypeLiteral } from '../../../../common/types/timeline'; const actionCreator = actionCreatorFactory('x-pack/security_solution/local/timeline'); @@ -67,6 +68,7 @@ export const createTimeline = actionCreator<{ sort?: Sort; showCheckboxes?: boolean; showRowRenderers?: boolean; + timelineType?: TimelineTypeLiteral; }>('CREATE_TIMELINE'); export const pinEvent = actionCreator<{ id: string; eventId: string }>('PIN_EVENT'); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts index b2472cbe89a50..97ac423cee653 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts @@ -7,6 +7,9 @@ import { getOr, omit, uniq, isEmpty, isEqualWith, union } from 'lodash/fp'; import { Filter } from '../../../../../../../src/plugins/data/public'; + +import { disableTemplate } from '../../../../common/constants'; + import { getColumnWidthFromType } from '../../../timelines/components/timeline/body/column_headers/helpers'; import { Sort } from '../../../timelines/components/timeline/body/sort'; import { @@ -15,11 +18,12 @@ import { QueryMatch, } from '../../../timelines/components/timeline/data_providers/data_provider'; import { KueryFilterQuery, SerializedFilterQuery } from '../../../common/store/model'; +import { TimelineNonEcsData } from '../../../graphql/types'; +import { TimelineTypeLiteral } from '../../../../common/types/timeline'; import { timelineDefaults } from './defaults'; import { ColumnHeaderOptions, KqlMode, TimelineModel, EventType } from './model'; import { TimelineById, TimelineState } from './types'; -import { TimelineNonEcsData } from '../../../graphql/types'; const EMPTY_TIMELINE_BY_ID: TimelineById = {}; // stable reference @@ -147,6 +151,7 @@ interface AddNewTimelineParams { showCheckboxes?: boolean; showRowRenderers?: boolean; timelineById: TimelineById; + timelineType: TimelineTypeLiteral; } /** Adds a new `Timeline` to the provided collection of `TimelineById` */ @@ -163,6 +168,7 @@ export const addNewTimeline = ({ showCheckboxes = false, showRowRenderers = true, timelineById, + timelineType, }: AddNewTimelineParams): TimelineById => ({ ...timelineById, [id]: { @@ -182,6 +188,7 @@ export const addNewTimeline = ({ isLoading: false, showCheckboxes, showRowRenderers, + timelineType: !disableTemplate ? timelineType : timelineDefaults.timelineType, }, }); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts index d17bc1f20e1e8..3bdb16be79939 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts @@ -139,6 +139,7 @@ describe('Timeline', () => { id: 'bar', columns: defaultHeaders, timelineById: timelineByIdMock, + timelineType: TimelineType.default, }); expect(update).not.toBe(timelineByIdMock); }); @@ -148,6 +149,7 @@ describe('Timeline', () => { id: 'bar', columns: timelineDefaults.columns, timelineById: timelineByIdMock, + timelineType: TimelineType.default, }); expect(update).toEqual({ foo: timelineByIdMock.foo, @@ -163,6 +165,7 @@ describe('Timeline', () => { id: 'bar', columns: defaultHeaders, timelineById: timelineByIdMock, + timelineType: TimelineType.default, }); expect(update).toEqual({ foo: timelineByIdMock.foo, diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts index a8ae39527cdbf..3666968e8ab92 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts @@ -98,6 +98,7 @@ import { } from './helpers'; import { TimelineState, EMPTY_TIMELINE_BY_ID } from './types'; +import { TimelineType } from '../../../../common/types/timeline'; export const initialTimelineState: TimelineState = { timelineById: EMPTY_TIMELINE_BY_ID, @@ -129,6 +130,7 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) sort, showCheckboxes, showRowRenderers, + timelineType = TimelineType.default, filters, } ) => ({ @@ -146,6 +148,7 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) showCheckboxes, showRowRenderers, timelineById: state.timelineById, + timelineType, }), }) ) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/pick_saved_timeline.ts b/x-pack/plugins/security_solution/server/lib/timeline/pick_saved_timeline.ts index 40c568ecda741..281726d488abe 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/pick_saved_timeline.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/pick_saved_timeline.ts @@ -39,17 +39,15 @@ export const pickSavedTimeline = ( savedTimeline.templateTimelineVersion = savedTimeline.templateTimelineVersion + 1; } } - } else if (savedTimeline.status === TimelineStatus.draft) { - savedTimeline.status = !isEmpty(savedTimeline.title) - ? TimelineStatus.active - : TimelineStatus.draft; - savedTimeline.templateTimelineId = null; - savedTimeline.templateTimelineVersion = null; } else { savedTimeline.timelineType = savedTimeline.timelineType ?? TimelineType.default; savedTimeline.templateTimelineId = null; savedTimeline.templateTimelineVersion = null; } + if (!isEmpty(savedTimeline.title) && savedTimeline.status === TimelineStatus.draft) { + savedTimeline.status = TimelineStatus.active; + } + return savedTimeline; }; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.test.ts index 9dc957604d4df..0e53cee0bf8ac 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.test.ts @@ -17,6 +17,7 @@ import { cleanDraftTimelinesRequest, createTimelineWithTimelineId, } from './__mocks__/request_responses'; +import { draftTimelineDefaults } from '../default_timeline'; describe('clean draft timelines', () => { let server: ReturnType; @@ -81,7 +82,12 @@ describe('clean draft timelines', () => { }); const response = await server.inject(cleanDraftTimelinesRequest(TimelineType.default), context); + const req = cleanDraftTimelinesRequest(TimelineType.default); expect(mockPersistTimeline).toHaveBeenCalled(); + expect(mockPersistTimeline.mock.calls[0][3]).toEqual({ + ...draftTimelineDefaults, + timelineType: req.body.timelineType, + }); expect(response.status).toEqual(200); expect(response.body).toEqual({ data: { @@ -100,8 +106,13 @@ describe('clean draft timelines', () => { mockGetTimeline.mockResolvedValue({ ...mockGetDraftTimelineValue }); const response = await server.inject(cleanDraftTimelinesRequest(TimelineType.default), context); + const req = cleanDraftTimelinesRequest(TimelineType.default); + expect(mockPersistTimeline).not.toHaveBeenCalled(); expect(mockResetTimeline).toHaveBeenCalled(); + expect(mockResetTimeline.mock.calls[0][1]).toEqual([mockGetDraftTimelineValue.savedObjectId]); + expect(mockResetTimeline.mock.calls[0][2]).toEqual(req.body.timelineType); + expect(mockGetTimeline).toHaveBeenCalled(); expect(response.status).toEqual(200); expect(response.body).toEqual({ diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.ts index 30225da6b42cc..9ad50b8f2266c 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.ts @@ -40,7 +40,11 @@ export const cleanDraftTimelinesRoute = ( } = await getDraftTimeline(frameworkRequest, request.body.timelineType); if (draftTimeline?.savedObjectId) { - await resetTimeline(frameworkRequest, [draftTimeline.savedObjectId]); + await resetTimeline( + frameworkRequest, + [draftTimeline.savedObjectId], + request.body.timelineType + ); const cleanedDraftTimeline = await getTimeline( frameworkRequest, draftTimeline.savedObjectId @@ -57,12 +61,10 @@ export const cleanDraftTimelinesRoute = ( }); } - const newTimelineResponse = await persistTimeline( - frameworkRequest, - null, - null, - draftTimelineDefaults - ); + const newTimelineResponse = await persistTimeline(frameworkRequest, null, null, { + ...draftTimelineDefaults, + timelineType: request.body.timelineType, + }); if (newTimelineResponse.code === 200) { return response.ok({ diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/get_draft_timelines_route.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/get_draft_timelines_route.test.ts index e9bceb2c66806..5447da8ef49d2 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/get_draft_timelines_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/get_draft_timelines_route.test.ts @@ -17,6 +17,7 @@ import { getDraftTimelinesRequest, createTimelineWithTimelineId, } from './__mocks__/request_responses'; +import { draftTimelineDefaults } from '../default_timeline'; describe('get draft timelines', () => { let server: ReturnType; @@ -80,12 +81,14 @@ describe('get draft timelines', () => { mockGetDraftTimeline.mockResolvedValue({ timeline: [], }); - - const response = await server.inject( - getDraftTimelinesRequest(TimelineType.default), - context - ); + const req = getDraftTimelinesRequest(TimelineType.default); + const response = await server.inject(req, context); expect(mockPersistTimeline).toHaveBeenCalled(); + expect(mockPersistTimeline.mock.calls[0][3]).toEqual({ + ...draftTimelineDefaults, + timelineType: req.query.timelineType, + }); + expect(response.status).toEqual(200); expect(response.body).toEqual({ data: { diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/get_draft_timelines_route.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/get_draft_timelines_route.ts index 7b379741fc217..4db434ec816aa 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/get_draft_timelines_route.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/get_draft_timelines_route.ts @@ -51,12 +51,10 @@ export const getDraftTimelinesRoute = ( }); } - const newTimelineResponse = await persistTimeline( - frameworkRequest, - null, - null, - draftTimelineDefaults - ); + const newTimelineResponse = await persistTimeline(frameworkRequest, null, null, { + ...draftTimelineDefaults, + timelineType: request.query.timelineType, + }); if (newTimelineResponse.code === 200) { return response.ok({ diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.ts index a479c818cb01d..d5ecd408a6ef4 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.ts @@ -52,6 +52,7 @@ export const updateTimelinesRoute = ( templateTimelineId != null ? await getTemplateTimeline(frameworkRequest, templateTimelineId) : null; + const errorObj = checkIsFailureCases( isHandlingTemplateTimeline, version, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts index dfbf273cbdec2..bbb11cd642c4c 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts @@ -7,7 +7,7 @@ import { getOr } from 'lodash/fp'; import { SavedObjectsFindOptions } from '../../../../../../src/core/server'; -import { UNAUTHENTICATED_USER } from '../../../common/constants'; +import { UNAUTHENTICATED_USER, disableTemplate } from '../../../common/constants'; import { NoteSavedObject } from '../../../common/types/timeline/note'; import { PinnedEventSavedObject } from '../../../common/types/timeline/pinned_event'; import { @@ -122,7 +122,6 @@ const getTimelineTypeFilter = ( const draftFilter = includeDraft ? `siem-ui-timeline.attributes.status: ${TimelineStatus.draft}` : `not siem-ui-timeline.attributes.status: ${TimelineStatus.draft}`; - return `${typeFilter} and ${draftFilter}`; }; @@ -147,7 +146,7 @@ export const getAllTimeline = async ( * Remove the comment here to enable template timeline and apply the change below * filter: getTimelineTypeFilter(timelineType, false) */ - filter: getTimelineTypeFilter(TimelineType.default, false), + filter: getTimelineTypeFilter(disableTemplate ? TimelineType.default : timelineType, false), sortField: sort != null ? sort.sortField : undefined, sortOrder: sort != null ? sort.sortOrder : undefined, }; @@ -321,7 +320,11 @@ const updatePartialSavedTimeline = async ( ); }; -export const resetTimeline = async (request: FrameworkRequest, timelineIds: string[]) => { +export const resetTimeline = async ( + request: FrameworkRequest, + timelineIds: string[], + timelineType: TimelineType +) => { if (!timelineIds.length) { return Promise.reject(new Error('timelineIds is empty')); } @@ -337,7 +340,7 @@ export const resetTimeline = async (request: FrameworkRequest, timelineIds: stri const response = await Promise.all( timelineIds.map((timelineId) => - updatePartialSavedTimeline(request, timelineId, draftTimelineDefaults) + updatePartialSavedTimeline(request, timelineId, { ...draftTimelineDefaults, timelineType }) ) );