[Cases] Do not add already attached alerts to the case (#154322)

## Summary

This PR filters out all alerts that are already attached to the selected
case. To avoid breaking changes and not confuse the users (trying to
find which alert is attached to which case) the UI will not produce any
error.

### Checklist

Delete any items that are not applicable to this PR.

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Christos Nasikas 2023-04-13 17:29:20 +03:00 committed by GitHub
parent 70500d7cd9
commit 9cc51bf65b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 566 additions and 266 deletions

View file

@ -131,8 +131,9 @@ const uploadPipeline = (pipelineContent: string | object) => {
}
if (
(await doAnyChangesMatch([/^x-pack\/plugins\/osquery/, /^x-pack\/test\/osquery_cypress/])) ||
GITHUB_PR_LABELS.includes('ci:all-cypress-suites')
((await doAnyChangesMatch([/^x-pack\/plugins\/osquery/, /^x-pack\/test\/osquery_cypress/])) ||
GITHUB_PR_LABELS.includes('ci:all-cypress-suites')) &&
!GITHUB_PR_LABELS.includes('ci:skip-cypress-osquery')
) {
pipeline.push(getPipeline('.buildkite/pipelines/pull_request/osquery_cypress.yml'));
}

View file

@ -169,6 +169,13 @@ export const useCasesToast = () => {
showSuccessToast: (title: string) => {
toasts.addSuccess({ title, className: 'eui-textBreakWord' });
},
showInfoToast: (title: string, text?: string) => {
toasts.addInfo({
title,
text,
className: 'eui-textBreakWord',
});
},
};
};

View file

