diff --git a/x-pack/plugins/siem/cypress/integration/cases.spec.ts b/x-pack/plugins/siem/cypress/integration/cases.spec.ts
index e11d76d8f608a..8f35a3209c69d 100644
--- a/x-pack/plugins/siem/cypress/integration/cases.spec.ts
+++ b/x-pack/plugins/siem/cypress/integration/cases.spec.ts
@@ -30,7 +30,6 @@ import {
CASE_DETAILS_PUSH_TO_EXTERNAL_SERVICE_BTN,
CASE_DETAILS_STATUS,
CASE_DETAILS_TAGS,
- CASE_DETAILS_TIMELINE_MARKDOWN,
CASE_DETAILS_USER_ACTION,
CASE_DETAILS_USERNAMES,
PARTICIPANTS,
@@ -103,13 +102,11 @@ describe('Cases', () => {
.should('have.text', case1.reporter);
cy.get(CASE_DETAILS_TAGS).should('have.text', expectedTags);
cy.get(CASE_DETAILS_PUSH_TO_EXTERNAL_SERVICE_BTN).should('have.attr', 'disabled');
- cy.get(CASE_DETAILS_TIMELINE_MARKDOWN).then($element => {
- const timelineLink = $element.prop('href').match(/http(s?):\/\/\w*:\w*(\S*)/)[0];
- openCaseTimeline(timelineLink);
- cy.get(TIMELINE_TITLE).should('have.attr', 'value', case1.timeline.title);
- cy.get(TIMELINE_DESCRIPTION).should('have.attr', 'value', case1.timeline.description);
- cy.get(TIMELINE_QUERY).should('have.attr', 'value', case1.timeline.query);
- });
+ openCaseTimeline();
+
+ cy.get(TIMELINE_TITLE).should('have.attr', 'value', case1.timeline.title);
+ cy.get(TIMELINE_DESCRIPTION).should('have.attr', 'value', case1.timeline.description);
+ cy.get(TIMELINE_QUERY).should('have.attr', 'value', case1.timeline.query);
});
});
diff --git a/x-pack/plugins/siem/cypress/screens/case_details.ts b/x-pack/plugins/siem/cypress/screens/case_details.ts
index 32bb64e93b05f..f2cdaa6994356 100644
--- a/x-pack/plugins/siem/cypress/screens/case_details.ts
+++ b/x-pack/plugins/siem/cypress/screens/case_details.ts
@@ -17,7 +17,7 @@ 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_MARKDOWN = '[data-test-subj="markdown-link"]';
+export const CASE_DETAILS_TIMELINE_LINK_MARKDOWN = '[data-test-subj="markdown-timeline-link"]';
export const CASE_DETAILS_USER_ACTION = '[data-test-subj="user-action-title"] .euiFlexItem';
diff --git a/x-pack/plugins/siem/cypress/tasks/case_details.ts b/x-pack/plugins/siem/cypress/tasks/case_details.ts
index a28f8b8010adb..976d568ab3a91 100644
--- a/x-pack/plugins/siem/cypress/tasks/case_details.ts
+++ b/x-pack/plugins/siem/cypress/tasks/case_details.ts
@@ -5,10 +5,9 @@
*/
import { TIMELINE_TITLE } from '../screens/timeline';
+import { CASE_DETAILS_TIMELINE_LINK_MARKDOWN } from '../screens/case_details';
-export const openCaseTimeline = (link: string) => {
- cy.visit('/app/kibana');
- cy.visit(link);
- cy.contains('a', 'SIEM');
+export const openCaseTimeline = () => {
+ cy.get(CASE_DETAILS_TIMELINE_LINK_MARKDOWN).click();
cy.get(TIMELINE_TITLE).should('exist');
};
diff --git a/x-pack/plugins/siem/public/cases/components/user_action_tree/user_action_markdown.test.tsx b/x-pack/plugins/siem/public/cases/components/user_action_tree/user_action_markdown.test.tsx
new file mode 100644
index 0000000000000..27438207bed97
--- /dev/null
+++ b/x-pack/plugins/siem/public/cases/components/user_action_tree/user_action_markdown.test.tsx
@@ -0,0 +1,79 @@
+/*
+ * 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 { Router, mockHistory } from '../__mock__/router';
+import { UserActionMarkdown } from './user_action_markdown';
+import { TestProviders } from '../../../common/mock';
+import * as timelineHelpers from '../../../timelines/components/open_timeline/helpers';
+import { useApolloClient } from '../../../common/utils/apollo_context';
+const mockUseApolloClient = useApolloClient as jest.Mock;
+jest.mock('../../../common/utils/apollo_context');
+const onChangeEditable = jest.fn();
+const onSaveContent = jest.fn();
+
+const timelineId = '1e10f150-949b-11ea-b63c-2bc51864784c';
+const defaultProps = {
+ content: `A link to a timeline [timeline](http://localhost:5601/app/siem#/timelines?timeline=(id:'${timelineId}',isOpen:!t))`,
+ id: 'markdown-id',
+ isEditable: false,
+ onChangeEditable,
+ onSaveContent,
+};
+
+describe('UserActionMarkdown ', () => {
+ const queryTimelineByIdSpy = jest.spyOn(timelineHelpers, 'queryTimelineById');
+ beforeEach(() => {
+ mockUseApolloClient.mockClear();
+ jest.resetAllMocks();
+ });
+
+ it('Opens timeline when timeline link clicked - isEditable: false', async () => {
+ const wrapper = mount(
+
+
+
+
+
+ );
+ wrapper
+ .find(`[data-test-subj="markdown-timeline-link"]`)
+ .first()
+ .simulate('click');
+
+ expect(queryTimelineByIdSpy).toBeCalledWith({
+ apolloClient: mockUseApolloClient(),
+ timelineId,
+ updateIsLoading: expect.any(Function),
+ updateTimeline: expect.any(Function),
+ });
+ });
+
+ it('Opens timeline when timeline link clicked - isEditable: true ', async () => {
+ const wrapper = mount(
+
+
+
+
+
+ );
+ wrapper
+ .find(`[data-test-subj="preview-tab"]`)
+ .first()
+ .simulate('click');
+ wrapper
+ .find(`[data-test-subj="markdown-timeline-link"]`)
+ .first()
+ .simulate('click');
+ expect(queryTimelineByIdSpy).toBeCalledWith({
+ apolloClient: mockUseApolloClient(),
+ timelineId,
+ updateIsLoading: expect.any(Function),
+ updateTimeline: expect.any(Function),
+ });
+ });
+});
diff --git a/x-pack/plugins/siem/public/cases/components/user_action_tree/user_action_markdown.tsx b/x-pack/plugins/siem/public/cases/components/user_action_tree/user_action_markdown.tsx
index 23d8d8f1a7e68..03dd599da88e5 100644
--- a/x-pack/plugins/siem/public/cases/components/user_action_tree/user_action_markdown.tsx
+++ b/x-pack/plugins/siem/public/cases/components/user_action_tree/user_action_markdown.tsx
@@ -8,6 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/e
import React, { useCallback } from 'react';
import styled, { css } from 'styled-components';
+import { useDispatch } from 'react-redux';
import * as i18n from '../case_view/translations';
import { Markdown } from '../../../common/components/markdown';
import { Form, useForm, UseField } from '../../../shared_imports';
@@ -15,6 +16,13 @@ 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 {
+ dispatchUpdateTimeline,
+ queryTimelineById,
+} from '../../../timelines/components/open_timeline/helpers';
+
+import { updateIsLoading as dispatchUpdateIsLoading } from '../../../timelines/store/timeline/actions';
+import { useApolloClient } from '../../../common/utils/apollo_context';
const ContentWrapper = styled.div`
${({ theme }) => css`
@@ -36,6 +44,8 @@ export const UserActionMarkdown = ({
onChangeEditable,
onSaveContent,
}: UserActionMarkdownProps) => {
+ const dispatch = useDispatch();
+ const apolloClient = useApolloClient();
const { form } = useForm({
defaultValue: { content },
options: { stripEmptyFields: false },
@@ -49,6 +59,24 @@ export const UserActionMarkdown = ({
onChangeEditable(id);
}, [id, onChangeEditable]);
+ const handleTimelineClick = useCallback(
+ (timelineId: string) => {
+ queryTimelineById({
+ apolloClient,
+ timelineId,
+ updateIsLoading: ({
+ id: currentTimelineId,
+ isLoading,
+ }: {
+ id: string;
+ isLoading: boolean;
+ }) => dispatch(dispatchUpdateIsLoading({ id: currentTimelineId, isLoading })),
+ updateTimeline: dispatchUpdateTimeline(dispatch),
+ });
+ },
+ [apolloClient]
+ );
+
const handleSaveAction = useCallback(async () => {
const { isValid, data } = await form.submit();
if (isValid) {
@@ -98,6 +126,7 @@ export const UserActionMarkdown = ({
cancelAction: handleCancelAction,
saveAction: handleSaveAction,
}),
+ onClickTimeline: handleTimelineClick,
onCursorPositionUpdate: handleCursorChange,
topRightContent: (
) : (
-
+
);
};
diff --git a/x-pack/plugins/siem/public/common/components/markdown/index.test.tsx b/x-pack/plugins/siem/public/common/components/markdown/index.test.tsx
index 89af9202a597e..bbf59177bcf04 100644
--- a/x-pack/plugins/siem/public/common/components/markdown/index.test.tsx
+++ b/x-pack/plugins/siem/public/common/components/markdown/index.test.tsx
@@ -164,5 +164,37 @@ describe('Markdown', () => {
expect(wrapper).toMatchSnapshot();
});
+
+ describe('markdown timeline links', () => {
+ const timelineId = '1e10f150-949b-11ea-b63c-2bc51864784c';
+ const markdownWithTimelineLink = `A link to a timeline [timeline](http://localhost:5601/app/siem#/timelines?timeline=(id:'${timelineId}',isOpen:!t))`;
+ const onClickTimeline = jest.fn();
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+ test('it renders a timeline link without href when provided the onClickTimeline argument', () => {
+ const wrapper = mount(
+
+ );
+
+ expect(
+ wrapper
+ .find('[data-test-subj="markdown-timeline-link"]')
+ .first()
+ .getDOMNode()
+ ).not.toHaveProperty('href');
+ });
+ test('timeline link onClick calls onClickTimeline with timelineId', () => {
+ const wrapper = mount(
+
+ );
+ wrapper
+ .find('[data-test-subj="markdown-timeline-link"]')
+ .first()
+ .simulate('click');
+
+ expect(onClickTimeline).toHaveBeenCalledWith(timelineId);
+ });
+ });
});
});
diff --git a/x-pack/plugins/siem/public/common/components/markdown/index.tsx b/x-pack/plugins/siem/public/common/components/markdown/index.tsx
index 8e051685af56d..1a4c9cb71a77e 100644
--- a/x-pack/plugins/siem/public/common/components/markdown/index.tsx
+++ b/x-pack/plugins/siem/public/common/components/markdown/index.tsx
@@ -10,6 +10,7 @@ import { EuiLink, EuiTableRow, EuiTableRowCell, EuiText, EuiToolTip } from '@ela
import React from 'react';
import ReactMarkdown from 'react-markdown';
import styled, { css } from 'styled-components';
+import * as i18n from './translations';
const TableHeader = styled.thead`
font-weight: bold;
@@ -37,8 +38,9 @@ const REL_NOREFERRER = 'noreferrer';
export const Markdown = React.memo<{
disableLinks?: boolean;
raw?: string;
+ onClickTimeline?: (timelineId: string) => void;
size?: 'xs' | 's' | 'm';
-}>(({ disableLinks = false, raw, size = 's' }) => {
+}>(({ disableLinks = false, onClickTimeline, raw, size = 's' }) => {
const markdownRenderers = {
root: ({ children }: { children: React.ReactNode[] }) => (
@@ -59,18 +61,33 @@ export const Markdown = React.memo<{
tableCell: ({ children }: { children: React.ReactNode[] }) => (
{children}
),
- link: ({ children, href }: { children: React.ReactNode[]; href?: string }) => (
-
-
- {children}
-
-
- ),
+ link: ({ children, href }: { children: React.ReactNode[]; href?: string }) => {
+ if (onClickTimeline != null && href != null && href.indexOf(`timelines?timeline=(id:`) > -1) {
+ const timelineId = href.split('timelines?timeline=(id:')[1].split("'")[1] ?? '';
+ return (
+
+ onClickTimeline(timelineId)}
+ data-test-subj="markdown-timeline-link"
+ >
+ {children}
+
+
+ );
+ }
+ return (
+
+
+ {children}
+
+
+ );
+ },
blockquote: ({ children }: { children: React.ReactNode[] }) => (
{children}
),
diff --git a/x-pack/plugins/siem/public/common/components/markdown/translations.ts b/x-pack/plugins/siem/public/common/components/markdown/translations.ts
index cfd9e9ef1b106..4524d27739ea8 100644
--- a/x-pack/plugins/siem/public/common/components/markdown/translations.ts
+++ b/x-pack/plugins/siem/public/common/components/markdown/translations.ts
@@ -51,3 +51,11 @@ export const MARKDOWN_HINT_STRIKETHROUGH = i18n.translate(
export const MARKDOWN_HINT_IMAGE_URL = i18n.translate('xpack.siem.markdown.hint.imageUrlLabel', {
defaultMessage: '![image](url)',
});
+
+export const TIMELINE_ID = (timelineId: string) =>
+ i18n.translate('xpack.siem.markdown.toolTip.timelineId', {
+ defaultMessage: 'Timeline id: { timelineId }',
+ values: {
+ timelineId,
+ },
+ });
diff --git a/x-pack/plugins/siem/public/common/components/markdown_editor/form.tsx b/x-pack/plugins/siem/public/common/components/markdown_editor/form.tsx
index 2ed85b04fe3f6..f9efbc5705b92 100644
--- a/x-pack/plugins/siem/public/common/components/markdown_editor/form.tsx
+++ b/x-pack/plugins/siem/public/common/components/markdown_editor/form.tsx
@@ -16,6 +16,7 @@ interface IMarkdownEditorForm {
field: FieldHook;
idAria: string;
isDisabled: boolean;
+ onClickTimeline?: (timelineId: string) => void;
onCursorPositionUpdate?: (cursorPosition: CursorPosition) => void;
placeholder?: string;
topRightContent?: React.ReactNode;
@@ -26,6 +27,7 @@ export const MarkdownEditorForm = ({
field,
idAria,
isDisabled = false,
+ onClickTimeline,
onCursorPositionUpdate,
placeholder,
topRightContent,
@@ -55,6 +57,7 @@ export const MarkdownEditorForm = ({
content={field.value as string}
isDisabled={isDisabled}
onChange={handleContentChange}
+ onClickTimeline={onClickTimeline}
onCursorPositionUpdate={onCursorPositionUpdate}
placeholder={placeholder}
topRightContent={topRightContent}
diff --git a/x-pack/plugins/siem/public/common/components/markdown_editor/index.tsx b/x-pack/plugins/siem/public/common/components/markdown_editor/index.tsx
index 4fb7086e82b28..b0df2b6b5b60f 100644
--- a/x-pack/plugins/siem/public/common/components/markdown_editor/index.tsx
+++ b/x-pack/plugins/siem/public/common/components/markdown_editor/index.tsx
@@ -74,6 +74,7 @@ export const MarkdownEditor = React.memo<{
content: string;
isDisabled?: boolean;
onChange: (description: string) => void;
+ onClickTimeline?: (timelineId: string) => void;
onCursorPositionUpdate?: (cursorPosition: CursorPosition) => void;
placeholder?: string;
}>(
@@ -83,6 +84,7 @@ export const MarkdownEditor = React.memo<{
content,
isDisabled = false,
onChange,
+ onClickTimeline,
placeholder,
onCursorPositionUpdate,
}) => {
@@ -125,9 +127,10 @@ export const MarkdownEditor = React.memo<{
{
id: 'preview',
name: i18n.PREVIEW,
+ 'data-test-subj': 'preview-tab',
content: (
-
+
),
},
diff --git a/x-pack/plugins/siem/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/siem/public/timelines/components/open_timeline/helpers.ts
index df433f147490e..30a88c58afff8 100644
--- a/x-pack/plugins/siem/public/timelines/components/open_timeline/helpers.ts
+++ b/x-pack/plugins/siem/public/timelines/components/open_timeline/helpers.ts
@@ -189,7 +189,7 @@ export const formatTimelineResultToModel = (
export interface QueryTimelineById {
apolloClient: ApolloClient | ApolloClient<{}> | undefined;
- duplicate: boolean;
+ duplicate?: boolean;
timelineId: string;
onOpenTimeline?: (timeline: TimelineModel) => void;
openTimeline?: boolean;