[Security Solution][Case] Add button to go to case view after adding an alert to a case (#89214)

This commit is contained in:
Christos Nasikas 2021-01-26 21:56:31 +02:00 committed by GitHub
parent 7bb8d3a7b2
commit 0c2c451830
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 195 additions and 34 deletions

View file

@ -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<Case>) => void;
}) => {
return (
<>
<button
type="button"
data-test-subj="form-context-on-success"
onClick={() => onSuccess({ id: 'new-case' })}
onClick={() =>
onSuccess({ id: 'new-case', title: 'the new case', settings: { syncAlerts: true } })
}
>
{'submit'}
</button>
@ -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(
<TestProviders>
<AddToCaseAction {...props} />
</TestProviders>
);
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(
<EuiGlobalToastList toasts={[toast]} toastLifeTimeMs={6000} dismissToast={() => {}} />
);
toastWrapper
.find('[data-test-subj="toaster-content-case-view-link"]')
.first()
.simulate('click');
expect(mockNavigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/new-case' });
});
});

View file

@ -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<AddToCaseActionProps> = ({
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<AddToCaseActionProps> = ({
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({

View file

@ -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',

View file

@ -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: (
<ToasterContent
caseId={theCase.id}
syncAlerts={theCase.settings.syncAlerts}
onViewCaseClick={onViewCaseClick}
/>
),
};
if (theCase.settings.syncAlerts) {
return { ...toast, text: i18n.CASE_CREATED_SUCCESS_TOAST_TEXT };
}
return toast;
};

View file

@ -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(
<ToasterContent caseId="case-id" syncAlerts={true} onViewCaseClick={onViewCaseClick} />
);
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(
<ToasterContent caseId="case-id" syncAlerts={false} onViewCaseClick={onViewCaseClick} />
);
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(
<ToasterContent caseId="case-id" syncAlerts={false} onViewCaseClick={onViewCaseClick} />
);
wrapper.find('[data-test-subj="toaster-content-case-view-link"]').first().simulate('click');
expect(onViewCaseClick).toHaveBeenCalled();
});
});

View file

@ -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<Props> = ({ caseId, syncAlerts, onViewCaseClick }) => {
const onClick = useCallback(() => onViewCaseClick(caseId), [caseId, onViewCaseClick]);
return (
<>
{syncAlerts && (
<EuiTextStyled size="s" data-test-subj="toaster-content-sync-text">
{i18n.CASE_CREATED_SUCCESS_TOAST_TEXT}
</EuiTextStyled>
)}
<EuiButtonEmpty
size="xs"
flush="left"
onClick={onClick}
data-test-subj="toaster-content-case-view-link"
>
{i18n.VIEW_CASE}
</EuiButtonEmpty>
</>
);
};
export const ToasterContent = memo(ToasterContentComponent);

View file

@ -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',
}
);