Skip to content

Commit

Permalink
[Security Solution] Update icons and timeline tabs when visualization…
Browse files Browse the repository at this point in the history
… in flyout is enabled (elastic#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
  • Loading branch information
christineweng authored Oct 11, 2024
1 parent dc1aced commit bd22f13
Show file tree
Hide file tree
Showing 5 changed files with 184 additions and 50 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -111,25 +114,48 @@ const ActionsComponent: React.FC<ActionProps> = ({
);
}, [ecsData, eventType]);

const [visualizationInFlyoutEnabled] = useUiSetting$<boolean>(
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);
}
}
}
}, [
Expand All @@ -139,6 +165,8 @@ const ActionsComponent: React.FC<ActionProps> = ({
ecsData._id,
setTimelineFullScreen,
setGlobalFullScreen,
visualizationInFlyoutEnabled,
navigateToAnalyzer,
]);

const sessionViewConfig = useMemo(() => {
Expand Down Expand Up @@ -169,23 +197,32 @@ const ActionsComponent: React.FC<ActionProps> = ({
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 }));
}
}
}
}, [
Expand All @@ -195,6 +232,8 @@ const ActionsComponent: React.FC<ActionProps> = ({
setTimelineFullScreen,
dispatch,
setGlobalFullScreen,
visualizationInFlyoutEnabled,
navigateToSessionView,
]);

const { activeStep, isTourShown, incrementStep } = useTourContext();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 })
);
Expand Down Expand Up @@ -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 })
);
Expand Down Expand Up @@ -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,
},
},
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
DocumentDetailsRightPanelKey,
DocumentDetailsAnalyzerPanelKey,
} from '../constants/panel_keys';
import { Flyouts } from '../constants/flyouts';
import { isTimelineScope } from '../../../../helpers';

export interface UseNavigateToAnalyzerParams {
/**
Expand Down Expand Up @@ -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(
() => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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(
Expand Down Expand Up @@ -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(
<TestProviders>
<TabsContent {...defaultProps} />
</TestProviders>
);
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(
<TestProviders>
<TabsContent {...defaultProps} />
</TestProviders>
);
expect(screen.queryByTestId(analyzerTabSubj)).not.toBeInTheDocument();
expect(screen.queryByTestId(sessionViewTabSubj)).not.toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 }) => ({
Expand Down Expand Up @@ -255,6 +257,10 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({
'securitySolutionNotesEnabled'
);

const [visualizationInFlyoutEnabled] = useUiSetting$<boolean>(
ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING
);

const activeTab = useShallowEqualSelector((state) => getActiveTab(state, timelineId));
const showTimeline = useShallowEqualSelector((state) => getShowTimeline(state, timelineId));
const shouldShowESQLTab = useMemo(() => {
Expand Down Expand Up @@ -409,16 +415,18 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({
{showTimeline && <EqlEventsCountBadge />}
</StyledEuiTab>
)}
<EuiTab
data-test-subj={`timelineTabs-${TimelineTabs.graph}`}
onClick={setGraphAsActiveTab}
isSelected={activeTab === TimelineTabs.graph}
disabled={!graphEventId}
key={TimelineTabs.graph}
>
{i18n.ANALYZER_TAB}
</EuiTab>
{isEnterprisePlus && (
{!visualizationInFlyoutEnabled && (
<EuiTab
data-test-subj={`timelineTabs-${TimelineTabs.graph}`}
onClick={setGraphAsActiveTab}
isSelected={activeTab === TimelineTabs.graph}
disabled={!graphEventId}
key={TimelineTabs.graph}
>
{i18n.ANALYZER_TAB}
</EuiTab>
)}
{isEnterprisePlus && !visualizationInFlyoutEnabled && (
<EuiTab
data-test-subj={`timelineTabs-${TimelineTabs.session}`}
onClick={setSessionAsActiveTab}
Expand Down

0 comments on commit bd22f13

Please sign in to comment.