[Security Solution] Update export timeline success message by exported timelines' type (#79469)
* init tests * fix export on success message * add cypress tests * fix unit tests * fix unit tests * Update x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com>
This commit is contained in:
parent
8cae9ef25b
commit
8cab902482
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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"]';
|
||||
|
||||
|
|
|
@ -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}`);
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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(<TimelineDownloader {...testProps} />);
|
||||
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(<TimelineDownloader {...testProps} />);
|
||||
});
|
||||
|
||||
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(<TimelineDownloader {...testProps} />);
|
||||
});
|
||||
|
||||
wrapper.update();
|
||||
|
||||
expect(mockDispatchToaster.mock.calls[0][0].title).toEqual(
|
||||
i18n.SUCCESSFULLY_EXPORTED_TIMELINES
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -29,7 +29,7 @@ export const Pin = React.memo<Props>(
|
|||
const isTemplate = timelineType === TimelineType.template;
|
||||
return (
|
||||
<EuiButtonIcon
|
||||
aria-label={pinned ? i18n.PINNED : i18n.UNPINNED}
|
||||
aria-label={isTemplate ? i18n.DISABLE_PIN : pinned ? i18n.PINNED : i18n.UNPINNED}
|
||||
data-test-subj="pin"
|
||||
iconSize={iconSize}
|
||||
iconType={getPinIcon(pinned)}
|
||||
|
|
Loading…
Reference in a new issue