From b40414716ba2c36685fa3d2bc2f0312853306b6d Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Tue, 19 May 2020 10:36:34 -0600 Subject: [PATCH] [SIEM] Cases] Capture timeline click and open timeline in case view (#66327) (#66933) --- .../siem/cypress/integration/cases.spec.ts | 13 ++- .../siem/cypress/screens/case_details.ts | 2 +- .../siem/cypress/tasks/case_details.ts | 7 +- .../user_action_markdown.test.tsx | 79 +++++++++++++++++++ .../user_action_tree/user_action_markdown.tsx | 35 +++++++- .../common/components/markdown/index.test.tsx | 32 ++++++++ .../common/components/markdown/index.tsx | 43 +++++++--- .../components/markdown/translations.ts | 8 ++ .../components/markdown_editor/form.tsx | 3 + .../components/markdown_editor/index.tsx | 5 +- .../components/open_timeline/helpers.ts | 2 +- 11 files changed, 200 insertions(+), 29 deletions(-) create mode 100644 x-pack/plugins/siem/public/cases/components/user_action_tree/user_action_markdown.test.tsx diff --git a/x-pack/plugins/siem/cypress/integration/cases.spec.ts b/x-pack/plugins/siem/cypress/integration/cases.spec.ts index e11d76d8f608a1..8f35a3209c69d7 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 32bb64e93b05f1..f2cdaa69943565 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 a28f8b8010adb0..976d568ab3a918 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 00000000000000..27438207bed975 --- /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 23d8d8f1a7e680..03dd599da88e5b 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 89af9202a597ef..bbf59177bcf049 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 8e051685af56d9..1a4c9cb71a77ed 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 cfd9e9ef1b1067..4524d27739ea8e 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 2ed85b04fe3f6e..f9efbc5705b926 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 4fb7086e82b283..b0df2b6b5b60f5 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 df433f147490e4..30a88c58afff85 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;