From 6a5a2150ea894f675e88fb818341fe190beb0f87 Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Tue, 17 Aug 2021 18:18:00 +0200 Subject: [PATCH] [Security solution] [Timeline] Improve timeline title and move description to notes tab (#106544) * Improve timeline title and move description to the notes tab Truncate the title only in the UI When the user hover the title we display the full title Truncate the title if it appears in a table --- .../common/types/timeline/index.ts | 11 +++ .../integration/timelines/creation.spec.ts | 5 +- .../cypress/tasks/timeline.ts | 17 ++-- .../common/components/line_clamp/index.tsx | 23 +---- .../components/markdown_editor/editor.tsx | 11 ++- .../components/scroll_to_top/index.test.tsx | 18 ++++ .../common/components/scroll_to_top/index.tsx | 16 +++- .../public/common/hooks/use_is_overflow.tsx | 32 +++++++ .../public/common/mock/utils.ts | 2 + .../flyout/header/active_timelines.tsx | 8 +- .../components/flyout/header/index.tsx | 83 +++++++++++++++---- .../components/flyout/header/translations.ts | 4 + .../__snapshots__/new_note.test.tsx.snap | 1 + .../components/notes/add_note/index.tsx | 10 ++- .../components/notes/add_note/new_note.tsx | 4 +- .../note_previews/index.test.tsx | 53 +++++++----- .../open_timeline/note_previews/index.tsx | 59 +++++++++++-- .../note_previews/translations.ts | 7 ++ .../timelines_table/common_columns.test.tsx | 9 ++ .../timelines_table/common_columns.tsx | 12 ++- .../timelines/components/timeline/index.tsx | 5 +- .../timeline/notes_tab_content/index.tsx | 29 ++++++- .../timeline/tabs_content/index.tsx | 11 ++- .../timeline/tabs_content/selectors.ts | 3 + .../timelines/store/timeline/actions.ts | 3 +- .../public/timelines/store/timeline/model.ts | 6 ++ .../timelines/store/timeline/reducer.ts | 7 +- 27 files changed, 354 insertions(+), 95 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/hooks/use_is_overflow.tsx diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index 6a3d812b1bf5b..cdd9b35a7fa30 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -460,6 +460,17 @@ export enum TimelineTabs { eql = 'eql', } +/** + * Used for scrolling top inside a tab. Especially when swiching tabs. + */ +export interface ScrollToTopEvent { + /** + * Timestamp of the moment when the event happened. + * The timestamp might be necessary for the scenario where the event could happen multiple times. + */ + timestamp: number; +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any type EmptyObject = Record; diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts index 4203b9125d155..096ac0595d76c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts @@ -16,6 +16,7 @@ import { TIMELINE_FLYOUT_WRAPPER, TIMELINE_PANEL, TIMELINE_TAB_CONTENT_EQL, + TIMELINE_TAB_CONTENT_GRAPHS_NOTES, } from '../../screens/timeline'; import { createTimelineTemplate } from '../../tasks/api_calls/timelines'; @@ -90,7 +91,9 @@ describe('Timelines', (): void => { it('can be added notes', () => { addNotesToTimeline(getTimeline().notes); - cy.get(NOTES_TEXT).should('have.text', getTimeline().notes); + cy.get(TIMELINE_TAB_CONTENT_GRAPHS_NOTES) + .find(NOTES_TEXT) + .should('have.text', getTimeline().notes); }); it('should update timeline after adding eql', () => { diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index d487cf6d00ed3..4a61a94e4acea 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -133,15 +133,16 @@ export const goToQueryTab = () => { export const addNotesToTimeline = (notes: string) => { goToNotesTab().then(() => { - cy.get(NOTES_TEXT_AREA).type(notes); - cy.root() - .pipe(($el) => { - $el.find(ADD_NOTE_BUTTON).trigger('click'); - return $el.find(NOTES_TAB_BUTTON).find('.euiBadge'); - }) - .should('have.text', '1'); + cy.get(NOTES_TAB_BUTTON) + .find('.euiBadge__text') + .then(($el) => { + const notesCount = parseInt($el.text(), 10); + + cy.get(NOTES_TEXT_AREA).type(notes); + cy.get(ADD_NOTE_BUTTON).trigger('click'); + cy.get(`${NOTES_TAB_BUTTON} .euiBadge`).should('have.text', `${notesCount + 1}`); + }); }); - goToQueryTab(); goToNotesTab(); }; diff --git a/x-pack/plugins/security_solution/public/common/components/line_clamp/index.tsx b/x-pack/plugins/security_solution/public/common/components/line_clamp/index.tsx index 372e7fd466b07..17e0262a2cffa 100644 --- a/x-pack/plugins/security_solution/public/common/components/line_clamp/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/line_clamp/index.tsx @@ -6,8 +6,9 @@ */ import { EuiButtonEmpty } from '@elastic/eui'; -import React, { useRef, useState, useEffect, useCallback, ReactNode } from 'react'; +import React, { useState, useCallback, ReactNode } from 'react'; import styled from 'styled-components'; +import { useIsOverflow } from '../../hooks/use_is_overflow'; import * as i18n from './translations'; const LINE_CLAMP = 3; @@ -39,29 +40,13 @@ const LineClampComponent: React.FC<{ children: ReactNode; lineClampHeight?: number; }> = ({ children, lineClampHeight = LINE_CLAMP_HEIGHT }) => { - const [isOverflow, setIsOverflow] = useState(null); const [isExpanded, setIsExpanded] = useState(null); - const descriptionRef = useRef(null); + const [isOverflow, descriptionRef] = useIsOverflow(children); + const toggleReadMore = useCallback(() => { setIsExpanded((prevState) => !prevState); }, []); - useEffect(() => { - if (descriptionRef?.current?.clientHeight != null) { - if ( - (descriptionRef?.current?.scrollHeight ?? 0) > (descriptionRef?.current?.clientHeight ?? 0) - ) { - setIsOverflow(true); - } - - if ( - (descriptionRef?.current?.scrollHeight ?? 0) <= (descriptionRef?.current?.clientHeight ?? 0) - ) { - setIsOverflow(false); - } - } - }, []); - if (isExpanded) { return ( <> 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 index 12084a17e888a..f1fa6dc0fa1ef 100644 --- 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 @@ -17,6 +17,7 @@ interface MarkdownEditorProps { editorId?: string; dataTestSubj?: string; height?: number; + autoFocusDisabled?: boolean; } const MarkdownEditorComponent: React.FC = ({ @@ -26,16 +27,18 @@ const MarkdownEditorComponent: React.FC = ({ editorId, dataTestSubj, height, + autoFocusDisabled = false, }) => { const [markdownErrorMessages, setMarkdownErrorMessages] = useState([]); const onParse = useCallback((err, { messages }) => { setMarkdownErrorMessages(err ? [err] : messages); }, []); - useEffect( - () => document.querySelector('textarea.euiMarkdownEditorTextArea')?.focus(), - [] - ); + useEffect(() => { + if (!autoFocusDisabled) { + document.querySelector('textarea.euiMarkdownEditorTextArea')?.focus(); + } + }, [autoFocusDisabled]); return ( { Object.defineProperty(globalNode.window, 'scroll', { value: null }); Object.defineProperty(globalNode.window, 'scrollTo', { value: spyScrollTo }); mount( useScrollToTop()} />); + expect(spyScrollTo).toHaveBeenCalled(); }); + + test('should not scroll when `shouldScroll` is false', () => { + Object.defineProperty(globalNode.window, 'scroll', { value: spyScroll }); + mount( useScrollToTop(undefined, false)} />); + + expect(spyScrollTo).not.toHaveBeenCalled(); + }); + + test('should scroll the element matching the given selector', () => { + const fakeElement = { scroll: spyScroll }; + Object.defineProperty(globalNode.document, 'querySelector', { + value: () => fakeElement, + }); + mount( useScrollToTop('fake selector')} />); + + expect(spyScroll).toHaveBeenCalledWith(0, 0); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/scroll_to_top/index.tsx b/x-pack/plugins/security_solution/public/common/components/scroll_to_top/index.tsx index d9f80b7e1c3d2..79e5273b9735e 100644 --- a/x-pack/plugins/security_solution/public/common/components/scroll_to_top/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/scroll_to_top/index.tsx @@ -7,14 +7,22 @@ import { useEffect } from 'react'; -export const useScrollToTop = () => { +/** + * containerSelector: The element with scrolling. It defaults to the window. + * shouldScroll: It should be used for conditional scrolling. + */ +export const useScrollToTop = (containerSelector?: string, shouldScroll = true) => { useEffect(() => { + const container = containerSelector ? document.querySelector(containerSelector) : window; + + if (!shouldScroll || !container) return; + // trying to use new API - https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollTo - if (window.scroll) { - window.scroll(0, 0); + if (container.scroll) { + container.scroll(0, 0); } else { // just a fallback for older browsers - window.scrollTo(0, 0); + container.scrollTo(0, 0); } }); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_is_overflow.tsx b/x-pack/plugins/security_solution/public/common/hooks/use_is_overflow.tsx new file mode 100644 index 0000000000000..c191b945cc31e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_is_overflow.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useRef, useState } from 'react'; + +/** + * It checks if the element that receives the returned Ref has oveflow the max height. + */ +export const useIsOverflow: ( + dependency: unknown +) => [isOveflow: boolean | null, ref: React.RefObject] = (dependency) => { + const [isOverflow, setIsOverflow] = useState(null); + const ref = useRef(null); + + useEffect(() => { + if (ref.current?.clientHeight != null) { + if ((ref?.current?.scrollHeight ?? 0) > (ref?.current?.clientHeight ?? 0)) { + setIsOverflow(true); + } + + if ((ref.current?.scrollHeight ?? 0) <= (ref?.current?.clientHeight ?? 0)) { + setIsOverflow(false); + } + } + }, [ref, dependency]); + + return [isOverflow, ref]; +}; diff --git a/x-pack/plugins/security_solution/public/common/mock/utils.ts b/x-pack/plugins/security_solution/public/common/mock/utils.ts index 0d9e2f4f367ec..b1851fd055b33 100644 --- a/x-pack/plugins/security_solution/public/common/mock/utils.ts +++ b/x-pack/plugins/security_solution/public/common/mock/utils.ts @@ -24,6 +24,8 @@ import { defaultHeaders } from '../../timelines/components/timeline/body/column_ interface Global extends NodeJS.Global { // eslint-disable-next-line @typescript-eslint/no-explicit-any window?: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + document?: any; } export const globalNode: Global = global; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx index 64832bf7f039d..4eb91ca8ee272 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx @@ -41,6 +41,12 @@ const StyledEuiButtonEmpty = styled(EuiButtonEmpty)` } `; +const TitleConatiner = styled(EuiFlexItem)` + overflow: hidden; + display: inline-block; + text-overflow: ellipsis; +`; + const ActiveTimelinesComponent: React.FC = ({ timelineId, timelineStatus, @@ -100,7 +106,7 @@ const ActiveTimelinesComponent: React.FC = ({ /> - {title} + {title} {!isOpen && ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx index ee994e2a16f46..e3a1152428d62 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx @@ -12,9 +12,10 @@ import { EuiToolTip, EuiButtonIcon, EuiText, + EuiButtonEmpty, EuiTextColor, } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; +import React, { MouseEventHandler, MouseEvent, useCallback, useMemo } from 'react'; import { isEmpty, get, pick } from 'lodash/fp'; import { useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; @@ -52,7 +53,9 @@ import * as i18n from './translations'; import * as commonI18n from '../../timeline/properties/translations'; import { getTimelineStatusByIdSelector } from './selectors'; import { TimelineKPIs } from './kpis'; -import { LineClamp } from '../../../../common/components/line_clamp'; + +import { setActiveTabTimeline } from '../../../store/timeline/actions'; +import { useIsOverflow } from '../../../../common/hooks/use_is_overflow'; // to hide side borders const StyledPanel = styled(EuiPanel)` @@ -67,6 +70,10 @@ interface FlyoutHeaderPanelProps { timelineId: string; } +const ActiveTimelinesContainer = styled(EuiFlexItem)` + overflow: hidden; +`; + const FlyoutHeaderPanelComponent: React.FC = ({ timelineId }) => { const dispatch = useDispatch(); const { indexPattern, browserFields } = useSourcererScope(SourcererScopeName.timeline); @@ -145,7 +152,7 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline > - + = ({ timeline isOpen={show} updated={updated} /> - + {show && ( @@ -190,6 +197,34 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline export const FlyoutHeaderPanel = React.memo(FlyoutHeaderPanelComponent); +const StyledDiv = styled.div` + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; +`; + +const ReadMoreButton = ({ + description, + onclick, +}: { + description: string; + onclick: MouseEventHandler; +}) => { + const [isOverflow, ref] = useIsOverflow(description); + return ( + <> + {description} + {isOverflow && ( + + {i18n.READ_MORE} + + )} + + ); +}; + const StyledTimelineHeader = styled(EuiFlexGroup)` ${({ theme }) => `margin: ${theme.eui.euiSizeXS} ${theme.eui.euiSizeS} 0 ${theme.eui.euiSizeS};`} flex: 0; @@ -197,6 +232,7 @@ const StyledTimelineHeader = styled(EuiFlexGroup)` const TimelineStatusInfoContainer = styled.span` ${({ theme }) => `margin-left: ${theme.eui.euiSizeS};`} + white-space: nowrap; `; const KpisContainer = styled.div` @@ -208,6 +244,14 @@ const RowFlexItem = styled(EuiFlexItem)` align-items: center; `; +const TimelineTitleContainer = styled.h3` + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + word-break: break-word; +`; + const TimelineNameComponent: React.FC = ({ timelineId }) => { const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const { title, timelineType } = useDeepEqualSelector((state) => @@ -224,9 +268,11 @@ const TimelineNameComponent: React.FC = ({ timelineId }) => { const content = useMemo(() => title || placeholder, [title, placeholder]); return ( - -

{content}

-
+ + + {content} + + ); }; @@ -237,15 +283,24 @@ const TimelineDescriptionComponent: React.FC = ({ timelineId const description = useDeepEqualSelector( (state) => (getTimeline(state, timelineId) ?? timelineDefaults).description ); + const dispatch = useDispatch(); + + const onReadMore: MouseEventHandler = useCallback( + (event: MouseEvent) => { + dispatch( + setActiveTabTimeline({ + id: timelineId, + activeTab: TimelineTabs.notes, + scrollToTop: true, + }) + ); + }, + [dispatch, timelineId] + ); + return ( - {description ? ( - - {description} - - ) : ( - commonI18n.DESCRIPTION - )} + ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts index 7483d0cae71c5..2f0717dea32aa 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts @@ -61,6 +61,10 @@ export const USER_KPI_TITLE = i18n.translate('xpack.securitySolution.timeline.kp defaultMessage: 'Users', }); +export const READ_MORE = i18n.translate('xpack.securitySolution.timeline.properties.readMore', { + defaultMessage: 'Read More', +}); + export const TIMELINE_TOGGLE_BUTTON_ARIA_LABEL = ({ isOpen, title, 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 69e06bc7e0d1b..32e17a19045b1 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 @@ -6,6 +6,7 @@ exports[`NewNote renders correctly 1`] = ` > void; updateNewNote: UpdateInternalNewNote; -}>(({ associateNote, newNote, onCancelAddNote, updateNewNote }) => { + autoFocusDisabled?: boolean; +}>(({ associateNote, newNote, onCancelAddNote, updateNewNote, autoFocusDisabled = false }) => { const dispatch = useDispatch(); const updateNote = useCallback((note: Note) => dispatch(appActions.updateNote({ note })), [ @@ -87,7 +88,12 @@ export const AddNote = React.memo<{

{i18n.YOU_ARE_EDITING_A_NOTE}

- + {onCancelAddNote != null ? ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/new_note.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/new_note.tsx index 761df470e6f4d..bf1a2227f6f99 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/new_note.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/new_note.tsx @@ -24,7 +24,8 @@ export const NewNote = React.memo<{ noteInputHeight: number; note: string; updateNewNote: UpdateInternalNewNote; -}>(({ note, noteInputHeight, updateNewNote }) => { + autoFocusDisabled?: boolean; +}>(({ note, noteInputHeight, updateNewNote, autoFocusDisabled = false }) => { return ( ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx index 0c611ca5106e8..1cca5a3999b81 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx @@ -10,11 +10,21 @@ import moment from 'moment'; import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; import '../../../../common/mock/formatted_relative'; - +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { mockTimelineResults } from '../../../../common/mock/timeline_results'; import { OpenTimelineResult, TimelineResultNote } from '../types'; import { NotePreviews } from '.'; +jest.mock('../../../../common/hooks/use_selector'); + +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => jest.fn(), + }; +}); + describe('NotePreviews', () => { let mockResults: OpenTimelineResult[]; let note1updated: number; @@ -26,6 +36,7 @@ describe('NotePreviews', () => { note1updated = moment('2019-03-24T04:12:33.000Z').valueOf(); note2updated = moment(note1updated).add(1, 'minute').valueOf(); note3updated = moment(note2updated).add(1, 'minute').valueOf(); + (useDeepEqualSelector as jest.Mock).mockReset(); }); test('it renders a note preview for each note when isModal is false', () => { @@ -48,24 +59,6 @@ describe('NotePreviews', () => { }); }); - test('it does NOT render the preview container if notes is undefined', () => { - const wrapper = mountWithIntl(); - - expect(wrapper.find('[data-test-subj="note-previews-container"]').exists()).toBe(false); - }); - - test('it does NOT render the preview container if notes is null', () => { - const wrapper = mountWithIntl(); - - expect(wrapper.find('[data-test-subj="note-previews-container"]').exists()).toBe(false); - }); - - test('it does NOT render the preview container if notes is empty', () => { - const wrapper = mountWithIntl(); - - expect(wrapper.find('[data-test-subj="note-previews-container"]').exists()).toBe(false); - }); - test('it filters-out non-unique savedObjectIds', () => { const nonUniqueNotes: TimelineResultNote[] = [ { @@ -145,4 +138,26 @@ describe('NotePreviews', () => { expect(wrapper.find(`.euiCommentEvent__headerUsername`).at(2).text()).toEqual('bob'); }); + + test('it renders timeline description as a note when showTimelineDescription is true and timelineId is defined', () => { + const timeline = mockTimelineResults[0]; + (useDeepEqualSelector as jest.Mock).mockReturnValue(timeline); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find('[data-test-subj="note-preview-description"]').first().text()).toContain( + timeline.description + ); + }); + + test('it does`t render timeline description as a note when it is undefined', () => { + const timeline = mockTimelineResults[0]; + (useDeepEqualSelector as jest.Mock).mockReturnValue({ ...timeline, description: undefined }); + + const wrapper = mountWithIntl(); + + expect(wrapper.find('[data-test-subj="note-preview-description"]').exists()).toBe(false); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx index 5581ea4e5c165..aff12b74fbfbf 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx @@ -6,7 +6,13 @@ */ import { uniqBy } from 'lodash/fp'; -import { EuiAvatar, EuiButtonIcon, EuiCommentList, EuiScreenReaderOnly } from '@elastic/eui'; +import { + EuiAvatar, + EuiButtonIcon, + EuiCommentList, + EuiScreenReaderOnly, + EuiText, +} from '@elastic/eui'; import { FormattedRelative } from '@kbn/i18n/react'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; @@ -15,12 +21,13 @@ import { useDispatch } from 'react-redux'; import { TimelineResultNote } from '../types'; import { getEmptyValue, defaultToEmptyTag } from '../../../../common/components/empty_value'; import { MarkdownRenderer } from '../../../../common/components/markdown_editor'; -import { timelineActions } from '../../../store/timeline'; +import { timelineActions, timelineSelectors } from '../../../store/timeline'; import { NOTE_CONTENT_CLASS_NAME } from '../../timeline/body/helpers'; import * as i18n from './translations'; import { TimelineTabs } from '../../../../../common/types/timeline'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { sourcererSelectors } from '../../../../common/store'; +import { SaveTimelineButton } from '../../timeline/header/save_timeline_button'; export const NotePreviewsContainer = styled.section` padding-top: ${({ theme }) => `${theme.eui.euiSizeS}`}; @@ -78,10 +85,45 @@ interface NotePreviewsProps { eventIdToNoteIds?: Record; notes?: TimelineResultNote[] | null; timelineId?: string; + showTimelineDescription?: boolean; } export const NotePreviews = React.memo( - ({ eventIdToNoteIds, notes, timelineId }) => { + ({ eventIdToNoteIds, notes, timelineId, showTimelineDescription }) => { + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const timeline = useDeepEqualSelector((state) => + timelineId ? getTimeline(state, timelineId) : null + ); + + const descriptionList = useMemo( + () => + showTimelineDescription && timelineId && timeline?.description + ? [ + { + username: defaultToEmptyTag(timeline.updatedBy), + event: i18n.ADDED_A_DESCRIPTION, + 'data-test-subj': 'note-preview-description', + id: 'note-preview-description', + timestamp: timeline.updated ? ( + + ) : ( + getEmptyValue() + ), + children: {timeline.description}, + timelineIcon: ( + + ), + actions: , + }, + ] + : [], + [timeline, timelineId, showTimelineDescription] + ); + const notesList = useMemo( () => uniqBy('savedObjectId', notes).map((note) => { @@ -125,11 +167,12 @@ export const NotePreviews = React.memo( [eventIdToNoteIds, notes, timelineId] ); - if (notes == null || notes.length === 0) { - return null; - } - - return ; + return ( + + ); } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/translations.ts index 0945050a34a4d..c2d01704c2d9e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/translations.ts @@ -18,6 +18,13 @@ export const ADDED_A_NOTE = i18n.translate('xpack.securitySolution.timeline.adde defaultMessage: 'added a note', }); +export const ADDED_A_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.timeline.addedADescriptionLabel', + { + defaultMessage: 'added description', + } +); + export const AN_UNKNOWN_USER = i18n.translate( 'xpack.securitySolution.timeline.anUnknownUserLabel', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx index 1826413110f1e..bdb55aaf20969 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx @@ -27,6 +27,15 @@ const mockTheme = getMockTheme({ eui: { euiColorMediumShade: '#ece' } }); jest.mock('../../../../common/lib/kibana'); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => jest.fn(), + useSelector: () => jest.fn(), + }; +}); + describe('#getCommonColumns', () => { let mockResults: OpenTimelineResult[]; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx index 65963c9609320..21262d66fdbfe 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx @@ -20,7 +20,7 @@ import { getEmptyTagValue } from '../../../../common/components/empty_value'; import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date'; import { TimelineType } from '../../../../../common/types/timeline'; -const DescriptionCell = styled.span` +const LineClampTextContainer = styled.span` text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 5; @@ -79,7 +79,11 @@ export const getCommonColumns = ({ }) } > - {isUntitled(timelineResult) ? i18n.UNTITLED_TIMELINE : title} + {isUntitled(timelineResult) ? ( + i18n.UNTITLED_TIMELINE + ) : ( + {title} + )} ) : (
@@ -93,9 +97,9 @@ export const getCommonColumns = ({ field: 'description', name: i18n.DESCRIPTION, render: (description: string) => ( - + {description != null && description.trim().length > 0 ? description : getEmptyTagValue()} - + ), sortable: false, }, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index e95efdf754418..e8846d88ef919 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -62,9 +62,9 @@ const StatefulTimelineComponent: React.FC = ({ const containerElement = useRef(null); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const { selectedPatterns } = useSourcererScope(SourcererScopeName.timeline); - const { graphEventId, savedObjectId, timelineType } = useDeepEqualSelector((state) => + const { graphEventId, savedObjectId, timelineType, description } = useDeepEqualSelector((state) => pick( - ['graphEventId', 'savedObjectId', 'timelineType'], + ['graphEventId', 'savedObjectId', 'timelineType', 'description'], getTimeline(state, timelineId) ?? timelineDefaults ) ); @@ -146,6 +146,7 @@ const StatefulTimelineComponent: React.FC = ({ setTimelineFullScreen={setTimelineFullScreen} timelineId={timelineId} timelineType={timelineType} + timelineDescription={description} timelineFullScreen={timelineFullScreen} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx index 2853a5afccdd2..7605bc8607bb0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/notes_tab_content/index.tsx @@ -25,7 +25,10 @@ import styled from 'styled-components'; import { useSourcererScope } from '../../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { timelineActions } from '../../../store/timeline'; -import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { + useDeepEqualSelector, + useShallowEqualSelector, +} from '../../../../common/hooks/use_selector'; import { TimelineStatus, TimelineTabs } from '../../../../../common/types/timeline'; import { appSelectors } from '../../../../common/store/app'; import { AddNote } from '../../notes/add_note'; @@ -35,6 +38,8 @@ import { NotePreviews } from '../../open_timeline/note_previews'; import { TimelineResultNote } from '../../open_timeline/types'; import { getTimelineNoteSelector } from './selectors'; import { DetailsPanel } from '../../side_panel'; +import { getScrollToTopSelector } from '../tabs_content/selectors'; +import { useScrollToTop } from '../../../../common/components/scroll_to_top'; const FullWidthFlexGroup = styled(EuiFlexGroup)` width: 100%; @@ -126,6 +131,12 @@ interface NotesTabContentProps { const NotesTabContentComponent: React.FC = ({ timelineId }) => { const dispatch = useDispatch(); + + const getScrollToTop = useMemo(() => getScrollToTopSelector(), []); + const scrollToTop = useShallowEqualSelector((state) => getScrollToTop(state, timelineId)); + + useScrollToTop('#scrollableNotes', !!scrollToTop); + const getTimelineNotes = useMemo(() => getTimelineNoteSelector(), []); const { createdBy, @@ -207,16 +218,26 @@ const NotesTabContentComponent: React.FC = ({ timelineId } return ( - +

{NOTES}

- + {!isImmutable && ( - + )}
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx index 8cdd7722d7fbd..cfe2af0ab7c31 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx @@ -6,6 +6,7 @@ */ import { EuiBadge, EuiLoadingContent, EuiTabs, EuiTab } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; import React, { lazy, memo, Suspense, useCallback, useEffect, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; @@ -59,6 +60,7 @@ interface BasicTimelineTab { timelineId: TimelineId; timelineType: TimelineType; graphEventId?: string; + timelineDescription: string; } const QueryTab: React.FC<{ @@ -222,6 +224,7 @@ const TabsContentComponent: React.FC = ({ timelineFullScreen, timelineType, graphEventId, + timelineDescription, }) => { const dispatch = useDispatch(); const getActiveTab = useMemo(() => getActiveTabSelector(), []); @@ -233,6 +236,7 @@ const TabsContentComponent: React.FC = ({ const activeTab = useShallowEqualSelector((state) => getActiveTab(state, timelineId)); const showTimeline = useShallowEqualSelector((state) => getShowTimeline(state, timelineId)); + const numberOfPinnedEvents = useShallowEqualSelector((state) => getNumberOfPinnedEvents(state, timelineId) ); @@ -253,8 +257,10 @@ const TabsContentComponent: React.FC = ({ }, [globalTimelineNoteIds, eventIdToNoteIds]); const numberOfNotes = useMemo( - () => appNotes.filter((appNote) => allTimelineNoteIds.includes(appNote.id)).length, - [appNotes, allTimelineNoteIds] + () => + appNotes.filter((appNote) => allTimelineNoteIds.includes(appNote.id)).length + + (isEmpty(timelineDescription) ? 0 : 1), + [appNotes, allTimelineNoteIds, timelineDescription] ); const setQueryAsActiveTab = useCallback(() => { @@ -362,6 +368,7 @@ const TabsContentComponent: React.FC = ({ rowRenderers={rowRenderers} timelineId={timelineId} timelineType={timelineType} + timelineDescription={timelineDescription} /> ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/selectors.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/selectors.ts index ccb07135747f5..04045e94aee25 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/selectors.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/selectors.ts @@ -27,3 +27,6 @@ export const getEventIdToNoteIdsSelector = () => export const getNotesSelector = () => createSelector(selectNotesById, (notesById) => Object.values(notesById)); + +export const getScrollToTopSelector = () => + createSelector(selectTimeline, (timeline) => timeline?.scrollToTop); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts index d5f692cc9dc17..d0d5fdacad312 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts @@ -17,7 +17,7 @@ import { import { KqlMode, TimelineModel } from './model'; import { InsertTimeline } from './types'; import { FieldsEqlOptions } from '../../../../common/search_strategy/timeline'; -import { +import type { TimelineEventsType, RowRendererId, TimelineTabs, @@ -204,6 +204,7 @@ export const updateIndexNames = actionCreator<{ export const setActiveTabTimeline = actionCreator<{ id: string; activeTab: TimelineTabs; + scrollToTop?: boolean; }>('SET_ACTIVE_TAB_TIMELINE'); export const toggleModalSaveTimeline = actionCreator<{ diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts index ef47b474350c7..3c2449a2e787d 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts @@ -11,6 +11,7 @@ import type { TimelineType, TimelineStatus, TimelineTabs, + ScrollToTopEvent, } from '../../../../common/types/timeline'; import { PinnedEvent } from '../../../../common/types/timeline/pinned_event'; import type { TGridModelForTimeline } from '../../../../../timelines/public'; @@ -23,6 +24,9 @@ export type TimelineModel = TGridModelForTimeline & { /** The selected tab to displayed in the timeline */ activeTab: TimelineTabs; prevActiveTab: TimelineTabs; + + /** Used for scrolling to top when swiching tabs. It includes the timestamp of when the event happened */ + scrollToTop?: ScrollToTopEvent; /** Timeline saved object owner */ createdBy?: string; /** A summary of the events and notes in this timeline */ @@ -63,6 +67,8 @@ export type TimelineModel = TGridModelForTimeline & { status: TimelineStatus; /** updated saved object timestamp */ updated?: number; + /** updated saved object user */ + updatedBy?: string | null; /** timeline is saving */ isSaving: boolean; version: string | null; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts index a302f43e61b13..97fa72667a3c6 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts @@ -331,7 +331,7 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) }, }, })) - .case(setActiveTabTimeline, (state, { id, activeTab }) => ({ + .case(setActiveTabTimeline, (state, { id, activeTab, scrollToTop }) => ({ ...state, timelineById: { ...state.timelineById, @@ -339,6 +339,11 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) ...state.timelineById[id], activeTab, prevActiveTab: state.timelineById[id].activeTab, + scrollToTop: scrollToTop + ? { + timestamp: Math.floor(Date.now() / 1000), // convert to seconds to avoid unnecessary rerenders for multiple clicks + } + : undefined, }, }, }))