mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
70500d7cd9
commit
9cc51bf65b
30 changed files with 566 additions and 266 deletions
|
@ -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'));
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -36,7 +36,7 @@ export const useCasesModal = <EmbeddableType extends MlEmbeddableTypes>(
|
|||
}
|
||||
|
||||
selectCaseModal.open({
|
||||
attachments: [
|
||||
getAttachments: () => [
|
||||
{
|
||||
type: CommentType.persistableState,
|
||||
persistableStateAttachmentTypeId: embeddableType,
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -62,7 +62,7 @@ export function HeaderActions({ alert }: HeaderActionsProps) {
|
|||
|
||||
const handleAddToCase = () => {
|
||||
setIsPopoverOpen(false);
|
||||
selectCaseModal.open({ attachments });
|
||||
selectCaseModal.open({ getAttachments: () => attachments });
|
||||
};
|
||||
|
||||
const handleViewRuleDetails = () => {
|
||||
|
|
|
@ -119,7 +119,7 @@ export function AlertActions({
|
|||
};
|
||||
|
||||
const handleAddToExistingCaseClick = () => {
|
||||
selectCaseModal.open({ attachments: caseAttachments });
|
||||
selectCaseModal.open({ getAttachments: () => caseAttachments });
|
||||
closeActionsPopover();
|
||||
};
|
||||
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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'}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue