[Security Solution][Case] Manual attach alert to a case (#82996)

This commit is contained in:
Christos Nasikas 2020-12-09 01:17:16 +02:00 committed by GitHub
parent 4b4419a930
commit 11470ac23a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 538 additions and 417 deletions

View file

@ -77,7 +77,7 @@ describe('AddComment ', () => {
await waitFor(() => {
expect(onCommentSaving).toBeCalled();
expect(postComment).toBeCalledWith(sampleData, onCommentPosted);
expect(postComment).toBeCalledWith(addCommentProps.caseId, sampleData, onCommentPosted);
expect(wrapper.find(`[data-test-subj="add-comment"] textarea`).text()).toBe('');
});
});

View file

@ -43,7 +43,7 @@ interface AddCommentProps {
export const AddComment = React.memo(
forwardRef<AddCommentRefObject, AddCommentProps>(
({ caseId, disabled, showLoading = true, onCommentPosted, onCommentSaving }, ref) => {
const { isLoading, postComment } = usePostComment(caseId);
const { isLoading, postComment } = usePostComment();
const { form } = useForm<AddCommentFormSchema>({
defaultValue: initialCommentValue,
@ -79,10 +79,10 @@ export const AddComment = React.memo(
if (onCommentSaving != null) {
onCommentSaving();
}
postComment({ ...data, type: CommentType.user }, onCommentPosted);
postComment(caseId, { ...data, type: CommentType.user }, onCommentPosted);
reset();
}
}, [onCommentPosted, onCommentSaving, postComment, reset, submit]);
}, [onCommentPosted, onCommentSaving, postComment, reset, submit, caseId]);
return (
<span id="add-comment-permLink">

View file

@ -437,7 +437,44 @@ describe('AllCases', () => {
);
await waitFor(() => {
wrapper.find('[data-test-subj="cases-table-row-1"]').first().simulate('click');
expect(onRowClick).toHaveBeenCalledWith('1');
expect(onRowClick).toHaveBeenCalledWith({
closedAt: null,
closedBy: null,
comments: [],
connector: { fields: null, id: '123', name: 'My Connector', type: '.none' },
createdAt: '2020-02-19T23:06:33.798Z',
createdBy: {
email: 'leslie.knope@elastic.co',
fullName: 'Leslie Knope',
username: 'lknope',
},
description: 'Security banana Issue',
externalService: {
connectorId: '123',
connectorName: 'connector name',
externalId: 'external_id',
externalTitle: 'external title',
externalUrl: 'basicPush.com',
pushedAt: '2020-02-20T15:02:57.995Z',
pushedBy: {
email: 'leslie.knope@elastic.co',
fullName: 'Leslie Knope',
username: 'lknope',
},
},
id: '1',
status: 'open',
tags: ['coke', 'pepsi'],
title: 'Another horrible breach!!',
totalComment: 0,
updatedAt: '2020-02-20T15:02:57.995Z',
updatedBy: {
email: 'leslie.knope@elastic.co',
fullName: 'Leslie Knope',
username: 'lknope',
},
version: 'WzQ3LDFd',
});
});
});

View file

@ -82,7 +82,7 @@ const getSortField = (field: string): SortFieldCase => {
};
interface AllCasesProps {
onRowClick?: (id?: string) => void;
onRowClick?: (theCase?: Case) => void;
isModal?: boolean;
userCanCrud: boolean;
}
@ -339,32 +339,20 @@ export const AllCases = React.memo<AllCasesProps>(
const TableWrap = useMemo(() => (isModal ? 'span' : Panel), [isModal]);
const onTableRowClick = useMemo(
() =>
memoize<(id: string) => () => void>((id) => () => {
if (onRowClick) {
onRowClick(id);
}
}),
[onRowClick]
);
const tableRowProps = useCallback(
(item) => {
const rowProps = {
'data-test-subj': `cases-table-row-${item.id}`,
(theCase: Case) => {
const onTableRowClick = memoize(() => {
if (onRowClick) {
onRowClick(theCase);
}
});
return {
'data-test-subj': `cases-table-row-${theCase.id}`,
...(isModal ? { onClick: onTableRowClick } : {}),
};
if (isModal) {
return {
...rowProps,
onClick: onTableRowClick(item.id),
};
}
return rowProps;
},
[isModal, onTableRowClick]
[isModal, onRowClick]
);
return (

View file

@ -1,153 +0,0 @@
/*
* 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 { mount } from 'enzyme';
import React from 'react';
import { waitFor } from '@testing-library/react';
import '../../../common/mock/match_media';
import { AllCasesModal } from '.';
import { TestProviders } from '../../../common/mock';
import { useGetCasesMockState, basicCaseId } from '../../containers/mock';
import { useDeleteCases } from '../../containers/use_delete_cases';
import { useGetCases } from '../../containers/use_get_cases';
import { useGetCasesStatus } from '../../containers/use_get_cases_status';
import { useUpdateCases } from '../../containers/use_bulk_update_case';
import { EuiTableRow } from '@elastic/eui';
jest.mock('react-router-dom', () => {
const original = jest.requireActual('react-router-dom');
return {
...original,
useHistory: () => ({
useHistory: jest.fn(),
}),
};
});
jest.mock('../../../common/components/link_to');
jest.mock('../../containers/use_bulk_update_case');
jest.mock('../../containers/use_delete_cases');
jest.mock('../../containers/use_get_cases');
jest.mock('../../containers/use_get_cases_status');
const useDeleteCasesMock = useDeleteCases as jest.Mock;
const useGetCasesMock = useGetCases as jest.Mock;
const useGetCasesStatusMock = useGetCasesStatus as jest.Mock;
const useUpdateCasesMock = useUpdateCases as jest.Mock;
jest.mock('../../../common/lib/kibana', () => {
const originalModule = jest.requireActual('../../../common/lib/kibana');
return {
...originalModule,
useGetUserSavedObjectPermissions: jest.fn(),
};
});
const onCloseCaseModal = jest.fn();
const onRowClick = jest.fn();
const defaultProps = {
onCloseCaseModal,
onRowClick,
showCaseModal: true,
};
describe('AllCasesModal', () => {
const dispatchResetIsDeleted = jest.fn();
const dispatchResetIsUpdated = jest.fn();
const dispatchUpdateCaseProperty = jest.fn();
const handleOnDeleteConfirm = jest.fn();
const handleToggleModal = jest.fn();
const refetchCases = jest.fn();
const setFilters = jest.fn();
const setQueryParams = jest.fn();
const setSelectedCases = jest.fn();
const updateBulkStatus = jest.fn();
const fetchCasesStatus = jest.fn();
const defaultGetCases = {
...useGetCasesMockState,
dispatchUpdateCaseProperty,
refetchCases,
setFilters,
setQueryParams,
setSelectedCases,
};
const defaultDeleteCases = {
dispatchResetIsDeleted,
handleOnDeleteConfirm,
handleToggleModal,
isDeleted: false,
isDisplayConfirmDeleteModal: false,
isLoading: false,
};
const defaultCasesStatus = {
countClosedCases: 0,
countOpenCases: 5,
fetchCasesStatus,
isError: false,
isLoading: true,
};
const defaultUpdateCases = {
isUpdated: false,
isLoading: false,
isError: false,
dispatchResetIsUpdated,
updateBulkStatus,
};
beforeEach(() => {
jest.resetAllMocks();
useUpdateCasesMock.mockImplementation(() => defaultUpdateCases);
useGetCasesMock.mockImplementation(() => defaultGetCases);
useDeleteCasesMock.mockImplementation(() => defaultDeleteCases);
useGetCasesStatusMock.mockImplementation(() => defaultCasesStatus);
});
it('renders with unselectable rows', async () => {
const wrapper = mount(
<TestProviders>
<AllCasesModal {...defaultProps} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeTruthy();
expect(wrapper.find(EuiTableRow).first().prop('isSelectable')).toBeFalsy();
});
});
it('does not render modal if showCaseModal: false', async () => {
const wrapper = mount(
<TestProviders>
<AllCasesModal {...{ ...defaultProps, showCaseModal: false }} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeFalsy();
});
});
it('onRowClick called when row is clicked', async () => {
const wrapper = mount(
<TestProviders>
<AllCasesModal {...defaultProps} />
</TestProviders>
);
await waitFor(() => {
const firstRow = wrapper.find(EuiTableRow).first();
firstRow.simulate('click');
expect(onRowClick.mock.calls[0][0]).toEqual(basicCaseId);
});
});
it('Closing modal calls onCloseCaseModal', async () => {
const wrapper = mount(
<TestProviders>
<AllCasesModal {...defaultProps} />
</TestProviders>
);
await waitFor(() => {
const modalClose = wrapper.find('.euiModal__closeIcon').first();
modalClose.simulate('click');
expect(onCloseCaseModal).toBeCalled();
});
});
});

View file

@ -1,57 +0,0 @@
/*
* 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 {
EuiModal,
EuiModalBody,
EuiModalHeader,
EuiModalHeaderTitle,
EuiOverlayMask,
} from '@elastic/eui';
import { useGetUserSavedObjectPermissions } from '../../../common/lib/kibana';
import { AllCases } from '../all_cases';
import * as i18n from './translations';
interface AllCasesModalProps {
onCloseCaseModal: () => void;
showCaseModal: boolean;
onRowClick: (id?: string) => void;
}
export const AllCasesModalComponent = ({
onCloseCaseModal,
onRowClick,
showCaseModal,
}: AllCasesModalProps) => {
const userPermissions = useGetUserSavedObjectPermissions();
let modal;
if (showCaseModal) {
modal = (
<EuiOverlayMask data-test-subj="all-cases-modal">
<EuiModal onClose={onCloseCaseModal}>
<EuiModalHeader>
<EuiModalHeaderTitle>{i18n.SELECT_CASE_TITLE}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<AllCases
onRowClick={onRowClick}
userCanCrud={userPermissions?.crud ?? false}
isModal
/>
</EuiModalBody>
</EuiModal>
</EuiOverlayMask>
);
}
return <>{modal}</>;
};
export const AllCasesModal = React.memo(AllCasesModalComponent);
AllCasesModal.displayName = 'AllCasesModal';

View file

@ -1,10 +0,0 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const SELECT_CASE_TITLE = i18n.translate('xpack.securitySolution.case.caseModal.title', {
defaultMessage: 'Select case to attach timeline',
});

View file

@ -19,7 +19,7 @@ const ExistingCaseComponent: React.FC<ExistingCaseProps> = ({ onCaseChanged, sel
const onCaseCreated = useCallback(() => refetchCases(), [refetchCases]);
const { Modal: CreateCaseModal, openModal } = useCreateCaseModal({ onCaseCreated });
const { modal, openModal } = useCreateCaseModal({ onCaseCreated });
const onChange = useCallback(
(id: string) => {
@ -46,7 +46,7 @@ const ExistingCaseComponent: React.FC<ExistingCaseProps> = ({ onCaseChanged, sel
selectedCase={selectedCase ?? undefined}
onCaseChanged={onChange}
/>
<CreateCaseModal />
{modal}
</>
);
};

View file

@ -0,0 +1,156 @@
/*
* 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, useState, useCallback, useMemo } from 'react';
import {
EuiPopover,
EuiButtonIcon,
EuiContextMenuPanel,
EuiText,
EuiContextMenuItem,
EuiToolTip,
} from '@elastic/eui';
import { CommentType } from '../../../../../case/common/api';
import { Ecs } from '../../../../common/ecs';
import { ActionIconItem } from '../../../timelines/components/timeline/body/actions/action_icon_item';
import * as i18n from './translations';
import { usePostComment } from '../../containers/use_post_comment';
import { Case } from '../../containers/types';
import { displaySuccessToast, useStateToaster } from '../../../common/components/toasters';
import { useCreateCaseModal } from '../use_create_case_modal';
import { useAllCasesModal } from '../use_all_cases_modal';
interface AddToCaseActionProps {
ecsRowData: Ecs;
disabled: boolean;
}
const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({ ecsRowData, disabled }) => {
const eventId = ecsRowData._id;
const eventIndex = ecsRowData._index;
const [, dispatchToaster] = useStateToaster();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const openPopover = useCallback(() => setIsPopoverOpen(true), []);
const closePopover = useCallback(() => setIsPopoverOpen(false), []);
const { postComment } = usePostComment();
const attachAlertToCase = useCallback(
(theCase: Case) => {
postComment(
theCase.id,
{
type: CommentType.alert,
alertId: eventId,
index: eventIndex ?? '',
},
() => displaySuccessToast(i18n.CASE_CREATED_SUCCESS_TOAST(theCase.title), dispatchToaster)
);
},
[postComment, eventId, eventIndex, dispatchToaster]
);
const { modal: createCaseModal, openModal: openCreateCaseModal } = useCreateCaseModal({
onCaseCreated: attachAlertToCase,
});
const onCaseClicked = useCallback(
(theCase) => {
/**
* No cases listed on the table.
* The user pressed the add new case table's button.
* We gonna open the create case modal.
*/
if (theCase == null) {
openCreateCaseModal();
return;
}
attachAlertToCase(theCase);
},
[attachAlertToCase, openCreateCaseModal]
);
const { modal: allCasesModal, openModal: openAllCaseModal } = useAllCasesModal({
onRowClick: onCaseClicked,
});
const addNewCaseClick = useCallback(() => {
closePopover();
openCreateCaseModal();
}, [openCreateCaseModal, closePopover]);
const addExistingCaseClick = useCallback(() => {
closePopover();
openAllCaseModal();
}, [openAllCaseModal, closePopover]);
const items = useMemo(
() => [
<EuiContextMenuItem
key="add-new-case-menu-item"
onClick={addNewCaseClick}
aria-label={i18n.ACTION_ADD_NEW_CASE}
data-test-subj="add-new-case-item"
disabled={disabled}
>
<EuiText size="m">{i18n.ACTION_ADD_NEW_CASE}</EuiText>
</EuiContextMenuItem>,
<EuiContextMenuItem
key="add-existing-case-menu-item"
onClick={addExistingCaseClick}
aria-label={i18n.ACTION_ADD_EXISTING_CASE}
data-test-subj="add-existing-case-menu-item"
disabled={disabled}
>
<EuiText size="m">{i18n.ACTION_ADD_EXISTING_CASE}</EuiText>
</EuiContextMenuItem>,
],
[addExistingCaseClick, addNewCaseClick, disabled]
);
const button = useMemo(
() => (
<EuiToolTip
data-test-subj="attach-alert-to-case-tooltip"
content={i18n.ACTION_ADD_TO_CASE_TOOLTIP}
>
<EuiButtonIcon
aria-label={i18n.ACTION_ADD_TO_CASE_ARIA_LABEL}
data-test-subj="attach-alert-to-case-button"
size="s"
iconType="folderClosed"
onClick={openPopover}
disabled={disabled}
/>
</EuiToolTip>
),
[disabled, openPopover]
);
return (
<>
<ActionIconItem id="attachAlertToCase">
<EuiPopover
id="attachAlertToCasePanel"
button={button}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
anchorPosition="downLeft"
repositionOnScroll
>
<EuiContextMenuPanel items={items} />
</EuiPopover>
</ActionIconItem>
{createCaseModal}
{allCasesModal}
</>
);
};
export const AddToCaseAction = memo(AddToCaseActionComponent);

View file

@ -0,0 +1,48 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const ACTION_ADD_CASE = i18n.translate(
'xpack.securitySolution.case.timeline.actions.addCase',
{
defaultMessage: 'Add to case',
}
);
export const ACTION_ADD_NEW_CASE = i18n.translate(
'xpack.securitySolution.case.timeline.actions.addNewCase',
{
defaultMessage: 'Add to new case',
}
);
export const ACTION_ADD_EXISTING_CASE = i18n.translate(
'xpack.securitySolution.case.timeline.actions.addExistingCase',
{
defaultMessage: 'Add to existing case',
}
);
export const ACTION_ADD_TO_CASE_ARIA_LABEL = i18n.translate(
'xpack.securitySolution.case.timeline.actions.addToCaseAriaLabel',
{
defaultMessage: 'Attach alert to case',
}
);
export const ACTION_ADD_TO_CASE_TOOLTIP = i18n.translate(
'xpack.securitySolution.case.timeline.actions.addToCaseTooltip',
{
defaultMessage: 'Add to case',
}
);
export const CASE_CREATED_SUCCESS_TOAST = (title: string) =>
i18n.translate('xpack.securitySolution.case.timeline.actions.caseCreatedSuccessToast', {
values: { title },
defaultMessage: 'An alert has been added to "{title}"',
});

View file

@ -3,6 +3,8 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable react/display-name */
import { mount } from 'enzyme';
import React from 'react';
import '../../../common/mock/match_media';
@ -10,10 +12,19 @@ import { AllCasesModal } from './all_cases_modal';
import { TestProviders } from '../../../common/mock';
jest.mock('../all_cases', () => {
const AllCases = () => {
return <></>;
return {
AllCases: ({ onRowClick }: { onRowClick: ({ id }: { id: string }) => void }) => {
return (
<button
type="button"
data-test-subj="all-cases-row"
onClick={() => onRowClick({ id: 'case-id' })}
>
{'case-row'}
</button>
);
},
};
return { AllCases };
});
jest.mock('../../../common/lib/kibana', () => {
@ -27,6 +38,7 @@ jest.mock('../../../common/lib/kibana', () => {
const onCloseCaseModal = jest.fn();
const onRowClick = jest.fn();
const defaultProps = {
isModalOpen: true,
onCloseCaseModal,
onRowClick,
};
@ -46,6 +58,16 @@ describe('AllCasesModal', () => {
expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeTruthy();
});
it('it does not render the modal isModalOpen=false ', () => {
const wrapper = mount(
<TestProviders>
<AllCasesModal {...defaultProps} isModalOpen={false} />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeFalsy();
});
it('Closing modal calls onCloseCaseModal', () => {
const wrapper = mount(
<TestProviders>
@ -71,4 +93,15 @@ describe('AllCasesModal', () => {
isModal: true,
});
});
it('onRowClick called when row is clicked', () => {
const wrapper = mount(
<TestProviders>
<AllCasesModal {...defaultProps} />
</TestProviders>
);
wrapper.find(`[data-test-subj='all-cases-row']`).first().simulate('click');
expect(onRowClick).toHaveBeenCalledWith({ id: 'case-id' });
});
});

View file

@ -14,18 +14,25 @@ import {
} from '@elastic/eui';
import { useGetUserSavedObjectPermissions } from '../../../common/lib/kibana';
import { Case } from '../../containers/types';
import { AllCases } from '../all_cases';
import * as i18n from './translations';
export interface AllCasesModalProps {
isModalOpen: boolean;
onCloseCaseModal: () => void;
onRowClick: (id?: string) => void;
onRowClick: (theCase?: Case) => void;
}
const AllCasesModalComponent: React.FC<AllCasesModalProps> = ({ onCloseCaseModal, onRowClick }) => {
const AllCasesModalComponent: React.FC<AllCasesModalProps> = ({
isModalOpen,
onCloseCaseModal,
onRowClick,
}) => {
const userPermissions = useGetUserSavedObjectPermissions();
const userCanCrud = userPermissions?.crud ?? false;
return (
return isModalOpen ? (
<EuiOverlayMask data-test-subj="all-cases-modal">
<EuiModal onClose={onCloseCaseModal}>
<EuiModalHeader>
@ -36,7 +43,7 @@ const AllCasesModalComponent: React.FC<AllCasesModalProps> = ({ onCloseCaseModal
</EuiModalBody>
</EuiModal>
</EuiOverlayMask>
);
) : null;
};
export const AllCasesModal = memo(AllCasesModalComponent);

View file

@ -5,13 +5,13 @@
*/
/* eslint-disable react/display-name */
import React from 'react';
import { renderHook, act } from '@testing-library/react-hooks';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useKibana } from '../../../common/lib/kibana';
import '../../../common/mock/match_media';
import { TimelineId } from '../../../../common/types/timeline';
import { useAllCasesModal, UseAllCasesModalProps, UseAllCasesModalReturnedValues } from '.';
import { mockTimelineModel, TestProviders } from '../../../common/mock';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
@ -26,10 +26,22 @@ jest.mock('react-redux', () => {
});
jest.mock('../../../common/lib/kibana');
jest.mock('../all_cases', () => {
return {
AllCases: ({ onRowClick }: { onRowClick: ({ id }: { id: string }) => void }) => {
return (
<button type="button" onClick={() => onRowClick({ id: 'case-id' })}>
{'case-row'}
</button>
);
},
};
});
jest.mock('../../../common/hooks/use_selector');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const onRowClick = jest.fn();
describe('useAllCasesModal', () => {
let navigateToApp: jest.Mock;
@ -42,51 +54,51 @@ describe('useAllCasesModal', () => {
it('init', async () => {
const { result } = renderHook<UseAllCasesModalProps, UseAllCasesModalReturnedValues>(
() => useAllCasesModal({ timelineId: TimelineId.test }),
() => useAllCasesModal({ onRowClick }),
{
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
}
);
expect(result.current.showModal).toBe(false);
expect(result.current.isModalOpen).toBe(false);
});
it('opens the modal', async () => {
const { result } = renderHook<UseAllCasesModalProps, UseAllCasesModalReturnedValues>(
() => useAllCasesModal({ timelineId: TimelineId.test }),
() => useAllCasesModal({ onRowClick }),
{
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
}
);
act(() => {
result.current.onOpenModal();
result.current.openModal();
});
expect(result.current.showModal).toBe(true);
expect(result.current.isModalOpen).toBe(true);
});
it('closes the modal', async () => {
const { result } = renderHook<UseAllCasesModalProps, UseAllCasesModalReturnedValues>(
() => useAllCasesModal({ timelineId: TimelineId.test }),
() => useAllCasesModal({ onRowClick }),
{
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
}
);
act(() => {
result.current.onOpenModal();
result.current.onCloseModal();
result.current.openModal();
result.current.closeModal();
});
expect(result.current.showModal).toBe(false);
expect(result.current.isModalOpen).toBe(false);
});
it('returns a memoized value', async () => {
const { result, rerender } = renderHook<UseAllCasesModalProps, UseAllCasesModalReturnedValues>(
() => useAllCasesModal({ timelineId: TimelineId.test }),
() => useAllCasesModal({ onRowClick }),
{
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
}
);
@ -99,49 +111,24 @@ describe('useAllCasesModal', () => {
it('closes the modal when clicking a row', async () => {
const { result } = renderHook<UseAllCasesModalProps, UseAllCasesModalReturnedValues>(
() => useAllCasesModal({ timelineId: TimelineId.test }),
() => useAllCasesModal({ onRowClick }),
{
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
}
);
act(() => {
result.current.onOpenModal();
result.current.onRowClick();
result.current.openModal();
});
expect(result.current.showModal).toBe(false);
});
it('navigates to the correct path without id', async () => {
const { result } = renderHook<UseAllCasesModalProps, UseAllCasesModalReturnedValues>(
() => useAllCasesModal({ timelineId: TimelineId.test }),
{
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
}
);
const modal = result.current.modal;
render(<>{modal}</>);
act(() => {
result.current.onOpenModal();
result.current.onRowClick();
userEvent.click(screen.getByText('case-row'));
});
expect(navigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/create' });
});
it('navigates to the correct path with id', async () => {
const { result } = renderHook<UseAllCasesModalProps, UseAllCasesModalReturnedValues>(
() => useAllCasesModal({ timelineId: TimelineId.test }),
{
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
}
);
act(() => {
result.current.onOpenModal();
result.current.onRowClick('case-id');
});
expect(navigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/case-id' });
expect(result.current.isModalOpen).toBe(false);
expect(onRowClick).toHaveBeenCalledWith({ id: 'case-id' });
});
});

View file

@ -4,84 +4,50 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { pick } from 'lodash/fp';
import React, { useState, useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { APP_ID } from '../../../../common/constants';
import { SecurityPageName } from '../../../app/types';
import { useKibana } from '../../../common/lib/kibana';
import { getCaseDetailsUrl, getCreateCaseUrl } from '../../../common/components/link_to';
import { setInsertTimeline } from '../../../timelines/store/timeline/actions';
import { timelineSelectors } from '../../../timelines/store/timeline';
import { Case } from '../../containers/types';
import { AllCasesModal } from './all_cases_modal';
import { timelineDefaults } from '../../../timelines/store/timeline/defaults';
export interface UseAllCasesModalProps {
timelineId: string;
onRowClick: (theCase?: Case) => void;
}
export interface UseAllCasesModalReturnedValues {
Modal: React.FC;
showModal: boolean;
onCloseModal: () => void;
onOpenModal: () => void;
onRowClick: (id?: string) => void;
modal: JSX.Element;
isModalOpen: boolean;
closeModal: () => void;
openModal: () => void;
}
export const useAllCasesModal = ({
timelineId,
onRowClick,
}: UseAllCasesModalProps): UseAllCasesModalReturnedValues => {
const dispatch = useDispatch();
const { navigateToApp } = useKibana().services.application;
const { graphEventId, savedObjectId, title } = useDeepEqualSelector((state) =>
pick(
['graphEventId', 'savedObjectId', 'title'],
timelineSelectors.selectTimeline(state, timelineId) ?? timelineDefaults
)
);
const [showModal, setShowModal] = useState<boolean>(false);
const onCloseModal = useCallback(() => setShowModal(false), []);
const onOpenModal = useCallback(() => setShowModal(true), []);
const onRowClick = useCallback(
async (id?: string) => {
onCloseModal();
await navigateToApp(`${APP_ID}:${SecurityPageName.case}`, {
path: id != null ? getCaseDetailsUrl({ id }) : getCreateCaseUrl(),
});
dispatch(
setInsertTimeline({
graphEventId,
timelineId,
timelineSavedObjectId: savedObjectId,
timelineTitle: title,
})
);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const closeModal = useCallback(() => setIsModalOpen(false), []);
const openModal = useCallback(() => setIsModalOpen(true), []);
const onClick = useCallback(
(theCase?: Case) => {
closeModal();
onRowClick(theCase);
},
[onCloseModal, navigateToApp, dispatch, graphEventId, timelineId, savedObjectId, title]
);
const Modal: React.FC = useCallback(
() =>
showModal ? <AllCasesModal onCloseCaseModal={onCloseModal} onRowClick={onRowClick} /> : null,
[onCloseModal, onRowClick, showModal]
[closeModal, onRowClick]
);
const state = useMemo(
() => ({
Modal,
showModal,
onCloseModal,
onOpenModal,
modal: (
<AllCasesModal
isModalOpen={isModalOpen}
onCloseCaseModal={closeModal}
onRowClick={onClick}
/>
),
isModalOpen,
closeModal,
openModal,
onRowClick,
}),
[showModal, onCloseModal, onOpenModal, onRowClick, Modal]
[isModalOpen, closeModal, onClick, openModal, onRowClick]
);
return state;

View file

@ -6,5 +6,5 @@
import { i18n } from '@kbn/i18n';
export const SELECT_CASE_TITLE = i18n.translate('xpack.securitySolution.case.caseModal.title', {
defaultMessage: 'Select case to attach timeline',
defaultMessage: 'Select case',
});

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { memo, useCallback } from 'react';
import React, { memo } from 'react';
import styled from 'styled-components';
import {
EuiModal,
@ -21,8 +21,9 @@ import { Case } from '../../containers/types';
import * as i18n from '../../translations';
export interface CreateCaseModalProps {
isModalOpen: boolean;
onCloseCaseModal: () => void;
onCaseCreated: (theCase: Case) => void;
onSuccess: (theCase: Case) => void;
}
const Container = styled.div`
@ -33,18 +34,11 @@ const Container = styled.div`
`;
const CreateModalComponent: React.FC<CreateCaseModalProps> = ({
isModalOpen,
onCloseCaseModal,
onCaseCreated,
onSuccess,
}) => {
const onSuccess = useCallback(
(theCase) => {
onCaseCreated(theCase);
onCloseCaseModal();
},
[onCaseCreated, onCloseCaseModal]
);
return (
return isModalOpen ? (
<EuiOverlayMask data-test-subj="all-cases-modal">
<EuiModal onClose={onCloseCaseModal}>
<EuiModalHeader>
@ -60,7 +54,7 @@ const CreateModalComponent: React.FC<CreateCaseModalProps> = ({
</EuiModalBody>
</EuiModal>
</EuiOverlayMask>
);
) : null;
};
export const CreateCaseModal = memo(CreateModalComponent);

View file

@ -12,7 +12,7 @@ interface Props {
onCaseCreated: (theCase: Case) => void;
}
export interface UseAllCasesModalReturnedValues {
Modal: React.FC;
modal: JSX.Element;
isModalOpen: boolean;
closeModal: () => void;
openModal: () => void;
@ -22,23 +22,28 @@ export const useCreateCaseModal = ({ onCaseCreated }: Props) => {
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const closeModal = useCallback(() => setIsModalOpen(false), []);
const openModal = useCallback(() => setIsModalOpen(true), []);
const Modal: React.FC = useCallback(
() =>
isModalOpen ? (
<CreateCaseModal onCloseCaseModal={closeModal} onCaseCreated={onCaseCreated} />
) : null,
[closeModal, isModalOpen, onCaseCreated]
const onSuccess = useCallback(
(theCase) => {
onCaseCreated(theCase);
closeModal();
},
[onCaseCreated, closeModal]
);
const state = useMemo(
() => ({
Modal,
modal: (
<CreateCaseModal
isModalOpen={isModalOpen}
onCloseCaseModal={closeModal}
onSuccess={onSuccess}
/>
),
isModalOpen,
closeModal,
openModal,
}),
[isModalOpen, closeModal, openModal, Modal]
[isModalOpen, closeModal, onSuccess, openModal]
);
return state;

View file

@ -11,7 +11,7 @@ import {
CasePatchRequest,
CasePostRequest,
CasesStatusResponse,
CommentRequestUserType,
CommentRequest,
User,
CaseUserActionsResponse,
CaseExternalServiceRequest,
@ -183,7 +183,7 @@ export const patchCasesStatus = async (
};
export const postComment = async (
newComment: CommentRequestUserType,
newComment: CommentRequest,
caseId: string,
signal: AbortSignal
): Promise<Case> => {

View file

@ -28,7 +28,7 @@ describe('usePostComment', () => {
it('init', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UsePostComment>(() =>
usePostComment(basicCaseId)
usePostComment()
);
await waitForNextUpdate();
expect(result.current).toEqual({
@ -44,11 +44,11 @@ describe('usePostComment', () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UsePostComment>(() =>
usePostComment(basicCaseId)
usePostComment()
);
await waitForNextUpdate();
result.current.postComment(samplePost, updateCaseCallback);
result.current.postComment(basicCaseId, samplePost, updateCaseCallback);
await waitForNextUpdate();
expect(spyOnPostCase).toBeCalledWith(samplePost, basicCaseId, abortCtrl.signal);
});
@ -57,10 +57,10 @@ describe('usePostComment', () => {
it('post case', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UsePostComment>(() =>
usePostComment(basicCaseId)
usePostComment()
);
await waitForNextUpdate();
result.current.postComment(samplePost, updateCaseCallback);
result.current.postComment(basicCaseId, samplePost, updateCaseCallback);
await waitForNextUpdate();
expect(result.current).toEqual({
isLoading: false,
@ -73,10 +73,10 @@ describe('usePostComment', () => {
it('set isLoading to true when posting case', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UsePostComment>(() =>
usePostComment(basicCaseId)
usePostComment()
);
await waitForNextUpdate();
result.current.postComment(samplePost, updateCaseCallback);
result.current.postComment(basicCaseId, samplePost, updateCaseCallback);
expect(result.current.isLoading).toBe(true);
});
@ -90,10 +90,10 @@ describe('usePostComment', () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UsePostComment>(() =>
usePostComment(basicCaseId)
usePostComment()
);
await waitForNextUpdate();
result.current.postComment(samplePost, updateCaseCallback);
result.current.postComment(basicCaseId, samplePost, updateCaseCallback);
expect(result.current).toEqual({
isLoading: false,

View file

@ -6,7 +6,7 @@
import { useReducer, useCallback } from 'react';
import { CommentRequestUserType } from '../../../../case/common/api';
import { CommentRequest } from '../../../../case/common/api';
import { errorToToaster, useStateToaster } from '../../common/components/toasters';
import { postComment } from './api';
@ -42,10 +42,10 @@ const dataFetchReducer = (state: NewCommentState, action: Action): NewCommentSta
};
export interface UsePostComment extends NewCommentState {
postComment: (data: CommentRequestUserType, updateCase: (newCase: Case) => void) => void;
postComment: (caseId: string, data: CommentRequest, updateCase?: (newCase: Case) => void) => void;
}
export const usePostComment = (caseId: string): UsePostComment => {
export const usePostComment = (): UsePostComment => {
const [state, dispatch] = useReducer(dataFetchReducer, {
isLoading: false,
isError: false,
@ -53,7 +53,7 @@ export const usePostComment = (caseId: string): UsePostComment => {
const [, dispatchToaster] = useStateToaster();
const postMyComment = useCallback(
async (data: CommentRequestUserType, updateCase: (newCase: Case) => void) => {
async (caseId: string, data: CommentRequest, updateCase?: (newCase: Case) => void) => {
let cancel = false;
const abortCtrl = new AbortController();
@ -62,7 +62,9 @@ export const usePostComment = (caseId: string): UsePostComment => {
const response = await postComment(data, caseId, abortCtrl.signal);
if (!cancel) {
dispatch({ type: 'FETCH_SUCCESS' });
updateCase(response);
if (updateCase) {
updateCase(response);
}
}
} catch (error) {
if (!cancel) {
@ -79,8 +81,7 @@ export const usePostComment = (caseId: string): UsePostComment => {
cancel = true;
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[caseId]
[dispatchToaster]
);
return { ...state, postComment: postMyComment };

View file

@ -0,0 +1,81 @@
/*
* 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 { useKibana } from '../../../../common/lib/kibana';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { mockTimelineModel, TestProviders } from '../../../../common/mock';
import { useAllCasesModal } from '../../../../cases/components/use_all_cases_modal';
import { AddToCaseButton } from '.';
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});
jest.mock('../../../../common/lib/kibana');
jest.mock('../../../../common/hooks/use_selector');
jest.mock('../../../../cases/components/use_all_cases_modal');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const useAllCasesModalMock = useAllCasesModal as jest.Mock;
describe('EventColumnView', () => {
const navigateToApp = jest.fn();
beforeEach(() => {
useKibanaMock().services.application.navigateToApp = navigateToApp;
(useDeepEqualSelector as jest.Mock).mockReturnValue(mockTimelineModel);
});
it('navigates to the correct path without id', async () => {
useAllCasesModalMock.mockImplementation(({ onRowClick }) => {
onRowClick();
return {
modal: <>{'test'}</>,
openModal: jest.fn(),
isModalOpen: true,
closeModal: jest.fn(),
};
});
mount(
<TestProviders>
<AddToCaseButton timelineId={'timeline-1'} />
</TestProviders>
);
expect(navigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/create' });
});
it('navigates to the correct path with id', async () => {
useAllCasesModalMock.mockImplementation(({ onRowClick }) => {
onRowClick({ id: 'case-id' });
return {
modal: <>{'test'}</>,
openModal: jest.fn(),
isModalOpen: true,
closeModal: jest.fn(),
};
});
mount(
<TestProviders>
<AddToCaseButton timelineId={'timeline-1'} />
</TestProviders>
);
expect(navigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/case-id' });
});
});

View file

@ -16,9 +16,10 @@ import { setInsertTimeline, showTimeline } from '../../../store/timeline/actions
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { useKibana } from '../../../../common/lib/kibana';
import { TimelineStatus, TimelineId, TimelineType } from '../../../../../common/types/timeline';
import { getCreateCaseUrl } from '../../../../common/components/link_to';
import { getCreateCaseUrl, getCaseDetailsUrl } from '../../../../common/components/link_to';
import { SecurityPageName } from '../../../../app/types';
import { timelineDefaults } from '../../../../timelines/store/timeline/defaults';
import { Case } from '../../../../cases/containers/types';
import * as i18n from '../../timeline/properties/translations';
interface Props {
@ -42,7 +43,26 @@ const AddToCaseButtonComponent: React.FC<Props> = ({ timelineId }) => {
)
);
const [isPopoverOpen, setPopover] = useState(false);
const { Modal: AllCasesModal, onOpenModal: onOpenCaseModal } = useAllCasesModal({ timelineId });
const onRowClick = useCallback(
async (theCase?: Case) => {
await navigateToApp(`${APP_ID}:${SecurityPageName.case}`, {
path: theCase != null ? getCaseDetailsUrl({ id: theCase.id }) : getCreateCaseUrl(),
});
dispatch(
setInsertTimeline({
graphEventId,
timelineId,
timelineSavedObjectId: savedObjectId,
timelineTitle,
})
);
},
[dispatch, graphEventId, navigateToApp, savedObjectId, timelineId, timelineTitle]
);
const { modal: allCasesModal, openModal: openCaseModal } = useAllCasesModal({ onRowClick });
const handleButtonClick = useCallback(() => {
setPopover((currentIsOpen) => !currentIsOpen);
@ -79,8 +99,8 @@ const AddToCaseButtonComponent: React.FC<Props> = ({ timelineId }) => {
const handleExistingCaseClick = useCallback(() => {
handlePopoverClose();
onOpenCaseModal();
}, [onOpenCaseModal, handlePopoverClose]);
openCaseModal();
}, [openCaseModal, handlePopoverClose]);
const closePopover = useCallback(() => {
setPopover(false);
@ -135,7 +155,7 @@ const AddToCaseButtonComponent: React.FC<Props> = ({ timelineId }) => {
>
<EuiContextMenuPanel items={items} />
</EuiPopover>
<AllCasesModal />
{allCasesModal}
</>
);
};

View file

@ -28,9 +28,9 @@ import { isFullScreen } from '../timeline/body/column_headers';
import { updateTimelineGraphEventId } from '../../../timelines/store/timeline/actions';
import { Resolver } from '../../../resolver/view';
import * as i18n from './translations';
import { useUiSetting$ } from '../../../common/lib/kibana';
import { useSignalIndex } from '../../../detections/containers/detection_engine/alerts/use_signal_index';
import * as i18n from './translations';
const OverlayContainer = styled.div`
${({ $restrictWidth }: { $restrictWidth: boolean }) =>

View file

@ -51,7 +51,7 @@ describe('EventColumnView', () => {
selectedEventIds: {},
showCheckboxes: false,
showNotes: false,
timelineId: 'timeline-1',
timelineId: 'timeline-test',
toggleShowNotes: jest.fn(),
updateNote: jest.fn(),
isEventPinned: false,

View file

@ -29,6 +29,7 @@ import { AddEventNoteAction } from '../actions/add_note_icon_item';
import { PinEventAction } from '../actions/pin_event_action';
import { inputsModel } from '../../../../../common/store';
import { TimelineId } from '../../../../../../common/types/timeline';
import { AddToCaseAction } from '../../../../../cases/components/timeline_actions/add_to_case_action';
interface Props {
id: string;
@ -138,6 +139,19 @@ export const EventColumnView = React.memo<Props>(
/>,
]
: []),
...([
TimelineId.detectionsPage,
TimelineId.detectionsRulesDetailsPage,
TimelineId.active,
].includes(timelineId as TimelineId)
? [
<AddToCaseAction
key="attach-to-case"
ecsRowData={ecsData}
disabled={eventType !== 'signal'}
/>,
]
: []),
<AlertContextMenu
key="alert-context-menu"
ecsRowData={ecsData}

View file

@ -38,6 +38,8 @@ interface OwnProps {
onRuleChange?: () => void;
}
const NUM_OF_ICON_IN_TIMELINE_ROW = 2;
export const hasAdditionalActions = (id: TimelineId): boolean =>
[TimelineId.detectionsPage, TimelineId.detectionsRulesDetailsPage, TimelineId.active].includes(
id
@ -127,7 +129,9 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
getActionsColumnWidth(
isEventViewer,
showCheckboxes,
hasAdditionalActions(id as TimelineId) ? DEFAULT_ICON_BUTTON_WIDTH + EXTRA_WIDTH : 0
hasAdditionalActions(id as TimelineId)
? DEFAULT_ICON_BUTTON_WIDTH * NUM_OF_ICON_IN_TIMELINE_ROW + EXTRA_WIDTH
: 0
),
[isEventViewer, showCheckboxes, id]
);