From 2b454091a6943a0d4226466cdd63fa9855c5983e Mon Sep 17 00:00:00 2001 From: Philippe Oberti Date: Thu, 11 Jan 2024 10:02:50 -0600 Subject: [PATCH] [Security Solution][Timeline] extract and cleanup timeline bottom bar component (#173886) --- .../all/alerts_automated_action_results.cy.ts | 2 +- .../cypress/e2e/all/alerts_linked_apps.cy.ts | 2 +- .../osquery/cypress/e2e/all/timelines.cy.ts | 4 +- .../pages/endpoint_hosts/view/index.test.tsx | 2 +- .../pages/policy/view/policy_details.test.tsx | 4 +- .../components/bottom_bar/index.test.tsx | 63 ++++++++++ .../timelines/components/bottom_bar/index.tsx | 80 +++++++++++++ .../flyout/bottom_bar/index.test.tsx | 44 ------- .../components/flyout/bottom_bar/index.tsx | 27 ----- .../flyout/bottom_bar/translations.ts | 12 -- .../flyout/header/active_timelines.test.tsx | 96 --------------- .../flyout/header/active_timelines.tsx | 112 ------------------ .../components/flyout/header/index.tsx | 56 ++++----- .../timelines/components/timeline/helpers.tsx | 19 --- .../public/timelines/store/selectors.ts | 62 +++++++++- .../public/timelines/wrapper/index.test.tsx | 4 +- .../public/timelines/wrapper/index.tsx | 6 +- .../cypress/screens/timeline.ts | 2 +- .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../sourcerer/sourcerer_timeline.cy.ts | 1 + .../timelines/open_timeline.cy.ts | 71 ++++++----- .../cypress/screens/security_main.ts | 4 +- .../cypress/screens/timeline.ts | 3 +- .../cypress/tasks/timeline.ts | 6 +- .../page_objects/timeline/index.ts | 4 +- 27 files changed, 278 insertions(+), 411 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/bottom_bar/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/bottom_bar/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/translations.ts delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx diff --git a/x-pack/plugins/osquery/cypress/e2e/all/alerts_automated_action_results.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/alerts_automated_action_results.cy.ts index ee2635651261cd..4c7c9663b2d40e 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/alerts_automated_action_results.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/alerts_automated_action_results.cy.ts @@ -112,7 +112,7 @@ describe('Alert Flyout Automated Action Results', () => { }); cy.contains(timelineRegex); cy.getBySel('securitySolutionFlyoutNavigationCollapseDetailButton').click(); - cy.getBySel('flyoutBottomBar').contains('Untitled timeline').click(); + cy.getBySel('timeline-bottom-bar').contains('Untitled timeline').click(); cy.contains(filterRegex); }); }); diff --git a/x-pack/plugins/osquery/cypress/e2e/all/alerts_linked_apps.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/alerts_linked_apps.cy.ts index 97dedb2ca6a2b9..f1284bf8b528f1 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/alerts_linked_apps.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/alerts_linked_apps.cy.ts @@ -89,7 +89,7 @@ describe( cy.getBySel(RESULTS_TABLE_BUTTON).should('not.exist'); }); cy.contains('Cancel').click(); - cy.getBySel('flyoutBottomBar').within(() => { + cy.getBySel('timeline-bottom-bar').within(() => { cy.contains(TIMELINE_NAME).click(); }); cy.getBySel('draggableWrapperKeyboardHandler').contains('action_id: "'); diff --git a/x-pack/plugins/osquery/cypress/e2e/all/timelines.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/timelines.cy.ts index 6c2380664ba4d0..1c6ff3b2fd66c6 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/timelines.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/timelines.cy.ts @@ -19,8 +19,8 @@ describe.skip('ALL - Timelines', { tags: ['@ess'] }, () => { it('should substitute osquery parameter on non-alert event take action', () => { cy.visit('/app/security/timelines'); - cy.getBySel('flyoutBottomBar').within(() => { - cy.getBySel('flyoutOverlay').click(); + cy.getBySel('timeline-bottom-bar').within(() => { + cy.getBySel('timeline-bottom-bar-title-button').click(); }); cy.getBySel('timelineQueryInput').type('NOT host.name: "dev-fleet-server.8220"{enter}'); // Filter out alerts diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 7988e29a6cfc22..0c9d989ad05712 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -181,7 +181,7 @@ describe('when on the endpoint list page', () => { }); const renderResult = render(); - const timelineFlyout = renderResult.queryByTestId('flyoutOverlay'); + const timelineFlyout = renderResult.queryByTestId('timeline-bottom-bar-title-button'); expect(timelineFlyout).toBeNull(); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx index dda667d4a2b91d..9f13e04f761a77 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx @@ -75,7 +75,7 @@ describe('Policy Details', () => { }); it('should NOT display timeline', async () => { - expect(policyView.find('flyoutOverlay')).toHaveLength(0); + expect(policyView.find('timeline-bottom-bar-title-button')).toHaveLength(0); }); it('should show loader followed by error message', async () => { @@ -136,7 +136,7 @@ describe('Policy Details', () => { it('should NOT display timeline', async () => { policyView = render(); await asyncActions; - expect(policyView.find('flyoutOverlay')).toHaveLength(0); + expect(policyView.find('timeline-bottom-bar-title-button')).toHaveLength(0); }); it('should display back to policy list button and policy title', async () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/bottom_bar/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/bottom_bar/index.test.tsx new file mode 100644 index 00000000000000..686a631736550b --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/bottom_bar/index.test.tsx @@ -0,0 +1,63 @@ +/* + * 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 React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock/test_providers'; +import { timelineActions } from '../../store'; +import { TimelineBottomBar } from '.'; +import { TimelineId } from '../../../../common/types'; + +jest.mock('react-redux', () => { + const origin = jest.requireActual('react-redux'); + return { + ...origin, + useDispatch: jest.fn().mockReturnValue(jest.fn()), + }; +}); + +describe('TimelineBottomBar', () => { + test('should render all components for bottom bar', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('timeline-bottom-bar')).toBeInTheDocument(); + expect(getByTestId('timeline-event-count-badge')).toBeInTheDocument(); + expect(getByTestId('timeline-save-status')).toBeInTheDocument(); + expect(getByTestId('timeline-favorite-empty-star')).toBeInTheDocument(); + }); + + test('should not render the event count badge if timeline is open', () => { + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('timeline-event-count-badge')).not.toBeInTheDocument(); + }); + + test('should dispatch show action when clicking on the title', () => { + const spy = jest.spyOn(timelineActions, 'showTimeline'); + + const { getByTestId } = render( + + + + ); + + getByTestId('timeline-bottom-bar-title-button').click(); + + expect(spy).toHaveBeenCalledWith({ + id: TimelineId.test, + show: true, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/bottom_bar/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/bottom_bar/index.tsx new file mode 100644 index 00000000000000..c3b086f7c4c1a2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/bottom_bar/index.tsx @@ -0,0 +1,80 @@ +/* + * 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 React, { useCallback } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiPanel } from '@elastic/eui'; +import { useDispatch, useSelector } from 'react-redux'; +import { i18n } from '@kbn/i18n'; +import type { State } from '../../../common/store'; +import { selectTitleByTimelineById } from '../../store/selectors'; +import { AddTimelineButton } from '../flyout/add_timeline_button'; +import { timelineActions } from '../../store'; +import { TimelineSaveStatus } from '../save_status'; +import { AddToFavoritesButton } from '../timeline/properties/helpers'; +import { TimelineEventsCountBadge } from '../../../common/hooks/use_timeline_events_count'; + +const openTimelineButton = (title: string) => + i18n.translate('xpack.securitySolution.timeline.bottomBar.toggleButtonAriaLabel', { + values: { title }, + defaultMessage: 'Open timeline {title}', + }); + +interface TimelineBottomBarProps { + /** + * Id of the timeline to be displayed in the bottom bar and within the portal + */ + timelineId: string; + /** + * True if the timeline modal is open + */ + show: boolean; +} + +/** + * This component renders the bottom bar for timeline displayed or most of the pages within Security Solution. + */ +export const TimelineBottomBar = React.memo(({ show, timelineId }) => { + const dispatch = useDispatch(); + + const handleToggleOpen = useCallback( + () => dispatch(timelineActions.showTimeline({ id: timelineId, show: true })), + [dispatch, timelineId] + ); + + const title = useSelector((state: State) => selectTitleByTimelineById(state, timelineId)); + + return ( + + + + + + + + + + + {title} + + + {!show && ( // this is a hack because TimelineEventsCountBadge is using react-reverse-portal so the component which is used in multiple places cannot be visible in multiple places at the same time + + + + )} + + + + + + ); +}); + +TimelineBottomBar.displayName = 'TimelineBottomBar'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.test.tsx deleted file mode 100644 index 6c97e250a8e79b..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { render, screen } from '@testing-library/react'; - -import { TestProviders } from '../../../../common/mock/test_providers'; -import { FlyoutBottomBar } from '.'; - -describe('FlyoutBottomBar', () => { - test('it renders the expected bottom bar', () => { - render( - - - - ); - - expect(screen.getByTestId('flyoutBottomBar')).toBeInTheDocument(); - }); - - test('it renders the flyout header panel', () => { - render( - - - - ); - - expect(screen.getByTestId('timeline-flyout-header-panel')).toBeInTheDocument(); - }); - - test('it hides the flyout header panel', () => { - render( - - - - ); - - expect(screen.queryByTestId('timeline-flyout-header-panel')).not.toBeInTheDocument(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx deleted file mode 100644 index bc1617d3b9a53f..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import React from 'react'; -import { FLYOUT_BUTTON_BAR_CLASS_NAME } from '../../timeline/helpers'; -import { FlyoutHeaderPanel } from '../header'; - -interface FlyoutBottomBarProps { - showTimelineHeaderPanel: boolean; - - timelineId: string; -} - -export const FlyoutBottomBar = React.memo( - ({ showTimelineHeaderPanel, timelineId }) => { - return ( -
- {showTimelineHeaderPanel && } -
- ); - } -); - -FlyoutBottomBar.displayName = 'FlyoutBottomBar'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/translations.ts deleted file mode 100644 index 10f5e3faafd00c..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/translations.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const TIMELINE = i18n.translate('xpack.securitySolution.flyout.button.timeline', { - defaultMessage: 'timeline', -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.test.tsx deleted file mode 100644 index 18bf93d0ab6c81..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - kibanaObservable, - mockGlobalState, - SUB_PLUGINS_REDUCER, - TestProviders, -} from '../../../../common/mock'; -import React from 'react'; -import type { ActiveTimelinesProps } from './active_timelines'; -import { ActiveTimelines } from './active_timelines'; -import { TimelineId } from '../../../../../common/types'; -import { TimelineType } from '../../../../../common/api/timeline'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { createSecuritySolutionStorageMock } from '@kbn/timelines-plugin/public/mock/mock_local_storage'; -import { createStore } from '../../../../common/store'; - -const { storage } = createSecuritySolutionStorageMock(); - -const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - -const TestComponent = (props: ActiveTimelinesProps) => { - return ( - - - - ); -}; - -describe('ActiveTimelines', () => { - describe('default timeline', () => { - it('should render timeline title as button when minimized', () => { - render( - - ); - - expect(screen.getByLabelText(/Open timeline timeline-test/).nodeName.toLowerCase()).toBe( - 'button' - ); - }); - - it('should render timeline title as text when maximized', () => { - render( - - ); - expect(screen.queryByLabelText(/Open timeline timeline-test/)).toBeFalsy(); - }); - - it('should maximized timeline when clicked on minimized timeline', async () => { - render( - - ); - - fireEvent.click(screen.getByLabelText(/Open timeline timeline-test/)); - - await waitFor(() => { - expect(store.getState().timeline.timelineById.test.show).toBe(true); - }); - }); - }); - - describe('template timeline', () => { - it('should render timeline template title as button when minimized', () => { - render( - - ); - - expect(screen.getByTestId(/timeline-title/)).toHaveTextContent(/Untitled template/); - }); - }); -}); 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 deleted file mode 100644 index 9a863a5fe81846..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiText } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; -import { isEmpty } from 'lodash/fp'; -import styled from 'styled-components'; - -import { TimelineType } from '../../../../../common/api/timeline'; -import { TimelineEventsCountBadge } from '../../../../common/hooks/use_timeline_events_count'; -import { - ACTIVE_TIMELINE_BUTTON_CLASS_NAME, - focusActiveTimelineButton, -} from '../../timeline/helpers'; -import { UNTITLED_TIMELINE, UNTITLED_TEMPLATE } from '../../timeline/properties/translations'; -import { timelineActions } from '../../../store'; -import * as i18n from './translations'; - -export interface ActiveTimelinesProps { - timelineId: string; - timelineTitle: string; - timelineType: TimelineType; - isOpen: boolean; -} - -const StyledEuiButtonEmpty = styled(EuiButtonEmpty)` - &:active, - &:focus { - background: transparent; - } - > span { - padding: 0; - } -`; - -const TitleConatiner = styled(EuiFlexItem)` - overflow: hidden; - display: inline-block; - text-overflow: ellipsis; - white-space: nowrap; -`; - -const ActiveTimelinesComponent: React.FC = ({ - timelineId, - timelineType, - timelineTitle, - isOpen, -}) => { - const dispatch = useDispatch(); - - const handleToggleOpen = useCallback(() => { - dispatch(timelineActions.showTimeline({ id: timelineId, show: !isOpen })); - focusActiveTimelineButton(); - }, [dispatch, isOpen, timelineId]); - - const title = !isEmpty(timelineTitle) - ? timelineTitle - : timelineType === TimelineType.template - ? UNTITLED_TEMPLATE - : UNTITLED_TIMELINE; - - const titleContent = useMemo(() => { - return ( - - - {isOpen ? ( - -

{title}

-
- ) : ( - <>{title} - )} -
- {!isOpen && ( - - - - )} -
- ); - }, [isOpen, title]); - - if (isOpen) { - return <>{titleContent}; - } - - return ( - - {titleContent} - - ); -}; - -export const ActiveTimelines = React.memo(ActiveTimelinesComponent); 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 90cb542dfe2915..e40fd01bdd3db4 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 @@ -5,24 +5,28 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiToolTip, EuiButtonIcon } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiToolTip, + EuiButtonIcon, + EuiText, +} from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import { isEmpty, get, pick } from 'lodash/fp'; import { useDispatch, useSelector } from 'react-redux'; -import styled from 'styled-components'; - import { getEsQueryConfig } from '@kbn/data-plugin/common'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; +import { selectTitleByTimelineById } from '../../../store/selectors'; import { createHistoryEntry } from '../../../../common/utils/global_query_string/helpers'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { timelineActions, timelineSelectors } from '../../../store'; import type { State } from '../../../../common/store'; import { useKibana } from '../../../../common/lib/kibana'; import { useSourcererDataView } from '../../../../common/containers/sourcerer'; -import { focusActiveTimelineButton } from '../../timeline/helpers'; import { combineQueries } from '../../../../common/lib/kuery'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; -import { ActiveTimelines } from './active_timelines'; import * as i18n from './translations'; import { TimelineActionMenu } from '../action_menu'; import { AddToFavoritesButton } from '../../timeline/properties/helpers'; @@ -34,13 +38,8 @@ interface FlyoutHeaderPanelProps { timelineId: string; } -const FlyoutHeaderPanelContentFlexGroupContainer = styled(EuiFlexGroup)` - overflow-x: auto; -`; - -const ActiveTimelinesContainer = styled(EuiFlexItem)` - overflow: hidden; -`; +const whiteSpaceNoWrapCSS = { 'white-space': 'nowrap' }; +const autoOverflowXCSS = { 'overflow-x': 'auto' }; const TimelinePanel = euiStyled(EuiPanel)<{ $isOpen?: boolean }>` backgroundColor: ${(props) => props.theme.eui.euiColorEmptyShade}; @@ -54,20 +53,14 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline const { browserFields, indexPattern } = useSourcererDataView(SourcererScopeName.timeline); const { uiSettings } = useKibana().services; const esQueryConfig = useMemo(() => getEsQueryConfig(uiSettings), [uiSettings]); + + const title = useSelector((state: State) => selectTitleByTimelineById(state, timelineId)); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const { activeTab, dataProviders, kqlQuery, title, timelineType, show, filters, kqlMode } = + const { activeTab, dataProviders, kqlQuery, timelineType, show, filters, kqlMode } = useDeepEqualSelector((state) => pick( - [ - 'activeTab', - 'dataProviders', - 'kqlQuery', - 'title', - 'timelineType', - 'show', - 'filters', - 'kqlMode', - ], + ['activeTab', 'dataProviders', 'kqlQuery', 'timelineType', 'show', 'filters', 'kqlMode'], getTimeline(state, timelineId) ?? timelineDefaults ) ); @@ -107,7 +100,6 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline const handleClose = useCallback(() => { createHistoryEntry(); dispatch(timelineActions.showTimeline({ id: timelineId, show: false })); - focusActiveTimelineButton(); }, [dispatch, timelineId]); return ( @@ -119,12 +111,13 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline data-test-subj="timeline-flyout-header-panel" data-show={show} > - @@ -137,14 +130,9 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline - - - + +

{title}

+
@@ -179,7 +167,7 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline )} -
+ ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx index 72be8b6110ae45..0f22f4ea15cacc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx @@ -173,25 +173,6 @@ export const onTimelineTabKeyPressed = ({ } }; -export const ACTIVE_TIMELINE_BUTTON_CLASS_NAME = 'active-timeline-button'; -export const FLYOUT_BUTTON_BAR_CLASS_NAME = 'timeline-flyout-button-bar'; - -/** - * This function focuses the active timeline button on the next tick. Focus - * is updated on the next tick because this function is typically - * invoked in `onClick` handlers that also dispatch Redux actions (that - * in-turn update focus states). - */ -export const focusActiveTimelineButton = () => { - setTimeout(() => { - document - .querySelector( - `div.${FLYOUT_BUTTON_BAR_CLASS_NAME} .${ACTIVE_TIMELINE_BUTTON_CLASS_NAME}` - ) - ?.focus(); - }, 0); -}; - /** * Focuses the utility bar action contained by the provided `containerElement` * when a valid container is provided diff --git a/x-pack/plugins/security_solution/public/timelines/store/selectors.ts b/x-pack/plugins/security_solution/public/timelines/store/selectors.ts index 9a2a732679fae2..496f867b1247f3 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/selectors.ts @@ -6,13 +6,18 @@ */ import { createSelector } from 'reselect'; +import { isEmpty } from 'lodash/fp'; +import { + UNTITLED_TEMPLATE, + UNTITLED_TIMELINE, +} from '../components/timeline/properties/translations'; import { timelineSelectors } from '.'; import { TimelineTabs } from '../../../common/types'; import type { State } from '../../common/store/types'; import type { TimelineModel } from './model'; import type { InsertTimeline, TimelineById } from './types'; -import { TimelineStatus } from '../../../common/api/timeline'; +import { TimelineStatus, TimelineType } from '../../../common/api/timeline'; export const getTimelineShowStatusByIdSelector = () => createSelector(timelineSelectors.selectTimeline, (timeline) => ({ @@ -23,19 +28,28 @@ export const getTimelineShowStatusByIdSelector = () => changed: timeline?.changed ?? false, })); -const selectTimelineById = (state: State): TimelineById => state.timeline.timelineById; +/** + * @deprecated + */ +const timelineByIdState = (state: State): TimelineById => state.timeline.timelineById; const selectCallOutUnauthorizedMsg = (state: State): boolean => state.timeline.showCallOutUnauthorizedMsg; +/** + * @deprecated prefer using selectTimelineById below + */ export const selectTimeline = (state: State, timelineId: string): TimelineModel => state.timeline.timelineById[timelineId]; export const selectInsertTimeline = (state: State): InsertTimeline | null => state.timeline.insertTimeline; +/** + * @deprecated prefer using selectTimelineById below + */ export const timelineByIdSelector = createSelector( - selectTimelineById, + timelineByIdState, (timelineById) => timelineById ); @@ -69,3 +83,45 @@ export const getKqlFilterKuerySelector = () => export const dataProviderVisibilitySelector = () => createSelector(selectTimeline, (timeline) => timeline.isDataProviderVisible); + +/** + * Selector that returns the timelineById slice of state + */ +export const selectTimelineById = createSelector( + (state: State) => state.timeline.timelineById, + (state: State, timelineId: string) => timelineId, + (timelineById, timelineId) => timelineById[timelineId] +); + +/** + * Selector that returns the timeline saved title. + */ +const selectTimelineTitle = createSelector(selectTimelineById, (timeline) => timeline?.title); + +/** + * Selector that returns the timeline type. + */ +const selectTimelineTimelineType = createSelector( + selectTimelineById, + (timeline) => timeline?.timelineType +); + +/** + * Selector that returns the title of a timeline. + * If the timeline has been saved, it will return the saved title. + * If timeline is in template mode, it will return the default 'Untitled template' value; + * If none of the above, it will return the default 'Untitled timeline' value. + */ +export const selectTitleByTimelineById = createSelector( + selectTimelineTitle, + selectTimelineTimelineType, + (savedTitle, timelineType): string => { + if (!isEmpty(savedTitle)) { + return savedTitle; + } + if (timelineType === TimelineType.template) { + return UNTITLED_TEMPLATE; + } + return UNTITLED_TIMELINE; + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/wrapper/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/wrapper/index.test.tsx index a88393fdea99f8..7eac49f7e066d2 100644 --- a/x-pack/plugins/security_solution/public/timelines/wrapper/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/wrapper/index.test.tsx @@ -46,7 +46,7 @@ describe('TimelineWrapper', () => { ); expect(getByTestId('flyout-pane')).toBeInTheDocument(); - expect(getByTestId('flyoutBottomBar')).toBeInTheDocument(); + expect(getByTestId('timeline-bottom-bar')).toBeInTheDocument(); }); it('should render the default timeline state as a bottom bar', () => { @@ -66,7 +66,7 @@ describe('TimelineWrapper', () => { ); - userEvent.click(getByTestId('flyoutOverlay')); + userEvent.click(getByTestId('timeline-bottom-bar-title-button')); expect(mockDispatch).toBeCalledWith( timelineActions.showTimeline({ id: TimelineId.test, show: true }) diff --git a/x-pack/plugins/security_solution/public/timelines/wrapper/index.tsx b/x-pack/plugins/security_solution/public/timelines/wrapper/index.tsx index 7146a50be90495..7b1c0743dbce61 100644 --- a/x-pack/plugins/security_solution/public/timelines/wrapper/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/wrapper/index.tsx @@ -11,12 +11,11 @@ import type { AppLeaveHandler } from '@kbn/core/public'; import { useDispatch } from 'react-redux'; import type { TimelineId } from '../../../common/types'; import { useDeepEqualSelector } from '../../common/hooks/use_selector'; -import { FlyoutBottomBar } from '../components/flyout/bottom_bar'; +import { TimelineBottomBar } from '../components/bottom_bar'; import { Pane } from '../components/flyout/pane'; import { getTimelineShowStatusByIdSelector } from '../store/selectors'; import { useTimelineSavePrompt } from '../../common/hooks/timeline/use_timeline_save_prompt'; import { timelineActions } from '../store'; -import { focusActiveTimelineButton } from '../components/timeline/helpers'; interface TimelineWrapperProps { /** @@ -42,7 +41,6 @@ export const TimelineWrapper: React.FC = React.memo( const handleClose = useCallback(() => { dispatch(timelineActions.showTimeline({ id: timelineId, show: false })); - focusActiveTimelineButton(); }, [dispatch, timelineId]); // pressing the ESC key closes the timeline portal @@ -62,7 +60,7 @@ export const TimelineWrapper: React.FC = React.memo( - + ); diff --git a/x-pack/plugins/threat_intelligence/cypress/screens/timeline.ts b/x-pack/plugins/threat_intelligence/cypress/screens/timeline.ts index 67bc60a4486eb2..e9e960db9858b3 100644 --- a/x-pack/plugins/threat_intelligence/cypress/screens/timeline.ts +++ b/x-pack/plugins/threat_intelligence/cypress/screens/timeline.ts @@ -17,7 +17,7 @@ import { } from '../../public/modules/indicators/components/table/test_ids'; export const INDICATORS_TABLE_INVESTIGATE_IN_TIMELINE_BUTTON_ICON = `[data-test-subj="${CELL_INVESTIGATE_IN_TIMELINE_TEST_ID}"]`; -export const UNTITLED_TIMELINE_BUTTON = `[data-test-subj="flyoutOverlay"]`; +export const UNTITLED_TIMELINE_BUTTON = `[data-test-subj="timeline-bottom-bar-title-button"]`; export const INDICATORS_TABLE_CELL_TIMELINE_BUTTON = `[data-test-subj="${CELL_TIMELINE_BUTTON_TEST_ID}"] button`; export const TIMELINE_DATA_PROVIDERS_WRAPPER = `[data-test-subj="dataProviders"]`; export const TIMELINE_DRAGGABLE_ITEM = `[data-test-subj="providerContainer"]`; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 60a74a6d325e90..6a1c64ae4f8abd 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -35040,7 +35040,6 @@ "xpack.securitySolution.fleetIntegration.assets.name": "Hôtes", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.description": "Filtre d'événement pour Cloud Security. Créé par l'intégration Elastic Defend.", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.name": "Sessions non interactives", - "xpack.securitySolution.flyout.button.timeline": "chronologie", "xpack.securitySolution.flyout.entities.failRelatedHostsDescription": "Impossible de lancer la recherche sur les hôtes associés", "xpack.securitySolution.flyout.entities.failRelatedUsersDescription": "Impossible de lancer la recherche sur les utilisateurs associés", "xpack.securitySolution.flyout.isolateHost.isolateTitle": "Isoler l'hôte", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index bc7eb85e78f6b1..be447f43db5c8b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -35039,7 +35039,6 @@ "xpack.securitySolution.fleetIntegration.assets.name": "ホスト", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.description": "クラウドセキュリティのイベントフィルター。Elastic Defend統合によって作成。", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.name": "非インタラクティブセッション", - "xpack.securitySolution.flyout.button.timeline": "タイムライン", "xpack.securitySolution.flyout.entities.failRelatedHostsDescription": "関連するホストで検索を実行できませんでした", "xpack.securitySolution.flyout.entities.failRelatedUsersDescription": "関連するユーザーで検索を実行できませんでした", "xpack.securitySolution.flyout.isolateHost.isolateTitle": "ホストの分離", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index abb16dee164fcf..0a23b53e0215d6 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -35021,7 +35021,6 @@ "xpack.securitySolution.fleetIntegration.assets.name": "主机", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.description": "云安全事件筛选。已由 Elastic Defend 集成创建。", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.name": "非交互式会话", - "xpack.securitySolution.flyout.button.timeline": "时间线", "xpack.securitySolution.flyout.entities.failRelatedHostsDescription": "无法对相关主机执行搜索", "xpack.securitySolution.flyout.entities.failRelatedUsersDescription": "无法对相关用户执行搜索", "xpack.securitySolution.flyout.isolateHost.isolateTitle": "隔离主机", diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/sourcerer/sourcerer_timeline.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/sourcerer/sourcerer_timeline.cy.ts index 30e1e39b4cd3e0..d8e1ed1ea33214 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/sourcerer/sourcerer_timeline.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/sourcerer/sourcerer_timeline.cy.ts @@ -109,6 +109,7 @@ describe('Timeline scope', { tags: ['@ess', '@serverless', '@brokenInServerless' }); it('Modifies timeline to alerts only, and switches to different saved timeline without issue', function () { + closeTimeline(); openTimelineById(this.timelineId).then(() => { cy.get(SOURCERER.badgeAlerts).should(`not.exist`); cy.get(SOURCERER.badgeModified).should(`not.exist`); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/open_timeline.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/open_timeline.cy.ts index e1dc678631124e..6c1ec9b093df76 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/open_timeline.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/open_timeline.cy.ts @@ -6,7 +6,6 @@ */ import { getTimeline } from '../../../objects/timeline'; - import { TIMELINE_TITLE, OPEN_TIMELINE_MODAL } from '../../../screens/timeline'; import { TIMELINES_DESCRIPTION, @@ -15,9 +14,7 @@ import { TIMELINES_FAVORITE, } from '../../../screens/timelines'; import { addNoteToTimeline } from '../../../tasks/api_calls/notes'; - import { createTimeline } from '../../../tasks/api_calls/timelines'; - import { login } from '../../../tasks/login'; import { visit } from '../../../tasks/navigation'; import { @@ -27,44 +24,42 @@ import { pinFirstEvent, refreshTimelinesUntilTimeLinePresent, } from '../../../tasks/timeline'; - import { TIMELINES_URL } from '../../../urls/navigation'; +import { deleteTimelines } from '../../../tasks/api_calls/common'; -describe('Open timeline', { tags: ['@serverless', '@ess'] }, () => { - describe('Open timeline modal', () => { - before(function () { - login(); - visit(TIMELINES_URL); - createTimeline(getTimeline()) - .then((response) => response.body.data.persistTimeline.timeline.savedObjectId) - .then((timelineId: string) => { - refreshTimelinesUntilTimeLinePresent(timelineId) - // This cy.wait is here because we cannot do a pipe on a timeline as that will introduce multiple URL - // request responses and indeterminism since on clicks to activates URL's. - .then(() => cy.wrap(timelineId).as('timelineId')) - // eslint-disable-next-line cypress/no-unnecessary-waiting - .then(() => cy.wait(1000)) - .then(() => - addNoteToTimeline(getTimeline().notes, timelineId).should((response) => - expect(response.status).to.equal(200) - ) +describe('Open timeline modal', { tags: ['@serverless', '@ess'] }, () => { + beforeEach(function () { + deleteTimelines(); + login(); + visit(TIMELINES_URL); + createTimeline(getTimeline()) + .then((response) => response.body.data.persistTimeline.timeline.savedObjectId) + .then((timelineId: string) => { + refreshTimelinesUntilTimeLinePresent(timelineId) + // This cy.wait is here because we cannot do a pipe on a timeline as that will introduce multiple URL + // request responses and indeterminism since on clicks to activates URL's. + .then(() => cy.wrap(timelineId).as('timelineId')) + // eslint-disable-next-line cypress/no-unnecessary-waiting + .then(() => cy.wait(1000)) + .then(() => + addNoteToTimeline(getTimeline().notes, timelineId).should((response) => + expect(response.status).to.equal(200) ) - .then(() => openTimelineById(timelineId)) - .then(() => pinFirstEvent()) - .then(() => markAsFavorite()); - }); - }); + ) + .then(() => openTimelineById(timelineId)) + .then(() => pinFirstEvent()) + .then(() => markAsFavorite()); + }); + }); - it('should display timeline info', function () { - openTimelineFromSettings(); - openTimelineById(this.timelineId); - cy.get(OPEN_TIMELINE_MODAL).should('be.visible'); - cy.contains(getTimeline().title).should('exist'); - cy.get(TIMELINES_DESCRIPTION).last().should('have.text', getTimeline().description); - cy.get(TIMELINES_PINNED_EVENT_COUNT).last().should('have.text', '1'); - cy.get(TIMELINES_NOTES_COUNT).last().should('have.text', '1'); - cy.get(TIMELINES_FAVORITE).last().should('exist'); - cy.get(TIMELINE_TITLE).should('have.text', getTimeline().title); - }); + it('should display timeline info in the open timeline modal', () => { + openTimelineFromSettings(); + cy.get(OPEN_TIMELINE_MODAL).should('be.visible'); + cy.contains(getTimeline().title).should('exist'); + cy.get(TIMELINES_DESCRIPTION).last().should('have.text', getTimeline().description); + cy.get(TIMELINES_PINNED_EVENT_COUNT).last().should('have.text', '1'); + cy.get(TIMELINES_NOTES_COUNT).last().should('have.text', '1'); + cy.get(TIMELINES_FAVORITE).last().should('exist'); + cy.get(TIMELINE_TITLE).should('have.text', getTimeline().title); }); }); diff --git a/x-pack/test/security_solution_cypress/cypress/screens/security_main.ts b/x-pack/test/security_solution_cypress/cypress/screens/security_main.ts index f68950dbd159ab..6eacc61dd76ad4 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/security_main.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/security_main.ts @@ -9,6 +9,6 @@ export const CLOSE_TIMELINE_BUTTON = '[data-test-subj="close-timeline"]'; export const MAIN_PAGE = '[data-test-subj="kibanaChrome"]'; -export const TIMELINE_TOGGLE_BUTTON = '[data-test-subj="flyoutOverlay"]'; +export const TIMELINE_TOGGLE_BUTTON = '[data-test-subj="timeline-bottom-bar-title-button"]'; -export const TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON = `[data-test-subj="flyoutBottomBar"] ${TIMELINE_TOGGLE_BUTTON}`; +export const TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON = `[data-test-subj="timeline-bottom-bar"] ${TIMELINE_TOGGLE_BUTTON}`; diff --git a/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts b/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts index c6d6b14fcdfd10..69d3d4020b37fc 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts @@ -262,8 +262,7 @@ export const ALERT_TABLE_SEVERITY_HEADER = '[data-gridcell-column-id="kibana.ale export const ALERT_TABLE_FILE_NAME_VALUES = '[data-gridcell-column-id="file.name"][data-test-subj="dataGridRowCell"]'; // empty column for the test data -export const ACTIVE_TIMELINE_BOTTOM_BAR = - '[data-test-subj="flyoutBottomBar"] .active-timeline-button'; +export const ACTIVE_TIMELINE_BOTTOM_BAR = '[data-test-subj="timeline-bottom-bar-title-button"]'; export const GET_TIMELINE_GRID_CELL = (fieldName: string) => `[data-test-subj="draggable-content-${fieldName}"]`; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts b/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts index 767123c9875b66..35248f80ca895c 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/timeline.ts @@ -84,6 +84,7 @@ import { TIMELINE_SEARCH_OR_FILTER, TIMELINE_KQLMODE_FILTER, TIMELINE_KQLMODE_SEARCH, + TIMELINE_PANEL, } from '../screens/timeline'; import { REFRESH_BUTTON, TIMELINE, TIMELINES_TAB_TEMPLATE } from '../screens/timelines'; @@ -364,7 +365,7 @@ export const saveTimeline = () => { export const markAsFavorite = () => { cy.intercept('PATCH', 'api/timeline/_favorite').as('markedAsFavourite'); - cy.get(STAR_ICON).click({ force: true }); + cy.get(TIMELINE_PANEL).within(() => cy.get(STAR_ICON).click()); cy.wait('@markedAsFavourite'); }; @@ -378,7 +379,6 @@ export const openTimelineInspectButton = () => { }; export const openTimelineFromSettings = () => { - cy.get(OPEN_TIMELINE_ICON).should('be.visible'); cy.get(OPEN_TIMELINE_ICON).click(); }; @@ -398,7 +398,7 @@ export const openTimelineById = (timelineId: string): Cypress.Chainable { diff --git a/x-pack/test/security_solution_ftr/page_objects/timeline/index.ts b/x-pack/test/security_solution_ftr/page_objects/timeline/index.ts index e7f35e31702cc0..83cc2f8bd1f336 100644 --- a/x-pack/test/security_solution_ftr/page_objects/timeline/index.ts +++ b/x-pack/test/security_solution_ftr/page_objects/timeline/index.ts @@ -9,7 +9,7 @@ import { subj as testSubjSelector } from '@kbn/test-subj-selector'; import { DATE_RANGE_OPTION_TO_TEST_SUBJ_MAP } from '@kbn/security-solution-plugin/common/test'; import { FtrService } from '../../../functional/ftr_provider_context'; -const TIMELINE_BOTTOM_BAR_CONTAINER_TEST_SUBJ = 'flyoutBottomBar'; +const TIMELINE_BOTTOM_BAR_CONTAINER_TEST_SUBJ = 'timeline-bottom-bar'; const TIMELINE_CLOSE_BUTTON_TEST_SUBJ = 'close-timeline'; const TIMELINE_MODAL_PAGE_TEST_SUBJ = 'timeline'; const TIMELINE_TAB_QUERY_TEST_SUBJ = 'timeline-tab-content-query'; @@ -17,7 +17,7 @@ const TIMELINE_TAB_QUERY_TEST_SUBJ = 'timeline-tab-content-query'; const TIMELINE_CSS_SELECTOR = Object.freeze({ bottomBarTimelineTitle: `${testSubjSelector( TIMELINE_BOTTOM_BAR_CONTAINER_TEST_SUBJ - )} ${testSubjSelector('timeline-title')}`, + )} ${testSubjSelector('timeline-bottom-bar-title-button')}`, /** The refresh button on the timeline view (top of view, next to the date selector) */ refreshButton: `${testSubjSelector(TIMELINE_TAB_QUERY_TEST_SUBJ)} ${testSubjSelector( 'superDatePickerApplyTimeButton'