From 781ccbdcd967831c663a383ff6c869f7d260b119 Mon Sep 17 00:00:00 2001 From: stephmilovic Date: Tue, 17 Mar 2020 18:21:33 -0600 Subject: [PATCH] email and case closed complete --- .../components/all_cases/__mock__/index.tsx | 14 +++++- .../case/components/all_cases/columns.tsx | 50 +++++++++++++------ .../pages/case/components/all_cases/index.tsx | 14 ++++-- .../components/case_view/__mock__/index.tsx | 48 ++++++------------ .../case/components/case_view/index.test.tsx | 47 +++++++++++++---- .../pages/case/components/case_view/index.tsx | 39 +++++++++++---- .../case/components/case_view/translations.ts | 16 ++++++ .../case/components/user_list/index.test.tsx | 40 +++++++++++++++ .../pages/case/components/user_list/index.tsx | 25 +++++++--- .../siem/public/pages/case/translations.ts | 4 ++ .../server/routes/api/cases/patch_cases.ts | 13 +++++ .../plugins/case/server/routes/api/utils.ts | 4 +- 12 files changed, 234 insertions(+), 80 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx index 0fe8daafcb30ab..5d00b770b3ca9c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -13,6 +13,8 @@ export const useGetCasesMockState: UseGetCasesState = { countOpenCases: 0, cases: [ { + closedAt: null, + closedBy: null, id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:23.627Z', createdBy: { username: 'elastic' }, @@ -27,6 +29,8 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { + closedAt: null, + closedBy: null, id: '362a5c10-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:13.328Z', createdBy: { username: 'elastic' }, @@ -41,6 +45,8 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { + closedAt: null, + closedBy: null, id: '34f8b9e0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:11.328Z', createdBy: { username: 'elastic' }, @@ -55,6 +61,8 @@ export const useGetCasesMockState: UseGetCasesState = { version: 'WzQ3LDFd', }, { + closedAt: '2020-02-13T19:44:13.328Z', + closedBy: { username: 'elastic' }, id: '31890e90-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:05.563Z', createdBy: { username: 'elastic' }, @@ -64,11 +72,13 @@ export const useGetCasesMockState: UseGetCasesState = { status: 'closed', tags: ['phishing'], title: 'Uh oh', - updatedAt: null, - updatedBy: null, + updatedAt: '2020-02-13T19:44:13.328Z', + updatedBy: { username: 'elastic' }, version: 'WzQ3LDFd', }, { + closedAt: null, + closedBy: null, id: '2f5b3210-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:01.901Z', createdBy: { username: 'elastic' }, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx index 5859e6bbce263f..b9e1113c486ad9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -36,7 +36,8 @@ const Spacer = styled.span` const renderStringField = (field: string, dataTestSubj: string) => field != null ? {field} : getEmptyTagValue(); export const getCasesColumns = ( - actions: Array> + actions: Array>, + filterStatus: string ): CasesColumns[] => [ { name: i18n.NAME, @@ -113,22 +114,39 @@ export const getCasesColumns = ( render: (comments: Case['commentIds']) => renderStringField(`${comments.length}`, `case-table-column-commentCount`), }, - { - field: 'createdAt', - name: i18n.OPENED_ON, - sortable: true, - render: (createdAt: Case['createdAt']) => { - if (createdAt != null) { - return ( - - ); + filterStatus === 'open' + ? { + field: 'createdAt', + name: i18n.OPENED_ON, + sortable: true, + render: (createdAt: Case['createdAt']) => { + if (createdAt != null) { + return ( + + ); + } + return getEmptyTagValue(); + }, } - return getEmptyTagValue(); - }, - }, + : { + field: 'closedAt', + name: i18n.CLOSED_ON, + sortable: true, + render: (closedAt: Case['closedAt']) => { + if (closedAt != null) { + return ( + + ); + } + return getEmptyTagValue(); + }, + }, { name: 'Actions', actions, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index 5d88f5e9bd077b..563e7b008ec58f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -206,17 +206,25 @@ export const AllCases = React.memo(() => { } setQueryParams(newQueryParams); }, - [setQueryParams, queryParams] + [queryParams] ); const onFilterChangedCallback = useCallback( (newFilterOptions: Partial) => { + if (newFilterOptions.status && newFilterOptions.status === 'closed') { + setQueryParams({ ...queryParams, sortField: SortFieldCase.closedAt }); + } else if (newFilterOptions.status && newFilterOptions.status === 'open') { + setQueryParams({ ...queryParams, sortField: SortFieldCase.createdAt }); + } setFilters({ ...filterOptions, ...newFilterOptions }); }, - [filterOptions, setFilters] + [filterOptions, queryParams] ); - const memoizedGetCasesColumns = useMemo(() => getCasesColumns(actions), [actions]); + const memoizedGetCasesColumns = useMemo(() => getCasesColumns(actions, filterOptions.status), [ + actions, + filterOptions.status, + ]); const memoizedPagination = useMemo( () => ({ pageIndex: queryParams.page - 1, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx index 6841c2c9f91282..e11441eac3a9d2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx @@ -44,38 +44,22 @@ export const caseProps: CaseProps = { version: 'WzQ3LDFd', }, }; - -export const data: Case = { - closedAt: null, - closedBy: null, - id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', - commentIds: ['a357c6a0-5435-11ea-b427-fb51a1fcb7b8'], - comments: [ - { - comment: 'Solve this fast!', - id: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', - createdAt: '2020-02-20T23:06:33.798Z', - createdBy: { - fullName: 'Steph Milovic', - username: 'smilovic', - email: 'notmyrealemailfool@elastic.co', - }, - updatedAt: '2020-02-20T23:06:33.798Z', - updatedBy: { - username: 'elastic', - }, - version: 'WzQ3LDFd', +export const caseClosedProps: CaseProps = { + ...caseProps, + initialData: { + ...caseProps.initialData, + closedAt: '2020-02-20T23:06:33.798Z', + closedBy: { + username: 'elastic', }, - ], - createdAt: '2020-02-13T19:44:23.627Z', - createdBy: { fullName: null, email: 'testemail@elastic.co', username: 'elastic' }, - description: 'Security banana Issue', - status: 'open', - tags: ['defacement'], - title: 'Another horrible breach!!', - updatedAt: '2020-02-19T15:02:57.995Z', - updatedBy: { - username: 'elastic', + status: 'closed', }, - version: 'WzQ3LDFd', +}; + +export const data: Case = { + ...caseProps.initialData, +}; + +export const dataClosed: Case = { + ...caseClosedProps.initialData, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx index 8754c0404d40ba..b26b646fd214c9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -7,22 +7,26 @@ import React from 'react'; import { mount } from 'enzyme'; import { CaseComponent } from './'; -import * as apiHook from '../../../../containers/case/use_update_case'; -import { caseProps, data } from './__mock__'; +import { caseProps, caseClosedProps, data, dataClosed } from './__mock__'; import { TestProviders } from '../../../../mock'; +import { useUpdateCase } from '../../../../containers/case/use_update_case'; +jest.mock('../../../../containers/case/use_update_case'); +const useUpdateCaseMock = useUpdateCase as jest.Mock; describe('CaseView ', () => { const updateCaseProperty = jest.fn(); + const defaultUpdateCaseState = { + caseData: data, + isLoading: false, + isError: false, + updateKey: null, + updateCaseProperty, + }; + beforeEach(() => { jest.resetAllMocks(); - jest.spyOn(apiHook, 'useUpdateCase').mockReturnValue({ - caseData: data, - isLoading: false, - isError: false, - updateKey: null, - updateCaseProperty, - }); + useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState); }); it('should render CaseComponent', () => { @@ -55,6 +59,7 @@ describe('CaseView ', () => { .first() .text() ).toEqual(data.createdBy.username); + expect(wrapper.contains(`[data-test-subj="case-view-closedAt"]`)).toBe(false); expect( wrapper .find(`[data-test-subj="case-view-createdAt"]`) @@ -68,6 +73,30 @@ describe('CaseView ', () => { .prop('raw') ).toEqual(data.description); }); + it('should show closed indicators in header when case is closed', () => { + useUpdateCaseMock.mockImplementation(() => ({ + ...defaultUpdateCaseState, + caseData: dataClosed, + })); + const wrapper = mount( + + + + ); + expect(wrapper.contains(`[data-test-subj="case-view-createdAt"]`)).toBe(false); + expect( + wrapper + .find(`[data-test-subj="case-view-closedAt"]`) + .first() + .prop('value') + ).toEqual(dataClosed.closedAt); + expect( + wrapper + .find(`[data-test-subj="case-view-status"]`) + .first() + .text() + ).toEqual(dataClosed.status); + }); it('should dispatch update state when button is toggled', () => { const wrapper = mount( diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index 5ff542d2089054..508b369607803f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -36,6 +36,7 @@ import { WhitePageWrapper } from '../wrappers'; import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; import { SiemPageName } from '../../../home/types'; import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; +import { useBasePath } from '../../../../lib/kibana'; interface Props { caseId: string; @@ -64,6 +65,8 @@ export interface CaseProps { } export const CaseComponent = React.memo(({ caseId, initialData }) => { + const basePath = window.location.origin + useBasePath(); + const caseLink = `${basePath}/app/siem#/case/${caseId}`; const { caseData, isLoading, updateKey, updateCaseProperty } = useUpdateCase(caseId, initialData); // Update Fields @@ -140,7 +143,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => const propertyActions = [ { iconType: 'trash', - label: 'Delete case', + label: i18n.DELETE_CASE, onClick: handleToggleModal, }, { @@ -192,15 +195,27 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => - - {i18n.CASE_OPENED} - - - - + {caseData.status === 'open' ? ( + + {i18n.CASE_OPENED} + + + + + ) : ( + + {i18n.CASE_CLOSEED} + + + + + )} @@ -237,6 +252,10 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts index 82b5e771e21513..5aaa5c3ac32c8b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts @@ -55,3 +55,19 @@ export const STATUS = i18n.translate('xpack.siem.case.caseView.statusLabel', { export const CASE_OPENED = i18n.translate('xpack.siem.case.caseView.caseOpened', { defaultMessage: 'Case opened', }); + +export const CASE_CLOSEED = i18n.translate('xpack.siem.case.caseView.caseClosed', { + defaultMessage: 'Case closed', +}); + +export const EMAIL_SUBJECT = (caseTitle: string) => + i18n.translate('xpack.siem.case.caseView.emailSubject', { + values: { caseTitle }, + defaultMessage: 'SIEM Case - {caseTitle}', + }); + +export const EMAIL_BODY = (caseUrl: string) => + i18n.translate('xpack.siem.case.caseView.emailBody', { + values: { caseUrl }, + defaultMessage: 'Case reference: {caseUrl}', + }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx new file mode 100644 index 00000000000000..51acb3b810d92e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.test.tsx @@ -0,0 +1,40 @@ +/* + * 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 { shallow } from 'enzyme'; +import { UserList } from './'; +import * as i18n from '../case_view/translations'; + +describe('UserList ', () => { + const title = 'Case Title'; + const caseLink = 'http://reddit.com'; + const user = { username: 'username', fullName: 'Full Name', email: 'testemail@elastic.co' }; + const open = jest.fn(); + beforeAll(() => { + window.open = open; + }); + beforeEach(() => { + jest.resetAllMocks(); + }); + it('triggers mailto when email icon clicked', () => { + const wrapper = shallow( + + ); + wrapper.find('[data-test-subj="user-list-email-button"]').simulate('click'); + expect(open).toBeCalledWith( + `mailto:${user.email}?subject=${i18n.EMAIL_SUBJECT(title)}&body=${i18n.EMAIL_BODY(caseLink)}`, + '_blank' + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx index abb49122dc1421..57ae6734aa95a4 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { EuiButtonIcon, EuiText, @@ -17,6 +17,10 @@ import styled, { css } from 'styled-components'; import { ElasticUser } from '../../../../containers/case/types'; interface UserListProps { + email: { + subject: string; + body: string; + }; headline: string; users: ElasticUser[]; } @@ -31,8 +35,11 @@ const MyFlexGroup = styled(EuiFlexGroup)` `} `; -const renderUsers = (users: ElasticUser[]) => { - return users.map(({ fullName, username }, key) => ( +const renderUsers = ( + users: ElasticUser[], + handleSendEmail: (emailAddress: string | undefined | null) => void +) => { + return users.map(({ fullName, username, email }, key) => ( @@ -50,7 +57,8 @@ const renderUsers = (users: ElasticUser[]) => { {}} // TO DO + data-test-subj="user-list-email-button" + onClick={handleSendEmail.bind(null, email)} // TO DO iconType="email" aria-label="email" /> @@ -59,12 +67,17 @@ const renderUsers = (users: ElasticUser[]) => { )); }; -export const UserList = React.memo(({ headline, users }: UserListProps) => { +export const UserList = React.memo(({ email, headline, users }: UserListProps) => { + const handleSendEmail = useCallback((emailAddress: string | undefined | null) => { + if (emailAddress && emailAddress != null) { + window.open(`mailto:${emailAddress}?subject=${email.subject}&body=${email.body}`, '_blank'); + } + }, []); return (

{headline}

- {renderUsers(users)} + {renderUsers(users, handleSendEmail)}
); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts index 6ef412d408ae5d..c2c645f07385ad 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts @@ -30,6 +30,10 @@ export const OPENED_ON = i18n.translate('xpack.siem.case.caseView.openedOn', { defaultMessage: 'Opened on', }); +export const CLOSED_ON = i18n.translate('xpack.siem.case.caseView.closedOn', { + defaultMessage: 'Closed on', +}); + export const REPORTER = i18n.translate('xpack.siem.case.caseView.createdBy', { defaultMessage: 'Reporter', }); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index f5a55ca66d4fb2..4aa0d8daf5b34a 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -37,10 +37,23 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { client: context.core.savedObjects.client, caseIds: query.cases.map(q => q.id), }); + let nonExistingCases: CasePatchRequest[] = []; const conflictedCases = query.cases.filter(q => { const myCase = myCases.saved_objects.find(c => c.id === q.id); + + if (myCase && myCase.error) { + nonExistingCases = [...nonExistingCases, q]; + return false; + } return myCase == null || myCase?.version !== q.version; }); + if (nonExistingCases.length > 0) { + throw Boom.notFound( + `These cases ${nonExistingCases + .map(c => c.id) + .join(', ')} do not exist. Please check you have the correct ids.` + ); + } if (conflictedCases.length > 0) { throw Boom.conflict( `These cases ${conflictedCases diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index f2978e764d3413..19dbb024d1e0be 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -37,8 +37,8 @@ export const transformNewCase = ({ newCase: CaseRequest; username: string; }): CaseAttributes => ({ - closed_at: null, - closed_by: null, + closed_at: newCase.status === 'closed' ? createdDate : null, + closed_by: newCase.status === 'closed' ? { email, full_name, username } : null, comment_ids: [], created_at: createdDate, created_by: { email, full_name, username },