From 11470ac23a801c6a2bd467435b41be07335316e8 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 9 Dec 2020 01:17:16 +0200 Subject: [PATCH] [Security Solution][Case] Manual attach alert to a case (#82996) --- .../components/add_comment/index.test.tsx | 2 +- .../cases/components/add_comment/index.tsx | 6 +- .../cases/components/all_cases/index.test.tsx | 39 ++++- .../cases/components/all_cases/index.tsx | 32 ++-- .../components/all_cases_modal/index.test.tsx | 153 ----------------- .../components/all_cases_modal/index.tsx | 57 ------- .../all_cases_modal/translations.ts | 10 -- .../connectors/case/existing_case.tsx | 4 +- .../timeline_actions/add_to_case_action.tsx | 156 ++++++++++++++++++ .../timeline_actions/translations.ts | 48 ++++++ .../all_cases_modal.test.tsx | 39 ++++- .../use_all_cases_modal/all_cases_modal.tsx | 15 +- .../use_all_cases_modal/index.test.tsx | 85 ++++------ .../components/use_all_cases_modal/index.tsx | 86 +++------- .../use_all_cases_modal/translations.ts | 2 +- .../create_case_modal.tsx | 20 +-- .../use_create_case_modal/index.tsx | 25 +-- .../public/cases/containers/api.ts | 4 +- .../containers/use_post_comment.test.tsx | 18 +- .../cases/containers/use_post_comment.tsx | 15 +- .../flyout/add_to_case_button/index.test.tsx | 81 +++++++++ .../flyout/add_to_case_button/index.tsx | 30 +++- .../components/graph_overlay/index.tsx | 2 +- .../body/events/event_column_view.test.tsx | 2 +- .../body/events/event_column_view.tsx | 14 ++ .../components/timeline/body/index.tsx | 6 +- 26 files changed, 536 insertions(+), 415 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/cases/components/all_cases_modal/translations.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/timeline_actions/translations.ts create mode 100644 x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.test.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx index 50e139bcd215f..e656c98d36573 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx @@ -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(''); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx index daa7c24858b94..495cec6972872 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx @@ -43,7 +43,7 @@ interface AddCommentProps { export const AddComment = React.memo( forwardRef( ({ caseId, disabled, showLoading = true, onCommentPosted, onCommentSaving }, ref) => { - const { isLoading, postComment } = usePostComment(caseId); + const { isLoading, postComment } = usePostComment(); const { form } = useForm({ 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 ( diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index 9ea39f5ca99b9..755dde9341dca 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -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', + }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx index 05bc6d10d22a5..b8b969a3536c2 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx @@ -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( const TableWrap = useMemo(() => (isModal ? 'span' : Panel), [isModal]); - const onTableRowClick = useMemo( - () => - memoize<(id: string) => () => void>((id) => () => { + const tableRowProps = useCallback( + (theCase: Case) => { + const onTableRowClick = memoize(() => { if (onRowClick) { - onRowClick(id); + onRowClick(theCase); } - }), - [onRowClick] - ); + }); - const tableRowProps = useCallback( - (item) => { - const rowProps = { - 'data-test-subj': `cases-table-row-${item.id}`, + 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 ( diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.test.tsx deleted file mode 100644 index 725759068a3ea..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.test.tsx +++ /dev/null @@ -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( - - - - ); - 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( - - - - ); - await waitFor(() => { - expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeFalsy(); - }); - }); - it('onRowClick called when row is clicked', async () => { - const wrapper = mount( - - - - ); - 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( - - - - ); - await waitFor(() => { - const modalClose = wrapper.find('.euiModal__closeIcon').first(); - modalClose.simulate('click'); - expect(onCloseCaseModal).toBeCalled(); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.tsx deleted file mode 100644 index efbe3e667c27b..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.tsx +++ /dev/null @@ -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 = ( - - - - {i18n.SELECT_CASE_TITLE} - - - - - - - ); - } - - return <>{modal}; -}; - -export const AllCasesModal = React.memo(AllCasesModalComponent); - -AllCasesModal.displayName = 'AllCasesModal'; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/translations.ts b/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/translations.ts deleted file mode 100644 index e0f84d8541424..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/translations.ts +++ /dev/null @@ -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', -}); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/case/existing_case.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/case/existing_case.tsx index 28e051a713bf4..284a24220948b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/case/existing_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/case/existing_case.tsx @@ -19,7 +19,7 @@ const ExistingCaseComponent: React.FC = ({ 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 = ({ onCaseChanged, sel selectedCase={selectedCase ?? undefined} onCaseChanged={onChange} /> - + {modal} ); }; 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 new file mode 100644 index 0000000000000..7466d34a9938f --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx @@ -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 = ({ 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( + () => [ + + {i18n.ACTION_ADD_NEW_CASE} + , + + {i18n.ACTION_ADD_EXISTING_CASE} + , + ], + [addExistingCaseClick, addNewCaseClick, disabled] + ); + + const button = useMemo( + () => ( + + + + ), + [disabled, openPopover] + ); + + return ( + <> + + + + + + {createCaseModal} + {allCasesModal} + + ); +}; + +export const AddToCaseAction = memo(AddToCaseActionComponent); 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 new file mode 100644 index 0000000000000..0ec6a5c89e65a --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/translations.ts @@ -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}"', + }); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.test.tsx index 6039fec2464cc..ea83cbb8065c5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.test.tsx @@ -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 ( + + ); + }, }; - 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( + + + + ); + + expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeFalsy(); + }); + it('Closing modal calls onCloseCaseModal', () => { const wrapper = mount( @@ -71,4 +93,15 @@ describe('AllCasesModal', () => { isModal: true, }); }); + + it('onRowClick called when row is clicked', () => { + const wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj='all-cases-row']`).first().simulate('click'); + expect(onRowClick).toHaveBeenCalledWith({ id: 'case-id' }); + }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx index b5885b330a822..9f59c73682cfe 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx @@ -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 = ({ onCloseCaseModal, onRowClick }) => { +const AllCasesModalComponent: React.FC = ({ + isModalOpen, + onCloseCaseModal, + onRowClick, +}) => { const userPermissions = useGetUserSavedObjectPermissions(); const userCanCrud = userPermissions?.crud ?? false; - return ( + + return isModalOpen ? ( @@ -36,7 +43,7 @@ const AllCasesModalComponent: React.FC = ({ onCloseCaseModal - ); + ) : null; }; export const AllCasesModal = memo(AllCasesModalComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx index 9b5a464bc2273..af7c253258032 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx @@ -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 ( + + ); + }, + }; +}); jest.mock('../../../common/hooks/use_selector'); const useKibanaMock = useKibana as jest.Mocked; +const onRowClick = jest.fn(); describe('useAllCasesModal', () => { let navigateToApp: jest.Mock; @@ -42,51 +54,51 @@ describe('useAllCasesModal', () => { it('init', async () => { const { result } = renderHook( - () => useAllCasesModal({ timelineId: TimelineId.test }), + () => useAllCasesModal({ onRowClick }), { - wrapper: ({ children }) => {children}, + wrapper: ({ children }) => {children}, } ); - expect(result.current.showModal).toBe(false); + expect(result.current.isModalOpen).toBe(false); }); it('opens the modal', async () => { const { result } = renderHook( - () => useAllCasesModal({ timelineId: TimelineId.test }), + () => useAllCasesModal({ onRowClick }), { - wrapper: ({ children }) => {children}, + wrapper: ({ children }) => {children}, } ); 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( - () => useAllCasesModal({ timelineId: TimelineId.test }), + () => useAllCasesModal({ onRowClick }), { - wrapper: ({ children }) => {children}, + wrapper: ({ children }) => {children}, } ); 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( - () => useAllCasesModal({ timelineId: TimelineId.test }), + () => useAllCasesModal({ onRowClick }), { - wrapper: ({ children }) => {children}, + wrapper: ({ children }) => {children}, } ); @@ -99,49 +111,24 @@ describe('useAllCasesModal', () => { it('closes the modal when clicking a row', async () => { const { result } = renderHook( - () => useAllCasesModal({ timelineId: TimelineId.test }), - { - wrapper: ({ children }) => {children}, - } - ); - - act(() => { - result.current.onOpenModal(); - result.current.onRowClick(); - }); - - expect(result.current.showModal).toBe(false); - }); - - it('navigates to the correct path without id', async () => { - const { result } = renderHook( - () => useAllCasesModal({ timelineId: TimelineId.test }), + () => useAllCasesModal({ onRowClick }), { - wrapper: ({ children }) => {children}, + wrapper: ({ children }) => {children}, } ); act(() => { - result.current.onOpenModal(); - result.current.onRowClick(); + result.current.openModal(); }); - expect(navigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/create' }); - }); - - it('navigates to the correct path with id', async () => { - const { result } = renderHook( - () => useAllCasesModal({ timelineId: TimelineId.test }), - { - wrapper: ({ children }) => {children}, - } - ); + const modal = result.current.modal; + render(<>{modal}); act(() => { - result.current.onOpenModal(); - result.current.onRowClick('case-id'); + userEvent.click(screen.getByText('case-row')); }); - expect(navigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/case-id' }); + expect(result.current.isModalOpen).toBe(false); + expect(onRowClick).toHaveBeenCalledWith({ id: 'case-id' }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx index f57009bccf956..d9a87a8f9a773 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx @@ -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(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(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 ? : null, - [onCloseModal, onRowClick, showModal] + [closeModal, onRowClick] ); const state = useMemo( () => ({ - Modal, - showModal, - onCloseModal, - onOpenModal, + modal: ( + + ), + isModalOpen, + closeModal, + openModal, onRowClick, }), - [showModal, onCloseModal, onOpenModal, onRowClick, Modal] + [isModalOpen, closeModal, onClick, openModal, onRowClick] ); return state; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/translations.ts b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/translations.ts index e0f84d8541424..8d3d185879766 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/translations.ts @@ -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', }); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx index 68446fc5b3171..d1a535ec4e3ae 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx @@ -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 = ({ + isModalOpen, onCloseCaseModal, - onCaseCreated, + onSuccess, }) => { - const onSuccess = useCallback( - (theCase) => { - onCaseCreated(theCase); - onCloseCaseModal(); - }, - [onCaseCreated, onCloseCaseModal] - ); - - return ( + return isModalOpen ? ( @@ -60,7 +54,7 @@ const CreateModalComponent: React.FC = ({ - ); + ) : null; }; export const CreateCaseModal = memo(CreateModalComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx index f07be3cc60821..0a5751d4c7271 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx @@ -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(false); const closeModal = useCallback(() => setIsModalOpen(false), []); const openModal = useCallback(() => setIsModalOpen(true), []); - - const Modal: React.FC = useCallback( - () => - isModalOpen ? ( - - ) : null, - [closeModal, isModalOpen, onCaseCreated] + const onSuccess = useCallback( + (theCase) => { + onCaseCreated(theCase); + closeModal(); + }, + [onCaseCreated, closeModal] ); const state = useMemo( () => ({ - Modal, + modal: ( + + ), isModalOpen, closeModal, openModal, }), - [isModalOpen, closeModal, openModal, Modal] + [isModalOpen, closeModal, onSuccess, openModal] ); return state; diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.ts b/x-pack/plugins/security_solution/public/cases/containers/api.ts index 5186dab6d62f5..5bfa071804713 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/api.ts @@ -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 => { diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx index 39ee21f942cbd..49a458c2b50b6 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.test.tsx @@ -28,7 +28,7 @@ describe('usePostComment', () => { it('init', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - usePostComment(basicCaseId) + usePostComment() ); await waitForNextUpdate(); expect(result.current).toEqual({ @@ -44,11 +44,11 @@ describe('usePostComment', () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - 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(() => - 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(() => - 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(() => - usePostComment(basicCaseId) + usePostComment() ); await waitForNextUpdate(); - result.current.postComment(samplePost, updateCaseCallback); + result.current.postComment(basicCaseId, samplePost, updateCaseCallback); expect(result.current).toEqual({ isLoading: false, diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx index cd3827a2887fb..ce505960c4d89 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_comment.tsx @@ -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 }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.test.tsx new file mode 100644 index 0000000000000..66ad59e4b4101 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.test.tsx @@ -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; +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( + + + + ); + + 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( + + + + ); + + expect(navigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/case-id' }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx index f26c34fb5c073..940da3906be21 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx @@ -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 = ({ 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 = ({ timelineId }) => { const handleExistingCaseClick = useCallback(() => { handlePopoverClose(); - onOpenCaseModal(); - }, [onOpenCaseModal, handlePopoverClose]); + openCaseModal(); + }, [openCaseModal, handlePopoverClose]); const closePopover = useCallback(() => { setPopover(false); @@ -135,7 +155,7 @@ const AddToCaseButtonComponent: React.FC = ({ timelineId }) => { > - + {allCasesModal} ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index 74185c9a803ac..5b558df8388e4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -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 }) => diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx index 693ea0502517c..f26fb1fc2a9c8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx @@ -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, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx index d37d5ec7be7e9..584350f9f7b66 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx @@ -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( />, ] : []), + ...([ + TimelineId.detectionsPage, + TimelineId.detectionsRulesDetailsPage, + TimelineId.active, + ].includes(timelineId as TimelineId) + ? [ + , + ] + : []), 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( 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] );