Skip to content

Commit

Permalink
[Security Solution] Preview navigation in document details flyout (el…
Browse files Browse the repository at this point in the history
…astic#204292)

## Summary

This PR enables navigation when an user is viewing an alert/event
preview.

- To see the new navigation, enable feature flag
`newExpandableFlyoutNavigationEnabled`
- This PR only covers navigations available in the alert/event preview.
Links in host and user flyouts will be done in a separate PR

How navigation used to work:
- When a document details flyout is open, many sections have nevigation
links to open the details panel in the left
- However, these navigations were disabled in a preview mode, and user
is limited to stay in the original flyout context

Preview navigations now available
- When the feature flag is on, navigation links is available
- For example, an user is on an alert preview, and click on `Entities`,
a new flyout will be opened (for the alert context) and the entities
details section will be available.

**Before**
<img width="1483" alt="image"
src="https://github.com/user-attachments/assets/a8a21b86-21c1-48d4-ae4e-4ca5b337a2b0"
/>

**After**
<img width="1449" alt="image"
src="https://github.com/user-attachments/assets/2db865f3-3a31-440c-bc84-3003928c1f6a"
/>



https://github.com/user-attachments/assets/ae62b553-1ec9-4663-9ed3-ff20b2355061


### Checklist

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [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 Dec 16, 2024
1 parent c42bf4a commit d638475
Show file tree
Hide file tree
Showing 35 changed files with 1,246 additions and 659 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID,
EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID,
} from '../../../shared/components/test_ids';

import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { useInvestigateInTimeline } from '../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline';

jest.mock(
Expand All @@ -32,6 +32,7 @@ jest.mock('../../shared/hooks/use_alert_prevalence_from_process_tree');
jest.mock(
'../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline'
);
jest.mock('../../../../common/hooks/use_experimental_features');

const mockNavigateToAnalyzer = jest.fn();
jest.mock('../../shared/hooks/use_navigate_to_analyzer', () => {
Expand Down Expand Up @@ -83,6 +84,7 @@ const renderAnalyzerPreview = (context = mockContextValue) =>
describe('AnalyzerPreviewContainer', () => {
beforeEach(() => {
jest.clearAllMocks();
(useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false);
});

it('should render component and link in header', () => {
Expand Down Expand Up @@ -256,4 +258,87 @@ describe('AnalyzerPreviewContainer', () => {
).not.toBeInTheDocument();
});
});

describe('when new navigation is enabled', () => {
beforeEach(() => {
(useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true);
});
describe('when visualizationInFlyoutEnabled is enabled', () => {
beforeEach(() => {
mockUseUiSetting.mockReturnValue([true]);
(useIsInvestigateInResolverActionEnabled as jest.Mock).mockReturnValue(true);
(useAlertPrevalenceFromProcessTree as jest.Mock).mockReturnValue({
loading: false,
error: false,
alertIds: ['alertid'],
statsNodes: mock.mockStatsNodes,
});
(useInvestigateInTimeline as jest.Mock).mockReturnValue({
investigateInTimelineAlertClick: jest.fn(),
});
});
it('should open left flyout visualization tab when clicking on title', () => {
const { getByTestId } = renderAnalyzerPreview();

getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(ANALYZER_PREVIEW_TEST_ID)).click();
expect(mockNavigateToAnalyzer).toHaveBeenCalled();
});

it('should disable link when in rule preview', () => {
const { queryByTestId } = renderAnalyzerPreview({ ...mockContextValue, isPreview: true });
expect(
queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(ANALYZER_PREVIEW_TEST_ID))
).not.toBeInTheDocument();
});

it('should render link when in preview mode', () => {
const { getByTestId } = renderAnalyzerPreview({ ...mockContextValue, isPreviewMode: true });

getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(ANALYZER_PREVIEW_TEST_ID)).click();
expect(mockNavigateToAnalyzer).toHaveBeenCalled();
});
});

describe('when visualizationInFlyoutEnabled is disabled', () => {
beforeEach(() => {
mockUseUiSetting.mockReturnValue([false]);
(useIsInvestigateInResolverActionEnabled as jest.Mock).mockReturnValue(true);
(useAlertPrevalenceFromProcessTree as jest.Mock).mockReturnValue({
loading: false,
error: false,
alertIds: ['alertid'],
statsNodes: mock.mockStatsNodes,
});
(useInvestigateInTimeline as jest.Mock).mockReturnValue({
investigateInTimelineAlertClick: jest.fn(),
});
});
it('should navigate to analyzer in timeline when clicking on title', () => {
const { getByTestId } = renderAnalyzerPreview();
const { investigateInTimelineAlertClick } = useInvestigateInTimeline({});

getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(ANALYZER_PREVIEW_TEST_ID)).click();
expect(investigateInTimelineAlertClick).toHaveBeenCalled();
});

it('should not navigate to analyzer when in preview and clicking on title', () => {
const { queryByTestId } = renderAnalyzerPreview({ ...mockContextValue, isPreview: true });
expect(
queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(ANALYZER_PREVIEW_TEST_ID))
).not.toBeInTheDocument();
const { investigateInTimelineAlertClick } = useInvestigateInTimeline({});
expect(investigateInTimelineAlertClick).not.toHaveBeenCalled();
});

it('should open analyzer in timelinewhen in preview mode', () => {
const { getByTestId } = renderAnalyzerPreview({
...mockContextValue,
isPreviewMode: true,
});
const { investigateInTimelineAlertClick } = useInvestigateInTimeline({});
getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(ANALYZER_PREVIEW_TEST_ID)).click();
expect(investigateInTimelineAlertClick).toHaveBeenCalled();
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 2.0.
*/

import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { TimelineTabs } from '@kbn/securitysolution-data-table';
import { EuiLink, EuiMark } from '@elastic/eui';
Expand All @@ -23,6 +23,7 @@ import { AnalyzerPreview } from './analyzer_preview';
import { ANALYZER_PREVIEW_TEST_ID } from './test_ids';
import { useNavigateToAnalyzer } from '../../shared/hooks/use_navigate_to_analyzer';
import { ExpandablePanel } from '../../../shared/components/expandable_panel';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';

const timelineId = 'timeline-1';

Expand All @@ -36,6 +37,9 @@ export const AnalyzerPreviewContainer: React.FC = () => {
const [visualizationInFlyoutEnabled] = useUiSetting$<boolean>(
ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING
);
const isNewNavigationEnabled = useIsExperimentalFeatureEnabled(
'newExpandableFlyoutNavigationEnabled'
);
// decide whether to show the analyzer preview or not
const isEnabled = useIsInvestigateInResolverActionEnabled(dataAsNestedObject);

Expand Down Expand Up @@ -66,8 +70,27 @@ export const AnalyzerPreviewContainer: React.FC = () => {
indexName,
isFlyoutOpen: true,
scopeId,
isPreviewMode,
});

const iconType = useMemo(() => {
const icon = visualizationInFlyoutEnabled ? 'arrowStart' : 'timeline';
return !isPreviewMode ? icon : undefined;
}, [visualizationInFlyoutEnabled, isPreviewMode]);

const isNavigationEnabled = useMemo(() => {
// if the analyzer is not enabled or in rule preview mode, the navigation is not enabled
if (!isEnabled || isPreview) {
return false;
}
// if the new navigation is enabled, the navigation is enabled (flyout or timeline)
if (isNewNavigationEnabled) {
return true;
}
// if the new navigation is not enabled, the navigation is enabled if the flyout is not in preview mode
return !isPreviewMode;
}, [isNewNavigationEnabled, isPreviewMode, isEnabled, isPreview]);

return (
<ExpandablePanel
header={{
Expand All @@ -77,25 +100,23 @@ export const AnalyzerPreviewContainer: React.FC = () => {
defaultMessage="Analyzer preview"
/>
),
iconType: visualizationInFlyoutEnabled ? 'arrowStart' : 'timeline',
...(isEnabled &&
!isPreview &&
!isPreviewMode && {
link: {
callback: visualizationInFlyoutEnabled ? navigateToAnalyzer : goToAnalyzerTab,
tooltip: visualizationInFlyoutEnabled ? (
<FormattedMessage
id="xpack.securitySolution.flyout.right.visualizations.analyzerPreview.analyzerPreviewOpenAnalyzerTooltip"
defaultMessage="Open analyzer graph"
/>
) : (
<FormattedMessage
id="xpack.securitySolution.flyout.right.visualizations.analyzerPreview.analyzerPreviewInvestigateTooltip"
defaultMessage="Investigate in timeline"
/>
),
},
}),
iconType,
...(isNavigationEnabled && {
link: {
callback: visualizationInFlyoutEnabled ? navigateToAnalyzer : goToAnalyzerTab,
tooltip: visualizationInFlyoutEnabled ? (
<FormattedMessage
id="xpack.securitySolution.flyout.right.visualizations.analyzerPreview.analyzerPreviewOpenAnalyzerTooltip"
defaultMessage="Open analyzer graph"
/>
) : (
<FormattedMessage
id="xpack.securitySolution.flyout.right.visualizations.analyzerPreview.analyzerPreviewInvestigateTooltip"
defaultMessage="Investigate in timeline"
/>
),
},
}),
}}
data-test-subj={ANALYZER_PREVIEW_TEST_ID}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,9 @@

import React from 'react';
import { render } from '@testing-library/react';
import type { ExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { DocumentDetailsContext } from '../../shared/context';
import { TestProviders } from '../../../../common/mock';
import { CorrelationsOverview } from './correlations_overview';
import { CORRELATIONS_TAB_ID } from '../../left/components/correlations_details';
import { DocumentDetailsLeftPanelKey } from '../../shared/constants/panel_keys';
import { LeftPanelInsightsTab } from '../../left';
import {
CORRELATIONS_RELATED_ALERTS_BY_ANCESTRY_TEST_ID,
CORRELATIONS_RELATED_ALERTS_BY_SAME_SOURCE_EVENT_TEST_ID,
Expand All @@ -35,6 +30,7 @@ import { useFetchRelatedAlertsBySameSourceEvent } from '../../shared/hooks/use_f
import { useFetchRelatedAlertsBySession } from '../../shared/hooks/use_fetch_related_alerts_by_session';
import { useTimelineDataFilters } from '../../../../timelines/containers/use_timeline_data_filters';
import { useFetchRelatedCases } from '../../shared/hooks/use_fetch_related_cases';
import { useNavigateToLeftPanel } from '../../shared/hooks/use_navigate_to_left_panel';
import {
EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID,
EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID,
Expand All @@ -53,6 +49,7 @@ jest.mock('../../shared/hooks/use_fetch_related_alerts_by_session');
jest.mock('../../shared/hooks/use_fetch_related_alerts_by_ancestry');
jest.mock('../../shared/hooks/use_fetch_related_alerts_by_same_source_event');
jest.mock('../../shared/hooks/use_fetch_related_cases');
jest.mock('../../shared/hooks/use_navigate_to_left_panel');

const TOGGLE_ICON_TEST_ID = EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID(CORRELATIONS_TEST_ID);
const TITLE_LINK_TEST_ID = EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(CORRELATIONS_TEST_ID);
Expand Down Expand Up @@ -104,12 +101,6 @@ const renderCorrelationsOverview = (contextValue: DocumentDetailsContext) => (

const NO_DATA_MESSAGE = 'No correlations data available.';

const flyoutContextValue = {
openLeftPanel: jest.fn(),
} as unknown as ExpandableFlyoutApi;

jest.mock('@kbn/expandable-flyout');

jest.mock('../../../../timelines/containers/use_timeline_data_filters', () => ({
useTimelineDataFilters: jest.fn(),
}));
Expand All @@ -120,10 +111,10 @@ jest.mock('../../../../common/components/guided_onboarding_tour', () => ({
}));

const originalEventId = 'originalEventId';
const mockNavigateToLeftPanel = jest.fn();

describe('<CorrelationsOverview />', () => {
beforeAll(() => {
jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue);
beforeEach(() => {
jest.mocked(useTourContext).mockReturnValue({
hidden: false,
setAllTourStepsHidden: jest.fn(),
Expand All @@ -133,10 +124,6 @@ describe('<CorrelationsOverview />', () => {
isTourShown: jest.fn(),
setStep: jest.fn(),
});
mockUseTimelineDataFilters.mockReturnValue({ selectedPatterns: ['index'] });
});

it('should render wrapper component', () => {
jest
.mocked(useShowRelatedAlertsByAncestry)
.mockReturnValue({ show: false, documentId: 'event-id' });
Expand All @@ -146,31 +133,38 @@ describe('<CorrelationsOverview />', () => {
jest.mocked(useShowRelatedAlertsBySession).mockReturnValue({ show: false });
jest.mocked(useShowRelatedCases).mockReturnValue(false);
jest.mocked(useShowSuppressedAlerts).mockReturnValue({ show: false, alertSuppressionCount: 0 });
mockUseTimelineDataFilters.mockReturnValue({ selectedPatterns: ['index'] });
(useNavigateToLeftPanel as jest.Mock).mockReturnValue({
navigateToLeftPanel: mockNavigateToLeftPanel,
isEnabled: true,
});
});

it('should render wrapper component', () => {
const { getByTestId, queryByTestId } = render(renderCorrelationsOverview(panelContextValue));
expect(queryByTestId(TOGGLE_ICON_TEST_ID)).not.toBeInTheDocument();
expect(getByTestId(TITLE_LINK_TEST_ID)).toBeInTheDocument();
expect(getByTestId(TITLE_ICON_TEST_ID)).toBeInTheDocument();
expect(queryByTestId(TITLE_TEXT_TEST_ID)).not.toBeInTheDocument();
});

it('should not render link when isPreviewMode is true', () => {
jest
.mocked(useShowRelatedAlertsByAncestry)
.mockReturnValue({ show: false, documentId: 'event-id' });
jest
.mocked(useShowRelatedAlertsBySameSourceEvent)
.mockReturnValue({ show: false, originalEventId });
jest.mocked(useShowRelatedAlertsBySession).mockReturnValue({ show: false });
jest.mocked(useShowRelatedCases).mockReturnValue(false);
jest.mocked(useShowSuppressedAlerts).mockReturnValue({ show: false, alertSuppressionCount: 0 });

it('should render link without icon if in preview mode', () => {
const { getByTestId, queryByTestId } = render(
renderCorrelationsOverview({ ...panelContextValue, isPreviewMode: true })
);
expect(getByTestId(TITLE_LINK_TEST_ID)).toBeInTheDocument();
expect(queryByTestId(TITLE_ICON_TEST_ID)).not.toBeInTheDocument();
});

it('should not render link when navigation is disabled', () => {
(useNavigateToLeftPanel as jest.Mock).mockReturnValue({
navigateToLeftPanel: mockNavigateToLeftPanel,
isEnabled: false,
});

const { getByTestId, queryByTestId } = render(renderCorrelationsOverview(panelContextValue));
expect(queryByTestId(TOGGLE_ICON_TEST_ID)).not.toBeInTheDocument();
expect(queryByTestId(TITLE_LINK_TEST_ID)).not.toBeInTheDocument();
expect(queryByTestId(TITLE_ICON_TEST_ID)).not.toBeInTheDocument();
expect(getByTestId(TITLE_TEXT_TEST_ID)).toBeInTheDocument();
});

Expand Down Expand Up @@ -261,15 +255,7 @@ describe('<CorrelationsOverview />', () => {
);

getByTestId(TITLE_LINK_TEST_ID).click();
expect(flyoutContextValue.openLeftPanel).toHaveBeenCalledWith({
id: DocumentDetailsLeftPanelKey,
path: { tab: LeftPanelInsightsTab, subTab: CORRELATIONS_TAB_ID },
params: {
id: panelContextValue.eventId,
indexName: panelContextValue.indexName,
scopeId: panelContextValue.scopeId,
},
});
expect(mockNavigateToLeftPanel).toHaveBeenCalled();
});

it('should navigate to the left section Insights tab automatically when active step is "view case"', () => {
Expand All @@ -280,15 +266,6 @@ describe('<CorrelationsOverview />', () => {
</DocumentDetailsContext.Provider>
</TestProviders>
);

expect(flyoutContextValue.openLeftPanel).toHaveBeenCalledWith({
id: DocumentDetailsLeftPanelKey,
path: { tab: LeftPanelInsightsTab, subTab: CORRELATIONS_TAB_ID },
params: {
id: panelContextValue.eventId,
indexName: panelContextValue.indexName,
scopeId: panelContextValue.scopeId,
},
});
expect(mockNavigateToLeftPanel).toHaveBeenCalled();
});
});
Loading

0 comments on commit d638475

Please sign in to comment.