diff --git a/x-pack/platform/packages/shared/response-ops/alerts-table/components/closing_reason/closing_reason_panel.tsx b/x-pack/platform/packages/shared/response-ops/alerts-table/components/closing_reason/closing_reason_panel.tsx new file mode 100644 index 0000000000000..c84425057ccd3 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerts-table/components/closing_reason/closing_reason_panel.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback, useMemo, useState } from 'react'; +import { EuiButton, EuiSelectable } from '@elastic/eui'; +import type { EuiSelectableOption } from '@elastic/eui'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; +import * as i18n from './translations'; + +const CUSTOM_ALERT_CLOSE_REASONS_SETTING_KEY = 'securitySolution:alertCloseReasons'; + +interface ClosingReasonOption { + key?: string; +} + +const defaultClosingReasons: Array> = [ + { label: i18n.CLOSING_REASON_CLOSE_WITHOUT_REASON, key: undefined }, + { label: i18n.CLOSING_REASON_DUPLICATE, key: 'duplicate' }, + { label: i18n.CLOSING_REASON_FALSE_POSITIVE, key: 'false_positive' }, + { label: i18n.CLOSING_REASON_TRUE_POSITIVE, key: 'true_positive' }, + { label: i18n.CLOSING_REASON_BENIGN_POSITIVE, key: 'benign_positive' }, + { label: i18n.CLOSING_REASON_OTHER, key: 'other' }, +]; + +export interface ClosingReasonPanelProps { + onSubmit: (reason?: string) => void; +} + +const ClosingReasonPanelComponent: React.FC = ({ onSubmit }) => { + const { + services: { uiSettings }, + } = useKibana<{ uiSettings: IUiSettingsClient }>(); + + const customClosingReasons = + uiSettings.get(CUSTOM_ALERT_CLOSE_REASONS_SETTING_KEY) ?? []; + + const [options, setOptions] = useState>>([ + ...defaultClosingReasons, + ...customClosingReasons.map((reason) => ({ label: reason, key: reason })), + ]); + + const selectedOption = useMemo(() => options.find((option) => option.checked), [options]); + + const onSubmitHandler = useCallback(() => { + if (!selectedOption) { + return; + } + + onSubmit(selectedOption.key); + }, [onSubmit, selectedOption]); + + return ( + <> + + {(list) => list} + + + {i18n.CLOSING_REASON_BUTTON_MESSAGE} + + + ); +}; + +export const ClosingReasonPanel = memo(ClosingReasonPanelComponent); diff --git a/x-pack/platform/packages/shared/response-ops/alerts-table/components/closing_reason/translations.ts b/x-pack/platform/packages/shared/response-ops/alerts-table/components/closing_reason/translations.ts new file mode 100644 index 0000000000000..3cac408896e5c --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerts-table/components/closing_reason/translations.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const BULK_ACTION_CLOSE_SELECTED = i18n.translate( + 'xpack.responseOps.alertsTable.bulkActions.closeSelectedTitle', + { + defaultMessage: 'Mark as closed', + } +); + +export const CLOSING_REASON_MENU_TITLE = i18n.translate( + 'xpack.responseOps.alertsTable.bulkActions.closingReason.menuTitle', + { + defaultMessage: 'Reason for closing', + } +); + +export const CLOSING_REASON_BUTTON_MESSAGE = i18n.translate( + 'xpack.responseOps.alertsTable.bulkActions.closingReason.buttonMessage', + { + defaultMessage: 'Close', + } +); + +export const CLOSING_REASON_DUPLICATE = i18n.translate( + 'xpack.responseOps.alertsTable.defaultClosingReason.duplicate', + { + defaultMessage: 'Duplicate', + } +); + +export const CLOSING_REASON_FALSE_POSITIVE = i18n.translate( + 'xpack.responseOps.alertsTable.defaultClosingReason.falsePositive', + { + defaultMessage: 'False Positive', + } +); + +export const CLOSING_REASON_CLOSE_WITHOUT_REASON = i18n.translate( + 'xpack.responseOps.alertsTable.defaultClosingReason.closeWithoutReason', + { + defaultMessage: 'Close without reason', + } +); + +export const CLOSING_REASON_TRUE_POSITIVE = i18n.translate( + 'xpack.responseOps.alertsTable.defaultClosingReason.truePositive', + { + defaultMessage: 'True positive', + } +); + +export const CLOSING_REASON_BENIGN_POSITIVE = i18n.translate( + 'xpack.responseOps.alertsTable.defaultClosingReason.benignPositive', + { + defaultMessage: 'Benign positive', + } +); + +export const CLOSING_REASON_OTHER = i18n.translate( + 'xpack.responseOps.alertsTable.defaultClosingReason.other', + { + defaultMessage: 'Other', + } +); diff --git a/x-pack/platform/packages/shared/response-ops/alerts-table/components/closing_reason/use_bulk_closing_reason_items.test.tsx b/x-pack/platform/packages/shared/response-ops/alerts-table/components/closing_reason/use_bulk_closing_reason_items.test.tsx new file mode 100644 index 0000000000000..40b298ca9e6f6 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerts-table/components/closing_reason/use_bulk_closing_reason_items.test.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react'; +import { + ALERT_CLOSING_REASON_PANEL_ID, + useBulkClosingReasonItems, +} from './use_bulk_closing_reason_items'; + +describe('useBulkClosingReasonItems', () => { + it('returns one item and one panel when enabled', () => { + const { result } = renderHook(() => + useBulkClosingReasonItems({ + isEnabled: true, + }) + ); + + expect(result.current.item?.panel).toBe(ALERT_CLOSING_REASON_PANEL_ID); + expect(result.current.panels.length).toBe(1); + }); + + it('returns no item and no panels when disabled', () => { + const { result } = renderHook(() => + useBulkClosingReasonItems({ + isEnabled: false, + }) + ); + + expect(result.current.item).toBeUndefined(); + expect(result.current.panels).toEqual([]); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerts-table/components/closing_reason/use_bulk_closing_reason_items.tsx b/x-pack/platform/packages/shared/response-ops/alerts-table/components/closing_reason/use_bulk_closing_reason_items.tsx new file mode 100644 index 0000000000000..d02dfd9613dcf --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerts-table/components/closing_reason/use_bulk_closing_reason_items.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo } from 'react'; +import type { ContentPanelConfig, RenderContentPanelProps } from '../../types'; +import { ClosingReasonPanel } from './closing_reason_panel'; +import * as i18n from './translations'; + +export const ALERT_CLOSING_REASON_PANEL_ID = 'ALERT_CLOSING_REASON_PANEL_ID'; + +export interface OnSubmitClosingReasonParams extends RenderContentPanelProps { + /** + * The reason the item(s) are being closed + */ + reason?: string; +} + +export interface UseBulkClosingReasonItemsProps { + /** + * Whether the closing reason action should be shown + */ + isEnabled?: boolean; + /** + * Called once the user confirms the closing reason + */ + onSubmitCloseReason?: (params: OnSubmitClosingReasonParams) => void; +} + +/** + * Returns menu items and panels to be used in a EuiContextMenu component + */ +export const useBulkClosingReasonItems = ({ + isEnabled = true, + onSubmitCloseReason, +}: UseBulkClosingReasonItemsProps = {}) => { + const item = useMemo( + () => + isEnabled + ? { + key: 'close-alert-with-reason', + 'data-test-subj': 'alert-close-context-menu-item', + label: i18n.BULK_ACTION_CLOSE_SELECTED, + panel: ALERT_CLOSING_REASON_PANEL_ID, + } + : undefined, + [isEnabled] + ); + + const getRenderContent = useCallback( + ({ + onSubmitCloseReason: onSubmitCloseReasonCb, + }: { + onSubmitCloseReason?: UseBulkClosingReasonItemsProps['onSubmitCloseReason']; + }): ContentPanelConfig['renderContent'] => { + return (renderProps: RenderContentPanelProps) => { + const handleSubmit = (reason?: string) => { + if (onSubmitCloseReasonCb) { + onSubmitCloseReasonCb({ + ...renderProps, + reason, + }); + return; + } + + renderProps.closePopoverMenu(); + }; + + return ; + }; + }, + [] + ); + + const getPanel = useCallback( + ({ + onSubmitCloseReason: onSubmitCloseReasonCb, + }: { + onSubmitCloseReason?: UseBulkClosingReasonItemsProps['onSubmitCloseReason']; + }): ContentPanelConfig => ({ + id: ALERT_CLOSING_REASON_PANEL_ID, + title: i18n.CLOSING_REASON_MENU_TITLE, + renderContent: getRenderContent({ onSubmitCloseReason: onSubmitCloseReasonCb }), + }), + [getRenderContent] + ); + + const panels = useMemo( + () => (isEnabled ? [getPanel({ onSubmitCloseReason })] : []), + [isEnabled, getPanel, onSubmitCloseReason] + ); + + const getPanels = useCallback( + ({ + onSubmitCloseReason: onSubmitCloseReasonCb, + }: { + onSubmitCloseReason?: UseBulkClosingReasonItemsProps['onSubmitCloseReason']; + }) => (isEnabled ? [getPanel({ onSubmitCloseReason: onSubmitCloseReasonCb })] : []), + [getPanel, isEnabled] + ); + + return useMemo( + () => ({ + item, + panels, + getPanels, + }), + [item, panels, getPanels] + ); +}; diff --git a/x-pack/platform/packages/shared/response-ops/alerts-table/index.ts b/x-pack/platform/packages/shared/response-ops/alerts-table/index.ts index 0eafa227e37c9..ab04c7102e029 100644 --- a/x-pack/platform/packages/shared/response-ops/alerts-table/index.ts +++ b/x-pack/platform/packages/shared/response-ops/alerts-table/index.ts @@ -7,6 +7,14 @@ import { AlertsTable } from './components/alerts_table'; export { AlertsTable } from './components/alerts_table'; +export { + ALERT_CLOSING_REASON_PANEL_ID, + useBulkClosingReasonItems, +} from './components/closing_reason/use_bulk_closing_reason_items'; +export type { + OnSubmitClosingReasonParams, + UseBulkClosingReasonItemsProps, +} from './components/closing_reason/use_bulk_closing_reason_items'; // Lazy load helper // eslint-disable-next-line import/no-default-export export default AlertsTable; diff --git a/x-pack/platform/packages/shared/response-ops/alerts-table/tsconfig.json b/x-pack/platform/packages/shared/response-ops/alerts-table/tsconfig.json index 1ae079789b4f9..4a0ee7d8b0a5e 100644 --- a/x-pack/platform/packages/shared/response-ops/alerts-table/tsconfig.json +++ b/x-pack/platform/packages/shared/response-ops/alerts-table/tsconfig.json @@ -34,6 +34,7 @@ "@kbn/core-http-browser-mocks", "@kbn/core-application-browser-mocks", "@kbn/response-ops-alerts-apis", + "@kbn/kibana-react-plugin", "@kbn/core-notifications-browser-mocks", "@kbn/licensing-plugin", "@kbn/core-application-browser", diff --git a/x-pack/platform/plugins/shared/cases/common/types/api/case/v1.ts b/x-pack/platform/plugins/shared/cases/common/types/api/case/v1.ts index 07b84b441e46f..a8b49a7943a18 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/api/case/v1.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/api/case/v1.ts @@ -34,6 +34,7 @@ import { CaseCustomFieldToggleRt, CustomFieldTextTypeRt, CustomFieldNumberTypeRt, + CaseCloseReasonRt, } from '../../domain'; import { CaseRt, @@ -135,6 +136,10 @@ export const CaseBaseOptionalFieldsRequestRt = rt.exact( settings: CaseSettingsRt, template: rt.union([CaseTemplate, rt.null]), [CASE_EXTENDED_FIELDS]: rt.union([rt.undefined, rt.record(rt.string, rt.string)]), + /** + * The case close reason + */ + closeReason: CaseCloseReasonRt, }) ); diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/case/v1.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/case/v1.ts index 5c468e9ba2f0a..66c444526e024 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/domain/case/v1.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/case/v1.ts @@ -28,6 +28,19 @@ export const CaseStatusRt = rt.union([ export const caseStatuses = Object.values(CaseStatuses); +/** + * Close reason + */ +export const CaseCloseReasonRt = rt.union([ + rt.literal('false_positive'), + rt.literal('duplicate'), + rt.literal('true_positive'), + rt.literal('benign_positive'), + rt.literal('automated_closure'), + rt.literal('other'), + rt.string, +]); + /** * Severity */ diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/status/v1.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/status/v1.ts index 1dd704d61b624..4e56e4a3a952a 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/status/v1.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/user_action/status/v1.ts @@ -6,10 +6,15 @@ */ import * as rt from 'io-ts'; -import { CaseStatusRt } from '../../case/v1'; +import { CaseStatusRt, CaseCloseReasonRt } from '../../case/v1'; import { UserActionTypes } from '../action/v1'; -export const StatusUserActionPayloadRt = rt.strict({ status: CaseStatusRt }); +export const StatusUserActionPayloadRt = rt.exact( + rt.intersection([ + rt.type({ status: CaseStatusRt }), + rt.partial({ closeReason: CaseCloseReasonRt }), + ]) +); export const StatusUserActionRt = rt.strict({ type: rt.literal(UserActionTypes.status), diff --git a/x-pack/platform/plugins/shared/cases/docs/openapi/components/schemas/case_close_reason.yaml b/x-pack/platform/plugins/shared/cases/docs/openapi/components/schemas/case_close_reason.yaml new file mode 100644 index 0000000000000..b85b4cca1d8a5 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/docs/openapi/components/schemas/case_close_reason.yaml @@ -0,0 +1,14 @@ +description: > + The reason for closing the case. Can be one of following predefined reasons: + [false_positive, duplicate, true_positive, benign_positive, automated_closure, + other] or a custom reason provided by the user. +oneOf: + - type: string + enum: + - false_positive + - duplicate + - true_positive + - benign_positive + - automated_closure + - other + - type: string diff --git a/x-pack/platform/plugins/shared/cases/docs/openapi/components/schemas/update_case_request.yaml b/x-pack/platform/plugins/shared/cases/docs/openapi/components/schemas/update_case_request.yaml index aed05b60dabc7..5a04110624563 100644 --- a/x-pack/platform/plugins/shared/cases/docs/openapi/components/schemas/update_case_request.yaml +++ b/x-pack/platform/plugins/shared/cases/docs/openapi/components/schemas/update_case_request.yaml @@ -60,6 +60,8 @@ properties: $ref: 'case_tags.yaml' title: $ref: 'case_title.yaml' + closeReason: + $ref: 'case_close_reason.yaml' version: description: > The current version of the case. diff --git a/x-pack/platform/plugins/shared/cases/public/components/actions/status/use_status_action.tsx b/x-pack/platform/plugins/shared/cases/public/components/actions/status/use_status_action.tsx index abbc0535656d3..96530df6921ed 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/actions/status/use_status_action.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/actions/status/use_status_action.tsx @@ -7,6 +7,7 @@ import { useCallback } from 'react'; import type { EuiContextMenuPanelItemDescriptor } from '@elastic/eui'; +import type { CaseUpdateRequest } from '../../../../common/ui'; import { useUpdateCases } from '../../../containers/use_bulk_update_case'; import type { CasesUI } from '../../../../common'; import { CaseStatuses } from '../../../../common/types/domain'; @@ -42,16 +43,17 @@ export const useStatusAction = ({ isDisabled, selectedStatus, }: UseStatusActionProps) => { - const { mutate: updateCases } = useUpdateCases(); + const { mutate: updateCases, isLoading: isUpdatingStatus } = useUpdateCases(); const { canUpdate, canReopenCase } = useUserPermissions(); const handleUpdateCaseStatus = useCallback( - (selectedCases: CasesUI, status: CaseStatuses) => { + (selectedCases: CasesUI, status: CaseStatuses, closeReason?: string) => { onAction(); const casesToUpdate = selectedCases.map((theCase) => ({ status, id: theCase.id, version: theCase.version, - })); + closeReason, + })) as CaseUpdateRequest[]; updateCases( { @@ -90,15 +92,19 @@ export const useStatusAction = ({ { name: statuses[CaseStatuses.closed].label, icon: getStatusIcon(CaseStatuses.closed), - onClick: () => handleUpdateCaseStatus(selectedCases, CaseStatuses.closed), disabled: isDisabled || shouldDisableStatus(selectedCases), 'data-test-subj': 'cases-bulk-action-status-closed', - key: 'cases-bulk-status-action', + key: 'cases-bulk-action-status-closed', }, ]; }; - return { getActions, canUpdateStatus: canUpdate || canReopenCase }; + return { + getActions, + canUpdateStatus: canUpdate || canReopenCase, + handleUpdateCaseStatus, + isUpdatingStatus, + }; }; export type UseStatusAction = ReturnType; diff --git a/x-pack/platform/plugins/shared/cases/public/components/all_cases/close_case_modal.tsx b/x-pack/platform/plugins/shared/cases/public/components/all_cases/close_case_modal.tsx new file mode 100644 index 0000000000000..ea2000dbd19f2 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/all_cases/close_case_modal.tsx @@ -0,0 +1,211 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import type { EuiSelectableOption } from '@elastic/eui'; +import { + EuiButton, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSelectable, + EuiText, +} from '@elastic/eui'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; + +import * as i18n from './translations'; + +const CUSTOM_ALERT_CLOSE_REASONS_SETTING_KEY = 'securitySolution:alertCloseReasons'; + +interface ClosingReasonOption { + key?: string; +} + +type CloseCaseModalStep = 'sync_decision' | 'reason_selection'; + +const getDefaultClosingReasonOptions = (): Array> => [ + { label: i18n.CLOSE_CASE_MODAL_REASON_CLOSE_WITHOUT_REASON, key: undefined }, + { label: i18n.CLOSE_CASE_MODAL_REASON_DUPLICATE, key: 'duplicate' }, + { label: i18n.CLOSE_CASE_MODAL_REASON_FALSE_POSITIVE, key: 'false_positive' }, + { label: i18n.CLOSE_CASE_MODAL_REASON_TRUE_POSITIVE, key: 'true_positive' }, + { label: i18n.CLOSE_CASE_MODAL_REASON_BENIGN_POSITIVE, key: 'benign_positive' }, + { label: i18n.CLOSE_CASE_MODAL_REASON_OTHER, key: 'other' }, +]; + +interface CloseCaseModalProps { + isSyncDecisionStep: boolean; + closeReasonOptions: Array>; + selectedClosingReason?: EuiSelectableOption; + onClose: () => void; + onCloseCaseOnly: () => void; + onGoToCloseReasonSelection: () => void; + onCloseCaseAndSyncAlerts: () => void; + onCloseReasonOptionsChange: ( + options: Array>, + event?: unknown, + changedOption?: EuiSelectableOption + ) => void; +} + +const CloseCaseModalComponent = React.memo( + ({ + isSyncDecisionStep, + closeReasonOptions, + selectedClosingReason, + onClose, + onCloseCaseOnly, + onGoToCloseReasonSelection, + onCloseCaseAndSyncAlerts, + onCloseReasonOptionsChange, + }) => ( + + + {i18n.CLOSE_CASE_MODAL_TITLE} + + + {isSyncDecisionStep ? ( + +

