From 75582eb4ae59d85fff95661ef8dfabfbb7197d28 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Wed, 15 Jul 2020 03:51:31 -0400 Subject: [PATCH] [SECURITY] Timeline bug 7.9 (#71748) * remove delay of rendering row * Fix flyout timeline to behave as we wanted * Fix tabs on timeline page * disable sensor visibility when you have less than 100 events in timeline * Fix container to fit content and not take all the place that it wants * do not update timeline time when switching top nav * fix timeline url in case * review I Co-authored-by: Elastic Machine --- .../cases/components/add_comment/index.tsx | 40 ++-------- .../cases/components/all_cases/index.test.tsx | 8 -- .../cases/components/all_cases/index.tsx | 25 +++++-- .../components/all_cases_modal/index.tsx | 2 +- .../public/cases/components/create/index.tsx | 6 +- .../user_action_markdown.test.tsx | 2 + .../user_action_tree/user_action_markdown.tsx | 30 +------- .../components/utils/use_timeline_click.tsx | 40 ++++++++++ .../events_viewer/events_viewer.tsx | 3 +- .../common/components/markdown/index.test.tsx | 14 +++- .../common/components/markdown/index.tsx | 10 ++- .../components/markdown_editor/form.tsx | 2 +- .../components/markdown_editor/index.tsx | 26 ++++--- .../components/url_state/use_url_state.tsx | 34 +++++++-- .../components/with_hover_actions/index.tsx | 8 +- .../components/alerts_table/index.tsx | 9 ++- .../components/flyout/pane/index.tsx | 1 + .../components/graph_overlay/index.tsx | 73 ++++++++++--------- .../components/manage_timeline/index.tsx | 12 +++ .../open_timeline/use_timeline_types.tsx | 21 +++--- .../components/timeline/body/events/index.tsx | 5 +- .../timeline/body/events/stateful_event.tsx | 44 ++--------- .../components/timeline/body/index.test.tsx | 5 +- .../components/timeline/body/index.tsx | 10 ++- .../timeline/body/stateful_body.tsx | 7 +- .../timelines/components/timeline/index.tsx | 2 + .../components/timeline/properties/index.tsx | 6 +- 27 files changed, 245 insertions(+), 200 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/cases/components/utils/use_timeline_click.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx index a830b299d655b..980083e8e9d20 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx @@ -8,7 +8,6 @@ import { EuiButton, EuiLoadingSpinner } from '@elastic/eui'; import React, { useCallback, useEffect } from 'react'; import styled from 'styled-components'; -import { useDispatch } from 'react-redux'; import { CommentRequest } from '../../../../../case/common/api'; import { usePostComment } from '../../containers/use_post_comment'; import { Case } from '../../containers/types'; @@ -19,12 +18,7 @@ import { Form, useForm, UseField } from '../../../shared_imports'; import * as i18n from './translations'; import { schema } from './schema'; -import { - dispatchUpdateTimeline, - queryTimelineById, -} from '../../../timelines/components/open_timeline/helpers'; -import { updateIsLoading as dispatchUpdateIsLoading } from '../../../timelines/store/timeline/actions'; -import { useApolloClient } from '../../../common/utils/apollo_context'; +import { useTimelineClick } from '../utils/use_timeline_click'; const MySpinner = styled(EuiLoadingSpinner)` position: absolute; @@ -53,8 +47,7 @@ export const AddComment = React.memo( options: { stripEmptyFields: false }, schema, }); - const dispatch = useDispatch(); - const apolloClient = useApolloClient(); + const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline( form, 'comment' @@ -68,30 +61,9 @@ export const AddComment = React.memo( `${comment}${comment.length > 0 ? '\n\n' : ''}${insertQuote}` ); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [insertQuote]); + }, [form, insertQuote]); - const handleTimelineClick = useCallback( - (timelineId: string) => { - queryTimelineById({ - apolloClient, - timelineId, - updateIsLoading: ({ - id: currentTimelineId, - isLoading: isLoadingTimeline, - }: { - id: string; - isLoading: boolean; - }) => - dispatch( - dispatchUpdateIsLoading({ id: currentTimelineId, isLoading: isLoadingTimeline }) - ), - updateTimeline: dispatchUpdateTimeline(dispatch), - }); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [apolloClient] - ); + const handleTimelineClick = useTimelineClick(); const onSubmit = useCallback(async () => { const { isValid, data } = await form.submit(); @@ -102,8 +74,8 @@ export const AddComment = React.memo( postComment(data, onCommentPosted); form.reset(); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form, onCommentPosted, onCommentSaving]); + }, [form, onCommentPosted, onCommentSaving, postComment]); + return ( {isLoading && showLoading && } diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index ed8ec432f7df5..d8acda8ec4f33 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -29,14 +29,6 @@ const useGetCasesMock = useGetCases as jest.Mock; const useGetCasesStatusMock = useGetCasesStatus as jest.Mock; const useUpdateCasesMock = useUpdateCases as jest.Mock; -jest.mock('react-router-dom', () => { - const originalModule = jest.requireActual('react-router-dom'); - return { - ...originalModule, - useHistory: jest.fn(), - }; -}); - jest.mock('../../../common/components/link_to'); describe('AllCases', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx index bf134a02dd822..f46dd9e858c7f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx @@ -5,7 +5,6 @@ */ /* eslint-disable react-hooks/exhaustive-deps */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useHistory } from 'react-router-dom'; import { EuiBasicTable, EuiContextMenuPanel, @@ -50,6 +49,8 @@ import { ConfigureCaseButton } from '../configure_cases/button'; import { ERROR_PUSH_SERVICE_CALLOUT_TITLE } from '../use_push_to_service/translations'; import { LinkButton } from '../../../common/components/links'; import { SecurityPageName } from '../../../app/types'; +import { useKibana } from '../../../common/lib/kibana'; +import { APP_ID } from '../../../../common/constants'; const Div = styled.div` margin-top: ${({ theme }) => theme.eui.paddingSizes.m}; @@ -81,13 +82,13 @@ const getSortField = (field: string): SortFieldCase => { }; interface AllCasesProps { - onRowClick?: (id: string) => void; + onRowClick?: (id?: string) => void; isModal?: boolean; userCanCrud: boolean; } export const AllCases = React.memo( - ({ onRowClick = () => {}, isModal = false, userCanCrud }) => { - const history = useHistory(); + ({ onRowClick, isModal = false, userCanCrud }) => { + const { navigateToApp } = useKibana().services.application; const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.case); const { actionLicense } = useGetActionLicense(); const { @@ -234,9 +235,15 @@ export const AllCases = React.memo( const goToCreateCase = useCallback( (ev) => { ev.preventDefault(); - history.push(getCreateCaseUrl(urlSearch)); + if (isModal && onRowClick != null) { + onRowClick(); + } else { + navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { + path: getCreateCaseUrl(urlSearch), + }); + } }, - [history, urlSearch] + [navigateToApp, isModal, onRowClick, urlSearch] ); const actions = useMemo( @@ -445,7 +452,11 @@ export const AllCases = React.memo( rowProps={(item) => isModal ? { - onClick: () => onRowClick(item.id), + onClick: () => { + if (onRowClick != null) { + onRowClick(item.id); + } + }, } : {} } diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.tsx index d2ca0f0cd02ee..d8f2e5293ee1b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.tsx @@ -19,7 +19,7 @@ import * as i18n from './translations'; interface AllCasesModalProps { onCloseCaseModal: () => void; showCaseModal: boolean; - onRowClick: (id: string) => void; + onRowClick: (id?: string) => void; } export const AllCasesModalComponent = ({ diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx index 9f078c725c3cf..1a2697bb132b0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx @@ -33,6 +33,7 @@ import * as i18n from '../../translations'; import { MarkdownEditorForm } from '../../../common/components//markdown_editor/form'; import { useGetTags } from '../../containers/use_get_tags'; import { getCaseDetailsUrl } from '../../../common/components/link_to'; +import { useTimelineClick } from '../utils/use_timeline_click'; export const CommonUseField = getUseField({ component: Field }); @@ -87,6 +88,7 @@ export const Create = React.memo(() => { form, 'description' ); + const handleTimelineClick = useTimelineClick(); const onSubmit = useCallback(async () => { const { isValid, data } = await form.submit(); @@ -94,8 +96,7 @@ export const Create = React.memo(() => { // `postCase`'s type is incorrect, it actually returns a promise await postCase(data); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form]); + }, [form, postCase]); const handleSetIsCancel = useCallback(() => { history.push('/'); @@ -145,6 +146,7 @@ export const Create = React.memo(() => { dataTestSubj: 'caseDescription', idAria: 'caseDescription', isDisabled: isLoading, + onClickTimeline: handleTimelineClick, onCursorPositionUpdate: handleCursorChange, topRightContent: ( { expect(queryTimelineByIdSpy).toBeCalledWith({ apolloClient: mockUseApolloClient(), + graphEventId: '', timelineId, updateIsLoading: expect.any(Function), updateTimeline: expect.any(Function), @@ -62,6 +63,7 @@ describe('UserActionMarkdown ', () => { wrapper.find(`[data-test-subj="markdown-timeline-link"]`).first().simulate('click'); expect(queryTimelineByIdSpy).toBeCalledWith({ apolloClient: mockUseApolloClient(), + graphEventId: '', timelineId, updateIsLoading: expect.any(Function), updateTimeline: expect.any(Function), 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 b3a5f1e0158d8..0a8167049266f 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 @@ -8,7 +8,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/e import React, { useCallback } from 'react'; import styled, { css } from 'styled-components'; -import { useDispatch } from 'react-redux'; import * as i18n from '../case_view/translations'; import { Markdown } from '../../../common/components/markdown'; import { Form, useForm, UseField } from '../../../shared_imports'; @@ -16,13 +15,7 @@ import { schema, Content } from './schema'; import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover'; import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline'; import { MarkdownEditorForm } from '../../../common/components//markdown_editor/form'; -import { - dispatchUpdateTimeline, - queryTimelineById, -} from '../../../timelines/components/open_timeline/helpers'; - -import { updateIsLoading as dispatchUpdateIsLoading } from '../../../timelines/store/timeline/actions'; -import { useApolloClient } from '../../../common/utils/apollo_context'; +import { useTimelineClick } from '../utils/use_timeline_click'; const ContentWrapper = styled.div` ${({ theme }) => css` @@ -44,8 +37,6 @@ export const UserActionMarkdown = ({ onChangeEditable, onSaveContent, }: UserActionMarkdownProps) => { - const dispatch = useDispatch(); - const apolloClient = useApolloClient(); const { form } = useForm({ defaultValue: { content }, options: { stripEmptyFields: false }, @@ -59,24 +50,7 @@ export const UserActionMarkdown = ({ onChangeEditable(id); }, [id, onChangeEditable]); - const handleTimelineClick = useCallback( - (timelineId: string) => { - queryTimelineById({ - apolloClient, - timelineId, - updateIsLoading: ({ - id: currentTimelineId, - isLoading, - }: { - id: string; - isLoading: boolean; - }) => dispatch(dispatchUpdateIsLoading({ id: currentTimelineId, isLoading })), - updateTimeline: dispatchUpdateTimeline(dispatch), - }); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [apolloClient] - ); + const handleTimelineClick = useTimelineClick(); const handleSaveAction = useCallback(async () => { const { isValid, data } = await form.submit(); diff --git a/x-pack/plugins/security_solution/public/cases/components/utils/use_timeline_click.tsx b/x-pack/plugins/security_solution/public/cases/components/utils/use_timeline_click.tsx new file mode 100644 index 0000000000000..971bc87c8cdd2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/utils/use_timeline_click.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 { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import { useApolloClient } from '../../../common/utils/apollo_context'; +import { + dispatchUpdateTimeline, + queryTimelineById, +} from '../../../timelines/components/open_timeline/helpers'; +import { updateIsLoading as dispatchUpdateIsLoading } from '../../../timelines/store/timeline/actions'; + +export const useTimelineClick = () => { + const dispatch = useDispatch(); + const apolloClient = useApolloClient(); + + const handleTimelineClick = useCallback( + (timelineId: string, graphEventId?: string) => { + queryTimelineById({ + apolloClient, + graphEventId, + timelineId, + updateIsLoading: ({ + id: currentTimelineId, + isLoading, + }: { + id: string; + isLoading: boolean; + }) => dispatch(dispatchUpdateIsLoading({ id: currentTimelineId, isLoading })), + updateTimeline: dispatchUpdateTimeline(dispatch), + }); + }, + [apolloClient, dispatch] + ); + + return handleTimelineClick; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index 5e0d5a6e9b099..6e6ba4911be26 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -106,8 +106,7 @@ const EventsViewerComponent: React.FC = ({ useEffect(() => { setIsTimelineLoading({ id, isLoading: isQueryLoading }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isQueryLoading]); + }, [id, isQueryLoading, setIsTimelineLoading]); const { queryFields, title, unit } = useMemo(() => getManageTimelineById(id), [ getManageTimelineById, 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 index 69620eb1f4341..e30391982ee7a 100644 --- 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 @@ -157,7 +157,19 @@ describe('Markdown', () => { ); wrapper.find('[data-test-subj="markdown-timeline-link"]').first().simulate('click'); - expect(onClickTimeline).toHaveBeenCalledWith(timelineId); + 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 index 1a4c9cb71a77e..1d73c3cb8a2aa 100644 --- a/x-pack/plugins/security_solution/public/common/components/markdown/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/markdown/index.tsx @@ -7,6 +7,7 @@ /* 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'; @@ -38,7 +39,7 @@ const REL_NOREFERRER = 'noreferrer'; export const Markdown = React.memo<{ disableLinks?: boolean; raw?: string; - onClickTimeline?: (timelineId: string) => void; + onClickTimeline?: (timelineId: string, graphEventId?: string) => void; size?: 'xs' | 's' | 'm'; }>(({ disableLinks = false, onClickTimeline, raw, size = 's' }) => { const markdownRenderers = { @@ -63,11 +64,14 @@ export const Markdown = React.memo<{ ), link: ({ children, href }: { children: React.ReactNode[]; href?: string }) => { if (onClickTimeline != null && href != null && href.indexOf(`timelines?timeline=(id:`) > -1) { - const timelineId = href.split('timelines?timeline=(id:')[1].split("'")[1] ?? ''; + 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)} + onClick={() => onClickTimeline(timelineId, graphEventId)} data-test-subj="markdown-timeline-link" > {children} diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/form.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/form.tsx index f9efbc5705b92..2cc3fe05a2215 100644 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/form.tsx +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/form.tsx @@ -16,7 +16,7 @@ interface IMarkdownEditorForm { field: FieldHook; idAria: string; isDisabled: boolean; - onClickTimeline?: (timelineId: string) => void; + onClickTimeline?: (timelineId: string, graphEventId?: string) => void; onCursorPositionUpdate?: (cursorPosition: CursorPosition) => void; placeholder?: string; topRightContent?: React.ReactNode; 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 d92952992d997..c40b3910ec152 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 @@ -74,7 +74,7 @@ export const MarkdownEditor = React.memo<{ content: string; isDisabled?: boolean; onChange: (description: string) => void; - onClickTimeline?: (timelineId: string) => void; + onClickTimeline?: (timelineId: string, graphEventId?: string) => void; onCursorPositionUpdate?: (cursorPosition: CursorPosition) => void; placeholder?: string; }>( @@ -95,15 +95,18 @@ export const MarkdownEditor = React.memo<{ [onChange] ); - const setCursorPosition = (e: React.ChangeEvent) => { - if (onCursorPositionUpdate) { - onCursorPositionUpdate({ - start: e!.target!.selectionStart ?? 0, - end: e!.target!.selectionEnd ?? 0, - }); - } - return false; - }; + const setCursorPosition = useCallback( + (e: React.ChangeEvent) => { + if (onCursorPositionUpdate) { + onCursorPositionUpdate({ + start: e!.target!.selectionStart ?? 0, + end: e!.target!.selectionEnd ?? 0, + }); + } + return false; + }, + [onCursorPositionUpdate] + ); const tabs = useMemo( () => [ @@ -135,8 +138,7 @@ export const MarkdownEditor = React.memo<{ ), }, ], - // eslint-disable-next-line react-hooks/exhaustive-deps - [content, isDisabled, placeholder] + [content, handleOnChange, isDisabled, onClickTimeline, placeholder, setCursorPosition] ); return ( diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx b/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx index c97be1fdfb99b..644fd46cb6aae 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx +++ b/x-pack/plugins/security_solution/public/common/components/url_state/use_url_state.tsx @@ -18,6 +18,7 @@ import { getTitle, replaceStateInLocation, updateUrlStateString, + decodeRisonUrlState, } from './helpers'; import { UrlStateContainerPropTypes, @@ -26,8 +27,10 @@ import { KeyUrlState, ALL_URL_STATE_KEYS, UrlStateToRedux, + UrlState, } from './types'; import { SecurityPageName } from '../../../app/types'; +import { TimelineUrl } from '../../../timelines/store/timeline/model'; function usePrevious(value: PreviousLocationUrlState) { const ref = useRef(value); @@ -37,6 +40,21 @@ function usePrevious(value: PreviousLocationUrlState) { return ref.current; } +const updateTimelineAtinitialization = ( + urlKey: CONSTANTS, + newUrlStateString: string, + urlState: UrlState +) => { + let updateUrlState = true; + if (urlKey === CONSTANTS.timeline) { + const timeline = decodeRisonUrlState(newUrlStateString); + if (timeline != null && urlState.timeline.id === timeline.id) { + updateUrlState = false; + } + } + return updateUrlState; +}; + export const useUrlStateHooks = ({ detailName, indexPattern, @@ -78,13 +96,15 @@ export const useUrlStateHooks = ({ getParamFromQueryString(getQueryStringFromLocation(mySearch), urlKey) ?? newUrlStateString; if (isInitializing || !deepEqual(updatedUrlStateString, newUrlStateString)) { - urlStateToUpdate = [ - ...urlStateToUpdate, - { - urlKey, - newUrlStateString: updatedUrlStateString, - }, - ]; + if (updateTimelineAtinitialization(urlKey, newUrlStateString, urlState)) { + urlStateToUpdate = [ + ...urlStateToUpdate, + { + urlKey, + newUrlStateString: updatedUrlStateString, + }, + ]; + } } } } else if ( diff --git a/x-pack/plugins/security_solution/public/common/components/with_hover_actions/index.tsx b/x-pack/plugins/security_solution/public/common/components/with_hover_actions/index.tsx index 361779a4a33b2..97705533689e9 100644 --- a/x-pack/plugins/security_solution/public/common/components/with_hover_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/with_hover_actions/index.tsx @@ -17,6 +17,10 @@ const WithHoverActionsPopover = (styled(EuiPopover as any)` } ` as unknown) as typeof EuiPopover; +const Container = styled.div` + width: fit-content; +`; + interface Props { /** * Always show the hover menu contents (default: false) @@ -75,7 +79,7 @@ export const WithHoverActions = React.memo( }, [closePopOverTrigger]); return ( -
+ ( > {isOpen ? <>{hoverContent} : null} -
+
); } ); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 87c631b80e38b..405ba0719a910 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -374,7 +374,7 @@ export const AlertsTableComponent: React.FC = ({ } }, [defaultFilters, filterGroup]); const { filterManager } = useKibana().services.data.query; - const { initializeTimeline, setTimelineRowActions } = useManageTimeline(); + const { initializeTimeline, setTimelineRowActions, setIndexToAdd } = useManageTimeline(); useEffect(() => { initializeTimeline({ @@ -383,6 +383,7 @@ export const AlertsTableComponent: React.FC = ({ filterManager, footerText: i18n.TOTAL_COUNT_OF_ALERTS, id: timelineId, + indexToAdd: defaultIndices, loadingText: i18n.LOADING_ALERTS, selectAll: canUserCRUD ? selectAll : false, timelineRowActions: () => [getInvestigateInResolverAction({ dispatch, timelineId })], @@ -390,6 +391,7 @@ export const AlertsTableComponent: React.FC = ({ }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { setTimelineRowActions({ id: timelineId, @@ -398,6 +400,11 @@ export const AlertsTableComponent: React.FC = ({ }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [additionalActions]); + + useEffect(() => { + setIndexToAdd({ id: timelineId, indexToAdd: defaultIndices }); + }, [timelineId, defaultIndices, setIndexToAdd]); + const headerFilterGroup = useMemo( () => , [onFilterGroupChangedCallback] diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx index 8c03d82aafafb..1616738897b0a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx @@ -31,6 +31,7 @@ const EuiFlyoutContainer = styled.div` z-index: 4001; min-width: 150px; width: auto; + animation: none; } `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index 0b5b51d6f1fb2..085f0863c7b27 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -12,7 +12,7 @@ import styled from 'styled-components'; import { SecurityPageName } from '../../../app/types'; import { AllCasesModal } from '../../../cases/components/all_cases_modal'; -import { getCaseDetailsUrl } from '../../../common/components/link_to'; +import { getCaseDetailsUrl, getCreateCaseUrl } from '../../../common/components/link_to'; import { APP_ID } from '../../../../common/constants'; import { useKibana } from '../../../common/lib/kibana'; import { State } from '../../../common/store'; @@ -28,6 +28,7 @@ import { import { Resolver } from '../../../resolver/view'; import * as i18n from './translations'; +import { TimelineType } from '../../../../common/types/timeline'; const OverlayContainer = styled.div<{ bodyHeight?: number }>` height: ${({ bodyHeight }) => (bodyHeight ? `${bodyHeight}px` : 'auto')}; @@ -44,6 +45,7 @@ interface OwnProps { bodyHeight?: number; graphEventId?: string; timelineId: string; + timelineType: TimelineType; } const GraphOverlayComponent = ({ @@ -52,6 +54,7 @@ const GraphOverlayComponent = ({ status, timelineId, title, + timelineType, }: OwnProps & PropsFromRedux) => { const dispatch = useDispatch(); const { navigateToApp } = useKibana().services.application; @@ -65,20 +68,20 @@ const GraphOverlayComponent = ({ timelineSelectors.selectTimeline(state, timelineId) ); const onRowClick = useCallback( - (id: string) => { + (id?: string) => { onCloseCaseModal(); - dispatch( - setInsertTimeline({ - graphEventId, - timelineId, - timelineSavedObjectId: currentTimeline.savedObjectId, - timelineTitle: title.length > 0 ? title : UNTITLED_TIMELINE, - }) - ); - navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { - path: getCaseDetailsUrl({ id }), + path: id != null ? getCaseDetailsUrl({ id }) : getCreateCaseUrl(), + }).then(() => { + dispatch( + setInsertTimeline({ + graphEventId, + timelineId, + timelineSavedObjectId: currentTimeline.savedObjectId, + timelineTitle: title.length > 0 ? title : UNTITLED_TIMELINE, + }) + ); }); }, [currentTimeline, dispatch, graphEventId, navigateToApp, onCloseCaseModal, timelineId, title] @@ -93,28 +96,30 @@ const GraphOverlayComponent = ({ {i18n.BACK_TO_EVENTS} - - - - - - - - - - + {timelineType === TimelineType.default && ( + + + + + + + + + + + )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx index 7882185cbd9d6..dba8506add0ad 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx @@ -138,6 +138,7 @@ const reducerManageTimeline = ( }; interface UseTimelineManager { + getIndexToAddById: (id: string) => string[] | null; getManageTimelineById: (id: string) => ManageTimeline; getTimelineFilterManager: (id: string) => FilterManager | undefined; initializeTimeline: (newTimeline: ManageTimelineInit) => void; @@ -216,9 +217,19 @@ const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseT }, [initializeTimeline, state] ); + const getIndexToAddById = useCallback( + (id: string): string[] | null => { + if (state[id] != null) { + return state[id].indexToAdd; + } + return getTimelineDefaults(id).indexToAdd; + }, + [state] + ); const isManagedTimeline = useCallback((id: string): boolean => state[id] != null, [state]); return { + getIndexToAddById, getManageTimelineById, getTimelineFilterManager, initializeTimeline, @@ -231,6 +242,7 @@ const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseT const init = { getManageTimelineById: (id: string) => getTimelineDefaults(id), + getIndexToAddById: (id: string) => null, getTimelineFilterManager: () => undefined, setIndexToAdd: () => undefined, isManagedTimeline: () => false, diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx index bee94db348872..7d54bb2209850 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx @@ -90,14 +90,17 @@ export const useTimelineTypes = ({ ); const onFilterClicked = useCallback( - (tabId) => { - if (tabId === timelineType) { - setTimelineTypes(null); - } else { - setTimelineTypes(tabId); - } + (tabId, tabStyle: TimelineTabsStyle) => { + setTimelineTypes((prevTimelineTypes) => { + if (tabId === prevTimelineTypes && tabStyle === TimelineTabsStyle.filter) { + return null; + } else if (prevTimelineTypes !== tabId) { + setTimelineTypes(tabId); + } + return prevTimelineTypes; + }); }, - [timelineType, setTimelineTypes] + [setTimelineTypes] ); const timelineTabs = useMemo(() => { @@ -112,7 +115,7 @@ export const useTimelineTypes = ({ href={tab.href} onClick={(ev) => { tab.onClick(ev); - onFilterClicked(tab.id); + onFilterClicked(tab.id, TimelineTabsStyle.tab); }} > {tab.name} @@ -133,7 +136,7 @@ export const useTimelineTypes = ({ numFilters={tab.count} onClick={(ev: { preventDefault: () => void }) => { tab.onClick(ev); - onFilterClicked(tab.id); + onFilterClicked(tab.id, TimelineTabsStyle.filter); }} withNext={tab.withNext} > diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/index.tsx index 9f0c4747db057..ca7a64db58c95 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/index.tsx @@ -9,7 +9,6 @@ import React from 'react'; import { BrowserFields, DocValueFields } from '../../../../../common/containers/source'; import { TimelineItem, TimelineNonEcsData } from '../../../../../graphql/types'; import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; -import { maxDelay } from '../../../../../common/lib/helpers/scheduler'; import { Note } from '../../../../../common/lib/note'; import { AddNoteToEvent, UpdateNote } from '../../../notes/helpers'; import { @@ -81,12 +80,13 @@ const EventsComponent: React.FC = ({ {data.map((event, i) => ( = ({ isEventViewer={isEventViewer} key={`${event._id}_${event._index}`} loadingEventIds={loadingEventIds} - maxDelay={maxDelay(i)} onColumnResized={onColumnResized} onPinEvent={onPinEvent} onRowSelected={onRowSelected} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx index f93a152211a66..344fbb59bbe57 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useRef, useState, useCallback } from 'react'; +import React, { useRef, useState, useCallback } from 'react'; import { useSelector } from 'react-redux'; import uuid from 'uuid'; import VisibilitySensor from 'react-visibility-sensor'; @@ -12,7 +12,6 @@ import VisibilitySensor from 'react-visibility-sensor'; import { BrowserFields, DocValueFields } from '../../../../../common/containers/source'; import { TimelineDetailsQuery } from '../../../../containers/details'; import { TimelineItem, DetailItem, TimelineNonEcsData } from '../../../../../graphql/types'; -import { requestIdleCallbackViaScheduler } from '../../../../../common/lib/helpers/scheduler'; import { Note } from '../../../../../common/lib/note'; import { ColumnHeaderOptions, TimelineModel } from '../../../../../timelines/store/timeline/model'; import { AddNoteToEvent, UpdateNote } from '../../../notes/helpers'; @@ -43,13 +42,13 @@ interface Props { browserFields: BrowserFields; columnHeaders: ColumnHeaderOptions[]; columnRenderers: ColumnRenderer[]; + disableSensorVisibility: boolean; docValueFields: DocValueFields[]; event: TimelineItem; eventIdToNoteIds: Readonly>; getNotesByIds: (noteIds: string[]) => Note[]; isEventViewer?: boolean; loadingEventIds: Readonly; - maxDelay?: number; onColumnResized: OnColumnResized; onPinEvent: OnPinEvent; onRowSelected: OnRowSelected; @@ -109,6 +108,7 @@ const StatefulEventComponent: React.FC = ({ containerElementRef, columnHeaders, columnRenderers, + disableSensorVisibility = true, docValueFields, event, eventIdToNoteIds, @@ -116,7 +116,6 @@ const StatefulEventComponent: React.FC = ({ isEventViewer = false, isEventPinned = false, loadingEventIds, - maxDelay = 0, onColumnResized, onPinEvent, onRowSelected, @@ -130,7 +129,6 @@ const StatefulEventComponent: React.FC = ({ updateNote, }) => { const [expanded, setExpanded] = useState<{ [eventId: string]: boolean }>({}); - const [initialRender, setInitialRender] = useState(false); const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({}); const timeline = useSelector((state) => { return state.timeline.timelineById['timeline-1']; @@ -160,39 +158,9 @@ const StatefulEventComponent: React.FC = ({ [addNoteToEvent, event, isEventPinned, onPinEvent] ); - /** - * Incrementally loads the events when it mounts by trying to - * see if it resides within a window frame and if it is it will - * indicate to React that it should render its self by setting - * its initialRender to true. - */ - useEffect(() => { - let _isMounted = true; - - requestIdleCallbackViaScheduler( - () => { - if (!initialRender && _isMounted) { - setInitialRender(true); - } - }, - { timeout: maxDelay } - ); - return () => { - _isMounted = false; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - // Number of current columns plus one for actions. const columnCount = columnHeaders.length + 1; - // If we are not ready to render yet, just return null - // see useEffect() for when it schedules the first - // time this stateful component should be rendered. - if (!initialRender) { - return ; - } - return ( = ({ offset={{ top: TOP_OFFSET, bottom: BOTTOM_OFFSET }} > {({ isVisible }) => { - if (isVisible) { + if (isVisible || disableSensorVisibility) { return ( = ({ } else { // Height place holder for visibility detection as well as re-rendering sections. const height = - divElement.current != null && divElement.current.clientHeight - ? `${divElement.current.clientHeight}px` + divElement.current != null && divElement.current!.clientHeight + ? `${divElement.current!.clientHeight}px` : DEFAULT_ROW_HEIGHT; return ; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index 68a8d474ff5ad..2df6a39f1a3df 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +import { ReactWrapper } from '@elastic/eui/node_modules/@types/enzyme'; import React from 'react'; import { useSelector } from 'react-redux'; @@ -18,7 +18,7 @@ import { Sort } from './sort'; import { wait } from '../../../../common/lib/helpers'; import { useMountAppended } from '../../../../common/utils/use_mount_appended'; import { SELECTOR_TIMELINE_BODY_CLASS_NAME, TimelineBody } from '../styles'; -import { ReactWrapper } from '@elastic/eui/node_modules/@types/enzyme'; +import { TimelineType } from '../../../../../common/types/timeline'; const testBodyHeight = 700; const mockGetNotesByIds = (eventId: string[]) => []; @@ -83,6 +83,7 @@ describe('Body', () => { show: true, sort: mockSort, showCheckboxes: false, + timelineType: TimelineType.default, toggleColumn: jest.fn(), updateNote: jest.fn(), }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index 86bb49fac7f3e..83e44b77802b7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -33,6 +33,7 @@ import { useManageTimeline } from '../../manage_timeline'; import { GraphOverlay } from '../../graph_overlay'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../helpers'; import { TimelineRowAction } from './actions'; +import { TimelineType } from '../../../../../common/types/timeline'; export interface BodyProps { addNoteToEvent: AddNoteToEvent; @@ -64,6 +65,7 @@ export interface BodyProps { show: boolean; showCheckboxes: boolean; sort: Sort; + timelineType: TimelineType; toggleColumn: (column: ColumnHeaderOptions) => void; updateNote: UpdateNote; } @@ -101,6 +103,7 @@ export const Body = React.memo( showCheckboxes, sort, toggleColumn, + timelineType, updateNote, }) => { const containerElementRef = useRef(null); @@ -148,7 +151,12 @@ export const Body = React.memo( return ( <> {graphEventId && ( - + )} ( showCheckboxes, graphEventId, sort, + timelineType, toggleColumn, unPinEvent, updateColumns, @@ -218,6 +219,7 @@ const StatefulBodyComponent = React.memo( show={id === TimelineId.active ? show : true} showCheckboxes={showCheckboxes} sort={sort} + timelineType={timelineType} toggleColumn={toggleColumn} updateNote={onUpdateNote} /> @@ -241,7 +243,8 @@ const StatefulBodyComponent = React.memo( prevProps.show === nextProps.show && prevProps.selectedEventIds === nextProps.selectedEventIds && prevProps.showCheckboxes === nextProps.showCheckboxes && - prevProps.sort === nextProps.sort + prevProps.sort === nextProps.sort && + prevProps.timelineType === nextProps.timelineType ); StatefulBodyComponent.displayName = 'StatefulBodyComponent'; @@ -268,6 +271,7 @@ const makeMapStateToProps = () => { selectedEventIds, show, showCheckboxes, + timelineType, } = timeline; return { @@ -284,6 +288,7 @@ const makeMapStateToProps = () => { selectedEventIds, show, showCheckboxes, + timelineType, }; }; return mapStateToProps; 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 2d7527d8a922c..c170c93ee6083 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 @@ -215,6 +215,7 @@ const StatefulTimelineComponent = React.memo( /> ); }, + // eslint-disable-next-line complexity (prevProps, nextProps) => { return ( prevProps.eventType === nextProps.eventType && @@ -223,6 +224,7 @@ const StatefulTimelineComponent = React.memo( prevProps.id === nextProps.id && prevProps.isLive === nextProps.isLive && prevProps.isSaving === nextProps.isSaving && + prevProps.isTimelineExists === nextProps.isTimelineExists && prevProps.itemsPerPage === nextProps.itemsPerPage && prevProps.kqlMode === nextProps.kqlMode && prevProps.kqlQueryExpression === nextProps.kqlQueryExpression && diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx index 6de40725f461c..96a773507a30a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx @@ -25,7 +25,7 @@ import { timelineSelectors } from '../../../store/timeline'; import { setInsertTimeline } from '../../../store/timeline/actions'; import { useKibana } from '../../../../common/lib/kibana'; import { APP_ID } from '../../../../../common/constants'; -import { getCaseDetailsUrl } from '../../../../common/components/link_to'; +import { getCaseDetailsUrl, getCreateCaseUrl } from '../../../../common/components/link_to'; type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void; type UpdateTitle = ({ id, title }: { id: string; title: string }) => void; @@ -111,11 +111,11 @@ export const Properties = React.memo( ); const onRowClick = useCallback( - (id: string) => { + (id?: string) => { onCloseCaseModal(); navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { - path: getCaseDetailsUrl({ id }), + path: id != null ? getCaseDetailsUrl({ id }) : getCreateCaseUrl(), }).then(() => dispatch( setInsertTimeline({