diff --git a/x-pack/plugins/cases/common/api/cases/comment.ts b/x-pack/plugins/cases/common/api/cases/comment.ts index e4e2a0793f7d4..19ad15286db6a 100644 --- a/x-pack/plugins/cases/common/api/cases/comment.ts +++ b/x-pack/plugins/cases/common/api/cases/comment.ts @@ -110,6 +110,14 @@ export const CommentResponseRt = rt.intersection([ }), ]); +export const CommentResponseTypeUserRt = rt.intersection([ + AttributesTypeUserRt, + rt.type({ + id: rt.string, + version: rt.string, + }), +]); + export const CommentResponseTypeAlertsRt = rt.intersection([ AttributesTypeAlertsRt, rt.type({ @@ -172,6 +180,7 @@ export type AttributesTypeUser = rt.TypeOf; export type CommentAttributes = rt.TypeOf; export type CommentRequest = rt.TypeOf; export type CommentResponse = rt.TypeOf; +export type CommentResponseUserType = rt.TypeOf; export type CommentResponseAlertsType = rt.TypeOf; export type CommentResponseActionsType = rt.TypeOf; export type AllCommentsResponse = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 65981e6aebd0f..cfa91a9c57cab 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -12,12 +12,12 @@ import { CasePatchRequest, CaseStatuses, CaseType, - CommentRequest, User, ActionConnector, CaseExternalServiceBasic, CaseUserActionResponse, CaseMetricsResponse, + CommentResponse, } from '../api'; import { SnakeToCamelCase } from '../types'; @@ -62,18 +62,7 @@ export type CaseViewRefreshPropInterface = null | { refreshCase: () => Promise; }; -export type Comment = CommentRequest & { - associationType: AssociationType; - id: string; - createdAt: string; - createdBy: ElasticUser; - pushedAt: string | null; - pushedBy: string | null; - updatedAt: string | null; - updatedBy: ElasticUser | null; - version: string; -}; - +export type Comment = SnakeToCamelCase; export type CaseUserActions = SnakeToCamelCase; export type CaseExternalService = SnakeToCamelCase; diff --git a/x-pack/plugins/cases/public/common/test_utils.ts b/x-pack/plugins/cases/public/common/test_utils.ts index 0ebff7693eed8..5965ccbcf504e 100644 --- a/x-pack/plugins/cases/public/common/test_utils.ts +++ b/x-pack/plugins/cases/public/common/test_utils.ts @@ -7,6 +7,7 @@ import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; +import { MatcherFunction } from '@testing-library/react'; /** * Convenience utility to remove text appended to links by EUI @@ -25,3 +26,16 @@ export const waitForComponentToUpdate = async () => act(async () => { return Promise.resolve(); }); + +type Query = (f: MatcherFunction) => HTMLElement; + +export const createQueryWithMarkup = + (query: Query) => + (text: string): HTMLElement => + query((content: string, node: Parameters[1]) => { + const hasText = (el: Parameters[1]) => el?.textContent === text; + const childrenDontHaveText = Array.from(node?.children ?? []).every( + (child) => !hasText(child as HTMLElement) + ); + return hasText(node) && childrenDontHaveText; + }); diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx index 7950f962a9cc1..5897a757b5bdf 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx @@ -792,8 +792,10 @@ describe('AllCasesListGeneric', () => { ); - const solutionHeader = wrapper.find({ children: 'Solution' }); - expect(solutionHeader.exists()).toBeTruthy(); + await waitFor(() => { + const solutionHeader = wrapper.find({ children: 'Solution' }); + expect(solutionHeader.exists()).toBeTruthy(); + }); }); it('hides Solution column if there is a set owner', async () => { @@ -805,8 +807,10 @@ describe('AllCasesListGeneric', () => { ); - const solutionHeader = wrapper.find({ children: 'Solution' }); - expect(solutionHeader.exists()).toBeFalsy(); + await waitFor(() => { + const solutionHeader = wrapper.find({ children: 'Solution' }); + expect(solutionHeader.exists()).toBeFalsy(); + }); }); it('should deselect cases when refreshing', async () => { diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx index ac10068b88b3e..9c0b7831893fa 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx @@ -35,7 +35,7 @@ jest.mock('../../containers/use_get_case_user_actions'); jest.mock('../../containers/use_get_case'); jest.mock('../../containers/configure/use_connectors'); jest.mock('../../containers/use_post_push_to_service'); -jest.mock('../user_action_tree/user_action_timestamp'); +jest.mock('../user_actions/timestamp'); jest.mock('../../common/lib/kibana'); jest.mock('../../common/navigation/hooks'); @@ -506,7 +506,7 @@ describe('CaseViewPage', () => { expect( wrapper .find( - '[data-test-subj="comment-create-action-alert-action-id"] .euiCommentEvent__headerEvent' + '[data-test-subj="user-action-alert-comment-create-action-alert-action-id"] .euiCommentEvent__headerEvent' ) .first() .text() diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx index 3ff8845f1e3a5..f1f4193ad346d 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx @@ -18,7 +18,7 @@ import { CaseStatuses, CaseAttributes, CaseType, CaseConnector } from '../../../ import { Case, UpdateKey, UpdateByKey } from '../../../common/ui'; import { EditableTitle } from '../header_page/editable_title'; import { TagList } from '../tag_list'; -import { UserActionTree } from '../user_action_tree'; +import { UserActions } from '../user_actions'; import { UserList } from '../user_list'; import { useUpdateCase } from '../../containers/use_update_case'; import { getTypedPayload } from '../../containers/utils'; @@ -363,12 +363,11 @@ export const CaseViewPage = React.memo( )} - { @@ -31,7 +32,7 @@ describe('Description', () => { }; beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); it('it renders', async () => { diff --git a/x-pack/plugins/cases/public/components/create/form.test.tsx b/x-pack/plugins/cases/public/components/create/form.test.tsx index 65fcc479979f1..dd0759ce723ae 100644 --- a/x-pack/plugins/cases/public/components/create/form.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form.test.tsx @@ -68,7 +68,7 @@ describe('CreateCaseForm', () => { }; beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); useGetTagsMock.mockReturnValue({ tags: ['test'] }); useConnectorsMock.mockReturnValue({ loading: false, connectors: connectorsMock }); useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/__mocks__/use_lens_draft_comment.ts b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/__mocks__/use_lens_draft_comment.ts index a0f0d49b211fb..677ef694894ec 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/__mocks__/use_lens_draft_comment.ts +++ b/x-pack/plugins/cases/public/components/markdown_editor/plugins/lens/__mocks__/use_lens_draft_comment.ts @@ -5,4 +5,9 @@ * 2.0. */ -export const useLensDraftComment = () => ({}); +export const useLensDraftComment = jest.fn().mockReturnValue({ + draftComment: null, + hasIncomingLensState: false, + clearDraftComment: jest.fn(), + openLensModal: jest.fn(), +}); diff --git a/x-pack/plugins/cases/public/components/user_action_tree/constants.ts b/x-pack/plugins/cases/public/components/user_action_tree/constants.ts deleted file mode 100644 index 584194be65f50..0000000000000 --- a/x-pack/plugins/cases/public/components/user_action_tree/constants.ts +++ /dev/null @@ -1,8 +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. - */ - -export const DRAFT_COMMENT_STORAGE_ID = 'xpack.cases.commentDraft'; diff --git a/x-pack/plugins/cases/public/components/user_action_tree/helpers.test.tsx b/x-pack/plugins/cases/public/components/user_action_tree/helpers.test.tsx deleted file mode 100644 index 7f3ae4a490264..0000000000000 --- a/x-pack/plugins/cases/public/components/user_action_tree/helpers.test.tsx +++ /dev/null @@ -1,211 +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 from 'react'; -import { mount } from 'enzyme'; - -import { - Actions, - CaseStatuses, - CommentType, - ConnectorTypes, - ConnectorUserAction, - PushedUserAction, - TagsUserAction, - TitleUserAction, -} from '../../../common/api'; -import { basicPush, getUserAction } from '../../containers/mock'; -import { getLabelTitle, getPushedServiceLabelTitle, getConnectorLabelTitle } from './helpers'; -import { connectorsMock } from '../../containers/configure/mock'; -import * as i18n from './translations'; -import { SnakeToCamelCase } from '../../../common/types'; -import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; - -describe('User action tree helpers', () => { - const connectors = connectorsMock; - it('label title generated for update tags', () => { - const action = getUserAction('tags', Actions.update, { payload: { tags: ['test'] } }); - const result: string | JSX.Element = getLabelTitle({ - action, - }); - - const tags = (action as unknown as TagsUserAction).payload.tags; - - const wrapper = mount(<>{result}); - expect(wrapper.find(`[data-test-subj="ua-tags-label"]`).first().text()).toEqual( - ` ${i18n.TAGS.toLowerCase()}` - ); - - expect(wrapper.find(`[data-test-subj="tag-${tags[0]}"]`).first().text()).toEqual(tags[0]); - }); - - it('label title generated for update title', () => { - const action = getUserAction('title', Actions.update, { payload: { title: 'test' } }); - const result: string | JSX.Element = getLabelTitle({ - action, - }); - - const title = (action as unknown as TitleUserAction).payload.title; - - expect(result).toEqual( - `${i18n.CHANGED_FIELD.toLowerCase()} ${i18n.CASE_NAME.toLowerCase()} ${i18n.TO} "${title}"` - ); - }); - - it('label title generated for update description', () => { - const action = getUserAction('description', Actions.update, { - payload: { description: 'test' }, - }); - const result: string | JSX.Element = getLabelTitle({ - action, - }); - - expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`); - }); - - it('label title generated for update status to open', () => { - const action = { - ...getUserAction('status', Actions.update, { payload: { status: CaseStatuses.open } }), - }; - const result: string | JSX.Element = getLabelTitle({ - action, - }); - - const wrapper = mount(<>{result}); - expect(wrapper.find(`[data-test-subj="status-badge-open"]`).first().text()).toEqual('Open'); - }); - - it('label title generated for update status to in-progress', () => { - const action = { - ...getUserAction('status', Actions.update, { - payload: { status: CaseStatuses['in-progress'] }, - }), - }; - const result: string | JSX.Element = getLabelTitle({ - action, - }); - - const wrapper = mount(<>{result}); - expect(wrapper.find(`[data-test-subj="status-badge-in-progress"]`).first().text()).toEqual( - 'In progress' - ); - }); - - it('label title generated for update status to closed', () => { - const action = { - ...getUserAction('status', Actions.update, { - payload: { status: CaseStatuses.closed }, - }), - }; - const result: string | JSX.Element = getLabelTitle({ - action, - }); - - const wrapper = mount(<>{result}); - expect(wrapper.find(`[data-test-subj="status-badge-closed"]`).first().text()).toEqual('Closed'); - }); - - it('label title is empty when status is not valid', () => { - const action = { - ...getUserAction('status', Actions.update, { - payload: { status: '' }, - }), - }; - - const result: string | JSX.Element = getLabelTitle({ - action, - }); - - expect(result).toEqual(''); - }); - - it('label title generated for update comment', () => { - const action = getUserAction('comment', Actions.update, { - payload: { - comment: { comment: 'a comment', type: CommentType.user, owner: SECURITY_SOLUTION_OWNER }, - }, - }); - const result: string | JSX.Element = getLabelTitle({ - action, - }); - - expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`); - }); - - it('label title generated for pushed incident', () => { - const action = getUserAction('pushed', 'push_to_service', { - payload: { externalService: basicPush }, - }) as SnakeToCamelCase; - const result: string | JSX.Element = getPushedServiceLabelTitle(action, true); - const externalService = (action as SnakeToCamelCase).payload.externalService; - - const wrapper = mount(<>{result}); - expect(wrapper.find(`[data-test-subj="pushed-label"]`).first().text()).toEqual( - `${i18n.PUSHED_NEW_INCIDENT} ${basicPush.connectorName}` - ); - expect(wrapper.find(`[data-test-subj="pushed-value"]`).first().prop('href')).toEqual( - externalService.externalUrl - ); - }); - - it('label title generated for needs update incident', () => { - const action = getUserAction('pushed', 'push_to_service') as SnakeToCamelCase; - const result: string | JSX.Element = getPushedServiceLabelTitle(action, false); - const externalService = (action as SnakeToCamelCase).payload.externalService; - - const wrapper = mount(<>{result}); - expect(wrapper.find(`[data-test-subj="pushed-label"]`).first().text()).toEqual( - `${i18n.UPDATE_INCIDENT} ${basicPush.connectorName}` - ); - expect(wrapper.find(`[data-test-subj="pushed-value"]`).first().prop('href')).toEqual( - externalService.externalUrl - ); - }); - - describe('getConnectorLabelTitle', () => { - it('returns an empty string when the encoded value is null', () => { - const result = getConnectorLabelTitle({ - // @ts-expect-error - action: getUserAction(['connector'], Actions.update, { payload: { connector: null } }), - connectors, - }); - - expect(result).toEqual(''); - }); - - it('returns the change connector label', () => { - const result: string | JSX.Element = getConnectorLabelTitle({ - action: getUserAction('connector', Actions.update, { - payload: { - connector: { - id: 'resilient-2', - type: ConnectorTypes.resilient, - name: 'a', - fields: null, - }, - }, - }) as unknown as ConnectorUserAction, - connectors, - }); - - expect(result).toEqual('selected My Connector 2 as incident management system'); - }); - - it('returns the removed connector label', () => { - const result: string | JSX.Element = getConnectorLabelTitle({ - action: getUserAction('connector', Actions.update, { - payload: { - connector: { id: 'none', type: ConnectorTypes.none, name: 'test', fields: null }, - }, - }) as unknown as ConnectorUserAction, - connectors, - }); - - expect(result).toEqual('removed external incident management system'); - }); - }); -}); diff --git a/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx b/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx deleted file mode 100644 index ccdc0f8ce888e..0000000000000 --- a/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx +++ /dev/null @@ -1,411 +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 { - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiLink, - EuiCommentProps, - EuiToken, -} from '@elastic/eui'; -import React, { useContext } from 'react'; -import classNames from 'classnames'; -import { ThemeContext } from 'styled-components'; -import { CaseExternalService, Comment } from '../../../common/ui/types'; -import { - ActionConnector, - CaseStatuses, - CommentType, - CommentRequestActionsType, - NONE_CONNECTOR_ID, - Actions, - ConnectorUserAction, - PushedUserAction, - TagsUserAction, -} from '../../../common/api'; -import { CaseUserActions } from '../../containers/types'; -import { CaseServices } from '../../containers/use_get_case_user_actions'; -import { Tags } from '../tag_list/tags'; -import { UserActionUsernameWithAvatar } from './user_action_username_with_avatar'; -import { UserActionTimestamp } from './user_action_timestamp'; -import { UserActionCopyLink } from './user_action_copy_link'; -import { ContentWrapper } from './user_action_markdown'; -import { UserActionMoveToReference } from './user_action_move_to_reference'; -import { Status, statuses } from '../status'; -import { UserActionShowAlert } from './user_action_show_alert'; -import * as i18n from './translations'; -import { AlertCommentEvent } from './user_action_alert_comment_event'; -import { CasesNavigation } from '../links'; -import { HostIsolationCommentEvent } from './user_action_host_isolation_comment_event'; -import { MarkdownRenderer } from '../markdown_editor'; -import { - isCommentUserAction, - isDescriptionUserAction, - isStatusUserAction, - isTagsUserAction, - isTitleUserAction, -} from '../../../common/utils/user_actions'; -import { SnakeToCamelCase } from '../../../common/types'; - -interface LabelTitle { - action: CaseUserActions; -} - -export type RuleDetailsNavigation = CasesNavigation; - -export type ActionsNavigation = CasesNavigation; - -const getStatusTitle = (id: string, status: CaseStatuses) => ( - - {i18n.MARKED_CASE_AS} - - - - -); - -const isStatusValid = (status: string): status is CaseStatuses => - Object.prototype.hasOwnProperty.call(statuses, status); - -export const getLabelTitle = ({ action }: LabelTitle) => { - if (isTagsUserAction(action)) { - return getTagsLabelTitle(action); - } else if (isTitleUserAction(action)) { - return `${i18n.CHANGED_FIELD.toLowerCase()} ${i18n.CASE_NAME.toLowerCase()} ${i18n.TO} "${ - action.payload.title - }"`; - } else if (isDescriptionUserAction(action) && action.action === Actions.update) { - return `${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`; - } else if (isStatusUserAction(action)) { - const status = action.payload.status ?? ''; - if (isStatusValid(status)) { - return getStatusTitle(action.actionId, status); - } - - return ''; - } else if (isCommentUserAction(action) && action.action === Actions.update) { - return `${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`; - } - - return ''; -}; - -export const getConnectorLabelTitle = ({ - action, - connectors, -}: { - action: ConnectorUserAction; - connectors: ActionConnector[]; -}) => { - const connector = action.payload.connector; - - if (connector == null) { - return ''; - } - - // ids are not the same so check and see if the id is a valid connector and then return its name - // if the connector id is the none connector value then it must have been removed - const newConnectorActionInfo = connectors.find((c) => c.id === connector.id); - if (connector.id !== NONE_CONNECTOR_ID && newConnectorActionInfo != null) { - return i18n.SELECTED_THIRD_PARTY(newConnectorActionInfo.name); - } - - // it wasn't a valid connector or it was the none connector, so it must have been removed - return i18n.REMOVED_THIRD_PARTY; -}; - -const getTagsLabelTitle = (action: TagsUserAction) => { - const tags = action.payload.tags ?? []; - - return ( - - - {action.action === Actions.add && i18n.ADDED_FIELD} - {action.action === Actions.delete && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()} - - - - - - ); -}; - -export const getPushedServiceLabelTitle = ( - action: SnakeToCamelCase, - firstPush: boolean -) => { - const externalService = action.payload.externalService; - - return ( - - - {`${firstPush ? i18n.PUSHED_NEW_INCIDENT : i18n.UPDATE_INCIDENT} ${ - externalService?.connectorName - }`} - - - - {externalService?.externalTitle} - - - - ); -}; - -export const getPushInfo = ( - caseServices: CaseServices, - externalService: CaseExternalService | undefined, - index: number -) => - externalService != null && externalService.connectorId !== NONE_CONNECTOR_ID - ? { - firstPush: caseServices[externalService.connectorId]?.firstPushIndex === index, - parsedConnectorId: externalService.connectorId, - parsedConnectorName: externalService.connectorName, - } - : { - firstPush: false, - parsedConnectorId: NONE_CONNECTOR_ID, - parsedConnectorName: NONE_CONNECTOR_ID, - }; - -const getUpdateActionIcon = (fields: string): string => { - if (fields === 'tags') { - return 'tag'; - } else if (fields === 'status') { - return 'folderClosed'; - } - - return 'dot'; -}; - -export const getUpdateAction = ({ - action, - label, - handleOutlineComment, -}: { - action: CaseUserActions; - label: string | JSX.Element; - handleOutlineComment: (id: string) => void; -}): EuiCommentProps => ({ - username: ( - - ), - type: 'update', - event: label, - 'data-test-subj': `${action.type}-${action.action}-action-${action.actionId}`, - timestamp: , - timelineIcon: getUpdateActionIcon(action.type), - actions: ( - - - - - {action.action === Actions.update && action.commentId != null && ( - - - - )} - - ), -}); - -export const getAlertAttachment = ({ - action, - alertId, - getRuleDetailsHref, - index, - loadingAlertData, - onRuleDetailsClick, - onShowAlertDetails, - ruleId, - ruleName, -}: { - action: CaseUserActions; - alertId: string; - getRuleDetailsHref: RuleDetailsNavigation['href']; - index: string; - loadingAlertData: boolean; - onRuleDetailsClick?: RuleDetailsNavigation['onClick']; - onShowAlertDetails: (alertId: string, index: string) => void; - ruleId?: string | null; - ruleName?: string | null; -}): EuiCommentProps => ({ - username: ( - - ), - className: 'comment-alert', - type: 'update', - event: ( - - ), - 'data-test-subj': `${action.type}-${action.action}-action-${action.actionId}`, - timestamp: , - timelineIcon: 'bell', - actions: ( - - - - - - - - - ), -}); - -export const getGeneratedAlertsAttachment = ({ - action, - alertIds, - getRuleDetailsHref, - onRuleDetailsClick, - renderInvestigateInTimelineActionComponent, - ruleId, - ruleName, -}: { - action: CaseUserActions; - alertIds: string[]; - getRuleDetailsHref: RuleDetailsNavigation['href']; - onRuleDetailsClick?: RuleDetailsNavigation['onClick']; - renderInvestigateInTimelineActionComponent?: (alertIds: string[]) => JSX.Element; - ruleId: string; - ruleName: string; -}): EuiCommentProps => ({ - username: , - className: 'comment-alert', - type: 'update', - event: ( - - ), - 'data-test-subj': `${action.type}-${action.action}-action-${action.actionId}`, - timestamp: , - timelineIcon: 'bell', - actions: ( - - - - - {renderInvestigateInTimelineActionComponent ? ( - - {renderInvestigateInTimelineActionComponent(alertIds)} - - ) : null} - - ), -}); - -const ActionIcon = React.memo<{ - actionType: string; -}>(({ actionType }) => { - const theme = useContext(ThemeContext); - return ( - - ); -}); - -ActionIcon.displayName = 'ActionIcon'; - -export const getActionAttachment = ({ - comment, - userCanCrud, - isLoadingIds, - actionsNavigation, - action, -}: { - comment: Comment & CommentRequestActionsType; - userCanCrud: boolean; - isLoadingIds: string[]; - actionsNavigation?: ActionsNavigation; - action: CaseUserActions; -}): EuiCommentProps => ({ - username: ( - - ), - className: classNames('comment-action', { 'empty-comment': comment.comment.trim().length === 0 }), - event: ( - - ), - 'data-test-subj': 'endpoint-action', - timestamp: , - timelineIcon: , - actions: , - children: comment.comment.trim().length > 0 && ( - - {comment.comment} - - ), -}); - -interface Signal { - rule: { - id: string; - name: string; - to: string; - from: string; - }; -} - -export interface Alert { - _id: string; - _index: string; - '@timestamp': string; - signal: Signal; - [key: string]: unknown; -} diff --git a/x-pack/plugins/cases/public/components/user_action_tree/index.tsx b/x-pack/plugins/cases/public/components/user_action_tree/index.tsx deleted file mode 100644 index d4270b464773c..0000000000000 --- a/x-pack/plugins/cases/public/components/user_action_tree/index.tsx +++ /dev/null @@ -1,689 +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 { - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, - EuiCommentList, - EuiCommentProps, -} from '@elastic/eui'; -import { ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils'; - -import classNames from 'classnames'; -import { get, isEmpty } from 'lodash'; -import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react'; -import styled from 'styled-components'; -import { isRight } from 'fp-ts/Either'; - -import * as i18n from './translations'; - -import { useUpdateComment } from '../../containers/use_update_comment'; -import { useCurrentUser } from '../../common/lib/kibana'; -import { AddComment, AddCommentRefObject } from '../add_comment'; -import { Case, CaseUserActions, Ecs } from '../../../common/ui/types'; -import { - ActionConnector, - Actions, - ActionsCommentRequestRt, - AlertCommentRequestRt, - CommentType, - ContextTypeUserRt, -} from '../../../common/api'; -import { CaseServices } from '../../containers/use_get_case_user_actions'; -import { - getConnectorLabelTitle, - getLabelTitle, - getPushedServiceLabelTitle, - getPushInfo, - getUpdateAction, - getAlertAttachment, - getGeneratedAlertsAttachment, - RuleDetailsNavigation, - ActionsNavigation, - getActionAttachment, -} from './helpers'; -import { UserActionAvatar } from './user_action_avatar'; -import { UserActionMarkdown, UserActionMarkdownRefObject } from './user_action_markdown'; -import { UserActionTimestamp } from './user_action_timestamp'; -import { UserActionUsername } from './user_action_username'; -import { UserActionContentToolbar } from './user_action_content_toolbar'; -import { getManualAlertIdsWithNoRuleId } from '../case_view/helpers'; -import { useLensDraftComment } from '../markdown_editor/plugins/lens/use_lens_draft_comment'; -import { useCaseViewParams } from '../../common/navigation'; -import { isConnectorUserAction, isPushedUserAction } from '../../../common/utils/user_actions'; -import type { OnUpdateFields } from '../case_view/types'; - -export interface UserActionTreeProps { - caseServices: CaseServices; - caseUserActions: CaseUserActions[]; - connectors: ActionConnector[]; - data: Case; - fetchUserActions: () => void; - getRuleDetailsHref?: RuleDetailsNavigation['href']; - actionsNavigation?: ActionsNavigation; - isLoadingDescription: boolean; - isLoadingUserActions: boolean; - onRuleDetailsClick?: RuleDetailsNavigation['onClick']; - onShowAlertDetails: (alertId: string, index: string) => void; - onUpdateField: ({ key, value, onSuccess, onError }: OnUpdateFields) => void; - renderInvestigateInTimelineActionComponent?: (alertIds: string[]) => JSX.Element; - statusActionButton: JSX.Element | null; - updateCase: (newCase: Case) => void; - useFetchAlertData: (alertIds: string[]) => [boolean, Record]; - userCanCrud: boolean; -} - -const MyEuiFlexGroup = styled(EuiFlexGroup)` - margin-bottom: 8px; -`; - -const MyEuiCommentList = styled(EuiCommentList)` - ${({ theme }) => ` - & .userAction__comment.outlined .euiCommentEvent { - outline: solid 5px ${theme.eui.euiColorVis1_behindText}; - margin: 0.5em; - transition: 0.8s; - } - - & .euiComment.isEdit { - & .euiCommentEvent { - border: none; - box-shadow: none; - } - - & .euiCommentEvent__body { - padding: 0; - } - - & .euiCommentEvent__header { - display: none; - } - } - - & .comment-alert .euiCommentEvent { - background-color: ${theme.eui.euiColorLightestShade}; - border: ${theme.eui.euiFlyoutBorder}; - padding: ${theme.eui.paddingSizes.s}; - border-radius: ${theme.eui.paddingSizes.xs}; - } - - & .comment-alert .euiCommentEvent__headerData { - flex-grow: 1; - } - - & .comment-action.empty-comment .euiCommentEvent--regular { - box-shadow: none; - .euiCommentEvent__header { - padding: ${theme.eui.euiSizeM} ${theme.eui.paddingSizes.s}; - border-bottom: 0; - } - } - `} -`; - -const DESCRIPTION_ID = 'description'; -const NEW_ID = 'newComment'; - -const isAddCommentRef = ( - ref: AddCommentRefObject | UserActionMarkdownRefObject | null | undefined -): ref is AddCommentRefObject => { - const commentRef = ref as AddCommentRefObject; - if (commentRef?.addQuote != null) { - return true; - } - - return false; -}; - -export const UserActionTree = React.memo( - ({ - caseServices, - caseUserActions, - connectors, - data: caseData, - fetchUserActions, - getRuleDetailsHref, - actionsNavigation, - isLoadingDescription, - isLoadingUserActions, - onRuleDetailsClick, - onShowAlertDetails, - onUpdateField, - renderInvestigateInTimelineActionComponent, - statusActionButton, - updateCase, - useFetchAlertData, - userCanCrud, - }: UserActionTreeProps) => { - const { detailName: caseId, subCaseId, commentId } = useCaseViewParams(); - const handlerTimeoutId = useRef(0); - const [initLoading, setInitLoading] = useState(true); - const [selectedOutlineCommentId, setSelectedOutlineCommentId] = useState(''); - const { isLoadingIds, patchComment } = useUpdateComment(); - const currentUser = useCurrentUser(); - const [manageMarkdownEditIds, setManageMarkdownEditIds] = useState([]); - const commentRefs = useRef< - Record - >({}); - const { clearDraftComment, draftComment, hasIncomingLensState, openLensModal } = - useLensDraftComment(); - - const [loadingAlertData, manualAlertsData] = useFetchAlertData( - getManualAlertIdsWithNoRuleId(caseData.comments) - ); - - const handleManageMarkdownEditId = useCallback( - (id: string) => { - clearDraftComment(); - setManageMarkdownEditIds((prevManageMarkdownEditIds) => - !prevManageMarkdownEditIds.includes(id) - ? prevManageMarkdownEditIds.concat(id) - : prevManageMarkdownEditIds.filter((myId) => id !== myId) - ); - }, - [clearDraftComment] - ); - - const handleSaveComment = useCallback( - ({ id, version }: { id: string; version: string }, content: string) => { - patchComment({ - caseId, - commentId: id, - commentUpdate: content, - fetchUserActions, - version, - updateCase, - subCaseId, - }); - }, - [caseId, fetchUserActions, patchComment, subCaseId, updateCase] - ); - - 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', - }); - if (id === 'add-comment') { - moveToTarget.getElementsByTagName('textarea')[0].focus(); - } - } - window.clearTimeout(handlerTimeoutId.current); - setSelectedOutlineCommentId(id); - handlerTimeoutId.current = window.setTimeout(() => { - setSelectedOutlineCommentId(''); - window.clearTimeout(handlerTimeoutId.current); - }, 2400); - }, - [handlerTimeoutId] - ); - - const handleManageQuote = useCallback( - (quote: string) => { - const ref = commentRefs?.current[NEW_ID]; - if (isAddCommentRef(ref)) { - ref.addQuote(quote); - } - - handleOutlineComment('add-comment'); - }, - [handleOutlineComment] - ); - - const handleUpdate = useCallback( - (newCase: Case) => { - updateCase(newCase); - fetchUserActions(); - }, - [fetchUserActions, updateCase] - ); - - const MarkdownDescription = useMemo( - () => ( - (commentRefs.current[DESCRIPTION_ID] = element)} - id={DESCRIPTION_ID} - content={caseData.description} - isEditable={manageMarkdownEditIds.includes(DESCRIPTION_ID)} - onSaveContent={(content: string) => { - onUpdateField({ key: DESCRIPTION_ID, value: content }); - }} - onChangeEditable={handleManageMarkdownEditId} - /> - ), - [caseData.description, handleManageMarkdownEditId, manageMarkdownEditIds, onUpdateField] - ); - - const MarkdownNewComment = useMemo( - () => ( - (commentRefs.current[NEW_ID] = element)} - onCommentPosted={handleUpdate} - onCommentSaving={handleManageMarkdownEditId.bind(null, NEW_ID)} - showLoading={false} - statusActionButton={statusActionButton} - subCaseId={subCaseId} - /> - ), - [caseId, userCanCrud, handleUpdate, handleManageMarkdownEditId, statusActionButton, subCaseId] - ); - - useEffect(() => { - if (initLoading && !isLoadingUserActions && isLoadingIds.length === 0) { - setInitLoading(false); - if (commentId != null) { - handleOutlineComment(commentId); - } - } - }, [commentId, initLoading, isLoadingUserActions, isLoadingIds, handleOutlineComment]); - - const descriptionCommentListObj: EuiCommentProps = useMemo( - () => ({ - username: ( - - ), - event: i18n.ADDED_DESCRIPTION, - 'data-test-subj': 'description-action', - timestamp: , - children: MarkdownDescription, - timelineIcon: ( - - ), - className: classNames({ - isEdit: manageMarkdownEditIds.includes(DESCRIPTION_ID), - }), - actions: ( - - ), - }), - [ - MarkdownDescription, - caseData, - handleManageMarkdownEditId, - handleManageQuote, - isLoadingDescription, - userCanCrud, - manageMarkdownEditIds, - ] - ); - - const userActions: EuiCommentProps[] = useMemo( - () => - caseUserActions.reduce( - // TODO: Decrease complexity. https://github.com/elastic/kibana/issues/115730 - // eslint-disable-next-line complexity - (comments, action, index) => { - // Comment creation - if (action.commentId != null && action.action === Actions.create) { - const comment = caseData.comments.find((c) => c.id === action.commentId); - if ( - comment != null && - isRight(ContextTypeUserRt.decode(comment)) && - comment.type === CommentType.user - ) { - return [ - ...comments, - { - username: ( - - ), - 'data-test-subj': `comment-create-action-${comment.id}`, - timestamp: ( - - ), - className: classNames('userAction__comment', { - outlined: comment.id === selectedOutlineCommentId, - isEdit: manageMarkdownEditIds.includes(comment.id), - }), - children: ( - (commentRefs.current[comment.id] = element)} - id={comment.id} - content={comment.comment} - isEditable={manageMarkdownEditIds.includes(comment.id)} - onChangeEditable={handleManageMarkdownEditId} - onSaveContent={handleSaveComment.bind(null, { - id: comment.id, - version: comment.version, - })} - /> - ), - timelineIcon: ( - - ), - actions: ( - - ), - }, - ]; - } else if ( - comment != null && - isRight(AlertCommentRequestRt.decode(comment)) && - comment.type === CommentType.alert - ) { - // TODO: clean this up - const alertId = Array.isArray(comment.alertId) - ? comment.alertId.length > 0 - ? comment.alertId[0] - : '' - : comment.alertId; - - const alertIndex = Array.isArray(comment.index) - ? comment.index.length > 0 - ? comment.index[0] - : '' - : comment.index; - - if (isEmpty(alertId)) { - return comments; - } - - const ruleId = - comment?.rule?.id ?? - manualAlertsData[alertId]?.signal?.rule?.id?.[0] ?? - get(manualAlertsData[alertId], ALERT_RULE_UUID)[0] ?? - null; - const ruleName = - comment?.rule?.name ?? - manualAlertsData[alertId]?.signal?.rule?.name?.[0] ?? - get(manualAlertsData[alertId], ALERT_RULE_NAME)[0] ?? - null; - - return [ - ...comments, - ...(getRuleDetailsHref != null - ? [ - getAlertAttachment({ - action, - alertId, - getRuleDetailsHref, - index: alertIndex, - loadingAlertData, - onRuleDetailsClick, - ruleId, - ruleName, - onShowAlertDetails, - }), - ] - : []), - ]; - } else if (comment != null && comment.type === CommentType.generatedAlert) { - // TODO: clean this up - const alertIds = Array.isArray(comment.alertId) - ? comment.alertId - : [comment.alertId]; - - if (isEmpty(alertIds)) { - return comments; - } - - return [ - ...comments, - ...(getRuleDetailsHref != null - ? [ - getGeneratedAlertsAttachment({ - action, - alertIds, - getRuleDetailsHref, - onRuleDetailsClick, - renderInvestigateInTimelineActionComponent, - ruleId: comment.rule?.id ?? '', - ruleName: comment.rule?.name ?? i18n.UNKNOWN_RULE, - }), - ] - : []), - ]; - } else if ( - comment != null && - isRight(ActionsCommentRequestRt.decode(comment)) && - comment.type === CommentType.actions - ) { - return [ - ...comments, - ...(comment.actions !== null - ? [ - getActionAttachment({ - comment, - userCanCrud, - isLoadingIds, - actionsNavigation, - action, - }), - ] - : []), - ]; - } - } - - // Connectors - if (isConnectorUserAction(action)) { - const label = getConnectorLabelTitle({ action, connectors }); - return [ - ...comments, - getUpdateAction({ - action, - label, - handleOutlineComment, - }), - ]; - } - - // Pushed information - if (isPushedUserAction<'camelCase'>(action)) { - const parsedExternalService = action.payload.externalService; - - const { firstPush, parsedConnectorId, parsedConnectorName } = getPushInfo( - caseServices, - parsedExternalService, - index - ); - - const label = getPushedServiceLabelTitle(action, firstPush); - - const showTopFooter = - action.action === Actions.push_to_service && - index === caseServices[parsedConnectorId]?.lastPushIndex; - - const showBottomFooter = - action.action === Actions.push_to_service && - index === caseServices[parsedConnectorId]?.lastPushIndex && - caseServices[parsedConnectorId].hasDataToPush; - - let footers: EuiCommentProps[] = []; - - if (showTopFooter) { - footers = [ - ...footers, - { - username: '', - type: 'update', - event: i18n.ALREADY_PUSHED_TO_SERVICE(`${parsedConnectorName}`), - timelineIcon: 'sortUp', - 'data-test-subj': 'top-footer', - }, - ]; - } - - if (showBottomFooter) { - footers = [ - ...footers, - { - username: '', - type: 'update', - event: i18n.REQUIRED_UPDATE_TO_SERVICE(`${parsedConnectorName}`), - timelineIcon: 'sortDown', - 'data-test-subj': 'bottom-footer', - }, - ]; - } - - return [ - ...comments, - getUpdateAction({ - action, - label, - handleOutlineComment, - }), - ...footers, - ]; - } - - // title, description, comment updates, tags - if (['title', 'description', 'comment', 'tags', 'status'].includes(action.type)) { - const label: string | JSX.Element = getLabelTitle({ - action, - }); - - return [ - ...comments, - getUpdateAction({ - action, - label, - handleOutlineComment, - }), - ]; - } - - return comments; - }, - [descriptionCommentListObj] - ), - [ - caseUserActions, - descriptionCommentListObj, - caseData.comments, - selectedOutlineCommentId, - manageMarkdownEditIds, - handleManageMarkdownEditId, - handleSaveComment, - actionsNavigation, - userCanCrud, - isLoadingIds, - handleManageQuote, - manualAlertsData, - getRuleDetailsHref, - loadingAlertData, - onRuleDetailsClick, - onShowAlertDetails, - renderInvestigateInTimelineActionComponent, - connectors, - handleOutlineComment, - caseServices, - ] - ); - - const bottomActions = userCanCrud - ? [ - { - username: ( - - ), - 'data-test-subj': 'add-comment', - timelineIcon: ( - - ), - className: 'isEdit', - children: MarkdownNewComment, - }, - ] - : []; - - const comments = [...userActions, ...bottomActions]; - - useEffect(() => { - if (draftComment?.commentId) { - setManageMarkdownEditIds((prevManageMarkdownEditIds) => { - if ( - ![NEW_ID].includes(draftComment?.commentId) && - !prevManageMarkdownEditIds.includes(draftComment?.commentId) - ) { - return [draftComment?.commentId]; - } - return prevManageMarkdownEditIds; - }); - - const ref = commentRefs?.current?.[draftComment.commentId]; - - if (isAddCommentRef(ref) && ref.editor?.textarea) { - ref.setComment(draftComment.comment); - if (hasIncomingLensState) { - openLensModal({ editorRef: ref.editor }); - } else { - clearDraftComment(); - } - } - } - }, [ - draftComment, - openLensModal, - commentRefs, - hasIncomingLensState, - clearDraftComment, - manageMarkdownEditIds, - ]); - - return ( - <> - - {(isLoadingUserActions || isLoadingIds.includes(NEW_ID)) && ( - - - - - - )} - - ); - } -); - -UserActionTree.displayName = 'UserActionTree'; diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.tsx deleted file mode 100644 index 692fbbb318b22..0000000000000 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.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 { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/eui'; -import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from 'react'; -import styled from 'styled-components'; - -import * as i18n from '../case_view/translations'; -import { Form, useForm, UseField } from '../../common/shared_imports'; -import { schema, Content } from './schema'; -import { MarkdownRenderer, MarkdownEditorForm } from '../markdown_editor'; - -export const ContentWrapper = styled.div` - padding: ${({ theme }) => `${theme.eui.euiSizeM} ${theme.eui.euiSizeL}`}; -`; - -interface UserActionMarkdownProps { - content: string; - id: string; - isEditable: boolean; - onChangeEditable: (id: string) => void; - onSaveContent: (content: string) => void; -} - -export interface UserActionMarkdownRefObject { - setComment: (newComment: string) => void; -} - -export const UserActionMarkdown = forwardRef( - ({ id, content, isEditable, onChangeEditable, onSaveContent }, ref) => { - const editorRef = useRef(); - const initialState = { content }; - const { form } = useForm({ - defaultValue: initialState, - options: { stripEmptyFields: false }, - schema, - }); - - const fieldName = 'content'; - const { setFieldValue, submit } = form; - - const handleCancelAction = useCallback(() => { - onChangeEditable(id); - }, [id, onChangeEditable]); - - const handleSaveAction = useCallback(async () => { - const { isValid, data } = await submit(); - - if (isValid && data.content !== content) { - onSaveContent(data.content); - } - onChangeEditable(id); - }, [content, id, onChangeEditable, onSaveContent, submit]); - - const setComment = useCallback( - (newComment) => { - setFieldValue(fieldName, newComment); - }, - [setFieldValue] - ); - - const EditorButtons = useMemo( - () => ( - - - - {i18n.CANCEL} - - - - - {i18n.SAVE} - - - - ), - [handleCancelAction, handleSaveAction] - ); - - useImperativeHandle(ref, () => ({ - setComment, - editor: editorRef.current, - })); - - return isEditable ? ( -
- - - ) : ( - - {content} - - ); - } -); diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_avatar.test.tsx b/x-pack/plugins/cases/public/components/user_actions/avatar.test.tsx similarity index 96% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_avatar.test.tsx rename to x-pack/plugins/cases/public/components/user_actions/avatar.test.tsx index 1df780db3bdaa..aeda9196cc58a 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_avatar.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/avatar.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; -import { UserActionAvatar } from './user_action_avatar'; +import { UserActionAvatar } from './avatar'; const props = { username: 'elastic', diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_avatar.tsx b/x-pack/plugins/cases/public/components/user_actions/avatar.tsx similarity index 74% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_avatar.tsx rename to x-pack/plugins/cases/public/components/user_actions/avatar.tsx index 80b048618bfc4..da43a122f3868 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_avatar.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/avatar.tsx @@ -6,19 +6,19 @@ */ import React, { memo } from 'react'; -import { EuiAvatar } from '@elastic/eui'; +import { EuiAvatar, EuiAvatarProps } from '@elastic/eui'; import * as i18n from './translations'; interface UserActionAvatarProps { username?: string | null; fullName?: string | null; + size?: EuiAvatarProps['size']; } -const UserActionAvatarComponent = ({ username, fullName }: UserActionAvatarProps) => { +const UserActionAvatarComponent = ({ username, fullName, size = 'm' }: UserActionAvatarProps) => { const avatarName = fullName && fullName.length > 0 ? fullName : username ?? i18n.UNKNOWN; - - return ; + return ; }; export const UserActionAvatar = memo(UserActionAvatarComponent); diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_username_with_avatar.test.tsx b/x-pack/plugins/cases/public/components/user_actions/avatar_username.test.tsx similarity index 69% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_username_with_avatar.test.tsx rename to x-pack/plugins/cases/public/components/user_actions/avatar_username.test.tsx index 3b6c956017120..776fa3325478a 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_username_with_avatar.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/avatar_username.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; -import { UserActionUsernameWithAvatar } from './user_action_username_with_avatar'; +import { UserActionUsernameWithAvatar } from './avatar_username'; const props = { username: 'elastic', @@ -25,19 +25,15 @@ describe('UserActionUsernameWithAvatar ', () => { expect( wrapper.find('[data-test-subj="user-action-username-with-avatar"]').first().exists() ).toBeTruthy(); - expect( - wrapper.find('[data-test-subj="user-action-username-avatar"]').first().exists() - ).toBeTruthy(); + expect(wrapper.find('[data-test-subj="user-action-avatar"]').first().exists()).toBeTruthy(); }); it('it shows the avatar', async () => { - expect(wrapper.find('[data-test-subj="user-action-username-avatar"]').first().text()).toBe('E'); + expect(wrapper.find('[data-test-subj="user-action-avatar"]').first().text()).toBe('E'); }); it('it shows the avatar without fullName', async () => { const newWrapper = mount(); - expect(newWrapper.find('[data-test-subj="user-action-username-avatar"]').first().text()).toBe( - 'e' - ); + expect(newWrapper.find('[data-test-subj="user-action-avatar"]').first().text()).toBe('e'); }); }); diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_username_with_avatar.tsx b/x-pack/plugins/cases/public/components/user_actions/avatar_username.tsx similarity index 71% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_username_with_avatar.tsx rename to x-pack/plugins/cases/public/components/user_actions/avatar_username.tsx index 685adc8724e87..581ebb7272d34 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_username_with_avatar.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/avatar_username.tsx @@ -6,11 +6,10 @@ */ import React, { memo } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiAvatar } from '@elastic/eui'; -import { isEmpty } from 'lodash/fp'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { UserActionUsername } from './user_action_username'; -import * as i18n from './translations'; +import { UserActionAvatar } from './avatar'; +import { UserActionUsername } from './username'; interface UserActionUsernameWithAvatarProps { username?: string | null; @@ -28,11 +27,7 @@ const UserActionUsernameWithAvatarComponent = ({ data-test-subj="user-action-username-with-avatar" > - + diff --git a/x-pack/plugins/cases/public/components/user_actions/builder.tsx b/x-pack/plugins/cases/public/components/user_actions/builder.tsx new file mode 100644 index 0000000000000..92f1dd6c123da --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/builder.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createCommentUserActionBuilder } from './comment/comment'; +import { createConnectorUserActionBuilder } from './connector'; +import { createDescriptionUserActionBuilder } from './description'; +import { createPushedUserActionBuilder } from './pushed'; +import { createStatusUserActionBuilder } from './status'; +import { createTagsUserActionBuilder } from './tags'; +import { createTitleUserActionBuilder } from './title'; +import { UserActionBuilderMap } from './types'; + +export const builderMap: UserActionBuilderMap = { + connector: createConnectorUserActionBuilder, + tags: createTagsUserActionBuilder, + title: createTitleUserActionBuilder, + status: createStatusUserActionBuilder, + pushed: createPushedUserActionBuilder, + comment: createCommentUserActionBuilder, + description: createDescriptionUserActionBuilder, +}; diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/actions.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/actions.tsx new file mode 100644 index 0000000000000..4dc189c14c74f --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/comment/actions.tsx @@ -0,0 +1,82 @@ +/* + * 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, { useContext } from 'react'; +import classNames from 'classnames'; +import { ThemeContext } from 'styled-components'; + +import { EuiToken } from '@elastic/eui'; +import { CommentResponseActionsType } from '../../../../common/api'; +import { UserActionBuilder, UserActionBuilderArgs } from '../types'; +import { UserActionTimestamp } from '../timestamp'; +import { SnakeToCamelCase } from '../../../../common/types'; +import { UserActionUsernameWithAvatar } from '../avatar_username'; +import { UserActionCopyLink } from '../copy_link'; +import { MarkdownRenderer } from '../../markdown_editor'; +import { ContentWrapper } from '../markdown_form'; +import { HostIsolationCommentEvent } from './host_isolation_event'; + +type BuilderArgs = Pick & { + comment: SnakeToCamelCase; +}; + +export const createActionAttachmentUserActionBuilder = ({ + userAction, + comment, + actionsNavigation, +}: BuilderArgs): ReturnType => ({ + build: () => { + return [ + { + username: ( + + ), + className: classNames('comment-action', { + 'empty-comment': comment.comment.trim().length === 0, + }), + event: ( + + ), + 'data-test-subj': 'endpoint-action', + timestamp: , + timelineIcon: , + actions: , + children: comment.comment.trim().length > 0 && ( + + {comment.comment} + + ), + }, + ]; + }, +}); + +const ActionIcon = React.memo<{ + actionType: string; +}>(({ actionType }) => { + const theme = useContext(ThemeContext); + return ( + + ); +}); + +ActionIcon.displayName = 'ActionIcon'; diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/alert.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/alert.tsx new file mode 100644 index 0000000000000..0341af259e402 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/comment/alert.tsx @@ -0,0 +1,106 @@ +/* + * 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 from 'react'; +import { get, isEmpty } from 'lodash'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils'; + +import { CommentType, CommentResponseAlertsType } from '../../../../common/api'; +import { UserActionBuilder, UserActionBuilderArgs } from '../types'; +import { UserActionTimestamp } from '../timestamp'; +import { SnakeToCamelCase } from '../../../../common/types'; +import { UserActionUsernameWithAvatar } from '../avatar_username'; +import { AlertCommentEvent } from './alert_event'; +import { UserActionCopyLink } from '../copy_link'; +import { UserActionShowAlert } from './show_alert'; + +type BuilderArgs = Pick< + UserActionBuilderArgs, + | 'userAction' + | 'alertData' + | 'getRuleDetailsHref' + | 'onRuleDetailsClick' + | 'loadingAlertData' + | 'onShowAlertDetails' +> & { comment: SnakeToCamelCase }; + +const getFirstItem = (items: string | string[]) => + Array.isArray(items) ? (items.length > 0 ? items[0] : '') : items; + +export const createAlertAttachmentUserActionBuilder = ({ + userAction, + comment, + alertData, + getRuleDetailsHref, + loadingAlertData, + onRuleDetailsClick, + onShowAlertDetails, +}: BuilderArgs): ReturnType => ({ + build: () => { + const alertId = getFirstItem(comment.alertId); + const alertIndex = getFirstItem(comment.index); + + if (isEmpty(alertId)) { + return []; + } + + const ruleId = + comment?.rule?.id ?? + alertData[alertId]?.signal?.rule?.id?.[0] ?? + get(alertData[alertId], ALERT_RULE_UUID)[0] ?? + null; + + const ruleName = + comment?.rule?.name ?? + alertData[alertId]?.signal?.rule?.name?.[0] ?? + get(alertData[alertId], ALERT_RULE_NAME)[0] ?? + null; + + return [ + { + username: ( + + ), + className: 'comment-alert', + type: 'update', + event: ( + + ), + 'data-test-subj': `user-action-alert-${userAction.type}-${userAction.action}-action-${userAction.actionId}`, + timestamp: , + timelineIcon: 'bell', + actions: ( + + + + + + + + + ), + }, + ]; + }, +}); diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_alert_comment_event.test.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/alert_event.test.tsx similarity index 62% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_alert_comment_event.test.tsx rename to x-pack/plugins/cases/public/components/user_actions/comment/alert_event.test.tsx index 858b54038286d..948a15eeba88e 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_alert_comment_event.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/alert_event.test.tsx @@ -8,14 +8,14 @@ import React from 'react'; import { mount } from 'enzyme'; -import { TestProviders } from '../../common/mock'; -import { useKibana } from '../../common/lib/kibana'; -import { AlertCommentEvent } from './user_action_alert_comment_event'; -import { CommentType } from '../../../common/api'; +import { TestProviders } from '../../../common/mock'; +import { useKibana } from '../../../common/lib/kibana'; +import { AlertCommentEvent } from './alert_event'; +import { CommentType } from '../../../../common/api'; const props = { alertId: 'alert-id-1', - getRuleDetailsHref: jest.fn().mockReturnValue('some-detection-rule-link'), + getRuleDetailsHref: jest.fn().mockReturnValue('https://example.com'), onRuleDetailsClick: jest.fn(), ruleId: 'rule-id-1', ruleName: 'Awesome rule', @@ -23,7 +23,7 @@ const props = { commentType: CommentType.alert, }; -jest.mock('../../common/lib/kibana'); +jest.mock('../../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; describe('UserActionAvatar ', () => { @@ -59,6 +59,33 @@ describe('UserActionAvatar ', () => { wrapper.find(`[data-test-subj="alert-rule-link-alert-id-1"]`).first().exists() ).toBeFalsy(); + expect(wrapper.text()).toBe('added an alert from Awesome rule'); + }); + + it('does NOT render the link when the href is invalid but it shows the rule name', async () => { + const wrapper = mount( + + + + ); + + expect( + wrapper.find(`[data-test-subj="alert-rule-link-alert-id-1"]`).first().exists() + ).toBeFalsy(); + + expect(wrapper.text()).toBe('added an alert from Awesome rule'); + }); + + it('show Unknown rule if the rule name is invalid', async () => { + const wrapper = mount( + + + + ); + + expect( + wrapper.find(`[data-test-subj="alert-rule-link-alert-id-1"]`).first().exists() + ).toBeTruthy(); expect(wrapper.text()).toBe('added an alert from Unknown rule'); }); diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_alert_comment_event.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/alert_event.tsx similarity index 54% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_alert_comment_event.tsx rename to x-pack/plugins/cases/public/components/user_actions/comment/alert_event.tsx index 4236691a16bb2..b4b4b3b75fe7e 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_alert_comment_event.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/alert_event.tsx @@ -7,17 +7,17 @@ import React, { memo, useCallback } from 'react'; import { isEmpty } from 'lodash'; -import { EuiText, EuiLoadingSpinner } from '@elastic/eui'; +import { EuiLoadingSpinner } from '@elastic/eui'; -import * as i18n from './translations'; -import { CommentType } from '../../../common/api'; -import { LinkAnchor } from '../links'; -import { RuleDetailsNavigation } from './helpers'; +import { CommentType } from '../../../../common/api'; +import * as i18n from '../translations'; +import { LinkAnchor } from '../../links'; +import { RuleDetailsNavigation } from '../types'; interface Props { alertId: string; commentType: CommentType; - getRuleDetailsHref: RuleDetailsNavigation['href']; + getRuleDetailsHref?: RuleDetailsNavigation['href']; onRuleDetailsClick?: RuleDetailsNavigation['onClick']; ruleId?: string | null; ruleName?: string | null; @@ -42,38 +42,24 @@ const AlertCommentEventComponent: React.FC = ({ }, [ruleId, onRuleDetailsClick] ); - const detectionsRuleDetailsHref = getRuleDetailsHref(ruleId); + const detectionsRuleDetailsHref = getRuleDetailsHref?.(ruleId); + const finalRuleName = ruleName ?? i18n.UNKNOWN_RULE; - return commentType !== CommentType.generatedAlert ? ( + return ( <> {`${i18n.ALERT_COMMENT_LABEL_TITLE} `} {loadingAlertData && } - {!loadingAlertData && !isEmpty(ruleId) && ( + {!loadingAlertData && !isEmpty(ruleId) && detectionsRuleDetailsHref != null && ( - {ruleName ?? i18n.UNKNOWN_RULE} + {finalRuleName} )} - {!loadingAlertData && isEmpty(ruleId) && i18n.UNKNOWN_RULE} - - ) : ( - <> - {i18n.GENERATED_ALERT_COUNT_COMMENT_LABEL_TITLE(alertsCount ?? 0)}{' '} - {i18n.GENERATED_ALERT_COMMENT_LABEL_TITLE}{' '} - {loadingAlertData && } - {!loadingAlertData && ruleId !== '' && ( - - {ruleName} - - )} - {!loadingAlertData && ruleId === '' && {ruleName}} + {!loadingAlertData && !isEmpty(ruleId) && detectionsRuleDetailsHref == null && finalRuleName} + {!loadingAlertData && isEmpty(ruleId) && finalRuleName} ); }; diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx new file mode 100644 index 0000000000000..ca45191dd4cb1 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/comment/comment.test.tsx @@ -0,0 +1,118 @@ +/* + * 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 from 'react'; +import { EuiCommentList } from '@elastic/eui'; +import { render, screen } from '@testing-library/react'; + +import { Actions } from '../../../../common/api'; +import { + alertComment, + basicCase, + getAlertUserAction, + getHostIsolationUserAction, + getUserAction, + hostIsolationComment, +} from '../../../containers/mock'; +import { TestProviders } from '../../../common/mock'; +import { createCommentUserActionBuilder } from './comment'; +import { getMockBuilderArgs } from '../mock'; + +jest.mock('../../../common/lib/kibana'); +jest.mock('../../../common/navigation/hooks'); + +describe('createCommentUserActionBuilder', () => { + const builderArgs = getMockBuilderArgs(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly when editing a comment', async () => { + const userAction = getUserAction('comment', Actions.update); + const builder = createCommentUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + expect(screen.getByText('edited comment')).toBeInTheDocument(); + }); + + it('renders correctly a user comment', async () => { + const userAction = getUserAction('comment', Actions.create, { + commentId: basicCase.comments[0].id, + }); + + const builder = createCommentUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + expect(screen.getByText('Solve this fast!')).toBeInTheDocument(); + }); + + it('renders correctly an alert', async () => { + const userAction = getAlertUserAction(); + + const builder = createCommentUserActionBuilder({ + ...builderArgs, + caseData: { + ...builderArgs.caseData, + comments: [alertComment], + }, + userAction, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + expect(screen.getByText('added an alert from')).toBeInTheDocument(); + expect(screen.getByText('Awesome rule')).toBeInTheDocument(); + }); + + it('renders correctly an action', async () => { + const userAction = getHostIsolationUserAction(); + + const builder = createCommentUserActionBuilder({ + ...builderArgs, + caseData: { + ...builderArgs.caseData, + comments: [hostIsolationComment()], + }, + userAction, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + expect(screen.getByText('submitted isolate request on host')).toBeInTheDocument(); + expect(screen.getByText('host1')).toBeInTheDocument(); + expect(screen.getByText('I just isolated the host!')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx new file mode 100644 index 0000000000000..79df2aaca9978 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx @@ -0,0 +1,142 @@ +/* + * 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 { EuiCommentProps } from '@elastic/eui'; + +import { CommentUserAction, Actions, CommentType } from '../../../../common/api'; +import { UserActionBuilder, UserActionBuilderArgs, UserActionResponse } from '../types'; +import { createCommonUpdateUserActionBuilder } from '../common'; +import { Comment } from '../../../containers/types'; +import * as i18n from '../translations'; +import { createUserAttachmentUserActionBuilder } from './user'; +import { createAlertAttachmentUserActionBuilder } from './alert'; +import { createActionAttachmentUserActionBuilder } from './actions'; + +const getUpdateLabelTitle = () => `${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`; + +const getCreateCommentUserAction = ({ + userAction, + comment, + userCanCrud, + commentRefs, + manageMarkdownEditIds, + selectedOutlineCommentId, + loadingCommentIds, + handleManageMarkdownEditId, + handleSaveComment, + handleManageQuote, + getRuleDetailsHref, + loadingAlertData, + onRuleDetailsClick, + alertData, + onShowAlertDetails, + actionsNavigation, +}: { + userAction: UserActionResponse; + comment: Comment; +} & Omit< + UserActionBuilderArgs, + 'caseData' | 'caseServices' | 'comments' | 'index' | 'handleOutlineComment' +>): EuiCommentProps[] => { + switch (comment.type) { + case CommentType.user: + const userBuilder = createUserAttachmentUserActionBuilder({ + comment, + userCanCrud, + outlined: comment.id === selectedOutlineCommentId, + isEdit: manageMarkdownEditIds.includes(comment.id), + commentRefs, + isLoading: loadingCommentIds.includes(comment.id), + handleManageMarkdownEditId, + handleSaveComment, + handleManageQuote, + }); + + return userBuilder.build(); + + case CommentType.alert: + const alertBuilder = createAlertAttachmentUserActionBuilder({ + alertData, + comment, + userAction, + getRuleDetailsHref, + loadingAlertData, + onRuleDetailsClick, + onShowAlertDetails, + }); + return alertBuilder.build(); + case CommentType.actions: + const actionBuilder = createActionAttachmentUserActionBuilder({ + userAction, + comment, + actionsNavigation, + }); + return actionBuilder.build(); + default: + return []; + } +}; + +export const createCommentUserActionBuilder: UserActionBuilder = ({ + caseData, + userAction, + userCanCrud, + commentRefs, + manageMarkdownEditIds, + selectedOutlineCommentId, + loadingCommentIds, + loadingAlertData, + alertData, + getRuleDetailsHref, + onRuleDetailsClick, + onShowAlertDetails, + handleManageMarkdownEditId, + handleSaveComment, + handleManageQuote, + handleOutlineComment, +}) => ({ + build: () => { + const commentUserAction = userAction as UserActionResponse; + const comment = caseData.comments.find((c) => c.id === commentUserAction.commentId); + + if (comment == null) { + return []; + } + + if (commentUserAction.action === Actions.create) { + const commentAction = getCreateCommentUserAction({ + userAction: commentUserAction, + comment, + userCanCrud, + commentRefs, + manageMarkdownEditIds, + selectedOutlineCommentId, + loadingCommentIds, + loadingAlertData, + alertData, + getRuleDetailsHref, + onRuleDetailsClick, + onShowAlertDetails, + handleManageMarkdownEditId, + handleSaveComment, + handleManageQuote, + }); + + return commentAction; + } + + const label = getUpdateLabelTitle(); + const commonBuilder = createCommonUpdateUserActionBuilder({ + userAction, + handleOutlineComment, + label, + icon: 'dot', + }); + + return commonBuilder.build(); + }, +}); diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_host_isolation_comment_event.test.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/host_isolation_event.test.tsx similarity index 93% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_host_isolation_comment_event.test.tsx rename to x-pack/plugins/cases/public/components/user_actions/comment/host_isolation_event.test.tsx index 80f9985ef15c1..64619ad137950 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_host_isolation_comment_event.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/host_isolation_event.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { mount } from 'enzyme'; -import { HostIsolationCommentEvent } from './user_action_host_isolation_comment_event'; +import { HostIsolationCommentEvent } from './host_isolation_event'; const defaultProps = () => { return { diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_host_isolation_comment_event.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/host_isolation_event.tsx similarity index 91% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_host_isolation_comment_event.tsx rename to x-pack/plugins/cases/public/components/user_actions/comment/host_isolation_event.tsx index 2381d31b3ada8..531323e548dd1 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_host_isolation_comment_event.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/host_isolation_event.tsx @@ -6,9 +6,9 @@ */ import React, { memo, useCallback } from 'react'; -import * as i18n from './translations'; -import { LinkAnchor } from '../links'; -import { ActionsNavigation } from './helpers'; +import * as i18n from '../translations'; +import { LinkAnchor } from '../../links'; +import { ActionsNavigation } from '../types'; interface EndpointInfo { endpointId: string; diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_show_alert.test.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/show_alert.test.tsx similarity index 94% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_show_alert.test.tsx rename to x-pack/plugins/cases/public/components/user_actions/comment/show_alert.test.tsx index d6005a8bd521e..cc570b245ec90 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_show_alert.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/show_alert.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; -import { UserActionShowAlert } from './user_action_show_alert'; +import { UserActionShowAlert } from './show_alert'; const props = { id: 'action-id', diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_show_alert.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/show_alert.tsx similarity index 96% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_show_alert.tsx rename to x-pack/plugins/cases/public/components/user_actions/comment/show_alert.tsx index c16382a96bb98..dd874b029dc9c 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_show_alert.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/show_alert.tsx @@ -7,7 +7,7 @@ import React, { memo, useCallback } from 'react'; import { EuiToolTip, EuiButtonIcon } from '@elastic/eui'; -import * as i18n from './translations'; +import * as i18n from '../translations'; interface UserActionShowAlertProps { id: string; diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/user.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/user.tsx new file mode 100644 index 0000000000000..e48246a375467 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/comment/user.tsx @@ -0,0 +1,95 @@ +/* + * 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 from 'react'; +import classNames from 'classnames'; + +import { CommentResponseUserType } from '../../../../common/api'; +import { UserActionTimestamp } from '../timestamp'; +import { SnakeToCamelCase } from '../../../../common/types'; +import { UserActionMarkdown } from '../markdown_form'; +import { UserActionAvatar } from '../avatar'; +import { UserActionContentToolbar } from '../content_toolbar'; +import { UserActionUsername } from '../username'; +import * as i18n from '../translations'; +import { UserActionBuilderArgs, UserActionBuilder } from '../types'; + +type BuilderArgs = Pick< + UserActionBuilderArgs, + | 'userCanCrud' + | 'handleManageMarkdownEditId' + | 'handleSaveComment' + | 'handleManageQuote' + | 'commentRefs' +> & { + comment: SnakeToCamelCase; + outlined: boolean; + isEdit: boolean; + isLoading: boolean; +}; + +export const createUserAttachmentUserActionBuilder = ({ + comment, + userCanCrud, + outlined, + isEdit, + isLoading, + commentRefs, + handleManageMarkdownEditId, + handleSaveComment, + handleManageQuote, +}: BuilderArgs): ReturnType => ({ + build: () => [ + { + username: ( + + ), + 'data-test-subj': `comment-create-action-${comment.id}`, + timestamp: ( + + ), + className: classNames('userAction__comment', { + outlined, + isEdit, + }), + children: ( + (commentRefs.current[comment.id] = element)} + id={comment.id} + content={comment.comment} + isEditable={isEdit} + onChangeEditable={handleManageMarkdownEditId} + onSaveContent={handleSaveComment.bind(null, { + id: comment.id, + version: comment.version, + })} + /> + ), + timelineIcon: ( + + ), + actions: ( + + ), + }, + ], +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/common.test.tsx b/x-pack/plugins/cases/public/components/user_actions/common.test.tsx new file mode 100644 index 0000000000000..ffb99757b45ac --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/common.test.tsx @@ -0,0 +1,115 @@ +/* + * 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 from 'react'; +import { EuiCommentList } from '@elastic/eui'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import copy from 'copy-to-clipboard'; + +import { Actions } from '../../../common/api'; +import { createCommonUpdateUserActionBuilder } from './common'; +import { getUserAction } from '../../containers/mock'; +import { TestProviders } from '../../common/mock'; + +jest.mock('../../common/lib/kibana'); +jest.mock('../../common/navigation/hooks'); +jest.mock('copy-to-clipboard', () => jest.fn()); + +describe('createCommonUpdateUserActionBuilder ', () => { + const label = <>{'A label'}; + const handleOutlineComment = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + const userAction = getUserAction('title', Actions.update); + const builder = createCommonUpdateUserActionBuilder({ + userAction, + label, + icon: 'dot', + handleOutlineComment, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + // The avatar + expect(screen.getByText('LK')).toBeInTheDocument(); + // The username + expect(screen.getByText(userAction.createdBy.username!)).toBeInTheDocument(); + // The label of the event + expect(screen.getByText('A label')).toBeInTheDocument(); + // The copy link button + expect(screen.getByLabelText('Copy reference link')).toBeInTheDocument(); + }); + + it('renders shows the move to comment button if the user action is an edit comment', async () => { + const userAction = getUserAction('comment', Actions.update); + const builder = createCommonUpdateUserActionBuilder({ + userAction, + label, + icon: 'dot', + handleOutlineComment, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + expect(screen.getByLabelText('Highlight the referenced comment')).toBeInTheDocument(); + }); + + it('it copies the reference link when clicking the reference button', async () => { + const userAction = getUserAction('comment', Actions.update); + const builder = createCommonUpdateUserActionBuilder({ + userAction, + label, + icon: 'dot', + handleOutlineComment, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + userEvent.click(screen.getByLabelText('Copy reference link')); + expect(copy).toHaveBeenCalled(); + }); + + it('calls the handleOutlineComment when clicking the reference button', async () => { + const userAction = getUserAction('comment', Actions.update); + const builder = createCommonUpdateUserActionBuilder({ + userAction, + label, + icon: 'dot', + handleOutlineComment, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + userEvent.click(screen.getByLabelText('Highlight the referenced comment')); + expect(handleOutlineComment).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/common.tsx b/x-pack/plugins/cases/public/components/user_actions/common.tsx new file mode 100644 index 0000000000000..407fface85ceb --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/common.tsx @@ -0,0 +1,85 @@ +/* + * 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 from 'react'; +import { EuiCommentProps, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { Actions, ConnectorUserAction, UserAction } from '../../../common/api'; +import { UserActionTimestamp } from './timestamp'; +import { UserActionBuilder, UserActionBuilderArgs, UserActionResponse } from './types'; +import { UserActionUsernameWithAvatar } from './avatar_username'; +import { UserActionCopyLink } from './copy_link'; +import { UserActionMoveToReference } from './move_to_reference'; + +interface Props { + userAction: UserActionResponse; + handleOutlineComment: (id: string) => void; +} + +const showMoveToReference = (action: UserAction, commentId: string | null): commentId is string => + action === Actions.update && commentId != null; + +const CommentListActions: React.FC = React.memo(({ userAction, handleOutlineComment }) => ( + + + + + {showMoveToReference(userAction.action, userAction.commentId) && ( + + + + )} + +)); + +CommentListActions.displayName = 'CommentListActions'; + +type BuilderArgs = Pick & { + label: EuiCommentProps['event']; + icon: EuiCommentProps['timelineIcon']; +}; + +export const createCommonUpdateUserActionBuilder = ({ + userAction, + label, + icon, + handleOutlineComment, +}: BuilderArgs): ReturnType => ({ + build: () => [ + { + username: ( + + ), + type: 'update' as const, + event: label, + 'data-test-subj': `${userAction.type}-${userAction.action}-action-${userAction.actionId}`, + timestamp: , + timelineIcon: icon, + actions: ( + + + + + {showMoveToReference(userAction.action, userAction.commentId) && ( + + + + )} + + ), + }, + ], +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/connector.test.tsx b/x-pack/plugins/cases/public/components/user_actions/connector.test.tsx new file mode 100644 index 0000000000000..51abe66a27fda --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/connector.test.tsx @@ -0,0 +1,67 @@ +/* + * 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 from 'react'; +import { EuiCommentList } from '@elastic/eui'; +import { render, screen } from '@testing-library/react'; + +import { Actions, NONE_CONNECTOR_ID } from '../../../common/api'; +import { getUserAction, getJiraConnector } from '../../containers/mock'; +import { TestProviders } from '../../common/mock'; +import { createConnectorUserActionBuilder } from './connector'; +import { getMockBuilderArgs } from './mock'; + +jest.mock('../../common/lib/kibana'); +jest.mock('../../common/navigation/hooks'); + +describe('createConnectorUserActionBuilder ', () => { + const builderArgs = getMockBuilderArgs(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + const userAction = getUserAction('connector', Actions.update, { + payload: { connector: getJiraConnector() }, + }); + + const builder = createConnectorUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + expect(screen.getByText('selected jira1 as incident management system')).toBeInTheDocument(); + }); + + it('renders the removed connector label if the connector is none', async () => { + const userAction = getUserAction('connector', Actions.update, { + payload: { connector: { ...getJiraConnector(), id: NONE_CONNECTOR_ID } }, + }); + + const builder = createConnectorUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + expect(screen.getByText('removed external incident management system')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/connector.tsx b/x-pack/plugins/cases/public/components/user_actions/connector.tsx new file mode 100644 index 0000000000000..70c1ecf274736 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/connector.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConnectorUserAction, NONE_CONNECTOR_ID } from '../../../common/api'; +import { UserActionBuilder, UserActionResponse } from './types'; +import { createCommonUpdateUserActionBuilder } from './common'; +import * as i18n from './translations'; + +const getLabelTitle = (userAction: UserActionResponse) => { + const connector = userAction.payload.connector; + + if (connector == null) { + return ''; + } + + if (connector.id === NONE_CONNECTOR_ID) { + return i18n.REMOVED_THIRD_PARTY; + } + + return i18n.SELECTED_THIRD_PARTY(connector.name); +}; + +export const createConnectorUserActionBuilder: UserActionBuilder = ({ + userAction, + handleOutlineComment, +}) => ({ + build: () => { + const connectorUserAction = userAction as UserActionResponse; + const label = getLabelTitle(connectorUserAction); + const commonBuilder = createCommonUpdateUserActionBuilder({ + userAction, + handleOutlineComment, + label, + icon: 'dot', + }); + + return commonBuilder.build(); + }, +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/constants.ts b/x-pack/plugins/cases/public/components/user_actions/constants.ts new file mode 100644 index 0000000000000..4cdc0f4fb5edb --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/constants.ts @@ -0,0 +1,19 @@ +/* + * 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 { omit } from 'lodash'; +import { ActionTypes } from '../../../common/api'; +import { SupportedUserActionTypes } from './types'; + +export const DRAFT_COMMENT_STORAGE_ID = 'xpack.cases.commentDraft'; + +export const UNSUPPORTED_ACTION_TYPES = ['create_case', 'delete_case', 'settings'] as const; +export const SUPPORTED_ACTION_TYPES: SupportedUserActionTypes[] = Object.keys( + omit(ActionTypes, UNSUPPORTED_ACTION_TYPES) +) as SupportedUserActionTypes[]; + +export const NEW_COMMENT_ID = 'newComment'; diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.test.tsx b/x-pack/plugins/cases/public/components/user_actions/content_toolbar.test.tsx similarity index 90% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.test.tsx rename to x-pack/plugins/cases/public/components/user_actions/content_toolbar.test.tsx index c2edfe2739715..74f5205578a1d 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/content_toolbar.test.tsx @@ -7,10 +7,7 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; -import { - UserActionContentToolbar, - UserActionContentToolbarProps, -} from './user_action_content_toolbar'; +import { UserActionContentToolbar, UserActionContentToolbarProps } from './content_toolbar'; jest.mock('../../common/navigation/hooks'); jest.mock('../../common/lib/kibana'); diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx b/x-pack/plugins/cases/public/components/user_actions/content_toolbar.tsx similarity index 90% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx rename to x-pack/plugins/cases/public/components/user_actions/content_toolbar.tsx index ab030348595d1..dee1a25c3b79c 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/content_toolbar.tsx @@ -8,8 +8,8 @@ import React, { memo } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { UserActionCopyLink } from './user_action_copy_link'; -import { UserActionPropertyActions } from './user_action_property_actions'; +import { UserActionCopyLink } from './copy_link'; +import { UserActionPropertyActions } from './property_actions'; export interface UserActionContentToolbarProps { commentMarkdown: string; diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_copy_link.test.tsx b/x-pack/plugins/cases/public/components/user_actions/copy_link.test.tsx similarity index 96% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_copy_link.test.tsx rename to x-pack/plugins/cases/public/components/user_actions/copy_link.test.tsx index 4e3496a06bb72..d4b093eed12f7 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_copy_link.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/copy_link.test.tsx @@ -11,7 +11,7 @@ import copy from 'copy-to-clipboard'; import { useKibana } from '../../common/lib/kibana'; import { TestProviders } from '../../common/mock'; -import { UserActionCopyLink } from './user_action_copy_link'; +import { UserActionCopyLink } from './copy_link'; const useKibanaMock = useKibana as jest.Mocked; diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_copy_link.tsx b/x-pack/plugins/cases/public/components/user_actions/copy_link.tsx similarity index 100% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_copy_link.tsx rename to x-pack/plugins/cases/public/components/user_actions/copy_link.tsx diff --git a/x-pack/plugins/cases/public/components/user_actions/description.test.tsx b/x-pack/plugins/cases/public/components/user_actions/description.test.tsx new file mode 100644 index 0000000000000..d76829add5a63 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/description.test.tsx @@ -0,0 +1,44 @@ +/* + * 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 from 'react'; +import { EuiCommentList } from '@elastic/eui'; +import { render, screen } from '@testing-library/react'; + +import { Actions } from '../../../common/api'; +import { getUserAction } from '../../containers/mock'; +import { TestProviders } from '../../common/mock'; +import { createDescriptionUserActionBuilder } from './description'; +import { getMockBuilderArgs } from './mock'; + +jest.mock('../../common/lib/kibana'); +jest.mock('../../common/navigation/hooks'); + +describe('createDescriptionUserActionBuilder ', () => { + const builderArgs = getMockBuilderArgs(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly when editing a description', async () => { + const userAction = getUserAction('description', Actions.update); + const builder = createDescriptionUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + expect(screen.getByText('edited description')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/description.tsx b/x-pack/plugins/cases/public/components/user_actions/description.tsx new file mode 100644 index 0000000000000..01b0e105ecd96 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/description.tsx @@ -0,0 +1,107 @@ +/* + * 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 from 'react'; +import classNames from 'classnames'; +import { EuiCommentProps } from '@elastic/eui'; + +import type { UserActionBuilder, UserActionBuilderArgs, UserActionTreeProps } from './types'; +import { createCommonUpdateUserActionBuilder } from './common'; +import { UserActionUsername } from './username'; +import { UserActionAvatar } from './avatar'; +import { UserActionContentToolbar } from './content_toolbar'; +import { UserActionTimestamp } from './timestamp'; +import { UserActionMarkdown } from './markdown_form'; +import * as i18n from './translations'; + +const DESCRIPTION_ID = 'description'; + +const getLabelTitle = () => `${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`; + +type GetDescriptionUserActionArgs = Pick< + UserActionBuilderArgs, + | 'caseData' + | 'commentRefs' + | 'manageMarkdownEditIds' + | 'userCanCrud' + | 'handleManageMarkdownEditId' + | 'handleManageQuote' +> & + Pick; + +export const getDescriptionUserAction = ({ + caseData, + commentRefs, + manageMarkdownEditIds, + isLoadingDescription, + userCanCrud, + onUpdateField, + handleManageMarkdownEditId, + handleManageQuote, +}: GetDescriptionUserActionArgs): EuiCommentProps => { + return { + username: ( + + ), + event: i18n.ADDED_DESCRIPTION, + 'data-test-subj': 'description-action', + timestamp: , + children: ( + (commentRefs.current[DESCRIPTION_ID] = element)} + id={DESCRIPTION_ID} + content={caseData.description} + isEditable={manageMarkdownEditIds.includes(DESCRIPTION_ID)} + onSaveContent={(content: string) => { + onUpdateField({ key: DESCRIPTION_ID, value: content }); + }} + onChangeEditable={handleManageMarkdownEditId} + /> + ), + timelineIcon: ( + + ), + className: classNames({ + isEdit: manageMarkdownEditIds.includes(DESCRIPTION_ID), + }), + actions: ( + + ), + }; +}; + +export const createDescriptionUserActionBuilder: UserActionBuilder = ({ + userAction, + handleOutlineComment, +}) => ({ + build: () => { + const label = getLabelTitle(); + const commonBuilder = createCommonUpdateUserActionBuilder({ + userAction, + handleOutlineComment, + label, + icon: 'dot', + }); + + return commonBuilder.build(); + }, +}); diff --git a/x-pack/plugins/cases/public/components/case_view/helpers.test.tsx b/x-pack/plugins/cases/public/components/user_actions/helpers.test.ts similarity index 63% rename from x-pack/plugins/cases/public/components/case_view/helpers.test.tsx rename to x-pack/plugins/cases/public/components/user_actions/helpers.test.ts index e398c5edad145..dd75ed8c6fcd8 100644 --- a/x-pack/plugins/cases/public/components/case_view/helpers.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/helpers.test.ts @@ -8,8 +8,7 @@ import { AssociationType, CommentType } from '../../../common/api'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { Comment } from '../../containers/types'; - -import { getManualAlertIdsWithNoRuleId } from './helpers'; +import { isUserActionTypeSupported, getManualAlertIdsWithNoRuleId } from './helpers'; const comments: Comment[] = [ { @@ -19,7 +18,7 @@ const comments: Comment[] = [ index: 'alert-index-1', id: 'comment-id', createdAt: '2020-02-19T23:06:33.798Z', - createdBy: { username: 'elastic' }, + createdBy: { username: 'elastic', email: 'elastic@elastic.co', fullName: 'Elastic' }, rule: { id: null, name: null, @@ -38,7 +37,7 @@ const comments: Comment[] = [ index: 'alert-index-2', id: 'comment-id', createdAt: '2020-02-19T23:06:33.798Z', - createdBy: { username: 'elastic' }, + createdBy: { username: 'elastic', email: 'elastic@elastic.co', fullName: 'Elastic' }, pushedAt: null, pushedBy: null, rule: { @@ -52,7 +51,28 @@ const comments: Comment[] = [ }, ]; -describe('Case view helpers', () => { +describe('Case view helpers', () => {}); + +describe('helpers', () => { + describe('isUserActionTypeSupported', () => { + const types: Array<[string, boolean]> = [ + ['comment', true], + ['connector', true], + ['description', true], + ['pushed', true], + ['tags', true], + ['title', true], + ['status', true], + ['settings', false], + ['create_case', false], + ['delete_case', false], + ]; + + it.each(types)('determines if the type is support %s', (type, supported) => { + expect(isUserActionTypeSupported(type)).toBe(supported); + }); + }); + describe('getAlertIdsFromComments', () => { it('it returns the alert id from the comments where rule is not defined', () => { expect(getManualAlertIdsWithNoRuleId(comments)).toEqual(['alert-id-1']); diff --git a/x-pack/plugins/cases/public/components/case_view/helpers.ts b/x-pack/plugins/cases/public/components/user_actions/helpers.ts similarity index 76% rename from x-pack/plugins/cases/public/components/case_view/helpers.ts rename to x-pack/plugins/cases/public/components/user_actions/helpers.ts index 04052d1eedea5..673af99ed7772 100644 --- a/x-pack/plugins/cases/public/components/case_view/helpers.ts +++ b/x-pack/plugins/cases/public/components/user_actions/helpers.ts @@ -8,6 +8,11 @@ import { isEmpty } from 'lodash'; import { CommentType } from '../../../common/api'; import type { Comment } from '../../containers/types'; +import { SUPPORTED_ACTION_TYPES } from './constants'; +import { SupportedUserActionTypes } from './types'; + +export const isUserActionTypeSupported = (type: string): type is SupportedUserActionTypes => + SUPPORTED_ACTION_TYPES.includes(type as SupportedUserActionTypes); export const getManualAlertIdsWithNoRuleId = (comments: Comment[]): string[] => { const dedupeAlerts = comments.reduce((alertIds, comment: Comment) => { diff --git a/x-pack/plugins/cases/public/components/user_action_tree/index.test.tsx b/x-pack/plugins/cases/public/components/user_actions/index.test.tsx similarity index 94% rename from x-pack/plugins/cases/public/components/user_action_tree/index.test.tsx rename to x-pack/plugins/cases/public/components/user_actions/index.test.tsx index 1e7b0dca172ca..67e9b4505ae62 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/index.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/index.test.tsx @@ -20,7 +20,7 @@ import { hostIsolationComment, hostReleaseComment, } from '../../containers/mock'; -import { UserActionTree } from '.'; +import { UserActions } from '.'; import { TestProviders } from '../../common/mock'; import { Ecs } from '../../../common/ui/types'; import { Actions } from '../../../common/api'; @@ -53,23 +53,25 @@ const defaultProps = { alerts: {}, onShowAlertDetails, }; -const useUpdateCommentMock = useUpdateComment as jest.Mock; + jest.mock('../../containers/use_update_comment'); -jest.mock('./user_action_timestamp'); +jest.mock('./timestamp'); jest.mock('../../common/lib/kibana'); +const useUpdateCommentMock = useUpdateComment as jest.Mock; const patchComment = jest.fn(); -describe(`UserActionTree`, () => { +describe(`UserActions`, () => { const sampleData = { content: 'what a great comment update', }; + beforeEach(() => { jest.clearAllMocks(); - useUpdateCommentMock.mockImplementation(() => ({ + useUpdateCommentMock.mockReturnValue({ isLoadingIds: [], patchComment, - })); + }); jest .spyOn(routeData, 'useParams') @@ -79,15 +81,14 @@ describe(`UserActionTree`, () => { it('Loading spinner when user actions loading and displays fullName/username', () => { const wrapper = mount( - + ); - expect(wrapper.find(`[data-test-subj="user-actions-loading"]`).exists()).toEqual(true); + expect(wrapper.find(`[data-test-subj="user-actions-loading"]`).exists()).toEqual(true); expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().prop('name')).toEqual( defaultProps.data.createdBy.fullName ); - expect( wrapper.find(`[data-test-subj="description-action"] figcaption strong`).first().text() ).toEqual(defaultProps.data.createdBy.username); @@ -114,7 +115,7 @@ describe(`UserActionTree`, () => { }; const wrapper = mount( - + ); await waitFor(() => { @@ -141,7 +142,7 @@ describe(`UserActionTree`, () => { const wrapper = mount( - + ); await waitFor(() => { @@ -161,7 +162,7 @@ describe(`UserActionTree`, () => { const wrapper = mount( - + ); expect( @@ -196,7 +197,7 @@ describe(`UserActionTree`, () => { const wrapper = mount( - + ); @@ -240,7 +241,7 @@ describe(`UserActionTree`, () => { const wrapper = mount( - + ); @@ -296,7 +297,7 @@ describe(`UserActionTree`, () => { it('calls update description when description markdown is saved', async () => { const wrapper = mount( - + ); @@ -340,7 +341,7 @@ describe(`UserActionTree`, () => { const wrapper = mount( - + ); @@ -373,7 +374,7 @@ describe(`UserActionTree`, () => { const wrapper = mount( - + ); await waitFor(() => { @@ -397,7 +398,7 @@ describe(`UserActionTree`, () => { const wrapper = mount( - + ); await waitFor(() => { @@ -415,7 +416,7 @@ describe(`UserActionTree`, () => { const wrapper = mount( - + ); await waitFor(() => { @@ -435,7 +436,7 @@ describe(`UserActionTree`, () => { const wrapper = mount( - + ); await waitFor(() => { @@ -454,7 +455,7 @@ describe(`UserActionTree`, () => { const wrapper = mount( - + ); await waitFor(() => { diff --git a/x-pack/plugins/cases/public/components/user_actions/index.tsx b/x-pack/plugins/cases/public/components/user_actions/index.tsx new file mode 100644 index 0000000000000..084d1c548f903 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/index.tsx @@ -0,0 +1,275 @@ +/* + * 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 { + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiCommentList, + EuiCommentProps, +} from '@elastic/eui'; + +import React, { useMemo, useState, useEffect } from 'react'; +import styled from 'styled-components'; + +import { useCurrentUser } from '../../common/lib/kibana'; +import { AddComment } from '../add_comment'; +import { UserActionAvatar } from './avatar'; +import { UserActionUsername } from './username'; +import { useCaseViewParams } from '../../common/navigation'; +import { builderMap } from './builder'; +import { isUserActionTypeSupported, getManualAlertIdsWithNoRuleId } from './helpers'; +import type { UserActionTreeProps } from './types'; +import { getDescriptionUserAction } from './description'; +import { useUserActionsHandler } from './use_user_actions_handler'; +import { NEW_COMMENT_ID } from './constants'; + +const MyEuiFlexGroup = styled(EuiFlexGroup)` + margin-bottom: 8px; +`; + +const MyEuiCommentList = styled(EuiCommentList)` + ${({ theme }) => ` + & .userAction__comment.outlined .euiCommentEvent { + outline: solid 5px ${theme.eui.euiColorVis1_behindText}; + margin: 0.5em; + transition: 0.8s; + } + + & .euiComment.isEdit { + & .euiCommentEvent { + border: none; + box-shadow: none; + } + + & .euiCommentEvent__body { + padding: 0; + } + + & .euiCommentEvent__header { + display: none; + } + } + + & .comment-alert .euiCommentEvent { + background-color: ${theme.eui.euiColorLightestShade}; + border: ${theme.eui.euiFlyoutBorder}; + padding: ${theme.eui.paddingSizes.s}; + border-radius: ${theme.eui.paddingSizes.xs}; + } + + & .comment-alert .euiCommentEvent__headerData { + flex-grow: 1; + } + + & .comment-action.empty-comment .euiCommentEvent--regular { + box-shadow: none; + .euiCommentEvent__header { + padding: ${theme.eui.euiSizeM} ${theme.eui.paddingSizes.s}; + border-bottom: 0; + } + } + `} +`; + +export const UserActions = React.memo( + ({ + caseServices, + caseUserActions, + data: caseData, + fetchUserActions, + getRuleDetailsHref, + actionsNavigation, + isLoadingDescription, + isLoadingUserActions, + onRuleDetailsClick, + onShowAlertDetails, + onUpdateField, + renderInvestigateInTimelineActionComponent, + statusActionButton, + updateCase, + useFetchAlertData, + userCanCrud, + }: UserActionTreeProps) => { + const { detailName: caseId, subCaseId, commentId } = useCaseViewParams(); + const [initLoading, setInitLoading] = useState(true); + const currentUser = useCurrentUser(); + + const [loadingAlertData, manualAlertsData] = useFetchAlertData( + getManualAlertIdsWithNoRuleId(caseData.comments) + ); + + const { + loadingCommentIds, + commentRefs, + selectedOutlineCommentId, + manageMarkdownEditIds, + handleManageMarkdownEditId, + handleOutlineComment, + handleSaveComment, + handleManageQuote, + handleUpdate, + } = useUserActionsHandler({ fetchUserActions, updateCase }); + + const MarkdownNewComment = useMemo( + () => ( + (commentRefs.current[NEW_COMMENT_ID] = element)} + onCommentPosted={handleUpdate} + onCommentSaving={handleManageMarkdownEditId.bind(null, NEW_COMMENT_ID)} + showLoading={false} + statusActionButton={statusActionButton} + subCaseId={subCaseId} + /> + ), + [ + caseId, + userCanCrud, + handleUpdate, + handleManageMarkdownEditId, + statusActionButton, + subCaseId, + commentRefs, + ] + ); + + useEffect(() => { + if (initLoading && !isLoadingUserActions && loadingCommentIds.length === 0) { + setInitLoading(false); + if (commentId != null) { + handleOutlineComment(commentId); + } + } + }, [commentId, initLoading, isLoadingUserActions, loadingCommentIds, handleOutlineComment]); + + const descriptionCommentListObj: EuiCommentProps = useMemo( + () => + getDescriptionUserAction({ + caseData, + commentRefs, + manageMarkdownEditIds, + isLoadingDescription, + userCanCrud, + onUpdateField, + handleManageMarkdownEditId, + handleManageQuote, + }), + [ + caseData, + commentRefs, + manageMarkdownEditIds, + isLoadingDescription, + userCanCrud, + onUpdateField, + handleManageMarkdownEditId, + handleManageQuote, + ] + ); + + const userActions: EuiCommentProps[] = useMemo( + () => + caseUserActions.reduce( + (comments, userAction, index) => { + if (!isUserActionTypeSupported(userAction.type)) { + return comments; + } + + const builder = builderMap[userAction.type]; + + if (builder == null) { + return comments; + } + + const userActionBuilder = builder({ + caseData, + userAction, + caseServices, + comments: caseData.comments, + index, + userCanCrud, + commentRefs, + manageMarkdownEditIds, + selectedOutlineCommentId, + loadingCommentIds, + loadingAlertData, + alertData: manualAlertsData, + handleOutlineComment, + handleManageMarkdownEditId, + handleSaveComment, + handleManageQuote, + onShowAlertDetails, + actionsNavigation, + getRuleDetailsHref, + onRuleDetailsClick, + }); + return [...comments, ...userActionBuilder.build()]; + }, + [descriptionCommentListObj] + ), + [ + caseUserActions, + descriptionCommentListObj, + caseData, + caseServices, + userCanCrud, + commentRefs, + manageMarkdownEditIds, + selectedOutlineCommentId, + loadingCommentIds, + loadingAlertData, + manualAlertsData, + handleOutlineComment, + handleManageMarkdownEditId, + handleSaveComment, + handleManageQuote, + onShowAlertDetails, + actionsNavigation, + getRuleDetailsHref, + onRuleDetailsClick, + ] + ); + + const bottomActions = userCanCrud + ? [ + { + username: ( + + ), + 'data-test-subj': 'add-comment', + timelineIcon: ( + + ), + className: 'isEdit', + children: MarkdownNewComment, + }, + ] + : []; + + const comments = [...userActions, ...bottomActions]; + + return ( + <> + + {(isLoadingUserActions || loadingCommentIds.includes(NEW_COMMENT_ID)) && ( + + + + + + )} + + ); + } +); + +UserActions.displayName = 'UserActions'; diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.test.tsx b/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx similarity index 97% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.test.tsx rename to x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx index 0695f9d5a2c44..19f60d7cb8c72 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_markdown.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { mount } from 'enzyme'; -import { UserActionMarkdown } from './user_action_markdown'; +import { UserActionMarkdown } from './markdown_form'; import { TestProviders } from '../../common/mock'; import { waitFor } from '@testing-library/react'; const onChangeEditable = jest.fn(); diff --git a/x-pack/plugins/cases/public/components/user_actions/markdown_form.tsx b/x-pack/plugins/cases/public/components/user_actions/markdown_form.tsx new file mode 100644 index 0000000000000..e8df35139cec4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/markdown_form.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/eui'; +import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from 'react'; +import styled from 'styled-components'; + +import * as i18n from '../case_view/translations'; +import { Form, useForm, UseField } from '../../common/shared_imports'; +import { schema, Content } from './schema'; +import { MarkdownRenderer, MarkdownEditorForm } from '../markdown_editor'; + +export const ContentWrapper = styled.div` + padding: ${({ theme }) => `${theme.eui.euiSizeM} ${theme.eui.euiSizeL}`}; +`; + +interface UserActionMarkdownProps { + content: string; + id: string; + isEditable: boolean; + onChangeEditable: (id: string) => void; + onSaveContent: (content: string) => void; +} + +export interface UserActionMarkdownRefObject { + setComment: (newComment: string) => void; +} + +const UserActionMarkdownComponent = forwardRef< + UserActionMarkdownRefObject, + UserActionMarkdownProps +>(({ id, content, isEditable, onChangeEditable, onSaveContent }, ref) => { + const editorRef = useRef(); + const initialState = { content }; + const { form } = useForm({ + defaultValue: initialState, + options: { stripEmptyFields: false }, + schema, + }); + + const fieldName = 'content'; + const { setFieldValue, submit } = form; + + const handleCancelAction = useCallback(() => { + onChangeEditable(id); + }, [id, onChangeEditable]); + + const handleSaveAction = useCallback(async () => { + const { isValid, data } = await submit(); + + if (isValid && data.content !== content) { + onSaveContent(data.content); + } + onChangeEditable(id); + }, [content, id, onChangeEditable, onSaveContent, submit]); + + const setComment = useCallback( + (newComment) => { + setFieldValue(fieldName, newComment); + }, + [setFieldValue] + ); + + const EditorButtons = useMemo( + () => ( + + + + {i18n.CANCEL} + + + + + {i18n.SAVE} + + + + ), + [handleCancelAction, handleSaveAction] + ); + + useImperativeHandle(ref, () => ({ + setComment, + editor: editorRef.current, + })); + + return isEditable ? ( +
+ + + ) : ( + + {content} + + ); +}); + +UserActionMarkdownComponent.displayName = 'UserActionMarkdownComponent'; + +export const UserActionMarkdown = React.memo(UserActionMarkdownComponent); diff --git a/x-pack/plugins/cases/public/components/user_actions/mock.ts b/x-pack/plugins/cases/public/components/user_actions/mock.ts new file mode 100644 index 0000000000000..7000f407f97e5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/mock.ts @@ -0,0 +1,80 @@ +/* + * 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 { Actions } from '../../../common/api'; +import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; +import { basicCase, basicPush, getUserAction } from '../../containers/mock'; +import { UserActionBuilderArgs } from './types'; + +export const getMockBuilderArgs = (): UserActionBuilderArgs => { + const userAction = getUserAction('title', Actions.update); + const commentRefs = { current: {} }; + const caseServices = { + '123': { + ...basicPush, + firstPushIndex: 0, + lastPushIndex: 0, + commentsToUpdate: [], + hasDataToPush: true, + }, + }; + + const alertData = { + 'alert-id-1': { + _id: 'alert-id-1', + _index: 'alert-index-1', + signal: { + rule: { + id: ['rule-id-1'], + name: ['Awesome rule'], + false_positives: [], + }, + }, + kibana: { + alert: { + rule: { + uuid: ['rule-id-1'], + name: ['Awesome rule'], + false_positives: [], + parameters: {}, + }, + }, + }, + owner: SECURITY_SOLUTION_OWNER, + }, + }; + + const getRuleDetailsHref = jest.fn().mockReturnValue('https://example.com'); + const onRuleDetailsClick = jest.fn(); + const onShowAlertDetails = jest.fn(); + const handleManageMarkdownEditId = jest.fn(); + const handleSaveComment = jest.fn(); + const handleManageQuote = jest.fn(); + const handleOutlineComment = jest.fn(); + + return { + userAction, + caseData: basicCase, + comments: basicCase.comments, + caseServices, + index: 0, + alertData, + userCanCrud: true, + commentRefs, + manageMarkdownEditIds: [], + selectedOutlineCommentId: '', + loadingCommentIds: [], + loadingAlertData: false, + getRuleDetailsHref, + onRuleDetailsClick, + onShowAlertDetails, + handleManageMarkdownEditId, + handleSaveComment, + handleManageQuote, + handleOutlineComment, + }; +}; diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_move_to_reference.test.tsx b/x-pack/plugins/cases/public/components/user_actions/move_to_reference.test.tsx similarity index 92% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_move_to_reference.test.tsx rename to x-pack/plugins/cases/public/components/user_actions/move_to_reference.test.tsx index acd3814786a34..cd207c635e9d4 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_move_to_reference.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/move_to_reference.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; -import { UserActionMoveToReference } from './user_action_move_to_reference'; +import { UserActionMoveToReference } from './move_to_reference'; const outlineComment = jest.fn(); const props = { diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_move_to_reference.tsx b/x-pack/plugins/cases/public/components/user_actions/move_to_reference.tsx similarity index 100% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_move_to_reference.tsx rename to x-pack/plugins/cases/public/components/user_actions/move_to_reference.tsx diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.test.tsx b/x-pack/plugins/cases/public/components/user_actions/property_actions.test.tsx similarity index 96% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.test.tsx rename to x-pack/plugins/cases/public/components/user_actions/property_actions.test.tsx index 999a3380f5797..167a7fb977929 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/property_actions.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; -import { UserActionPropertyActions } from './user_action_property_actions'; +import { UserActionPropertyActions } from './property_actions'; jest.mock('../../common/lib/kibana'); diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.tsx b/x-pack/plugins/cases/public/components/user_actions/property_actions.tsx similarity index 100% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.tsx rename to x-pack/plugins/cases/public/components/user_actions/property_actions.tsx diff --git a/x-pack/plugins/cases/public/components/user_actions/pushed.test.tsx b/x-pack/plugins/cases/public/components/user_actions/pushed.test.tsx new file mode 100644 index 0000000000000..219a7a6d2c7c8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/pushed.test.tsx @@ -0,0 +1,160 @@ +/* + * 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 from 'react'; +import { EuiCommentList } from '@elastic/eui'; +import { render, screen } from '@testing-library/react'; + +import { Actions, NONE_CONNECTOR_ID } from '../../../common/api'; +import { getUserAction } from '../../containers/mock'; +import { TestProviders } from '../../common/mock'; +import { createPushedUserActionBuilder } from './pushed'; +import { getMockBuilderArgs } from './mock'; + +jest.mock('../../common/lib/kibana'); +jest.mock('../../common/navigation/hooks'); + +describe('createPushedUserActionBuilder ', () => { + const builderArgs = getMockBuilderArgs(); + const caseServices = builderArgs.caseServices; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly pushing for the first time', async () => { + const userAction = getUserAction('pushed', Actions.push_to_service); + const builder = createPushedUserActionBuilder({ + ...builderArgs, + userAction, + caseServices, + index: 0, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + expect(screen.getByText('pushed as new incident connector name')).toBeInTheDocument(); + expect(screen.getByText('external title').closest('a')).toHaveAttribute( + 'href', + 'basicPush.com' + ); + }); + + it('renders correctly when updating an external service', async () => { + const userAction = getUserAction('pushed', Actions.push_to_service); + const builder = createPushedUserActionBuilder({ + ...builderArgs, + userAction, + caseServices, + index: 1, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + expect(screen.getByText('updated incident connector name')).toBeInTheDocument(); + }); + + it('renders the pushing indicators correctly', async () => { + const userAction = getUserAction('pushed', Actions.push_to_service); + const builder = createPushedUserActionBuilder({ + ...builderArgs, + userAction, + caseServices: { + ...caseServices, + '123': { + ...caseServices['123'], + lastPushIndex: 1, + }, + }, + index: 1, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + expect(screen.getByText('Already pushed to connector name incident')).toBeInTheDocument(); + expect(screen.getByText('Requires update to connector name incident')).toBeInTheDocument(); + }); + + it('shows only the already pushed indicator if has no data to push', async () => { + const userAction = getUserAction('pushed', Actions.push_to_service); + const builder = createPushedUserActionBuilder({ + ...builderArgs, + userAction, + caseServices: { + ...caseServices, + '123': { + ...caseServices['123'], + lastPushIndex: 1, + hasDataToPush: false, + }, + }, + index: 1, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + expect(screen.getByText('Already pushed to connector name incident')).toBeInTheDocument(); + expect( + screen.queryByText('Requires update to connector name incident') + ).not.toBeInTheDocument(); + }); + + it('does not show the push information if the connector is none', async () => { + const userAction = getUserAction('pushed', Actions.push_to_service, { + payload: { + externalService: { connectorId: NONE_CONNECTOR_ID, connectorName: 'none connector' }, + }, + }); + + const builder = createPushedUserActionBuilder({ + ...builderArgs, + userAction, + caseServices: { + ...caseServices, + '123': { + ...caseServices['123'], + lastPushIndex: 1, + }, + }, + index: 1, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + expect(screen.queryByText('pushed as new incident none connector')).not.toBeInTheDocument(); + expect(screen.queryByText('updated incident none connector')).not.toBeInTheDocument(); + expect(screen.queryByText('Already pushed to connector name incident')).not.toBeInTheDocument(); + expect( + screen.queryByText('Requires update to connector name incident') + ).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/pushed.tsx b/x-pack/plugins/cases/public/components/user_actions/pushed.tsx new file mode 100644 index 0000000000000..e02bde992b651 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/pushed.tsx @@ -0,0 +1,148 @@ +/* + * 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 from 'react'; +import { EuiCommentProps, EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; + +import { Actions, NONE_CONNECTOR_ID, PushedUserAction } from '../../../common/api'; +import { UserActionBuilder, UserActionResponse } from './types'; +import { createCommonUpdateUserActionBuilder } from './common'; +import * as i18n from './translations'; +import { CaseServices } from '../../containers/use_get_case_user_actions'; +import { CaseExternalService } from '../../containers/types'; + +const getPushInfo = ( + caseServices: CaseServices, + externalService: CaseExternalService | undefined, + index: number +) => + externalService != null && externalService.connectorId !== NONE_CONNECTOR_ID + ? { + firstPush: caseServices[externalService.connectorId]?.firstPushIndex === index, + parsedConnectorId: externalService.connectorId, + parsedConnectorName: externalService.connectorName, + } + : { + firstPush: false, + parsedConnectorId: NONE_CONNECTOR_ID, + parsedConnectorName: NONE_CONNECTOR_ID, + }; + +const getLabelTitle = (action: UserActionResponse, firstPush: boolean) => { + const externalService = action.payload.externalService; + + return ( + + + {`${firstPush ? i18n.PUSHED_NEW_INCIDENT : i18n.UPDATE_INCIDENT} ${ + externalService?.connectorName + }`} + + + + {externalService?.externalTitle} + + + + ); +}; + +const getFooters = ({ + userAction, + caseServices, + connectorId, + connectorName, + index, +}: { + userAction: UserActionResponse; + caseServices: CaseServices; + connectorId: string; + connectorName: string; + index: number; +}): EuiCommentProps[] => { + const showTopFooter = + userAction.action === Actions.push_to_service && + index === caseServices[connectorId]?.lastPushIndex; + + const showBottomFooter = + userAction.action === Actions.push_to_service && + index === caseServices[connectorId]?.lastPushIndex && + caseServices[connectorId].hasDataToPush; + + let footers: EuiCommentProps[] = []; + + if (showTopFooter) { + footers = [ + ...footers, + { + username: '', + type: 'update', + event: i18n.ALREADY_PUSHED_TO_SERVICE(`${connectorName}`), + timelineIcon: 'sortUp', + 'data-test-subj': 'top-footer', + }, + ]; + } + + if (showBottomFooter) { + footers = [ + ...footers, + { + username: '', + type: 'update', + event: i18n.REQUIRED_UPDATE_TO_SERVICE(`${connectorName}`), + timelineIcon: 'sortDown', + 'data-test-subj': 'bottom-footer', + }, + ]; + } + + return footers; +}; + +export const createPushedUserActionBuilder: UserActionBuilder = ({ + userAction, + caseServices, + index, + handleOutlineComment, +}) => ({ + build: () => { + const pushedUserAction = userAction as UserActionResponse; + const { firstPush, parsedConnectorId, parsedConnectorName } = getPushInfo( + caseServices, + pushedUserAction.payload.externalService, + index + ); + + if (parsedConnectorId === NONE_CONNECTOR_ID) { + return []; + } + + const footers = getFooters({ + userAction: pushedUserAction, + caseServices, + connectorId: parsedConnectorId, + connectorName: parsedConnectorName, + index, + }); + + const label = getLabelTitle(pushedUserAction, firstPush); + const commonBuilder = createCommonUpdateUserActionBuilder({ + userAction, + handleOutlineComment, + label, + icon: 'dot', + }); + + return [...commonBuilder.build(), ...footers]; + }, +}); diff --git a/x-pack/plugins/cases/public/components/user_action_tree/schema.ts b/x-pack/plugins/cases/public/components/user_actions/schema.ts similarity index 100% rename from x-pack/plugins/cases/public/components/user_action_tree/schema.ts rename to x-pack/plugins/cases/public/components/user_actions/schema.ts diff --git a/x-pack/plugins/cases/public/components/user_actions/status.test.tsx b/x-pack/plugins/cases/public/components/user_actions/status.test.tsx new file mode 100644 index 0000000000000..037a2e1756419 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/status.test.tsx @@ -0,0 +1,50 @@ +/* + * 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 from 'react'; +import { EuiCommentList } from '@elastic/eui'; +import { render, screen } from '@testing-library/react'; + +import { Actions, CaseStatuses } from '../../../common/api'; +import { getUserAction } from '../../containers/mock'; +import { TestProviders } from '../../common/mock'; +import { createStatusUserActionBuilder } from './status'; +import { getMockBuilderArgs } from './mock'; + +jest.mock('../../common/lib/kibana'); +jest.mock('../../common/navigation/hooks'); + +describe('createStatusUserActionBuilder ', () => { + const builderArgs = getMockBuilderArgs(); + const tests = [ + [CaseStatuses.open, 'Open'], + [CaseStatuses['in-progress'], 'In progress'], + [CaseStatuses.closed, 'Closed'], + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each(tests)('renders correctly when changed to %s status', async (status, label) => { + const userAction = getUserAction('status', Actions.update, { payload: { status } }); + const builder = createStatusUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + expect(screen.getByText('marked case as')).toBeInTheDocument(); + expect(screen.getByText(label)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/status.tsx b/x-pack/plugins/cases/public/components/user_actions/status.tsx new file mode 100644 index 0000000000000..6300bee347c4b --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/status.tsx @@ -0,0 +1,56 @@ +/* + * 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 from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { CaseStatuses, StatusUserAction } from '../../../common/api'; +import { UserActionBuilder, UserActionResponse } from './types'; +import { createCommonUpdateUserActionBuilder } from './common'; +import { Status, statuses } from '../status'; +import * as i18n from './translations'; + +const isStatusValid = (status: string): status is CaseStatuses => + Object.prototype.hasOwnProperty.call(statuses, status); + +const getLabelTitle = (userAction: UserActionResponse) => { + const status = userAction.payload.status ?? ''; + if (isStatusValid(status)) { + return ( + + {i18n.MARKED_CASE_AS} + + + + + ); + } + + return <>; +}; + +export const createStatusUserActionBuilder: UserActionBuilder = ({ + userAction, + handleOutlineComment, +}) => ({ + build: () => { + const statusUserAction = userAction as UserActionResponse; + const label = getLabelTitle(statusUserAction); + const commonBuilder = createCommonUpdateUserActionBuilder({ + userAction, + handleOutlineComment, + label, + icon: 'folderClosed', + }); + + return commonBuilder.build(); + }, +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/tags.test.tsx b/x-pack/plugins/cases/public/components/user_actions/tags.test.tsx new file mode 100644 index 0000000000000..058d2232e666d --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/tags.test.tsx @@ -0,0 +1,63 @@ +/* + * 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 from 'react'; +import { EuiCommentList } from '@elastic/eui'; +import { render, screen } from '@testing-library/react'; + +import { Actions } from '../../../common/api'; +import { getUserAction } from '../../containers/mock'; +import { TestProviders } from '../../common/mock'; +import { createTagsUserActionBuilder } from './tags'; +import { getMockBuilderArgs } from './mock'; + +jest.mock('../../common/lib/kibana'); +jest.mock('../../common/navigation/hooks'); + +describe('createTagsUserActionBuilder ', () => { + const builderArgs = getMockBuilderArgs(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly when adding a tag', async () => { + const userAction = getUserAction('tags', Actions.add); + const builder = createTagsUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + expect(screen.getByText('added tags')).toBeInTheDocument(); + expect(screen.getByText('a tag')).toBeInTheDocument(); + }); + + it('renders correctly when deleting a tag', async () => { + const userAction = getUserAction('tags', Actions.delete); + const builder = createTagsUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + expect(screen.getByText('removed tags')).toBeInTheDocument(); + expect(screen.getByText('a tag')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/tags.tsx b/x-pack/plugins/cases/public/components/user_actions/tags.tsx new file mode 100644 index 0000000000000..d5553a3f6f13d --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/tags.tsx @@ -0,0 +1,49 @@ +/* + * 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 from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { Actions, TagsUserAction } from '../../../common/api'; +import { UserActionBuilder, UserActionResponse } from './types'; +import { createCommonUpdateUserActionBuilder } from './common'; +import { Tags } from '../tag_list/tags'; +import * as i18n from './translations'; + +const getLabelTitle = (userAction: UserActionResponse) => { + const tags = userAction.payload.tags ?? []; + + return ( + + + {userAction.action === Actions.add && i18n.ADDED_FIELD} + {userAction.action === Actions.delete && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()} + + + + + + ); +}; + +export const createTagsUserActionBuilder: UserActionBuilder = ({ + userAction, + handleOutlineComment, +}) => ({ + build: () => { + const tagsUserAction = userAction as UserActionResponse; + const label = getLabelTitle(tagsUserAction); + const commonBuilder = createCommonUpdateUserActionBuilder({ + userAction, + handleOutlineComment, + label, + icon: 'tag', + }); + + return commonBuilder.build(); + }, +}); diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_timestamp.test.tsx b/x-pack/plugins/cases/public/components/user_actions/timestamp.test.tsx similarity index 97% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_timestamp.test.tsx rename to x-pack/plugins/cases/public/components/user_actions/timestamp.test.tsx index f2e5d9793f3a8..d380f246566e9 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_timestamp.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/timestamp.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; import { TestProviders } from '../../common/mock'; -import { UserActionTimestamp } from './user_action_timestamp'; +import { UserActionTimestamp } from './timestamp'; jest.mock('@kbn/i18n-react', () => { const originalModule = jest.requireActual('@kbn/i18n-react'); diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_timestamp.tsx b/x-pack/plugins/cases/public/components/user_actions/timestamp.tsx similarity index 94% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_timestamp.tsx rename to x-pack/plugins/cases/public/components/user_actions/timestamp.tsx index 45ad44932831d..98e25fe265dbb 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_timestamp.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/timestamp.tsx @@ -9,7 +9,7 @@ import React, { memo } from 'react'; import { EuiTextColor } from '@elastic/eui'; import { FormattedRelative } from '@kbn/i18n-react'; -import { LocalizedDateTooltip } from '../../components/localized_date_tooltip'; +import { LocalizedDateTooltip } from '../localized_date_tooltip'; import * as i18n from './translations'; interface UserActionAvatarProps { diff --git a/x-pack/plugins/cases/public/components/user_actions/title.test.tsx b/x-pack/plugins/cases/public/components/user_actions/title.test.tsx new file mode 100644 index 0000000000000..c8d063e9a5343 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/title.test.tsx @@ -0,0 +1,45 @@ +/* + * 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 from 'react'; +import { EuiCommentList } from '@elastic/eui'; +import { render, screen } from '@testing-library/react'; + +import { Actions } from '../../../common/api'; +import { getUserAction } from '../../containers/mock'; +import { TestProviders } from '../../common/mock'; +import { createTitleUserActionBuilder } from './title'; +import { getMockBuilderArgs } from './mock'; + +jest.mock('../../common/lib/kibana'); +jest.mock('../../common/navigation/hooks'); + +describe('createTitleUserActionBuilder ', () => { + const builderArgs = getMockBuilderArgs(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + const userAction = getUserAction('title', Actions.update); + // @ts-ignore no need to pass all the arguments + const builder = createTitleUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + render( + + + + ); + + expect(screen.getByText(`changed case name to "a title"`)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/title.tsx b/x-pack/plugins/cases/public/components/user_actions/title.tsx new file mode 100644 index 0000000000000..203b0e9882f64 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/title.tsx @@ -0,0 +1,34 @@ +/* + * 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 { TitleUserAction } from '../../../common/api'; +import { UserActionBuilder, UserActionResponse } from './types'; +import { createCommonUpdateUserActionBuilder } from './common'; +import * as i18n from './translations'; + +const getLabelTitle = (userAction: UserActionResponse) => + `${i18n.CHANGED_FIELD.toLowerCase()} ${i18n.CASE_NAME.toLowerCase()} ${i18n.TO} "${ + userAction.payload.title + }"`; + +export const createTitleUserActionBuilder: UserActionBuilder = ({ + userAction, + handleOutlineComment, +}) => ({ + build: () => { + const titleUserAction = userAction as UserActionResponse; + const label = getLabelTitle(titleUserAction); + const commonBuilder = createCommonUpdateUserActionBuilder({ + userAction, + handleOutlineComment, + label, + icon: 'dot', + }); + + return commonBuilder.build(); + }, +}); diff --git a/x-pack/plugins/cases/public/components/user_action_tree/translations.ts b/x-pack/plugins/cases/public/components/user_actions/translations.ts similarity index 100% rename from x-pack/plugins/cases/public/components/user_action_tree/translations.ts rename to x-pack/plugins/cases/public/components/user_actions/translations.ts diff --git a/x-pack/plugins/cases/public/components/user_actions/types.ts b/x-pack/plugins/cases/public/components/user_actions/types.ts new file mode 100644 index 0000000000000..80657cc90cba9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/types.ts @@ -0,0 +1,91 @@ +/* + * 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 { EuiCommentProps } from '@elastic/eui'; +import { SnakeToCamelCase } from '../../../common/types'; +import { ActionTypes, UserActionWithResponse } from '../../../common/api'; +import { Case, CaseUserActions, Ecs, Comment } from '../../containers/types'; +import { CaseServices } from '../../containers/use_get_case_user_actions'; +import { AddCommentRefObject } from '../add_comment'; +import { UserActionMarkdownRefObject } from './markdown_form'; +import { CasesNavigation } from '../links'; +import { UNSUPPORTED_ACTION_TYPES } from './constants'; +import type { OnUpdateFields } from '../case_view/types'; + +export interface UserActionTreeProps { + caseServices: CaseServices; + caseUserActions: CaseUserActions[]; + data: Case; + fetchUserActions: () => void; + getRuleDetailsHref?: RuleDetailsNavigation['href']; + actionsNavigation?: ActionsNavigation; + isLoadingDescription: boolean; + isLoadingUserActions: boolean; + onRuleDetailsClick?: RuleDetailsNavigation['onClick']; + onShowAlertDetails: (alertId: string, index: string) => void; + onUpdateField: ({ key, value, onSuccess, onError }: OnUpdateFields) => void; + renderInvestigateInTimelineActionComponent?: (alertIds: string[]) => JSX.Element; + statusActionButton: JSX.Element | null; + updateCase: (newCase: Case) => void; + useFetchAlertData: (alertIds: string[]) => [boolean, Record]; + userCanCrud: boolean; +} + +type UnsupportedUserActionTypes = typeof UNSUPPORTED_ACTION_TYPES[number]; +export type SupportedUserActionTypes = keyof Omit; + +export interface UserActionBuilderArgs { + caseData: Case; + userAction: CaseUserActions; + caseServices: CaseServices; + comments: Comment[]; + index: number; + userCanCrud: boolean; + commentRefs: React.MutableRefObject< + Record + >; + manageMarkdownEditIds: string[]; + selectedOutlineCommentId: string; + loadingCommentIds: string[]; + loadingAlertData: boolean; + alertData: Record; + handleOutlineComment: (id: string) => void; + handleManageMarkdownEditId: (id: string) => void; + handleSaveComment: ({ id, version }: { id: string; version: string }, content: string) => void; + handleManageQuote: (quote: string) => void; + onShowAlertDetails: (alertId: string, index: string) => void; + actionsNavigation?: ActionsNavigation; + getRuleDetailsHref?: RuleDetailsNavigation['href']; + onRuleDetailsClick?: RuleDetailsNavigation['onClick']; +} + +export type UserActionResponse = SnakeToCamelCase>; +export type UserActionBuilder = (args: UserActionBuilderArgs) => { + build: () => EuiCommentProps[]; +}; + +export type UserActionBuilderMap = Record; + +export type RuleDetailsNavigation = CasesNavigation; +export type ActionsNavigation = CasesNavigation; + +interface Signal { + rule: { + id: string; + name: string; + to: string; + from: string; + }; +} + +export interface Alert { + _id: string; + _index: string; + '@timestamp': string; + signal: Signal; + [key: string]: unknown; +} diff --git a/x-pack/plugins/cases/public/components/user_actions/use_user_actions_handler.test.tsx b/x-pack/plugins/cases/public/components/user_actions/use_user_actions_handler.test.tsx new file mode 100644 index 0000000000000..574109b438167 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/use_user_actions_handler.test.tsx @@ -0,0 +1,171 @@ +/* + * 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, act } from '@testing-library/react-hooks'; +import { basicCase } from '../../containers/mock'; + +import { useUpdateComment } from '../../containers/use_update_comment'; +import { useLensDraftComment } from '../markdown_editor/plugins/lens/use_lens_draft_comment'; +import { NEW_COMMENT_ID } from './constants'; +import { + useUserActionsHandler, + UseUserActionsHandlerArgs, + UseUserActionsHandler, +} from './use_user_actions_handler'; + +jest.mock('../../common/lib/kibana'); +jest.mock('../../common/navigation/hooks'); +jest.mock('../markdown_editor/plugins/lens/use_lens_draft_comment'); +jest.mock('../../containers/use_update_comment'); + +const useUpdateCommentMock = useUpdateComment as jest.Mock; +const useLensDraftCommentMock = useLensDraftComment as jest.Mock; +const patchComment = jest.fn(); +const clearDraftComment = jest.fn(); +const openLensModal = jest.fn(); + +describe('useUserActionsHandler', () => { + const fetchUserActions = jest.fn(); + const updateCase = jest.fn(); + + beforeAll(() => { + jest.useFakeTimers(); + jest.spyOn(global, 'setTimeout'); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + useUpdateCommentMock.mockReturnValue({ + isLoadingIds: [], + patchComment, + }); + + useLensDraftCommentMock.mockReturnValue({ + clearDraftComment, + openLensModal, + draftComment: null, + hasIncomingLensState: false, + }); + }); + + it('init', async () => { + const { result } = renderHook(() => + useUserActionsHandler({ fetchUserActions, updateCase }) + ); + + expect(result.current).toEqual({ + loadingCommentIds: [], + selectedOutlineCommentId: '', + manageMarkdownEditIds: [], + commentRefs: result.current.commentRefs, + handleManageMarkdownEditId: result.current.handleManageMarkdownEditId, + handleOutlineComment: result.current.handleOutlineComment, + handleSaveComment: result.current.handleSaveComment, + handleManageQuote: result.current.handleManageQuote, + handleUpdate: result.current.handleUpdate, + }); + }); + + it('should saves a comment', async () => { + const { result } = renderHook(() => + useUserActionsHandler({ fetchUserActions, updateCase }) + ); + + result.current.handleSaveComment({ id: 'test-id', version: 'test-version' }, 'a comment'); + expect(patchComment).toHaveBeenCalledWith({ + caseId: 'basic-case-id', + commentId: 'test-id', + commentUpdate: 'a comment', + fetchUserActions, + subCaseId: undefined, + updateCase, + version: 'test-version', + }); + }); + + it('should update a case', async () => { + const { result } = renderHook(() => + useUserActionsHandler({ fetchUserActions, updateCase }) + ); + + result.current.handleUpdate(basicCase); + expect(fetchUserActions).toHaveBeenCalled(); + expect(updateCase).toHaveBeenCalledWith(basicCase); + }); + + it('should handle markdown edit', async () => { + const { result } = renderHook(() => + useUserActionsHandler({ fetchUserActions, updateCase }) + ); + + act(() => { + result.current.handleManageMarkdownEditId('test-id'); + }); + + expect(clearDraftComment).toHaveBeenCalled(); + expect(result.current.manageMarkdownEditIds).toEqual(['test-id']); + }); + + it('should remove id from the markdown edit ids', async () => { + const { result } = renderHook(() => + useUserActionsHandler({ fetchUserActions, updateCase }) + ); + + act(() => { + result.current.handleManageMarkdownEditId('test-id'); + }); + + expect(result.current.manageMarkdownEditIds).toEqual(['test-id']); + + act(() => { + result.current.handleManageMarkdownEditId('test-id'); + }); + + expect(result.current.manageMarkdownEditIds).toEqual([]); + }); + + it('should outline a comment', async () => { + const { result } = renderHook(() => + useUserActionsHandler({ fetchUserActions, updateCase }) + ); + + act(() => { + result.current.handleOutlineComment('test-id'); + }); + + expect(result.current.selectedOutlineCommentId).toBe('test-id'); + + act(() => { + jest.runAllTimers(); + }); + + expect(result.current.selectedOutlineCommentId).toBe(''); + }); + + it('should quote', async () => { + const addQuote = jest.fn(); + const { result } = renderHook(() => + useUserActionsHandler({ fetchUserActions, updateCase }) + ); + + result.current.commentRefs.current[NEW_COMMENT_ID] = { + addQuote, + setComment: jest.fn(), + }; + + act(() => { + result.current.handleManageQuote('my quote'); + }); + + expect(addQuote).toHaveBeenCalledWith('my quote'); + expect(result.current.selectedOutlineCommentId).toBe('add-comment'); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/use_user_actions_handler.tsx b/x-pack/plugins/cases/public/components/user_actions/use_user_actions_handler.tsx new file mode 100644 index 0000000000000..b9943a8960392 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/use_user_actions_handler.tsx @@ -0,0 +1,167 @@ +/* + * 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 { useCallback, useEffect, useRef, useState } from 'react'; +import { useCaseViewParams } from '../../common/navigation'; +import { Case } from '../../containers/types'; +import { useLensDraftComment } from '../markdown_editor/plugins/lens/use_lens_draft_comment'; +import { useUpdateComment } from '../../containers/use_update_comment'; +import { AddCommentRefObject } from '../add_comment'; +import { UserActionMarkdownRefObject } from './markdown_form'; +import { UserActionBuilderArgs, UserActionTreeProps } from './types'; +import { NEW_COMMENT_ID } from './constants'; + +export type UseUserActionsHandlerArgs = Pick< + UserActionTreeProps, + 'fetchUserActions' | 'updateCase' +>; + +export type UseUserActionsHandler = Pick< + UserActionBuilderArgs, + | 'loadingCommentIds' + | 'selectedOutlineCommentId' + | 'manageMarkdownEditIds' + | 'commentRefs' + | 'handleManageMarkdownEditId' + | 'handleOutlineComment' + | 'handleSaveComment' + | 'handleManageQuote' +> & { handleUpdate: (updatedCase: Case) => void }; + +const isAddCommentRef = ( + ref: AddCommentRefObject | UserActionMarkdownRefObject | null | undefined +): ref is AddCommentRefObject => { + const commentRef = ref as AddCommentRefObject; + return commentRef?.addQuote != null; +}; + +export const useUserActionsHandler = ({ + fetchUserActions, + updateCase, +}: UseUserActionsHandlerArgs): UseUserActionsHandler => { + const { detailName: caseId, subCaseId } = useCaseViewParams(); + const { clearDraftComment, draftComment, hasIncomingLensState, openLensModal } = + useLensDraftComment(); + const handlerTimeoutId = useRef(0); + const { isLoadingIds, patchComment } = useUpdateComment(); + const [selectedOutlineCommentId, setSelectedOutlineCommentId] = useState(''); + const [manageMarkdownEditIds, setManageMarkdownEditIds] = useState([]); + const commentRefs = useRef< + Record + >({}); + + const handleManageMarkdownEditId = useCallback( + (id: string) => { + clearDraftComment(); + setManageMarkdownEditIds((prevManageMarkdownEditIds) => + !prevManageMarkdownEditIds.includes(id) + ? prevManageMarkdownEditIds.concat(id) + : prevManageMarkdownEditIds.filter((myId) => id !== myId) + ); + }, + [clearDraftComment] + ); + + const handleSaveComment = useCallback( + ({ id, version }: { id: string; version: string }, content: string) => { + patchComment({ + caseId, + commentId: id, + commentUpdate: content, + fetchUserActions, + version, + updateCase, + subCaseId, + }); + }, + [caseId, fetchUserActions, patchComment, subCaseId, updateCase] + ); + + const handleOutlineComment = useCallback( + (id: string) => { + const moveToTarget = document.getElementById(`${id}-permLink`); + if (moveToTarget != null) { + const yOffset = -120; + const y = moveToTarget.getBoundingClientRect().top + window.pageYOffset + yOffset; + window.scrollTo({ + top: y, + behavior: 'smooth', + }); + + if (id === 'add-comment') { + moveToTarget.getElementsByTagName('textarea')[0].focus(); + } + } + + window.clearTimeout(handlerTimeoutId.current); + setSelectedOutlineCommentId(id); + + handlerTimeoutId.current = window.setTimeout(() => { + setSelectedOutlineCommentId(''); + window.clearTimeout(handlerTimeoutId.current); + }, 2400); + }, + [handlerTimeoutId] + ); + + const handleManageQuote = useCallback( + (quote: string) => { + const ref = commentRefs?.current[NEW_COMMENT_ID]; + if (isAddCommentRef(ref)) { + ref.addQuote(quote); + } + + handleOutlineComment('add-comment'); + }, + [handleOutlineComment] + ); + + const handleUpdate = useCallback( + (newCase: Case) => { + updateCase(newCase); + fetchUserActions(); + }, + [fetchUserActions, updateCase] + ); + + useEffect(() => { + if (draftComment?.commentId) { + setManageMarkdownEditIds((prevManageMarkdownEditIds) => { + if ( + NEW_COMMENT_ID !== draftComment?.commentId && + !prevManageMarkdownEditIds.includes(draftComment?.commentId) + ) { + return [draftComment?.commentId]; + } + return prevManageMarkdownEditIds; + }); + + const ref = commentRefs?.current?.[draftComment.commentId]; + + if (isAddCommentRef(ref) && ref.editor?.textarea) { + ref.setComment(draftComment.comment); + if (hasIncomingLensState) { + openLensModal({ editorRef: ref.editor }); + } else { + clearDraftComment(); + } + } + } + }, [clearDraftComment, draftComment, hasIncomingLensState, openLensModal]); + + return { + loadingCommentIds: isLoadingIds, + selectedOutlineCommentId, + manageMarkdownEditIds, + commentRefs, + handleManageMarkdownEditId, + handleOutlineComment, + handleSaveComment, + handleManageQuote, + handleUpdate, + }; +}; diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_username.test.tsx b/x-pack/plugins/cases/public/components/user_actions/username.test.tsx similarity index 97% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_username.test.tsx rename to x-pack/plugins/cases/public/components/user_actions/username.test.tsx index f664da71fc1f6..f8bfd7a54005d 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_username.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/username.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; -import { UserActionUsername } from './user_action_username'; +import { UserActionUsername } from './username'; const props = { username: 'elastic', diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_username.tsx b/x-pack/plugins/cases/public/components/user_actions/username.tsx similarity index 100% rename from x-pack/plugins/cases/public/components/user_action_tree/user_action_username.tsx rename to x-pack/plugins/cases/public/components/user_actions/username.tsx diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index 7fb4dab915c2d..fb69ca6f22793 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -32,6 +32,7 @@ import { import { SECURITY_SOLUTION_OWNER } from '../../common/constants'; import { UseGetCasesState, DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; import { SnakeToCamelCase } from '../../common/types'; +import { covertToSnakeCase } from './utils'; export { connectorsMock } from './configure/mock'; export const basicCaseId = 'basic-case-id'; @@ -258,6 +259,8 @@ export const basicCommentPatch: Comment = { updatedAt: basicUpdatedAt, updatedBy: { username: 'elastic', + email: 'elastic@elastic.co', + fullName: 'Elastic', }, }; @@ -428,55 +431,138 @@ export const allCasesSnake: CasesFindResponse = { ...casesStatusSnake, }; -const basicActionSnake = { - created_at: basicCreatedAt, - created_by: elasticUserSnake, - case_id: basicCaseId, - comment_id: null, - owner: SECURITY_SOLUTION_OWNER, +export const getUserAction = ( + type: UserActionTypes, + action: UserAction, + overrides?: Record +): CaseUserActions => { + const commonProperties = { + ...basicAction, + actionId: `${type}-${action}`, + action, + }; + + const externalService = { + connectorId: pushConnectorId, + connectorName: 'connector name', + externalId: 'external_id', + externalTitle: 'external title', + externalUrl: 'basicPush.com', + pushedAt: basicUpdatedAt, + pushedBy: elasticUser, + }; + + switch (type) { + case ActionTypes.comment: + return { + ...commonProperties, + type: ActionTypes.comment, + payload: { + comment: { comment: 'a comment', type: CommentType.user, owner: SECURITY_SOLUTION_OWNER }, + }, + commentId: basicCommentId, + ...overrides, + }; + case ActionTypes.connector: + return { + ...commonProperties, + type: ActionTypes.connector, + payload: { + connector: { ...getJiraConnector() }, + }, + ...overrides, + }; + case ActionTypes.create_case: + return { + ...commonProperties, + type: ActionTypes.create_case, + payload: { + description: 'a desc', + connector: { ...getJiraConnector() }, + status: CaseStatuses.open, + title: 'a title', + tags: ['a tag'], + settings: { syncAlerts: true }, + owner: SECURITY_SOLUTION_OWNER, + }, + ...overrides, + }; + case ActionTypes.delete_case: + return { + ...commonProperties, + type: ActionTypes.delete_case, + payload: {}, + ...overrides, + }; + case ActionTypes.description: + return { + ...commonProperties, + type: ActionTypes.description, + payload: { description: 'a desc' }, + ...overrides, + }; + case ActionTypes.pushed: + return { + ...commonProperties, + type: ActionTypes.pushed, + payload: { + externalService, + }, + ...overrides, + }; + case ActionTypes.settings: + return { + ...commonProperties, + type: ActionTypes.settings, + payload: { settings: { syncAlerts: true } }, + ...overrides, + }; + case ActionTypes.status: + return { + ...commonProperties, + type: ActionTypes.status, + payload: { status: CaseStatuses.open }, + ...overrides, + }; + case ActionTypes.tags: + return { + ...commonProperties, + type: ActionTypes.tags, + payload: { tags: ['a tag'] }, + ...overrides, + }; + case ActionTypes.title: + return { + ...commonProperties, + type: ActionTypes.title, + payload: { title: 'a title' }, + ...overrides, + }; + + default: + return { + ...commonProperties, + ...overrides, + } as CaseUserActions; + } }; export const getUserActionSnake = ( type: UserActionTypes, action: UserAction, - payload?: Record + overrides?: Record ): CaseUserActionResponse => { - const isPushToService = type === ActionTypes.pushed; - return { - ...basicActionSnake, - action_id: `${type}-${action}`, - type, - action, - comment_id: type === 'comment' ? basicCommentId : null, - payload: isPushToService ? { externalService: basicPushSnake } : payload ?? basicAction.payload, + ...covertToSnakeCase(getUserAction(type, action, overrides)), } as unknown as CaseUserActionResponse; }; export const caseUserActionsSnake: CaseUserActionsResponse = [ - getUserActionSnake('description', Actions.create, { description: 'a desc' }), - getUserActionSnake('comment', Actions.create, { - comment: { comment: 'a comment', type: CommentType.user, owner: SECURITY_SOLUTION_OWNER }, - }), - getUserActionSnake('description', Actions.update, { description: 'a desc updated' }), + getUserActionSnake('description', Actions.create), + getUserActionSnake('comment', Actions.create), + getUserActionSnake('description', Actions.update), ]; -export const getUserAction = ( - type: UserActionTypes, - action: UserAction, - overrides?: Record -): CaseUserActions => { - return { - ...basicAction, - actionId: `${type}-${action}`, - type, - action, - commentId: type === 'comment' ? basicCommentId : null, - payload: type === 'pushed' ? { externalService: basicPush } : basicAction.payload, - ...overrides, - } as CaseUserActions; -}; - export const getJiraConnector = (overrides?: Partial): CaseConnector => { return { id: '123', @@ -492,9 +578,8 @@ export const jiraFields = { fields: { issueType: '10006', priority: null, parent export const getAlertUserAction = (): SnakeToCamelCase< UserActionWithResponse > => ({ - ...basicAction, + ...getUserAction(ActionTypes.comment, Actions.create), actionId: 'alert-action-id', - action: Actions.create, commentId: 'alert-comment-id', type: ActionTypes.comment, payload: { @@ -514,10 +599,9 @@ export const getAlertUserAction = (): SnakeToCamelCase< export const getHostIsolationUserAction = (): SnakeToCamelCase< UserActionWithResponse > => ({ - ...basicAction, + ...getUserAction(ActionTypes.comment, Actions.create), actionId: 'isolate-action-id', type: ActionTypes.comment, - action: Actions.create, commentId: 'isolate-comment-id', payload: { comment: { @@ -530,13 +614,9 @@ export const getHostIsolationUserAction = (): SnakeToCamelCase< }); export const caseUserActions: CaseUserActions[] = [ - getUserAction('description', Actions.create, { payload: { description: 'a desc' } }), - getUserAction('comment', Actions.create, { - payload: { - comment: { comment: 'a comment', type: CommentType.user, owner: SECURITY_SOLUTION_OWNER }, - }, - }), - getUserAction('description', Actions.update, { payload: { description: 'a desc updated' } }), + getUserAction('description', Actions.create), + getUserAction('comment', Actions.create), + getUserAction('description', Actions.update), ]; // components tests diff --git a/x-pack/plugins/cases/public/containers/utils.ts b/x-pack/plugins/cases/public/containers/utils.ts index 069c883b99392..303600fdb3398 100644 --- a/x-pack/plugins/cases/public/containers/utils.ts +++ b/x-pack/plugins/cases/public/containers/utils.ts @@ -6,7 +6,7 @@ */ import { set } from '@elastic/safer-lodash-set'; -import { camelCase, isArray, isObject } from 'lodash'; +import { camelCase, isArray, isObject, transform, snakeCase } from 'lodash'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; @@ -40,6 +40,12 @@ import * as i18n from './translations'; export const getTypedPayload = (a: unknown): T => a as T; +export const covertToSnakeCase = (obj: Record) => + transform(obj, (acc: Record, value, key, target) => { + const camelKey = Array.isArray(target) ? key : snakeCase(key); + acc[camelKey] = isObject(value) ? covertToSnakeCase(value as Record) : value; + }); + export const convertArrayToCamelCase = (arrayOfSnakes: unknown[]): unknown[] => arrayOfSnakes.reduce((acc: unknown[], value) => { if (isArray(value)) { @@ -51,8 +57,8 @@ export const convertArrayToCamelCase = (arrayOfSnakes: unknown[]): unknown[] => } }, []); -export const convertToCamelCase = (snakeCase: T): U => - Object.entries(snakeCase).reduce((acc, [key, value]) => { +export const convertToCamelCase = (obj: T): U => + Object.entries(obj).reduce((acc, [key, value]) => { if (isArray(value)) { set(acc, camelCase(key), convertArrayToCamelCase(value)); } else if (isObject(value)) { @@ -64,7 +70,7 @@ export const convertToCamelCase = (snakeCase: T): U => }, {} as U); export const convertAllCasesToCamel = (snakeCases: CasesFindResponse): AllCases => ({ - cases: snakeCases.cases.map((snakeCase) => convertToCamelCase(snakeCase)), + cases: snakeCases.cases.map((theCase) => convertToCamelCase(theCase)), countOpenCases: snakeCases.count_open_cases, countInProgressCases: snakeCases.count_in_progress_cases, countClosedCases: snakeCases.count_closed_cases,