From 8ead390813d2d6d7c87596b563a096332061bd0a Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 6 Jan 2021 11:43:18 +0200 Subject: [PATCH] [Security Solution][Case] Attach/Sync alert to case: Fix toast messages (#87206) --- .../cases/components/case_view/index.tsx | 12 +- .../timeline_actions/add_to_case_action.tsx | 7 +- .../timeline_actions/helpers.test.ts | 43 +++++ .../components/timeline_actions/helpers.ts | 25 +++ .../timeline_actions/translations.ts | 7 + .../public/cases/containers/api.ts | 5 +- .../public/cases/containers/translations.ts | 13 ++ .../public/cases/containers/types.ts | 16 ++ .../cases/containers/use_get_cases.test.tsx | 2 +- .../public/cases/containers/use_get_cases.tsx | 5 +- .../cases/containers/use_update_case.test.tsx | 5 +- .../cases/containers/use_update_case.tsx | 35 ++-- .../public/cases/containers/utils.test.ts | 169 ++++++++++++++++++ .../public/cases/containers/utils.ts | 52 +++++- 14 files changed, 353 insertions(+), 43 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.ts create mode 100644 x-pack/plugins/security_solution/public/cases/containers/utils.test.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index 2425b4c74d12a..56bec02b6e6c6 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -180,7 +180,7 @@ export const CaseComponent = React.memo( updateKey: 'title', updateValue: titleUpdate, updateCase: handleUpdateNewCase, - version: caseData.version, + caseData, onSuccess, onError, }); @@ -194,7 +194,7 @@ export const CaseComponent = React.memo( updateKey: 'connector', updateValue: connector, updateCase: handleUpdateNewCase, - version: caseData.version, + caseData, onSuccess, onError, }); @@ -208,7 +208,7 @@ export const CaseComponent = React.memo( updateKey: 'description', updateValue: descriptionUpdate, updateCase: handleUpdateNewCase, - version: caseData.version, + caseData, onSuccess, onError, }); @@ -221,7 +221,7 @@ export const CaseComponent = React.memo( updateKey: 'tags', updateValue: tagsUpdate, updateCase: handleUpdateNewCase, - version: caseData.version, + caseData, onSuccess, onError, }); @@ -234,7 +234,7 @@ export const CaseComponent = React.memo( updateKey: 'status', updateValue: statusUpdate, updateCase: handleUpdateNewCase, - version: caseData.version, + caseData, onSuccess, onError, }); @@ -248,7 +248,7 @@ export const CaseComponent = React.memo( updateKey: 'settings', updateValue: settingsUpdate, updateCase: handleUpdateNewCase, - version: caseData.version, + caseData, onSuccess, onError, }); diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx index d2993fa63937d..3ebc0654fc019 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx @@ -17,12 +17,13 @@ import { 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 { useStateToaster } from '../../../common/components/toasters'; import { useCreateCaseModal } from '../use_create_case_modal'; import { useAllCasesModal } from '../use_all_cases_modal'; +import { createUpdateSuccessToaster } from './helpers'; +import * as i18n from './translations'; interface AddToCaseActionProps { ariaLabel?: string; @@ -53,7 +54,7 @@ const AddToCaseActionComponent: React.FC = ({ alertId: eventId, index: eventIndex ?? '', }, - () => displaySuccessToast(i18n.CASE_CREATED_SUCCESS_TOAST(theCase.title), dispatchToaster) + () => dispatchToaster({ type: 'addToaster', toast: createUpdateSuccessToaster(theCase) }) ); }, [postComment, eventId, eventIndex, dispatchToaster] diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.ts b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.ts new file mode 100644 index 0000000000000..58c9c4baf82eb --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.test.ts @@ -0,0 +1,43 @@ +/* + * 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 { createUpdateSuccessToaster } from './helpers'; +import { Case } from '../../containers/types'; + +const theCase = { + title: 'My case', + settings: { + syncAlerts: true, + }, +} as Case; + +describe('helpers', () => { + describe('createUpdateSuccessToaster', () => { + it('creates the correct toast when the sync alerts is on', () => { + // We remove the id as is randomly generated + const { id, ...toast } = createUpdateSuccessToaster(theCase); + expect(toast).toEqual({ + color: 'success', + iconType: 'check', + text: 'Alerts in this case have their status synched with the case status', + title: 'An alert has been added to "My case"', + }); + }); + + it('creates the correct toast when the sync alerts is off', () => { + // We remove the id as is randomly generated + const { id, ...toast } = createUpdateSuccessToaster({ + ...theCase, + settings: { syncAlerts: false }, + }); + expect(toast).toEqual({ + color: 'success', + iconType: 'check', + title: 'An alert has been added to "My case"', + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.ts b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.ts new file mode 100644 index 0000000000000..abafa55c28903 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/helpers.ts @@ -0,0 +1,25 @@ +/* + * 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 uuid from 'uuid'; +import { AppToast } from '../../../common/components/toasters'; +import { Case } from '../../containers/types'; +import * as i18n from './translations'; + +export const createUpdateSuccessToaster = (theCase: Case): AppToast => { + const toast: AppToast = { + id: uuid.v4(), + color: 'success', + iconType: 'check', + title: i18n.CASE_CREATED_SUCCESS_TOAST(theCase.title), + }; + + if (theCase.settings.syncAlerts) { + return { ...toast, text: i18n.CASE_CREATED_SUCCESS_TOAST_TEXT }; + } + + return toast; +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/translations.ts b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/translations.ts index 0ec6a5c89e65a..479323ed1301c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/translations.ts @@ -46,3 +46,10 @@ export const CASE_CREATED_SUCCESS_TOAST = (title: string) => values: { title }, defaultMessage: 'An alert has been added to "{title}"', }); + +export const CASE_CREATED_SUCCESS_TOAST_TEXT = i18n.translate( + 'xpack.securitySolution.case.timeline.actions.caseCreatedSuccessToastText', + { + defaultMessage: 'Alerts in this case have their status synched with the case status', + } +); 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 07f7391ca94d9..fd130aa01196a 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/api.ts @@ -156,7 +156,10 @@ export const postCase = async (newCase: CasePostRequest, signal: AbortSignal): P export const patchCase = async ( caseId: string, - updatedCase: Pick, + updatedCase: Pick< + CasePatchRequest, + 'description' | 'status' | 'tags' | 'title' | 'settings' | 'connector' + >, version: string, signal: AbortSignal ): Promise => { diff --git a/x-pack/plugins/security_solution/public/cases/containers/translations.ts b/x-pack/plugins/security_solution/public/cases/containers/translations.ts index a45fd342a23ea..39baa09a02bbf 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/translations.ts @@ -74,3 +74,16 @@ export const ERROR_GET_FIELDS = i18n.translate( defaultMessage: 'Error getting fields from service', } ); + +export const SYNC_CASE = (caseTitle: string) => + i18n.translate('xpack.securitySolution.containers.case.syncCase', { + values: { caseTitle }, + defaultMessage: 'Alerts in "{caseTitle}" have been synced', + }); + +export const STATUS_CHANGED_TOASTER_TEXT = i18n.translate( + 'xpack.securitySolution.case.containers.statusChangeToasterText', + { + defaultMessage: 'Alerts in this case have been also had their status updated', + } +); diff --git a/x-pack/plugins/security_solution/public/cases/containers/types.ts b/x-pack/plugins/security_solution/public/cases/containers/types.ts index f83f8c70e5d87..4e9baed62c644 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/types.ts @@ -12,6 +12,7 @@ import { CommentRequest, CaseStatuses, CaseAttributes, + CasePatchRequest, } from '../../../../case/common/api'; export { CaseConnector, ActionConnector } from '../../../../case/common/api'; @@ -137,3 +138,18 @@ export interface FieldMappings { id: string; title?: string; } + +export type UpdateKey = keyof Pick< + CasePatchRequest, + 'connector' | 'description' | 'status' | 'tags' | 'title' | 'settings' +>; + +export interface UpdateByKey { + updateKey: UpdateKey; + updateValue: CasePatchRequest[UpdateKey]; + fetchCaseUserActions?: (caseId: string) => void; + updateCase?: (newCase: Case) => void; + caseData: Case; + onSuccess?: () => void; + onError?: () => void; +} diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.test.tsx index 9b4bf966a1434..f8e8d8d6c6969 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.test.tsx @@ -13,7 +13,7 @@ import { useGetCases, UseGetCases, } from './use_get_cases'; -import { UpdateKey } from './use_update_case'; +import { UpdateKey } from './types'; import { allCases, basicCase } from './mock'; import * as api from './api'; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx index e773a25237d0a..3dd0ad3d564a3 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx @@ -7,10 +7,9 @@ import { useCallback, useEffect, useReducer } from 'react'; import { CaseStatuses } from '../../../../case/common/api'; import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from './constants'; -import { AllCases, SortFieldCase, FilterOptions, QueryParams, Case } from './types'; +import { AllCases, SortFieldCase, FilterOptions, QueryParams, Case, UpdateByKey } from './types'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import * as i18n from './translations'; -import { UpdateByKey } from './use_update_case'; import { getCases, patchCase } from './api'; export interface UseGetCasesState { @@ -22,7 +21,7 @@ export interface UseGetCasesState { selectedCases: Case[]; } -export interface UpdateCase extends UpdateByKey { +export interface UpdateCase extends Omit { caseId: string; version: string; refetchCasesStatus: () => void; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.test.tsx index 01e64fa780d52..7b0b7159f2601 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.test.tsx @@ -5,9 +5,10 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import { useUpdateCase, UseUpdateCase, UpdateKey } from './use_update_case'; +import { useUpdateCase, UseUpdateCase } from './use_update_case'; import { basicCase } from './mock'; import * as api from './api'; +import { UpdateKey } from './types'; jest.mock('./api'); @@ -24,7 +25,7 @@ describe('useUpdateCase', () => { updateKey, updateValue: 'updated description', updateCase, - version: basicCase.version, + caseData: basicCase, onSuccess, onError, }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx index 08333416d3c46..ba589acaca507 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx @@ -6,21 +6,12 @@ import { useReducer, useCallback } from 'react'; -import { - displaySuccessToast, - errorToToaster, - useStateToaster, -} from '../../common/components/toasters'; -import { CasePatchRequest } from '../../../../case/common/api'; +import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { patchCase } from './api'; +import { UpdateKey, UpdateByKey } from './types'; import * as i18n from './translations'; -import { Case } from './types'; - -export type UpdateKey = keyof Pick< - CasePatchRequest, - 'connector' | 'description' | 'status' | 'tags' | 'title' | 'settings' ->; +import { createUpdateSuccessToaster } from './utils'; interface NewCaseState { isLoading: boolean; @@ -28,16 +19,6 @@ interface NewCaseState { updateKey: UpdateKey | null; } -export interface UpdateByKey { - updateKey: UpdateKey; - updateValue: CasePatchRequest[UpdateKey]; - fetchCaseUserActions?: (caseId: string) => void; - updateCase?: (newCase: Case) => void; - version: string; - onSuccess?: () => void; - onError?: () => void; -} - type Action = | { type: 'FETCH_INIT'; payload: UpdateKey } | { type: 'FETCH_SUCCESS' } @@ -89,7 +70,7 @@ export const useUpdateCase = ({ caseId }: { caseId: string }): UseUpdateCase => updateKey, updateValue, updateCase, - version, + caseData, onSuccess, onError, }: UpdateByKey) => { @@ -101,7 +82,7 @@ export const useUpdateCase = ({ caseId }: { caseId: string }): UseUpdateCase => const response = await patchCase( caseId, { [updateKey]: updateValue }, - version, + caseData.version, abortCtrl.signal ); if (!cancel) { @@ -112,7 +93,11 @@ export const useUpdateCase = ({ caseId }: { caseId: string }): UseUpdateCase => updateCase(response[0]); } dispatch({ type: 'FETCH_SUCCESS' }); - displaySuccessToast(i18n.UPDATED_CASE(response[0].title), dispatchToaster); + dispatchToaster({ + type: 'addToaster', + toast: createUpdateSuccessToaster(caseData, response[0], updateKey, updateValue), + }); + if (onSuccess) { onSuccess(); } diff --git a/x-pack/plugins/security_solution/public/cases/containers/utils.test.ts b/x-pack/plugins/security_solution/public/cases/containers/utils.test.ts new file mode 100644 index 0000000000000..9fec6049f61bc --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/containers/utils.test.ts @@ -0,0 +1,169 @@ +/* + * 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 { + valueToUpdateIsSettings, + valueToUpdateIsStatus, + createUpdateSuccessToaster, +} from './utils'; + +import { Case } from './types'; + +const caseBeforeUpdate = { + comments: [ + { + type: 'alert', + }, + ], + settings: { + syncAlerts: true, + }, +} as Case; + +const caseAfterUpdate = { title: 'My case' } as Case; + +describe('utils', () => { + describe('valueToUpdateIsSettings', () => { + it('returns true if key is settings', () => { + expect(valueToUpdateIsSettings('settings', 'value')).toBe(true); + }); + + it('returns false if key is NOT settings', () => { + expect(valueToUpdateIsSettings('tags', 'value')).toBe(false); + }); + }); + + describe('valueToUpdateIsStatus', () => { + it('returns true if key is status', () => { + expect(valueToUpdateIsStatus('status', 'value')).toBe(true); + }); + + it('returns false if key is NOT status', () => { + expect(valueToUpdateIsStatus('tags', 'value')).toBe(false); + }); + }); + + describe('createUpdateSuccessToaster', () => { + it('creates the correct toast when sync alerts is turned on and case has alerts', () => { + // We remove the id as is randomly generated + const { id, ...toast } = createUpdateSuccessToaster( + caseBeforeUpdate, + caseAfterUpdate, + 'settings', + { + syncAlerts: true, + } + ); + + expect(toast).toEqual({ + color: 'success', + iconType: 'check', + title: 'Alerts in "My case" have been synced', + }); + }); + + it('creates the correct toast when sync alerts is turned on and case does NOT have alerts', () => { + // We remove the id as is randomly generated + const { id, ...toast } = createUpdateSuccessToaster( + { ...caseBeforeUpdate, comments: [] }, + caseAfterUpdate, + 'settings', + { + syncAlerts: true, + } + ); + + expect(toast).toEqual({ + color: 'success', + iconType: 'check', + title: 'Updated "My case"', + }); + }); + + it('creates the correct toast when sync alerts is turned off and case has alerts', () => { + // We remove the id as is randomly generated + const { id, ...toast } = createUpdateSuccessToaster( + caseBeforeUpdate, + caseAfterUpdate, + 'settings', + { + syncAlerts: false, + } + ); + + expect(toast).toEqual({ + color: 'success', + iconType: 'check', + title: 'Updated "My case"', + }); + }); + + it('creates the correct toast when the status change, case has alerts, and sync alerts is on', () => { + // We remove the id as is randomly generated + const { id, ...toast } = createUpdateSuccessToaster( + caseBeforeUpdate, + caseAfterUpdate, + 'status', + 'closed' + ); + + expect(toast).toEqual({ + color: 'success', + iconType: 'check', + title: 'Updated "My case"', + text: 'Alerts in this case have been also had their status updated', + }); + }); + + it('creates the correct toast when the status change, case has alerts, and sync alerts is off', () => { + // We remove the id as is randomly generated + const { id, ...toast } = createUpdateSuccessToaster( + { ...caseBeforeUpdate, settings: { syncAlerts: false } }, + caseAfterUpdate, + 'status', + 'closed' + ); + + expect(toast).toEqual({ + color: 'success', + iconType: 'check', + title: 'Updated "My case"', + }); + }); + + it('creates the correct toast when the status change, case does NOT have alerts, and sync alerts is on', () => { + // We remove the id as is randomly generated + const { id, ...toast } = createUpdateSuccessToaster( + { ...caseBeforeUpdate, comments: [] }, + caseAfterUpdate, + 'status', + 'closed' + ); + + expect(toast).toEqual({ + color: 'success', + iconType: 'check', + title: 'Updated "My case"', + }); + }); + + it('creates the correct toast if not a status or a setting', () => { + // We remove the id as is randomly generated + const { id, ...toast } = createUpdateSuccessToaster( + caseBeforeUpdate, + caseAfterUpdate, + 'title', + 'My new title' + ); + + expect(toast).toEqual({ + color: 'success', + iconType: 'check', + title: 'Updated "My case"', + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/containers/utils.ts b/x-pack/plugins/security_solution/public/cases/containers/utils.ts index 6d0d9fa0f030d..388fc58d91c23 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/utils.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/utils.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import uuid from 'uuid'; import { set } from '@elastic/safer-lodash-set'; import { camelCase, isArray, isObject } from 'lodash'; import { fold } from 'fp-ts/lib/Either'; @@ -26,9 +27,12 @@ import { CaseUserActionsResponseRt, ServiceConnectorCaseResponseRt, ServiceConnectorCaseResponse, + CommentType, + CasePatchRequest, } from '../../../../case/common/api'; -import { ToasterError } from '../../common/components/toasters'; -import { AllCases, Case } from './types'; +import { AppToast, ToasterError } from '../../common/components/toasters'; +import { AllCases, Case, UpdateByKey } from './types'; +import * as i18n from './translations'; export const getTypedPayload = (a: unknown): T => a as T; @@ -107,3 +111,47 @@ export const decodeServiceConnectorCaseResponse = (respPushCase?: ServiceConnect ServiceConnectorCaseResponseRt.decode(respPushCase), fold(throwErrors(createToasterPlainError), identity) ); + +export const valueToUpdateIsSettings = ( + key: UpdateByKey['updateKey'], + value: UpdateByKey['updateValue'] +): value is CasePatchRequest['settings'] => key === 'settings'; + +export const valueToUpdateIsStatus = ( + key: UpdateByKey['updateKey'], + value: UpdateByKey['updateValue'] +): value is CasePatchRequest['status'] => key === 'status'; + +export const createUpdateSuccessToaster = ( + caseBeforeUpdate: Case, + caseAfterUpdate: Case, + key: UpdateByKey['updateKey'], + value: UpdateByKey['updateValue'] +): AppToast => { + const caseHasAlerts = caseBeforeUpdate.comments.some( + (comment) => comment.type === CommentType.alert + ); + + const toast: AppToast = { + id: uuid.v4(), + color: 'success', + iconType: 'check', + title: i18n.UPDATED_CASE(caseAfterUpdate.title), + }; + + if (valueToUpdateIsSettings(key, value) && value?.syncAlerts && caseHasAlerts) { + return { + ...toast, + title: i18n.SYNC_CASE(caseAfterUpdate.title), + }; + } + + if (valueToUpdateIsStatus(key, value) && caseHasAlerts && caseBeforeUpdate.settings.syncAlerts) { + return { + ...toast, + text: i18n.STATUS_CHANGED_TOASTER_TEXT, + }; + } + + return toast; +};