diff --git a/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts index 6194d6892d799..a45b1fd18a4b6 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts @@ -24,17 +24,16 @@ import { ALL_CASES_TAGS_COUNT, } from '../screens/all_cases'; import { - ACTION, CASE_DETAILS_DESCRIPTION, CASE_DETAILS_PAGE_TITLE, CASE_DETAILS_PUSH_TO_EXTERNAL_SERVICE_BTN, CASE_DETAILS_STATUS, CASE_DETAILS_TAGS, - CASE_DETAILS_USER_ACTION, + CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME, + CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT, CASE_DETAILS_USERNAMES, PARTICIPANTS, REPORTER, - USER, } from '../screens/case_details'; import { TIMELINE_DESCRIPTION, TIMELINE_QUERY, TIMELINE_TITLE } from '../screens/timeline'; @@ -84,8 +83,8 @@ describe('Cases', () => { const expectedTags = case1.tags.join(''); cy.get(CASE_DETAILS_PAGE_TITLE).should('have.text', case1.name); cy.get(CASE_DETAILS_STATUS).should('have.text', 'open'); - cy.get(CASE_DETAILS_USER_ACTION).eq(USER).should('have.text', case1.reporter); - cy.get(CASE_DETAILS_USER_ACTION).eq(ACTION).should('have.text', 'added description'); + cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME).should('have.text', case1.reporter); + cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT).should('have.text', 'added description'); cy.get(CASE_DETAILS_DESCRIPTION).should( 'have.text', `${case1.description} ${case1.timeline.title}` diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts index 6af4d174b9583..3862a89a7d833 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts @@ -11,7 +11,7 @@ import { addNewCase, selectCase, } from '../tasks/timeline'; -import { DESCRIPTION_INPUT } from '../screens/create_new_case'; +import { DESCRIPTION_INPUT, ADD_COMMENT_INPUT } from '../screens/create_new_case'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { caseTimeline, TIMELINE_CASE_ID } from '../objects/case'; @@ -34,7 +34,7 @@ describe('attach timeline to case', () => { cy.location('origin').then((origin) => { cy.get(DESCRIPTION_INPUT).should( 'have.text', - `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:'${caseTimeline.id}',isOpen:!t))` + `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:%27${caseTimeline.id}%27,isOpen:!t))` ); }); }); @@ -46,7 +46,7 @@ describe('attach timeline to case', () => { cy.location('origin').then((origin) => { cy.get(DESCRIPTION_INPUT).should( 'have.text', - `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:'${caseTimeline.id}',isOpen:!t))` + `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:%27${caseTimeline.id}%27,isOpen:!t))` ); }); }); @@ -66,9 +66,9 @@ describe('attach timeline to case', () => { selectCase(TIMELINE_CASE_ID); cy.location('origin').then((origin) => { - cy.get(DESCRIPTION_INPUT).should( + cy.get(ADD_COMMENT_INPUT).should( 'have.text', - `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:'${caseTimeline.id}',isOpen:!t))` + `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:%27${caseTimeline.id}%27,isOpen:!t))` ); }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/case_details.ts b/x-pack/plugins/security_solution/cypress/screens/case_details.ts index f2cdaa6994356..7b995f5395543 100644 --- a/x-pack/plugins/security_solution/cypress/screens/case_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/case_details.ts @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -export const ACTION = 2; - -export const CASE_DETAILS_DESCRIPTION = '[data-test-subj="markdown-root"]'; +export const CASE_DETAILS_DESCRIPTION = + '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]'; export const CASE_DETAILS_PAGE_TITLE = '[data-test-subj="header-page-title"]'; @@ -17,14 +16,17 @@ export const CASE_DETAILS_STATUS = '[data-test-subj="case-view-status"]'; export const CASE_DETAILS_TAGS = '[data-test-subj="case-tags"]'; -export const CASE_DETAILS_TIMELINE_LINK_MARKDOWN = '[data-test-subj="markdown-timeline-link"]'; +export const CASE_DETAILS_TIMELINE_LINK_MARKDOWN = + '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"] button'; + +export const CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT = + '[data-test-subj="description-action"] .euiCommentEvent__headerEvent'; -export const CASE_DETAILS_USER_ACTION = '[data-test-subj="user-action-title"] .euiFlexItem'; +export const CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME = + '[data-test-subj="description-action"] .euiCommentEvent__headerUsername'; export const CASE_DETAILS_USERNAMES = '[data-test-subj="case-view-username"]'; export const PARTICIPANTS = 1; export const REPORTER = 0; - -export const USER = 1; diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts index 9431c054d96a4..4f348b4dcdbd1 100644 --- a/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts +++ b/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +export const ADD_COMMENT_INPUT = '[data-test-subj="add-comment"] textarea'; + export const BACK_TO_CASES_BTN = '[data-test-subj="backToCases"]'; -export const DESCRIPTION_INPUT = '[data-test-subj="textAreaInput"]'; +export const DESCRIPTION_INPUT = '[data-test-subj="caseDescription"] textarea'; -export const INSERT_TIMELINE_BTN = '[data-test-subj="insert-timeline-button"]'; +export const INSERT_TIMELINE_BTN = '.euiMarkdownEditorToolbar [aria-label="Insert timeline link"]'; export const LOADING_SPINNER = '[data-test-subj="create-case-loading-spinner"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts index 1d5d240c5c53d..f5013eed07d29 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts @@ -13,7 +13,6 @@ import { INSERT_TIMELINE_BTN, LOADING_SPINNER, TAGS_INPUT, - TIMELINE, TIMELINE_SEARCHBOX, TITLE_INPUT, } from '../screens/create_new_case'; @@ -43,9 +42,6 @@ export const createNewCaseWithTimeline = (newCase: TestCase) => { cy.get(INSERT_TIMELINE_BTN).click({ force: true }); cy.get(TIMELINE_SEARCHBOX).type(`${newCase.timeline.title}{enter}`); - cy.get(TIMELINE).should('be.visible'); - cy.wait(300); - cy.get(TIMELINE).eq(0).click({ force: true }); cy.get(SUBMIT_BTN).click({ force: true }); cy.get(LOADING_SPINNER).should('exist'); diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx index ef13c87a92dbb..14c42697dcbb4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx @@ -11,14 +11,14 @@ import styled from 'styled-components'; import { CommentRequest } from '../../../../../case/common/api'; import { usePostComment } from '../../containers/use_post_comment'; import { Case } from '../../containers/types'; -import { MarkdownEditorForm } from '../../../common/components/markdown_editor/form'; +import { MarkdownEditorForm } from '../../../common/components/markdown_editor/eui_form'; import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover'; import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline'; import { Form, useForm, UseField, useFormData } from '../../../shared_imports'; import * as i18n from './translations'; import { schema } from './schema'; -import { useTimelineClick } from '../utils/use_timeline_click'; +import { useTimelineClick } from '../../../common/utils/timeline/use_timeline_click'; const MySpinner = styled(EuiLoadingSpinner)` position: absolute; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx index e1d7d98ba8c51..246df1c94b817 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx @@ -114,34 +114,41 @@ describe('CaseView ', () => { expect(wrapper.find(`[data-test-subj="case-view-title"]`).first().prop('title')).toEqual( data.title ); + expect(wrapper.find(`[data-test-subj="case-view-status"]`).first().text()).toEqual( data.status ); + expect( wrapper - .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="case-tag-coke"]`) + .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-coke"]`) .first() .text() ).toEqual(data.tags[0]); + expect( wrapper - .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="case-tag-pepsi"]`) + .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-pepsi"]`) .first() .text() ).toEqual(data.tags[1]); + expect(wrapper.find(`[data-test-subj="case-view-username"]`).first().text()).toEqual( data.createdBy.username ); + expect(wrapper.contains(`[data-test-subj="case-view-closedAt"]`)).toBe(false); + expect(wrapper.find(`[data-test-subj="case-view-createdAt"]`).first().prop('value')).toEqual( data.createdAt ); + expect( wrapper .find(`[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]`) .first() - .prop('raw') - ).toEqual(data.description); + .text() + ).toBe(data.description); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx index 3c3cc95218b03..a8babe729fde0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx @@ -31,10 +31,10 @@ import { schema } from './schema'; import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover'; import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline'; import * as i18n from '../../translations'; -import { MarkdownEditorForm } from '../../../common/components//markdown_editor/form'; +import { MarkdownEditorForm } from '../../../common/components/markdown_editor/eui_form'; import { useGetTags } from '../../containers/use_get_tags'; import { getCaseDetailsUrl } from '../../../common/components/link_to'; -import { useTimelineClick } from '../utils/use_timeline_click'; +import { useTimelineClick } from '../../../common/utils/timeline/use_timeline_click'; export const CommonUseField = getUseField({ component: Field }); diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx index 7c3fcde687033..a60167a18762f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx @@ -58,6 +58,7 @@ describe('TagList ', () => { fetchTags, })); }); + it('Renders no tags, and then edit', () => { const wrapper = mount( @@ -69,6 +70,7 @@ describe('TagList ', () => { expect(wrapper.find(`[data-test-subj="no-tags"]`).last().exists()).toBeFalsy(); expect(wrapper.find(`[data-test-subj="edit-tags"]`).last().exists()).toBeTruthy(); }); + it('Edit tag on submit', async () => { const wrapper = mount( @@ -81,6 +83,7 @@ describe('TagList ', () => { await waitFor(() => expect(onSubmit).toBeCalledWith(sampleTags)); }); }); + it('Tag options render with new tags added', () => { const wrapper = mount( @@ -92,6 +95,7 @@ describe('TagList ', () => { wrapper.find(`[data-test-subj="caseTags"] [data-test-subj="input"]`).first().prop('options') ).toEqual([{ label: 'coke' }, { label: 'pepsi' }, { label: 'rad' }, { label: 'dude' }]); }); + it('Cancels on cancel', async () => { const props = { ...defaultProps, @@ -102,17 +106,19 @@ describe('TagList ', () => { ); - expect(wrapper.find(`[data-test-subj="case-tag-pepsi"]`).last().exists()).toBeTruthy(); + + expect(wrapper.find(`[data-test-subj="tag-pepsi"]`).last().exists()).toBeTruthy(); wrapper.find(`[data-test-subj="tag-list-edit-button"]`).last().simulate('click'); await act(async () => { - expect(wrapper.find(`[data-test-subj="case-tag-pepsi"]`).last().exists()).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="tag-pepsi"]`).last().exists()).toBeFalsy(); wrapper.find(`[data-test-subj="edit-tags-cancel"]`).last().simulate('click'); await waitFor(() => { wrapper.update(); - expect(wrapper.find(`[data-test-subj="case-tag-pepsi"]`).last().exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="tag-pepsi"]`).last().exists()).toBeTruthy(); }); }); }); + it('Renders disabled button', () => { const props = { ...defaultProps, disabled: true }; const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx index eeb7c49eceab5..4af781e3c31f4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx @@ -10,8 +10,6 @@ import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, - EuiBadgeGroup, - EuiBadge, EuiButton, EuiButtonEmpty, EuiButtonIcon, @@ -25,6 +23,8 @@ import { schema } from './schema'; import { CommonUseField } from '../create'; import { useGetTags } from '../../containers/use_get_tags'; +import { Tags } from './tags'; + interface TagListProps { disabled?: boolean; isLoading: boolean; @@ -99,15 +99,7 @@ export const TagList = React.memo( {tags.length === 0 && !isEditTags &&

{i18n.NO_TAGS}

} - - {tags.length > 0 && - !isEditTags && - tags.map((tag) => ( - - {tag} - - ))} - + {!isEditTags && } {isEditTags && ( diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/tags.tsx b/x-pack/plugins/security_solution/public/cases/components/tag_list/tags.tsx new file mode 100644 index 0000000000000..e257563ce751e --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/tag_list/tags.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiBadgeGroup, EuiBadge, EuiBadgeGroupProps } from '@elastic/eui'; + +interface TagsProps { + tags: string[]; + color?: string; + gutterSize?: EuiBadgeGroupProps['gutterSize']; +} + +const TagsComponent: React.FC = ({ tags, color = 'default', gutterSize }) => { + return ( + <> + {tags.length > 0 && ( + + {tags.map((tag) => ( + + {tag} + + ))} + + )} + + ); +}; + +export const Tags = memo(TagsComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx index b5be84db59920..4e5c05f2f1404 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx @@ -14,7 +14,7 @@ import { connectorsMock } from '../../containers/configure/mock'; describe('User action tree helpers', () => { const connectors = connectorsMock; it('label title generated for update tags', () => { - const action = getUserAction(['title'], 'update'); + const action = getUserAction(['tags'], 'update'); const result: string | JSX.Element = getLabelTitle({ action, connectors, @@ -27,8 +27,11 @@ describe('User action tree helpers', () => { ` ${i18n.TAGS.toLowerCase()}` ); - expect(wrapper.find(`[data-test-subj="ua-tag"]`).first().text()).toEqual(action.newValue); + expect(wrapper.find(`[data-test-subj="tag-${action.newValue}"]`).first().text()).toEqual( + action.newValue + ); }); + it('label title generated for update title', () => { const action = getUserAction(['title'], 'update'); const result: string | JSX.Element = getLabelTitle({ @@ -44,6 +47,7 @@ describe('User action tree helpers', () => { }"` ); }); + it('label title generated for update description', () => { const action = getUserAction(['description'], 'update'); const result: string | JSX.Element = getLabelTitle({ @@ -55,6 +59,7 @@ describe('User action tree helpers', () => { expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`); }); + it('label title generated for update status to open', () => { const action = { ...getUserAction(['status'], 'update'), newValue: 'open' }; const result: string | JSX.Element = getLabelTitle({ @@ -66,6 +71,7 @@ describe('User action tree helpers', () => { expect(result).toEqual(`${i18n.REOPENED_CASE.toLowerCase()} ${i18n.CASE}`); }); + it('label title generated for update status to closed', () => { const action = { ...getUserAction(['status'], 'update'), newValue: 'closed' }; const result: string | JSX.Element = getLabelTitle({ @@ -77,6 +83,7 @@ describe('User action tree helpers', () => { expect(result).toEqual(`${i18n.CLOSED_CASE.toLowerCase()} ${i18n.CASE}`); }); + it('label title generated for update comment', () => { const action = getUserAction(['comment'], 'update'); const result: string | JSX.Element = getLabelTitle({ @@ -88,6 +95,7 @@ describe('User action tree helpers', () => { expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`); }); + it('label title generated for pushed incident', () => { const action = getUserAction(['pushed'], 'push-to-service'); const result: string | JSX.Element = getLabelTitle({ @@ -105,6 +113,7 @@ describe('User action tree helpers', () => { JSON.parse(action.newValue).external_url ); }); + it('label title generated for needs update incident', () => { const action = getUserAction(['pushed'], 'push-to-service'); const result: string | JSX.Element = getLabelTitle({ @@ -122,6 +131,7 @@ describe('User action tree helpers', () => { JSON.parse(action.newValue).external_url ); }); + it('label title generated for update connector', () => { const action = getUserAction(['connector_id'], 'update'); const result: string | JSX.Element = getLabelTitle({ @@ -136,6 +146,8 @@ describe('User action tree helpers', () => { ` ${i18n.TAGS.toLowerCase()}` ); - expect(wrapper.find(`[data-test-subj="ua-tag"]`).first().text()).toEqual(action.newValue); + expect(wrapper.find(`[data-test-subj="tag-${action.newValue}"]`).first().text()).toEqual( + action.newValue + ); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx index e343c3da6cc8b..4d8bb9ba078e5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx @@ -4,12 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiBadgeGroup, EuiBadge, EuiLink } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; import React from 'react'; import { CaseFullExternalService, Connector } from '../../../../../case/common/api'; import { CaseUserActions } from '../../containers/types'; +import { CaseServices } from '../../containers/use_get_case_user_actions'; import * as i18n from '../case_view/translations'; +import { Tags } from '../tag_list/tags'; interface LabelTitle { action: CaseUserActions; @@ -44,22 +46,21 @@ export const getLabelTitle = ({ action, connectors, field, firstPush }: LabelTit return ''; }; -const getTagsLabelTitle = (action: CaseUserActions) => ( - - - {action.action === 'add' && i18n.ADDED_FIELD} - {action.action === 'delete' && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()} - - - {action.newValue != null && - action.newValue.split(',').map((tag) => ( - - {tag} - - ))} - - -); +const getTagsLabelTitle = (action: CaseUserActions) => { + const tags = action.newValue != null ? action.newValue.split(',') : []; + + return ( + + + {action.action === 'add' && i18n.ADDED_FIELD} + {action.action === 'delete' && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()} + + + + + + ); +}; const getPushedServiceLabelTitle = (action: CaseUserActions, firstPush: boolean) => { const pushedVal = JSON.parse(action.newValue ?? '') as CaseFullExternalService; @@ -78,3 +79,20 @@ const getPushedServiceLabelTitle = (action: CaseUserActions, firstPush: boolean) ); }; + +export const getPushInfo = ( + caseServices: CaseServices, + parsedValue: { connector_id: string; connector_name: string }, + index: number +) => + parsedValue != null + ? { + firstPush: caseServices[parsedValue.connector_id].firstPushIndex === index, + parsedConnectorId: parsedValue.connector_id, + parsedConnectorName: parsedValue.connector_name, + } + : { + firstPush: false, + parsedConnectorId: 'none', + parsedConnectorName: 'none', + }; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx index d67c364bbda10..d2bb2fb243458 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx @@ -6,6 +6,9 @@ import React from 'react'; import { mount } from 'enzyme'; +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router'; import { getFormMock, useFormMock, useFormDataMock } from '../__mock__/form'; @@ -13,9 +16,6 @@ import { useUpdateComment } from '../../containers/use_update_comment'; import { basicCase, basicPush, getUserAction } from '../../containers/mock'; import { UserActionTree } from '.'; import { TestProviders } from '../../../common/mock'; -// we don't have the types for waitFor just yet, so using "as waitFor" until when we do -import { wait as waitFor } from '@testing-library/react'; -import { act } from 'react-dom/test-utils'; const fetchUserActions = jest.fn(); const onUpdateField = jest.fn(); @@ -66,9 +66,10 @@ describe('UserActionTree ', () => { expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().prop('name')).toEqual( defaultProps.data.createdBy.fullName ); - expect(wrapper.find(`[data-test-subj="user-action-title"] strong`).first().text()).toEqual( - defaultProps.data.createdBy.username - ); + + expect( + wrapper.find(`[data-test-subj="description-action"] figcaption strong`).first().text() + ).toEqual(defaultProps.data.createdBy.username); }); it('Renders service now update line with top and bottom when push is required', async () => { @@ -76,6 +77,7 @@ describe('UserActionTree ', () => { getUserAction(['pushed'], 'push-to-service'), getUserAction(['comment'], 'update'), ]; + const props = { ...defaultProps, caseServices: { @@ -90,20 +92,18 @@ describe('UserActionTree ', () => { caseUserActions: ourActions, }; - const wrapper = mount( - - - - - - ); - await act(async () => { - wrapper.update(); + const wrapper = mount( + + + + + + ); + + expect(wrapper.find(`[data-test-subj="top-footer"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="bottom-footer"]`).exists()).toBeTruthy(); }); - - expect(wrapper.find(`[data-test-subj="show-top-footer"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="show-bottom-footer"]`).exists()).toBeTruthy(); }); it('Renders service now update line with top only when push is up to date', async () => { @@ -122,20 +122,17 @@ describe('UserActionTree ', () => { }, }; - const wrapper = mount( - - - - - - ); - await act(async () => { - wrapper.update(); + const wrapper = mount( + + + + + + ); + expect(wrapper.find(`[data-test-subj="top-footer"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="bottom-footer"]`).exists()).toBeFalsy(); }); - - expect(wrapper.find(`[data-test-subj="show-top-footer"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="show-bottom-footer"]`).exists()).toBeFalsy(); }); it('Outlines comment when update move to link is clicked', async () => { @@ -145,89 +142,104 @@ describe('UserActionTree ', () => { caseUserActions: ourActions, }; - const wrapper = mount( - - - - - - ); - await act(async () => { - wrapper.update(); - }); + const wrapper = mount( + + + + + + ); + + expect( + wrapper + .find(`[data-test-subj="comment-create-action-${props.data.comments[0].id}"]`) + .first() + .hasClass('outlined') + ).toBeFalsy(); - expect( - wrapper.find(`[data-test-subj="comment-create-action"]`).first().prop('idToOutline') - ).toEqual(''); - wrapper - .find(`[data-test-subj="comment-update-action"] [data-test-subj="move-to-link"]`) - .first() - .simulate('click'); - expect( - wrapper.find(`[data-test-subj="comment-create-action"]`).first().prop('idToOutline') - ).toEqual(ourActions[0].commentId); + wrapper + .find( + `[data-test-subj="comment-update-action-${ourActions[1].actionId}"] [data-test-subj="move-to-link-${props.data.comments[0].id}"]` + ) + .first() + .simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect( + wrapper + .find(`[data-test-subj="comment-create-action-${props.data.comments[0].id}"]`) + .first() + .hasClass('outlined') + ).toBeTruthy(); + }); + }); }); it('Switches to markdown when edit is clicked and back to panel when canceled', async () => { - const ourActions = [getUserAction(['comment'], 'create')]; - const props = { - ...defaultProps, - caseUserActions: ourActions, - }; - - const wrapper = mount( - - - - - - ); - - await act(async () => { - wrapper.update(); - }); + await waitFor(() => { + const ourActions = [getUserAction(['comment'], 'create')]; + const props = { + ...defaultProps, + caseUserActions: ourActions, + }; + + const wrapper = mount( + + + + + + ); + + expect( + wrapper + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + ) + .exists() + ).toEqual(false); - expect( wrapper .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-ellipses"]` ) - .exists() - ).toEqual(false); + .first() + .simulate('click'); - wrapper - .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-ellipses"]`) - .first() - .simulate('click'); - - wrapper - .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-pencil"]`) - .first() - .simulate('click'); + wrapper.update(); - expect( wrapper .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-pencil"]` ) - .exists() - ).toEqual(true); + .first() + .simulate('click'); - wrapper - .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-cancel-markdown"]` - ) - .first() - .simulate('click'); + expect( + wrapper + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + ) + .exists() + ).toEqual(true); - expect( wrapper .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-cancel-markdown"]` ) - .exists() - ).toEqual(false); + .first() + .simulate('click'); + + expect( + wrapper + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + ) + .exists() + ).toEqual(false); + }); }); it('calls update comment when comment markdown is saved', async () => { @@ -236,6 +248,7 @@ describe('UserActionTree ', () => { ...defaultProps, caseUserActions: ourActions, }; + const wrapper = mount( @@ -243,27 +256,35 @@ describe('UserActionTree ', () => { ); + wrapper - .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-ellipses"]`) + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-ellipses"]` + ) .first() .simulate('click'); + wrapper - .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-pencil"]`) + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-pencil"]` + ) .first() .simulate('click'); + wrapper .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-save-markdown"]` + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-save-markdown"]` ) .first() .simulate('click'); + await act(async () => { await waitFor(() => { wrapper.update(); expect( wrapper .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` ) .exists() ).toEqual(false); @@ -288,93 +309,101 @@ describe('UserActionTree ', () => {
); + wrapper .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]`) .first() .simulate('click'); + wrapper .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-pencil"]`) .first() .simulate('click'); - wrapper - .find( - `[data-test-subj="user-action-description"] [data-test-subj="user-action-save-markdown"]` - ) - .first() - .simulate('click'); + await act(async () => { - await waitFor(() => { - expect( - wrapper - .find( - `[data-test-subj="user-action-${props.data.id}"] [data-test-subj="user-action-markdown-form"]` - ) - .exists() - ).toEqual(false); - expect(onUpdateField).toBeCalledWith({ key: 'description', value: sampleData.content }); - }); + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="user-action-save-markdown"]`) + .first() + .simulate('click'); }); + + wrapper.update(); + + expect( + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="user-action-markdown-form"]`) + .exists() + ).toEqual(false); + + expect(onUpdateField).toBeCalledWith({ key: 'description', value: sampleData.content }); }); it('quotes', async () => { - const commentData = { - comment: '', - }; - const formHookMock = getFormMock(commentData); - const setFieldValue = jest.fn(); - useFormMock.mockImplementation(() => ({ form: { ...formHookMock, setFieldValue } })); - const props = defaultProps; - const wrapper = mount( - - - - - - ); - await act(async () => { + const commentData = { + comment: '', + }; + const setFieldValue = jest.fn(); + + const formHookMock = getFormMock(commentData); + useFormMock.mockImplementation(() => ({ form: { ...formHookMock, setFieldValue } })); + + const props = defaultProps; + const wrapper = mount( + + + + + + ); + + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]`) + .first() + .simulate('click'); + await waitFor(() => { - wrapper - .find( - `[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]` - ) - .first() - .simulate('click'); wrapper.update(); }); - }); - wrapper - .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-quote"]`) - .first() - .simulate('click'); + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-quote"]`) + .first() + .simulate('click'); - expect(setFieldValue).toBeCalledWith('comment', `> ${props.data.description} \n`); + expect(setFieldValue).toBeCalledWith('comment', `> ${props.data.description} \n`); + }); }); it('Outlines comment when url param is provided', async () => { - const commentId = 'neat-comment-id'; - const ourActions = [getUserAction(['comment'], 'create')]; - const props = { - ...defaultProps, - caseUserActions: ourActions, - }; - + const commentId = 'basic-comment-id'; jest.spyOn(routeData, 'useParams').mockReturnValue({ commentId }); - const wrapper = mount( - - - - - - ); await act(async () => { - wrapper.update(); - }); + const ourActions = [getUserAction(['comment'], 'create')]; + const props = { + ...defaultProps, + caseUserActions: ourActions, + }; + + const wrapper = mount( + + + + + + ); - expect( - wrapper.find(`[data-test-subj="comment-create-action"]`).first().prop('idToOutline') - ).toEqual(commentId); + await waitFor(() => { + wrapper.update(); + }); + + expect( + wrapper + .find(`[data-test-subj="comment-create-action-${commentId}"]`) + .first() + .hasClass('outlined') + ).toBeTruthy(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx index d1263ab13f41b..bada15294de09 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx @@ -3,25 +3,38 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import classNames from 'classnames'; -import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiCommentList, + EuiCommentProps, +} from '@elastic/eui'; import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; import styled from 'styled-components'; -import * as i18n from '../case_view/translations'; +import * as i18n from './translations'; import { Case, CaseUserActions } from '../../containers/types'; import { useUpdateComment } from '../../containers/use_update_comment'; import { useCurrentUser } from '../../../common/lib/kibana'; import { AddComment, AddCommentRefObject } from '../add_comment'; -import { getLabelTitle } from './helpers'; -import { UserActionItem } from './user_action_item'; -import { UserActionMarkdown } from './user_action_markdown'; import { Connector } from '../../../../../case/common/api/cases'; import { CaseServices } from '../../containers/use_get_case_user_actions'; import { parseString } from '../../containers/utils'; import { OnUpdateFields } from '../case_view'; +import { getLabelTitle, getPushInfo } from './helpers'; +import { UserActionAvatar } from './user_action_avatar'; +import { UserActionMarkdown } from './user_action_markdown'; +import { UserActionTimestamp } from './user_action_timestamp'; +import { UserActionCopyLink } from './user_action_copy_link'; +import { UserActionMoveToReference } from './user_action_move_to_reference'; +import { UserActionUsername } from './user_action_username'; +import { UserActionUsernameWithAvatar } from './user_action_username_with_avatar'; +import { UserActionContentToolbar } from './user_action_content_toolbar'; export interface UserActionTreeProps { caseServices: CaseServices; @@ -40,6 +53,31 @@ 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; + } + } + `} +`; + const DESCRIPTION_ID = 'description'; const NEW_ID = 'newComment'; @@ -86,8 +124,7 @@ export const UserActionTree = React.memo( updateCase, }); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [caseData, handleManageMarkdownEditId, patchComment, updateCase] + [caseData.id, fetchUserActions, patchComment, updateCase] ); const handleOutlineComment = useCallback( @@ -172,117 +209,246 @@ export const UserActionTree = React.memo( } } }, [commentId, initLoading, isLoadingUserActions, isLoadingIds, handleOutlineComment]); - return ( - <> - {i18n.ADDED_DESCRIPTION}} - fullName={caseData.createdBy.fullName ?? caseData.createdBy.username ?? ''} - markdown={MarkdownDescription} - onEdit={handleManageMarkdownEditId.bind(null, DESCRIPTION_ID)} - onQuote={handleManageQuote.bind(null, caseData.description)} - username={caseData.createdBy.username ?? i18n.UNKNOWN} - /> - {caseUserActions.map((action, index) => { - if (action.commentId != null && action.action === 'create') { - const comment = caseData.comments.find((c) => c.id === action.commentId); - if (comment != null) { - return ( - {i18n.ADDED_COMMENT}} - fullName={comment.createdBy.fullName ?? comment.createdBy.username ?? ''} - markdown={ - - } - onEdit={handleManageMarkdownEditId.bind(null, comment.id)} - onQuote={handleManageQuote.bind(null, comment.comment)} - outlineComment={handleOutlineComment} - username={comment.createdBy.username ?? ''} - updatedAt={comment.updatedAt} - /> + 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( + (comments, action, index) => { + if (action.commentId != null && action.action === 'create') { + const comment = caseData.comments.find((c) => c.id === action.commentId); + if (comment != null) { + 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: ( + + ), + timelineIcon: ( + + ), + actions: ( + + ), + }, + ]; + } + } + + if (action.actionField.length === 1) { + const myField = action.actionField[0]; + const parsedValue = parseString(`${action.newValue}`); + const { firstPush, parsedConnectorId, parsedConnectorName } = getPushInfo( + caseServices, + parsedValue, + index ); + + const labelTitle: string | JSX.Element = getLabelTitle({ + action, + field: myField, + firstPush, + connectors, + }); + + const showTopFooter = + action.action === 'push-to-service' && + index === caseServices[parsedConnectorId].lastPushIndex; + + const showBottomFooter = + action.action === '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, + { + username: ( + + ), + type: 'update', + event: labelTitle, + 'data-test-subj': `${action.actionField[0]}-${action.action}-action-${action.actionId}`, + timestamp: , + timelineIcon: + action.action === 'add' || action.action === 'delete' ? 'tag' : 'dot', + actions: ( + + + + + {action.action === 'update' && action.commentId != null && ( + + + + )} + + ), + }, + ...footers, + ]; } - } - if (action.actionField.length === 1) { - const myField = action.actionField[0]; - const parsedValue = parseString(`${action.newValue}`); - const { firstPush, parsedConnectorId, parsedConnectorName } = - parsedValue != null - ? { - firstPush: caseServices[parsedValue.connector_id].firstPushIndex === index, - parsedConnectorId: parsedValue.connector_id, - parsedConnectorName: parsedValue.connector_name, - } - : { - firstPush: false, - parsedConnectorId: 'none', - parsedConnectorName: 'none', - }; - const labelTitle: string | JSX.Element = getLabelTitle({ - action, - field: myField, - firstPush, - connectors, - }); - - return ( - {labelTitle}} - linkId={ - action.action === 'update' && action.commentId != null ? action.commentId : null - } - fullName={action.actionBy.fullName ?? action.actionBy.username ?? ''} - outlineComment={handleOutlineComment} - showTopFooter={ - action.action === 'push-to-service' && - index === caseServices[parsedConnectorId].lastPushIndex - } - showBottomFooter={ - action.action === 'push-to-service' && - index === caseServices[parsedConnectorId].lastPushIndex && - caseServices[parsedConnectorId].hasDataToPush - } - username={action.actionBy.username ?? ''} - /> - ); - } - return null; - })} + + return comments; + }, + [descriptionCommentListObj] + ), + [ + caseData, + caseServices, + caseUserActions, + connectors, + handleOutlineComment, + descriptionCommentListObj, + handleManageMarkdownEditId, + handleManageQuote, + handleSaveComment, + isLoadingIds, + manageMarkdownEditIds, + selectedOutlineCommentId, + userCanCrud, + ] + ); + + const bottomActions = [ + { + username: ( + + ), + 'data-test-subj': 'add-comment', + timelineIcon: ( + + ), + className: 'isEdit', + children: MarkdownNewComment, + }, + ]; + + const comments = [...userActions, ...bottomActions]; + + return ( + <> + {(isLoadingUserActions || isLoadingIds.includes(NEW_ID)) && ( @@ -290,17 +456,6 @@ export const UserActionTree = React.memo( )} - ); } diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx new file mode 100644 index 0000000000000..df5c51394b88a --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionAvatar } from './user_action_avatar'; + +const props = { + username: 'elastic', + fullName: 'Elastic', +}; + +describe('UserActionAvatar ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().exists()).toBeTruthy(); + expect( + wrapper.find(`[data-test-subj="user-action-avatar-loading-spinner"]`).first().exists() + ).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().text()).toBe('E'); + }); + + it('it shows the username if the fullName is undefined', async () => { + wrapper = mount(); + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().exists()).toBeTruthy(); + expect( + wrapper.find(`[data-test-subj="user-action-avatar-loading-spinner"]`).first().exists() + ).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().text()).toBe('e'); + }); + + it('shows the loading spinner when the username AND the fullName are undefined', async () => { + wrapper = mount(); + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().exists()).toBeFalsy(); + expect( + wrapper.find(`[data-test-subj="user-action-avatar-loading-spinner"]`).first().exists() + ).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx index f3276bd50e72c..8339d9bedd123 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx @@ -4,15 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiAvatar } from '@elastic/eui'; -import React from 'react'; +import React, { memo } from 'react'; +import { EuiAvatar, EuiLoadingSpinner } from '@elastic/eui'; interface UserActionAvatarProps { - name: string; + username?: string | null; + fullName?: string | null; } -export const UserActionAvatar = ({ name }: UserActionAvatarProps) => { +const UserActionAvatarComponent = ({ username, fullName }: UserActionAvatarProps) => { + const avatarName = fullName && fullName.length > 0 ? fullName : username ?? null; + return ( - + <> + {avatarName ? ( + + ) : ( + + )} + ); }; + +export const UserActionAvatar = memo(UserActionAvatarComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.test.tsx new file mode 100644 index 0000000000000..1f4c858e9581e --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.test.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionContentToolbar } from './user_action_content_toolbar'; + +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); + + return { + ...originalModule, + useParams: jest.fn().mockReturnValue({ detailName: 'case-1' }), + }; +}); + +jest.mock('../../../common/components/navigation/use_get_url_search'); + +jest.mock('../../../common/lib/kibana', () => { + return { + useKibana: () => ({ + services: { + application: { + getUrlForApp: jest.fn(), + }, + }, + }), + }; +}); + +const props = { + id: '1', + editLabel: 'edit', + quoteLabel: 'quote', + disabled: false, + isLoading: false, + onEdit: jest.fn(), + onQuote: jest.fn(), +}; + +describe('UserActionContentToolbar ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect(wrapper.find(`[data-test-subj="copy-link-${props.id}"]`).first().exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="property-actions"]').first().exists()).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.tsx new file mode 100644 index 0000000000000..89239c9e8392c --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { UserActionCopyLink } from './user_action_copy_link'; +import { UserActionPropertyActions } from './user_action_property_actions'; + +interface UserActionContentToolbarProps { + id: string; + editLabel: string; + quoteLabel: string; + disabled: boolean; + isLoading: boolean; + onEdit: (id: string) => void; + onQuote: (id: string) => void; +} + +const UserActionContentToolbarComponent = ({ + id, + editLabel, + quoteLabel, + disabled, + isLoading, + onEdit, + onQuote, +}: UserActionContentToolbarProps) => { + return ( + + + + + + + + + ); +}; + +export const UserActionContentToolbar = memo(UserActionContentToolbarComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.test.tsx new file mode 100644 index 0000000000000..0566281dac130 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.test.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { useParams } from 'react-router-dom'; +import copy from 'copy-to-clipboard'; + +import { TestProviders } from '../../../common/mock'; +import { UserActionCopyLink } from './user_action_copy_link'; +import { useGetUrlSearch } from '../../../common/components/navigation/use_get_url_search'; + +const searchURL = + '?timerange=(global:(linkTo:!(),timerange:(from:1585487656371,fromStr:now-24h,kind:relative,to:1585574056371,toStr:now)),timeline:(linkTo:!(),timerange:(from:1585227005527,kind:absolute,to:1585313405527)))'; + +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); + + return { + ...originalModule, + useParams: jest.fn(), + }; +}); + +jest.mock('copy-to-clipboard', () => { + return jest.fn(); +}); + +jest.mock('../../../common/components/navigation/use_get_url_search'); + +const mockGetUrlForApp = jest.fn( + (appId: string, options?: { path?: string; absolute?: boolean }) => + `${appId}${options?.path ?? ''}` +); + +jest.mock('../../../common/lib/kibana', () => { + return { + useKibana: () => ({ + services: { + application: { + getUrlForApp: mockGetUrlForApp, + }, + }, + }), + }; +}); + +const props = { + id: 'comment-id', +}; + +describe('UserActionCopyLink ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + (useParams as jest.Mock).mockReturnValue({ detailName: 'case-1' }); + (useGetUrlSearch as jest.Mock).mockReturnValue(searchURL); + wrapper = mount(, { wrappingComponent: TestProviders }); + }); + + it('it renders', async () => { + expect(wrapper.find(`[data-test-subj="copy-link-${props.id}"]`).first().exists()).toBeTruthy(); + }); + + it('calls copy clipboard correctly', async () => { + wrapper.find(`[data-test-subj="copy-link-${props.id}"]`).first().simulate('click'); + expect(copy).toHaveBeenCalledWith( + 'securitySolution:case/case-1/comment-id?timerange=(global:(linkTo:!(),timerange:(from:1585487656371,fromStr:now-24h,kind:relative,to:1585574056371,toStr:now)),timeline:(linkTo:!(),timerange:(from:1585227005527,kind:absolute,to:1585313405527)))' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.tsx new file mode 100644 index 0000000000000..98de2ab3288a8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useCallback } from 'react'; +import { EuiToolTip, EuiButtonIcon } from '@elastic/eui'; +import { useParams } from 'react-router-dom'; +import copy from 'copy-to-clipboard'; + +import { useFormatUrl, getCaseDetailsUrlWithCommentId } from '../../../common/components/link_to'; +import { SecurityPageName } from '../../../app/types'; +import * as i18n from './translations'; + +interface UserActionCopyLinkProps { + id: string; +} + +const UserActionCopyLinkComponent = ({ id }: UserActionCopyLinkProps) => { + const { detailName: caseId } = useParams<{ detailName: string }>(); + const { formatUrl } = useFormatUrl(SecurityPageName.case); + + const handleAnchorLink = useCallback(() => { + copy( + formatUrl(getCaseDetailsUrlWithCommentId({ id: caseId, commentId: id }), { absolute: true }) + ); + }, [caseId, formatUrl, id]); + + return ( + {i18n.COPY_REFERENCE_LINK}

}> + +
+ ); +}; + +export const UserActionCopyLink = memo(UserActionCopyLinkComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_item.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_item.tsx deleted file mode 100644 index eeb728aa7d1df..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_item.tsx +++ /dev/null @@ -1,197 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, - EuiPanel, - EuiHorizontalRule, - EuiText, -} from '@elastic/eui'; -import React from 'react'; -import styled, { css } from 'styled-components'; - -import { UserActionAvatar } from './user_action_avatar'; -import { UserActionTitle } from './user_action_title'; -import * as i18n from './translations'; - -interface UserActionItemProps { - caseConnectorName?: string; - createdAt: string; - 'data-test-subj'?: string; - disabled: boolean; - id: string; - isEditable: boolean; - isLoading: boolean; - labelEditAction?: string; - labelQuoteAction?: string; - labelTitle?: JSX.Element; - linkId?: string | null; - fullName?: string | null; - markdown?: React.ReactNode; - onEdit?: (id: string) => void; - onQuote?: (id: string) => void; - username: string; - updatedAt?: string | null; - outlineComment?: (id: string) => void; - showBottomFooter?: boolean; - showTopFooter?: boolean; - idToOutline?: string | null; -} - -export const UserActionItemContainer = styled(EuiFlexGroup)` - ${({ theme }) => css` - & { - background-image: linear-gradient( - to right, - transparent 0, - transparent 15px, - ${theme.eui.euiBorderColor} 15px, - ${theme.eui.euiBorderColor} 17px, - transparent 17px, - transparent 100% - ); - background-repeat: no-repeat; - background-position: left ${theme.eui.euiSizeXXL}; - margin-bottom: ${theme.eui.euiSizeS}; - } - .userAction__panel { - margin-bottom: ${theme.eui.euiSize}; - } - .userAction__circle { - flex-shrink: 0; - margin-right: ${theme.eui.euiSize}; - vertical-align: top; - } - .userAction_loadingAvatar { - position: relative; - margin-right: ${theme.eui.euiSizeXL}; - top: ${theme.eui.euiSizeM}; - left: ${theme.eui.euiSizeS}; - } - .userAction__title { - padding: ${theme.eui.euiSizeS} ${theme.eui.euiSizeL}; - background: ${theme.eui.euiColorLightestShade}; - border-bottom: ${theme.eui.euiBorderThin}; - border-radius: ${theme.eui.euiBorderRadius} ${theme.eui.euiBorderRadius} 0 0; - } - .euiText--small * { - margin-bottom: 0; - } - `} -`; - -const MyEuiPanel = styled(EuiPanel)<{ showoutline: string }>` - flex-grow: 0; - ${({ theme, showoutline }) => - showoutline === 'true' - ? ` - outline: solid 5px ${theme.eui.euiColorVis1_behindText}; - margin: 0.5em; - transition: 0.8s; - ` - : ''} -`; - -const PushedContainer = styled(EuiFlexItem)` - ${({ theme }) => ` - margin-top: ${theme.eui.euiSizeS}; - margin-bottom: ${theme.eui.euiSizeXL}; - hr { - margin: 5px; - height: ${theme.eui.euiBorderWidthThick}; - } - `} -`; - -const PushedInfoContainer = styled.div` - margin-left: 48px; -`; - -export const UserActionItem = ({ - caseConnectorName, - createdAt, - disabled, - 'data-test-subj': dataTestSubj, - id, - idToOutline, - isEditable, - isLoading, - labelEditAction, - labelQuoteAction, - labelTitle, - linkId, - fullName, - markdown, - onEdit, - onQuote, - outlineComment, - showBottomFooter, - showTopFooter, - username, - updatedAt, -}: UserActionItemProps) => ( - - - - - {(fullName && fullName.length > 0) || (username && username.length > 0) ? ( - 0 ? fullName : username ?? ''} /> - ) : ( - - )} - - - {isEditable && markdown} - {!isEditable && ( - - } - linkId={linkId} - onEdit={onEdit} - onQuote={onQuote} - outlineComment={outlineComment} - updatedAt={updatedAt} - username={username} - /> - {markdown} - - )} - - - - {showTopFooter && ( - - - - {i18n.ALREADY_PUSHED_TO_SERVICE(`${caseConnectorName}`)} - - - - {showBottomFooter && ( - - - {i18n.REQUIRED_UPDATE_TO_SERVICE(`${caseConnectorName}`)} - - - )} - - )} - -); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.test.tsx index 6cf827ea55f1f..f1f7d40009045 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.test.tsx @@ -17,8 +17,9 @@ const onChangeEditable = jest.fn(); const onSaveContent = jest.fn(); const timelineId = '1e10f150-949b-11ea-b63c-2bc51864784c'; +const timelineMarkdown = `[timeline](http://localhost:5601/app/security/timelines?timeline=(id:'${timelineId}',isOpen:!t))`; const defaultProps = { - content: `A link to a timeline [timeline](http://localhost:5601/app/security/timelines?timeline=(id:'${timelineId}',isOpen:!t))`, + content: `A link to a timeline ${timelineMarkdown}`, id: 'markdown-id', isEditable: false, onChangeEditable, @@ -40,7 +41,11 @@ describe('UserActionMarkdown ', () => {
); - wrapper.find(`[data-test-subj="markdown-timeline-link"]`).first().simulate('click'); + + wrapper + .find(`[data-test-subj="markdown-timeline-link-${timelineId}"]`) + .first() + .simulate('click'); expect(queryTimelineByIdSpy).toBeCalledWith({ apolloClient: mockUseApolloClient(), @@ -59,8 +64,19 @@ describe('UserActionMarkdown ', () => { ); - wrapper.find(`[data-test-subj="preview-tab"]`).first().simulate('click'); - wrapper.find(`[data-test-subj="markdown-timeline-link"]`).first().simulate('click'); + + // Preview button of Markdown editor + wrapper + .find( + `[data-test-subj="user-action-markdown-form"] .euiMarkdownEditorToolbar .euiButtonEmpty` + ) + .first() + .simulate('click'); + + wrapper + .find(`[data-test-subj="markdown-timeline-link-${timelineId}"]`) + .first() + .simulate('click'); expect(queryTimelineByIdSpy).toBeCalledWith({ apolloClient: mockUseApolloClient(), graphEventId: '', diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx index ac2ad179ec60c..45e46b2d7d2db 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx @@ -4,18 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, + EuiMarkdownFormat, +} from '@elastic/eui'; import React, { useCallback } from 'react'; import styled from 'styled-components'; import * as i18n from '../case_view/translations'; -import { Markdown } from '../../../common/components/markdown'; -import { Form, useForm, UseField, useFormData } from '../../../shared_imports'; +import { Form, useForm, UseField } from '../../../shared_imports'; import { schema, Content } from './schema'; -import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover'; -import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline'; -import { MarkdownEditorForm } from '../../../common/components//markdown_editor/form'; -import { useTimelineClick } from '../utils/use_timeline_click'; +import { + MarkdownEditorForm, + parsingPlugins, + processingPlugins, +} from '../../../common/components/markdown_editor/eui_form'; const ContentWrapper = styled.div` padding: ${({ theme }) => `${theme.eui.euiSizeM} ${theme.eui.euiSizeL}`}; @@ -43,24 +49,12 @@ export const UserActionMarkdown = ({ }); const fieldName = 'content'; - const { submit, setFieldValue } = form; - const [{ content: contentFormValue }] = useFormData({ form, watch: [fieldName] }); - - const onContentChange = useCallback((newValue) => setFieldValue(fieldName, newValue), [ - setFieldValue, - ]); - - const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline( - contentFormValue, - onContentChange - ); + const { submit } = form; const handleCancelAction = useCallback(() => { onChangeEditable(id); }, [id, onChangeEditable]); - const handleTimelineClick = useTimelineClick(); - const handleSaveAction = useCallback(async () => { const { isValid, data } = await submit(); if (isValid) { @@ -105,29 +99,24 @@ export const UserActionMarkdown = ({ path={fieldName} component={MarkdownEditorForm} componentProps={{ + 'aria-label': 'Cases markdown editor', + value: content, + id, bottomRightContent: renderButtons({ cancelAction: handleCancelAction, saveAction: handleSaveAction, }), - onClickTimeline: handleTimelineClick, - onCursorPositionUpdate: handleCursorChange, - topRightContent: ( - - ), }} /> ) : ( - - + + + {content} + ); }; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.test.tsx new file mode 100644 index 0000000000000..5bb0f50ce25e5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionMoveToReference } from './user_action_move_to_reference'; + +const outlineComment = jest.fn(); +const props = { + id: 'move-to-ref-id', + outlineComment, +}; + +describe('UserActionMoveToReference ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect( + wrapper.find(`[data-test-subj="move-to-link-${props.id}"]`).first().exists() + ).toBeTruthy(); + }); + + it('calls outlineComment correctly', async () => { + wrapper.find(`[data-test-subj="move-to-link-${props.id}"]`).first().simulate('click'); + expect(outlineComment).toHaveBeenCalledWith(props.id); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.tsx new file mode 100644 index 0000000000000..39d016dd69520 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useCallback } from 'react'; +import { EuiToolTip, EuiButtonIcon } from '@elastic/eui'; + +import * as i18n from './translations'; + +interface UserActionMoveToReferenceProps { + id: string; + outlineComment: (id: string) => void; +} + +const UserActionMoveToReferenceComponent = ({ + id, + outlineComment, +}: UserActionMoveToReferenceProps) => { + const handleMoveToLink = useCallback(() => { + outlineComment(id); + }, [id, outlineComment]); + + return ( + {i18n.MOVE_TO_ORIGINAL_COMMENT}

}> + +
+ ); +}; + +export const UserActionMoveToReference = memo(UserActionMoveToReferenceComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.test.tsx new file mode 100644 index 0000000000000..bd5da8aca7d4f --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionPropertyActions } from './user_action_property_actions'; + +const props = { + id: 'property-actions-id', + editLabel: 'edit', + quoteLabel: 'quote', + disabled: false, + isLoading: false, + onEdit: jest.fn(), + onQuote: jest.fn(), +}; + +describe('UserActionPropertyActions ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect( + wrapper.find('[data-test-subj="user-action-title-loading"]').first().exists() + ).toBeFalsy(); + + expect(wrapper.find('[data-test-subj="property-actions"]').first().exists()).toBeTruthy(); + }); + + it('it shows the edit and quote buttons', async () => { + wrapper.find('[data-test-subj="property-actions-ellipses"]').first().simulate('click'); + wrapper.find('[data-test-subj="property-actions-pencil"]').exists(); + wrapper.find('[data-test-subj="property-actions-quote"]').exists(); + }); + + it('it shows the spinner when loading', async () => { + wrapper = mount(); + expect( + wrapper.find('[data-test-subj="user-action-title-loading"]').first().exists() + ).toBeTruthy(); + + expect(wrapper.find('[data-test-subj="property-actions"]').first().exists()).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.tsx new file mode 100644 index 0000000000000..454880e93a27f --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useMemo, useCallback } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; + +import { PropertyActions } from '../property_actions'; + +interface UserActionPropertyActionsProps { + id: string; + editLabel: string; + quoteLabel: string; + disabled: boolean; + isLoading: boolean; + onEdit: (id: string) => void; + onQuote: (id: string) => void; +} + +const UserActionPropertyActionsComponent = ({ + id, + editLabel, + quoteLabel, + disabled, + isLoading, + onEdit, + onQuote, +}: UserActionPropertyActionsProps) => { + const onEditClick = useCallback(() => onEdit(id), [id, onEdit]); + const onQuoteClick = useCallback(() => onQuote(id), [id, onQuote]); + + const propertyActions = useMemo(() => { + return [ + { + disabled, + iconType: 'pencil', + label: editLabel, + onClick: onEditClick, + }, + { + disabled, + iconType: 'quote', + label: quoteLabel, + onClick: onQuoteClick, + }, + ]; + }, [disabled, editLabel, quoteLabel, onEditClick, onQuoteClick]); + return ( + <> + {isLoading && } + {!isLoading && } + + ); +}; + +export const UserActionPropertyActions = memo(UserActionPropertyActionsComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.test.tsx new file mode 100644 index 0000000000000..a65806520c854 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.test.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { TestProviders } from '../../../common/mock'; +import { UserActionTimestamp } from './user_action_timestamp'; + +jest.mock('@kbn/i18n/react', () => { + const originalModule = jest.requireActual('@kbn/i18n/react'); + const FormattedRelative = jest.fn(); + FormattedRelative.mockImplementationOnce(() => '2 days ago'); + FormattedRelative.mockImplementation(() => '20 hours ago'); + + return { + ...originalModule, + FormattedRelative, + }; +}); + +const props = { + createdAt: '2020-09-06T14:40:59.889Z', + updatedAt: '2020-09-07T14:40:59.889Z', +}; + +describe('UserActionTimestamp ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(, { wrappingComponent: TestProviders }); + }); + + it('it renders', async () => { + expect( + wrapper.find('[data-test-subj="user-action-title-creation-relative-time"]').first().exists() + ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="user-action-title-edited-relative-time"]').first().exists() + ).toBeTruthy(); + }); + + it('it shows only the created time when the updated time is missing', async () => { + const newWrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect( + newWrapper + .find('[data-test-subj="user-action-title-creation-relative-time"]') + .first() + .exists() + ).toBeTruthy(); + expect( + newWrapper.find('[data-test-subj="user-action-title-edited-relative-time"]').first().exists() + ).toBeFalsy(); + }); + + it('it shows the timestamp correctly', async () => { + const createdText = wrapper + .find('[data-test-subj="user-action-title-creation-relative-time"]') + .first() + .text(); + + const updatedText = wrapper + .find('[data-test-subj="user-action-title-edited-relative-time"]') + .first() + .text(); + + expect(`${createdText} (${updatedText})`).toBe('2 days ago (20 hours ago)'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.tsx new file mode 100644 index 0000000000000..72dc5de9cdb3b --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiTextColor } from '@elastic/eui'; +import { FormattedRelative } from '@kbn/i18n/react'; + +import { LocalizedDateTooltip } from '../../../common/components/localized_date_tooltip'; +import * as i18n from './translations'; + +interface UserActionAvatarProps { + createdAt: string; + updatedAt?: string | null; +} + +const UserActionTimestampComponent = ({ createdAt, updatedAt }: UserActionAvatarProps) => { + return ( + <> + + + + {updatedAt && ( + + {/* be careful of the extra space at the beginning of the parenthesis */} + {' ('} + {i18n.EDITED_FIELD}{' '} + + + + {')'} + + )} + + ); +}; + +export const UserActionTimestamp = memo(UserActionTimestampComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.test.tsx deleted file mode 100644 index 0bb02ce69a544..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.test.tsx +++ /dev/null @@ -1,54 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { mount } from 'enzyme'; -import copy from 'copy-to-clipboard'; -import { Router, routeData, mockHistory } from '../__mock__/router'; -import { caseUserActions as basicUserActions } from '../../containers/mock'; -import { UserActionTitle } from './user_action_title'; -import { TestProviders } from '../../../common/mock'; - -const outlineComment = jest.fn(); -const onEdit = jest.fn(); -const onQuote = jest.fn(); - -jest.mock('copy-to-clipboard'); -const defaultProps = { - createdAt: basicUserActions[0].actionAt, - disabled: false, - fullName: basicUserActions[0].actionBy.fullName, - id: basicUserActions[0].actionId, - isLoading: false, - labelEditAction: 'labelEditAction', - labelQuoteAction: 'labelQuoteAction', - labelTitle: <>{'cool'}, - linkId: basicUserActions[0].commentId, - onEdit, - onQuote, - outlineComment, - updatedAt: basicUserActions[0].actionAt, - username: basicUserActions[0].actionBy.username, -}; - -describe('UserActionTitle ', () => { - beforeEach(() => { - jest.resetAllMocks(); - jest.spyOn(routeData, 'useParams').mockReturnValue({ commentId: '123' }); - }); - - it('Calls copy when copy link is clicked', async () => { - const wrapper = mount( - - - - - - ); - wrapper.find(`[data-test-subj="copy-link"]`).first().simulate('click'); - expect(copy).toBeCalledTimes(1); - }); -}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.tsx deleted file mode 100644 index 9477299e563a8..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.tsx +++ /dev/null @@ -1,183 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - EuiLoadingSpinner, - EuiFlexGroup, - EuiFlexItem, - EuiText, - EuiButtonIcon, - EuiToolTip, -} from '@elastic/eui'; -import { FormattedRelative } from '@kbn/i18n/react'; -import copy from 'copy-to-clipboard'; -import { isEmpty } from 'lodash/fp'; -import React, { useMemo, useCallback } from 'react'; -import styled from 'styled-components'; -import { useParams } from 'react-router-dom'; - -import { LocalizedDateTooltip } from '../../../common/components/localized_date_tooltip'; -import { useGetUrlSearch } from '../../../common/components/navigation/use_get_url_search'; -import { navTabs } from '../../../app/home/home_navigations'; -import { PropertyActions } from '../property_actions'; -import { SecurityPageName } from '../../../app/types'; -import * as i18n from './translations'; - -const MySpinner = styled(EuiLoadingSpinner)` - .euiLoadingSpinner { - margin-top: 1px; // yes it matters! - } -`; - -interface UserActionTitleProps { - createdAt: string; - disabled: boolean; - id: string; - isLoading: boolean; - labelEditAction?: string; - labelQuoteAction?: string; - labelTitle: JSX.Element; - linkId?: string | null; - fullName?: string | null; - updatedAt?: string | null; - username?: string | null; - onEdit?: (id: string) => void; - onQuote?: (id: string) => void; - outlineComment?: (id: string) => void; -} - -export const UserActionTitle = ({ - createdAt, - disabled, - fullName, - id, - isLoading, - labelEditAction, - labelQuoteAction, - labelTitle, - linkId, - onEdit, - onQuote, - outlineComment, - updatedAt, - username = i18n.UNKNOWN, -}: UserActionTitleProps) => { - const { detailName: caseId } = useParams<{ detailName: string }>(); - const urlSearch = useGetUrlSearch(navTabs.case); - const propertyActions = useMemo(() => { - return [ - ...(labelEditAction != null && onEdit != null - ? [ - { - disabled, - iconType: 'pencil', - label: labelEditAction, - onClick: () => onEdit(id), - }, - ] - : []), - ...(labelQuoteAction != null && onQuote != null - ? [ - { - disabled, - iconType: 'quote', - label: labelQuoteAction, - onClick: () => onQuote(id), - }, - ] - : []), - ]; - }, [disabled, id, labelEditAction, onEdit, labelQuoteAction, onQuote]); - - const handleAnchorLink = useCallback(() => { - copy( - `${window.location.origin}${window.location.pathname}#${SecurityPageName.case}/${caseId}/${id}${urlSearch}` - ); - }, [caseId, id, urlSearch]); - - const handleMoveToLink = useCallback(() => { - if (outlineComment != null && linkId != null) { - outlineComment(linkId); - } - }, [linkId, outlineComment]); - return ( - - - - - - {fullName ?? username}

}> - {username} -
-
- {labelTitle} - - - - - - {updatedAt != null && ( - - - {'('} - {i18n.EDITED_FIELD}{' '} - - - - {')'} - - - )} -
-
- - - {!isEmpty(linkId) && ( - - {i18n.MOVE_TO_ORIGINAL_COMMENT}

}> - -
-
- )} - - {i18n.COPY_REFERENCE_LINK}

}> - -
-
- {propertyActions.length > 0 && ( - - {isLoading && } - {!isLoading && } - - )} -
-
-
-
- ); -}; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.test.tsx new file mode 100644 index 0000000000000..008eb18aef074 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionUsername } from './user_action_username'; + +const props = { + username: 'elastic', + fullName: 'Elastic', +}; + +describe('UserActionUsername ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect( + wrapper.find('[data-test-subj="user-action-username-tooltip"]').first().exists() + ).toBeTruthy(); + }); + + it('it shows the username', async () => { + expect(wrapper.find('[data-test-subj="user-action-username-tooltip"]').text()).toBe('elastic'); + }); + + test('it shows the fullname when hovering the username', () => { + // Use fake timers so we don't have to wait for the EuiToolTip timeout + jest.useFakeTimers(); + + wrapper.find('[data-test-subj="user-action-username-tooltip"]').first().simulate('mouseOver'); + + // Run the timers so the EuiTooltip will be visible + jest.runAllTimers(); + + wrapper.update(); + expect(wrapper.find('.euiToolTipPopover').text()).toBe('Elastic'); + + // Clearing all mocks will also reset fake timers. + jest.clearAllMocks(); + }); + + test('it shows the username when hovering the username and the fullname is missing', () => { + // Use fake timers so we don't have to wait for the EuiToolTip timeout + jest.useFakeTimers(); + + const newWrapper = mount(); + newWrapper + .find('[data-test-subj="user-action-username-tooltip"]') + .first() + .simulate('mouseOver'); + + // Run the timers so the EuiTooltip will be visible + jest.runAllTimers(); + + newWrapper.update(); + expect(newWrapper.find('.euiToolTipPopover').text()).toBe('elastic'); + + // Clearing all mocks will also reset fake timers. + jest.clearAllMocks(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx new file mode 100644 index 0000000000000..dbc153ddbe577 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiToolTip } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; + +interface UserActionUsernameProps { + username: string; + fullName?: string; +} + +const UserActionUsernameComponent = ({ username, fullName }: UserActionUsernameProps) => { + return ( + {isEmpty(fullName) ? username : fullName}

} + data-test-subj="user-action-username-tooltip" + > + {username} +
+ ); +}; + +export const UserActionUsername = memo(UserActionUsernameComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.test.tsx new file mode 100644 index 0000000000000..f8403738c24ea --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionUsernameWithAvatar } from './user_action_username_with_avatar'; + +const props = { + username: 'elastic', + fullName: 'Elastic', +}; + +describe('UserActionUsernameWithAvatar ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + 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(); + }); + + it('it shows the avatar', async () => { + expect(wrapper.find('[data-test-subj="user-action-username-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' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx new file mode 100644 index 0000000000000..e2326a3580e6f --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiAvatar } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; + +import { UserActionUsername } from './user_action_username'; + +interface UserActionUsernameWithAvatarProps { + username: string; + fullName?: string; +} + +const UserActionUsernameWithAvatarComponent = ({ + username, + fullName, +}: UserActionUsernameWithAvatarProps) => { + return ( + + + + + + + + + ); +}; + +export const UserActionUsernameWithAvatar = memo(UserActionUsernameWithAvatarComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts index 403c8d838fa44..89fcc67bcd15f 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts @@ -16,25 +16,40 @@ export { getDetectionEngineUrl } from './redirect_to_detection_engine'; export { getAppOverviewUrl } from './redirect_to_overview'; export { getHostDetailsUrl, getHostsUrl } from './redirect_to_hosts'; export { getNetworkUrl, getNetworkDetailsUrl } from './redirect_to_network'; -export { getTimelinesUrl, getTimelineTabsUrl } from './redirect_to_timelines'; +export { getTimelinesUrl, getTimelineTabsUrl, getTimelineUrl } from './redirect_to_timelines'; export { getCaseDetailsUrl, getCaseUrl, getCreateCaseUrl, getConfigureCasesUrl, + getCaseDetailsUrlWithCommentId, } from './redirect_to_case'; +interface FormatUrlOptions { + absolute: boolean; + skipSearch: boolean; +} + +type FormatUrl = (path: string, options?: Partial) => string; + export const useFormatUrl = (page: SecurityPageName) => { const { getUrlForApp } = useKibana().services.application; const search = useGetUrlSearch(navTabs[page]); - const formatUrl = useCallback( - (path: string) => { + const formatUrl = useCallback( + (path: string, { absolute = false, skipSearch = false } = {}) => { const pathArr = path.split('?'); const formattedPath = `${pathArr[0]}${ - isEmpty(pathArr[1]) ? search : `${pathArr[1]}${isEmpty(search) ? '' : `&${search}`}` + !skipSearch + ? isEmpty(pathArr[1]) + ? search + : `?${pathArr[1]}${isEmpty(search) ? '' : `&${search}`}` + : isEmpty(pathArr[1]) + ? '' + : `?${pathArr[1]}` }`; return getUrlForApp(`${APP_ID}:${page}`, { path: formattedPath, + absolute, }); }, [getUrlForApp, page, search] diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx index 7005460999fc7..3ef00635844f6 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx +++ b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_case.tsx @@ -11,6 +11,17 @@ export const getCaseUrl = (search: string | null) => `${appendSearch(search ?? u export const getCaseDetailsUrl = ({ id, search }: { id: string; search?: string | null }) => `/${encodeURIComponent(id)}${appendSearch(search ?? undefined)}`; +export const getCaseDetailsUrlWithCommentId = ({ + id, + commentId, + search, +}: { + id: string; + commentId: string; + search?: string | null; +}) => + `/${encodeURIComponent(id)}/${encodeURIComponent(commentId)}${appendSearch(search ?? undefined)}`; + export const getCreateCaseUrl = (search?: string | null) => `/create${appendSearch(search ?? undefined)}`; diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx index 75a2fa1efa414..58b9f940ceaa6 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx +++ b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { isEmpty } from 'lodash/fp'; import { TimelineTypeLiteral } from '../../../../common/types/timeline'; import { appendSearch } from './helpers'; @@ -11,3 +12,8 @@ export const getTimelinesUrl = (search?: string) => `${appendSearch(search)}`; export const getTimelineTabsUrl = (tabName: TimelineTypeLiteral, search?: string) => `/${tabName}${appendSearch(search)}`; + +export const getTimelineUrl = (id: string, graphEventId?: string) => + `?timeline=(id:'${id}',isOpen:!t${ + isEmpty(graphEventId) ? ')' : `,graphEventId:'${graphEventId}')` + }`; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx new file mode 100644 index 0000000000000..481ed7892a8be --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useCallback } from 'react'; +import styled from 'styled-components'; +import { + EuiMarkdownEditor, + EuiMarkdownEditorProps, + EuiFormRow, + EuiFlexItem, + EuiFlexGroup, + getDefaultEuiMarkdownParsingPlugins, + getDefaultEuiMarkdownProcessingPlugins, +} from '@elastic/eui'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../../../shared_imports'; + +import * as timelineMarkdownPlugin from './plugins/timeline'; + +type MarkdownEditorFormProps = EuiMarkdownEditorProps & { + id: string; + field: FieldHook; + dataTestSubj: string; + idAria: string; + isDisabled?: boolean; + bottomRightContent?: React.ReactNode; +}; + +const BottomContentWrapper = styled(EuiFlexGroup)` + ${({ theme }) => ` + padding: ${theme.eui.ruleMargins.marginSmall} 0; + `} +`; + +export const parsingPlugins = getDefaultEuiMarkdownParsingPlugins(); +parsingPlugins.push(timelineMarkdownPlugin.parser); + +export const processingPlugins = getDefaultEuiMarkdownProcessingPlugins(); +processingPlugins[1][1].components.timeline = timelineMarkdownPlugin.renderer; + +export const MarkdownEditorForm: React.FC = ({ + id, + field, + dataTestSubj, + idAria, + bottomRightContent, +}) => { + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + const [markdownErrorMessages, setMarkdownErrorMessages] = useState([]); + const onParse = useCallback((err, { messages }) => { + setMarkdownErrorMessages(err ? [err] : messages); + }, []); + + return ( + + <> + + {bottomRightContent && ( + + {bottomRightContent} + + )} + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/form.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/form.tsx deleted file mode 100644 index 2cc3fe05a2215..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/form.tsx +++ /dev/null @@ -1,67 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiFormRow } from '@elastic/eui'; -import React, { useCallback } from 'react'; - -import { FieldHook, getFieldValidityAndErrorMessage } from '../../../shared_imports'; -import { CursorPosition, MarkdownEditor } from '.'; - -interface IMarkdownEditorForm { - bottomRightContent?: React.ReactNode; - dataTestSubj: string; - field: FieldHook; - idAria: string; - isDisabled: boolean; - onClickTimeline?: (timelineId: string, graphEventId?: string) => void; - onCursorPositionUpdate?: (cursorPosition: CursorPosition) => void; - placeholder?: string; - topRightContent?: React.ReactNode; -} -export const MarkdownEditorForm = ({ - bottomRightContent, - dataTestSubj, - field, - idAria, - isDisabled = false, - onClickTimeline, - onCursorPositionUpdate, - placeholder, - topRightContent, -}: IMarkdownEditorForm) => { - const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); - - const handleContentChange = useCallback( - (newContent: string) => { - field.setValue(newContent); - }, - [field] - ); - - return ( - - - - ); -}; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.test.tsx deleted file mode 100644 index b5e5b01189418..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.test.tsx +++ /dev/null @@ -1,49 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { mount } from 'enzyme'; -import React from 'react'; - -import { MarkdownEditor } from '.'; -import { TestProviders } from '../../mock'; - -describe('Markdown Editor', () => { - const onChange = jest.fn(); - const onCursorPositionUpdate = jest.fn(); - const defaultProps = { - content: 'hello world', - onChange, - onCursorPositionUpdate, - }; - beforeEach(() => { - jest.clearAllMocks(); - }); - test('it calls onChange with correct value', () => { - const wrapper = mount( - - - - ); - const newValue = 'a new string'; - wrapper - .find(`[data-test-subj="textAreaInput"]`) - .first() - .simulate('change', { target: { value: newValue } }); - expect(onChange).toBeCalledWith(newValue); - }); - test('it calls onCursorPositionUpdate with correct args', () => { - const wrapper = mount( - - - - ); - wrapper.find(`[data-test-subj="textAreaInput"]`).first().simulate('blur'); - expect(onCursorPositionUpdate).toBeCalledWith({ - start: 0, - end: 0, - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.tsx index d4ad4a11b60a3..9f4141dbcae7d 100644 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.tsx @@ -4,167 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiFlexGroup, - EuiFlexItem, - EuiLink, - EuiPanel, - EuiTabbedContent, - EuiTextArea, -} from '@elastic/eui'; -import React, { useMemo, useCallback, ChangeEvent } from 'react'; -import styled, { css } from 'styled-components'; - -import { Markdown } from '../markdown'; -import * as i18n from './translations'; -import { MARKDOWN_HELP_LINK } from './constants'; - -const TextArea = styled(EuiTextArea)` - width: 100%; -`; - -const Container = styled(EuiPanel)` - ${({ theme }) => css` - padding: 0; - background: ${theme.eui.euiColorLightestShade}; - position: relative; - .markdown-tabs-header { - position: absolute; - top: ${theme.eui.euiSizeS}; - right: ${theme.eui.euiSizeS}; - z-index: ${theme.eui.euiZContentMenu}; - } - .euiTab { - padding: 10px; - } - .markdown-tabs { - width: 100%; - } - .markdown-tabs-footer { - height: 41px; - padding: 0 ${theme.eui.euiSizeM}; - .euiLink { - font-size: ${theme.eui.euiSizeM}; - } - } - .euiFormRow__labelWrapper { - position: absolute; - top: -${theme.eui.euiSizeL}; - } - .euiFormErrorText { - padding: 0 ${theme.eui.euiSizeM}; - } - `} -`; - -const MarkdownContainer = styled(EuiPanel)` - min-height: 150px; - overflow: auto; -`; - -export interface CursorPosition { - start: number; - end: number; -} - -/** An input for entering a new case description */ -export const MarkdownEditor = React.memo<{ - bottomRightContent?: React.ReactNode; - topRightContent?: React.ReactNode; - content: string; - isDisabled?: boolean; - onChange: (description: string) => void; - onClickTimeline?: (timelineId: string, graphEventId?: string) => void; - onCursorPositionUpdate?: (cursorPosition: CursorPosition) => void; - placeholder?: string; -}>( - ({ - bottomRightContent, - topRightContent, - content, - isDisabled = false, - onChange, - onClickTimeline, - placeholder, - onCursorPositionUpdate, - }) => { - const handleOnChange = useCallback( - (evt: ChangeEvent) => { - onChange(evt.target.value); - }, - [onChange] - ); - - const setCursorPosition = useCallback( - (e: React.ChangeEvent) => { - if (onCursorPositionUpdate) { - onCursorPositionUpdate({ - start: e!.target!.selectionStart ?? 0, - end: e!.target!.selectionEnd ?? 0, - }); - } - }, - [onCursorPositionUpdate] - ); - - const tabs = useMemo( - () => [ - { - id: 'comment', - name: i18n.MARKDOWN, - content: ( -