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 45e46b2d7d2db..5bffebbefa40f 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,24 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, - EuiButton, - EuiMarkdownFormat, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/eui'; import React, { useCallback } from 'react'; import styled from 'styled-components'; import * as i18n from '../case_view/translations'; import { Form, useForm, UseField } from '../../../shared_imports'; import { schema, Content } from './schema'; -import { - MarkdownEditorForm, - parsingPlugins, - processingPlugins, -} from '../../../common/components/markdown_editor/eui_form'; +import { MarkdownRenderer, MarkdownEditorForm } from '../../../common/components/markdown_editor'; const ContentWrapper = styled.div` padding: ${({ theme }) => `${theme.eui.euiSizeM} ${theme.eui.euiSizeL}`}; @@ -111,12 +101,7 @@ export const UserActionMarkdown = ({ ) : ( - - {content} - + {content} ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/markdown/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 4850547f30c52..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/markdown/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,58 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Markdown markdown links it renders the expected content containing a link 1`] = ` - -`; - -exports[`Markdown markdown tables it renders the expected table content 1`] = ` - -`; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown/__snapshots__/markdown_hint.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/markdown/__snapshots__/markdown_hint.test.tsx.snap deleted file mode 100644 index 7f350072439c5..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/markdown/__snapshots__/markdown_hint.test.tsx.snap +++ /dev/null @@ -1,55 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`MarkdownHintComponent rendering it renders the expected hints 1`] = ` - - - # heading - - - **bold** - - - _italics_ - - - \`code\` - - - [link](url) - - - * bullet - - - \`\`\`preformatted\`\`\` - - - >quote - - ~~ - - strikethrough - - ~~ - - ![image](url) - - -`; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/markdown/index.test.tsx deleted file mode 100644 index e30391982ee7a..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/markdown/index.test.tsx +++ /dev/null @@ -1,176 +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, shallow } from 'enzyme'; -import React from 'react'; - -import { Markdown } from '.'; - -describe('Markdown', () => { - test(`it renders when raw markdown is NOT provided`, () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="markdown"]').exists()).toEqual(true); - }); - - test('it renders plain text', () => { - const raw = 'this has no special markdown formatting'; - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="markdown-root"]').first().text()).toEqual(raw); - }); - - test('it applies the EUI text style to all markdown content', () => { - const wrapper = mount(); - - expect( - wrapper.find('[data-test-subj="markdown-root"]').first().childAt(0).hasClass('euiText') - ).toBe(true); - }); - - describe('markdown tables', () => { - const headerColumns = ['we', 'support', 'markdown', 'tables']; - const header = `| ${headerColumns[0]} | ${headerColumns[1]} | ${headerColumns[2]} | ${headerColumns[3]} |`; - - const rawTable = `${header}\n|---------|---------|------------|--------|\n| because | tables | are | pretty |\n| useful | for | formatting | data |`; - - test('it applies EUI table styling to tables', () => { - const wrapper = mount(); - - expect(wrapper.find('table').first().childAt(0).hasClass('euiTable')).toBe(true); - }); - - headerColumns.forEach((headerText) => { - test(`it renders the "${headerText}" table header`, () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="markdown-table-header"]').first().text()).toContain( - headerText - ); - }); - }); - - test('it applies EUI table styling to table rows', () => { - const wrapper = mount(); - - expect( - wrapper - .find('[data-test-subj="markdown-table-row"]') - .first() - .childAt(0) - .hasClass('euiTableRow') - ).toBe(true); - }); - - test('it applies EUI table styling to table cells', () => { - const wrapper = mount(); - - expect( - wrapper - .find('[data-test-subj="markdown-table-cell"]') - .first() - .childAt(0) - .hasClass('euiTableRowCell') - ).toBe(true); - }); - - test('it renders the expected table content', () => { - const wrapper = shallow(); - - expect(wrapper).toMatchSnapshot(); - }); - }); - - describe('markdown links', () => { - const markdownWithLink = 'A link to an external site [External Site](https://google.com)'; - - test('it renders the expected link text', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="markdown-link"]').first().text()).toEqual( - 'External Site' - ); - }); - - test('it renders the expected href', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( - 'href', - 'https://google.com/' - ); - }); - - test('it does NOT render the href if links are disabled', () => { - const wrapper = mount(); - - expect( - wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode() - ).not.toHaveProperty('href'); - }); - - test('it opens links in a new tab via target="_blank"', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( - 'target', - '_blank' - ); - }); - - test('it sets the link `rel` attribute to `noopener` to prevent the new page from accessing `window.opener`, `nofollow` to note the link is not endorsed by us, and noreferrer to prevent the browser from sending the current address', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( - 'rel', - 'nofollow noopener noreferrer' - ); - }); - - test('it renders the expected content containing a link', () => { - const wrapper = shallow(); - - 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, ''); - }); - - test('timeline link onClick calls onClickTimeline with timelineId and graphEventId', () => { - const graphEventId = '2bc51864784c'; - const markdownWithTimelineAndGraphEventLink = `A link to a timeline [timeline](http://localhost:5601/app/siem#/timelines?timeline=(id:'${timelineId}',isOpen:!t,graphEventId:'${graphEventId}'))`; - - const wrapper = mount( - - ); - wrapper.find('[data-test-subj="markdown-timeline-link"]').first().simulate('click'); - - expect(onClickTimeline).toHaveBeenCalledWith(timelineId, graphEventId); - }); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/markdown/index.tsx b/x-pack/plugins/security_solution/public/common/components/markdown/index.tsx deleted file mode 100644 index 1d73c3cb8a2aa..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/markdown/index.tsx +++ /dev/null @@ -1,110 +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. - */ - -/* eslint-disable react/display-name */ - -import { EuiLink, EuiTableRow, EuiTableRowCell, EuiText, EuiToolTip } from '@elastic/eui'; -import { clone } from 'lodash/fp'; -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; -`; - -const MyBlockquote = styled.div` - ${({ theme }) => css` - padding: 0 ${theme.eui.euiSize}; - color: ${theme.eui.euiColorMediumShade}; - border-left: ${theme.eui.euiSizeXS} solid ${theme.eui.euiColorLightShade}; - `} -`; - -TableHeader.displayName = 'TableHeader'; - -/** prevents links to the new pages from accessing `window.opener` */ -const REL_NOOPENER = 'noopener'; - -/** prevents search engine manipulation by noting the linked document is not trusted or endorsed by us */ -const REL_NOFOLLOW = 'nofollow'; - -/** prevents the browser from sending the current address as referrer via the Referer HTTP header */ -const REL_NOREFERRER = 'noreferrer'; - -export const Markdown = React.memo<{ - disableLinks?: boolean; - raw?: string; - onClickTimeline?: (timelineId: string, graphEventId?: string) => void; - size?: 'xs' | 's' | 'm'; -}>(({ disableLinks = false, onClickTimeline, raw, size = 's' }) => { - const markdownRenderers = { - root: ({ children }: { children: React.ReactNode[] }) => ( - - {children} - - ), - table: ({ children }: { children: React.ReactNode[] }) => ( - - {children} -
- ), - tableHead: ({ children }: { children: React.ReactNode[] }) => ( - {children} - ), - tableRow: ({ children }: { children: React.ReactNode[] }) => ( - {children} - ), - tableCell: ({ children }: { children: React.ReactNode[] }) => ( - {children} - ), - link: ({ children, href }: { children: React.ReactNode[]; href?: string }) => { - if (onClickTimeline != null && href != null && href.indexOf(`timelines?timeline=(id:`) > -1) { - const timelineId = clone(href).split('timeline=(id:')[1].split("'")[1] ?? ''; - const graphEventId = href.includes('graphEventId:') - ? clone(href).split('graphEventId:')[1].split("'")[1] ?? '' - : ''; - return ( - - onClickTimeline(timelineId, graphEventId)} - data-test-subj="markdown-timeline-link" - > - {children} - - - ); - } - return ( - - - {children} - - - ); - }, - blockquote: ({ children }: { children: React.ReactNode[] }) => ( - {children} - ), - }; - - return ( - - ); -}); - -Markdown.displayName = 'Markdown'; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown/markdown_hint.test.tsx b/x-pack/plugins/security_solution/public/common/components/markdown/markdown_hint.test.tsx deleted file mode 100644 index 5ec37f8aed0cb..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/markdown/markdown_hint.test.tsx +++ /dev/null @@ -1,89 +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, shallow } from 'enzyme'; -import React from 'react'; - -import { MarkdownHintComponent } from './markdown_hint'; - -describe('MarkdownHintComponent ', () => { - test('it has inline visibility when show is true', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="markdown-hint"]').first()).toHaveStyleRule( - 'visibility', - 'inline' - ); - }); - - test('it has hidden visibility when show is false', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="markdown-hint"]').first()).toHaveStyleRule( - 'visibility', - 'hidden' - ); - }); - - test('it renders the heading hint', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="heading-hint"]').first().text()).toEqual('# heading'); - }); - - test('it renders the bold hint with a bold font-weight', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="bold-hint"]').first()).toHaveStyleRule( - 'font-weight', - 'bold' - ); - }); - - test('it renders the italic hint with an italic font-style', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="italic-hint"]').first()).toHaveStyleRule( - 'font-style', - 'italic' - ); - }); - - test('it renders the code hint with a monospace font family', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="code-hint"]').first()).toHaveStyleRule( - 'font-family', - 'monospace' - ); - }); - - test('it renders the preformatted hint with a monospace font family', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="preformatted-hint"]').first()).toHaveStyleRule( - 'font-family', - 'monospace' - ); - }); - - test('it renders the strikethrough hint with a line-through text-decoration', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="strikethrough-hint"]').first()).toHaveStyleRule( - 'text-decoration', - 'line-through' - ); - }); - - describe('rendering', () => { - test('it renders the expected hints', () => { - const wrapper = shallow(); - - expect(wrapper).toMatchSnapshot(); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/markdown/markdown_hint.tsx b/x-pack/plugins/security_solution/public/common/components/markdown/markdown_hint.tsx deleted file mode 100644 index 199059670e4bd..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/markdown/markdown_hint.tsx +++ /dev/null @@ -1,92 +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 { EuiText } from '@elastic/eui'; -import React from 'react'; -import styled from 'styled-components'; - -import * as i18n from './translations'; - -const Heading = styled.span` - margin-right: 5px; -`; - -Heading.displayName = 'Heading'; - -const Bold = styled.span` - font-weight: bold; - margin-right: 5px; -`; - -Bold.displayName = 'Bold'; - -const MarkdownHintContainer = styled(EuiText)<{ visibility: string }>` - visibility: ${({ visibility }) => visibility}; -`; - -MarkdownHintContainer.displayName = 'MarkdownHintContainer'; - -const ImageUrl = styled.span` - margin-left: 5px; -`; - -ImageUrl.displayName = 'ImageUrl'; - -const Italic = styled.span` - font-style: italic; - margin-right: 5px; -`; - -Italic.displayName = 'Italic'; - -const Strikethrough = styled.span` - text-decoration: line-through; -`; - -Strikethrough.displayName = 'Strikethrough'; - -const Code = styled.span` - font-family: monospace; - margin-right: 5px; -`; - -Code.displayName = 'Code'; - -const TrailingWhitespace = styled.span` - margin-right: 5px; -`; - -TrailingWhitespace.displayName = 'TrailingWhitespace'; - -export const MarkdownHintComponent = ({ show }: { show: boolean }) => ( - - {i18n.MARKDOWN_HINT_HEADING} - {i18n.MARKDOWN_HINT_BOLD} - {i18n.MARKDOWN_HINT_ITALICS} - {i18n.MARKDOWN_HINT_CODE} - {i18n.MARKDOWN_HINT_URL} - {i18n.MARKDOWN_HINT_BULLET} - {i18n.MARKDOWN_HINT_PREFORMATTED} - {i18n.MARKDOWN_HINT_QUOTE} - {'~~'} - - {i18n.MARKDOWN_HINT_STRIKETHROUGH} - - {'~~'} - {i18n.MARKDOWN_HINT_IMAGE_URL} - -); - -MarkdownHintComponent.displayName = 'MarkdownHintComponent'; - -export const MarkdownHint = React.memo(MarkdownHintComponent); - -MarkdownHint.displayName = 'MarkdownHint'; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown/translations.ts b/x-pack/plugins/security_solution/public/common/components/markdown/translations.ts deleted file mode 100644 index 98d2e7d47b3fb..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/markdown/translations.ts +++ /dev/null @@ -1,76 +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 { i18n } from '@kbn/i18n'; - -export const MARKDOWN_HINT_HEADING = i18n.translate( - 'xpack.securitySolution.markdown.hint.headingLabel', - { - defaultMessage: '# heading', - } -); - -export const MARKDOWN_HINT_BOLD = i18n.translate('xpack.securitySolution.markdown.hint.boldLabel', { - defaultMessage: '**bold**', -}); - -export const MARKDOWN_HINT_ITALICS = i18n.translate( - 'xpack.securitySolution.markdown.hint.italicsLabel', - { - defaultMessage: '_italics_', - } -); - -export const MARKDOWN_HINT_CODE = i18n.translate('xpack.securitySolution.markdown.hint.codeLabel', { - defaultMessage: '`code`', -}); - -export const MARKDOWN_HINT_URL = i18n.translate('xpack.securitySolution.markdown.hint.urlLabel', { - defaultMessage: '[link](url)', -}); - -export const MARKDOWN_HINT_BULLET = i18n.translate( - 'xpack.securitySolution.markdown.hint.bulletLabel', - { - defaultMessage: '* bullet', - } -); - -export const MARKDOWN_HINT_PREFORMATTED = i18n.translate( - 'xpack.securitySolution.markdown.hint.preformattedLabel', - { - defaultMessage: '```preformatted```', - } -); - -export const MARKDOWN_HINT_QUOTE = i18n.translate( - 'xpack.securitySolution.markdown.hint.quoteLabel', - { - defaultMessage: '>quote', - } -); - -export const MARKDOWN_HINT_STRIKETHROUGH = i18n.translate( - 'xpack.securitySolution.markdown.hint.strikethroughLabel', - { - defaultMessage: 'strikethrough', - } -); - -export const MARKDOWN_HINT_IMAGE_URL = i18n.translate( - 'xpack.securitySolution.markdown.hint.imageUrlLabel', - { - defaultMessage: '![image](url)', - } -); - -export const TIMELINE_ID = (timelineId: string) => - i18n.translate('xpack.securitySolution.markdown.toolTip.timelineId', { - defaultMessage: 'Timeline id: { timelineId }', - values: { - timelineId, - }, - }); diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/editor.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/editor.tsx new file mode 100644 index 0000000000000..b8632de71abe1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/editor.tsx @@ -0,0 +1,51 @@ +/* + * 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, useState, useCallback } from 'react'; +import { EuiMarkdownEditor } from '@elastic/eui'; + +import { uiPlugins, parsingPlugins, processingPlugins } from './plugins'; + +interface MarkdownEditorProps { + onChange: (content: string) => void; + value: string; + ariaLabel: string; + editorId?: string; + dataTestSubj?: string; + height?: number; +} + +const MarkdownEditorComponent: React.FC = ({ + onChange, + value, + ariaLabel, + editorId, + dataTestSubj, + height, +}) => { + const [markdownErrorMessages, setMarkdownErrorMessages] = useState([]); + const onParse = useCallback((err, { messages }) => { + setMarkdownErrorMessages(err ? [err] : messages); + }, []); + + return ( + + ); +}; + +export const MarkdownEditor = memo(MarkdownEditorComponent); 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 index 481ed7892a8be..a28abbc8a59e4 100644 --- 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 @@ -4,20 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useCallback } from 'react'; +import React from 'react'; import styled from 'styled-components'; -import { - EuiMarkdownEditor, - EuiMarkdownEditorProps, - EuiFormRow, - EuiFlexItem, - EuiFlexGroup, - getDefaultEuiMarkdownParsingPlugins, - getDefaultEuiMarkdownProcessingPlugins, -} from '@elastic/eui'; +import { EuiMarkdownEditorProps, EuiFormRow, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { FieldHook, getFieldValidityAndErrorMessage } from '../../../shared_imports'; -import * as timelineMarkdownPlugin from './plugins/timeline'; +import { MarkdownEditor } from './editor'; type MarkdownEditorFormProps = EuiMarkdownEditorProps & { id: string; @@ -34,12 +26,6 @@ const BottomContentWrapper = styled(EuiFlexGroup)` `} `; -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, @@ -48,10 +34,6 @@ export const MarkdownEditorForm: React.FC = ({ bottomRightContent, }) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); - const [markdownErrorMessages, setMarkdownErrorMessages] = useState([]); - const onParse = useCallback((err, { messages }) => { - setMarkdownErrorMessages(err ? [err] : messages); - }, []); return ( = ({ labelAppend={field.labelAppend} > <> - {bottomRightContent && ( 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 9f4141dbcae7d..41f5aab691a7a 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 @@ -5,3 +5,6 @@ */ export * from './types'; +export * from './renderer'; +export * from './editor'; +export * from './eui_form'; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/markdown_link.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/markdown_link.tsx new file mode 100644 index 0000000000000..f904b63d4bace --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/markdown_link.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, { memo } from 'react'; +import { EuiLink, EuiLinkAnchorProps, EuiToolTip } from '@elastic/eui'; + +type MarkdownLinkProps = { disableLinks?: boolean } & EuiLinkAnchorProps; + +/** prevents search engine manipulation by noting the linked document is not trusted or endorsed by us */ +const REL_NOFOLLOW = 'nofollow'; + +const MarkdownLinkComponent: React.FC = ({ + disableLinks, + href, + target, + children, + ...props +}) => ( + + + {children} + + +); + +export const MarkdownLink = memo(MarkdownLinkComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/index.ts b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/index.ts new file mode 100644 index 0000000000000..b3d91d26e50da --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/index.ts @@ -0,0 +1,21 @@ +/* + * 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 { + getDefaultEuiMarkdownParsingPlugins, + getDefaultEuiMarkdownProcessingPlugins, +} from '@elastic/eui'; + +import * as timelineMarkdownPlugin from './timeline'; + +export const uiPlugins = [timelineMarkdownPlugin.plugin]; +export const parsingPlugins = getDefaultEuiMarkdownParsingPlugins(); +export const processingPlugins = getDefaultEuiMarkdownProcessingPlugins(); + +parsingPlugins.push(timelineMarkdownPlugin.parser); + +// This line of code is TS-compatible and it will break if [1][1] change in the future. +processingPlugins[1][1].components.timeline = timelineMarkdownPlugin.renderer; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/renderer.test.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/renderer.test.tsx new file mode 100644 index 0000000000000..e6a38863d7e5f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/renderer.test.tsx @@ -0,0 +1,61 @@ +/* + * 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 { MarkdownRenderer } from './renderer'; + +describe('Markdown', () => { + describe('markdown links', () => { + const markdownWithLink = 'A link to an external site [External Site](https://google.com)'; + + test('it renders the expected link text', () => { + const wrapper = mount({markdownWithLink}); + + expect(wrapper.find('[data-test-subj="markdown-link"]').first().text()).toEqual( + 'External Site' + ); + }); + + test('it renders the expected href', () => { + const wrapper = mount({markdownWithLink}); + + expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( + 'href', + 'https://google.com/' + ); + }); + + test('it does NOT render the href if links are disabled', () => { + const wrapper = mount( + {markdownWithLink} + ); + + expect( + wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode() + ).not.toHaveProperty('href'); + }); + + test('it opens links in a new tab via target="_blank"', () => { + const wrapper = mount({markdownWithLink}); + + expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( + 'target', + '_blank' + ); + }); + + test('it sets the link `rel` attribute to `noopener` to prevent the new page from accessing `window.opener`, `nofollow` to note the link is not endorsed by us, and noreferrer to prevent the browser from sending the current address', () => { + const wrapper = mount({markdownWithLink}); + + expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( + 'rel', + 'nofollow noopener noreferrer' + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/renderer.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/renderer.tsx new file mode 100644 index 0000000000000..7a7693512afbe --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/renderer.tsx @@ -0,0 +1,40 @@ +/* + * 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 } from 'react'; +import { cloneDeep } from 'lodash/fp'; +import { EuiMarkdownFormat, EuiLinkAnchorProps } from '@elastic/eui'; + +import { parsingPlugins, processingPlugins } from './plugins'; +import { MarkdownLink } from './markdown_link'; + +interface Props { + children: string; + disableLinks?: boolean; +} + +const MarkdownRendererComponent: React.FC = ({ children, disableLinks }) => { + const MarkdownLinkProcessingComponent: React.FC = useMemo( + () => (props) => , + [disableLinks] + ); + + // Deep clone of the processing plugins to prevent affecting the markdown editor. + const processingPluginList = cloneDeep(processingPlugins); + // This line of code is TS-compatible and it will break if [1][1] change in the future. + processingPluginList[1][1].components.a = MarkdownLinkProcessingComponent; + + return ( + + {children} + + ); +}; + +export const MarkdownRenderer = memo(MarkdownRendererComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.test.tsx index 8e398e6236510..757319e7aa1ae 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.test.tsx @@ -154,7 +154,9 @@ describe('StepAboutRuleToggleDetails', () => { .simulate('change', { target: { value: 'notes' } }); expect(wrapper.find('EuiButtonGroup[idSelected="notes"]').exists()).toBeTruthy(); - expect(wrapper.find('Markdown h1').text()).toEqual('this is some markdown documentation'); + expect(wrapper.find('.euiMarkdownFormat').text()).toEqual( + 'this is some markdown documentation' + ); }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.tsx index 8604a5293a710..52e9dc7e44ff7 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.tsx @@ -20,7 +20,7 @@ import styled from 'styled-components'; import { isEmpty } from 'lodash/fp'; import { HeaderSection } from '../../../../common/components/header_section'; -import { Markdown } from '../../../../common/components/markdown'; +import { MarkdownRenderer } from '../../../../common/components/markdown_editor'; import { AboutStepRule, AboutStepRuleDetails } from '../../../pages/detection_engine/rules/types'; import * as i18n from './translations'; import { StepAboutRule } from '../step_about_rule'; @@ -136,7 +136,7 @@ const StepAboutRuleToggleDetailsComponent: React.FC = ({ maxHeight={aboutPanelHeight} className="eui-yScrollWithShadows" > - + {stepDataDetails.note} )} diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_cases/recent_cases.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_cases/recent_cases.tsx index 5867a9d859f04..95f0fbb194ca6 100644 --- a/x-pack/plugins/security_solution/public/overview/components/recent_cases/recent_cases.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/recent_cases/recent_cases.tsx @@ -10,7 +10,7 @@ import styled from 'styled-components'; import { Case } from '../../../cases/containers/types'; import { getCaseDetailsUrl } from '../../../common/components/link_to/redirect_to_case'; -import { Markdown } from '../../../common/components/markdown'; +import { MarkdownRenderer } from '../../../common/components/markdown_editor'; import { useFormatUrl } from '../../../common/components/link_to'; import { IconWithCount } from '../recent_timelines/counts'; import { LinkAnchor } from '../../../common/components/links'; @@ -52,7 +52,7 @@ const RecentCasesComponent = ({ cases }: { cases: Case[] }) => { {c.description && c.description.length && ( - + {c.description} )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/index.test.tsx.snap index 718e7ce1d27a5..53bc76bfeb8e8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/index.test.tsx.snap @@ -11,13 +11,6 @@ exports[`AddNote renders correctly 1`] = ` noteInputHeight={200} updateNewNote={[MockFunction]} /> - - - diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/new_note.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/new_note.test.tsx.snap index 9bf2b5c65e829..69e06bc7e0d1b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/new_note.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/new_note.test.tsx.snap @@ -3,54 +3,13 @@ exports[`NewNote renders correctly 1`] = ` , - "id": "note", - "name": "Note", - } - } - tabs={ - Array [ - Object { - "content":