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, }, }, }))