[Security Solution][Case] Attach/Sync alert to case: Fix toast messages (#87206)

This commit is contained in:
Christos Nasikas 2021-01-06 11:43:18 +02:00 committed by GitHub
parent 34a3982f3a
commit 8ead390813
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 353 additions and 43 deletions

View file

@ -180,7 +180,7 @@ export const CaseComponent = React.memo<CaseProps>(
updateKey: 'title',
updateValue: titleUpdate,
updateCase: handleUpdateNewCase,
version: caseData.version,
caseData,
onSuccess,
onError,
});
@ -194,7 +194,7 @@ export const CaseComponent = React.memo<CaseProps>(
updateKey: 'connector',
updateValue: connector,
updateCase: handleUpdateNewCase,
version: caseData.version,
caseData,
onSuccess,
onError,
});
@ -208,7 +208,7 @@ export const CaseComponent = React.memo<CaseProps>(
updateKey: 'description',
updateValue: descriptionUpdate,
updateCase: handleUpdateNewCase,
version: caseData.version,
caseData,
onSuccess,
onError,
});
@ -221,7 +221,7 @@ export const CaseComponent = React.memo<CaseProps>(
updateKey: 'tags',
updateValue: tagsUpdate,
updateCase: handleUpdateNewCase,
version: caseData.version,
caseData,
onSuccess,
onError,
});
@ -234,7 +234,7 @@ export const CaseComponent = React.memo<CaseProps>(
updateKey: 'status',
updateValue: statusUpdate,
updateCase: handleUpdateNewCase,
version: caseData.version,
caseData,
onSuccess,
onError,
});
@ -248,7 +248,7 @@ export const CaseComponent = React.memo<CaseProps>(
updateKey: 'settings',
updateValue: settingsUpdate,
updateCase: handleUpdateNewCase,
version: caseData.version,
caseData,
onSuccess,
onError,
});

View file

@ -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<AddToCaseActionProps> = ({
alertId: eventId,
index: eventIndex ?? '',
},
() => displaySuccessToast(i18n.CASE_CREATED_SUCCESS_TOAST(theCase.title), dispatchToaster)
() => dispatchToaster({ type: 'addToaster', toast: createUpdateSuccessToaster(theCase) })
);
},
[postComment, eventId, eventIndex, dispatchToaster]

View file

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

View file

@ -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;
};

View file

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

View file

@ -156,7 +156,10 @@ export const postCase = async (newCase: CasePostRequest, signal: AbortSignal): P
export const patchCase = async (
caseId: string,
updatedCase: Pick<CasePatchRequest, 'description' | 'status' | 'tags' | 'title'>,
updatedCase: Pick<
CasePatchRequest,
'description' | 'status' | 'tags' | 'title' | 'settings' | 'connector'
>,
version: string,
signal: AbortSignal
): Promise<Case[]> => {

View file

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

View file

@ -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;
}

View file

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

View file

@ -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<UpdateByKey, 'caseData'> {
caseId: string;
version: string;
refetchCasesStatus: () => void;

View file

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

View file

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

View file

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

View file

@ -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 = <T>(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;
};