From bd22f1370fc55179ea6f2737176570176f700b6e Mon Sep 17 00:00:00 2001 From: christineweng <18648970+christineweng@users.noreply.github.com> Date: Fri, 11 Oct 2024 10:00:42 -0500 Subject: [PATCH] [Security Solution] Update icons and timeline tabs when visualization in flyout is enabled (#195687) ## Summary When advanced setting `securitySolution:enableVisualizationsInFlyout` is enabled, clicking on analyzer or session view button should open the visualizations in flyout, the related tabs are also removed from timeline. https://github.com/user-attachments/assets/2502c1f5-f71b-4027-87a3-36cc73ea9451 ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../components/header_actions/actions.tsx | 93 +++++++++++++------ .../hooks/use_navigate_to_analyzer.test.tsx | 47 +++++++++- .../shared/hooks/use_navigate_to_analyzer.tsx | 8 +- .../components/timeline/tabs/index.test.tsx | 58 ++++++++++-- .../components/timeline/tabs/index.tsx | 28 ++++-- 5 files changed, 184 insertions(+), 50 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx b/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx index 7a1bc09c6c45e..2a40c4edd7d2c 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx @@ -9,8 +9,9 @@ import React, { useCallback, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import styled from 'styled-components'; - +import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; import { TimelineTabs, TableId } from '@kbn/securitysolution-data-table'; +import { ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING } from '../../../../common/constants'; import { selectNotesByDocumentId, selectDocumentNotesBySavedObjectId, @@ -46,6 +47,8 @@ import { isDetectionsAlertsTable } from '../top_n/helpers'; import { GuidedOnboardingTourStep } from '../guided_onboarding_tour/tour_step'; import { DEFAULT_ACTION_BUTTON_WIDTH, isAlert } from './helpers'; import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; +import { useNavigateToAnalyzer } from '../../../flyout/document_details/shared/hooks/use_navigate_to_analyzer'; +import { useNavigateToSessionView } from '../../../flyout/document_details/shared/hooks/use_navigate_to_session_view'; const ActionsContainer = styled.div` align-items: center; @@ -111,25 +114,48 @@ const ActionsComponent: React.FC = ({ ); }, [ecsData, eventType]); + const [visualizationInFlyoutEnabled] = useUiSetting$( + ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING + ); + + const { navigateToAnalyzer } = useNavigateToAnalyzer({ + isFlyoutOpen: false, + eventId, + indexName: ecsData._index, + scopeId: timelineId, + }); + + const { navigateToSessionView } = useNavigateToSessionView({ + isFlyoutOpen: false, + eventId, + indexName: ecsData._index, + scopeId: timelineId, + }); + const isDisabled = !useIsInvestigateInResolverActionEnabled(ecsData); const { setGlobalFullScreen } = useGlobalFullScreen(); const { setTimelineFullScreen } = useTimelineFullScreen(); const handleClick = useCallback(() => { startTransaction({ name: ALERTS_ACTIONS.OPEN_ANALYZER }); - const scopedActions = getScopedActions(timelineId); - const dataGridIsFullScreen = document.querySelector('.euiDataGrid--fullScreen'); - if (scopedActions) { - dispatch(scopedActions.updateGraphEventId({ id: timelineId, graphEventId: ecsData._id })); - } - if (timelineId === TimelineId.active) { - if (dataGridIsFullScreen) { - setTimelineFullScreen(true); - } - dispatch(setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.graph })); + if (visualizationInFlyoutEnabled) { + navigateToAnalyzer(); } else { - if (dataGridIsFullScreen) { - setGlobalFullScreen(true); + const scopedActions = getScopedActions(timelineId); + + const dataGridIsFullScreen = document.querySelector('.euiDataGrid--fullScreen'); + if (scopedActions) { + dispatch(scopedActions.updateGraphEventId({ id: timelineId, graphEventId: ecsData._id })); + } + if (timelineId === TimelineId.active) { + if (dataGridIsFullScreen) { + setTimelineFullScreen(true); + } + dispatch(setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.graph })); + } else { + if (dataGridIsFullScreen) { + setGlobalFullScreen(true); + } } } }, [ @@ -139,6 +165,8 @@ const ActionsComponent: React.FC = ({ ecsData._id, setTimelineFullScreen, setGlobalFullScreen, + visualizationInFlyoutEnabled, + navigateToAnalyzer, ]); const sessionViewConfig = useMemo(() => { @@ -169,23 +197,32 @@ const ActionsComponent: React.FC = ({ const openSessionView = useCallback(() => { const dataGridIsFullScreen = document.querySelector('.euiDataGrid--fullScreen'); startTransaction({ name: ALERTS_ACTIONS.OPEN_SESSION_VIEW }); - const scopedActions = getScopedActions(timelineId); - if (timelineId === TimelineId.active) { - if (dataGridIsFullScreen) { - setTimelineFullScreen(true); - } - if (sessionViewConfig !== null) { - dispatch(setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.session })); - } + if ( + visualizationInFlyoutEnabled && + sessionViewConfig !== null && + timelineId !== TableId.kubernetesPageSessions + ) { + navigateToSessionView(); } else { - if (dataGridIsFullScreen) { - setGlobalFullScreen(true); + const scopedActions = getScopedActions(timelineId); + + if (timelineId === TimelineId.active) { + if (dataGridIsFullScreen) { + setTimelineFullScreen(true); + } + if (sessionViewConfig !== null) { + dispatch(setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.session })); + } + } else { + if (dataGridIsFullScreen) { + setGlobalFullScreen(true); + } } - } - if (sessionViewConfig !== null) { - if (scopedActions) { - dispatch(scopedActions.updateSessionViewConfig({ id: timelineId, sessionViewConfig })); + if (sessionViewConfig !== null) { + if (scopedActions) { + dispatch(scopedActions.updateSessionViewConfig({ id: timelineId, sessionViewConfig })); + } } } }, [ @@ -195,6 +232,8 @@ const ActionsComponent: React.FC = ({ setTimelineFullScreen, dispatch, setGlobalFullScreen, + visualizationInFlyoutEnabled, + navigateToSessionView, ]); const { activeStep, isTourShown, incrementStep } = useTourContext(); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.test.tsx index 7ae0601d37ce9..6f578c7cdb95c 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.test.tsx @@ -27,7 +27,8 @@ const mockedUseKibana = mockUseKibana(); (useKibana as jest.Mock).mockReturnValue(mockedUseKibana); const mockUseWhichFlyout = useWhichFlyout as jest.Mock; -const FLYOUT_KEY = 'securitySolution'; +const FLYOUT_KEY = 'SecuritySolution'; +const TIMELINE_FLYOUT_KEY = 'Timeline'; const eventId = 'eventId1'; const indexName = 'index1'; @@ -36,11 +37,11 @@ const scopeId = 'scopeId1'; describe('useNavigateToAnalyzer', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseWhichFlyout.mockReturnValue(FLYOUT_KEY); jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); }); it('when isFlyoutOpen is true, should return callback that opens left and preview panels', () => { + mockUseWhichFlyout.mockReturnValue(FLYOUT_KEY); const hookResult = renderHook(() => useNavigateToAnalyzer({ isFlyoutOpen: true, eventId, indexName, scopeId }) ); @@ -68,7 +69,9 @@ describe('useNavigateToAnalyzer', () => { }); }); - it('when isFlyoutOpen is false, should return callback that opens a new flyout', () => { + it('when isFlyoutOpen is false and scopeId is not timeline, should return callback that opens a new flyout', () => { + mockUseWhichFlyout.mockReturnValue(null); + const hookResult = renderHook(() => useNavigateToAnalyzer({ isFlyoutOpen: false, eventId, indexName, scopeId }) ); @@ -103,4 +106,42 @@ describe('useNavigateToAnalyzer', () => { }, }); }); + + it('when isFlyoutOpen is false and scopeId is current timeline, should return callback that opens a new flyout in timeline', () => { + mockUseWhichFlyout.mockReturnValue(null); + const timelineId = 'timeline-1'; + const hookResult = renderHook(() => + useNavigateToAnalyzer({ isFlyoutOpen: false, eventId, indexName, scopeId: timelineId }) + ); + hookResult.result.current.navigateToAnalyzer(); + expect(mockFlyoutApi.openFlyout).toHaveBeenCalledWith({ + right: { + id: DocumentDetailsRightPanelKey, + params: { + id: eventId, + indexName, + scopeId: timelineId, + }, + }, + left: { + id: DocumentDetailsLeftPanelKey, + path: { + tab: 'visualize', + subTab: ANALYZE_GRAPH_ID, + }, + params: { + id: eventId, + indexName, + scopeId: timelineId, + }, + }, + preview: { + id: DocumentDetailsAnalyzerPanelKey, + params: { + resolverComponentInstanceID: `${TIMELINE_FLYOUT_KEY}-${timelineId}`, + banner: ANALYZER_PREVIEW_BANNER, + }, + }, + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.tsx index 448f2c081c946..516a43332d29f 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.tsx @@ -17,6 +17,8 @@ import { DocumentDetailsRightPanelKey, DocumentDetailsAnalyzerPanelKey, } from '../constants/panel_keys'; +import { Flyouts } from '../constants/flyouts'; +import { isTimelineScope } from '../../../../helpers'; export interface UseNavigateToAnalyzerParams { /** @@ -56,7 +58,11 @@ export const useNavigateToAnalyzer = ({ }: UseNavigateToAnalyzerParams): UseNavigateToAnalyzerResult => { const { telemetry } = useKibana().services; const { openLeftPanel, openPreviewPanel, openFlyout } = useExpandableFlyoutApi(); - const key = useWhichFlyout() ?? 'memory'; + let key = useWhichFlyout() ?? 'memory'; + + if (!isFlyoutOpen) { + key = isTimelineScope(scopeId) ? Flyouts.timeline : Flyouts.securitySolution; + } const right: FlyoutPanelProps = useMemo( () => ({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/index.test.tsx index 7bf2dee022b1b..2fa95f066f80a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/index.test.tsx @@ -15,6 +15,18 @@ import { TimelineId, TimelineTabs } from '../../../../../common/types/timeline'; import { TimelineTypeEnum } from '../../../../../common/api/timeline'; import { useEsqlAvailability } from '../../../../common/hooks/esql/use_esql_availability'; import { render, screen, waitFor } from '@testing-library/react'; +import { useLicense } from '../../../../common/hooks/use_license'; + +jest.mock('../../../../common/hooks/use_license'); + +const mockUseUiSetting = jest.fn().mockReturnValue([false]); +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + useUiSetting$: () => mockUseUiSetting(), + }; +}); jest.mock('react-router-dom', () => { const original = jest.requireActual('react-router-dom'); @@ -33,18 +45,19 @@ jest.mock('../../../../common/hooks/esql/use_esql_availability', () => ({ const useEsqlAvailabilityMock = useEsqlAvailability as jest.Mock; +const defaultProps = { + renderCellValue: () => { + return null; + }, + rowRenderers: [], + timelineId: TimelineId.test, + timelineType: TimelineTypeEnum.default, + timelineDescription: '', +}; + describe('Timeline', () => { describe('esql tab', () => { const esqlTabSubj = `timelineTabs-${TimelineTabs.esql}`; - const defaultProps = { - renderCellValue: () => { - return null; - }, - rowRenderers: [], - timelineId: TimelineId.test, - timelineType: TimelineTypeEnum.default, - timelineDescription: '', - }; it('should show the esql tab', () => { render( @@ -131,4 +144,31 @@ describe('Timeline', () => { }); }); }); + + describe('analyzer tab and session view tab', () => { + const analyzerTabSubj = `timelineTabs-${TimelineTabs.graph}`; + const sessionViewTabSubj = `timelineTabs-${TimelineTabs.session}`; + it('should show the analyzer tab when the advanced setting is disabled', () => { + (useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true }); + render( + + + + ); + expect(screen.getByTestId(analyzerTabSubj)).toBeInTheDocument(); + expect(screen.getByTestId(sessionViewTabSubj)).toBeInTheDocument(); + }); + + it('should not show the analyzer tab when the advanced setting is enabled', async () => { + mockUseUiSetting.mockReturnValue([true]); + (useLicense as jest.Mock).mockReturnValue({ isEnterprise: () => true }); + render( + + + + ); + expect(screen.queryByTestId(analyzerTabSubj)).not.toBeInTheDocument(); + expect(screen.queryByTestId(sessionViewTabSubj)).not.toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/index.tsx index 442f7548bcf73..601a31d97fa5c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/index.tsx @@ -11,6 +11,7 @@ import type { Ref, ReactElement, ComponentType } from 'react'; import React, { lazy, memo, Suspense, useCallback, useEffect, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; +import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import type { State } from '../../../../common/store'; @@ -42,6 +43,7 @@ import { useLicense } from '../../../../common/hooks/use_license'; import { initializeTimelineSettings } from '../../../store/actions'; import { selectTimelineById, selectTimelineESQLSavedSearchId } from '../../../store/selectors'; import { fetchNotesBySavedObjectIds, selectSortedNotesBySavedObjectId } from '../../../../notes'; +import { ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING } from '../../../../../common/constants'; const HideShowContainer = styled.div.attrs<{ $isVisible: boolean; isOverflowYScroll: boolean }>( ({ $isVisible = false, isOverflowYScroll = false }) => ({ @@ -255,6 +257,10 @@ const TabsContentComponent: React.FC = ({ 'securitySolutionNotesEnabled' ); + const [visualizationInFlyoutEnabled] = useUiSetting$( + ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING + ); + const activeTab = useShallowEqualSelector((state) => getActiveTab(state, timelineId)); const showTimeline = useShallowEqualSelector((state) => getShowTimeline(state, timelineId)); const shouldShowESQLTab = useMemo(() => { @@ -409,16 +415,18 @@ const TabsContentComponent: React.FC = ({ {showTimeline && } )} - - {i18n.ANALYZER_TAB} - - {isEnterprisePlus && ( + {!visualizationInFlyoutEnabled && ( + + {i18n.ANALYZER_TAB} + + )} + {isEnterprisePlus && !visualizationInFlyoutEnabled && (