diff --git a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx
index 3056b166c1153..20ba0b50f5126 100644
--- a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx
+++ b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx
@@ -31,6 +31,7 @@ export const RedirectToConfigureCasesPage = () => (
const baseCaseUrl = `#/link-to/${SiemPageName.case}`;
export const getCaseUrl = () => baseCaseUrl;
-export const getCaseDetailsUrl = (detailName: string) => `${baseCaseUrl}/${detailName}`;
-export const getCreateCaseUrl = () => `${baseCaseUrl}/create`;
-export const getConfigureCasesUrl = () => `${baseCaseUrl}/configure`;
+export const getCaseDetailsUrl = (detailName: string, search: string) =>
+ `${baseCaseUrl}/${detailName}${search}`;
+export const getCreateCaseUrl = (search: string) => `${baseCaseUrl}/create${search}`;
+export const getConfigureCasesUrl = (search: string) => `${baseCaseUrl}/configure${search}`;
diff --git a/x-pack/legacy/plugins/siem/public/components/links/index.tsx b/x-pack/legacy/plugins/siem/public/components/links/index.tsx
index 04de0b1d5d3bf..935df9ad3361f 100644
--- a/x-pack/legacy/plugins/siem/public/components/links/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/components/links/index.tsx
@@ -23,8 +23,10 @@ import {
import { FlowTarget, FlowTargetSourceDest } from '../../graphql/types';
import { useUiSetting$ } from '../../lib/kibana';
import { IP_REPUTATION_LINKS_SETTING } from '../../../common/constants';
+import { navTabs } from '../../pages/home/home_navigations';
import * as i18n from '../page/network/ip_overview/translations';
import { isUrlInvalid } from '../../pages/detection_engine/rules/components/step_about_rule/helpers';
+import { useGetUrlSearch } from '../navigation/use_get_url_search';
import { ExternalLinkIcon } from '../external_link_icon';
export const DEFAULT_NUMBER_OF_LINK = 5;
@@ -89,20 +91,24 @@ export const IPDetailsLink = React.memo(IPDetailsLinkComponent);
const CaseDetailsLinkComponent: React.FC<{ children?: React.ReactNode; detailName: string }> = ({
children,
detailName,
-}) => (
-
- {children ? children : detailName}
-
-);
+}) => {
+ const urlSearch = useGetUrlSearch(navTabs.case);
+ return (
+
+ {children ? children : detailName}
+
+ );
+};
export const CaseDetailsLink = React.memo(CaseDetailsLinkComponent);
CaseDetailsLink.displayName = 'CaseDetailsLink';
-export const CreateCaseLink = React.memo<{ children: React.ReactNode }>(({ children }) => (
- {children}
-));
+export const CreateCaseLink = React.memo<{ children: React.ReactNode }>(({ children }) => {
+ const urlSearch = useGetUrlSearch(navTabs.case);
+ return {children};
+});
CreateCaseLink.displayName = 'CreateCaseLink';
diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx
index 10aa388449d91..6e957313d9b04 100644
--- a/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx
+++ b/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx
@@ -158,7 +158,7 @@ describe('UrlStateContainer', () => {
hash: '',
pathname: examplePath,
search: [CONSTANTS.timelinePage].includes(page)
- ? '?timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))'
+ ? `?query=(language:kuery,query:'host.name:%22siem-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))`
: `?query=(language:kuery,query:'host.name:%22siem-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))`,
state: '',
});
diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts
index 2cb1b0c96ad79..c6f49d8a0e49b 100644
--- a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts
+++ b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts
@@ -60,8 +60,20 @@ export const URL_STATE_KEYS: Record = {
CONSTANTS.timerange,
CONSTANTS.timeline,
],
- timeline: [CONSTANTS.timeline, CONSTANTS.timerange],
- case: [],
+ timeline: [
+ CONSTANTS.appQuery,
+ CONSTANTS.filters,
+ CONSTANTS.savedQuery,
+ CONSTANTS.timeline,
+ CONSTANTS.timerange,
+ ],
+ case: [
+ CONSTANTS.appQuery,
+ CONSTANTS.filters,
+ CONSTANTS.savedQuery,
+ CONSTANTS.timeline,
+ CONSTANTS.timerange,
+ ],
};
export type LocationTypes =
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/api.ts
index 5ba1f010e0d52..16ee294224bb9 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/api.ts
+++ b/x-pack/legacy/plugins/siem/public/containers/case/api.ts
@@ -13,9 +13,15 @@ import {
CommentRequest,
CommentResponse,
User,
+ CaseUserActionsResponse,
+ CaseExternalServiceRequest,
+ ServiceConnectorCaseParams,
+ ServiceConnectorCaseResponse,
+ ActionTypeExecutorResult,
} from '../../../../../../plugins/case/common/api';
import { KibanaServices } from '../../lib/kibana';
import {
+ ActionLicense,
AllCases,
BulkUpdateStatus,
Case,
@@ -23,16 +29,20 @@ import {
Comment,
FetchCasesProps,
SortFieldCase,
+ CaseUserActions,
} from './types';
import { CASES_URL } from './constants';
import {
convertToCamelCase,
convertAllCasesToCamel,
+ convertArrayToCamelCase,
decodeCaseResponse,
decodeCasesResponse,
decodeCasesFindResponse,
decodeCasesStatusResponse,
decodeCommentResponse,
+ decodeCaseUserActionsResponse,
+ decodeServiceConnectorCaseResponse,
} from './utils';
export const getCase = async (caseId: string, includeComments: boolean = true): Promise => {
@@ -71,6 +81,20 @@ export const getReporters = async (signal: AbortSignal): Promise => {
return response ?? [];
};
+export const getCaseUserActions = async (
+ caseId: string,
+ signal: AbortSignal
+): Promise => {
+ const response = await KibanaServices.get().http.fetch(
+ `${CASES_URL}/${caseId}/user_actions`,
+ {
+ method: 'GET',
+ signal,
+ }
+ );
+ return convertArrayToCamelCase(decodeCaseUserActionsResponse(response)) as CaseUserActions[];
+};
+
export const getCases = async ({
filterOptions = {
search: '',
@@ -161,3 +185,43 @@ export const deleteCases = async (caseIds: string[]): Promise => {
});
return response === 'true' ? true : false;
};
+
+export const pushCase = async (
+ caseId: string,
+ push: CaseExternalServiceRequest,
+ signal: AbortSignal
+): Promise => {
+ const response = await KibanaServices.get().http.fetch(
+ `${CASES_URL}/${caseId}/_push`,
+ {
+ method: 'POST',
+ body: JSON.stringify(push),
+ signal,
+ }
+ );
+ return convertToCamelCase(decodeCaseResponse(response));
+};
+
+export const pushToService = async (
+ connectorId: string,
+ casePushParams: ServiceConnectorCaseParams,
+ signal: AbortSignal
+): Promise => {
+ const response = await KibanaServices.get().http.fetch(
+ `/api/action/${connectorId}/_execute`,
+ {
+ method: 'POST',
+ body: JSON.stringify({ params: casePushParams }),
+ signal,
+ }
+ );
+ return decodeServiceConnectorCaseResponse(response.data);
+};
+
+export const getActionLicense = async (signal: AbortSignal): Promise => {
+ const response = await KibanaServices.get().http.fetch(`/api/action/types`, {
+ method: 'GET',
+ signal,
+ });
+ return response;
+};
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts
index fc7aaa3643d77..d69c23fe02ec9 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts
+++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts
@@ -26,6 +26,7 @@ export interface CaseConfigure {
createdAt: string;
createdBy: ElasticUser;
connectorId: string;
+ connectorName: string;
closureType: ClosureType;
updatedAt: string;
updatedBy: ElasticUser;
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx
index 22ac54093d1dc..a24f8303824c5 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx
+++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx
@@ -13,6 +13,7 @@ import { ClosureType } from './types';
interface PersistCaseConfigure {
connectorId: string;
+ connectorName: string;
closureType: ClosureType;
}
@@ -24,12 +25,12 @@ export interface ReturnUseCaseConfigure {
}
interface UseCaseConfigure {
- setConnectorId: (newConnectorId: string) => void;
- setClosureType: (newClosureType: ClosureType) => void;
+ setConnector: (newConnectorId: string, newConnectorName?: string) => void;
+ setClosureType?: (newClosureType: ClosureType) => void;
}
export const useCaseConfigure = ({
- setConnectorId,
+ setConnector,
setClosureType,
}: UseCaseConfigure): ReturnUseCaseConfigure => {
const [, dispatchToaster] = useStateToaster();
@@ -48,8 +49,10 @@ export const useCaseConfigure = ({
if (!didCancel) {
setLoading(false);
if (res != null) {
- setConnectorId(res.connectorId);
- setClosureType(res.closureType);
+ setConnector(res.connectorId, res.connectorName);
+ if (setClosureType != null) {
+ setClosureType(res.closureType);
+ }
setVersion(res.version);
}
}
@@ -74,7 +77,7 @@ export const useCaseConfigure = ({
}, []);
const persistCaseConfigure = useCallback(
- async ({ connectorId, closureType }: PersistCaseConfigure) => {
+ async ({ connectorId, connectorName, closureType }: PersistCaseConfigure) => {
let didCancel = false;
const abortCtrl = new AbortController();
const saveCaseConfiguration = async () => {
@@ -83,7 +86,11 @@ export const useCaseConfigure = ({
const res =
version.length === 0
? await postCaseConfigure(
- { connector_id: connectorId, closure_type: closureType },
+ {
+ connector_id: connectorId,
+ connector_name: connectorName,
+ closure_type: closureType,
+ },
abortCtrl.signal
)
: await patchCaseConfigure(
@@ -92,8 +99,10 @@ export const useCaseConfigure = ({
);
if (!didCancel) {
setPersistLoading(false);
- setConnectorId(res.connectorId);
- setClosureType(res.closureType);
+ setConnector(res.connectorId);
+ if (setClosureType) {
+ setClosureType(res.closureType);
+ }
setVersion(res.version);
}
} catch (error) {
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/translations.ts b/x-pack/legacy/plugins/siem/public/containers/case/translations.ts
index 0c8b896e2b426..601db373f041e 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/translations.ts
+++ b/x-pack/legacy/plugins/siem/public/containers/case/translations.ts
@@ -16,3 +16,10 @@ export const TAG_FETCH_FAILURE = i18n.translate(
defaultMessage: 'Failed to fetch Tags',
}
);
+
+export const SUCCESS_SEND_TO_EXTERNAL_SERVICE = i18n.translate(
+ 'xpack.siem.containers.case.pushToExterService',
+ {
+ defaultMessage: 'Successfully sent to ServiceNow',
+ }
+);
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts
index 44519031e91cb..bbbb13788d53a 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts
+++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts
@@ -4,30 +4,53 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { User } from '../../../../../../plugins/case/common/api';
+import { User, UserActionField, UserAction } from '../../../../../../plugins/case/common/api';
export interface Comment {
id: string;
createdAt: string;
createdBy: ElasticUser;
comment: string;
+ pushedAt: string | null;
+ pushedBy: string | null;
updatedAt: string | null;
updatedBy: ElasticUser | null;
version: string;
}
+export interface CaseUserActions {
+ actionId: string;
+ actionField: UserActionField;
+ action: UserAction;
+ actionAt: string;
+ actionBy: ElasticUser;
+ caseId: string;
+ commentId: string | null;
+ newValue: string | null;
+ oldValue: string | null;
+}
+export interface CaseExternalService {
+ pushedAt: string;
+ pushedBy: string;
+ connectorId: string;
+ connectorName: string;
+ externalId: string;
+ externalTitle: string;
+ externalUrl: string;
+}
export interface Case {
id: string;
closedAt: string | null;
closedBy: ElasticUser | null;
comments: Comment[];
- commentIds: string[];
createdAt: string;
createdBy: ElasticUser;
description: string;
+ externalService: CaseExternalService | null;
status: string;
tags: string[];
title: string;
+ totalComment: number;
updatedAt: string | null;
updatedBy: ElasticUser | null;
version: string;
@@ -84,3 +107,10 @@ export interface BulkUpdateStatus {
id: string;
version: string;
}
+export interface ActionLicense {
+ id: string;
+ name: string;
+ enabled: boolean;
+ enabledInConfig: boolean;
+ enabledInLicense: boolean;
+}
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx
new file mode 100644
index 0000000000000..12f92b2db039b
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx
@@ -0,0 +1,74 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { useCallback, useEffect, useState } from 'react';
+
+import { errorToToaster, useStateToaster } from '../../components/toasters';
+import { getActionLicense } from './api';
+import * as i18n from './translations';
+import { ActionLicense } from './types';
+
+interface ActionLicenseState {
+ actionLicense: ActionLicense | null;
+ isLoading: boolean;
+ isError: boolean;
+}
+
+const initialData: ActionLicenseState = {
+ actionLicense: null,
+ isLoading: true,
+ isError: false,
+};
+
+export const useGetActionLicense = (): ActionLicenseState => {
+ const [actionLicenseState, setActionLicensesState] = useState(initialData);
+
+ const [, dispatchToaster] = useStateToaster();
+
+ const fetchActionLicense = useCallback(() => {
+ let didCancel = false;
+ const abortCtrl = new AbortController();
+ const fetchData = async () => {
+ setActionLicensesState({
+ ...actionLicenseState,
+ isLoading: true,
+ });
+ try {
+ const response = await getActionLicense(abortCtrl.signal);
+ if (!didCancel) {
+ setActionLicensesState({
+ actionLicense: response.find(l => l.id === '.servicenow') ?? null,
+ isLoading: false,
+ isError: false,
+ });
+ }
+ } catch (error) {
+ if (!didCancel) {
+ errorToToaster({
+ title: i18n.ERROR_TITLE,
+ error: error.body && error.body.message ? new Error(error.body.message) : error,
+ dispatchToaster,
+ });
+ setActionLicensesState({
+ actionLicense: null,
+ isLoading: false,
+ isError: true,
+ });
+ }
+ }
+ };
+ fetchData();
+ return () => {
+ didCancel = true;
+ abortCtrl.abort();
+ };
+ }, [actionLicenseState]);
+
+ useEffect(() => {
+ fetchActionLicense();
+ }, []);
+ return { ...actionLicenseState };
+};
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx
index b70195e2c126f..02b41c9fc720f 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx
+++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx
@@ -53,14 +53,15 @@ const initialData: Case = {
closedBy: null,
createdAt: '',
comments: [],
- commentIds: [],
createdBy: {
username: '',
},
description: '',
+ externalService: null,
status: '',
tags: [],
title: '',
+ totalComment: 0,
updatedAt: null,
updatedBy: null,
version: '',
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx
new file mode 100644
index 0000000000000..4c278bc038134
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx
@@ -0,0 +1,126 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { isEmpty, uniqBy } from 'lodash/fp';
+import { useCallback, useEffect, useState } from 'react';
+
+import { errorToToaster, useStateToaster } from '../../components/toasters';
+import { getCaseUserActions } from './api';
+import * as i18n from './translations';
+import { CaseUserActions, ElasticUser } from './types';
+
+interface CaseUserActionsState {
+ caseUserActions: CaseUserActions[];
+ firstIndexPushToService: number;
+ hasDataToPush: boolean;
+ participants: ElasticUser[];
+ isLoading: boolean;
+ isError: boolean;
+ lastIndexPushToService: number;
+}
+
+const initialData: CaseUserActionsState = {
+ caseUserActions: [],
+ firstIndexPushToService: -1,
+ lastIndexPushToService: -1,
+ hasDataToPush: false,
+ isLoading: true,
+ isError: false,
+ participants: [],
+};
+
+interface UseGetCaseUserActions extends CaseUserActionsState {
+ fetchCaseUserActions: (caseId: string) => void;
+}
+
+const getPushedInfo = (
+ caseUserActions: CaseUserActions[]
+): { firstIndexPushToService: number; lastIndexPushToService: number; hasDataToPush: boolean } => {
+ const firstIndexPushToService = caseUserActions.findIndex(
+ cua => cua.action === 'push-to-service'
+ );
+ const lastIndexPushToService = caseUserActions
+ .map(cua => cua.action)
+ .lastIndexOf('push-to-service');
+
+ const hasDataToPush =
+ lastIndexPushToService === -1 || lastIndexPushToService < caseUserActions.length - 1;
+ return {
+ firstIndexPushToService,
+ lastIndexPushToService,
+ hasDataToPush,
+ };
+};
+
+export const useGetCaseUserActions = (caseId: string): UseGetCaseUserActions => {
+ const [caseUserActionsState, setCaseUserActionsState] = useState(
+ initialData
+ );
+
+ const [, dispatchToaster] = useStateToaster();
+
+ const fetchCaseUserActions = useCallback(
+ (thisCaseId: string) => {
+ let didCancel = false;
+ const abortCtrl = new AbortController();
+ const fetchData = async () => {
+ setCaseUserActionsState({
+ ...caseUserActionsState,
+ isLoading: true,
+ });
+ try {
+ const response = await getCaseUserActions(thisCaseId, abortCtrl.signal);
+ if (!didCancel) {
+ // Attention Future developer
+ // We are removing the first item because it will always be the creation of the case
+ // and we do not want it to simplify our life
+ const participants = !isEmpty(response)
+ ? uniqBy('actionBy.username', response).map(cau => cau.actionBy)
+ : [];
+ const caseUserActions = !isEmpty(response) ? response.slice(1) : [];
+ setCaseUserActionsState({
+ caseUserActions,
+ ...getPushedInfo(caseUserActions),
+ isLoading: false,
+ isError: false,
+ participants,
+ });
+ }
+ } catch (error) {
+ if (!didCancel) {
+ errorToToaster({
+ title: i18n.ERROR_TITLE,
+ error: error.body && error.body.message ? new Error(error.body.message) : error,
+ dispatchToaster,
+ });
+ setCaseUserActionsState({
+ caseUserActions: [],
+ firstIndexPushToService: -1,
+ lastIndexPushToService: -1,
+ hasDataToPush: false,
+ isLoading: false,
+ isError: true,
+ participants: [],
+ });
+ }
+ }
+ };
+ fetchData();
+ return () => {
+ didCancel = true;
+ abortCtrl.abort();
+ };
+ },
+ [caseUserActionsState]
+ );
+
+ useEffect(() => {
+ if (!isEmpty(caseId)) {
+ fetchCaseUserActions(caseId);
+ }
+ }, [caseId]);
+ return { ...caseUserActionsState, fetchCaseUserActions };
+};
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx
new file mode 100644
index 0000000000000..b6fb15f4fa083
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx
@@ -0,0 +1,183 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { useReducer, useCallback } from 'react';
+
+import {
+ ServiceConnectorCaseResponse,
+ ServiceConnectorCaseParams,
+} from '../../../../../../plugins/case/common/api';
+import { errorToToaster, useStateToaster, displaySuccessToast } from '../../components/toasters';
+
+import { getCase, pushToService, pushCase } from './api';
+import * as i18n from './translations';
+import { Case } from './types';
+
+interface PushToServiceState {
+ serviceData: ServiceConnectorCaseResponse | null;
+ pushedCaseData: Case | null;
+ isLoading: boolean;
+ isError: boolean;
+}
+type Action =
+ | { type: 'FETCH_INIT' }
+ | { type: 'FETCH_SUCCESS_PUSH_SERVICE'; payload: ServiceConnectorCaseResponse | null }
+ | { type: 'FETCH_SUCCESS_PUSH_CASE'; payload: Case | null }
+ | { type: 'FETCH_FAILURE' };
+
+const dataFetchReducer = (state: PushToServiceState, action: Action): PushToServiceState => {
+ switch (action.type) {
+ case 'FETCH_INIT':
+ return {
+ ...state,
+ isLoading: true,
+ isError: false,
+ };
+ case 'FETCH_SUCCESS_PUSH_SERVICE':
+ return {
+ ...state,
+ isLoading: false,
+ isError: false,
+ serviceData: action.payload ?? null,
+ };
+ case 'FETCH_SUCCESS_PUSH_CASE':
+ return {
+ ...state,
+ isLoading: false,
+ isError: false,
+ pushedCaseData: action.payload ?? null,
+ };
+ case 'FETCH_FAILURE':
+ return {
+ ...state,
+ isLoading: false,
+ isError: true,
+ };
+ default:
+ return state;
+ }
+};
+
+interface PushToServiceRequest {
+ caseId: string;
+ connectorId: string;
+ connectorName: string;
+ updateCase: (newCase: Case) => void;
+}
+
+interface UsePostPushToService extends PushToServiceState {
+ postPushToService: ({ caseId, connectorId, updateCase }: PushToServiceRequest) => void;
+}
+
+export const usePostPushToService = (): UsePostPushToService => {
+ const [state, dispatch] = useReducer(dataFetchReducer, {
+ serviceData: null,
+ pushedCaseData: null,
+ isLoading: false,
+ isError: false,
+ });
+ const [, dispatchToaster] = useStateToaster();
+
+ const postPushToService = useCallback(
+ async ({ caseId, connectorId, connectorName, updateCase }: PushToServiceRequest) => {
+ let cancel = false;
+ const abortCtrl = new AbortController();
+ try {
+ dispatch({ type: 'FETCH_INIT' });
+ const casePushData = await getCase(caseId);
+ const responseService = await pushToService(
+ connectorId,
+ formatServiceRequestData(casePushData),
+ abortCtrl.signal
+ );
+ const responseCase = await pushCase(
+ caseId,
+ {
+ connector_id: connectorId,
+ connector_name: connectorName,
+ external_id: responseService.incidentId,
+ external_title: responseService.number,
+ external_url: responseService.url,
+ },
+ abortCtrl.signal
+ );
+ if (!cancel) {
+ dispatch({ type: 'FETCH_SUCCESS_PUSH_SERVICE', payload: responseService });
+ dispatch({ type: 'FETCH_SUCCESS_PUSH_CASE', payload: responseCase });
+ updateCase(responseCase);
+ displaySuccessToast(i18n.SUCCESS_SEND_TO_EXTERNAL_SERVICE, dispatchToaster);
+ }
+ } catch (error) {
+ if (!cancel) {
+ errorToToaster({
+ title: i18n.ERROR_TITLE,
+ error: error.body && error.body.message ? new Error(error.body.message) : error,
+ dispatchToaster,
+ });
+ dispatch({ type: 'FETCH_FAILURE' });
+ }
+ }
+ return () => {
+ cancel = true;
+ abortCtrl.abort();
+ };
+ },
+ []
+ );
+
+ return { ...state, postPushToService };
+};
+
+const formatServiceRequestData = (myCase: Case): ServiceConnectorCaseParams => {
+ const {
+ id: caseId,
+ createdAt,
+ createdBy,
+ comments,
+ description,
+ externalService,
+ title,
+ updatedAt,
+ updatedBy,
+ } = myCase;
+
+ return {
+ caseId,
+ createdAt,
+ createdBy: {
+ fullName: createdBy.fullName ?? null,
+ username: createdBy?.username,
+ },
+ comments: comments.map(c => ({
+ commentId: c.id,
+ comment: c.comment,
+ createdAt: c.createdAt,
+ createdBy: {
+ fullName: c.createdBy.fullName ?? null,
+ username: c.createdBy.username,
+ },
+ updatedAt: c.updatedAt,
+ updatedBy:
+ c.updatedBy != null
+ ? {
+ fullName: c.updatedBy.fullName ?? null,
+ username: c.updatedBy.username,
+ }
+ : null,
+ })),
+ description,
+ incidentId: externalService?.externalId ?? null,
+ title,
+ updatedAt,
+ updatedBy:
+ updatedBy != null
+ ? {
+ fullName: updatedBy.fullName ?? null,
+ username: updatedBy.username,
+ }
+ : null,
+ };
+};
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx
index 987620469901b..f8af088f7e03b 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx
+++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx
@@ -25,6 +25,7 @@ interface NewCaseState {
export interface UpdateByKey {
updateKey: UpdateKey;
updateValue: CaseRequest[UpdateKey];
+ fetchCaseUserActions?: (caseId: string) => void;
}
type Action =
@@ -64,6 +65,7 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState =>
interface UseUpdateCase extends NewCaseState {
updateCaseProperty: (updates: UpdateByKey) => void;
+ updateCase: (newCase: Case) => void;
}
export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase => {
const [state, dispatch] = useReducer(dataFetchReducer, {
@@ -74,8 +76,12 @@ export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase
});
const [, dispatchToaster] = useStateToaster();
+ const updateCase = useCallback((newCase: Case) => {
+ dispatch({ type: 'FETCH_SUCCESS', payload: newCase });
+ }, []);
+
const dispatchUpdateCaseProperty = useCallback(
- async ({ updateKey, updateValue }: UpdateByKey) => {
+ async ({ fetchCaseUserActions, updateKey, updateValue }: UpdateByKey) => {
let cancel = false;
try {
dispatch({ type: 'FETCH_INIT', payload: updateKey });
@@ -85,6 +91,9 @@ export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase
state.caseData.version
);
if (!cancel) {
+ if (fetchCaseUserActions != null) {
+ fetchCaseUserActions(caseId);
+ }
dispatch({ type: 'FETCH_SUCCESS', payload: response[0] });
}
} catch (error) {
@@ -104,5 +113,5 @@ export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase
[state]
);
- return { ...state, updateCaseProperty: dispatchUpdateCaseProperty };
+ return { ...state, updateCase, updateCaseProperty: dispatchUpdateCaseProperty };
};
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx
index a40a1100ca735..c1b2bfde30126 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx
+++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx
@@ -70,8 +70,15 @@ const dataFetchReducer = (state: CommentUpdateState, action: Action): CommentUpd
}
};
+interface UpdateComment {
+ caseId: string;
+ commentId: string;
+ commentUpdate: string;
+ fetchUserActions: () => void;
+}
+
interface UseUpdateComment extends CommentUpdateState {
- updateComment: (caseId: string, commentId: string, commentUpdate: string) => void;
+ updateComment: ({ caseId, commentId, commentUpdate, fetchUserActions }: UpdateComment) => void;
addPostedComment: Dispatch;
}
@@ -84,7 +91,7 @@ export const useUpdateComment = (comments: Comment[]): UseUpdateComment => {
const [, dispatchToaster] = useStateToaster();
const dispatchUpdateComment = useCallback(
- async (caseId: string, commentId: string, commentUpdate: string) => {
+ async ({ caseId, commentId, commentUpdate, fetchUserActions }: UpdateComment) => {
let cancel = false;
try {
dispatch({ type: 'FETCH_INIT', payload: commentId });
@@ -98,6 +105,7 @@ export const useUpdateComment = (comments: Comment[]): UseUpdateComment => {
currentComment.version
);
if (!cancel) {
+ fetchUserActions();
dispatch({ type: 'FETCH_SUCCESS', payload: { update: response, commentId } });
}
} catch (error) {
diff --git a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts
index 8f24d5a435240..ce23ac6c440b6 100644
--- a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts
+++ b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts
@@ -23,6 +23,10 @@ import {
CommentResponseRt,
CasesConfigureResponse,
CaseConfigureResponseRt,
+ CaseUserActionsResponse,
+ CaseUserActionsResponseRt,
+ ServiceConnectorCaseResponseRt,
+ ServiceConnectorCaseResponse,
} from '../../../../../../plugins/case/common/api';
import { ToasterError } from '../../components/toasters';
import { AllCases, Case } from './types';
@@ -86,3 +90,15 @@ export const decodeCaseConfigureResponse = (respCase?: CasesConfigureResponse) =
CaseConfigureResponseRt.decode(respCase),
fold(throwErrors(createToasterPlainError), identity)
);
+
+export const decodeCaseUserActionsResponse = (respUserActions?: CaseUserActionsResponse) =>
+ pipe(
+ CaseUserActionsResponseRt.decode(respUserActions),
+ fold(throwErrors(createToasterPlainError), identity)
+ );
+
+export const decodeServiceConnectorCaseResponse = (respPushCase?: ServiceConnectorCaseResponse) =>
+ pipe(
+ ServiceConnectorCaseResponseRt.decode(respPushCase),
+ fold(throwErrors(createToasterPlainError), identity)
+ );
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx
index 0b3b0daaf4bbc..836595c7c45d9 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx
@@ -30,72 +30,79 @@ const initialCommentValue: CommentRequest = {
interface AddCommentProps {
caseId: string;
+ onCommentSaving?: () => void;
onCommentPosted: (commentResponse: Comment) => void;
+ showLoading?: boolean;
}
-export const AddComment = React.memo(({ caseId, onCommentPosted }) => {
- const { commentData, isLoading, postComment, resetCommentData } = usePostComment(caseId);
- const { form } = useForm({
- defaultValue: initialCommentValue,
- options: { stripEmptyFields: false },
- schema,
- });
- const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline(
- form,
- 'comment'
- );
+export const AddComment = React.memo(
+ ({ caseId, showLoading = true, onCommentPosted, onCommentSaving }) => {
+ const { commentData, isLoading, postComment, resetCommentData } = usePostComment(caseId);
+ const { form } = useForm({
+ defaultValue: initialCommentValue,
+ options: { stripEmptyFields: false },
+ schema,
+ });
+ const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline(
+ form,
+ 'comment'
+ );
- useEffect(() => {
- if (commentData !== null) {
- onCommentPosted(commentData);
- form.reset();
- resetCommentData();
- }
- }, [commentData]);
+ useEffect(() => {
+ if (commentData !== null) {
+ onCommentPosted(commentData);
+ form.reset();
+ resetCommentData();
+ }
+ }, [commentData]);
- const onSubmit = useCallback(async () => {
- const { isValid, data } = await form.submit();
- if (isValid) {
- await postComment(data);
- }
- }, [form]);
+ const onSubmit = useCallback(async () => {
+ const { isValid, data } = await form.submit();
+ if (isValid) {
+ if (onCommentSaving != null) {
+ onCommentSaving();
+ }
+ await postComment(data);
+ }
+ }, [form]);
- return (
- <>
- {isLoading && }
-
- >
- );
-});
+ return (
+ <>
+ {isLoading && showLoading && }
+
+ >
+ );
+ }
+);
AddComment.displayName = 'AddComment';
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx
index 48fbb4e74c407..d4ec32dfd070b 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx
@@ -18,12 +18,13 @@ export const useGetCasesMockState: UseGetCasesState = {
id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15',
createdAt: '2020-02-13T19:44:23.627Z',
createdBy: { username: 'elastic' },
- commentIds: [],
comments: [],
description: 'Security banana Issue',
+ externalService: null,
status: 'open',
tags: ['defacement'],
title: 'Another horrible breach',
+ totalComment: 0,
updatedAt: null,
updatedBy: null,
version: 'WzQ3LDFd',
@@ -34,12 +35,13 @@ export const useGetCasesMockState: UseGetCasesState = {
id: '362a5c10-4e99-11ea-9290-35d05cb55c15',
createdAt: '2020-02-13T19:44:13.328Z',
createdBy: { username: 'elastic' },
- commentIds: [],
comments: [],
description: 'Security banana Issue',
+ externalService: null,
status: 'open',
tags: ['phishing'],
title: 'Bad email',
+ totalComment: 0,
updatedAt: null,
updatedBy: null,
version: 'WzQ3LDFd',
@@ -50,12 +52,13 @@ export const useGetCasesMockState: UseGetCasesState = {
id: '34f8b9e0-4e99-11ea-9290-35d05cb55c15',
createdAt: '2020-02-13T19:44:11.328Z',
createdBy: { username: 'elastic' },
- commentIds: [],
comments: [],
description: 'Security banana Issue',
+ externalService: null,
status: 'open',
tags: ['phishing'],
title: 'Bad email',
+ totalComment: 0,
updatedAt: null,
updatedBy: null,
version: 'WzQ3LDFd',
@@ -66,14 +69,15 @@ export const useGetCasesMockState: UseGetCasesState = {
id: '31890e90-4e99-11ea-9290-35d05cb55c15',
createdAt: '2020-02-13T19:44:05.563Z',
createdBy: { username: 'elastic' },
- commentIds: [],
comments: [],
description: 'Security banana Issue',
+ externalService: null,
status: 'closed',
tags: ['phishing'],
title: 'Uh oh',
- updatedAt: '2020-02-13T19:44:13.328Z',
- updatedBy: { username: 'elastic' },
+ totalComment: 0,
+ updatedAt: null,
+ updatedBy: null,
version: 'WzQ3LDFd',
},
{
@@ -82,12 +86,13 @@ export const useGetCasesMockState: UseGetCasesState = {
id: '2f5b3210-4e99-11ea-9290-35d05cb55c15',
createdAt: '2020-02-13T19:44:01.901Z',
createdBy: { username: 'elastic' },
- commentIds: [],
comments: [],
description: 'Security banana Issue',
+ externalService: null,
status: 'open',
tags: ['phishing'],
title: 'Uh oh',
+ totalComment: 0,
updatedAt: null,
updatedBy: null,
version: 'WzQ3LDFd',
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx
index b9e1113c486ad..32a29483e9c75 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx
@@ -35,6 +35,7 @@ const Spacer = styled.span`
const renderStringField = (field: string, dataTestSubj: string) =>
field != null ? {field} : getEmptyTagValue();
+
export const getCasesColumns = (
actions: Array>,
filterStatus: string
@@ -108,11 +109,11 @@ export const getCasesColumns = (
},
{
align: 'right',
- field: 'commentIds',
+ field: 'totalComment',
name: i18n.COMMENTS,
sortable: true,
- render: (comments: Case['commentIds']) =>
- renderStringField(`${comments.length}`, `case-table-column-commentCount`),
+ render: (totalComment: Case['totalComment']) =>
+ renderStringField(`${totalComment}`, `case-table-column-commentCount`),
},
filterStatus === 'open'
? {
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx
index 13869c79c45fd..bdcb87b483851 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx
@@ -95,7 +95,9 @@ describe('AllCases', () => {
.find(`a[data-test-subj="case-details-link"]`)
.first()
.prop('href')
- ).toEqual(`#/link-to/case/${useGetCasesMockState.data.cases[0].id}`);
+ ).toEqual(
+ `#/link-to/case/${useGetCasesMockState.data.cases[0].id}?timerange=(global:(linkTo:!(timeline),timerange:(from:0,fromStr:now-24h,kind:relative,to:1,toStr:now)),timeline:(linkTo:!(global),timerange:(from:0,fromStr:now-24h,kind:relative,to:1,toStr:now)))`
+ );
expect(
wrapper
.find(`a[data-test-subj="case-details-link"]`)
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx
index e7e1e624ccba2..87a2ea888831a 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx
@@ -35,7 +35,9 @@ import {
UtilityBarText,
} from '../../../../components/utility_bar';
import { getConfigureCasesUrl, getCreateCaseUrl } from '../../../../components/link_to';
-
+import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case';
+import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search';
+import { navTabs } from '../../../home/home_navigations';
import { getBulkItems } from '../bulk_actions';
import { CaseHeaderPage } from '../case_header_page';
import { ConfirmDeleteCaseModal } from '../confirm_delete_case';
@@ -43,10 +45,6 @@ import { OpenClosedStats } from '../open_closed_stats';
import { getActions } from './actions';
import { CasesTableFilters } from './table_filters';
-import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case';
-
-const CONFIGURE_CASES_URL = getConfigureCasesUrl();
-const CREATE_CASE_URL = getCreateCaseUrl();
const Div = styled.div`
margin-top: ${({ theme }) => theme.eui.paddingSizes.m};
@@ -78,6 +76,7 @@ const getSortField = (field: string): SortFieldCase => {
return SortFieldCase.createdAt;
};
export const AllCases = React.memo(() => {
+ const urlSearch = useGetUrlSearch(navTabs.case);
const {
countClosedCases,
countOpenCases,
@@ -276,12 +275,12 @@ export const AllCases = React.memo(() => {
/>
-
+
{i18n.CONFIGURE_CASES_BUTTON}
-
+
{i18n.CREATE_TITLE}
@@ -342,7 +341,12 @@ export const AllCases = React.memo(() => {
titleSize="xs"
body={i18n.NO_CASES_BODY}
actions={
-
+
{i18n.ADD_NEW_CASE}
}
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx
index 9dbd71ea3e34c..0420a71fea907 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import React, { useCallback } from 'react';
+import React from 'react';
import styled, { css } from 'styled-components';
import {
EuiBadge,
@@ -39,7 +39,7 @@ interface CaseStatusProps {
isSelected: boolean;
status: string;
title: string;
- toggleStatusCase: (status: string) => void;
+ toggleStatusCase: (evt: unknown) => void;
value: string | null;
}
const CaseStatusComp: React.FC = ({
@@ -55,51 +55,46 @@ const CaseStatusComp: React.FC = ({
title,
toggleStatusCase,
value,
-}) => {
- const onChange = useCallback(e => toggleStatusCase(e.target.checked ? 'closed' : 'open'), [
- toggleStatusCase,
- ]);
- return (
-
-
-
-
-
- {i18n.STATUS}
-
-
- {status}
-
-
-
-
- {title}
-
-
-
-
-
-
-
-
-
-
-
-
+}) => (
+
+
+
+
-
+ {i18n.STATUS}
+
+
+ {status}
+
+
+
+
+ {title}
+
+
+
-
-
- );
-};
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
export const CaseStatus = React.memo(CaseStatusComp);
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx
index e11441eac3a9d..7aadea1a453a7 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx
@@ -13,7 +13,6 @@ export const caseProps: CaseProps = {
closedAt: null,
closedBy: null,
id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15',
- commentIds: ['a357c6a0-5435-11ea-b427-fb51a1fcb7b8'],
comments: [
{
comment: 'Solve this fast!',
@@ -24,6 +23,8 @@ export const caseProps: CaseProps = {
username: 'smilovic',
email: 'notmyrealemailfool@elastic.co',
},
+ pushedAt: null,
+ pushedBy: null,
updatedAt: '2020-02-20T23:06:33.798Z',
updatedBy: {
username: 'elastic',
@@ -34,9 +35,11 @@ export const caseProps: CaseProps = {
createdAt: '2020-02-13T19:44:23.627Z',
createdBy: { fullName: null, email: 'testemail@elastic.co', username: 'elastic' },
description: 'Security banana Issue',
+ externalService: null,
status: 'open',
tags: ['defacement'],
title: 'Another horrible breach!!',
+ totalComment: 1,
updatedAt: '2020-02-19T15:02:57.995Z',
updatedBy: {
username: 'elastic',
@@ -44,6 +47,7 @@ export const caseProps: CaseProps = {
version: 'WzQ3LDFd',
},
};
+
export const caseClosedProps: CaseProps = {
...caseProps,
initialData: {
@@ -63,3 +67,21 @@ export const data: Case = {
export const dataClosed: Case = {
...caseClosedProps.initialData,
};
+
+export const caseUserActions = [
+ {
+ actionField: ['comment'],
+ action: 'create',
+ actionAt: '2020-03-20T17:10:09.814Z',
+ actionBy: {
+ fullName: 'Steph Milovic',
+ username: 'smilovic',
+ email: 'notmyrealemailfool@elastic.co',
+ },
+ newValue: 'Solve this fast!',
+ oldValue: null,
+ actionId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15',
+ caseId: '9b833a50-6acd-11ea-8fad-af86b1071bd9',
+ commentId: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8',
+ },
+];
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx
index 3f4a83d1bff33..18cc33d8a6d4d 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx
@@ -11,11 +11,18 @@ import { mount } from 'enzyme';
import routeData from 'react-router';
/* eslint-enable @kbn/eslint/module_migration */
import { CaseComponent } from './';
-import { caseProps, caseClosedProps, data, dataClosed } from './__mock__';
+import { caseProps, caseClosedProps, data, dataClosed, caseUserActions } from './__mock__';
import { TestProviders } from '../../../../mock';
import { useUpdateCase } from '../../../../containers/case/use_update_case';
+import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions';
+import { wait } from '../../../../lib/helpers';
+import { usePushToService } from './push_to_service';
jest.mock('../../../../containers/case/use_update_case');
+jest.mock('../../../../containers/case/use_get_case_user_actions');
+jest.mock('./push_to_service');
const useUpdateCaseMock = useUpdateCase as jest.Mock;
+const useGetCaseUserActionsMock = useGetCaseUserActions as jest.Mock;
+const usePushToServiceMock = usePushToService as jest.Mock;
type Action = 'PUSH' | 'POP' | 'REPLACE';
const pop: Action = 'POP';
const location = {
@@ -47,6 +54,7 @@ const mockLocation = {
describe('CaseView ', () => {
const updateCaseProperty = jest.fn();
+ const fetchCaseUserActions = jest.fn();
/* eslint-disable no-console */
// Silence until enzyme fixed to use ReactTestUtils.act()
const originalError = console.error;
@@ -66,13 +74,31 @@ describe('CaseView ', () => {
updateCaseProperty,
};
+ const defaultUseGetCaseUserActions = {
+ caseUserActions,
+ fetchCaseUserActions,
+ firstIndexPushToService: -1,
+ hasDataToPush: false,
+ isLoading: false,
+ isError: false,
+ lastIndexPushToService: -1,
+ participants: [data.createdBy],
+ };
+
+ const defaultUsePushToServiceMock = {
+ pushButton: <>{'Hello Button'}>,
+ pushCallouts: null,
+ };
+
beforeEach(() => {
jest.resetAllMocks();
useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState);
jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation);
+ useGetCaseUserActionsMock.mockImplementation(() => defaultUseGetCaseUserActions);
+ usePushToServiceMock.mockImplementation(() => defaultUsePushToServiceMock);
});
- it('should render CaseComponent', () => {
+ it('should render CaseComponent', async () => {
const wrapper = mount(
@@ -80,6 +106,7 @@ describe('CaseView ', () => {
);
+ await wait();
expect(
wrapper
.find(`[data-test-subj="case-view-title"]`)
@@ -119,7 +146,7 @@ describe('CaseView ', () => {
).toEqual(data.description);
});
- it('should show closed indicators in header when case is closed', () => {
+ it('should show closed indicators in header when case is closed', async () => {
useUpdateCaseMock.mockImplementation(() => ({
...defaultUpdateCaseState,
caseData: dataClosed,
@@ -131,6 +158,7 @@ describe('CaseView ', () => {
);
+ await wait();
expect(wrapper.contains(`[data-test-subj="case-view-createdAt"]`)).toBe(false);
expect(
wrapper
@@ -146,7 +174,7 @@ describe('CaseView ', () => {
).toEqual(dataClosed.status);
});
- it('should dispatch update state when button is toggled', () => {
+ it('should dispatch update state when button is toggled', async () => {
const wrapper = mount(
@@ -154,18 +182,19 @@ describe('CaseView ', () => {
);
-
+ await wait();
wrapper
.find('input[data-test-subj="toggle-case-status"]')
.simulate('change', { target: { checked: true } });
expect(updateCaseProperty).toBeCalledWith({
+ fetchCaseUserActions,
updateKey: 'status',
updateValue: 'closed',
});
});
- it('should render comments', () => {
+ it('should render comments', async () => {
const wrapper = mount(
@@ -173,6 +202,7 @@ describe('CaseView ', () => {
);
+ await wait();
expect(
wrapper
.find(
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx
index 0ac3adeb860ff..742921cb9f69e 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx
@@ -4,10 +4,17 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import React, { useCallback, useMemo } from 'react';
-import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
-
+import {
+ EuiButtonToggle,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiLoadingContent,
+ EuiLoadingSpinner,
+ EuiHorizontalRule,
+} from '@elastic/eui';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
+
import * as i18n from './translations';
import { Case } from '../../../../containers/case/types';
import { getCaseUrl } from '../../../../components/link_to';
@@ -24,6 +31,8 @@ import { WhitePageWrapper } from '../wrappers';
import { useBasePath } from '../../../../lib/kibana';
import { CaseStatus } from '../case_status';
import { SpyRoute } from '../../../../utils/route/spy_routes';
+import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions';
+import { usePushToService } from './push_to_service';
interface Props {
caseId: string;
@@ -37,6 +46,13 @@ const MyEuiFlexGroup = styled(EuiFlexGroup)`
height: 100%;
`;
+const MyEuiHorizontalRule = styled(EuiHorizontalRule)`
+ margin-left: 48px;
+ &.euiHorizontalRule--full {
+ width: calc(100% - 48px);
+ }
+`;
+
export interface CaseProps {
caseId: string;
initialData: Case;
@@ -45,7 +61,20 @@ export interface CaseProps {
export const CaseComponent = React.memo(({ caseId, initialData }) => {
const basePath = window.location.origin + useBasePath();
const caseLink = `${basePath}/app/siem#/case/${caseId}`;
- const { caseData, isLoading, updateKey, updateCaseProperty } = useUpdateCase(caseId, initialData);
+ const [initLoadingData, setInitLoadingData] = useState(true);
+ const {
+ caseUserActions,
+ fetchCaseUserActions,
+ firstIndexPushToService,
+ hasDataToPush,
+ isLoading: isLoadingUserActions,
+ lastIndexPushToService,
+ participants,
+ } = useGetCaseUserActions(caseId);
+ const { caseData, isLoading, updateKey, updateCase, updateCaseProperty } = useUpdateCase(
+ caseId,
+ initialData
+ );
// Update Fields
const onUpdateField = useCallback(
@@ -55,6 +84,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
const titleUpdate = getTypedPayload(updateValue);
if (titleUpdate.length > 0) {
updateCaseProperty({
+ fetchCaseUserActions,
updateKey: 'title',
updateValue: titleUpdate,
});
@@ -64,6 +94,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
const descriptionUpdate = getTypedPayload(updateValue);
if (descriptionUpdate.length > 0) {
updateCaseProperty({
+ fetchCaseUserActions,
updateKey: 'description',
updateValue: descriptionUpdate,
});
@@ -72,6 +103,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
case 'tags':
const tagsUpdate = getTypedPayload(updateValue);
updateCaseProperty({
+ fetchCaseUserActions,
updateKey: 'tags',
updateValue: tagsUpdate,
});
@@ -80,6 +112,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
const statusUpdate = getTypedPayload(updateValue);
if (caseData.status !== updateValue) {
updateCaseProperty({
+ fetchCaseUserActions,
updateKey: 'status',
updateValue: statusUpdate,
});
@@ -88,12 +121,29 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
return null;
}
},
- [caseData.status]
+ [fetchCaseUserActions, updateCaseProperty, caseData.status]
+ );
+ const handleUpdateCase = useCallback(
+ (newCase: Case) => {
+ updateCase(newCase);
+ fetchCaseUserActions(newCase.id);
+ },
+ [updateCase, fetchCaseUserActions]
);
+
+ const { pushButton, pushCallouts } = usePushToService({
+ caseId: caseData.id,
+ caseStatus: caseData.status,
+ isNew: caseUserActions.filter(cua => cua.action === 'push-to-service').length === 0,
+ updateCase: handleUpdateCase,
+ });
+
const onSubmitTags = useCallback(newTags => onUpdateField('tags', newTags), [onUpdateField]);
const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]);
- const toggleStatusCase = useCallback(status => onUpdateField('status', status), [onUpdateField]);
-
+ const toggleStatusCase = useCallback(
+ e => onUpdateField('status', e.target.checked ? 'closed' : 'open'),
+ [onUpdateField]
+ );
const spyState = useMemo(() => ({ caseTitle: caseData.title }), [caseData.title]);
const caseStatusData = useMemo(
@@ -111,7 +161,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
}
: {
'data-test-subj': 'case-view-closedAt',
- value: caseData.closedAt,
+ value: caseData.closedAt ?? '',
title: i18n.CASE_CLOSED,
buttonLabel: i18n.REOPEN_CASE,
status: caseData.status,
@@ -126,8 +176,15 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
subject: i18n.EMAIL_SUBJECT(caseData.title),
body: i18n.EMAIL_BODY(caseLink),
}),
- [caseData.title]
+ [caseLink, caseData.title]
);
+
+ useEffect(() => {
+ if (initLoadingData && !isLoadingUserActions) {
+ setInitLoadingData(false);
+ }
+ }, [initLoadingData, isLoadingUserActions]);
+
return (
<>
@@ -157,21 +214,52 @@ export const CaseComponent = React.memo(({ caseId, initialData }) =>
+ {pushCallouts != null && pushCallouts}
-
+ {initLoadingData && }
+ {!initLoadingData && (
+ <>
+
+
+
+
+
+
+ {hasDataToPush && {pushButton}}
+
+ >
+ )}
+
void;
+}
+
+interface Connector {
+ connectorId: string;
+ connectorName: string;
+}
+
+interface ReturnUsePushToService {
+ pushButton: JSX.Element;
+ pushCallouts: JSX.Element | null;
+}
+
+export const usePushToService = ({
+ caseId,
+ caseStatus,
+ updateCase,
+ isNew,
+}: UsePushToService): ReturnUsePushToService => {
+ const urlSearch = useGetUrlSearch(navTabs.case);
+ const [connector, setConnector] = useState(null);
+
+ const { isLoading, postPushToService } = usePostPushToService();
+
+ const handleSetConnector = useCallback((connectorId: string, connectorName?: string) => {
+ setConnector({ connectorId, connectorName: connectorName ?? '' });
+ }, []);
+
+ const { loading: loadingCaseConfigure } = useCaseConfigure({
+ setConnector: handleSetConnector,
+ });
+
+ const { isLoading: loadingLicense, actionLicense } = useGetActionLicense();
+
+ const handlePushToService = useCallback(() => {
+ if (connector != null) {
+ postPushToService({
+ caseId,
+ ...connector,
+ updateCase,
+ });
+ }
+ }, [caseId, connector, postPushToService, updateCase]);
+
+ const errorsMsg = useMemo(() => {
+ let errors: Array<{ title: string; description: JSX.Element }> = [];
+ if (actionLicense != null && !actionLicense.enabledInLicense) {
+ errors = [
+ ...errors,
+ {
+ title: i18n.PUSH_DISABLE_BY_LICENSE_TITLE,
+ description: (
+
+ {i18n.LINK_CLOUD_DEPLOYMENT}
+
+ ),
+ }}
+ />
+ ),
+ },
+ ];
+ }
+ if (connector == null && !loadingCaseConfigure && !loadingLicense) {
+ errors = [
+ ...errors,
+ {
+ title: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE,
+ description: (
+
+ {i18n.LINK_CONNECTOR_CONFIGURE}
+
+ ),
+ }}
+ />
+ ),
+ },
+ ];
+ }
+ if (caseStatus === 'closed') {
+ errors = [
+ ...errors,
+ {
+ title: i18n.PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE,
+ description: (
+
+ ),
+ },
+ ];
+ }
+ if (actionLicense != null && !actionLicense.enabledInConfig) {
+ errors = [
+ ...errors,
+ {
+ title: i18n.PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE,
+ description: (
+
+ {'coming soon...'}
+
+ ),
+ }}
+ />
+ ),
+ },
+ ];
+ }
+ return errors;
+ }, [actionLicense, caseStatus, connector, loadingCaseConfigure, loadingLicense, urlSearch]);
+
+ const pushToServiceButton = useMemo(
+ () => (
+ 0}
+ isLoading={isLoading}
+ >
+ {isNew ? i18n.PUSH_SERVICENOW : i18n.UPDATE_PUSH_SERVICENOW}
+
+ ),
+ [isNew, handlePushToService, isLoading, loadingLicense, loadingCaseConfigure, errorsMsg]
+ );
+
+ const objToReturn = useMemo(
+ () => ({
+ pushButton:
+ errorsMsg.length > 0 ? (
+ {errorsMsg[0].description}
}
+ >
+ {pushToServiceButton}
+
+ ) : (
+ <>{pushToServiceButton}>
+ ),
+ pushCallouts: errorsMsg.length > 0 ? : null,
+ }),
+ [errorsMsg, pushToServiceButton]
+ );
+ return objToReturn;
+};
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts
index e5fa3bff51f85..beba80ccd934c 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts
@@ -18,17 +18,40 @@ export const SHOWING_CASES = (actionDate: string, actionName: string, userName:
defaultMessage: '{userName} {actionName} on {actionDate}',
});
-export const ADDED_DESCRIPTION = i18n.translate(
- 'xpack.siem.case.caseView.actionLabel.addDescription',
+export const ADDED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.addedField', {
+ defaultMessage: 'added',
+});
+
+export const CHANGED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.changededField', {
+ defaultMessage: 'changed',
+});
+
+export const EDITED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.editedField', {
+ defaultMessage: 'edited',
+});
+
+export const REMOVED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.removedField', {
+ defaultMessage: 'removed',
+});
+
+export const PUSHED_NEW_INCIDENT = i18n.translate(
+ 'xpack.siem.case.caseView.actionLabel.pushedNewIncident',
{
- defaultMessage: 'added description',
+ defaultMessage: 'pushed as new incident',
+ }
+);
+
+export const UPDATE_INCIDENT = i18n.translate(
+ 'xpack.siem.case.caseView.actionLabel.updateIncident',
+ {
+ defaultMessage: 'updated incident',
}
);
-export const EDITED_DESCRIPTION = i18n.translate(
- 'xpack.siem.case.caseView.actionLabel.editDescription',
+export const ADDED_DESCRIPTION = i18n.translate(
+ 'xpack.siem.case.caseView.actionLabel.addDescription',
{
- defaultMessage: 'edited description',
+ defaultMessage: 'added description',
}
);
@@ -52,6 +75,14 @@ export const STATUS = i18n.translate('xpack.siem.case.caseView.statusLabel', {
defaultMessage: 'Status',
});
+export const CASE = i18n.translate('xpack.siem.case.caseView.case', {
+ defaultMessage: 'case',
+});
+
+export const COMMENT = i18n.translate('xpack.siem.case.caseView.comment', {
+ defaultMessage: 'comment',
+});
+
export const CASE_OPENED = i18n.translate('xpack.siem.case.caseView.caseOpened', {
defaultMessage: 'Case opened',
});
@@ -71,3 +102,56 @@ export const EMAIL_BODY = (caseUrl: string) =>
values: { caseUrl },
defaultMessage: 'Case reference: {caseUrl}',
});
+
+export const PUSH_SERVICENOW = i18n.translate('xpack.siem.case.caseView.pushAsServicenowIncident', {
+ defaultMessage: 'Push as ServiceNow incident',
+});
+
+export const UPDATE_PUSH_SERVICENOW = i18n.translate(
+ 'xpack.siem.case.caseView.updatePushAsServicenowIncident',
+ {
+ defaultMessage: 'Update ServiceNow incident',
+ }
+);
+
+export const PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE = i18n.translate(
+ 'xpack.siem.case.caseView.pushToServiceDisableByNoCaseConfigTitle',
+ {
+ defaultMessage: 'Configure external connector',
+ }
+);
+
+export const PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE = i18n.translate(
+ 'xpack.siem.case.caseView.pushToServiceDisableBecauseCaseClosedTitle',
+ {
+ defaultMessage: 'Reopen the case',
+ }
+);
+
+export const PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE = i18n.translate(
+ 'xpack.siem.case.caseView.pushToServiceDisableByConfigTitle',
+ {
+ defaultMessage: 'Enable ServiceNow in Kibana configuration file',
+ }
+);
+
+export const PUSH_DISABLE_BY_LICENSE_TITLE = i18n.translate(
+ 'xpack.siem.case.caseView.pushToServiceDisableByLicenseTitle',
+ {
+ defaultMessage: 'Upgrade to Elastic Platinum',
+ }
+);
+
+export const LINK_CLOUD_DEPLOYMENT = i18n.translate(
+ 'xpack.siem.case.caseView.cloudDeploymentLink',
+ {
+ defaultMessage: 'cloud deployment',
+ }
+);
+
+export const LINK_CONNECTOR_CONFIGURE = i18n.translate(
+ 'xpack.siem.case.caseView.connectorConfigureLink',
+ {
+ defaultMessage: 'connector',
+ }
+);
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx
index c8ef6e32595d0..fb4d91492c1d4 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx
@@ -113,7 +113,7 @@ const ConfigureCasesComponent: React.FC = () => {
}, []);
const { loading: loadingCaseConfigure, persistLoading, persistCaseConfigure } = useCaseConfigure({
- setConnectorId,
+ setConnector: setConnectorId,
setClosureType,
});
const { loading: isLoadingConnectors, connectors, refetchConnectors } = useConnectors();
@@ -128,9 +128,13 @@ const ConfigureCasesComponent: React.FC = () => {
// TO DO give a warning/error to user when field are not mapped so they have chance to do it
() => {
setActionBarVisible(false);
- persistCaseConfigure({ connectorId, closureType });
+ persistCaseConfigure({
+ connectorId,
+ connectorName: connectors.find(c => c.id === connectorId)?.name ?? '',
+ closureType,
+ });
},
- [connectorId, closureType, mapping]
+ [connectorId, connectors, closureType, mapping]
);
const onChangeConnector = useCallback((newConnectorId: string) => {
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx
new file mode 100644
index 0000000000000..15b50e4b4cd8d
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx
@@ -0,0 +1,33 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiCallOut, EuiButton, EuiDescriptionList, EuiSpacer } from '@elastic/eui';
+import React, { memo, useCallback, useState } from 'react';
+
+import * as i18n from './translations';
+
+interface ErrorsPushServiceCallOut {
+ errors: Array<{ title: string; description: JSX.Element }>;
+}
+
+const ErrorsPushServiceCallOutComponent = ({ errors }: ErrorsPushServiceCallOut) => {
+ const [showCallOut, setShowCallOut] = useState(true);
+ const handleCallOut = useCallback(() => setShowCallOut(false), [setShowCallOut]);
+
+ return showCallOut ? (
+ <>
+
+
+
+ {i18n.DISMISS_CALLOUT}
+
+
+
+ >
+ ) : null;
+};
+
+export const ErrorsPushServiceCallOut = memo(ErrorsPushServiceCallOutComponent);
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts
new file mode 100644
index 0000000000000..57712e720f6d0
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+
+export const ERROR_PUSH_SERVICE_CALLOUT_TITLE = i18n.translate(
+ 'xpack.siem.case.errorsPushServiceCallOutTitle',
+ {
+ defaultMessage: 'To send cases to external systems, you need to:',
+ }
+);
+
+export const DISMISS_CALLOUT = i18n.translate(
+ 'xpack.siem.case.dismissErrorsPushServiceCallOutTitle',
+ {
+ defaultMessage: 'Dismiss',
+ }
+);
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx
new file mode 100644
index 0000000000000..008f4d7048f56
--- /dev/null
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx
@@ -0,0 +1,75 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiLink } from '@elastic/eui';
+import React from 'react';
+
+import { CaseFullExternalService } from '../../../../../../../../plugins/case/common/api';
+import { CaseUserActions } from '../../../../containers/case/types';
+import * as i18n from '../case_view/translations';
+
+interface LabelTitle {
+ action: CaseUserActions;
+ field: string;
+ firstIndexPushToService: number;
+ index: number;
+}
+
+export const getLabelTitle = ({ action, field, firstIndexPushToService, index }: LabelTitle) => {
+ if (field === 'tags') {
+ return getTagsLabelTitle(action);
+ } else if (field === 'title' && action.action === 'update') {
+ return `${i18n.CHANGED_FIELD.toLowerCase()} ${i18n.CASE_NAME.toLowerCase()} ${i18n.TO} "${
+ action.newValue
+ }"`;
+ } else if (field === 'description' && action.action === 'update') {
+ return `${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`;
+ } else if (field === 'status' && action.action === 'update') {
+ return `${
+ action.newValue === 'open' ? i18n.REOPENED_CASE.toLowerCase() : i18n.CLOSED_CASE.toLowerCase()
+ } ${i18n.CASE}`;
+ } else if (field === 'comment' && action.action === 'update') {
+ return `${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`;
+ } else if (field === 'pushed' && action.action === 'push-to-service' && action.newValue != null) {
+ return getPushedServiceLabelTitle(action, firstIndexPushToService, index);
+ }
+ return '';
+};
+
+const getTagsLabelTitle = (action: CaseUserActions) => (
+
+
+ {action.action === 'add' && i18n.ADDED_FIELD}
+ {action.action === 'delete' && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()}
+
+ {action.newValue != null &&
+ action.newValue.split(',').map(tag => (
+
+ {tag}
+
+ ))}
+
+);
+
+const getPushedServiceLabelTitle = (
+ action: CaseUserActions,
+ firstIndexPushToService: number,
+ index: number
+) => {
+ const pushedVal = JSON.parse(action.newValue ?? '') as CaseFullExternalService;
+ return (
+
+
+ {firstIndexPushToService === index ? i18n.PUSHED_NEW_INCIDENT : i18n.UPDATE_INCIDENT}
+
+
+
+ {pushedVal?.connector_name} {pushedVal?.external_title}
+
+
+
+ );
+};
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx
index 6a3d319561353..8b77186f76f77 100644
--- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx
+++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx
@@ -4,27 +4,54 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import React, { useCallback, useMemo, useState } from 'react';
+import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
+import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react';
+import { useParams } from 'react-router-dom';
+import styled from 'styled-components';
+
import * as i18n from '../case_view/translations';
-import { Case } from '../../../../containers/case/types';
+import { Case, CaseUserActions, Comment } from '../../../../containers/case/types';
import { useUpdateComment } from '../../../../containers/case/use_update_comment';
+import { useCurrentUser } from '../../../../lib/kibana';
+import { AddComment } from '../add_comment';
+import { getLabelTitle } from './helpers';
import { UserActionItem } from './user_action_item';
import { UserActionMarkdown } from './user_action_markdown';
-import { AddComment } from '../add_comment';
-import { useCurrentUser } from '../../../../lib/kibana';
export interface UserActionTreeProps {
data: Case;
+ caseUserActions: CaseUserActions[];
+ fetchUserActions: () => void;
+ firstIndexPushToService: number;
isLoadingDescription: boolean;
+ isLoadingUserActions: boolean;
+ lastIndexPushToService: number;
onUpdateField: (updateKey: keyof Case, updateValue: string | string[]) => void;
}
-const DescriptionId = 'description';
-const NewId = 'newComment';
+const MyEuiFlexGroup = styled(EuiFlexGroup)`
+ margin-bottom: 8px;
+`;
+
+const DESCRIPTION_ID = 'description';
+const NEW_ID = 'newComment';
export const UserActionTree = React.memo(
- ({ data: caseData, onUpdateField, isLoadingDescription }: UserActionTreeProps) => {
+ ({
+ data: caseData,
+ caseUserActions,
+ fetchUserActions,
+ firstIndexPushToService,
+ isLoadingDescription,
+ isLoadingUserActions,
+ lastIndexPushToService,
+ onUpdateField,
+ }: UserActionTreeProps) => {
+ const { commentId } = useParams();
+ const handlerTimeoutId = useRef(0);
+ const [initLoading, setInitLoading] = useState(true);
+ const [selectedOutlineCommentId, setSelectedOutlineCommentId] = useState('');
const { comments, isLoadingIds, updateComment, addPostedComment } = useUpdateComment(
caseData.comments
);
@@ -45,20 +72,54 @@ export const UserActionTree = React.memo(
const handleSaveComment = useCallback(
(id: string, content: string) => {
handleManageMarkdownEditId(id);
- updateComment(caseData.id, id, content);
+ updateComment({
+ caseId: caseData.id,
+ commentId: id,
+ commentUpdate: content,
+ fetchUserActions,
+ });
},
[handleManageMarkdownEditId, updateComment]
);
+ const handleOutlineComment = useCallback(
+ (id: string) => {
+ const moveToTarget = document.getElementById(`${id}-permLink`);
+ if (moveToTarget != null) {
+ const yOffset = -60;
+ const y = moveToTarget.getBoundingClientRect().top + window.pageYOffset + yOffset;
+ window.scrollTo({
+ top: y,
+ behavior: 'smooth',
+ });
+ }
+ window.clearTimeout(handlerTimeoutId.current);
+ setSelectedOutlineCommentId(id);
+ handlerTimeoutId.current = window.setTimeout(() => {
+ setSelectedOutlineCommentId('');
+ window.clearTimeout(handlerTimeoutId.current);
+ }, 2400);
+ },
+ [handlerTimeoutId.current]
+ );
+
+ const handleUpdate = useCallback(
+ (comment: Comment) => {
+ addPostedComment(comment);
+ fetchUserActions();
+ },
+ [addPostedComment, fetchUserActions]
+ );
+
const MarkdownDescription = useMemo(
() => (
{
- handleManageMarkdownEditId(DescriptionId);
- onUpdateField(DescriptionId, content);
+ handleManageMarkdownEditId(DESCRIPTION_ID);
+ onUpdateField(DESCRIPTION_ID, content);
}}
onChangeEditable={handleManageMarkdownEditId}
/>
@@ -67,55 +128,123 @@ export const UserActionTree = React.memo(
);
const MarkdownNewComment = useMemo(
- () => ,
- [caseData.id]
+ () => (
+
+ ),
+ [caseData.id, handleUpdate]
);
+ useEffect(() => {
+ if (initLoading && !isLoadingUserActions && isLoadingIds.length === 0) {
+ setInitLoading(false);
+ if (commentId != null) {
+ handleOutlineComment(commentId);
+ }
+ }
+ }, [commentId, initLoading, isLoadingUserActions, isLoadingIds]);
+
return (
<>
{i18n.ADDED_DESCRIPTION}>}
fullName={caseData.createdBy.fullName ?? caseData.createdBy.username}
markdown={MarkdownDescription}
- onEdit={handleManageMarkdownEditId.bind(null, DescriptionId)}
+ onEdit={handleManageMarkdownEditId.bind(null, DESCRIPTION_ID)}
userName={caseData.createdBy.username}
/>
- {comments.map(comment => (
-
+
+ {caseUserActions.map((action, index) => {
+ if (action.commentId != null && action.action === 'create') {
+ const comment = comments.find(c => c.id === action.commentId);
+ if (comment != null) {
+ return (
+