{i18n.CLOSE_CASE_MODAL_SYNC_DECISION_DESCRIPTION}

+
+ ) : ( + + {(list) => list} + + )} +
+ + {isSyncDecisionStep ? ( + <> + + {i18n.CLOSE_CASE_MODAL_DO_NOT_SYNC_CLOSE_REASON} + + + {i18n.CLOSE_CASE_MODAL_SYNC_CLOSE_REASON} + + + ) : ( + + {i18n.CLOSE_CASE_MODAL_CONFIRM} + + )} + +
+ ) +); + +CloseCaseModalComponent.displayName = 'CloseCaseModalComponent'; + +interface UseCloseCaseModalProps { + canSyncCloseReasonToAlerts: boolean; + onCloseCase: (closeReason?: string) => void; +} + +interface UseCloseCaseModalReturnValue { + openCloseCaseModal: () => void; + closeCaseModal: JSX.Element | null; +} + +export const useCloseCaseModal = ({ + canSyncCloseReasonToAlerts, + onCloseCase, +}: UseCloseCaseModalProps): UseCloseCaseModalReturnValue => { + const { + services: { uiSettings }, + } = useKibana<{ uiSettings: IUiSettingsClient }>(); + + const initialCloseReasonOptions = useMemo(() => { + const customClosingReasons = + uiSettings.get(CUSTOM_ALERT_CLOSE_REASONS_SETTING_KEY) ?? []; + + return [ + ...getDefaultClosingReasonOptions(), + ...customClosingReasons.map((reason) => ({ label: reason, key: reason })), + ]; + }, [uiSettings]); + + const createCloseReasonOptions = useCallback( + () => + initialCloseReasonOptions.map((closeReasonOption) => ({ + ...closeReasonOption, + checked: undefined, + })), + [initialCloseReasonOptions] + ); + const [isCloseCaseModalVisible, setIsCloseCaseModalVisible] = useState(false); + const [closeCaseModalStep, setCloseCaseModalStep] = useState('sync_decision'); + const [closeReasonOptions, setCloseReasonOptions] = useState< + Array> + >(() => createCloseReasonOptions()); + const [selectedClosingReason, setSelectedClosingReason] = + useState>(); + const onCloseReasonOptionsChange = useCallback( + ( + options: Array>, + _event?: unknown, + changedOption?: EuiSelectableOption + ) => { + setCloseReasonOptions(options); + setSelectedClosingReason(changedOption?.checked === 'on' ? changedOption : undefined); + }, + [] + ); + + const closeCloseCaseModal = useCallback(() => { + setIsCloseCaseModalVisible(false); + }, []); + + const onCloseCaseOnly = useCallback(() => { + closeCloseCaseModal(); + onCloseCase(); + }, [closeCloseCaseModal, onCloseCase]); + + const onCloseCaseAndSyncAlerts = useCallback(() => { + closeCloseCaseModal(); + onCloseCase(selectedClosingReason?.key); + }, [closeCloseCaseModal, onCloseCase, selectedClosingReason?.key]); + + const onGoToCloseReasonSelection = useCallback(() => { + setCloseCaseModalStep('reason_selection'); + }, []); + + const openCloseCaseModal = useCallback(() => { + // Should automatically close without reason when case sync to alerts is disabled + if (!canSyncCloseReasonToAlerts) { + onCloseCase(); + return; + } + setCloseReasonOptions(createCloseReasonOptions()); + setSelectedClosingReason(undefined); + setCloseCaseModalStep('sync_decision'); + setIsCloseCaseModalVisible(true); + }, [canSyncCloseReasonToAlerts, createCloseReasonOptions, onCloseCase]); + + return { + openCloseCaseModal, + closeCaseModal: isCloseCaseModalVisible ? ( + + ) : null, + }; +}; diff --git a/x-pack/platform/plugins/shared/cases/public/components/all_cases/translations.ts b/x-pack/platform/plugins/shared/cases/public/components/all_cases/translations.ts index 03250c0ee67cb..e9f23effc9855 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/all_cases/translations.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/all_cases/translations.ts @@ -247,3 +247,88 @@ export const OPTIONS = (totalCount: number) => export const MORE_FILTERS_LABEL = i18n.translate('xpack.cases.tableFilters.moreFiltersLabel', { defaultMessage: 'More', }); + +export const CLOSE_CASE_MODAL_TITLE = i18n.translate( + 'xpack.cases.allCasesView.closeCaseModal.title', + { + defaultMessage: 'Close case', + } +); + +export const CLOSE_CASE_MODAL_REASON_LABEL = i18n.translate( + 'xpack.cases.allCasesView.closeCaseModal.reasonLabel', + { + defaultMessage: 'Reason for closing', + } +); + +export const CLOSE_CASE_MODAL_CONFIRM = i18n.translate( + 'xpack.cases.allCasesView.closeCaseModal.confirmButtonLabel', + { + defaultMessage: 'Close with reason', + } +); + +export const CLOSE_CASE_MODAL_SYNC_DECISION_DESCRIPTION = i18n.translate( + 'xpack.cases.allCasesView.closeCaseModal.syncDecisionDescription', + { + defaultMessage: + 'Choose whether to sync a closing reason to related alerts before closing this case.', + } +); + +export const CLOSE_CASE_MODAL_SYNC_CLOSE_REASON = i18n.translate( + 'xpack.cases.allCasesView.closeCaseModal.syncCloseReasonButtonLabel', + { + defaultMessage: 'Sync close reason with alerts', + } +); + +export const CLOSE_CASE_MODAL_DO_NOT_SYNC_CLOSE_REASON = i18n.translate( + 'xpack.cases.allCasesView.closeCaseModal.doNotSyncCloseReasonButtonLabel', + { + defaultMessage: 'Do not sync close reason', + } +); + +export const CLOSE_CASE_MODAL_REASON_CLOSE_WITHOUT_REASON = i18n.translate( + 'xpack.cases.allCasesView.closeCaseModal.reason.closeWithoutReason', + { + defaultMessage: 'Close without reason', + } +); + +export const CLOSE_CASE_MODAL_REASON_DUPLICATE = i18n.translate( + 'xpack.cases.allCasesView.closeCaseModal.reason.duplicate', + { + defaultMessage: 'Duplicate', + } +); + +export const CLOSE_CASE_MODAL_REASON_FALSE_POSITIVE = i18n.translate( + 'xpack.cases.allCasesView.closeCaseModal.reason.falsePositive', + { + defaultMessage: 'False Positive', + } +); + +export const CLOSE_CASE_MODAL_REASON_TRUE_POSITIVE = i18n.translate( + 'xpack.cases.allCasesView.closeCaseModal.reason.truePositive', + { + defaultMessage: 'True positive', + } +); + +export const CLOSE_CASE_MODAL_REASON_BENIGN_POSITIVE = i18n.translate( + 'xpack.cases.allCasesView.closeCaseModal.reason.benignPositive', + { + defaultMessage: 'Benign positive', + } +); + +export const CLOSE_CASE_MODAL_REASON_OTHER = i18n.translate( + 'xpack.cases.allCasesView.closeCaseModal.reason.other', + { + defaultMessage: 'Other', + } +); diff --git a/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_actions.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_actions.test.tsx index b2b591852c1bd..e19628cd5c522 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_actions.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_actions.test.tsx @@ -26,6 +26,7 @@ import { renderWithTestingProviders, } from '../../common/mock'; import React from 'react'; +import * as i18n from './translations'; jest.mock('../../containers/api'); jest.mock('../../containers/user_profiles/api'); @@ -70,7 +71,14 @@ describe('useActions', () => { wrapper: TestProviders, }); - const comp = result.current.actions!.render(basicCase) as React.ReactElement; + const comp = result.current.actions!.render({ + ...basicCase, + totalAlerts: 2, + settings: { + ...basicCase.settings, + syncAlerts: true, + }, + }) as React.ReactElement; renderWithTestingProviders(comp); expect(screen.getByTestId(`case-action-popover-${basicCase.id}`)).toBeInTheDocument(); @@ -81,7 +89,14 @@ describe('useActions', () => { wrapper: TestProviders, }); - const comp = result.current.actions!.render(basicCase) as React.ReactElement; + const comp = result.current.actions!.render({ + ...basicCase, + totalAlerts: 2, + settings: { + ...basicCase.settings, + syncAlerts: true, + }, + }) as React.ReactElement; renderWithTestingProviders(comp); await user.click(screen.getByTestId(`case-action-popover-button-${basicCase.id}`)); @@ -100,7 +115,14 @@ describe('useActions', () => { wrapper: TestProviders, }); - const comp = result.current.actions!.render(basicCase) as React.ReactElement; + const comp = result.current.actions!.render({ + ...basicCase, + totalAlerts: 2, + settings: { + ...basicCase.settings, + syncAlerts: true, + }, + }) as React.ReactElement; renderWithTestingProviders(comp); await user.click(screen.getByTestId(`case-action-popover-button-${basicCase.id}`)); @@ -120,6 +142,143 @@ describe('useActions', () => { }); }); + it('changes the status of the case to closed with closing reason', async () => { + const updateCasesSpy = jest.spyOn(api, 'updateCases'); + + const { result } = renderHook(() => useActions({ disableActions: false }), { + wrapper: TestProviders, + }); + + const comp = result.current.actions!.render({ + ...basicCase, + totalAlerts: 2, + settings: { + ...basicCase.settings, + syncAlerts: true, + }, + }) as React.ReactElement; + renderWithTestingProviders(comp); + + await user.click(screen.getByTestId(`case-action-popover-button-${basicCase.id}`)); + await waitForEuiPopoverOpen(); + + await user.click(screen.getByTestId(`case-action-status-panel-${basicCase.id}`)); + await waitForEuiContextMenuPanelTransition(); + + await user.click(screen.getByTestId('cases-bulk-action-status-closed')); + await waitFor(() => { + expect(screen.getByRole('dialog', { name: i18n.CLOSE_CASE_MODAL_TITLE })).toBeInTheDocument(); + }); + + await user.click(screen.getByText(i18n.CLOSE_CASE_MODAL_SYNC_CLOSE_REASON)); + await user.click(screen.getByText('Duplicate')); + await user.click(screen.getByText(i18n.CLOSE_CASE_MODAL_CONFIRM)); + + await waitFor(() => { + expect(updateCasesSpy).toHaveBeenCalledWith( + expect.objectContaining({ + cases: [ + expect.objectContaining({ + id: basicCase.id, + status: CaseStatuses.closed, + version: basicCase.version, + closeReason: 'duplicate', + }), + ], + }) + ); + }); + }); + + it('changes the status to closed without syncing close reason to alerts', async () => { + const updateCasesSpy = jest.spyOn(api, 'updateCases'); + + const { result } = renderHook(() => useActions({ disableActions: false }), { + wrapper: TestProviders, + }); + + const comp = result.current.actions!.render({ + ...basicCase, + totalAlerts: 2, + settings: { + ...basicCase.settings, + syncAlerts: false, + }, + }) as React.ReactElement; + renderWithTestingProviders(comp); + + await user.click(screen.getByTestId(`case-action-popover-button-${basicCase.id}`)); + await waitForEuiPopoverOpen(); + + await user.click(screen.getByTestId(`case-action-status-panel-${basicCase.id}`)); + await waitForEuiContextMenuPanelTransition(); + + await user.click(screen.getByTestId('cases-bulk-action-status-closed')); + expect(screen.queryByRole('dialog', { name: i18n.CLOSE_CASE_MODAL_TITLE })).not.toBeInTheDocument(); + + await waitFor(() => { + expect(updateCasesSpy).toHaveBeenCalledWith( + expect.objectContaining({ + cases: [ + expect.objectContaining({ + id: basicCase.id, + status: CaseStatuses.closed, + version: basicCase.version, + }), + ], + }) + ); + }); + }); + + it('changes the status to closed and syncs close reason to alerts', async () => { + const updateCasesSpy = jest.spyOn(api, 'updateCases'); + + const { result } = renderHook(() => useActions({ disableActions: false }), { + wrapper: TestProviders, + }); + + const comp = result.current.actions!.render({ + ...basicCase, + totalAlerts: 2, + }) as React.ReactElement; + renderWithTestingProviders(comp); + + await user.click(screen.getByTestId(`case-action-popover-button-${basicCase.id}`)); + await waitForEuiPopoverOpen(); + + await user.click(screen.getByTestId(`case-action-status-panel-${basicCase.id}`)); + await waitForEuiContextMenuPanelTransition(); + + await user.click(screen.getByTestId('cases-bulk-action-status-closed')); + await waitFor(() => { + expect(screen.getByRole('dialog', { name: i18n.CLOSE_CASE_MODAL_TITLE })).toBeInTheDocument(); + }); + + await user.click(screen.getByText(i18n.CLOSE_CASE_MODAL_SYNC_CLOSE_REASON)); + await waitFor(() => { + expect(screen.getByText('Close without reason')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Duplicate')); + await user.click(screen.getByText(i18n.CLOSE_CASE_MODAL_CONFIRM)); + + await waitFor(() => { + expect(updateCasesSpy).toHaveBeenCalledWith( + expect.objectContaining({ + cases: [ + expect.objectContaining({ + id: basicCase.id, + status: CaseStatuses.closed, + version: basicCase.version, + closeReason: 'duplicate', + }), + ], + }) + ); + }); + }); + it('change the severity of the case', async () => { const updateCasesSpy = jest.spyOn(api, 'updateCases'); diff --git a/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_actions.tsx b/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_actions.tsx index 2594bd18a9dca..e24515c99cb0a 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_actions.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_actions.tsx @@ -13,6 +13,7 @@ import type { } from '@elastic/eui'; import { EuiButtonIcon, EuiContextMenu, EuiPopover } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { CaseStatuses } from '@kbn/cases-components'; import type { CaseUI } from '../../containers/types'; import { useDeleteAction } from '../actions/delete/use_delete_action'; import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; @@ -29,13 +30,15 @@ import { useAssigneesAction } from '../actions/assignees/use_assignees_action'; import { EditAssigneesFlyout } from '../actions/assignees/edit_assignees_flyout'; import { useCopyIDAction } from '../actions/copy_id/use_copy_id_action'; import { useShouldDisableStatus } from '../actions/status/use_should_disable_status'; +import { useCloseCaseModal } from './close_case_modal'; +import { useCanSyncCloseReasonToAlerts } from './use_can_sync_close_reason_to_alerts'; const ActionColumnComponent: React.FC<{ theCase: CaseUI; disableActions: boolean }> = ({ theCase, disableActions, }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const tooglePopover = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]); + const togglePopover = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]); const closePopover = useCallback(() => setIsPopoverOpen(false), []); const refreshCases = useRefreshCases(); const { permissions } = useCasesContext(); @@ -83,6 +86,36 @@ const ActionColumnComponent: React.FC<{ theCase: CaseUI; disableActions: boolean onActionSuccess: refreshCases, }); + const onCloseCase = useCallback( + (closeReason?: string) => { + statusAction.handleUpdateCaseStatus([theCase], CaseStatuses.closed, closeReason); + }, + [statusAction, theCase] + ); + const canSyncCloseReasonToAlerts = useCanSyncCloseReasonToAlerts({ + totalAlerts: theCase.totalAlerts, + syncAlertsEnabled: theCase.settings.syncAlerts, + }); + + const { openCloseCaseModal, closeCaseModal } = useCloseCaseModal({ + canSyncCloseReasonToAlerts, + onCloseCase, + }); + + const statusActions: EuiContextMenuPanelItemDescriptor[] = useMemo(() => { + return statusAction + .getActions([theCase]) + .map((statusActionMenuItem: EuiContextMenuPanelItemDescriptor) => { + if (statusActionMenuItem.key === 'cases-bulk-action-status-closed') { + return { + ...statusActionMenuItem, + onClick: openCloseCaseModal, + } as EuiContextMenuPanelItemDescriptor; + } + return statusActionMenuItem; + }); + }, [openCloseCaseModal, statusAction, theCase]); + const canDelete = deleteAction.canDelete; const canUpdate = statusAction.canUpdateStatus; const canAssign = permissions.assign; @@ -155,7 +188,7 @@ const ActionColumnComponent: React.FC<{ theCase: CaseUI; disableActions: boolean panelsToBuild.push({ id: 1, title: i18n.STATUS, - items: statusAction.getActions([theCase]), + items: statusActions, }); } if (severityAction.canUpdateSeverity) { @@ -176,6 +209,7 @@ const ActionColumnComponent: React.FC<{ theCase: CaseUI; disableActions: boolean deleteAction, severityAction, statusAction, + statusActions, tagsAction, theCase, shouldDisableStatus, @@ -189,7 +223,7 @@ const ActionColumnComponent: React.FC<{ theCase: CaseUI; disableActions: boolean data-test-subj={`case-action-popover-${theCase.id}`} button={ ) : null} + {closeCaseModal} ); }; diff --git a/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_bulk_actions.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_bulk_actions.test.tsx index b0de2a568e8fe..04aa0e345173d 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_bulk_actions.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_bulk_actions.test.tsx @@ -22,6 +22,7 @@ import { import { useBulkActions } from './use_bulk_actions'; import * as api from '../../containers/api'; import { basicCase, basicCaseClosed } from '../../containers/mock'; +import * as i18n from './translations'; jest.mock('../../containers/api'); jest.mock('../../containers/user_profiles/api'); @@ -43,145 +44,10 @@ describe('useBulkActions', () => { } ); - expect(result.current).toMatchInlineSnapshot(` - Object { - "flyouts": , - "modals": , - "panels": Array [ - Object { - "id": 0, - "items": Array [ - Object { - "data-test-subj": "case-bulk-action-status", - "disabled": false, - "key": "case-bulk-action-status", - "name": "Status", - "panel": 1, - }, - Object { - "data-test-subj": "case-bulk-action-severity", - "disabled": false, - "key": "case-bulk-action-severity", - "name": "Severity", - "panel": 2, - }, - Object { - "data-test-subj": "bulk-actions-separator", - "isSeparator": true, - "key": "bulk-actions-separator", - }, - Object { - "data-test-subj": "cases-bulk-action-tags", - "disabled": false, - "icon": , - "key": "cases-bulk-action-tags", - "name": "Edit tags", - "onClick": [Function], - }, - Object { - "data-test-subj": "cases-bulk-action-assignees", - "disabled": false, - "icon": , - "key": "cases-bulk-action-assignees", - "name": "Edit assignees", - "onClick": [Function], - }, - Object { - "data-test-subj": "cases-bulk-action-delete", - "disabled": false, - "icon": , - "key": "cases-bulk-action-delete", - "name": - Delete case - , - "onClick": [Function], - }, - ], - "title": "Actions", - }, - Object { - "id": 1, - "items": Array [ - Object { - "data-test-subj": "cases-bulk-action-status-open", - "disabled": false, - "icon": "empty", - "key": "cases-bulk-action-status-open", - "name": "Open", - "onClick": [Function], - }, - Object { - "data-test-subj": "cases-bulk-action-status-in-progress", - "disabled": false, - "icon": "empty", - "key": "cases-bulk-action-status-in-progress", - "name": "In progress", - "onClick": [Function], - }, - Object { - "data-test-subj": "cases-bulk-action-status-closed", - "disabled": false, - "icon": "empty", - "key": "cases-bulk-status-action", - "name": "Closed", - "onClick": [Function], - }, - ], - "title": "Status", - }, - Object { - "id": 2, - "items": Array [ - Object { - "data-test-subj": "cases-bulk-action-severity-low", - "disabled": true, - "icon": "empty", - "key": "cases-bulk-action-severity-low", - "name": "Low", - "onClick": [Function], - }, - Object { - "data-test-subj": "cases-bulk-action-severity-medium", - "disabled": false, - "icon": "empty", - "key": "cases-bulk-action-severity-medium", - "name": "Medium", - "onClick": [Function], - }, - Object { - "data-test-subj": "cases-bulk-action-severity-high", - "disabled": false, - "icon": "empty", - "key": "cases-bulk-action-severity-high", - "name": "High", - "onClick": [Function], - }, - Object { - "data-test-subj": "cases-bulk-action-severity-critical", - "disabled": false, - "icon": "empty", - "key": "cases-bulk-action-severity-critical", - "name": "Critical", - "onClick": [Function], - }, - ], - "title": "Severity", - }, - ], - } - `); + expect(result.current.panels).toHaveLength(3); + expect(result.current.panels[0].title).toBe('Actions'); + expect(result.current.panels[1].title).toBe('Status'); + expect(result.current.panels[2].title).toBe('Severity'); }); it('change the status of cases', async () => { @@ -220,6 +86,121 @@ describe('useBulkActions', () => { }); }); + it('shows the sync step and reason step when closing from bulk actions', async () => { + const { result } = renderHook( + () => + useBulkActions({ + onAction, + onActionSuccess, + selectedCases: [ + { + ...basicCase, + totalAlerts: 2, + settings: { + ...basicCase.settings, + syncAlerts: true, + }, + }, + ], + }), + { + wrapper: TestProviders, + } + ); + + let modals = result.current.modals; + const panels = result.current.panels; + + const { rerender } = renderWithTestingProviders( + <> + + {modals} + + ); + + await userEvent.click(screen.getByTestId('case-bulk-action-status')); + await userEvent.click(await screen.findByTestId('cases-bulk-action-status-closed'), { + pointerEventsCheck: 0, + }); + + modals = result.current.modals; + rerender( + <> + + {modals} + + ); + + expect(await screen.findByRole('dialog', { name: i18n.CLOSE_CASE_MODAL_TITLE })).toBeInTheDocument(); + await userEvent.click(screen.getByText(i18n.CLOSE_CASE_MODAL_SYNC_CLOSE_REASON)); + + modals = result.current.modals; + rerender( + <> + + {modals} + + ); + + expect(screen.getByText('Close without reason')).toBeInTheDocument(); + }); + + it('closes without modal when sync alerts is off', async () => { + const updateCasesSpy = jest.spyOn(api, 'updateCases'); + + const { result } = renderHook( + () => + useBulkActions({ + onAction, + onActionSuccess, + selectedCases: [ + { + ...basicCase, + totalAlerts: 2, + settings: { + ...basicCase.settings, + syncAlerts: false, + }, + }, + ], + }), + { + wrapper: TestProviders, + } + ); + + const modals = result.current.modals; + const panels = result.current.panels; + + renderWithTestingProviders( + <> + + {modals} + + ); + + await userEvent.click(screen.getByTestId('case-bulk-action-status')); + await userEvent.click(await screen.findByTestId('cases-bulk-action-status-closed'), { + pointerEventsCheck: 0, + }); + + expect(screen.queryByRole('dialog', { name: i18n.CLOSE_CASE_MODAL_TITLE })).not.toBeInTheDocument(); + + await waitFor(() => { + expect(updateCasesSpy).toHaveBeenCalledWith( + expect.objectContaining({ + cases: [ + expect.objectContaining({ + id: basicCase.id, + status: 'closed', + version: basicCase.version, + }), + ], + }) + ); + }); + }); + it('change the severity of cases', async () => { const updateCasesSpy = jest.spyOn(api, 'updateCases'); diff --git a/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_bulk_actions.tsx b/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_bulk_actions.tsx index f20ce20a8b0e4..0d4995f6ef033 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_bulk_actions.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_bulk_actions.tsx @@ -9,7 +9,8 @@ import type { EuiContextMenuPanelDescriptor, EuiContextMenuPanelItemDescriptor, } from '@elastic/eui'; -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; +import { CaseStatuses } from '@kbn/cases-components'; import type { CasesUI } from '../../containers/types'; import { useDeleteAction } from '../actions/delete/use_delete_action'; @@ -21,6 +22,8 @@ import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; import { useCasesContext } from '../cases_context/use_cases_context'; import { useAssigneesAction } from '../actions/assignees/use_assignees_action'; import { EditAssigneesFlyout } from '../actions/assignees/edit_assignees_flyout'; +import { useCloseCaseModal } from './close_case_modal'; +import { useCanSyncCloseReasonToAlerts } from './use_can_sync_close_reason_to_alerts'; import * as i18n from './translations'; interface UseBulkActionsProps { @@ -76,6 +79,34 @@ export const useBulkActions = ({ const canDelete = deleteAction.canDelete; const canUpdate = statusAction.canUpdateStatus; const canAssign = permissions.assign; + const canSyncCloseReasonToAlerts = useCanSyncCloseReasonToAlerts({ + selectedCases, + }); + + const onCloseCase = useCallback( + (closeReason?: string) => { + statusAction.handleUpdateCaseStatus(selectedCases, CaseStatuses.closed, closeReason); + }, + [selectedCases, statusAction] + ); + const { openCloseCaseModal, closeCaseModal } = useCloseCaseModal({ + canSyncCloseReasonToAlerts, + onCloseCase, + }); + + const statusActions = useMemo((): EuiContextMenuPanelItemDescriptor[] => { + return statusAction.getActions(selectedCases).map((statusActionMenuItem) => { + if (statusActionMenuItem['data-test-subj'] === 'cases-bulk-action-status-closed') { + return { + ...statusActionMenuItem, + panel: undefined, + onClick: openCloseCaseModal, + }; + } + + return statusActionMenuItem; + }); + }, [openCloseCaseModal, selectedCases, statusAction]); const panels = useMemo((): EuiContextMenuPanelDescriptor[] => { const mainPanelItems: EuiContextMenuPanelItemDescriptor[] = []; @@ -133,7 +164,7 @@ export const useBulkActions = ({ panelsToBuild.push({ id: 1, title: i18n.STATUS, - items: statusAction.getActions(selectedCases), + items: statusActions, }); panelsToBuild.push({ @@ -152,7 +183,7 @@ export const useBulkActions = ({ isDisabled, selectedCases, severityAction, - statusAction, + statusActions, tagsAction, assigneesAction, ]); @@ -167,6 +198,7 @@ export const useBulkActions = ({ onConfirm={deleteAction.onConfirmDeletion} /> ) : null} + {closeCaseModal} ), flyouts: ( diff --git a/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_can_sync_close_reason_to_alerts.ts b/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_can_sync_close_reason_to_alerts.ts new file mode 100644 index 0000000000000..8749b436da5b0 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_can_sync_close_reason_to_alerts.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCasesContext } from '../cases_context/use_cases_context'; + +interface CaseSyncSettings { + syncAlerts: boolean; +} + +interface CaseSyncInfo { + totalAlerts: number; + settings: CaseSyncSettings; +} + +interface SingleCaseParams { + totalAlerts: number; + syncAlertsEnabled: boolean; +} + +interface BulkCaseParams { + selectedCases: CaseSyncInfo[]; +} + +type UseCanSyncCloseReasonToAlertsParams = SingleCaseParams | BulkCaseParams; + +export const useCanSyncCloseReasonToAlerts = ( + params: UseCanSyncCloseReasonToAlertsParams +): boolean => { + const { features } = useCasesContext(); + + if (!features.alerts.sync) { + return false; + } + + if ('selectedCases' in params) { + return params.selectedCases.some( + (selectedCase) => selectedCase.totalAlerts > 0 && selectedCase.settings.syncAlerts + ); + } + + return params.totalAlerts > 0 && params.syncAlertsEnabled; +}; diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_action_bar/index.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_action_bar/index.tsx index eb0fda5516ec5..aeff1c980840f 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_action_bar/index.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_action_bar/index.tsx @@ -24,6 +24,7 @@ import { useCasesContext } from '../cases_context/use_cases_context'; import { useCasesFeatures } from '../../common/use_cases_features'; import { useGetCaseConnectors } from '../../containers/use_get_case_connectors'; import { useShouldDisableStatus } from '../actions/status/use_should_disable_status'; +import { useStatusAction } from '../actions/status/use_status_action'; export interface CaseActionBarProps { caseData: CaseUI; @@ -46,14 +47,26 @@ const CaseActionBarComponent: React.FC = ({ const title = getStatusTitle(caseData.status); const refreshCaseViewPage = useRefreshCaseViewPage(); + const statusAction = useStatusAction({ + isDisabled: false, + onAction: () => {}, + onActionSuccess: refreshCaseViewPage, + selectedStatus: caseData.status, + }); const onStatusChanged = useCallback( - (status: CaseStatuses) => - onUpdateField({ - key: 'status', - value: status, - }), - [onUpdateField] + (status: CaseStatuses, closeReason?: string) => { + if (status !== 'closed' || closeReason === null) { + onUpdateField({ + key: 'status', + value: status, + }); + return; + } + + statusAction.handleUpdateCaseStatus([caseData], status, closeReason); + }, + [caseData, onUpdateField, statusAction] ); const currentExternalIncident = @@ -89,8 +102,10 @@ const CaseActionBarComponent: React.FC = ({ diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_action_bar/status_context_menu.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_action_bar/status_context_menu.test.tsx index e4497b14ff75e..79ccb86e1edd9 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_action_bar/status_context_menu.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_action_bar/status_context_menu.test.tsx @@ -26,7 +26,12 @@ describe('StatusContextMenu', () => { it('renders', async () => { const wrapper = mount( - + ); @@ -39,6 +44,8 @@ describe('StatusContextMenu', () => { @@ -51,7 +58,12 @@ describe('StatusContextMenu', () => { it('renders the current status correctly', async () => { const wrapper = mount( - + ); @@ -63,7 +75,12 @@ describe('StatusContextMenu', () => { it('changes the status', async () => { const wrapper = mount( - + ); @@ -79,7 +96,12 @@ describe('StatusContextMenu', () => { (useShouldDisableStatus as jest.Mock).mockReturnValue(() => true); const wrapper = mount( - + ); @@ -97,7 +119,12 @@ describe('StatusContextMenu', () => { const wrapper = mount( - + ); @@ -111,7 +138,12 @@ describe('StatusContextMenu', () => { const wrapper = mount( - + ); @@ -124,7 +156,12 @@ describe('StatusContextMenu', () => { const wrapper = mount( - + ); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_action_bar/status_context_menu.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_action_bar/status_context_menu.tsx index b1c65fc796b46..0ac8e5c6dd8ea 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_action_bar/status_context_menu.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_action_bar/status_context_menu.tsx @@ -6,29 +6,54 @@ */ import React, { memo, useCallback, useMemo, useState } from 'react'; -import { EuiPopover, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; +import { EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; import { Status } from '@kbn/cases-components/src/status/status'; -import type { CaseStatuses } from '../../../common/types/domain'; -import { caseStatuses } from '../../../common/types/domain'; +import { CaseStatuses, caseStatuses } from '../../../common/types/domain'; import { StatusPopoverButton } from '../status'; import { CHANGE_STATUS } from '../all_cases/translations'; +import { useCloseCaseModal } from '../all_cases/close_case_modal'; +import { useCanSyncCloseReasonToAlerts } from '../all_cases/use_can_sync_close_reason_to_alerts'; import { useShouldDisableStatus } from '../actions/status/use_should_disable_status'; interface Props { currentStatus: CaseStatuses; + totalAlerts: number; + syncAlertsEnabled: boolean; disabled?: boolean; isLoading?: boolean; - onStatusChanged: (status: CaseStatuses) => void; + onStatusChanged: (status: CaseStatuses, closeReason?: string) => void; } const StatusContextMenuComponent: React.FC = ({ currentStatus, + totalAlerts, + syncAlertsEnabled, disabled = false, isLoading = false, onStatusChanged, }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const shouldDisableStatus = useShouldDisableStatus(); + const canSyncCloseReasonToAlerts = useCanSyncCloseReasonToAlerts({ + totalAlerts, + syncAlertsEnabled, + }); + + const onCloseCase = useCallback( + (closeReason?: string) => { + if (closeReason == null) { + onStatusChanged(CaseStatuses.closed); + return; + } + + onStatusChanged(CaseStatuses.closed, closeReason); + }, + [onStatusChanged] + ); + const { openCloseCaseModal, closeCaseModal } = useCloseCaseModal({ + canSyncCloseReasonToAlerts, + onCloseCase, + }); const togglePopover = useCallback( () => setIsPopoverOpen((prevPopoverStatus) => !prevPopoverStatus), [] @@ -51,10 +76,14 @@ const StatusContextMenuComponent: React.FC = ({ (status: CaseStatuses) => { closePopover(); if (currentStatus !== status) { + if (status === CaseStatuses.closed) { + openCloseCaseModal(); + return; + } onStatusChanged(status); } }, - [closePopover, currentStatus, onStatusChanged] + [closePopover, currentStatus, onStatusChanged, openCloseCaseModal] ); const panelItems = useMemo( @@ -79,17 +108,20 @@ const StatusContextMenuComponent: React.FC = ({ } return ( - - - + <> + + + + {closeCaseModal} + ); }; diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_activity.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_activity.tsx index 5ace40b34fb45..c8316e60a153d 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_activity.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/components/case_view_activity.tsx @@ -17,6 +17,8 @@ import { import { css } from '@emotion/react'; import React, { useCallback, useMemo, useState } from 'react'; import { isEqual } from 'lodash'; +import { CaseStatuses } from '@kbn/cases-components'; +import type { CaseSeverity, CaseUI } from '../../../../common'; import { useCasesLocalStorage } from '../../../common/use_cases_local_storage'; import { useGetCaseConfiguration } from '../../../containers/configure/use_get_case_configuration'; import { useGetCaseUsers } from '../../../containers/use_get_case_users'; @@ -24,9 +26,7 @@ import { useGetCaseConnectors } from '../../../containers/use_get_case_connector import { useCasesFeatures } from '../../../common/use_cases_features'; import { useGetCurrentUserProfile } from '../../../containers/user_profiles/use_get_current_user_profile'; import { useGetSupportedActionConnectors } from '../../../containers/configure/use_get_supported_action_connectors'; -import type { CaseSeverity, CaseStatuses } from '../../../../common/types/domain'; import type { CaseUICustomField, UseFetchAlertData } from '../../../../common/ui/types'; -import type { CaseUI } from '../../../../common'; import type { EditConnectorProps } from '../../edit_connector'; import { EditConnector } from '../../edit_connector'; import type { CasesNavigation } from '../../links'; @@ -53,6 +53,8 @@ import { EditCategory } from './edit_category'; import { parseCaseUsers } from '../../utils'; import { CustomFields } from './custom_fields'; import { useReplaceCustomField } from '../../../containers/use_replace_custom_field'; +import { useStatusAction } from '../../actions/status/use_status_action'; +import { useRefreshCaseViewPage } from '../use_on_refresh_case_view_page'; const LOCALSTORAGE_SORT_ORDER_KEY = 'cases.userActivity.sortOrder'; @@ -114,6 +116,13 @@ export const CaseViewActivity = ({ const { onUpdateField, isLoading, loadingKey } = useOnUpdateField({ caseData, }); + const refreshCaseViewPage = useRefreshCaseViewPage(); + const statusAction = useStatusAction({ + isDisabled: false, + onAction: () => {}, + onActionSuccess: refreshCaseViewPage, + selectedStatus: caseData.status, + }); const { isLoading: isUpdatingCustomField, mutate: replaceCustomField } = useReplaceCustomField(); @@ -121,12 +130,18 @@ export const CaseViewActivity = ({ (isLoading && loadingKey === 'assignees') || isLoadingCaseUsers || isLoadingCurrentUserProfile; const changeStatus = useCallback( - (status: CaseStatuses) => - onUpdateField({ - key: 'status', - value: status, - }), - [onUpdateField] + (status: CaseStatuses, closeReason?: string) => { + if (status !== CaseStatuses.closed || closeReason == null) { + onUpdateField({ + key: 'status', + value: status, + }); + return; + } + + statusAction.handleUpdateCaseStatus([caseData], status, closeReason); + }, + [caseData, onUpdateField, statusAction] ); const onSubmitTags = useCallback( @@ -255,8 +270,12 @@ export const CaseViewActivity = ({ permissions.update ? ( ) : null } diff --git a/x-pack/platform/plugins/shared/cases/public/components/status/button.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/status/button.test.tsx index 3e575ee8e3f67..778ae241fb727 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/status/button.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/status/button.test.tsx @@ -10,25 +10,35 @@ import { mount } from 'enzyme'; import { CaseStatuses } from '../../../common/types/domain'; import { StatusActionButton } from './button'; +import { TestProviders } from '../../common/mock'; +import * as i18n from '../all_cases/translations'; describe('StatusActionButton', () => { const onStatusChanged = jest.fn(); const defaultProps = { status: CaseStatuses.open, + totalAlerts: 0, + syncAlertsEnabled: true, disabled: false, isLoading: false, onStatusChanged, }; + const mountComponent = (props = defaultProps) => + mount( + + + + ); it('it renders', async () => { - const wrapper = mount(); + const wrapper = mountComponent(); expect(wrapper.find(`[data-test-subj="case-view-status-action-button"]`).exists()).toBeTruthy(); }); describe('Button icons', () => { it('it renders the correct button icon: status open', () => { - const wrapper = mount(); + const wrapper = mountComponent(); expect( wrapper.find(`[data-test-subj="case-view-status-action-button"]`).first().prop('iconType') @@ -36,9 +46,7 @@ describe('StatusActionButton', () => { }); it('it renders the correct button icon: status in-progress', () => { - const wrapper = mount( - - ); + const wrapper = mountComponent({ ...defaultProps, status: CaseStatuses['in-progress'] }); expect( wrapper.find(`[data-test-subj="case-view-status-action-button"]`).first().prop('iconType') @@ -46,7 +54,7 @@ describe('StatusActionButton', () => { }); it('it renders the correct button icon: status closed', () => { - const wrapper = mount(); + const wrapper = mountComponent({ ...defaultProps, status: CaseStatuses.closed }); expect( wrapper.find(`[data-test-subj="case-view-status-action-button"]`).first().prop('iconType') @@ -56,7 +64,7 @@ describe('StatusActionButton', () => { describe('Status rotation', () => { it('rotates correctly to in-progress when status is open', () => { - const wrapper = mount(); + const wrapper = mountComponent(); wrapper .find(`button[data-test-subj="case-view-status-action-button"]`) @@ -66,19 +74,25 @@ describe('StatusActionButton', () => { }); it('rotates correctly to closed when status is in-progress', () => { - const wrapper = mount( - - ); + const wrapper = mountComponent({ + ...defaultProps, + status: CaseStatuses['in-progress'], + totalAlerts: 1, + }); wrapper .find(`button[data-test-subj="case-view-status-action-button"]`) .first() .simulate('click'); + wrapper + .find('button') + .filterWhere((node) => node.text() === i18n.CLOSE_CASE_MODAL_DO_NOT_SYNC_CLOSE_REASON) + .simulate('click'); expect(onStatusChanged).toHaveBeenCalledWith('closed'); }); it('rotates correctly to open when status is closed', () => { - const wrapper = mount(); + const wrapper = mountComponent({ ...defaultProps, status: CaseStatuses.closed }); wrapper .find(`button[data-test-subj="case-view-status-action-button"]`) diff --git a/x-pack/platform/plugins/shared/cases/public/components/status/button.tsx b/x-pack/platform/plugins/shared/cases/public/components/status/button.tsx index e67d66a657e90..8f34317b68e6e 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/status/button.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/status/button.tsx @@ -8,39 +8,76 @@ import React, { memo, useCallback, useMemo } from 'react'; import { EuiButton } from '@elastic/eui'; -import type { CaseStatuses } from '../../../common/types/domain'; -import { caseStatuses } from '../../../common/types/domain'; +import { CaseStatuses, caseStatuses } from '../../../common/types/domain'; +import { useCloseCaseModal } from '../all_cases/close_case_modal'; +import { useCanSyncCloseReasonToAlerts } from '../all_cases/use_can_sync_close_reason_to_alerts'; import { statuses } from './config'; interface Props { status: CaseStatuses; + totalAlerts: number; + syncAlertsEnabled: boolean; isLoading: boolean; - onStatusChanged: (status: CaseStatuses) => void; + onStatusChanged: (status: CaseStatuses, closeReason?: string) => void; } // Rotate over the statuses. open -> in-progress -> closes -> open... const getNextItem = (item: number) => (item + 1) % caseStatuses.length; -const StatusActionButtonComponent: React.FC = ({ status, onStatusChanged, isLoading }) => { +const StatusActionButtonComponent: React.FC = ({ + status, + totalAlerts, + syncAlertsEnabled, + onStatusChanged, + isLoading, +}) => { + const canSyncCloseReasonToAlerts = useCanSyncCloseReasonToAlerts({ + totalAlerts, + syncAlertsEnabled, + }); + const onCloseCase = useCallback( + (closeReason?: string) => { + if (closeReason == null) { + onStatusChanged(CaseStatuses.closed); + return; + } + onStatusChanged(CaseStatuses.closed, closeReason); + }, + [onStatusChanged] + ); + const { openCloseCaseModal, closeCaseModal } = useCloseCaseModal({ + canSyncCloseReasonToAlerts, + onCloseCase, + }); + const indexOfCurrentStatus = useMemo( () => caseStatuses.findIndex((caseStatus) => caseStatus === status), [status] ); const nextStatusIndex = useMemo(() => getNextItem(indexOfCurrentStatus), [indexOfCurrentStatus]); + const nextStatus = caseStatuses[nextStatusIndex]; const onClick = useCallback(() => { - onStatusChanged(caseStatuses[nextStatusIndex]); - }, [nextStatusIndex, onStatusChanged]); + if (nextStatus === CaseStatuses.closed) { + openCloseCaseModal(); + return; + } + + onStatusChanged(nextStatus); + }, [nextStatus, onStatusChanged, openCloseCaseModal]); return ( - - {statuses[caseStatuses[nextStatusIndex]].button.label} - + <> + + {statuses[caseStatuses[nextStatusIndex]].button.label} + + {closeCaseModal} + ); }; StatusActionButtonComponent.displayName = 'StatusActionButton'; diff --git a/x-pack/platform/plugins/shared/cases/public/components/user_actions/status.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/user_actions/status.test.tsx index 79ed2f79eddfa..376d1cb86017c 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/user_actions/status.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/user_actions/status.test.tsx @@ -48,4 +48,25 @@ describe('createStatusUserActionBuilder ', () => { expect(screen.getByText('marked case as')).toBeInTheDocument(); expect(screen.getByText(label)).toBeInTheDocument(); }); + + it('renders close reason details when provided by status user action', () => { + const userAction = getUserAction('status', UserActionActions.update, { + payload: { status: CaseStatuses.closed, closeReason: 'false_positive' }, + }); + const builder = createStatusUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + expect(screen.getByText('and synced alerts with close reason:')).toBeInTheDocument(); + expect(screen.getByText('false_positive')).toBeInTheDocument(); + expect(screen.getByTestId('status-update-user-action-close-reason-badge')).toBeInTheDocument(); + }); }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/user_actions/status.tsx b/x-pack/platform/plugins/shared/cases/public/components/user_actions/status.tsx index 9d384500fb09b..808487d4d5525 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/user_actions/status.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/user_actions/status.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { Status } from '@kbn/cases-components/src/status/status'; import type { SnakeToCamelCase } from '../../../common/types'; import type { StatusUserAction, CaseStatuses } from '../../../common/types/domain'; @@ -19,6 +19,8 @@ const isStatusValid = (status: string): status is CaseStatuses => Object.hasOwn( const getLabelTitle = (userAction: SnakeToCamelCase) => { const status = userAction.payload.status ?? ''; + const closeReason = userAction.payload.closeReason; + if (isStatusValid(status)) { return ( ) => { + {closeReason != null && ( + <> + {i18n.SYNCED_ALERTS_CLOSE_REASON} + + {closeReason} + + + )} ); } diff --git a/x-pack/platform/plugins/shared/cases/public/components/user_actions/translations.ts b/x-pack/platform/plugins/shared/cases/public/components/user_actions/translations.ts index e4e45d531b262..3d1381c46bd26 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/user_actions/translations.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/user_actions/translations.ts @@ -160,3 +160,10 @@ export const USER_ACTION_EDITED = (type: string) => values: { type }, defaultMessage: `Edited "{type}"`, }); + +export const SYNCED_ALERTS_CLOSE_REASON = i18n.translate( + 'xpack.cases.caseView.userActions.status.syncedAlertsCloseReason', + { + defaultMessage: 'and synced alerts with close reason', + } +); diff --git a/x-pack/platform/plugins/shared/cases/server/client/alerts/types.ts b/x-pack/platform/plugins/shared/cases/server/client/alerts/types.ts index be1907b0a5e87..3e80a1cf47df1 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/alerts/types.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/alerts/types.ts @@ -28,6 +28,7 @@ export interface UpdateAlertStatusRequest { id: string; index: string; status: CaseStatuses; + closingReason?: string; } export interface AlertUpdateStatus { diff --git a/x-pack/platform/plugins/shared/cases/server/client/cases/bulk_update.test.ts b/x-pack/platform/plugins/shared/cases/server/client/cases/bulk_update.test.ts index f19b634912410..0c4fcb8c4ab57 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/cases/bulk_update.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/cases/bulk_update.test.ts @@ -17,7 +17,7 @@ import { MAX_ASSIGNEES_PER_CASE, MAX_CUSTOM_FIELDS_PER_CASE, } from '../../../common/constants'; -import { mockCases } from '../../mocks'; +import { mockCaseComments, mockCases } from '../../mocks'; import { createCasesClientMock, createCasesClientMockArgs } from '../mocks'; import { Operations } from '../../authorization'; import { bulkUpdate, getOperationsToAuthorize } from './bulk_update'; @@ -1962,5 +1962,54 @@ describe('update', () => { expect(updatedAttributes.time_to_investigate).toEqual(expect.any(Number)); expect(updatedAttributes.time_to_resolve).toEqual(expect.any(Number)); }); + + it('propagates closeReason to alerts without persisting it on cases', async () => { + const closeReason = 'false_positive'; + const alertComment = { + ...mockCaseComments[3], + references: [ + { + ...mockCaseComments[3].references[0], + id: mockCases[0].id, + }, + ], + }; + + clientArgs.services.caseService.getAllCaseComments.mockResolvedValue({ + saved_objects: [alertComment], + total: 1, + per_page: 10, + page: 1, + }); + + await bulkUpdate( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + status: CaseStatuses.closed, + closeReason, + }, + ], + }, + clientArgs, + casesClientMock + ); + + expect(clientArgs.services.alertsService.updateAlertsStatus).toHaveBeenCalledWith([ + { + id: 'test-id', + index: 'test-index', + status: CaseStatuses.closed, + closingReason: closeReason, + }, + ]); + + const updatedAttributes = + clientArgs.services.caseService.patchCases.mock.calls[0][0].cases[0].updatedAttributes; + + expect(updatedAttributes).not.toHaveProperty('closeReason'); + }); }); }); diff --git a/x-pack/platform/plugins/shared/cases/server/client/cases/bulk_update.ts b/x-pack/platform/plugins/shared/cases/server/client/cases/bulk_update.ts index 304cd220b92a2..7c237a9e33e12 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/cases/bulk_update.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/cases/bulk_update.ts @@ -205,15 +205,15 @@ function getSyncStatusForComment({ casesToSyncToStatus, }: { alertComment: SavedObjectsFindResult; - casesToSyncToStatus: Map; -}): CaseStatuses { + casesToSyncToStatus: Map; +}): [CaseStatuses, string?] { const id = getID(alertComment, CASE_SAVED_OBJECT); if (!id) { - return CaseStatuses.open; + return [CaseStatuses.open, undefined]; } - return casesToSyncToStatus.get(id) ?? CaseStatuses.open; + return casesToSyncToStatus.get(id) ?? [CaseStatuses.open, undefined]; } /** @@ -236,11 +236,16 @@ async function updateAlerts({ */ const casesToSync = [...casesWithSyncSettingChangedToOn, ...casesWithStatusChangedAndSynced]; - // build a map of case id to the status it has + // build a map of case id to the status it has, and optionally a closing reason const casesToSyncToStatus = casesToSync.reduce((acc, { updateReq, originalCase }) => { - acc.set(updateReq.id, updateReq.status ?? originalCase.attributes.status ?? CaseStatuses.open); + acc.set(updateReq.id, [ + updateReq.status ?? originalCase.attributes.status ?? CaseStatuses.open, + updateReq.status && updateReq.status === CaseStatuses.closed + ? updateReq.closeReason + : undefined, + ]); return acc; - }, new Map()); + }, new Map()); // get all the alerts for all the alert comments for all cases const totalAlerts = await getAlertComments({ @@ -252,12 +257,18 @@ async function updateAlerts({ const alertsToUpdate = totalAlerts.saved_objects.reduce( (acc: UpdateAlertStatusRequest[], alertComment) => { if (isCommentRequestTypeAlert(alertComment.attributes)) { - const status = getSyncStatusForComment({ + const statusAndReason = getSyncStatusForComment({ alertComment, casesToSyncToStatus, }); - acc.push(...createAlertUpdateStatusRequest({ comment: alertComment.attributes, status })); + acc.push( + ...createAlertUpdateStatusRequest({ + comment: alertComment.attributes, + status: statusAndReason[0], + closingReason: statusAndReason[1], + }) + ); } return acc; @@ -448,11 +459,16 @@ export const bulkUpdate = async ( } const fieldsToUpdate = getCaseToUpdate(originalCase.attributes, updateCase); + // Explicitly add the closing reason if it exists in the request + const fieldsToUpdateIncludingCloseReason = + fieldsToUpdate.status === CaseStatuses.closed && updateCase.closeReason != null + ? { ...fieldsToUpdate, closeReason: updateCase.closeReason } + : fieldsToUpdate; - const { id, version, ...restFields } = fieldsToUpdate; + const { id, version, ...restFields } = fieldsToUpdateIncludingCloseReason; if (Object.keys(restFields).length > 0) { - acc.push({ originalCase, updateReq: fieldsToUpdate }); + acc.push({ originalCase, updateReq: fieldsToUpdateIncludingCloseReason }); } return acc; @@ -583,7 +599,10 @@ export const bulkUpdate = async ( }; const normalizeCaseAttributes = ( - updateCaseAttributes: Omit, + updateCaseAttributes: Omit< + CasePatchRequest, + 'id' | 'version' | 'owner' | 'assignees' | 'closeReason' + >, customFieldsConfiguration?: CustomFieldsConfiguration ) => { let trimmedAttributes = { ...updateCaseAttributes }; @@ -636,8 +655,15 @@ const createPatchCasesPayload = ({ return { cases: casesToUpdate.map(({ updateReq, originalCase }) => { - // intentionally removing owner from the case so that we don't accidentally allow it to be updated - const { id: caseId, version, owner, assignees, ...updateCaseAttributes } = updateReq; + // intentionally removing owner and closeReason from the case so that we don't accidentally allow it to be updated + const { + id: caseId, + version, + owner, + assignees, + closeReason: _closeReason, + ...updateCaseAttributes + } = updateReq; const dedupedAssignees = dedupAssignees(assignees); @@ -649,6 +675,7 @@ const createPatchCasesPayload = ({ return { caseId, originalCase, + closeReason: updateReq.closeReason, updatedAttributes: { ...trimmedCaseAttributes, ...(dedupedAssignees && { assignees: dedupedAssignees }), diff --git a/x-pack/platform/plugins/shared/cases/server/common/utils.ts b/x-pack/platform/plugins/shared/cases/server/common/utils.ts index e1660cf66defd..260270eafea2e 100644 --- a/x-pack/platform/plugins/shared/cases/server/common/utils.ts +++ b/x-pack/platform/plugins/shared/cases/server/common/utils.ts @@ -321,11 +321,13 @@ export const isFileAttachmentRequest = ( export function createAlertUpdateStatusRequest({ comment, status, + closingReason, }: { comment: AttachmentRequest; status: CaseStatuses; + closingReason?: string; }): UpdateAlertStatusRequest[] { - return getAlertInfoFromComments([comment]).map((alert) => ({ ...alert, status })); + return getAlertInfoFromComments([comment]).map((alert) => ({ ...alert, status, closingReason })); } /** diff --git a/x-pack/platform/plugins/shared/cases/server/services/alerts/index.test.ts b/x-pack/platform/plugins/shared/cases/server/services/alerts/index.test.ts index c930c4be1bd47..026470549f993 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/alerts/index.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/alerts/index.test.ts @@ -50,13 +50,27 @@ describe('updateAlertsStatus', () => { }, "script": Object { "lang": "painless", - "source": "if (ctx._source['kibana.alert.workflow_status'] != null) { - ctx._source['kibana.alert.workflow_status'] = 'closed'; - ctx._source['kibana.alert.workflow_status_updated_at'] = '2022-02-21T17:35:00.000Z'; - } - if (ctx._source.signal != null && ctx._source.signal.status != null) { - ctx._source.signal.status = 'closed' - }", + "params": Object { + "reason": null, + "shouldRemoveWorkflowReason": false, + "status": "closed", + "updatedAt": "2022-02-21T17:35:00.000Z", + }, + "source": " + if (ctx._source['kibana.alert.workflow_status'] != null && ctx._source['kibana.alert.workflow_status'] != params.status) { + ctx._source['kibana.alert.workflow_status'] = params.status; + ctx._source['kibana.alert.workflow_status_updated_at'] = params.updatedAt; + } + if (ctx._source.signal != null && ctx._source.signal.status != null) { + ctx._source.signal.status = params.status; + } + if (params.reason != null) { + ctx._source['kibana.alert.workflow_reason'] = params.reason; + } + if (params.shouldRemoveWorkflowReason) { + ctx._source.remove('kibana.alert.workflow_reason'); + } + ", }, }, ] @@ -88,13 +102,27 @@ describe('updateAlertsStatus', () => { }, "script": Object { "lang": "painless", - "source": "if (ctx._source['kibana.alert.workflow_status'] != null) { - ctx._source['kibana.alert.workflow_status'] = 'closed'; - ctx._source['kibana.alert.workflow_status_updated_at'] = '2022-02-21T17:35:00.000Z'; - } - if (ctx._source.signal != null && ctx._source.signal.status != null) { - ctx._source.signal.status = 'closed' - }", + "params": Object { + "reason": null, + "shouldRemoveWorkflowReason": false, + "status": "closed", + "updatedAt": "2022-02-21T17:35:00.000Z", + }, + "source": " + if (ctx._source['kibana.alert.workflow_status'] != null && ctx._source['kibana.alert.workflow_status'] != params.status) { + ctx._source['kibana.alert.workflow_status'] = params.status; + ctx._source['kibana.alert.workflow_status_updated_at'] = params.updatedAt; + } + if (ctx._source.signal != null && ctx._source.signal.status != null) { + ctx._source.signal.status = params.status; + } + if (params.reason != null) { + ctx._source['kibana.alert.workflow_reason'] = params.reason; + } + if (params.shouldRemoveWorkflowReason) { + ctx._source.remove('kibana.alert.workflow_reason'); + } + ", }, }, ] @@ -122,14 +150,27 @@ describe('updateAlertsStatus', () => { }, "script": Object { "lang": "painless", - "source": "if (ctx._source['kibana.alert.workflow_status'] != null) { - ctx._source['kibana.alert.workflow_status'] = 'acknowledged'; - ctx._source['kibana.alert.workflow_status_updated_at'] = '2022-02-21T17:35:00.000Z'; - } - if (ctx._source.signal != null && ctx._source.signal.status != null) { - ctx._source.signal.status = 'acknowledged' - } - ctx._source.remove('kibana.alert.workflow_reason')", + "params": Object { + "reason": null, + "shouldRemoveWorkflowReason": true, + "status": "acknowledged", + "updatedAt": "2022-02-21T17:35:00.000Z", + }, + "source": " + if (ctx._source['kibana.alert.workflow_status'] != null && ctx._source['kibana.alert.workflow_status'] != params.status) { + ctx._source['kibana.alert.workflow_status'] = params.status; + ctx._source['kibana.alert.workflow_status_updated_at'] = params.updatedAt; + } + if (ctx._source.signal != null && ctx._source.signal.status != null) { + ctx._source.signal.status = params.status; + } + if (params.reason != null) { + ctx._source['kibana.alert.workflow_reason'] = params.reason; + } + if (params.shouldRemoveWorkflowReason) { + ctx._source.remove('kibana.alert.workflow_reason'); + } + ", }, }, ] @@ -161,13 +202,27 @@ describe('updateAlertsStatus', () => { }, "script": Object { "lang": "painless", - "source": "if (ctx._source['kibana.alert.workflow_status'] != null) { - ctx._source['kibana.alert.workflow_status'] = 'closed'; - ctx._source['kibana.alert.workflow_status_updated_at'] = '2022-02-21T17:35:00.000Z'; - } - if (ctx._source.signal != null && ctx._source.signal.status != null) { - ctx._source.signal.status = 'closed' - }", + "params": Object { + "reason": null, + "shouldRemoveWorkflowReason": false, + "status": "closed", + "updatedAt": "2022-02-21T17:35:00.000Z", + }, + "source": " + if (ctx._source['kibana.alert.workflow_status'] != null && ctx._source['kibana.alert.workflow_status'] != params.status) { + ctx._source['kibana.alert.workflow_status'] = params.status; + ctx._source['kibana.alert.workflow_status_updated_at'] = params.updatedAt; + } + if (ctx._source.signal != null && ctx._source.signal.status != null) { + ctx._source.signal.status = params.status; + } + if (params.reason != null) { + ctx._source['kibana.alert.workflow_reason'] = params.reason; + } + if (params.shouldRemoveWorkflowReason) { + ctx._source.remove('kibana.alert.workflow_reason'); + } + ", }, }, ] @@ -189,14 +244,27 @@ describe('updateAlertsStatus', () => { }, "script": Object { "lang": "painless", - "source": "if (ctx._source['kibana.alert.workflow_status'] != null) { - ctx._source['kibana.alert.workflow_status'] = 'open'; - ctx._source['kibana.alert.workflow_status_updated_at'] = '2022-02-21T17:35:00.000Z'; - } - if (ctx._source.signal != null && ctx._source.signal.status != null) { - ctx._source.signal.status = 'open' - } - ctx._source.remove('kibana.alert.workflow_reason')", + "params": Object { + "reason": null, + "shouldRemoveWorkflowReason": true, + "status": "open", + "updatedAt": "2022-02-21T17:35:00.000Z", + }, + "source": " + if (ctx._source['kibana.alert.workflow_status'] != null && ctx._source['kibana.alert.workflow_status'] != params.status) { + ctx._source['kibana.alert.workflow_status'] = params.status; + ctx._source['kibana.alert.workflow_status_updated_at'] = params.updatedAt; + } + if (ctx._source.signal != null && ctx._source.signal.status != null) { + ctx._source.signal.status = params.status; + } + if (params.reason != null) { + ctx._source['kibana.alert.workflow_reason'] = params.reason; + } + if (params.shouldRemoveWorkflowReason) { + ctx._source.remove('kibana.alert.workflow_reason'); + } + ", }, }, ] @@ -228,13 +296,27 @@ describe('updateAlertsStatus', () => { }, "script": Object { "lang": "painless", - "source": "if (ctx._source['kibana.alert.workflow_status'] != null) { - ctx._source['kibana.alert.workflow_status'] = 'closed'; - ctx._source['kibana.alert.workflow_status_updated_at'] = '2022-02-21T17:35:00.000Z'; - } - if (ctx._source.signal != null && ctx._source.signal.status != null) { - ctx._source.signal.status = 'closed' - }", + "params": Object { + "reason": null, + "shouldRemoveWorkflowReason": false, + "status": "closed", + "updatedAt": "2022-02-21T17:35:00.000Z", + }, + "source": " + if (ctx._source['kibana.alert.workflow_status'] != null && ctx._source['kibana.alert.workflow_status'] != params.status) { + ctx._source['kibana.alert.workflow_status'] = params.status; + ctx._source['kibana.alert.workflow_status_updated_at'] = params.updatedAt; + } + if (ctx._source.signal != null && ctx._source.signal.status != null) { + ctx._source.signal.status = params.status; + } + if (params.reason != null) { + ctx._source['kibana.alert.workflow_reason'] = params.reason; + } + if (params.shouldRemoveWorkflowReason) { + ctx._source.remove('kibana.alert.workflow_reason'); + } + ", }, }, ] @@ -256,14 +338,27 @@ describe('updateAlertsStatus', () => { }, "script": Object { "lang": "painless", - "source": "if (ctx._source['kibana.alert.workflow_status'] != null) { - ctx._source['kibana.alert.workflow_status'] = 'open'; - ctx._source['kibana.alert.workflow_status_updated_at'] = '2022-02-21T17:35:00.000Z'; - } - if (ctx._source.signal != null && ctx._source.signal.status != null) { - ctx._source.signal.status = 'open' - } - ctx._source.remove('kibana.alert.workflow_reason')", + "params": Object { + "reason": null, + "shouldRemoveWorkflowReason": true, + "status": "open", + "updatedAt": "2022-02-21T17:35:00.000Z", + }, + "source": " + if (ctx._source['kibana.alert.workflow_status'] != null && ctx._source['kibana.alert.workflow_status'] != params.status) { + ctx._source['kibana.alert.workflow_status'] = params.status; + ctx._source['kibana.alert.workflow_status_updated_at'] = params.updatedAt; + } + if (ctx._source.signal != null && ctx._source.signal.status != null) { + ctx._source.signal.status = params.status; + } + if (params.reason != null) { + ctx._source['kibana.alert.workflow_reason'] = params.reason; + } + if (params.shouldRemoveWorkflowReason) { + ctx._source.remove('kibana.alert.workflow_reason'); + } + ", }, }, ] diff --git a/x-pack/platform/plugins/shared/cases/server/services/alerts/index.ts b/x-pack/platform/plugins/shared/cases/server/services/alerts/index.ts index c9246186cc723..c57ea0ec81e79 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/alerts/index.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/alerts/index.ts @@ -88,13 +88,12 @@ export class AlertService { public async updateAlertsStatus(alerts: UpdateAlertStatusRequest[]) { try { - const bucketedAlerts = this.bucketAlertsByIndexAndStatus(alerts); + const bucketedAlerts = this.bucketAlerts(alerts); const indexBuckets = Array.from(bucketedAlerts.entries()); await pMap( indexBuckets, - async (indexBucket: [string, Map]) => - this.updateByQuery(indexBucket), + async (indexBucket: [string, StatusAndReasonBuckets]) => this.updateByQuery(indexBucket), { concurrency: MAX_CONCURRENT_SEARCHES } ); } catch (error) { @@ -106,35 +105,23 @@ export class AlertService { } } - private bucketAlertsByIndexAndStatus( - alerts: UpdateAlertStatusRequest[] - ): Map> { - return alerts.reduce>>( - (acc, alert) => { - // skip any alerts that are empty - if (AlertService.isEmptyAlert(alert)) { - return acc; - } - - const translatedAlert = { ...alert, status: this.translateStatus(alert) }; - const statusToAlertId = acc.get(translatedAlert.index); - - // if we haven't seen the index before - if (!statusToAlertId) { - // add a new index in the parent map, with an entry for the status the alert set to pointing - // to an initial array of only the current alert - acc.set(translatedAlert.index, createStatusToAlertMap(translatedAlert)); - } else { - // We had the index in the map so check to see if we have a bucket for the - // status, if not add a new status entry with the alert, if so update the status entry - // with the alert - updateIndexEntryWithStatus(statusToAlertId, translatedAlert); - } - + private bucketAlerts(alerts: UpdateAlertStatusRequest[]): Map { + return alerts.reduce>((acc, alert) => { + if (AlertService.isEmptyAlert(alert)) { return acc; - }, - new Map() - ); + } + + const translatedAlert = { ...alert, status: this.translateStatus(alert) }; + const statusAndReasonBuckets = acc.get(translatedAlert.index); + + if (!statusAndReasonBuckets) { + acc.set(translatedAlert.index, createStatusAndReasonBuckets(translatedAlert)); + } else { + updateIndexEntryWithStatusAndReason(statusAndReasonBuckets, translatedAlert); + } + + return acc; + }, new Map()); } private static isEmptyAlert(alert: AlertInfo): boolean { @@ -159,37 +146,21 @@ export class AlertService { return translatedStatus ?? 'open'; } - private async updateByQuery([index, statusToAlertMap]: [ - string, - Map - ]) { - const statusBuckets = Array.from(statusToAlertMap); + private async updateByQuery([index, statusAndReasonBuckets]: [string, StatusAndReasonBuckets]) { + const statusBuckets = Array.from(statusAndReasonBuckets.entries()); return Promise.all( - // this will create three update by query calls one for each of the three statuses - statusBuckets.map(([status, translatedAlerts]) => - this.scopedClusterClient.updateByQuery({ - index, - conflicts: 'abort', - script: { - source: `if (ctx._source['${ALERT_WORKFLOW_STATUS}'] != null) { - ctx._source['${ALERT_WORKFLOW_STATUS}'] = '${status}'; - ctx._source['${ALERT_WORKFLOW_STATUS_UPDATED_AT}'] = '${new Date().toISOString()}'; - } - if (ctx._source.signal != null && ctx._source.signal.status != null) { - ctx._source.signal.status = '${status}' - }${ - status !== 'closed' - ? ` - ctx._source.remove('${ALERT_WORKFLOW_REASON}')` - : '' - }`, - lang: 'painless', - }, - // the query here will contain all the ids that have the same status for the same index - // being updated - query: { ids: { values: translatedAlerts.map(({ id }) => id) } }, - ignore_unavailable: true, - }) + // this will create up to three update-by-query calls per status, split by close reason when closed + statusBuckets.flatMap(([status, reasonToAlerts]) => + Array.from(reasonToAlerts.entries()).map(([reason, alerts]) => + this.scopedClusterClient.updateByQuery({ + index, + conflicts: 'abort', + script: getUpdateAlertsStatusScript(status, reason), + // the query here will contain all the ids that have the same status (and reason for closed) + query: { ids: { values: alerts.map(({ id }) => id) } }, + ignore_unavailable: true, + }) + ) ) ); } @@ -311,26 +282,74 @@ interface TranslatedUpdateAlertRequest { id: string; index: string; status: STATUS_VALUES; + closingReason?: string; } - -function createStatusToAlertMap( +/** + * Buckets translated alerts by status, and then by close reason. + * Non-closed statuses use the `undefined` reason bucket. + */ +type StatusAndReasonBuckets = Map< + STATUS_VALUES, + Map +>; + +const getUpdateAlertsStatusScript = (status: STATUS_VALUES, reason?: string) => ({ + source: ` + if (ctx._source['${ALERT_WORKFLOW_STATUS}'] != null && ctx._source['${ALERT_WORKFLOW_STATUS}'] != params.status) { + ctx._source['${ALERT_WORKFLOW_STATUS}'] = params.status; + ctx._source['${ALERT_WORKFLOW_STATUS_UPDATED_AT}'] = params.updatedAt; + } + if (ctx._source.signal != null && ctx._source.signal.status != null) { + ctx._source.signal.status = params.status; + } + if (params.reason != null) { + ctx._source['${ALERT_WORKFLOW_REASON}'] = params.reason; + } + if (params.shouldRemoveWorkflowReason) { + ctx._source.remove('${ALERT_WORKFLOW_REASON}'); + } + `, + lang: 'painless', + params: { + status, + updatedAt: new Date().toISOString(), + shouldRemoveWorkflowReason: status !== 'closed', + reason: reason ?? null, + }, +}); + +const getReasonBucketKey = (alert: TranslatedUpdateAlertRequest): string | undefined => { + return alert.status === 'closed' ? alert.closingReason : undefined; +}; + +const createStatusAndReasonBuckets = ( alert: TranslatedUpdateAlertRequest -): Map { - return new Map([[alert.status, [alert]]]); -} - -function updateIndexEntryWithStatus( - statusToAlerts: Map, +): StatusAndReasonBuckets => { + return new Map>([ + [alert.status, new Map([[getReasonBucketKey(alert), [alert]]])], + ]); +}; + +const updateIndexEntryWithStatusAndReason = ( + statusAndReasonBuckets: StatusAndReasonBuckets, alert: TranslatedUpdateAlertRequest -) { - const statusBucket = statusToAlerts.get(alert.status); +) => { + const reasonBucketKey = getReasonBucketKey(alert); + const reasonToAlerts = statusAndReasonBuckets.get(alert.status); - if (!statusBucket) { - statusToAlerts.set(alert.status, [alert]); - } else { - statusBucket.push(alert); + if (!reasonToAlerts) { + statusAndReasonBuckets.set(alert.status, new Map([[reasonBucketKey, [alert]]])); + return; } -} + + const alerts = reasonToAlerts.get(reasonBucketKey); + if (!alerts) { + reasonToAlerts.set(reasonBucketKey, [alert]); + return; + } + + alerts.push(alert); +}; export interface Alert { _id: string; diff --git a/x-pack/platform/plugins/shared/cases/server/services/cases/types.ts b/x-pack/platform/plugins/shared/cases/server/services/cases/types.ts index 8c26c7904d50b..272ef82d9e9bc 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/cases/types.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/cases/types.ts @@ -65,6 +65,7 @@ export interface PatchCase extends IndexRefresh { caseId: string; updatedAttributes: Partial; originalCase: CaseSavedObjectTransformed; + closeReason?: string; version?: string; } diff --git a/x-pack/platform/plugins/shared/cases/server/services/user_actions/builder_factory.test.ts b/x-pack/platform/plugins/shared/cases/server/services/user_actions/builder_factory.test.ts index 260c0c058925a..0feff7a0a8e1d 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/user_actions/builder_factory.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/user_actions/builder_factory.test.ts @@ -1107,6 +1107,26 @@ describe('UserActionBuilder', () => { ); }); + it('logs a status user action with close reason when alerts are synced', () => { + const builder = builderFactory.getBuilder(UserActionTypes.status)!; + const userAction = builder.build({ + payload: { + status: CaseStatuses.closed, + closeReason: 'false_positive', + syncAlerts: true, + }, + ...commonArgs, + }); + + expect(userAction!.eventDetails.getMessage('123')).toMatchInlineSnapshot( + `"User closed case id: 123 and synced alerts with close reason: false_positive - user action id: 123"` + ); + expect(userAction!.parameters.attributes.payload).toEqual({ + status: 'closed', + closeReason: 'false_positive', + }); + }); + it('logs a severity user action correctly', () => { const builder = builderFactory.getBuilder(UserActionTypes.severity)!; const userAction = builder.build({ diff --git a/x-pack/platform/plugins/shared/cases/server/services/user_actions/builders/status.ts b/x-pack/platform/plugins/shared/cases/server/services/user_actions/builders/status.ts index f2aa1455d90d3..6120949b21fbc 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/user_actions/builders/status.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/user_actions/builders/status.ts @@ -6,24 +6,42 @@ */ import { CASE_SAVED_OBJECT } from '../../../../common/constants'; -import { UserActionActions, UserActionTypes } from '../../../../common/types/domain'; +import { CaseStatuses, UserActionActions, UserActionTypes } from '../../../../common/types/domain'; import { UserActionBuilder } from '../abstract_builder'; -import type { EventDetails, UserActionParameters, UserActionEvent } from '../types'; +import type { + EventDetails, + UserActionParameters, + UserActionEvent, + SavedObjectParameters, +} from '../types'; export class StatusUserActionBuilder extends UserActionBuilder { build(args: UserActionParameters<'status'>): UserActionEvent { const action = UserActionActions.update; + const shouldLogCloseReasonSyncMessage = + args.payload.status === CaseStatuses.closed && + args.payload.syncAlerts === true && + args.payload.closeReason != null; - const parameters = this.buildCommonUserAction({ - ...args, - action, - valueKey: 'status', - value: args.payload.status, - type: UserActionTypes.status, - }); - + const parameters: SavedObjectParameters = { + attributes: { + ...this.getCommonUserActionAttributes({ + user: args.user, + owner: args.owner, + }), + action, + payload: { + status: args.payload.status, + ...(shouldLogCloseReasonSyncMessage ? { closeReason: args.payload.closeReason } : {}), + }, + type: UserActionTypes.status, + }, + references: this.createCaseReferences(args.caseId), + }; const getMessage = (id?: string) => - `User updated the status for case id: ${args.caseId} - user action id: ${id}`; + shouldLogCloseReasonSyncMessage + ? `User closed case id: ${args.caseId} and synced alerts with close reason: ${args.payload.closeReason} - user action id: ${id}` + : `User updated the status for case id: ${args.caseId} - user action id: ${id}`; const eventDetails: EventDetails = { getMessage, diff --git a/x-pack/platform/plugins/shared/cases/server/services/user_actions/operations/create.test.ts b/x-pack/platform/plugins/shared/cases/server/services/user_actions/operations/create.test.ts index 986029127f95b..cc7af206866b8 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/user_actions/operations/create.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/user_actions/operations/create.test.ts @@ -45,7 +45,7 @@ import { patchBothSettingsCasesRequest, getBothSettingsUserActions, } from '../mocks'; -import { AttachmentType } from '../../../../common/types/domain'; +import { AttachmentType, UserActionTypes } from '../../../../common/types/domain'; describe('UserActionPersister', () => { const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); @@ -315,6 +315,46 @@ describe('UserActionPersister', () => { ).toEqual(getBothSettingsUserActions({ isMock: false })); }); + it('adds close reason details to the status audit message when alerts are synced', () => { + const updatedCases = { + ...patchCasesRequest, + cases: patchCasesRequest.cases.map((theCase) => { + if (theCase.caseId !== '1') { + return theCase; + } + + return { + ...theCase, + closeReason: 'false_positive', + updatedAttributes: { + ...theCase.updatedAttributes, + settings: { + syncAlerts: true, + extractObservables: false, + }, + }, + }; + }), + }; + + const builtUserActions = persister.buildUserActions({ + updatedCases, + user: testUser, + }); + + const statusAction = builtUserActions['1'].find( + ({ parameters }) => parameters.attributes.type === UserActionTypes.status + ); + + expect(statusAction?.eventDetails.getMessage('status-user-action-id')).toBe( + 'User closed case id: 1 and synced alerts with close reason: false_positive - user action id: status-user-action-id' + ); + expect(statusAction?.parameters.attributes.payload).toEqual({ + status: 'closed', + closeReason: 'false_positive', + }); + }); + describe('customFields', () => { it('creates the correct user actions when adding a new custom field to a case without custom fields', async () => { expect( diff --git a/x-pack/platform/plugins/shared/cases/server/services/user_actions/operations/create.ts b/x-pack/platform/plugins/shared/cases/server/services/user_actions/operations/create.ts index 7ff889fdc1a9c..3abf576cebd69 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/user_actions/operations/create.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/user_actions/operations/create.ts @@ -85,6 +85,29 @@ export class UserActionPersister { updatedFields .filter((field) => UserActionPersister.userActionFieldsAllowed.has(field)) .forEach((field) => { + // Special case for status as it can possibly have an associated closeReason (syncing to alerts) + // Persist the closeReason to the status userAction + if (field === UserActionTypes.status && updatedCase.updatedAttributes.status != null) { + const userActionBuilder = this.builderFactory.getBuilder(UserActionTypes.status); + const statusUserAction = userActionBuilder?.build({ + caseId, + owner, + user, + payload: { + status: updatedCase.updatedAttributes.status, + closeReason: updatedCase.closeReason, + syncAlerts: + updatedCase.updatedAttributes.settings?.syncAlerts ?? + originalCase.attributes.settings.syncAlerts, + }, + }); + + if (statusUserAction != null) { + userActions.push(statusUserAction); + } + return; + } + const originalValue = get(originalCase, ['attributes', field]); const newValue = get(updatedCase, ['updatedAttributes', field]); userActions.push( diff --git a/x-pack/platform/plugins/shared/cases/server/services/user_actions/types.ts b/x-pack/platform/plugins/shared/cases/server/services/user_actions/types.ts index 0dd02c52f4da2..ef2a5745f258d 100644 --- a/x-pack/platform/plugins/shared/cases/server/services/user_actions/types.ts +++ b/x-pack/platform/plugins/shared/cases/server/services/user_actions/types.ts @@ -49,7 +49,9 @@ export interface BuilderParameters { parameters: { payload: { description: string } }; }; status: { - parameters: { payload: { status: CaseStatuses } }; + parameters: { + payload: { status: CaseStatuses; closeReason?: string; syncAlerts?: boolean }; + }; }; severity: { parameters: { payload: { severity: CaseSeverity } }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_closing_reason.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_closing_reason.test.tsx deleted file mode 100644 index 758b1d990ea19..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_closing_reason.test.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { render } from '@testing-library/react'; -import { BulkAlertClosingReason, defaultClosingReasons } from './alert_bulk_closing_reason'; -import React from 'react'; - -jest.mock('@kbn/kibana-react-plugin/public', () => ({ - useKibana: () => ({ - services: { - uiSettings: { - get: jest.fn(() => []), - }, - }, - }), -})); - -describe('BulkAlertClosingReason', () => { - defaultClosingReasons.forEach((item) => { - it(`"${item.label}" should be visible in the document`, () => { - const { getByText } = render(); - expect(getByText(item.label)).toBeInTheDocument(); - }); - }); -}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_closing_reason.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_closing_reason.tsx index e07401e7f8692..e69de29bb2d1d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_closing_reason.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_closing_reason.tsx @@ -1,96 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useCallback, useMemo, useState } from 'react'; -import { EuiButton, EuiSelectable } from '@elastic/eui'; -import type { EuiSelectableOption } from '@elastic/eui'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import type { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; -import { DEFAULT_ALERT_CLOSE_REASONS_KEY } from '../../../../../common/constants'; -import * as i18n from './translations'; -import { AlertDefaultClosingReasonValues } from '../../../../../common/types'; -import type { AlertClosingReason } from '../../../../../common/types'; - -export const defaultClosingReasons: EuiSelectableOption<{ - key?: AlertClosingReason; -}>[] = [ - { label: i18n.CLOSING_REASON_CLOSE_WITHOUT_REASON, key: undefined }, - - { label: i18n.CLOSING_REASON_DUPLICATE, key: AlertDefaultClosingReasonValues.duplicate }, - { - label: i18n.CLOSING_REASON_FALSE_POSITIVE, - key: AlertDefaultClosingReasonValues.false_positive, - }, - { label: i18n.CLOSING_REASON_TRUE_POSITIVE, key: AlertDefaultClosingReasonValues.true_positive }, - { - label: i18n.CLOSING_REASON_BENIGN_POSITIVE, - key: AlertDefaultClosingReasonValues.benign_positive, - }, - { label: i18n.CLOSING_REASON_OTHER, key: AlertDefaultClosingReasonValues.other }, -]; - -interface BulkAlertClosingReasonComponentProps { - /** - * Callback call once the user confirm their selection. - * The reason passed is of type AlertClosingReasonValues or - * `undefined` in case the user selected the option "close without reason" - * @param reason - */ - onSubmit(reason?: AlertClosingReason): void; -} - -/** - * Renders the list of available closing action for - * the alerts and the confirm button - */ -const BulkAlertClosingReasonComponent: React.FC = ({ - onSubmit, -}) => { - const { - services: { uiSettings }, - } = useKibana<{ uiSettings: IUiSettingsClient }>(); - - const customClosingReasons = uiSettings.get(DEFAULT_ALERT_CLOSE_REASONS_KEY); - const [options, setOptions] = useState< - EuiSelectableOption<{ - key?: AlertClosingReason; - }>[] - >([ - ...defaultClosingReasons, - ...customClosingReasons.map((reason) => { - return { - label: reason, - key: reason, - }; - }), - ]); - - const selectedOption = useMemo(() => options.find((option) => option.checked), [options]); - - const onSubmitHandler = useCallback(() => { - if (!selectedOption) return; - - onSubmit(selectedOption.key); - }, [onSubmit, selectedOption]); - - return ( - <> - setOptions(updatedOptions)} - singleSelection="always" - > - {(list) => list} - - - {i18n.ALERT_CLOSING_REASON_BUTTON_MESSAGE} - - - ); -}; - -export const BulkAlertClosingReason = memo(BulkAlertClosingReasonComponent); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/translations.ts index bff26816440f7..c91606e61e349 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/translations.ts @@ -225,57 +225,3 @@ export const REMOVE_ALERT_ASSIGNEES_CONTEXT_MENU_TITLE = i18n.translate( defaultMessage: 'Unassign alert', } ); - -export const ALERT_CLOSING_REASON_MENU_TITLE = i18n.translate( - 'xpack.securitySolution.bulkActions.alertClosingReason.MenuTitle', - { - defaultMessage: 'Reason for closing', - } -); - -export const ALERT_CLOSING_REASON_BUTTON_MESSAGE = i18n.translate( - 'xpack.securitySolution.bulkActions.alertClosingReason.ButtonMessage', - { - defaultMessage: 'Close alert', - } -); - -export const CLOSING_REASON_DUPLICATE = i18n.translate( - 'xpack.securitySolution.defaultAlertClosingReason.duplicate', - { - defaultMessage: 'Duplicate', - } -); - -export const CLOSING_REASON_FALSE_POSITIVE = i18n.translate( - 'xpack.securitySolution.defaultAlertClosingReason.falsePositive', - { - defaultMessage: 'False Positive', - } -); - -export const CLOSING_REASON_CLOSE_WITHOUT_REASON = i18n.translate( - 'xpack.securitySolution.defaultAlertClosingReason.CloseWithoutReason', - { - defaultMessage: 'Close without reason', - } -); - -export const CLOSING_REASON_TRUE_POSITIVE = i18n.translate( - 'xpack.securitySolution.defaultAlertClosingReason.true_positive', - { defaultMessage: 'True positive' } -); -export const CLOSING_REASON_BENIGN_POSITIVE = i18n.translate( - 'xpack.securitySolution.defaultAlertClosingReason.benign_positive', - { defaultMessage: 'Benign positive' } -); -export const CLOSING_REASON_AUTOMATED_CLOSURE = i18n.translate( - 'xpack.securitySolution.defaultAlertClosingReason.automated_closure', - { defaultMessage: 'Automated closure' } -); -export const CLOSING_REASON_OTHER = i18n.translate( - 'xpack.securitySolution.defaultAlertClosingReason.other', - { - defaultMessage: 'Other', - } -); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_action_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_action_items.tsx index d7febb8066dae..5ace1350dd0e8 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_action_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_action_items.tsx @@ -7,6 +7,7 @@ import { useMemo, useCallback } from 'react'; import type { EuiContextMenuPanelDescriptor } from '@elastic/eui'; +import { useBulkClosingReasonItems } from '@kbn/response-ops-alerts-table'; import type { AlertTableContextMenuItem } from '../../../../detections/components/alerts_table/types'; import { FILTER_ACKNOWLEDGED, FILTER_CLOSED, FILTER_OPEN } from '../../../../../common/types'; import type { @@ -23,7 +24,7 @@ import { APM_USER_INTERACTIONS } from '../../../lib/apm/constants'; import type { AlertWorkflowStatus } from '../../../types'; import type { OnUpdateAlertStatusError, OnUpdateAlertStatusSuccess } from './types'; import { useAlertCloseInfoModal } from '../../../../detections/hooks/use_alert_close_info_modal'; -import { useBulkAlertClosingReasonItems } from './use_bulk_alert_closing_reason_items'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; export interface BulkActionsProps { eventIds: string[]; @@ -51,6 +52,7 @@ export const useBulkActionItems = ({ const { addSuccess, addError, addWarning } = useAppToasts(); const { startTransaction } = useStartTransaction(); const { promptAlertCloseConfirmation } = useAlertCloseInfoModal(); + const { hasIndexWrite } = useAlertsPrivileges(); const onAlertStatusUpdateSuccess = useCallback( (updated: number, conflicts: number, newStatus: AlertWorkflowStatus) => { @@ -152,7 +154,8 @@ export const useBulkActionItems = ({ ); const { item: alertClosingReasonItem, panels: alertClosingReasonPanels } = - useBulkAlertClosingReasonItems({ + useBulkClosingReasonItems({ + isEnabled: hasIndexWrite ?? false, onSubmitCloseReason({ reason }) { onClickUpdate(FILTER_CLOSED as AlertWorkflowStatus, reason); }, diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_closing_reason_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_closing_reason_items.test.tsx deleted file mode 100644 index 0dd1aea4880be..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_closing_reason_items.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { renderHook } from '@testing-library/react'; -import { useAppToasts } from '../../../hooks/use_app_toasts'; -import type { UseBulkAlertClosingReasonItemsProps } from './use_bulk_alert_closing_reason_items'; -import { - ALERT_CLOSING_REASON_PANEL_ID, - useBulkAlertClosingReasonItems, -} from './use_bulk_alert_closing_reason_items'; - -jest.mock('../../../hooks/use_app_toasts'); -jest.mock('../../../lib/kibana'); -jest.mock( - '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges', - () => ({ - useAlertsPrivileges: jest.fn().mockReturnValue({ hasIndexWrite: true }), - }) -); -jest.mock('../../../hooks/use_experimental_features', () => ({ - useIsExperimentalFeatureEnabled: jest.fn(), -})); -(useAppToasts as jest.Mock).mockReturnValue({ - addSuccess: jest.fn(), - addError: jest.fn(), -}); - -function renderUseBulkAlertClosingReasonItems( - props?: Partial -) { - return renderHook(() => - useBulkAlertClosingReasonItems({ - onSubmitCloseReason: jest.fn(), - ...props, - }) - ); -} - -describe('useBulkAlertClosingReasonItems', () => { - const { result } = renderUseBulkAlertClosingReasonItems(); - - it('should return one item to open the closing reason selection', () => { - const item = result.current.item; - expect(item?.panel).toBe(ALERT_CLOSING_REASON_PANEL_ID); - }); - - it('should return one panel', () => { - const { panels } = result.current; - expect(panels.length).toBe(1); - }); -}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_closing_reason_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_closing_reason_items.tsx deleted file mode 100644 index 8dbf19f7396af..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_closing_reason_items.tsx +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useCallback, useMemo } from 'react'; -import type { - BulkActionsConfig, - ContentPanelConfig, - RenderContentPanelProps, -} from '@kbn/response-ops-alerts-table/types'; -import { BulkAlertClosingReason } from './alert_bulk_closing_reason'; -import * as i18n from './translations'; -import type { AlertClosingReason } from '../../../../../common/types'; -import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; - -export const ALERT_CLOSING_REASON_PANEL_ID = 'ALERT_CLOSING_REASON_PANEL_ID'; - -interface OnSubmitCloseReasonParams extends RenderContentPanelProps { - /** - * The reason the alert(s) are being closed - */ - reason?: AlertClosingReason; -} - -export interface UseBulkAlertClosingReasonItemsProps { - /** - * Called once the user confirms the closing reason - */ - onSubmitCloseReason?: (params: OnSubmitCloseReasonParams) => void; -} - -/** - * Returns items and panels to be used in a EuiContextMenu component - */ -export const useBulkAlertClosingReasonItems = ({ - onSubmitCloseReason, -}: UseBulkAlertClosingReasonItemsProps = {}) => { - const { hasIndexWrite } = useAlertsPrivileges(); - const item = useMemo( - () => - hasIndexWrite - ? ({ - key: 'close-alert-with-reason', - 'data-test-subj': 'alert-close-context-menu-item', - label: i18n.BULK_ACTION_CLOSE_SELECTED, - panel: ALERT_CLOSING_REASON_PANEL_ID, - } as BulkActionsConfig) - : undefined, - [hasIndexWrite] - ); - - const getRenderContent = useCallback( - ({ - onSubmitCloseReason: onSubmitCloseReasonCb, - }: { - onSubmitCloseReason?: UseBulkAlertClosingReasonItemsProps['onSubmitCloseReason']; - }) => { - function renderContent(renderProps: RenderContentPanelProps) { - const handleSubmit = (reason: AlertClosingReason) => { - onSubmitCloseReasonCb?.({ - ...renderProps, - reason, - }); - }; - - return ; - } - - return renderContent; - }, - [] - ); - - const panels = useMemo( - () => - hasIndexWrite - ? ([ - { - id: ALERT_CLOSING_REASON_PANEL_ID, - title: i18n.ALERT_CLOSING_REASON_MENU_TITLE, - renderContent: getRenderContent({ onSubmitCloseReason }), - }, - ] as ContentPanelConfig[]) - : [], - [hasIndexWrite, getRenderContent, onSubmitCloseReason] - ); - - /** - * function to use instead of `panels` in case we need to - * pass the `onSubmitCloseReason` at run time - */ - const getPanels = useCallback( - ({ - onSubmitCloseReason: onSubmitCloseReasonCb, - }: { - onSubmitCloseReason?: UseBulkAlertClosingReasonItemsProps['onSubmitCloseReason']; - }) => - hasIndexWrite - ? ([ - { - id: ALERT_CLOSING_REASON_PANEL_ID, - title: i18n.ALERT_CLOSING_REASON_MENU_TITLE, - renderContent: getRenderContent({ onSubmitCloseReason: onSubmitCloseReasonCb }), - }, - ] as ContentPanelConfig[]) - : [], - [getRenderContent, hasIndexWrite] - ); - - return useMemo( - () => ({ - item, - panels, - getPanels, - }), - [item, panels, getPanels] - ); -}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alerts_table/use_group_take_action_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alerts_table/use_group_take_action_items.tsx index 50fc5c6aedd51..1a20e48719f86 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alerts_table/use_group_take_action_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alerts_table/use_group_take_action_items.tsx @@ -11,6 +11,7 @@ import type { EuiContextMenuPanelItemDescriptor, } from '@elastic/eui'; import { EuiContextMenu, EuiContextMenuItem } from '@elastic/eui'; +import { useBulkClosingReasonItems } from '@kbn/response-ops-alerts-table'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import type { GroupTakeActionItems } from '../../components/alerts_table/types'; import type { Status } from '../../../../common/api/detection_engine'; @@ -37,7 +38,7 @@ import * as i18n from '../translations'; import { AlertsEventTypes, METRIC_TYPE, track } from '../../../common/lib/telemetry'; import type { StartServices } from '../../../types'; import { useAlertCloseInfoModal } from '../use_alert_close_info_modal'; -import { useBulkAlertClosingReasonItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_closing_reason_items'; +import { useAlertsPrivileges } from '../../containers/detection_engine/alerts/use_alerts_privileges'; const getTelemetryEvent = { groupedAlertsTakeAction: ({ @@ -73,6 +74,7 @@ export const useGroupTakeActionsItems = ({ }: UseGroupTakeActionsItemsParams): GroupTakeActionItems => { const { addSuccess, addError, addWarning } = useAppToasts(); const { startTransaction } = useStartTransaction(); + const { hasIndexWrite } = useAlertsPrivileges(); const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuery(), []); const globalQueries = useDeepEqualSelector(getGlobalQuerySelector); const { @@ -200,7 +202,9 @@ export const useGroupTakeActionsItems = ({ ] ); const { item: alertClosingReasonItem, getPanels: getAlertClosingReasonPanels } = - useBulkAlertClosingReasonItems(); + useBulkClosingReasonItems({ + isEnabled: hasIndexWrite ?? false, + }); return useCallback( ({ query, tableId, groupNumber, selectedGroup }) => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_workflow_status_items.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_workflow_status_items.test.tsx index f8f92b11b8fbf..891903f7715e2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_workflow_status_items.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_workflow_status_items.test.tsx @@ -6,6 +6,7 @@ */ import { renderHook } from '@testing-library/react'; +import { ALERT_CLOSING_REASON_PANEL_ID } from '@kbn/response-ops-alerts-table'; import { QueryClient, QueryClientProvider } from '@kbn/react-query'; import React from 'react'; @@ -105,10 +106,16 @@ describe('useBulkAttackWorkflowStatusItems', () => { expect(openItem).toBeUndefined(); }); - it('should return empty panels array', () => { + it('should return closing reason panel', () => { const { result } = renderHook(() => useBulkAttackWorkflowStatusItems(), { wrapper }); - expect(result.current.panels).toEqual([]); + expect(result.current.panels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: ALERT_CLOSING_REASON_PANEL_ID, + }), + ]) + ); }); describe('actions', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_workflow_status_items.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_workflow_status_items.tsx index 265ad2c3b3df6..feb3317ef977c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_workflow_status_items.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/attacks/bulk_actions/bulk_action_items/use_bulk_attack_workflow_status_items.tsx @@ -7,6 +7,7 @@ import { useCallback, useMemo } from 'react'; import type { BulkActionsConfig } from '@kbn/response-ops-alerts-table/types'; +import { useBulkClosingReasonItems } from '@kbn/response-ops-alerts-table'; import type { TimelineItem } from '@kbn/timelines-plugin/common'; import type { AttacksActionTelemetrySource } from '../../../../../common/lib/telemetry'; @@ -16,7 +17,6 @@ import { FILTER_ACKNOWLEDGED, FILTER_CLOSED, FILTER_OPEN } from '../../../../../ import { useAttacksPrivileges } from '../use_attacks_privileges'; import { extractRelatedDetectionAlertIds } from '../utils/extract_related_detection_alert_ids'; import { useApplyAttackWorkflowStatus } from '../apply_actions/use_apply_attack_workflow_status'; -import { useBulkAlertClosingReasonItems } from '../../../../../common/components/toolbar/bulk_actions/use_bulk_alert_closing_reason_items'; import * as i18n from '../translations'; import type { AttackContentPanelConfig, BulkAttackActionItems } from '../types'; @@ -79,7 +79,10 @@ export const useBulkAttackWorkflowStatusItems = ({ ); const { item: alertClosingReasonItem, panels: alertClosingReasonPanels } = - useBulkAlertClosingReasonItems({ onSubmitCloseReason }); + useBulkClosingReasonItems({ + isEnabled: hasIndexWrite ?? false, + onSubmitCloseReason, + }); const workflowStatusItems: BulkActionsConfig[] = useMemo(() => { // Return empty array if user doesn't have required permissions or data is still loading diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_alert_actions.tsx b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_alert_actions.tsx index 1961e24cd7d47..b157524b5f7d2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_alert_actions.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_alert_actions.tsx @@ -9,6 +9,7 @@ import type { BulkActionsConfig, BulkActionsPanelConfig, } from '@kbn/response-ops-alerts-table/types'; +import { useBulkClosingReasonItems } from '@kbn/response-ops-alerts-table'; import { useCallback, useMemo } from 'react'; import type { Filter } from '@kbn/es-query'; import { buildEsQuery } from '@kbn/es-query'; @@ -24,7 +25,6 @@ import * as i18n from '../translations'; import { buildTimeRangeFilter } from '../../components/alerts_table/helpers'; import { useAlertsPrivileges } from '../../containers/detection_engine/alerts/use_alerts_privileges'; import { useAlertCloseInfoModal } from '../use_alert_close_info_modal'; -import { useBulkAlertClosingReasonItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_closing_reason_items'; export interface UseBulkAlertActionItemsArgs { /* Table ID for which this hook is being used */ @@ -171,7 +171,8 @@ export const useBulkAlertActionItems = ({ ); const { item: alertClosingReasonItem, panels: alertClosingReasonPanels } = - useBulkAlertClosingReasonItems({ + useBulkClosingReasonItems({ + isEnabled: hasIndexWrite ?? false, onSubmitCloseReason({ reason, alertItems,