From 1b501aabd0a006fbeb985285ad5a5f3540391da3 Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Tue, 11 May 2021 13:43:11 +0200 Subject: [PATCH] [SecuritySolution] Add success toast to timeline deletion (#99612) (#99764) * Add success toast to timeline deletion * Add unit tests for timeline deletion toast * Refactor export_timeline to use useAppToasts instead of useStateToaster --- .../delete_timeline_modal/index.test.tsx | 35 ++++++++++++++++- .../delete_timeline_modal/index.tsx | 19 ++++++++- .../export_timeline/export_timeline.test.tsx | 39 +++++++------------ .../export_timeline/export_timeline.tsx | 24 +++++------- .../components/open_timeline/translations.ts | 14 +++++++ 5 files changed, 87 insertions(+), 44 deletions(-) diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.test.tsx index cfbc7d255062..54b405feeb17 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.test.tsx @@ -11,6 +11,10 @@ import { useParams } from 'react-router-dom'; import { DeleteTimelineModalOverlay } from '.'; import { TimelineType } from '../../../../../common/types/timeline'; +import * as i18n from '../translations'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; + +jest.mock('../../../../common/hooks/use_app_toasts'); jest.mock('react-router-dom', () => { const actual = jest.requireActual('react-router-dom'); @@ -21,12 +25,19 @@ jest.mock('react-router-dom', () => { }); describe('DeleteTimelineModal', () => { - const savedObjectId = 'abcd'; + const mockAddSuccess = jest.fn(); + (useAppToasts as jest.Mock).mockReturnValue({ addSuccess: mockAddSuccess }); + + afterEach(() => { + mockAddSuccess.mockClear(); + }); + + const savedObjectIds = ['abcd']; const defaultProps = { closeModal: jest.fn(), deleteTimelines: jest.fn(), isModalOpen: true, - savedObjectIds: [savedObjectId], + savedObjectIds, title: 'Privilege Escalation', }; @@ -56,5 +67,25 @@ describe('DeleteTimelineModal', () => { expect(wrapper.find('[data-test-subj="remove-popover"]').first().exists()).toBe(true); }); + + test('it shows correct toast message on success for deleted timelines', async () => { + const wrapper = mountWithIntl(); + wrapper.find('button[data-test-subj="confirmModalConfirmButton"]').simulate('click'); + + expect(mockAddSuccess.mock.calls[0][0].title).toEqual( + i18n.SUCCESSFULLY_DELETED_TIMELINES(savedObjectIds.length) + ); + }); + + test('it shows correct toast message on success for deleted templates', async () => { + (useParams as jest.Mock).mockReturnValue({ tabName: TimelineType.template }); + + const wrapper = mountWithIntl(); + wrapper.find('button[data-test-subj="confirmModalConfirmButton"]').simulate('click'); + + expect(mockAddSuccess.mock.calls[0][0].title).toEqual( + i18n.SUCCESSFULLY_DELETED_TIMELINE_TEMPLATES(savedObjectIds.length) + ); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.tsx index 7dde3fbe4cd2..41e491ccc0ce 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.tsx @@ -9,8 +9,13 @@ import { EuiModal } from '@elastic/eui'; import React, { useCallback } from 'react'; import { createGlobalStyle } from 'styled-components'; +import { useParams } from 'react-router-dom'; import { DeleteTimelineModal, DELETE_TIMELINE_MODAL_WIDTH } from './delete_timeline_modal'; import { DeleteTimelines } from '../types'; +import { TimelineType } from '../../../../../common/types/timeline'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import * as i18n from '../translations'; + const RemovePopover = createGlobalStyle` div.euiPopover__panel-isOpen { display: none; @@ -29,19 +34,29 @@ interface Props { */ export const DeleteTimelineModalOverlay = React.memo( ({ deleteTimelines, isModalOpen, savedObjectIds, title, onComplete }) => { + const { addSuccess } = useAppToasts(); + const { tabName: timelineType } = useParams<{ tabName: TimelineType }>(); + const internalCloseModal = useCallback(() => { if (onComplete != null) { onComplete(); } }, [onComplete]); const onDelete = useCallback(() => { - if (savedObjectIds != null) { + if (savedObjectIds.length > 0) { deleteTimelines(savedObjectIds); + + addSuccess({ + title: + timelineType === TimelineType.template + ? i18n.SUCCESSFULLY_DELETED_TIMELINE_TEMPLATES(savedObjectIds.length) + : i18n.SUCCESSFULLY_DELETED_TIMELINES(savedObjectIds.length), + }); } if (onComplete != null) { onComplete(); } - }, [deleteTimelines, savedObjectIds, onComplete]); + }, [deleteTimelines, savedObjectIds, onComplete, addSuccess, timelineType]); return ( <> {isModalOpen && } 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 feb30364fba2..a273ef1df978 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 @@ -6,7 +6,6 @@ */ import React from 'react'; -import { useStateToaster } from '../../../../common/components/toasters'; import { TimelineDownloader } from './export_timeline'; import { mockSelectedTimeline } from './mocks'; @@ -16,12 +15,9 @@ import { ReactWrapper, mount } from 'enzyme'; import { waitFor } from '@testing-library/react'; import { useParams } from 'react-router-dom'; -jest.mock('../translations', () => { - return { - EXPORT_SELECTED: 'EXPORT_SELECTED', - EXPORT_FILENAME: 'TIMELINE', - }; -}); +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; + +jest.mock('../../../../common/hooks/use_app_toasts'); jest.mock('.', () => { return { @@ -38,34 +34,26 @@ jest.mock('react-router-dom', () => { }; }); -jest.mock('../../../../common/components/toasters', () => { - const actual = jest.requireActual('../../../../common/components/toasters'); - return { - ...actual, - useStateToaster: jest.fn(), - }; -}); - describe('TimelineDownloader', () => { + const mockAddSuccess = jest.fn(); + (useAppToasts as jest.Mock).mockReturnValue({ addSuccess: mockAddSuccess }); + let wrapper: ReactWrapper; + const exportedIds = ['baa20980-6301-11ea-9223-95b6d4dd806c']; const defaultTestProps = { - exportedIds: ['baa20980-6301-11ea-9223-95b6d4dd806c'], + exportedIds, getExportedData: jest.fn(), 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(); + mockAddSuccess.mockClear(); }); describe('should not render a downloader', () => { @@ -104,11 +92,12 @@ describe('TimelineDownloader', () => { }; wrapper = mount(); + await waitFor(() => { wrapper.update(); - expect(mockDispatchToaster.mock.calls[0][0].title).toEqual( - i18n.SUCCESSFULLY_EXPORTED_TIMELINES + expect(mockAddSuccess.mock.calls[0][0].title).toEqual( + i18n.SUCCESSFULLY_EXPORTED_TIMELINES(exportedIds.length) ); }); }); @@ -124,8 +113,8 @@ describe('TimelineDownloader', () => { await waitFor(() => { wrapper.update(); - expect(mockDispatchToaster.mock.calls[0][0].title).toEqual( - i18n.SUCCESSFULLY_EXPORTED_TIMELINES + expect(mockAddSuccess.mock.calls[0][0].title).toEqual( + i18n.SUCCESSFULLY_EXPORTED_TIMELINE_TEMPLATES(exportedIds.length) ); }); }); 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 01f18b5ad9c3..b8b1c76ffd6d 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,7 +6,6 @@ */ import React, { useCallback } from 'react'; -import uuid from 'uuid'; import { useParams } from 'react-router-dom'; import { @@ -14,8 +13,8 @@ import { ExportSelectedData, } from '../../../../common/components/generic_downloader'; import * as i18n from '../translations'; -import { useStateToaster } from '../../../../common/components/toasters'; import { TimelineType } from '../../../../../common/types/timeline'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; const ExportTimeline: React.FC<{ exportedIds: string[] | undefined; @@ -23,8 +22,8 @@ const ExportTimeline: React.FC<{ isEnableDownloader: boolean; onComplete?: () => void; }> = ({ onComplete, isEnableDownloader, exportedIds, getExportedData }) => { - const [, dispatchToaster] = useStateToaster(); const { tabName: timelineType } = useParams<{ tabName: TimelineType }>(); + const { addSuccess } = useAppToasts(); const onExportSuccess = useCallback( (exportCount) => { @@ -32,20 +31,15 @@ const ExportTimeline: React.FC<{ onComplete(); } - dispatchToaster({ - type: 'addToaster', - toast: { - id: uuid.v4(), - title: - timelineType === TimelineType.template - ? i18n.SUCCESSFULLY_EXPORTED_TIMELINE_TEMPLATES(exportCount) - : i18n.SUCCESSFULLY_EXPORTED_TIMELINES(exportCount), - color: 'success', - iconType: 'check', - }, + addSuccess({ + title: + timelineType === TimelineType.template + ? i18n.SUCCESSFULLY_EXPORTED_TIMELINE_TEMPLATES(exportCount) + : i18n.SUCCESSFULLY_EXPORTED_TIMELINES(exportCount), + 'data-test-subj': 'addObjectToContainerSuccess', }); }, - [dispatchToaster, onComplete, timelineType] + [addSuccess, onComplete, timelineType] ); const onExportFailure = useCallback(() => { if (onComplete != null) { 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 4858bf3ed608..40af4514e26a 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 @@ -293,6 +293,20 @@ export const SUCCESSFULLY_EXPORTED_TIMELINE_TEMPLATES = (totalTimelineTemplates: } ); +export const SUCCESSFULLY_DELETED_TIMELINES = (totalTimelines: number) => + i18n.translate('xpack.securitySolution.open.timeline.successfullyDeletedTimelinesTitle', { + values: { totalTimelines }, + defaultMessage: + 'Successfully deleted {totalTimelines, plural, =0 {all timelines} =1 {{totalTimelines} timeline} other {{totalTimelines} timelines}}', + }); + +export const SUCCESSFULLY_DELETED_TIMELINE_TEMPLATES = (totalTimelineTemplates: number) => + i18n.translate('xpack.securitySolution.open.timeline.successfullyDeletedTimelineTemplatesTitle', { + values: { totalTimelineTemplates }, + defaultMessage: + 'Successfully deleted {totalTimelineTemplates, plural, =0 {all timelines} =1 {{totalTimelineTemplates} timeline template} other {{totalTimelineTemplates} timeline templates}}', + }); + export const TAB_TIMELINES = i18n.translate( 'xpack.securitySolution.timelines.components.tabs.timelinesTitle', {