diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx index 0c156e247a5e..71d7387d8d39 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx @@ -6,18 +6,24 @@ /* eslint-disable react/display-name */ import React, { ReactNode } from 'react'; - import { mount } from 'enzyme'; +import { EuiGlobalToastList } from '@elastic/eui'; + +import { useKibana } from '../../../common/lib/kibana'; +import { useStateToaster } from '../../../common/components/toasters'; import { TestProviders } from '../../../common/mock'; import { usePostComment } from '../../containers/use_post_comment'; +import { Case } from '../../containers/types'; import { AddToCaseAction } from './add_to_case_action'; jest.mock('../../containers/use_post_comment'); -jest.mock('../../../common/lib/kibana', () => { - const originalModule = jest.requireActual('../../../common/lib/kibana'); +jest.mock('../../../common/lib/kibana'); + +jest.mock('../../../common/components/toasters', () => { + const actual = jest.requireActual('../../../common/components/toasters'); return { - ...originalModule, - useGetUserSavedObjectPermissions: jest.fn(), + ...actual, + useStateToaster: jest.fn(), }; }); @@ -44,14 +50,16 @@ jest.mock('../create/form_context', () => { onSuccess, }: { children: ReactNode; - onSuccess: ({ id }: { id: string }) => void; + onSuccess: (theCase: Partial) => void; }) => { return ( <> @@ -95,9 +103,16 @@ describe('AddToCaseAction', () => { disabled: false, }; + const mockDispatchToaster = jest.fn(); + const mockNavigateToApp = jest.fn(); + beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); usePostCommentMock.mockImplementation(() => defaultPostComment); + (useStateToaster as jest.Mock).mockReturnValue([jest.fn(), mockDispatchToaster]); + (useKibana as jest.Mock).mockReturnValue({ + services: { application: { navigateToApp: mockNavigateToApp } }, + }); }); it('it renders', async () => { @@ -187,4 +202,37 @@ describe('AddToCaseAction', () => { type: 'alert', }); }); + + it('navigates to case view', async () => { + usePostCommentMock.mockImplementation(() => { + return { + ...defaultPostComment, + postComment: jest.fn().mockImplementation((caseId, data, updateCase) => updateCase()), + }; + }); + + const wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="add-new-case-item"]`).first().simulate('click'); + wrapper.find(`[data-test-subj="form-context-on-success"]`).first().simulate('click'); + + expect(mockDispatchToaster).toHaveBeenCalled(); + const toast = mockDispatchToaster.mock.calls[0][0].toast; + + const toastWrapper = mount( + {}} /> + ); + + toastWrapper + .find('[data-test-subj="toaster-content-case-view-link"]') + .first() + .simulate('click'); + + expect(mockNavigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/new-case' }); + }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx index 3ebc0654fc01..eed4f2092fd5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx @@ -20,6 +20,10 @@ import { ActionIconItem } from '../../../timelines/components/timeline/body/acti import { usePostComment } from '../../containers/use_post_comment'; import { Case } from '../../containers/types'; import { useStateToaster } from '../../../common/components/toasters'; +import { APP_ID } from '../../../../common/constants'; +import { useKibana } from '../../../common/lib/kibana'; +import { getCaseDetailsUrl } from '../../../common/components/link_to'; +import { SecurityPageName } from '../../../app/types'; import { useCreateCaseModal } from '../use_create_case_modal'; import { useAllCasesModal } from '../use_all_cases_modal'; import { createUpdateSuccessToaster } from './helpers'; @@ -39,12 +43,23 @@ const AddToCaseActionComponent: React.FC = ({ const eventId = ecsRowData._id; const eventIndex = ecsRowData._index; + const { navigateToApp } = useKibana().services.application; const [, dispatchToaster] = useStateToaster(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const openPopover = useCallback(() => setIsPopoverOpen(true), []); const closePopover = useCallback(() => setIsPopoverOpen(false), []); const { postComment } = usePostComment(); + + const onViewCaseClick = useCallback( + (id) => { + navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { + path: getCaseDetailsUrl({ id }), + }); + }, + [navigateToApp] + ); + const attachAlertToCase = useCallback( (theCase: Case) => { postComment( @@ -54,10 +69,14 @@ const AddToCaseActionComponent: React.FC = ({ alertId: eventId, index: eventIndex ?? '', }, - () => dispatchToaster({ type: 'addToaster', toast: createUpdateSuccessToaster(theCase) }) + () => + dispatchToaster({ + type: 'addToaster', + toast: createUpdateSuccessToaster(theCase, onViewCaseClick), + }) ); }, - [postComment, eventId, eventIndex, dispatchToaster] + [postComment, eventId, eventIndex, dispatchToaster, onViewCaseClick] ); const { modal: createCaseModal, openModal: openCreateCaseModal } = useCreateCaseModal({ diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.ts b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.tsx similarity index 55% rename from x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.ts rename to x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.tsx index 58c9c4baf82e..b05dc4134cb1 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.tsx @@ -8,6 +8,7 @@ import { createUpdateSuccessToaster } from './helpers'; import { Case } from '../../containers/types'; const theCase = { + id: 'case-id', title: 'My case', settings: { syncAlerts: true, @@ -15,24 +16,13 @@ const theCase = { } as Case; describe('helpers', () => { + const onViewCaseClick = jest.fn(); + describe('createUpdateSuccessToaster', () => { it('creates the correct toast when the sync alerts is on', () => { - // We remove the id as is randomly generated - const { id, ...toast } = createUpdateSuccessToaster(theCase); - expect(toast).toEqual({ - color: 'success', - iconType: 'check', - text: 'Alerts in this case have their status synched with the case status', - title: 'An alert has been added to "My case"', - }); - }); - - it('creates the correct toast when the sync alerts is off', () => { - // We remove the id as is randomly generated - const { id, ...toast } = createUpdateSuccessToaster({ - ...theCase, - settings: { syncAlerts: false }, - }); + // We remove the id as is randomly generated and the text as it is a React component + // which is being test on toaster_content.test.tsx + const { id, text, ...toast } = createUpdateSuccessToaster(theCase, onViewCaseClick); expect(toast).toEqual({ color: 'success', iconType: 'check', diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.ts b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.tsx similarity index 59% rename from x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.ts rename to x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.tsx index abafa55c2890..b1bae8df0a0b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.ts +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.tsx @@ -4,22 +4,28 @@ * you may not use this file except in compliance with the Elastic License. */ +import React from 'react'; import uuid from 'uuid'; import { AppToast } from '../../../common/components/toasters'; import { Case } from '../../containers/types'; +import { ToasterContent } from './toaster_content'; import * as i18n from './translations'; -export const createUpdateSuccessToaster = (theCase: Case): AppToast => { - const toast: AppToast = { +export const createUpdateSuccessToaster = ( + theCase: Case, + onViewCaseClick: (id: string) => void +): AppToast => { + return { id: uuid.v4(), color: 'success', iconType: 'check', title: i18n.CASE_CREATED_SUCCESS_TOAST(theCase.title), + text: ( + + ), }; - - if (theCase.settings.syncAlerts) { - return { ...toast, text: i18n.CASE_CREATED_SUCCESS_TOAST_TEXT }; - } - - return toast; }; diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/toaster_content.test.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/toaster_content.test.tsx new file mode 100644 index 000000000000..b04ebb8903ae --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/toaster_content.test.tsx @@ -0,0 +1,45 @@ +/* + * 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 } from 'enzyme'; + +import { ToasterContent } from './toaster_content'; + +describe('ToasterContent', () => { + const onViewCaseClick = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders with syncAlerts=true', () => { + const wrapper = mount( + + ); + + expect(wrapper.find('[data-test-subj="toaster-content-case-view-link"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="toaster-content-sync-text"]').exists()).toBeTruthy(); + }); + + it('renders with syncAlerts=false', () => { + const wrapper = mount( + + ); + + expect(wrapper.find('[data-test-subj="toaster-content-case-view-link"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="toaster-content-sync-text"]').exists()).toBeFalsy(); + }); + + it('calls onViewCaseClick', () => { + const wrapper = mount( + + ); + + wrapper.find('[data-test-subj="toaster-content-case-view-link"]').first().simulate('click'); + expect(onViewCaseClick).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/toaster_content.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/toaster_content.tsx new file mode 100644 index 000000000000..871db464d857 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/toaster_content.tsx @@ -0,0 +1,46 @@ +/* + * 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, { memo, useCallback } from 'react'; +import { EuiButtonEmpty, EuiText } from '@elastic/eui'; +import styled from 'styled-components'; + +import * as i18n from './translations'; + +const EuiTextStyled = styled(EuiText)` + ${({ theme }) => ` + margin-bottom: ${theme.eui?.paddingSizes?.s ?? 8}px; + `} +`; + +interface Props { + caseId: string; + syncAlerts: boolean; + onViewCaseClick: (id: string) => void; +} + +const ToasterContentComponent: React.FC = ({ caseId, syncAlerts, onViewCaseClick }) => { + const onClick = useCallback(() => onViewCaseClick(caseId), [caseId, onViewCaseClick]); + return ( + <> + {syncAlerts && ( + + {i18n.CASE_CREATED_SUCCESS_TOAST_TEXT} + + )} + + {i18n.VIEW_CASE} + + + ); +}; + +export const ToasterContent = memo(ToasterContentComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/translations.ts b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/translations.ts index 479323ed1301..dd3d6f0b50f1 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/translations.ts @@ -53,3 +53,10 @@ export const CASE_CREATED_SUCCESS_TOAST_TEXT = i18n.translate( defaultMessage: 'Alerts in this case have their status synched with the case status', } ); + +export const VIEW_CASE = i18n.translate( + 'xpack.securitySolution.case.timeline.actions.caseCreatedSuccessToastViewCaseLink', + { + defaultMessage: 'View Case', + } +);