diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_template_creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_template_creation.spec.ts
new file mode 100644
index 000000000000..e262d12770d3
--- /dev/null
+++ b/x-pack/plugins/security_solution/cypress/integration/timeline_template_creation.spec.ts
@@ -0,0 +1,100 @@
+/*
+ * 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 { timeline } from '../objects/timeline';
+
+import {
+ FAVORITE_TIMELINE,
+ LOCKED_ICON,
+ NOTES,
+ NOTES_BUTTON,
+ NOTES_COUNT,
+ NOTES_TEXT_AREA,
+ TIMELINE_DESCRIPTION,
+ // TIMELINE_FILTER,
+ TIMELINE_QUERY,
+ TIMELINE_TITLE,
+} from '../screens/timeline';
+import {
+ TIMELINES_DESCRIPTION,
+ TIMELINES_PINNED_EVENT_COUNT,
+ TIMELINES_NOTES_COUNT,
+ TIMELINES_FAVORITE,
+} from '../screens/timelines';
+
+import { loginAndWaitForPage } from '../tasks/login';
+import { openTimelineUsingToggle } from '../tasks/security_main';
+import {
+ addDescriptionToTimeline,
+ addFilter,
+ addNameToTimeline,
+ addNotesToTimeline,
+ closeNotes,
+ closeTimeline,
+ createNewTimelineTemplate,
+ markAsFavorite,
+ openTimelineFromSettings,
+ populateTimeline,
+ waitForTimelineChanges,
+} from '../tasks/timeline';
+import { openTimeline } from '../tasks/timelines';
+
+import { OVERVIEW_URL } from '../urls/navigation';
+
+describe('Timeline Templates', () => {
+ before(() => {
+ cy.server();
+ cy.route('PATCH', '**/api/timeline').as('timeline');
+ });
+
+ it('Creates a timeline template', async () => {
+ loginAndWaitForPage(OVERVIEW_URL);
+ openTimelineUsingToggle();
+ createNewTimelineTemplate();
+ populateTimeline();
+ addFilter(timeline.filter);
+ // To fix
+ // cy.get(PIN_EVENT).should(
+ // 'have.attr',
+ // 'aria-label',
+ // 'This event may not be pinned while editing a template timeline'
+ // );
+ cy.get(LOCKED_ICON).should('be.visible');
+
+ addNameToTimeline(timeline.title);
+
+ const response = await cy.wait('@timeline').promisify();
+ const timelineId = JSON.parse(response.xhr.responseText).data.persistTimeline.timeline
+ .savedObjectId;
+
+ addDescriptionToTimeline(timeline.description);
+ addNotesToTimeline(timeline.notes);
+ closeNotes();
+ markAsFavorite();
+ waitForTimelineChanges();
+ createNewTimelineTemplate();
+ closeTimeline();
+ openTimelineFromSettings();
+
+ cy.contains(timeline.title).should('exist');
+ cy.get(TIMELINES_DESCRIPTION).first().should('have.text', timeline.description);
+ cy.get(TIMELINES_PINNED_EVENT_COUNT).first().should('have.text', '1');
+ cy.get(TIMELINES_NOTES_COUNT).first().should('have.text', '1');
+ cy.get(TIMELINES_FAVORITE).first().should('exist');
+
+ openTimeline(timelineId);
+
+ cy.get(FAVORITE_TIMELINE).should('exist');
+ cy.get(TIMELINE_TITLE).should('have.attr', 'value', timeline.title);
+ cy.get(TIMELINE_DESCRIPTION).should('have.attr', 'value', timeline.description);
+ cy.get(TIMELINE_QUERY).should('have.text', timeline.query);
+ // Comments this assertion until we agreed what to do with the filters.
+ // cy.get(TIMELINE_FILTER(timeline.filter)).should('exist');
+ cy.get(NOTES_COUNT).should('have.text', '1');
+ cy.get(NOTES_BUTTON).click();
+ cy.get(NOTES_TEXT_AREA).should('have.attr', 'placeholder', 'Add a Note');
+ cy.get(NOTES).should('have.text', timeline.notes);
+ });
+});
diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_templates_export.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_templates_export.spec.ts
new file mode 100644
index 000000000000..8dcb5e144c24
--- /dev/null
+++ b/x-pack/plugins/security_solution/cypress/integration/timeline_templates_export.spec.ts
@@ -0,0 +1,47 @@
+/*
+ * 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 { exportTimeline } from '../tasks/timelines';
+import { esArchiverLoad } from '../tasks/es_archiver';
+import { loginAndWaitForPageWithoutDateRange } from '../tasks/login';
+import { timeline as timelineTemplate } from '../objects/timeline';
+
+import { TIMELINE_TEMPLATES_URL } from '../urls/navigation';
+import { openTimelineUsingToggle } from '../tasks/security_main';
+import { addNameToTimeline, closeTimeline, createNewTimelineTemplate } from '../tasks/timeline';
+
+describe('Export timelines', () => {
+ before(() => {
+ esArchiverLoad('timeline');
+ cy.server();
+ cy.route('PATCH', '**/api/timeline').as('timeline');
+ cy.route('POST', '**api/timeline/_export?file_name=timelines_export.ndjson*').as('export');
+ });
+
+ it('Exports a custom timeline template', async () => {
+ loginAndWaitForPageWithoutDateRange(TIMELINE_TEMPLATES_URL);
+ openTimelineUsingToggle();
+ createNewTimelineTemplate();
+ addNameToTimeline(timelineTemplate.title);
+ closeTimeline();
+
+ const result = await cy.wait('@timeline').promisify();
+
+ const timelineId = JSON.parse(result.xhr.responseText).data.persistTimeline.timeline
+ .savedObjectId;
+ const templateTimelineId = JSON.parse(result.xhr.responseText).data.persistTimeline.timeline
+ .templateTimelineId;
+
+ await exportTimeline(timelineId);
+
+ cy.wait('@export').then((response) => {
+ cy.wrap(JSON.parse(response.xhr.responseText).templateTimelineId).should(
+ 'eql',
+ templateTimelineId
+ );
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/cypress/screens/timeline.ts b/x-pack/plugins/security_solution/cypress/screens/timeline.ts
index 94255a2af897..e397dd9b5a41 100644
--- a/x-pack/plugins/security_solution/cypress/screens/timeline.ts
+++ b/x-pack/plugins/security_solution/cypress/screens/timeline.ts
@@ -29,6 +29,8 @@ export const COMBO_BOX = '.euiComboBoxOption__content';
export const CREATE_NEW_TIMELINE = '[data-test-subj="timeline-new"]';
+export const CREATE_NEW_TIMELINE_TEMPLATE = '[data-test-subj="template-timeline-new"]';
+
export const DRAGGABLE_HEADER =
'[data-test-subj="events-viewer-panel"] [data-test-subj="headers-group"] [data-test-subj="draggable-header"]';
diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts
index 438700bdfca8..7c9c95427a4d 100644
--- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts
+++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts
@@ -43,6 +43,7 @@ import {
TIMELINE_TITLE,
TIMESTAMP_TOGGLE_FIELD,
TOGGLE_TIMELINE_EXPAND_EVENT,
+ CREATE_NEW_TIMELINE_TEMPLATE,
} from '../screens/timeline';
import { TIMELINES_TABLE } from '../screens/timelines';
@@ -114,6 +115,11 @@ export const createNewTimeline = () => {
cy.get(CLOSE_TIMELINE_BTN).click({ force: true });
};
+export const createNewTimelineTemplate = () => {
+ cy.get(TIMELINE_SETTINGS_ICON).click({ force: true });
+ cy.get(CREATE_NEW_TIMELINE_TEMPLATE).click();
+};
+
export const executeTimelineKQL = (query: string) => {
cy.get(`${SEARCH_OR_FILTER_CONTAINER} textarea`).type(`${query} {enter}`);
};
diff --git a/x-pack/plugins/security_solution/cypress/urls/navigation.ts b/x-pack/plugins/security_solution/cypress/urls/navigation.ts
index b53b06db5bed..66b9bf8e111a 100644
--- a/x-pack/plugins/security_solution/cypress/urls/navigation.ts
+++ b/x-pack/plugins/security_solution/cypress/urls/navigation.ts
@@ -20,3 +20,4 @@ export const ADMINISTRATION_URL = '/app/security/administration';
export const NETWORK_URL = '/app/security/network';
export const OVERVIEW_URL = '/app/security/overview';
export const TIMELINES_URL = '/app/security/timelines';
+export const TIMELINE_TEMPLATES_URL = '/app/security/timelines/template';
diff --git a/x-pack/plugins/security_solution/public/common/components/generic_downloader/index.tsx b/x-pack/plugins/security_solution/public/common/components/generic_downloader/index.tsx
index 33e26cd4db03..c8efc366643e 100644
--- a/x-pack/plugins/security_solution/public/common/components/generic_downloader/index.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/generic_downloader/index.tsx
@@ -71,9 +71,11 @@ export const GenericDownloaderComponent = ({
anchorRef.current.href = objectURL; // eslint-disable-line require-atomic-updates
anchorRef.current.download = filename; // eslint-disable-line require-atomic-updates
anchorRef.current.click();
- window.URL.revokeObjectURL(objectURL);
- }
+ if (typeof window.URL.revokeObjectURL === 'function') {
+ window.URL.revokeObjectURL(objectURL);
+ }
+ }
if (onExportSuccess != null) {
onExportSuccess(ids.length);
}
diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx
index b8a7cfd59d22..d0cfbaccde7d 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx
@@ -5,9 +5,15 @@
*/
import React from 'react';
+import { useStateToaster } from '../../../../common/components/toasters';
+
import { TimelineDownloader } from './export_timeline';
import { mockSelectedTimeline } from './mocks';
+import * as i18n from '../translations';
+
import { ReactWrapper, mount } from 'enzyme';
+import { act } from 'react-dom/test-utils';
+import { useParams } from 'react-router-dom';
jest.mock('../translations', () => {
return {
@@ -22,6 +28,23 @@ jest.mock('.', () => {
};
});
+jest.mock('react-router-dom', () => {
+ const actual = jest.requireActual('react-router-dom');
+
+ return {
+ ...actual,
+ useParams: jest.fn(),
+ };
+});
+
+jest.mock('../../../../common/components/toasters', () => {
+ const actual = jest.requireActual('../../../../common/components/toasters');
+ return {
+ ...actual,
+ useStateToaster: jest.fn(),
+ };
+});
+
describe('TimelineDownloader', () => {
let wrapper: ReactWrapper;
const defaultTestProps = {
@@ -30,6 +53,20 @@ describe('TimelineDownloader', () => {
isEnableDownloader: true,
onComplete: jest.fn(),
};
+ const mockDispatchToaster = jest.fn();
+
+ beforeEach(() => {
+ (useStateToaster as jest.Mock).mockReturnValue([jest.fn(), mockDispatchToaster]);
+ (useParams as jest.Mock).mockReturnValue({ tabName: 'default' });
+ });
+
+ afterEach(() => {
+ (useStateToaster as jest.Mock).mockClear();
+ (useParams as jest.Mock).mockReset();
+
+ (mockDispatchToaster as jest.Mock).mockClear();
+ });
+
describe('should not render a downloader', () => {
test('Without exportedIds', () => {
const testProps = {
@@ -59,5 +96,38 @@ describe('TimelineDownloader', () => {
wrapper = mount();
expect(wrapper.find('[data-test-subj="export-timeline-downloader"]').exists()).toBeTruthy();
});
+
+ test('With correct toast message on success for exported timelines', async () => {
+ const testProps = {
+ ...defaultTestProps,
+ };
+
+ await act(() => {
+ wrapper = mount();
+ });
+
+ wrapper.update();
+
+ expect(mockDispatchToaster.mock.calls[0][0].title).toEqual(
+ i18n.SUCCESSFULLY_EXPORTED_TIMELINES
+ );
+ });
+
+ test('With correct toast message on success for exported templates', async () => {
+ const testProps = {
+ ...defaultTestProps,
+ };
+ (useParams as jest.Mock).mockReturnValue({ tabName: 'template' });
+
+ await act(() => {
+ wrapper = mount();
+ });
+
+ wrapper.update();
+
+ expect(mockDispatchToaster.mock.calls[0][0].title).toEqual(
+ i18n.SUCCESSFULLY_EXPORTED_TIMELINES
+ );
+ });
});
});
diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.tsx
index e6eec7198d11..cc62d29cde34 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.tsx
@@ -6,12 +6,15 @@
import React, { useCallback } from 'react';
import uuid from 'uuid';
+import { useParams } from 'react-router-dom';
+
import {
GenericDownloader,
ExportSelectedData,
} from '../../../../common/components/generic_downloader';
import * as i18n from '../translations';
import { useStateToaster } from '../../../../common/components/toasters';
+import { TimelineType } from '../../../../../common/types/timeline';
const ExportTimeline: React.FC<{
exportedIds: string[] | undefined;
@@ -20,22 +23,28 @@ const ExportTimeline: React.FC<{
onComplete?: () => void;
}> = ({ onComplete, isEnableDownloader, exportedIds, getExportedData }) => {
const [, dispatchToaster] = useStateToaster();
+ const { tabName: timelineType } = useParams<{ tabName: TimelineType }>();
+
const onExportSuccess = useCallback(
(exportCount) => {
if (onComplete != null) {
onComplete();
}
+
dispatchToaster({
type: 'addToaster',
toast: {
id: uuid.v4(),
- title: i18n.SUCCESSFULLY_EXPORTED_TIMELINES(exportCount),
+ title:
+ timelineType === TimelineType.template
+ ? i18n.SUCCESSFULLY_EXPORTED_TIMELINE_TEMPLATES(exportCount)
+ : i18n.SUCCESSFULLY_EXPORTED_TIMELINES(exportCount),
color: 'success',
iconType: 'check',
},
});
},
- [dispatchToaster, onComplete]
+ [dispatchToaster, onComplete, timelineType]
);
const onExportFailure = useCallback(() => {
if (onComplete != null) {
diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx
index 2d5849463270..07f22bd47a9d 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx
@@ -22,6 +22,15 @@ import { TimelineType, TimelineStatus } from '../../../../common/types/timeline'
jest.mock('../../../common/lib/kibana');
+jest.mock('react-router-dom', () => {
+ const actual = jest.requireActual('react-router-dom');
+
+ return {
+ ...actual,
+ useParams: jest.fn().mockReturnValue({ tabName: 'default' }),
+ };
+});
+
describe('OpenTimeline', () => {
const theme = () => ({ eui: euiDarkVars, darkMode: true });
const title = 'All Timelines / Open Timelines';
diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts
index bad2fe7e02e1..3f391714bb05 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts
+++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts
@@ -270,6 +270,16 @@ export const SUCCESSFULLY_EXPORTED_TIMELINES = (totalTimelines: number) =>
'Successfully exported {totalTimelines, plural, =0 {all timelines} =1 {{totalTimelines} timeline} other {{totalTimelines} timelines}}',
});
+export const SUCCESSFULLY_EXPORTED_TIMELINE_TEMPLATES = (totalTimelineTemplates: number) =>
+ i18n.translate(
+ 'xpack.securitySolution.open.timeline.successfullyExportedTimelineTemplatesTitle',
+ {
+ values: { totalTimelineTemplates },
+ defaultMessage:
+ 'Successfully exported {totalTimelineTemplates, plural, =0 {all timelines} =1 {{totalTimelineTemplates} timeline template} other {{totalTimelineTemplates} timeline templates}}',
+ }
+ );
+
export const FILTER_TIMELINES = (timelineType: string) =>
i18n.translate('xpack.securitySolution.open.timeline.filterByTimelineTypesTitle', {
values: { timelineType },
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx
index 27780c7754d0..df7fd7befb81 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx
@@ -29,7 +29,7 @@ export const Pin = React.memo(
const isTemplate = timelineType === TimelineType.template;
return (