From 631aaad1e0c1b775135f1aeef5ecf456b03dd95a Mon Sep 17 00:00:00 2001
From: Christos Nasikas
Date: Thu, 17 Sep 2020 17:39:35 +0300
Subject: [PATCH] [Security Solutions][Cases] Cases Redesign (#73247)
Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com>
Co-authored-by: Yara Tercero
Co-authored-by: Elastic Machine
---
.../cypress/integration/cases.spec.ts | 9 +-
.../timeline_attach_to_case.spec.ts | 10 +-
.../cypress/screens/case_details.ts | 16 +-
.../cypress/screens/create_new_case.ts | 6 +-
.../cypress/tasks/create_new_case.ts | 4 -
.../cases/components/add_comment/index.tsx | 4 +-
.../cases/components/case_view/index.test.tsx | 15 +-
.../public/cases/components/create/index.tsx | 4 +-
.../cases/components/tag_list/index.test.tsx | 12 +-
.../cases/components/tag_list/index.tsx | 14 +-
.../public/cases/components/tag_list/tags.tsx | 32 ++
.../user_action_tree/helpers.test.tsx | 18 +-
.../components/user_action_tree/helpers.tsx | 52 ++-
.../user_action_tree/index.test.tsx | 349 ++++++++-------
.../components/user_action_tree/index.tsx | 407 ++++++++++++------
.../user_action_avatar.test.tsx | 47 ++
.../user_action_tree/user_action_avatar.tsx | 21 +-
.../user_action_content_toolbar.test.tsx | 55 +++
.../user_action_content_toolbar.tsx | 52 +++
.../user_action_copy_link.test.tsx | 74 ++++
.../user_action_copy_link.tsx | 43 ++
.../user_action_tree/user_action_item.tsx | 197 ---------
.../user_action_markdown.test.tsx | 24 +-
.../user_action_tree/user_action_markdown.tsx | 59 ++-
.../user_action_move_to_reference.test.tsx | 34 ++
.../user_action_move_to_reference.tsx | 37 ++
.../user_action_property_actions.test.tsx | 50 +++
.../user_action_property_actions.tsx | 58 +++
.../user_action_timestamp.test.tsx | 74 ++++
.../user_action_timestamp.tsx | 46 ++
.../user_action_title.test.tsx | 54 ---
.../user_action_tree/user_action_title.tsx | 183 --------
.../user_action_username.test.tsx | 68 +++
.../user_action_tree/user_action_username.tsx | 28 ++
.../user_action_username_with_avatar.test.tsx | 42 ++
.../user_action_username_with_avatar.tsx | 43 ++
.../public/common/components/link_to/index.ts | 23 +-
.../components/link_to/redirect_to_case.tsx | 11 +
.../link_to/redirect_to_timelines.tsx | 6 +
.../components/markdown_editor/eui_form.tsx | 87 ++++
.../components/markdown_editor/form.tsx | 67 ---
.../components/markdown_editor/index.test.tsx | 49 ---
.../components/markdown_editor/index.tsx | 165 +------
.../plugins/timeline/constants.ts | 8 +
.../markdown_editor/plugins/timeline/index.ts | 11 +
.../plugins/timeline/parser.ts | 119 +++++
.../plugins/timeline/plugin.tsx | 87 ++++
.../plugins/timeline/processor.tsx | 34 ++
.../plugins/timeline/translations.ts | 54 +++
.../markdown_editor/plugins/timeline/types.ts | 18 +
.../components/markdown_editor/types.ts | 10 +
.../utils/timeline}/use_timeline_click.tsx | 0
.../rules/step_about_rule/index.tsx | 2 +-
.../use_insert_timeline.tsx | 19 +-
54 files changed, 1888 insertions(+), 1123 deletions(-)
create mode 100644 x-pack/plugins/security_solution/public/cases/components/tag_list/tags.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.tsx
delete mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_item.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.tsx
delete mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.test.tsx
delete mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx
create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx
delete mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/form.tsx
delete mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/index.test.tsx
create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/constants.ts
create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/index.ts
create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/parser.ts
create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/plugin.tsx
create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/processor.tsx
create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/translations.ts
create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/types.ts
create mode 100644 x-pack/plugins/security_solution/public/common/components/markdown_editor/types.ts
rename x-pack/plugins/security_solution/public/{cases/components/utils => common/utils/timeline}/use_timeline_click.tsx (100%)
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 (
-
}>
+
+
+ );
+};
+
+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: (
-
- ),
- },
- {
- id: 'preview',
- name: i18n.PREVIEW,
- 'data-test-subj': 'preview-tab',
- content: (
-
-
-
- ),
- },
- ],
- [content, handleOnChange, isDisabled, onClickTimeline, placeholder, setCursorPosition]
- );
- return (
-
- {topRightContent && {topRightContent}
}
-
-
-
-
- {i18n.MARKDOWN_SYNTAX_HELP}
-
-
- {bottomRightContent && {bottomRightContent}}
-
-
- );
- }
-);
-
-MarkdownEditor.displayName = 'MarkdownEditor';
+export * from './types';
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/constants.ts b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/constants.ts
new file mode 100644
index 0000000000000..917000a8ba21c
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/constants.ts
@@ -0,0 +1,8 @@
+/*
+ * 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.
+ */
+
+export const ID = 'timeline';
+export const PREFIX = `[`;
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/index.ts b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/index.ts
new file mode 100644
index 0000000000000..701889013ee53
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/index.ts
@@ -0,0 +1,11 @@
+/*
+ * 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 { plugin } from './plugin';
+import { TimelineParser } from './parser';
+import { TimelineMarkDownRenderer } from './processor';
+
+export { plugin, TimelineParser as parser, TimelineMarkDownRenderer as renderer };
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/parser.ts b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/parser.ts
new file mode 100644
index 0000000000000..d322a2c9e1929
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/parser.ts
@@ -0,0 +1,119 @@
+/*
+ * 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 { Plugin } from '@elastic/eui/node_modules/unified';
+import { RemarkTokenizer } from '@elastic/eui';
+import { parse } from 'query-string';
+import { decodeRisonUrlState } from '../../../url_state/helpers';
+import { ID, PREFIX } from './constants';
+import * as i18n from './translations';
+
+export const TimelineParser: Plugin = function () {
+ const Parser = this.Parser;
+ const tokenizers = Parser.prototype.inlineTokenizers;
+ const methods = Parser.prototype.inlineMethods;
+
+ const parseTimeline: RemarkTokenizer = function (eat, value, silent) {
+ let index = 0;
+ const nextChar = value[index];
+
+ if (nextChar !== '[') {
+ return false;
+ }
+
+ if (silent) {
+ return true;
+ }
+
+ function readArg(open: string, close: string) {
+ if (value[index] !== open) {
+ throw new Error(i18n.NO_PARENTHESES);
+ }
+
+ index++;
+
+ let body = '';
+ let openBrackets = 0;
+
+ for (; index < value.length; index++) {
+ const char = value[index];
+
+ if (char === close && openBrackets === 0) {
+ index++;
+ return body;
+ } else if (char === close) {
+ openBrackets--;
+ } else if (char === open) {
+ openBrackets++;
+ }
+
+ body += char;
+ }
+
+ return '';
+ }
+
+ const timelineTitle = readArg('[', ']');
+ const timelineUrl = readArg('(', ')');
+ const now = eat.now();
+
+ if (!timelineTitle) {
+ this.file.info(i18n.NO_TIMELINE_NAME_FOUND, {
+ line: now.line,
+ column: now.column,
+ });
+ return false;
+ }
+
+ try {
+ const timelineSearch = timelineUrl.split('?');
+ const parseTimelineUrlSearch = parse(timelineSearch[1]) as { timeline: string };
+ const { id: timelineId = '', graphEventId = '' } = decodeRisonUrlState(
+ parseTimelineUrlSearch.timeline ?? ''
+ ) ?? { id: null, graphEventId: '' };
+
+ if (!timelineId) {
+ this.file.info(i18n.NO_TIMELINE_ID_FOUND, {
+ line: now.line,
+ column: now.column + timelineUrl.indexOf('id'),
+ });
+ return false;
+ }
+
+ return eat(`[${timelineTitle}](${timelineUrl})`)({
+ type: ID,
+ id: timelineId,
+ title: timelineTitle,
+ graphEventId,
+ });
+ } catch {
+ this.file.info(i18n.TIMELINE_URL_IS_NOT_VALID(timelineUrl), {
+ line: now.line,
+ column: now.column,
+ });
+ }
+
+ return false;
+ };
+
+ const tokenizeTimeline: RemarkTokenizer = function tokenizeTimeline(eat, value, silent) {
+ if (
+ value.startsWith(PREFIX) === false ||
+ (value.startsWith(PREFIX) === true && !value.includes('timelines?timeline=(id'))
+ ) {
+ return false;
+ }
+
+ return parseTimeline.call(this, eat, value, silent);
+ };
+
+ tokenizeTimeline.locator = (value: string, fromIndex: number) => {
+ return value.indexOf(PREFIX, fromIndex);
+ };
+
+ tokenizers.timeline = tokenizeTimeline;
+ methods.splice(methods.indexOf('url'), 0, ID);
+};
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/plugin.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/plugin.tsx
new file mode 100644
index 0000000000000..8d2488b269d76
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/plugin.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, { useCallback, memo } from 'react';
+import {
+ EuiSelectableOption,
+ EuiModalBody,
+ EuiMarkdownEditorUiPlugin,
+ EuiCodeBlock,
+} from '@elastic/eui';
+
+import { TimelineType } from '../../../../../../common/types/timeline';
+import { SelectableTimeline } from '../../../../../timelines/components/timeline/selectable_timeline';
+import { OpenTimelineResult } from '../../../../../timelines/components/open_timeline/types';
+import { getTimelineUrl, useFormatUrl } from '../../../link_to';
+
+import { ID } from './constants';
+import * as i18n from './translations';
+import { SecurityPageName } from '../../../../../app/types';
+
+interface TimelineEditorProps {
+ onClosePopover: () => void;
+ onInsert: (markdown: string, config: { block: boolean }) => void;
+}
+
+const TimelineEditorComponent: React.FC = ({ onClosePopover, onInsert }) => {
+ const { formatUrl } = useFormatUrl(SecurityPageName.timelines);
+
+ const handleGetSelectableOptions = useCallback(
+ ({ timelines }: { timelines: OpenTimelineResult[] }) => [
+ ...timelines.map(
+ (t: OpenTimelineResult, index: number) =>
+ ({
+ description: t.description,
+ favorite: t.favorite,
+ label: t.title,
+ id: t.savedObjectId,
+ key: `${t.title}-${index}`,
+ title: t.title,
+ checked: undefined,
+ } as EuiSelectableOption)
+ ),
+ ],
+ []
+ );
+
+ return (
+
+ {
+ const url = formatUrl(getTimelineUrl(timelineId ?? '', graphEventId), {
+ absolute: true,
+ skipSearch: true,
+ });
+ onInsert(`[${timelineTitle}](${url})`, {
+ block: false,
+ });
+ }}
+ onClosePopover={onClosePopover}
+ timelineType={TimelineType.default}
+ />
+
+ );
+};
+
+const TimelineEditor = memo(TimelineEditorComponent);
+
+export const plugin: EuiMarkdownEditorUiPlugin = {
+ name: ID,
+ button: {
+ label: i18n.INSERT_TIMELINE,
+ iconType: 'timeline',
+ },
+ helpText: (
+
+ {'[title](url)'}
+
+ ),
+ editor: function editor({ node, onSave, onCancel }) {
+ return ;
+ },
+};
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/processor.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/processor.tsx
new file mode 100644
index 0000000000000..fb72b4368c8ea
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/processor.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, { useCallback, memo } from 'react';
+import { EuiToolTip, EuiLink, EuiMarkdownAstNodePosition } from '@elastic/eui';
+
+import { useTimelineClick } from '../../../../utils/timeline/use_timeline_click';
+import { TimelineProps } from './types';
+import * as i18n from './translations';
+
+export const TimelineMarkDownRendererComponent: React.FC<
+ TimelineProps & {
+ position: EuiMarkdownAstNodePosition;
+ }
+> = ({ id, title, graphEventId }) => {
+ const handleTimelineClick = useTimelineClick();
+ const onClickTimeline = useCallback(() => handleTimelineClick(id ?? '', graphEventId), [
+ id,
+ graphEventId,
+ handleTimelineClick,
+ ]);
+ return (
+
+
+ {title}
+
+
+ );
+};
+
+export const TimelineMarkDownRenderer = memo(TimelineMarkDownRendererComponent);
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/translations.ts b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/translations.ts
new file mode 100644
index 0000000000000..5a23b2a742157
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/translations.ts
@@ -0,0 +1,54 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { i18n } from '@kbn/i18n';
+
+export const INSERT_TIMELINE = i18n.translate(
+ 'xpack.securitySolution.markdownEditor.plugins.timeline.insertTimelineButtonLabel',
+ {
+ defaultMessage: 'Insert timeline link',
+ }
+);
+
+export const TIMELINE_ID = (timelineId: string) =>
+ i18n.translate('xpack.securitySolution.markdownEditor.plugins.timeline.toolTip.timelineId', {
+ defaultMessage: 'Timeline id: { timelineId }',
+ values: {
+ timelineId,
+ },
+ });
+
+export const NO_TIMELINE_NAME_FOUND = i18n.translate(
+ 'xpack.securitySolution.markdownEditor.plugins.timeline.noTimelineNameFoundErrorMsg',
+ {
+ defaultMessage: 'No timeline name found',
+ }
+);
+
+export const NO_TIMELINE_ID_FOUND = i18n.translate(
+ 'xpack.securitySolution.markdownEditor.plugins.timeline.noTimelineIdFoundErrorMsg',
+ {
+ defaultMessage: 'No timeline id found',
+ }
+);
+
+export const TIMELINE_URL_IS_NOT_VALID = (timelineUrl: string) =>
+ i18n.translate(
+ 'xpack.securitySolution.markdownEditor.plugins.timeline.toolTip.timelineUrlIsNotValidErrorMsg',
+ {
+ defaultMessage: 'Timeline URL is not valid => {timelineUrl}',
+ values: {
+ timelineUrl,
+ },
+ }
+ );
+
+export const NO_PARENTHESES = i18n.translate(
+ 'xpack.securitySolution.markdownEditor.plugins.timeline.noParenthesesErrorMsg',
+ {
+ defaultMessage: 'Expected left parentheses',
+ }
+);
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/types.ts b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/types.ts
new file mode 100644
index 0000000000000..8b9111fc9fc7d
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/timeline/types.ts
@@ -0,0 +1,18 @@
+/*
+ * 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 { ID } from './constants';
+
+export interface TimelineConfiguration {
+ id: string | null;
+ title: string;
+ graphEventId?: string;
+ [key: string]: string | null | undefined;
+}
+
+export interface TimelineProps extends TimelineConfiguration {
+ type: typeof ID;
+}
diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/types.ts b/x-pack/plugins/security_solution/public/common/components/markdown_editor/types.ts
new file mode 100644
index 0000000000000..030def21ac36f
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/types.ts
@@ -0,0 +1,10 @@
+/*
+ * 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.
+ */
+
+export interface CursorPosition {
+ start: number;
+ end: number;
+}
diff --git a/x-pack/plugins/security_solution/public/cases/components/utils/use_timeline_click.tsx b/x-pack/plugins/security_solution/public/common/utils/timeline/use_timeline_click.tsx
similarity index 100%
rename from x-pack/plugins/security_solution/public/cases/components/utils/use_timeline_click.tsx
rename to x-pack/plugins/security_solution/public/common/utils/timeline/use_timeline_click.tsx
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx
index d2c84883fa99b..66f95f5ce15d2 100644
--- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx
@@ -36,7 +36,7 @@ import { schema } from './schema';
import * as I18n from './translations';
import { StepContentWrapper } from '../step_content_wrapper';
import { NextStep } from '../next_step';
-import { MarkdownEditorForm } from '../../../../common/components/markdown_editor/form';
+import { MarkdownEditorForm } from '../../../../common/components/markdown_editor/eui_form';
import { SeverityField } from '../severity_mapping';
import { RiskScoreField } from '../risk_score_mapping';
import { useFetchIndexPatterns } from '../../../containers/detection_engine/rules';
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx
index 55c0709bd5543..f1f419fd4b52a 100644
--- a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx
+++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx
@@ -4,17 +4,17 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { isEmpty } from 'lodash/fp';
import { useCallback, useState, useEffect } from 'react';
import { useDispatch, useSelector, shallowEqual } from 'react-redux';
-import { useBasePath } from '../../../../common/lib/kibana';
+import { SecurityPageName } from '../../../../../common/constants';
+import { getTimelineUrl, useFormatUrl } from '../../../../common/components/link_to';
import { CursorPosition } from '../../../../common/components/markdown_editor';
import { timelineActions, timelineSelectors } from '../../../../timelines/store/timeline';
import { setInsertTimeline } from '../../../store/timeline/actions';
export const useInsertTimeline = (value: string, onChange: (newValue: string) => void) => {
- const basePath = window.location.origin + useBasePath();
const dispatch = useDispatch();
+ const { formatUrl } = useFormatUrl(SecurityPageName.timelines);
const [cursorPosition, setCursorPosition] = useState({
start: 0,
end: 0,
@@ -24,21 +24,22 @@ export const useInsertTimeline = (value: string, onChange: (newValue: string) =>
const handleOnTimelineChange = useCallback(
(title: string, id: string | null, graphEventId?: string) => {
- const builtLink = `${basePath}/app/security/timelines?timeline=(id:'${id}'${
- !isEmpty(graphEventId) ? `,graphEventId:'${graphEventId}'` : ''
- },isOpen:!t)`;
+ const url = formatUrl(getTimelineUrl(id ?? '', graphEventId), {
+ absolute: true,
+ skipSearch: true,
+ });
const newValue: string = [
value.slice(0, cursorPosition.start),
cursorPosition.start === cursorPosition.end
- ? `[${title}](${builtLink})`
- : `[${value.slice(cursorPosition.start, cursorPosition.end)}](${builtLink})`,
+ ? `[${title}](${url})`
+ : `[${value.slice(cursorPosition.start, cursorPosition.end)}](${url})`,
value.slice(cursorPosition.end),
].join('');
onChange(newValue);
},
- [value, onChange, basePath, cursorPosition]
+ [value, onChange, cursorPosition, formatUrl]
);
const handleCursorChange = useCallback((cp: CursorPosition) => {