@ -20,6 +20,7 @@ import { useCreateAttachments } from '../../../containers/use_create_attachments
import { CasesContext } from '../../cases_context';
import { CasesContextStoreActionsList } from '../../cases_context/cases_context_reducer';
import { ExternalReferenceAttachmentTypeRegistry } from '../../../client/attachment_framework/external_reference_registry';
import type { AddToExistingCaseModalProps } from './use_cases_add_to_existing_case_modal';
import { useCasesAddToExistingCaseModal } from './use_cases_add_to_existing_case_modal';
import { PersistableStateAttachmentTypeRegistry } from '../../../client/attachment_framework/persistable_state_registry';
@ -33,15 +34,18 @@ jest.mock('./all_cases_selector_modal', () => {
});
const onSuccess = jest.fn();
const getAttachments = jest.fn().mockReturnValue([alertComment]);
const useCasesToastMock = useCasesToast as jest.Mock;
const AllCasesSelectorModalMock = AllCasesSelectorModal as unknown as jest.Mock;
// test component to test the hook integration
const TestComponent: React.FC = () => {
const hook = useCasesAddToExistingCaseModal({ onSuccess });
const TestComponent: React.FC<AddToExistingCaseModalProps> = (
props: AddToExistingCaseModalProps = {}
) => {
const hook = useCasesAddToExistingCaseModal({ onSuccess, ...props });
const onClick = () => {
hook.open({ attachments: [alertComment] });
hook.open({ getAttachments });
};
return <button type="button" data-test-subj="open-modal" onClick={onClick} />;
@ -138,6 +142,65 @@ describe('use cases add to existing case modal hook', () => {
);
});
it('should call getAttachments with the case info', async () => {
AllCasesSelectorModalMock.mockImplementation(({ onRowClick }) => {
onRowClick({ id: 'test' } as Case);
return null;
});
const result = appMockRender.render(<TestComponent />);
userEvent.click(result.getByTestId('open-modal'));
await waitFor(() => {
expect(getAttachments).toHaveBeenCalledTimes(1);
expect(getAttachments).toHaveBeenCalledWith({ theCase: { id: 'test' } });
});
});
it('should show a toaster info when no attachments are defined and noAttachmentsToaster is defined', async () => {
AllCasesSelectorModalMock.mockImplementation(({ onRowClick }) => {
onRowClick({ id: 'test' } as Case);
return null;
});
getAttachments.mockReturnValueOnce([]);
const mockedToastInfo = jest.fn();
useCasesToastMock.mockReturnValue({
showInfoToast: mockedToastInfo,
});
const result = appMockRender.render(
<TestComponent noAttachmentsToaster={{ title: 'My title', content: 'My content' }} />
);
userEvent.click(result.getByTestId('open-modal'));
await waitFor(() => {
expect(mockedToastInfo).toHaveBeenCalledWith('My title', 'My content');
});
});
it('should show a toaster info when no attachments are defined and noAttachmentsToaster is not defined', async () => {
AllCasesSelectorModalMock.mockImplementation(({ onRowClick }) => {
onRowClick({ id: 'test' } as Case);
return null;
});
getAttachments.mockReturnValueOnce([]);
const mockedToastInfo = jest.fn();
useCasesToastMock.mockReturnValue({
showInfoToast: mockedToastInfo,
});
const result = appMockRender.render(<TestComponent />);
userEvent.click(result.getByTestId('open-modal'));
await waitFor(() => {
expect(mockedToastInfo).toHaveBeenCalledWith('No attachments added to the case', undefined);
});
});
it('should call createAttachments when a case is selected and show a toast message', async () => {
const mockBulkCreateAttachments = jest.fn();
useCreateAttachmentsMock.mockReturnValueOnce({

View file

@ -16,14 +16,21 @@ import { useCasesAddToNewCaseFlyout } from '../../create/flyout/use_cases_add_to
import type { CaseAttachmentsWithoutOwner } from '../../../types';
import { useCreateAttachments } from '../../../containers/use_create_attachments';
import { useAddAttachmentToExistingCaseTransaction } from '../../../common/apm/use_cases_transactions';
import { NO_ATTACHMENTS_ADDED } from '../translations';
type AddToExistingFlyoutProps = Omit<AllCasesSelectorModalProps, 'onRowClick'> & {
toastTitle?: string;
toastContent?: string;
export type AddToExistingCaseModalProps = Omit<AllCasesSelectorModalProps, 'onRowClick'> & {
successToaster?: {
title?: string;
content?: string;
};
noAttachmentsToaster?: {
title?: string;
content?: string;
};
onSuccess?: (theCase: Case) => void;
};
export const useCasesAddToExistingCaseModal = (props: AddToExistingFlyoutProps = {}) => {
export const useCasesAddToExistingCaseModal = (props: AddToExistingCaseModalProps = {}) => {
const createNewCaseFlyout = useCasesAddToNewCaseFlyout({
onClose: props.onClose,
onSuccess: (theCase?: Case) => {
@ -31,8 +38,8 @@ export const useCasesAddToExistingCaseModal = (props: AddToExistingFlyoutProps =
return props.onSuccess(theCase);
}
},
toastTitle: props.toastTitle,
toastContent: props.toastContent,
toastTitle: props.successToaster?.title,
toastContent: props.successToaster?.content,
});
const { dispatch, appId } = useCasesContext();
@ -52,7 +59,11 @@ export const useCasesAddToExistingCaseModal = (props: AddToExistingFlyoutProps =
}, [dispatch]);
const handleOnRowClick = useCallback(
async (theCase: Case | undefined, attachments: CaseAttachmentsWithoutOwner) => {
async (
theCase: Case | undefined,
getAttachments?: ({ theCase }: { theCase?: Case }) => CaseAttachmentsWithoutOwner
) => {
const attachments = getAttachments?.({ theCase }) ?? [];
// when the case is undefined in the modal
// the user clicked "create new case"
if (theCase === undefined) {
@ -63,27 +74,33 @@ export const useCasesAddToExistingCaseModal = (props: AddToExistingFlyoutProps =
try {
// add attachments to the case
if (attachments !== undefined && attachments.length > 0) {
startTransaction({ appId, attachments });
if (attachments === undefined || attachments.length === 0) {
const title = props.noAttachmentsToaster?.title ?? NO_ATTACHMENTS_ADDED;
const content = props.noAttachmentsToaster?.content;
casesToasts.showInfoToast(title, content);
await createAttachments({
caseId: theCase.id,
caseOwner: theCase.owner,
data: attachments,
throwOnError: true,
});
if (props.onSuccess) {
props.onSuccess(theCase);
}
casesToasts.showSuccessAttach({
theCase,
attachments,
title: props.toastTitle,
content: props.toastContent,
});
return;
}
startTransaction({ appId, attachments });
await createAttachments({
caseId: theCase.id,
caseOwner: theCase.owner,
data: attachments,
throwOnError: true,
});
if (props.onSuccess) {
props.onSuccess(theCase);
}
casesToasts.showSuccessAttach({
theCase,
attachments,
title: props.successToaster?.title,
content: props.successToaster?.content,
});
} catch (error) {
// error toast is handled
// inside the createAttachments method
@ -101,15 +118,18 @@ export const useCasesAddToExistingCaseModal = (props: AddToExistingFlyoutProps =
);
const openModal = useCallback(
({ attachments }: { attachments?: CaseAttachmentsWithoutOwner } = {}) => {
({
getAttachments,
}: {
getAttachments?: ({ theCase }: { theCase?: Case }) => CaseAttachmentsWithoutOwner;
} = {}) => {
dispatch({
type: CasesContextStoreActionsList.OPEN_ADD_TO_CASE_MODAL,
payload: {
...props,
hiddenStatuses: [CaseStatuses.closed, StatusAll],
onRowClick: (theCase?: Case) => {
const caseAttachments = attachments ?? [];
handleOnRowClick(theCase, caseAttachments);
handleOnRowClick(theCase, getAttachments);
},
onClose: () => {
closeModal();

View file

@ -147,3 +147,10 @@ export const SHOW_MORE = (count: number) =>
defaultMessage: '+{count} more',
values: { count },
});
export const NO_ATTACHMENTS_ADDED = i18n.translate(
'xpack.cases.modal.attachments.noAttachmentsTitle',
{
defaultMessage: 'No attachments added to the case',
}
);

View file

@ -83,7 +83,9 @@ describe('use cases add to new case flyout hook', () => {
},
{ wrapper }
);
result.current.open({ attachments: [alertComment] });
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: CasesContextStoreActionsList.OPEN_CREATE_CASE_FLYOUT,

View file

@ -84,7 +84,7 @@ const sampleId = 'case-id';
const defaultPostCase = {
isLoading: false,
isError: false,
postCase,
mutateAsync: postCase,
};
const defaultCreateCaseForm: CreateCaseFormFieldsProps = {
@ -226,7 +226,7 @@ describe('Create case', () => {
expect(postCase).toHaveBeenCalled();
});
expect(postCase).toBeCalledWith({ ...sampleDataWithoutTags, tags: sampleTags });
expect(postCase).toBeCalledWith({ request: { ...sampleDataWithoutTags, tags: sampleTags } });
});
it('should post a case on submit click with the selected severity', async () => {
@ -258,8 +258,10 @@ describe('Create case', () => {
});
expect(postCase).toBeCalledWith({
...sampleDataWithoutTags,
severity: CaseSeverity.HIGH,
request: {
...sampleDataWithoutTags,
severity: CaseSeverity.HIGH,
},
});
});
@ -313,8 +315,10 @@ describe('Create case', () => {
await waitFor(() => expect(postCase).toHaveBeenCalled());
expect(postCase).toBeCalledWith({
...sampleDataWithoutTags,
settings: { syncAlerts: false },
request: {
...sampleDataWithoutTags,
settings: { syncAlerts: false },
},
});
});
@ -343,8 +347,10 @@ describe('Create case', () => {
await waitFor(() => expect(postCase).toHaveBeenCalled());
expect(postCase).toBeCalledWith({
...sampleDataWithoutTags,
settings: { syncAlerts: false },
request: {
...sampleDataWithoutTags,
settings: { syncAlerts: false },
},
});
});
@ -398,18 +404,20 @@ describe('Create case', () => {
await waitFor(() => expect(postCase).toHaveBeenCalled());
expect(postCase).toBeCalledWith({
...sampleDataWithoutTags,
connector: {
fields: {
impact: null,
severity: null,
urgency: null,
category: null,
subcategory: null,
request: {
...sampleDataWithoutTags,
connector: {
fields: {
impact: null,
severity: null,
urgency: null,
category: null,
subcategory: null,
},
id: 'servicenow-1',
name: 'My SN connector',
type: '.servicenow',
},
id: 'servicenow-1',
name: 'My SN connector',
type: '.servicenow',
},
});
});
@ -449,7 +457,7 @@ describe('Create case', () => {
});
expect(pushCaseToExternalService).not.toHaveBeenCalled();
expect(postCase).toBeCalledWith(sampleDataWithoutTags);
expect(postCase).toBeCalledWith({ request: sampleDataWithoutTags });
});
});
@ -501,12 +509,14 @@ describe('Create case', () => {
});
expect(postCase).toBeCalledWith({
...sampleDataWithoutTags,
connector: {
id: 'resilient-2',
name: 'My Connector 2',
type: '.resilient',
fields: { incidentTypes: ['21'], severityCode: '4' },
request: {
...sampleDataWithoutTags,
connector: {
id: 'resilient-2',
name: 'My Resilient connector',
type: '.resilient',
fields: { incidentTypes: ['21'], severityCode: '4' },
},
},
});
@ -514,7 +524,7 @@ describe('Create case', () => {
caseId: sampleId,
connector: {
id: 'resilient-2',
name: 'My Connector 2',
name: 'My Resilient connector',
type: '.resilient',
fields: { incidentTypes: ['21'], severityCode: '4' },
},
@ -572,6 +582,7 @@ describe('Create case', () => {
...sampleConnectorData,
data: connectorsMock,
});
const attachments = [
{
alertId: '1234',
@ -623,6 +634,7 @@ describe('Create case', () => {
...sampleConnectorData,
data: connectorsMock,
});
const attachments: CaseAttachments = [];
mockedContext.render(
@ -766,8 +778,10 @@ describe('Create case', () => {
});
expect(postCase).toBeCalledWith({
...sampleDataWithoutTags,
assignees: [{ uid: userProfiles[0].uid }],
request: {
...sampleDataWithoutTags,
assignees: [{ uid: userProfiles[0].uid }],
},
});
});

View file

@ -59,7 +59,7 @@ export const FormContext: React.FC<Props> = ({
useGetSupportedActionConnectors();
const { owner, appId } = useCasesContext();
const { isSyncAlertsEnabled } = useCasesFeatures();
const { postCase } = usePostCase();
const { mutateAsync: postCase } = usePostCase();
const { createAttachments } = useCreateAttachments();
const { pushCaseToExternalService } = usePostPushToService();
const { startTransaction } = useCreateCaseWithAttachmentsTransaction();
@ -84,48 +84,50 @@ export const FormContext: React.FC<Props> = ({
? normalizeActionConnector(caseConnector, fields)
: getNoneConnector();
const updatedCase = await postCase({
...userFormData,
connector: connectorToUpdate,
settings: { syncAlerts },
owner: selectedOwner ?? owner[0],
const theCase = await postCase({
request: {
...userFormData,
connector: connectorToUpdate,
settings: { syncAlerts },
owner: selectedOwner ?? owner[0],
},
});
// add attachments to the case
if (updatedCase && Array.isArray(attachments) && attachments.length > 0) {
if (theCase && Array.isArray(attachments) && attachments.length > 0) {
await createAttachments({
caseId: updatedCase.id,
caseOwner: updatedCase.owner,
caseId: theCase.id,
caseOwner: theCase.owner,
data: attachments,
});
}
if (afterCaseCreated && updatedCase) {
await afterCaseCreated(updatedCase, createAttachments);
if (afterCaseCreated && theCase) {
await afterCaseCreated(theCase, createAttachments);
}
if (updatedCase?.id && connectorToUpdate.id !== 'none') {
if (theCase?.id && connectorToUpdate.id !== 'none') {
await pushCaseToExternalService({
caseId: updatedCase.id,
caseId: theCase.id,
connector: connectorToUpdate,
});
}
if (onSuccess && updatedCase) {
onSuccess(updatedCase);
if (onSuccess && theCase) {
onSuccess(theCase);
}
}
},
[
appId,
startTransaction,
isSyncAlertsEnabled,
connectors,
startTransaction,
appId,
attachments,
postCase,
owner,
afterCaseCreated,
onSuccess,
attachments,
createAttachments,
pushCaseToExternalService,
]

View file

@ -46,6 +46,7 @@ export const casesQueriesKeys = {
};
export const casesMutationsKeys = {
createCase: ['create-case'] as const,
deleteCases: ['delete-cases'] as const,
updateCases: ['update-cases'] as const,
deleteComment: ['delete-comment'] as const,

View file

@ -13,6 +13,10 @@ export const ERROR_TITLE = i18n.translate('xpack.cases.containers.errorTitle', {
defaultMessage: 'Error fetching data',
});
export const ERROR_CREATING_CASE = i18n.translate('xpack.cases.containers.errorCreatingCaseTitle', {
defaultMessage: 'Error creating case',
});
export const ERROR_DELETING = i18n.translate('xpack.cases.containers.errorDeletingTitle', {
defaultMessage: 'Error deleting data',
});

View file

@ -6,18 +6,19 @@
*/
import { renderHook, act } from '@testing-library/react-hooks';
import type { UsePostCase } from './use_post_case';
import { usePostCase } from './use_post_case';
import * as api from './api';
import { ConnectorTypes } from '../../common/api';
import { SECURITY_SOLUTION_OWNER } from '../../common/constants';
import { basicCasePost } from './mock';
import { useToasts } from '../common/lib/kibana';
import type { AppMockRenderer } from '../common/mock';
import { createAppMockRenderer } from '../common/mock';
import { usePostCase } from './use_post_case';
import { casesQueriesKeys } from './constants';
jest.mock('./api');
jest.mock('../common/lib/kibana');
describe('usePostCase', () => {
const abortCtrl = new AbortController();
const samplePost = {
description: 'description',
tags: ['tags'],
@ -33,86 +34,84 @@ describe('usePostCase', () => {
},
owner: SECURITY_SOLUTION_OWNER,
};
beforeEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
it('init', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UsePostCase>(() => usePostCase());
await waitForNextUpdate();
expect(result.current).toEqual({
isLoading: false,
isError: false,
postCase: result.current.postCase,
});
});
const abortCtrl = new AbortController();
const addSuccess = jest.fn();
const addError = jest.fn();
(useToasts as jest.Mock).mockReturnValue({ addSuccess, addError });
let appMockRender: AppMockRenderer;
beforeEach(() => {
appMockRender = createAppMockRenderer();
jest.clearAllMocks();
});
it('calls postCase with correct arguments', async () => {
const spyOnPostCase = jest.spyOn(api, 'postCase');
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UsePostCase>(() => usePostCase());
await waitForNextUpdate();
result.current.postCase(samplePost);
await waitForNextUpdate();
expect(spyOnPostCase).toBeCalledWith(samplePost, abortCtrl.signal);
it('calls the api when invoked with the correct parameters', async () => {
const spy = jest.spyOn(api, 'postCase');
const { waitForNextUpdate, result } = renderHook(() => usePostCase(), {
wrapper: appMockRender.AppWrapper,
});
act(() => {
result.current.mutate({ request: samplePost });
});
await waitForNextUpdate();
expect(spy).toHaveBeenCalledWith(samplePost, abortCtrl.signal);
});
it('calls postCase with correct result', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UsePostCase>(() => usePostCase());
await waitForNextUpdate();
const postData = await result.current.postCase(samplePost);
expect(postData).toEqual(basicCasePost);
it('invalidates the queries correctly', async () => {
const queryClientSpy = jest.spyOn(appMockRender.queryClient, 'invalidateQueries');
const { waitForNextUpdate, result } = renderHook(() => usePostCase(), {
wrapper: appMockRender.AppWrapper,
});
act(() => {
result.current.mutate({ request: samplePost });
});
await waitForNextUpdate();
expect(queryClientSpy).toHaveBeenCalledWith(casesQueriesKeys.casesList());
expect(queryClientSpy).toHaveBeenCalledWith(casesQueriesKeys.tags());
expect(queryClientSpy).toHaveBeenCalledWith(casesQueriesKeys.userProfiles());
});
it('post case', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UsePostCase>(() => usePostCase());
await waitForNextUpdate();
result.current.postCase(samplePost);
await waitForNextUpdate();
expect(result.current).toEqual({
isLoading: false,
isError: false,
postCase: result.current.postCase,
});
it('does not show a success toaster', async () => {
const { waitForNextUpdate, result } = renderHook(() => usePostCase(), {
wrapper: appMockRender.AppWrapper,
});
act(() => {
result.current.mutate({ request: samplePost });
});
await waitForNextUpdate();
expect(addSuccess).not.toHaveBeenCalled();
});
it('set isLoading to true when posting case', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UsePostCase>(() => usePostCase());
await waitForNextUpdate();
result.current.postCase(samplePost);
it('shows a toast error when the api return an error', async () => {
jest.spyOn(api, 'postCase').mockRejectedValue(new Error('usePostCase: Test error'));
expect(result.current.isLoading).toBe(true);
});
});
it('unhappy path', async () => {
const spyOnPostCase = jest.spyOn(api, 'postCase');
spyOnPostCase.mockImplementation(() => {
throw new Error('Something went wrong');
const { waitForNextUpdate, result } = renderHook(() => usePostCase(), {
wrapper: appMockRender.AppWrapper,
});
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UsePostCase>(() => usePostCase());
await waitForNextUpdate();
result.current.postCase(samplePost);
expect(result.current).toEqual({
isLoading: false,
isError: true,
postCase: result.current.postCase,
});
act(() => {
result.current.mutate({ request: samplePost });
});
await waitForNextUpdate();
expect(addError).toHaveBeenCalled();
});
});

View file

@ -5,87 +5,38 @@
* 2.0.
*/
import { useReducer, useCallback, useRef, useEffect } from 'react';
import { useMutation } from '@tanstack/react-query';
import type { CasePostRequest } from '../../common/api';
import { postCase } from './api';
import * as i18n from './translations';
import type { Case } from './types';
import { useToasts } from '../common/lib/kibana';
interface NewCaseState {
isLoading: boolean;
isError: boolean;
import { useCasesToast } from '../common/use_cases_toast';
import type { ServerError } from '../types';
import { casesMutationsKeys } from './constants';
import { useRefreshCases } from '../components/all_cases/use_on_refresh_cases';
interface MutationArgs {
request: CasePostRequest;
}
type Action = { type: 'FETCH_INIT' } | { type: 'FETCH_SUCCESS' } | { type: 'FETCH_FAILURE' };
const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => {
switch (action.type) {
case 'FETCH_INIT':
return {
...state,
isLoading: true,
isError: false,
};
case 'FETCH_SUCCESS':
return {
...state,
isLoading: false,
isError: false,
};
case 'FETCH_FAILURE':
return {
...state,
isLoading: false,
isError: true,
};
default:
return state;
}
};
export interface UsePostCase extends NewCaseState {
postCase: (data: CasePostRequest) => Promise<Case | undefined>;
}
export const usePostCase = (): UsePostCase => {
const [state, dispatch] = useReducer(dataFetchReducer, {
isLoading: false,
isError: false,
});
const toasts = useToasts();
const isCancelledRef = useRef(false);
const abortCtrlRef = useRef(new AbortController());
export const usePostCase = () => {
const { showErrorToast } = useCasesToast();
const refreshCases = useRefreshCases();
const postMyCase = useCallback(async (data: CasePostRequest) => {
try {
isCancelledRef.current = false;
abortCtrlRef.current.abort();
abortCtrlRef.current = new AbortController();
dispatch({ type: 'FETCH_INIT' });
const response = await postCase(data, abortCtrlRef.current.signal);
if (!isCancelledRef.current) {
dispatch({ type: 'FETCH_SUCCESS' });
}
return response;
} catch (error) {
if (!isCancelledRef.current) {
if (error.name !== 'AbortError') {
toasts.addError(
error.body && error.body.message ? new Error(error.body.message) : error,
{ title: i18n.ERROR_TITLE }
);
}
dispatch({ type: 'FETCH_FAILURE' });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(
() => () => {
isCancelledRef.current = true;
abortCtrlRef.current.abort();
return useMutation(
({ request }: MutationArgs) => {
const abortCtrlRef = new AbortController();
return postCase(request, abortCtrlRef.signal);
},
[]
{
mutationKey: casesMutationsKeys.createCase,
onSuccess: () => {
refreshCases();
},
onError: (error: ServerError) => {
showErrorToast(error, { title: i18n.ERROR_CREATING_CASE });
},
}
);
return { ...state, postCase: postMyCase };
};
export type UsePostCase = ReturnType<typeof usePostCase>;

View file

@ -36,7 +36,7 @@ export const useCasesModal = <EmbeddableType extends MlEmbeddableTypes>(
}
selectCaseModal.open({
attachments: [
getAttachments: () => [
{
type: CommentType.persistableState,
persistableStateAttachmentTypeId: embeddableType,

View file

@ -8,7 +8,7 @@
import React from 'react';
import { fireEvent } from '@testing-library/react';
import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks';
import { casesPluginMock, openAddToExistingCaseModalMock } from '@kbn/cases-plugin/public/mocks';
import { casesPluginMock } from '@kbn/cases-plugin/public/mocks';
import { render } from '../../../utils/test_helper';
import { useKibana } from '../../../utils/kibana_react';
@ -16,17 +16,19 @@ import { kibanaStartMock } from '../../../utils/kibana_react.mock';
import { alertWithTags, mockAlertUuid } from '../mock/alert';
import { HeaderActions } from './header_actions';
import { CasesUiStart } from '@kbn/cases-plugin/public';
jest.mock('../../../utils/kibana_react');
const useKibanaMock = useKibana as jest.Mock;
const mockCases = casesPluginMock.createStartContract();
const mockKibana = () => {
useKibanaMock.mockReturnValue({
services: {
...kibanaStartMock.startContract(),
triggersActionsUi: triggersActionsUiMock.createStart(),
cases: casesPluginMock.createStartContract(),
cases: mockCases,
},
});
};
@ -59,25 +61,33 @@ describe('Header Actions', () => {
describe('when clicking the actions button', () => {
it('should offer an "add to case" button which opens the add to case modal', async () => {
let attachments: any[] = [];
const useCasesAddToExistingCaseModalMock: any = jest.fn().mockImplementation(() => ({
open: ({ getAttachments }: { getAttachments: () => any[] }) => {
attachments = getAttachments();
},
})) as CasesUiStart['hooks']['useCasesAddToExistingCaseModal'];
mockCases.hooks.useCasesAddToExistingCaseModal = useCasesAddToExistingCaseModalMock;
const { getByTestId, findByRole } = render(<HeaderActions alert={alertWithTags} />);
fireEvent.click(await findByRole('button', { name: 'Actions' }));
fireEvent.click(getByTestId('add-to-case-button'));
expect(openAddToExistingCaseModalMock).toBeCalledWith({
attachments: [
{
alertId: mockAlertUuid,
index: '.internal.alerts-observability.metrics.alerts-*',
rule: {
id: ruleId,
name: ruleName,
},
type: 'alert',
expect(attachments).toEqual([
{
alertId: mockAlertUuid,
index: '.internal.alerts-observability.metrics.alerts-*',
rule: {
id: ruleId,
name: ruleName,
},
],
});
type: 'alert',
},
]);
});
});
});

View file

@ -62,7 +62,7 @@ export function HeaderActions({ alert }: HeaderActionsProps) {
const handleAddToCase = () => {
setIsPopoverOpen(false);
selectCaseModal.open({ attachments });
selectCaseModal.open({ getAttachments: () => attachments });
};
const handleViewRuleDetails = () => {

View file

@ -119,7 +119,7 @@ export function AlertActions({
};
const handleAddToExistingCaseClick = () => {
selectCaseModal.open({ attachments: caseAttachments });
selectCaseModal.open({ getAttachments: () => caseAttachments });
closeActionsPopover();
};

View file

@ -76,7 +76,7 @@ export const AddToCaseButton: React.FC<AddToCaseButtonProps> = ({
},
];
if (hasCasesPermissions) {
selectCaseModal.open({ attachments });
selectCaseModal.open({ getAttachments: () => attachments });
}
}, [actionId, agentIds, alertAttachments, hasCasesPermissions, queryId, selectCaseModal]);

View file

@ -59,7 +59,9 @@ describe('useAddToExistingCase', () => {
);
expect(mockGetUseCasesAddToExistingCaseModal).toHaveBeenCalledWith({
onClose: mockOnAddToCaseClicked,
toastContent: 'Successfully added visualization to the case',
successToaster: {
title: 'Successfully added visualization to the case',
},
});
expect(result.current.disabled).toEqual(false);
});

View file

@ -37,14 +37,16 @@ export const useAddToExistingCase = ({
const selectCaseModal = cases.hooks.useCasesAddToExistingCaseModal({
onClose: onAddToCaseClicked,
toastContent: ADD_TO_CASE_SUCCESS,
successToaster: {
title: ADD_TO_CASE_SUCCESS,
},
});
const onAddToExistingCaseClicked = useCallback(() => {
if (onAddToCaseClicked) {
onAddToCaseClicked();
}
selectCaseModal.open({ attachments });
selectCaseModal.open({ getAttachments: () => attachments });
}, [attachments, onAddToCaseClicked, selectCaseModal]);
return {

View file

@ -127,7 +127,7 @@ export const useAddToCaseActions = ({
const handleAddToExistingCaseClick = useCallback(() => {
// TODO rename this, this is really `closePopover()`
onMenuItemClick();
selectCaseModal.open({ attachments: caseAttachments });
selectCaseModal.open({ getAttachments: () => caseAttachments });
}, [caseAttachments, onMenuItemClick, selectCaseModal]);
const addToCaseActionItems = useMemo(() => {

View file

@ -102,7 +102,7 @@ export const CasesPanel = React.memo<CasesPanelProps>(
const addToExistingCase = useCallback(() => {
if (userCasesPermissions.update) {
selectCaseModal.open({ attachments: caseAttachments });
selectCaseModal.open({ getAttachments: () => caseAttachments });
}
}, [caseAttachments, selectCaseModal, userCasesPermissions.update]);

View file

@ -59,7 +59,7 @@ export const AddToExistingCase: VFC<AddToExistingCaseProps> = ({
);
const menuItemClicked = () => {
onClick();
selectCaseModal.open({ attachments });
selectCaseModal.open({ getAttachments: () => attachments });
};
const disabled: boolean = useCaseDisabled(attachmentMetadata.indicatorName);

View file

@ -267,6 +267,78 @@ describe('AlertsTable.BulkActions', () => {
const { queryByTestId } = render(<AlertsTableWithBulkActionsContext {...tableProps} />);
expect(queryByTestId('bulk-actions-header')).toBeNull();
});
it('should pass the case ids when selecting alerts', async () => {
const mockedFn = jest.fn();
const newAlertsData = {
...alertsData,
alerts: [
{
[AlertsField.name]: ['one'],
[AlertsField.reason]: ['two'],
[AlertsField.uuid]: ['uuidone'],
[AlertsField.case_ids]: ['test-case'],
_id: 'alert0',
_index: 'idx0',
},
] as unknown as Alerts,
};
const props = {
...tablePropsWithBulkActions,
useFetchAlertsData: () => newAlertsData,
initialBulkActionsState: {
...defaultBulkActionsState,
isAllSelected: true,
rowCount: 1,
rowSelection: new Map([[0, { isLoading: false }]]),
},
alertsTableConfiguration: {
...alertsTableConfiguration,
useBulkActions: () => [
{
label: 'Fake Bulk Action',
key: 'fakeBulkAction',
'data-test-subj': 'fake-bulk-action',
disableOnQuery: false,
onClick: mockedFn,
},
],
},
};
render(<AlertsTableWithBulkActionsContext {...props} />);
fireEvent.click(await screen.findByTestId('selectedShowBulkActionsButton'));
await waitForEuiPopoverOpen();
fireEvent.click(await screen.findByText('Fake Bulk Action'));
expect(mockedFn.mock.calls[0][0]).toEqual([
{
_id: 'alert0',
_index: 'idx0',
data: [
{
field: 'kibana.alert.rule.name',
value: ['one'],
},
{
field: 'kibana.alert.rule.uuid',
value: ['uuidone'],
},
{
field: 'kibana.alert.case_ids',
value: ['test-case'],
},
],
ecs: {
_id: 'alert0',
_index: 'idx0',
},
},
]);
});
});
describe('when the bulk action hook is set', () => {
@ -493,6 +565,10 @@ describe('AlertsTable.BulkActions', () => {
field: 'kibana.alert.rule.uuid',
value: ['uuidtwo'],
},
{
field: 'kibana.alert.case_ids',
value: [],
},
],
ecs: {
_id: 'alert1',

View file

@ -9,7 +9,7 @@ import { EuiPopover, EuiButtonEmpty, EuiContextMenuPanel, EuiContextMenuItem } f
import numeral from '@elastic/numeral';
import React, { useState, useCallback, useMemo, useContext, useEffect } from 'react';
import { useUiSetting$ } from '@kbn/kibana-react-plugin/public';
import { ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils';
import { ALERT_CASE_IDS, ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils';
import { Alerts, BulkActionsConfig, BulkActionsVerbs, RowSelection } from '../../../../../types';
import * as i18n from '../translations';
import { BulkActionsContext } from '../context';
@ -52,6 +52,7 @@ const selectedIdsToTimelineItemMapper = (
data: [
{ field: ALERT_RULE_NAME, value: alert[ALERT_RULE_NAME] },
{ field: ALERT_RULE_UUID, value: alert[ALERT_RULE_UUID] },
{ field: ALERT_CASE_IDS, value: alert[ALERT_CASE_IDS] ?? [] },
],
ecs: {
_id: alert._id,

View file

@ -41,3 +41,17 @@ export const ADD_TO_CASE_DISABLED = i18n.translate(
defaultMessage: 'Add to case is not supported for this selection',
}
);
export const NO_ALERTS_ADDED_TO_CASE = i18n.translate(
'xpack.triggersActionsUI.alerts.table.actions.noAlertsAddedToCaseTitle',
{
defaultMessage: 'No alerts added to the case',
}
);
export const ALERTS_ALREADY_ATTACHED_TO_CASE = i18n.translate(
'xpack.triggersActionsUI.alerts.table.actions.alertsAlreadyAttachedToCase',
{
defaultMessage: 'All selected alerts are already attached to the case',
}
);

View file

@ -29,6 +29,8 @@ jest.mock('@kbn/kibana-react-plugin/public', () => {
};
});
const caseId = 'test-case';
describe('bulk action hooks', () => {
const casesConfig = { featureId: 'test-feature-id', owner: ['test-owner'] };
let appMockRender: AppMockRenderer;
@ -40,17 +42,20 @@ describe('bulk action hooks', () => {
const refresh = jest.fn();
const clearSelection = jest.fn();
const open = jest.fn();
const openNewCase = jest.fn();
const openExistingCase = jest.fn().mockImplementation(({ getAttachments }) => {
getAttachments({ theCase: { id: caseId } });
});
mockCaseService.helpers.canUseCases = jest.fn().mockReturnValue({ create: true, read: true });
mockCaseService.ui.getCasesContext = jest.fn().mockReturnValue(() => 'Cases context');
const addNewCaseMock = (
mockCaseService.hooks.useCasesAddToNewCaseFlyout as jest.Mock
).mockReturnValue({ open });
).mockReturnValue({ open: openNewCase });
const addExistingCaseMock = (
mockCaseService.hooks.useCasesAddToExistingCaseModal as jest.Mock
).mockReturnValue({ open });
).mockReturnValue({ open: openExistingCase });
describe('useBulkAddToCaseActions', () => {
beforeEach(() => {
@ -75,6 +80,20 @@ describe('bulk action hooks', () => {
expect(refresh).toHaveBeenCalled();
});
it('should useCasesAddToExistingCaseModal with correct toaster params', async () => {
renderHook(() => useBulkAddToCaseActions({ casesConfig, refresh, clearSelection }), {
wrapper: appMockRender.AppWrapper,
});
expect(addExistingCaseMock).toHaveBeenCalledWith({
noAttachmentsToaster: {
title: 'No alerts added to the case',
content: 'All selected alerts are already attached to the case',
},
onSuccess: expect.anything(),
});
});
it('should open the case flyout', async () => {
const { result } = renderHook(
() => useBulkAddToCaseActions({ casesConfig, refresh, clearSelection }),
@ -87,7 +106,7 @@ describe('bulk action hooks', () => {
result.current[0].onClick([]);
expect(mockCaseService.helpers.groupAlertsByRule).toHaveBeenCalled();
expect(open).toHaveBeenCalled();
expect(openNewCase).toHaveBeenCalled();
});
it('should open the case modal', async () => {
@ -102,7 +121,65 @@ describe('bulk action hooks', () => {
result.current[1].onClick([]);
expect(mockCaseService.helpers.groupAlertsByRule).toHaveBeenCalled();
expect(open).toHaveBeenCalled();
expect(openExistingCase).toHaveBeenCalled();
});
it('should remove alerts that are already attached to the case', async () => {
const { result } = renderHook(
() => useBulkAddToCaseActions({ casesConfig, refresh, clearSelection }),
{
wrapper: appMockRender.AppWrapper,
}
);
// @ts-expect-error: cases do not need all arguments
result.current[1].onClick([
{
_id: 'alert0',
_index: 'idx0',
data: [
{
field: 'kibana.alert.case_ids',
value: [caseId],
},
],
ecs: {
_id: 'alert0',
_index: 'idx0',
},
},
{
_id: 'alert1',
_index: 'idx1',
data: [
{
field: 'kibana.alert.case_ids',
value: ['test-case-2'],
},
],
ecs: {
_id: 'alert1',
_index: 'idx1',
},
},
]);
expect(mockCaseService.helpers.groupAlertsByRule).toHaveBeenCalledWith([
{
_id: 'alert1',
_index: 'idx1',
data: [
{
field: 'kibana.alert.case_ids',
value: ['test-case-2'],
},
],
ecs: {
_id: 'alert1',
_index: 'idx1',
},
},
]);
});
it('should not show the bulk actions when the user does not have write access', async () => {

View file

@ -7,6 +7,7 @@
import { useCallback, useContext, useEffect, useMemo } from 'react';
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { ALERT_CASE_IDS } from '@kbn/rule-data-utils';
import {
Alerts,
AlertsTableConfigurationRegistry,
@ -21,7 +22,14 @@ import {
GetLeadingControlColumn,
} from '../bulk_actions/get_leading_control_column';
import { CasesService } from '../types';
import { ADD_TO_CASE_DISABLED, ADD_TO_EXISTING_CASE, ADD_TO_NEW_CASE } from './translations';
import {
ADD_TO_CASE_DISABLED,
ADD_TO_EXISTING_CASE,
ADD_TO_NEW_CASE,
ALERTS_ALREADY_ATTACHED_TO_CASE,
NO_ALERTS_ADDED_TO_CASE,
} from './translations';
import { TimelineItem } from '../bulk_actions/components/toolbar';
interface BulkActionsProps {
query: Pick<QueryDslQueryContainer, 'bool' | 'ids'>;
@ -43,6 +51,27 @@ export interface UseBulkActions {
type UseBulkAddToCaseActionsProps = Pick<BulkActionsProps, 'casesConfig' | 'refresh'> &
Pick<UseBulkActions, 'clearSelection'>;
const filterAlertsAlreadyAttachedToCase = (alerts: TimelineItem[], caseId: string) =>
alerts.filter(
(alert) =>
!alert.data.some(
(field) => field.field === ALERT_CASE_IDS && field.value?.some((id) => id === caseId)
)
);
const getCaseAttachments = ({
alerts,
caseId,
groupAlertsByRule,
}: {
caseId: string;
groupAlertsByRule?: CasesService['helpers']['groupAlertsByRule'];
alerts?: TimelineItem[];
}) => {
const filteredAlerts = filterAlertsAlreadyAttachedToCase(alerts ?? [], caseId);
return groupAlertsByRule?.(filteredAlerts) ?? [];
};
export const useBulkAddToCaseActions = ({
casesConfig,
refresh,
@ -60,7 +89,13 @@ export const useBulkAddToCaseActions = ({
}, [clearSelection, refresh]);
const createCaseFlyout = casesService?.hooks.useCasesAddToNewCaseFlyout({ onSuccess });
const selectCaseModal = casesService?.hooks.useCasesAddToExistingCaseModal({ onSuccess });
const selectCaseModal = casesService?.hooks.useCasesAddToExistingCaseModal({
onSuccess,
noAttachmentsToaster: {
title: NO_ALERTS_ADDED_TO_CASE,
content: ALERTS_ALREADY_ATTACHED_TO_CASE,
},
});
return useMemo(() => {
return isCasesContextAvailable &&
@ -75,9 +110,9 @@ export const useBulkAddToCaseActions = ({
'data-test-subj': 'attach-new-case',
disableOnQuery: true,
disabledLabel: ADD_TO_CASE_DISABLED,
onClick: (items?: any[]) => {
const caseAttachments = items
? casesService?.helpers.groupAlertsByRule(items) ?? []
onClick: (alerts?: TimelineItem[]) => {
const caseAttachments = alerts
? casesService?.helpers.groupAlertsByRule(alerts) ?? []
: [];
createCaseFlyout.open({
@ -91,13 +126,15 @@ export const useBulkAddToCaseActions = ({
disableOnQuery: true,
disabledLabel: ADD_TO_CASE_DISABLED,
'data-test-subj': 'attach-existing-case',
onClick: (items?: any[]) => {
const caseAttachments = items
? casesService?.helpers.groupAlertsByRule(items) ?? []
: [];
onClick: (alerts?: TimelineItem[]) => {
selectCaseModal.open({
attachments: caseAttachments,
getAttachments: ({ theCase }) => {
return getCaseAttachments({
alerts,
caseId: theCase.id,
groupAlertsByRule: casesService?.helpers.groupAlertsByRule,
});
},
});
},
},

View file

@ -32,18 +32,27 @@ export interface SystemCellComponentMap {
export type SystemCellId = keyof SystemCellComponentMap;
type CaseHooks = (props?: Record<string, unknown>) => {
type UseCasesAddToNewCaseFlyout = (props?: Record<string, unknown>) => {
open: ({ attachments }: { attachments: any[] }) => void;
close: () => void;
};
type UseCasesAddToExistingCaseModal = (props?: Record<string, unknown>) => {
open: ({
getAttachments,
}: {
getAttachments: ({ theCase }: { theCase: { id: string } }) => any[];
}) => void;
close: () => void;
};
export interface CasesService {
ui: {
getCasesContext: () => React.FC<any>;
};
hooks: {
useCasesAddToNewCaseFlyout: CaseHooks;
useCasesAddToExistingCaseModal: CaseHooks;
useCasesAddToNewCaseFlyout: UseCasesAddToNewCaseFlyout;
useCasesAddToExistingCaseModal: UseCasesAddToExistingCaseModal;
};
helpers: {
groupAlertsByRule: (items?: any[]) => any[];

View file

@ -457,6 +457,7 @@ export enum AlertsField {
name = 'kibana.alert.rule.name',
reason = 'kibana.alert.reason',
uuid = 'kibana.alert.rule.uuid',
case_ids = 'kibana.alert.case_ids',
}
export interface InspectQuery {

View file

@ -77,7 +77,7 @@ const CasesFixtureAppWithContext: React.FC<CasesFixtureAppDeps> = (props) => {
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={() => selectCaseModal.open({ attachments })}
onClick={() => selectCaseModal.open({ getAttachments: () => attachments })}
data-test-subj="case-fixture-attach-to-existing-case"
>
{'Attach to an existing case'}