[Security Solution][Expandable flyout] Introducing Flyout history in document flyout (#184970)

## Summary

This PR introduced flyout history in expandable flyouts to keep tracked
of previously opened flyouts. The history button is available when
feature flag `newExpandableFlyoutNavigationEnabled` is enabled.

Flag is currently default `False` 

### Changes in
[kbn-expandable-flyout](https://github.com/elastic/kibana/tree/main/packages/kbn-expandable-flyout)
package

- When `openFlyout` is called, the **right** panel will be appended to
the `history` slice in redux.
- History can be accessed via `useExpandableFlyoutHistory` API


![image](https://github.com/user-attachments/assets/081d6d6f-3c10-40f0-8882-73bc8c275e68)


### Changes to expandable flyouts in security solution

- When feature flag is on, opening more than 1 flyout will show a
history icon. Currently max at 10 entries
- When user clicks a flyout from the history, it does not add on top on
history, instead the position will be moved up. There is no duplicate
entries.


![image](https://github.com/user-attachments/assets/3bc68519-5eea-4fb7-9386-f6688b28b525)

**To illustrate how ordering works:**
-> History: [host1, user1, alert1]
-> clicks alert1 
-> History: [alert1, host1, user1] 
Keep in mind this is slightly different in the actual implementation, as
we do not display the current entry (i.e. alert1 in this example)

### Other changes in order to support flyout history
- Added a preview panel for network. Previously we reused the panel for
both network flyout and network preview. A dedicated network preview
with out history is now available
- Replaced `openRightPanel` with `openFlyout` in applicable places
- Added `isPreview` and `isPreviewMode` checks in EA flyouts

## How to test
- Enable feature flag `newExpandableFlyoutNavigationEnabled`

<details>
<summary>  Alerts page</summary>
Available for alert, host, user, rule name and ip's
<img
src="https://github.com/user-attachments/assets/e74a6444-763f-4e18-8370-f6c0c83e0d4c"
/>
</details>

<details>
<summary>  Explore pages (event table)</summary>
Available for events, host, user, rule name and ip's
<img
src="https://github.com/user-attachments/assets/d2b9f0b9-a788-4174-bc80-8ac9c51fb16a"
/>
</details>

<details>
<summary>  Cases</summary>
Note: the rule and entity link still go to a page, this will be
addressed in a separate PR
<img
src="https://github.com/user-attachments/assets/fa7a5c86-d1e3-4dad-80ed-405c52efc486"
/>
</details>


<details>
<summary>  Discover in severless</summary>
- enable `discover.experimental.enabledProfiles:
['security-root-profile']`
<img
src="https://github.com/user-attachments/assets/ebd5de5d-1ed3-42ad-bb6f-1beccdc48e62"
/>
</details>

<details>
<summary> Disabled in alert preview </summary>
<img
src="https://github.com/user-attachments/assets/53e82ded-0db8-4639-afa1-c5cf224cf193"
/>
</details>

<details>
<summary> Disabled in preview mode </summary>
<img
src="https://github.com/user-attachments/assets/a12b741f-2558-4fb5-852f-282af6e10f93"
/>
</details>


## WIP
- [x] Investigate performance with process history
- [ ] Final ui of the entries - pending UIUX team


### Checklist

- [x] 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]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [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
This commit is contained in:
christineweng 2024-12-10 15:43:28 -06:00 committed by GitHub
parent a5c9ed7bb8
commit 5b6887dd3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1320 additions and 206 deletions

View file

@ -61,6 +61,8 @@ To control (or mutate) flyout's layout, you can utilize [useExpandableFlyoutApi]
> The expandable flyout propagates the `onClose` callback from the EuiFlyout component. As we recommend having a single instance of the flyout in your application, it's up to the application's code to dispatch the event (through Redux, window events, observable, prop drilling...).
When calling `openFlyout`, the right panel state is automatically appended in the `history` slice in the redux context. To access the flyout's history, you can use the [useExpandableFlyoutHistory](https://github.com/elastic/kibana/blob/main/packages/kbn-expandable-flyout/src/hooks/use_expandable_flyout_history.ts) hook.
## Usage
To use the expandable flyout in your plugin, first you need wrap your code with the [context provider](https://github.com/elastic/kibana/blob/main/packages/kbn-expandable-flyout/src/context.tsx) at a high enough level as follows:

View file

@ -11,6 +11,7 @@ export { ExpandableFlyout } from './src';
export { useExpandableFlyoutApi } from './src/hooks/use_expandable_flyout_api';
export { useExpandableFlyoutState } from './src/hooks/use_expandable_flyout_state';
export { useExpandableFlyoutHistory } from './src/hooks/use_expandable_flyout_history';
export { type FlyoutPanels as ExpandableFlyoutState } from './src/store/state';

View file

@ -58,6 +58,7 @@ describe('Container', () => {
},
left: undefined,
preview: undefined,
history: [{ id: 'key' }],
},
},
},
@ -85,6 +86,7 @@ describe('Container', () => {
id: 'key',
},
preview: undefined,
history: [],
},
},
},
@ -112,6 +114,7 @@ describe('Container', () => {
id: 'key',
},
],
history: [],
},
},
},
@ -137,6 +140,7 @@ describe('Container', () => {
},
left: undefined,
preview: undefined,
history: [],
},
},
},
@ -163,6 +167,7 @@ describe('Container', () => {
},
left: undefined,
preview: undefined,
history: [],
},
},
},

View file

@ -30,6 +30,7 @@ describe('PreviewSection', () => {
id: 'key',
},
],
history: [],
},
},
},

View file

@ -0,0 +1,22 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { REDUX_ID_FOR_MEMORY_STORAGE } from '../constants';
import { useExpandableFlyoutContext } from '../context';
import { selectHistoryById, useSelector } from '../store/redux';
/**
* This hook allows you to access the flyout state, read open right, left and preview panels.
*/
export const useExpandableFlyoutHistory = () => {
const { urlKey } = useExpandableFlyoutContext();
// if no urlKey is provided, we are in memory storage mode and use the reserved word 'memory'
const id = urlKey || REDUX_ID_FOR_MEMORY_STORAGE;
return useSelector(selectHistoryById(id));
};

View file

@ -111,6 +111,7 @@ export const Right: Story<void> = () => {
},
left: undefined,
preview: undefined,
history: [{ id: 'right' }],
},
},
},
@ -139,6 +140,7 @@ export const Left: Story<void> = () => {
id: 'left',
},
preview: undefined,
history: [{ id: 'right' }],
},
},
},
@ -171,6 +173,7 @@ export const Preview: Story<void> = () => {
id: 'preview1',
},
],
history: [{ id: 'right' }],
},
},
},
@ -206,6 +209,7 @@ export const MultiplePreviews: Story<void> = () => {
id: 'preview2',
},
],
history: [{ id: 'right' }],
},
},
},
@ -232,6 +236,7 @@ export const CollapsedPushMode: Story<void> = () => {
},
left: undefined,
preview: undefined,
history: [{ id: 'right' }],
},
},
},
@ -260,6 +265,7 @@ export const ExpandedPushMode: Story<void> = () => {
id: 'left',
},
preview: undefined,
history: [{ id: 'right' }],
},
},
},
@ -288,6 +294,7 @@ export const DisableTypeSelection: Story<void> = () => {
id: 'left',
},
preview: undefined,
history: [{ id: 'right' }],
},
},
},
@ -318,6 +325,7 @@ export const ResetWidths: Story<void> = () => {
id: 'left',
},
preview: undefined,
history: [{ id: 'right' }],
},
},
},
@ -343,6 +351,7 @@ export const DisableResizeWidthSelection: Story<void> = () => {
id: 'left',
},
preview: undefined,
history: [{ id: 'right' }],
},
},
},

View file

@ -51,6 +51,7 @@ describe('ExpandableFlyout', () => {
},
left: undefined,
preview: undefined,
history: [{ id: 'key' }],
},
},
},

View file

@ -34,6 +34,7 @@ describe('UrlSynchronizer', () => {
right: { id: 'key1' },
left: { id: 'key11' },
preview: undefined,
history: [{ id: 'key1' }],
},
},
needsSync: true,
@ -93,6 +94,7 @@ describe('UrlSynchronizer', () => {
right: { id: 'key1' },
left: { id: 'key2' },
preview: undefined,
history: [{ id: 'key1' }],
},
},
needsSync: true,

View file

@ -74,19 +74,21 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
},
needsSync: true,
});
});
it('should override all panels in the state', () => {
it('should override all panels in the state and update history', () => {
const state: PanelsState = {
byId: {
[id1]: {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1, { id: 'preview' }],
history: [rightPanel1],
},
},
};
@ -104,6 +106,7 @@ describe('panelsReducer', () => {
left: leftPanel2,
right: rightPanel2,
preview: [previewPanel2],
history: [rightPanel1, rightPanel2],
},
},
needsSync: true,
@ -117,6 +120,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
},
};
@ -132,6 +136,7 @@ describe('panelsReducer', () => {
left: undefined,
right: rightPanel2,
preview: undefined,
history: [rightPanel1, rightPanel2],
},
},
needsSync: true,
@ -145,6 +150,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
},
};
@ -160,11 +166,13 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
[id2]: {
left: undefined,
right: rightPanel2,
preview: undefined,
history: [rightPanel2],
},
},
needsSync: true,
@ -173,7 +181,7 @@ describe('panelsReducer', () => {
});
describe('should handle openRightPanel action', () => {
it('should add right panel to empty state', () => {
it('should add right panel to empty state but does not update history', () => {
const state: PanelsState = initialPanelsState;
const action = openRightPanelAction({ right: rightPanel1, id: id1 });
const newState: PanelsState = panelsReducer(state, action);
@ -184,19 +192,21 @@ describe('panelsReducer', () => {
left: undefined,
right: rightPanel1,
preview: undefined,
history: [],
},
},
needsSync: true,
});
});
it('should replace right panel', () => {
it('should replace right panel but does not update history', () => {
const state: PanelsState = {
byId: {
[id1]: {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
},
};
@ -209,6 +219,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel2,
preview: [previewPanel1],
history: [rightPanel1],
},
},
needsSync: true,
@ -222,6 +233,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
},
};
@ -234,11 +246,13 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
[id2]: {
left: undefined,
right: rightPanel2,
preview: undefined,
history: [],
},
},
needsSync: true,
@ -258,6 +272,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: undefined,
preview: undefined,
history: [],
},
},
needsSync: true,
@ -271,6 +286,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [],
},
},
};
@ -283,6 +299,7 @@ describe('panelsReducer', () => {
left: leftPanel2,
right: rightPanel1,
preview: [previewPanel1],
history: [],
},
},
needsSync: true,
@ -296,6 +313,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [],
},
},
};
@ -308,11 +326,13 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [],
},
[id2]: {
left: leftPanel2,
right: undefined,
preview: undefined,
history: [],
},
},
needsSync: true,
@ -332,6 +352,7 @@ describe('panelsReducer', () => {
left: undefined,
right: undefined,
preview: [previewPanel1],
history: [],
},
},
needsSync: true,
@ -345,6 +366,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [],
},
},
};
@ -357,6 +379,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1, previewPanel2],
history: [],
},
},
needsSync: true,
@ -370,6 +393,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [],
},
},
};
@ -382,11 +406,13 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [],
},
[id2]: {
left: undefined,
right: undefined,
preview: [previewPanel2],
history: [],
},
},
needsSync: true,
@ -413,6 +439,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: undefined,
preview: [previewPanel1],
history: [],
},
},
};
@ -432,6 +459,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
},
};
@ -445,6 +473,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: undefined,
preview: [previewPanel1],
history: [rightPanel1],
},
},
needsSync: true,
@ -458,6 +487,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
},
};
@ -471,6 +501,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
},
needsSync: true,
@ -497,6 +528,7 @@ describe('panelsReducer', () => {
left: undefined,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
},
};
@ -516,6 +548,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
},
};
@ -528,6 +561,7 @@ describe('panelsReducer', () => {
left: undefined,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
},
needsSync: true,
@ -541,6 +575,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
},
};
@ -553,6 +588,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
},
needsSync: true,
@ -579,6 +615,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: undefined,
history: [rightPanel1],
},
},
};
@ -598,6 +635,7 @@ describe('panelsReducer', () => {
left: rightPanel1,
right: leftPanel1,
preview: [previewPanel1, previewPanel2],
history: [rightPanel1],
},
},
};
@ -610,6 +648,7 @@ describe('panelsReducer', () => {
left: rightPanel1,
right: leftPanel1,
preview: undefined,
history: [rightPanel1],
},
},
needsSync: true,
@ -623,6 +662,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
},
};
@ -635,6 +675,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
},
needsSync: true,
@ -661,6 +702,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: undefined,
history: [rightPanel1],
},
},
};
@ -677,9 +719,10 @@ describe('panelsReducer', () => {
const state: PanelsState = {
byId: {
[id1]: {
left: rightPanel1,
right: leftPanel1,
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1, previewPanel2],
history: [rightPanel1],
},
},
};
@ -689,9 +732,10 @@ describe('panelsReducer', () => {
expect(newState).toEqual({
byId: {
[id1]: {
left: rightPanel1,
right: leftPanel1,
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
},
needsSync: false,
@ -705,6 +749,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
},
};
@ -717,6 +762,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
},
needsSync: false,
@ -743,6 +789,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
},
};
@ -755,6 +802,7 @@ describe('panelsReducer', () => {
left: undefined,
right: undefined,
preview: undefined,
history: [rightPanel1],
},
},
needsSync: true,
@ -768,6 +816,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
},
};
@ -780,6 +829,7 @@ describe('panelsReducer', () => {
left: leftPanel1,
right: rightPanel1,
preview: [previewPanel1],
history: [rightPanel1],
},
},
needsSync: true,

View file

@ -35,11 +35,15 @@ export const panelsReducer = createReducer(initialPanelsState, (builder) => {
state.byId[id].right = right;
state.byId[id].left = left;
state.byId[id].preview = preview ? [preview] : undefined;
if (right) {
state.byId[id].history?.push(right);
}
} else {
state.byId[id] = {
left,
right,
preview: preview ? [preview] : undefined,
history: right ? [right] : [],
};
}
@ -54,6 +58,7 @@ export const panelsReducer = createReducer(initialPanelsState, (builder) => {
left,
right: undefined,
preview: undefined,
history: [],
};
}
@ -68,6 +73,7 @@ export const panelsReducer = createReducer(initialPanelsState, (builder) => {
right,
left: undefined,
preview: undefined,
history: [],
};
}
@ -90,6 +96,7 @@ export const panelsReducer = createReducer(initialPanelsState, (builder) => {
right: undefined,
left: undefined,
preview: preview ? [preview] : undefined,
history: [],
};
}
@ -149,6 +156,7 @@ export const panelsReducer = createReducer(initialPanelsState, (builder) => {
right,
left,
preview: preview ? [preview] : undefined,
history: right ? [right] : [], // update history only when loading flyout on refresh
};
}

View file

@ -48,6 +48,8 @@ const panelsSelector = createSelector(stateSelector, (state) => state.panels);
export const selectPanelsById = (id: string) =>
createSelector(panelsSelector, (state) => state.byId[id] || {});
export const selectNeedsSync = () => createSelector(panelsSelector, (state) => state.needsSync);
export const selectHistoryById = (id: string) =>
createSelector(stateSelector, (state) => state.panels.byId[id].history || []);
const uiSelector = createSelector(stateSelector, (state) => state.ui);
export const selectPushVsOverlay = createSelector(uiSelector, (state) => state.pushVsOverlay);

View file

@ -22,6 +22,10 @@ export interface FlyoutPanels {
* Panels to render in the preview section
*/
preview: FlyoutPanelProps[] | undefined;
/*
* History of the right panels that were opened
*/
history: FlyoutPanelProps[];
}
export interface PanelsState {

View file

@ -40777,9 +40777,6 @@
"xpack.securitySolution.flyout.right.response.responseButtonLabel": "Réponse",
"xpack.securitySolution.flyout.right.response.sectionTitle": "Réponse",
"xpack.securitySolution.flyout.right.rule.rulePreviewTitle": "Afficher les détails de la règle",
"xpack.securitySolution.flyout.right.title.alertEventTitle": "Détails d'alerte externe",
"xpack.securitySolution.flyout.right.title.eventTitle": "Détails de l'événement",
"xpack.securitySolution.flyout.right.title.otherEventTitle": "Détails de {eventKind}",
"xpack.securitySolution.flyout.right.user.userPreviewTitle": "Aperçu des détails de l'utilisateur",
"xpack.securitySolution.flyout.right.visualizations.analyzerPreview.analyzerPreviewInvestigateTooltip": "Investiguer dans la chronologie",
"xpack.securitySolution.flyout.right.visualizations.analyzerPreview.analyzerPreviewOpenAnalyzerTooltip": "Ouvrir l'analyseur de graphe",

View file

@ -40634,9 +40634,6 @@
"xpack.securitySolution.flyout.right.response.responseButtonLabel": "応答",
"xpack.securitySolution.flyout.right.response.sectionTitle": "応答",
"xpack.securitySolution.flyout.right.rule.rulePreviewTitle": "ルール詳細をプレビュー",
"xpack.securitySolution.flyout.right.title.alertEventTitle": "外部アラート詳細",
"xpack.securitySolution.flyout.right.title.eventTitle": "イベントの詳細",
"xpack.securitySolution.flyout.right.title.otherEventTitle": "{eventKind}詳細",
"xpack.securitySolution.flyout.right.user.userPreviewTitle": "ユーザー詳細をプレビュー",
"xpack.securitySolution.flyout.right.visualizations.analyzerPreview.analyzerPreviewInvestigateTooltip": "タイムラインで調査",
"xpack.securitySolution.flyout.right.visualizations.analyzerPreview.analyzerPreviewOpenAnalyzerTooltip": "アナライザーグラフを開く",

View file

@ -40032,9 +40032,6 @@
"xpack.securitySolution.flyout.right.response.responseButtonLabel": "响应",
"xpack.securitySolution.flyout.right.response.sectionTitle": "响应",
"xpack.securitySolution.flyout.right.rule.rulePreviewTitle": "预览规则详情",
"xpack.securitySolution.flyout.right.title.alertEventTitle": "外部告警详情",
"xpack.securitySolution.flyout.right.title.eventTitle": "事件详情",
"xpack.securitySolution.flyout.right.title.otherEventTitle": "{eventKind} 详情",
"xpack.securitySolution.flyout.right.user.userPreviewTitle": "预览用户详情",
"xpack.securitySolution.flyout.right.visualizations.analyzerPreview.analyzerPreviewInvestigateTooltip": "在时间线中调查",
"xpack.securitySolution.flyout.right.visualizations.analyzerPreview.analyzerPreviewOpenAnalyzerTooltip": "打开分析器图表",

View file

@ -258,6 +258,11 @@ export const allowedExperimentalValues = Object.freeze({
*/
defendInsights: false,
/**
* Enables flyout history and new preview navigation
*/
newExpandableFlyoutNavigationEnabled: false,
/**
* Enables CrowdStrike's RunScript RTR command
*/

View file

@ -39,7 +39,7 @@ import { HostPreviewPanelKey } from '../../../entity_details/host_right';
import { HOST_PREVIEW_BANNER } from '../../right/components/host_entity_overview';
import { UserPreviewPanelKey } from '../../../entity_details/user_right';
import { USER_PREVIEW_BANNER } from '../../right/components/user_entity_overview';
import { NetworkPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details';
import { NetworkPreviewPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details';
import { useAlertsByStatus } from '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status';
jest.mock('@kbn/expandable-flyout');
@ -313,10 +313,11 @@ describe('<HostDetails />', () => {
getAllByTestId(HOST_DETAILS_RELATED_USERS_IP_LINK_TEST_ID)[0].click();
expect(mockFlyoutApi.openPreviewPanel).toHaveBeenCalledWith({
id: NetworkPanelKey,
id: NetworkPreviewPanelKey,
params: {
ip: '100.XXX.XXX',
flowTarget: 'source',
scopeId: defaultProps.scopeId,
banner: NETWORK_PREVIEW_BANNER,
},
});

View file

@ -37,7 +37,7 @@ import { HostPreviewPanelKey } from '../../../entity_details/host_right';
import { HOST_PREVIEW_BANNER } from '../../right/components/host_entity_overview';
import { UserPreviewPanelKey } from '../../../entity_details/user_right';
import { USER_PREVIEW_BANNER } from '../../right/components/user_entity_overview';
import { NetworkPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details';
import { NetworkPreviewPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details';
import { useAlertsByStatus } from '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status';
jest.mock('@kbn/expandable-flyout');
@ -291,10 +291,11 @@ describe('<UserDetails />', () => {
getAllByTestId(USER_DETAILS_RELATED_HOSTS_IP_LINK_TEST_ID)[0].click();
expect(mockFlyoutApi.openPreviewPanel).toHaveBeenCalledWith({
id: NetworkPanelKey,
id: NetworkPreviewPanelKey,
params: {
ip: '100.XXX.XXX',
flowTarget: 'source',
scopeId: defaultProps.scopeId,
banner: NETWORK_PREVIEW_BANNER,
},
});

View file

@ -83,9 +83,4 @@ describe('<AlertHeaderTitle />', () => {
const { getByTestId } = renderHeader(mockContextValue);
expect(getByTestId(NOTES_TITLE_TEST_ID)).toBeInTheDocument();
});
it('should render fall back values if document is not alert', () => {
const { getByTestId } = renderHeader({ ...mockContextValue, dataFormattedForFieldBrowser: [] });
expect(getByTestId(HEADER_TEXT_TEST_ID)).toHaveTextContent('Document details');
});
});

View file

@ -8,7 +8,6 @@
import React, { memo, useCallback, useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiLink } from '@elastic/eui';
import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils';
import { i18n } from '@kbn/i18n';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { Notes } from './notes';
import { useRuleDetailsLink } from '../../shared/hooks/use_rule_details_link';
@ -22,6 +21,7 @@ import { PreferenceFormattedDate } from '../../../../common/components/formatted
import { FLYOUT_ALERT_HEADER_TITLE_TEST_ID, ALERT_SUMMARY_PANEL_TEST_ID } from './test_ids';
import { Assignees } from './assignees';
import { FlyoutTitle } from '../../../shared/components/flyout_title';
import { getAlertTitle } from '../../shared/utils';
// minWidth for each block, allows to switch for a 1 row 4 blocks to 2 rows with 2 block each
const blockStyles = {
@ -44,17 +44,15 @@ export const AlertHeaderTitle = memo(() => {
'securitySolutionNotesDisabled'
);
const { isAlert, ruleName, timestamp, ruleId } = useBasicDataFromDetailsData(
dataFormattedForFieldBrowser
);
const { ruleName, timestamp, ruleId } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser);
const title = useMemo(() => getAlertTitle({ ruleName }), [ruleName]);
const href = useRuleDetailsLink({ ruleId: !isPreview ? ruleId : null });
const ruleTitle = useMemo(
() =>
href ? (
<EuiLink href={href} target="_blank" external={false}>
<FlyoutTitle
title={ruleName}
title={title}
iconType={'warning'}
isLink
data-test-subj={FLYOUT_ALERT_HEADER_TITLE_TEST_ID}
@ -62,12 +60,12 @@ export const AlertHeaderTitle = memo(() => {
</EuiLink>
) : (
<FlyoutTitle
title={ruleName}
title={title}
iconType={'warning'}
data-test-subj={FLYOUT_ALERT_HEADER_TITLE_TEST_ID}
/>
),
[ruleName, href]
[title, href]
);
const { refetch } = useRefetchByScope({ scopeId });
@ -86,17 +84,7 @@ export const AlertHeaderTitle = memo(() => {
<EuiSpacer size="m" />
{timestamp && <PreferenceFormattedDate value={new Date(timestamp)} />}
<EuiSpacer size="xs" />
{isAlert && ruleName ? (
ruleTitle
) : (
<FlyoutTitle
title={i18n.translate('xpack.securitySolution.flyout.right.header.headerTitle', {
defaultMessage: 'Document details',
})}
iconType={'warning'}
data-test-subj={FLYOUT_ALERT_HEADER_TITLE_TEST_ID}
/>
)}
{ruleTitle}
<EuiSpacer size="m" />
{securitySolutionNotesDisabled ? (
<EuiFlexGroup

View file

@ -6,17 +6,14 @@
*/
import React, { memo, useMemo } from 'react';
import { startCase } from 'lodash';
import { EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FlyoutTitle } from '../../../shared/components/flyout_title';
import { DocumentSeverity } from './severity';
import { useBasicDataFromDetailsData } from '../../shared/hooks/use_basic_data_from_details_data';
import { useDocumentDetailsContext } from '../../shared/context';
import { PreferenceFormattedDate } from '../../../../common/components/formatted_date';
import { FLYOUT_EVENT_HEADER_TITLE_TEST_ID } from './test_ids';
import { getField } from '../../shared/utils';
import { EVENT_CATEGORY_TO_FIELD } from '../utils/event_utils';
import { getField, getEventTitle } from '../../shared/utils';
/**
* Event details flyout right section header
@ -28,31 +25,10 @@ export const EventHeaderTitle = memo(() => {
const eventKind = getField(getFieldsData('event.kind'));
const eventCategory = getField(getFieldsData('event.category'));
const title = useMemo(() => {
const defaultTitle = i18n.translate('xpack.securitySolution.flyout.right.title.eventTitle', {
defaultMessage: `Event details`,
});
if (eventKind === 'event' && eventCategory) {
const fieldName = EVENT_CATEGORY_TO_FIELD[eventCategory];
return getField(getFieldsData(fieldName)) ?? defaultTitle;
}
if (eventKind === 'alert') {
return i18n.translate('xpack.securitySolution.flyout.right.title.alertEventTitle', {
defaultMessage: 'External alert details',
});
}
return eventKind
? i18n.translate('xpack.securitySolution.flyout.right.title.otherEventTitle', {
defaultMessage: '{eventKind} details',
values: {
eventKind: startCase(eventKind),
},
})
: defaultTitle;
}, [eventKind, getFieldsData, eventCategory]);
const title = useMemo(
() => getEventTitle({ eventKind, eventCategory, getFieldsData }),
[eventKind, eventCategory, getFieldsData]
);
return (
<>

View file

@ -26,7 +26,7 @@ import { HostPreviewPanelKey } from '../../../entity_details/host_right';
import { HOST_PREVIEW_BANNER } from './host_entity_overview';
import { UserPreviewPanelKey } from '../../../entity_details/user_right';
import { USER_PREVIEW_BANNER } from './user_entity_overview';
import { NetworkPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details';
import { NetworkPreviewPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details';
import { createTelemetryServiceMock } from '../../../../common/lib/telemetry/telemetry_service.mock';
jest.mock('../../../../management/hooks');
@ -137,10 +137,11 @@ describe('<HighlightedFieldsCell />', () => {
getByTestId(HIGHLIGHTED_FIELDS_LINKED_CELL_TEST_ID).click();
expect(mockFlyoutApi.openPreviewPanel).toHaveBeenCalledWith({
id: NetworkPanelKey,
id: NetworkPreviewPanelKey,
params: {
ip: '100:XXX:XXX',
flowTarget: 'source',
scopeId: panelContextValue.scopeId,
banner: NETWORK_PREVIEW_BANNER,
},
});

View file

@ -14,7 +14,7 @@ import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import type { EventFieldsData } from '../../../../common/components/event_details/types';
import { TableFieldValueCell } from './table_field_value_cell';
import { TestProviders } from '../../../../common/mock';
import { NetworkPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details';
import { NetworkPreviewPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details';
import { mockFlyoutApi } from '../../shared/mocks/mock_flyout_context';
import { FLYOUT_TABLE_PREVIEW_LINK_FIELD_TEST_ID } from './test_ids';
import { createTelemetryServiceMock } from '../../../../common/lib/telemetry/telemetry_service.mock';
@ -217,10 +217,11 @@ describe('TableFieldValueCell', () => {
screen.getByTestId(`${FLYOUT_TABLE_PREVIEW_LINK_FIELD_TEST_ID}-0`).click();
expect(mockFlyoutApi.openPreviewPanel).toHaveBeenCalledWith({
id: NetworkPanelKey,
id: NetworkPreviewPanelKey,
params: {
ip: '127.0.0.1',
flowTarget: 'source',
scopeId: 'scopeId',
banner: NETWORK_PREVIEW_BANNER,
},
});

View file

@ -25,7 +25,7 @@ interface PanelNavigationProps {
export const PanelNavigation: FC<PanelNavigationProps> = memo(({ flyoutIsExpandable }) => {
const { telemetry } = useKibana().services;
const { openLeftPanel } = useExpandableFlyoutApi();
const { eventId, indexName, scopeId } = useDocumentDetailsContext();
const { eventId, indexName, scopeId, isPreview } = useDocumentDetailsContext();
const expandDetails = useCallback(() => {
openLeftPanel({
@ -47,6 +47,8 @@ export const PanelNavigation: FC<PanelNavigationProps> = memo(({ flyoutIsExpanda
flyoutIsExpandable={flyoutIsExpandable}
expandDetails={expandDetails}
actions={<HeaderActions />}
isPreviewMode={false}
isPreview={isPreview}
/>
);
});

View file

@ -48,25 +48,3 @@ export const getEcsAllowedValueDescription = (fieldName: FieldName, value: strin
})
);
};
// mapping of event category to the field displayed as title
export const EVENT_CATEGORY_TO_FIELD: Record<string, string> = {
authentication: 'user.name',
configuration: '',
database: '',
driver: '',
email: '',
file: 'file.name',
host: 'host.name',
iam: '',
intrusion_detection: '',
malware: '',
network: '',
package: '',
process: 'process.name',
registry: '',
session: '',
threat: '',
vulnerability: '',
web: '',
};

View file

@ -4,9 +4,9 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { getField, getFieldArray } from './utils';
import { getField, getFieldArray, getEventTitle, getAlertTitle } from './utils';
describe('test getField', () => {
describe('getField', () => {
it('should return the string value if field is a string', () => {
expect(getField('test string')).toBe('test string');
});
@ -29,7 +29,7 @@ describe('test getField', () => {
});
});
describe('test getFieldArray', () => {
describe('getFieldArray', () => {
it('should return the string value in an array if field is a string', () => {
expect(getFieldArray('test string')).toStrictEqual(['test string']);
});
@ -47,3 +47,43 @@ describe('test getFieldArray', () => {
expect(getFieldArray(null)).toStrictEqual([]);
});
});
describe('getEventTitle', () => {
it('should return event title based on category when event kind is event', () => {
expect(
getEventTitle({
eventKind: 'event',
eventCategory: 'process',
getFieldsData: (field) => (field === 'process.name' ? 'process name' : ''),
})
).toBe('process name');
});
it('should return External alert details when event kind is alert', () => {
expect(
getEventTitle({ eventKind: 'alert', eventCategory: null, getFieldsData: jest.fn() })
).toBe('External alert details');
});
it('should return generic event details when event kind is not event or alert', () => {
expect(
getEventTitle({ eventKind: 'metric', eventCategory: null, getFieldsData: jest.fn() })
).toBe('Metric details');
});
it('should return Event details when event kind is null', () => {
expect(getEventTitle({ eventKind: null, eventCategory: null, getFieldsData: jest.fn() })).toBe(
'Event details'
);
});
});
describe('getAlertTitle', () => {
it('should return Document details when ruleName is undefined', () => {
expect(getAlertTitle({ ruleName: undefined })).toBe('Document details');
});
it('should return ruleName when ruleName is defined', () => {
expect(getAlertTitle({ ruleName: 'test rule' })).toBe('test rule');
});
});

View file

@ -4,6 +4,9 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { startCase } from 'lodash';
import type { GetFieldsData } from './hooks/use_get_fields_data';
/**
* Helper function to retrieve a field's value (used in combination with the custom hook useGetFieldsData (https://github.com/elastic/kibana/blob/main/x-pack/plugins/security_solution/public/common/hooks/use_get_fields_data.ts)
@ -33,3 +36,73 @@ export const getFieldArray = (field: unknown | unknown[]) => {
}
return [];
};
// mapping of event category to the field displayed as title
export const EVENT_CATEGORY_TO_FIELD: Record<string, string> = {
authentication: 'user.name',
configuration: '',
database: '',
driver: '',
email: '',
file: 'file.name',
host: 'host.name',
iam: '',
intrusion_detection: '',
malware: '',
network: '',
package: '',
process: 'process.name',
registry: '',
session: '',
threat: '',
vulnerability: '',
web: '',
};
/**
* Helper function to retrieve the alert title
*/
export const getAlertTitle = ({ ruleName }: { ruleName?: string | null }) => {
const defaultAlertTitle = i18n.translate(
'xpack.securitySolution.flyout.right.header.headerTitle',
{ defaultMessage: 'Document details' }
);
return ruleName ?? defaultAlertTitle;
};
/**
* Helper function to retrieve the event title
*/
export const getEventTitle = ({
eventKind,
eventCategory,
getFieldsData,
}: {
eventKind: string | null;
eventCategory: string | null;
getFieldsData: GetFieldsData;
}) => {
const defaultTitle = i18n.translate('xpack.securitySolution.flyout.title.eventTitle', {
defaultMessage: `Event details`,
});
if (eventKind === 'event' && eventCategory) {
const fieldName = EVENT_CATEGORY_TO_FIELD[eventCategory];
return getField(getFieldsData(fieldName)) ?? defaultTitle;
}
if (eventKind === 'alert') {
return i18n.translate('xpack.securitySolution.flyout.title.alertEventTitle', {
defaultMessage: 'External alert details',
});
}
return eventKind
? i18n.translate('xpack.securitySolution.flyout.title.otherEventTitle', {
defaultMessage: '{eventKind} details',
values: {
eventKind: startCase(eventKind),
},
})
: defaultTitle;
};

View file

@ -9,7 +9,16 @@ import { render } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../common/mock';
import { mockHostRiskScoreState, mockObservedHostData } from '../mocks';
import type {
FlyoutPanelProps,
ExpandableFlyoutState,
ExpandableFlyoutApi,
} from '@kbn/expandable-flyout';
import {
useExpandableFlyoutApi,
useExpandableFlyoutState,
useExpandableFlyoutHistory,
} from '@kbn/expandable-flyout';
import type { HostPanelProps } from '.';
import { HostPanel } from '.';
@ -34,10 +43,24 @@ jest.mock('./hooks/use_observed_host', () => ({
useObservedHost: () => mockedUseObservedHost(),
}));
const flyoutContextValue = {
closeLeftPanel: jest.fn(),
} as unknown as ExpandableFlyoutApi;
const flyoutHistory = [{ id: 'id1', params: {} }] as unknown as FlyoutPanelProps[];
jest.mock('@kbn/expandable-flyout', () => ({
useExpandableFlyoutApi: jest.fn(),
useExpandableFlyoutHistory: jest.fn(),
useExpandableFlyoutState: jest.fn(),
}));
describe('HostPanel', () => {
beforeEach(() => {
mockedHostRiskScore.mockReturnValue(mockHostRiskScoreState);
mockedUseObservedHost.mockReturnValue(mockObservedHostData);
jest.mocked(useExpandableFlyoutHistory).mockReturnValue(flyoutHistory);
jest.mocked(useExpandableFlyoutState).mockReturnValue({} as unknown as ExpandableFlyoutState);
jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue);
});
it('renders', () => {

View file

@ -11,6 +11,7 @@ import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { useHasMisconfigurations } from '@kbn/cloud-security-posture/src/hooks/use_has_misconfigurations';
import { useHasVulnerabilities } from '@kbn/cloud-security-posture/src/hooks/use_has_vulnerabilities';
import { TableId } from '@kbn/securitysolution-data-table';
import { useNonClosedAlerts } from '../../../cloud_security_posture/hooks/use_non_closed_alerts';
import { DETECTION_RESPONSE_ALERTS_BY_STATUS_ID } from '../../../overview/components/detection_response/alerts_by_status/types';
import { useRefetchQueryById } from '../../../entity_analytics/api/hooks/use_refetch_query_by_id';
@ -36,7 +37,6 @@ import { HostDetailsPanelKey } from '../host_details_left';
import { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header';
import { HostPreviewPanelFooter } from '../host_preview/footer';
import { EntityEventTypes } from '../../../common/lib/telemetry';
export interface HostPanelProps extends Record<string, unknown> {
contextID: string;
scopeId: string;
@ -187,13 +187,14 @@ export const HostPanel = ({
<>
<FlyoutNavigation
flyoutIsExpandable={
!isPreviewMode &&
(isRiskScoreExist ||
hasMisconfigurationFindings ||
hasVulnerabilitiesFindings ||
hasNonClosedAlerts)
isRiskScoreExist ||
hasMisconfigurationFindings ||
hasVulnerabilitiesFindings ||
hasNonClosedAlerts
}
expandDetails={openDefaultPanel}
isPreviewMode={isPreviewMode}
isPreview={scopeId === TableId.rulePreview}
/>
<HostPanelHeader hostName={hostName} observedHost={observedHostWithAnomalies} />
<HostPanelContent

View file

@ -10,7 +10,16 @@ import React from 'react';
import { TestProviders } from '../../../common/mock';
import type { UserPanelProps } from '.';
import { UserPanel } from '.';
import type {
FlyoutPanelProps,
ExpandableFlyoutState,
ExpandableFlyoutApi,
} from '@kbn/expandable-flyout';
import {
useExpandableFlyoutApi,
useExpandableFlyoutState,
useExpandableFlyoutHistory,
} from '@kbn/expandable-flyout';
import { mockManagedUserData, mockObservedUser } from './mocks';
import { mockRiskScoreState } from '../../shared/mocks';
@ -44,11 +53,25 @@ jest.mock('../../../common/hooks/use_experimental_features', () => ({
useIsExperimentalFeatureEnabled: () => mockedUseIsExperimentalFeatureEnabled(),
}));
const flyoutContextValue = {
closeLeftPanel: jest.fn(),
} as unknown as ExpandableFlyoutApi;
const flyoutHistory = [{ id: 'id1', params: {} }] as unknown as FlyoutPanelProps[];
jest.mock('@kbn/expandable-flyout', () => ({
useExpandableFlyoutApi: jest.fn(),
useExpandableFlyoutHistory: jest.fn(),
useExpandableFlyoutState: jest.fn(),
}));
describe('UserPanel', () => {
beforeEach(() => {
mockedUseRiskScore.mockReturnValue(mockRiskScoreState);
mockedUseManagedUser.mockReturnValue(mockManagedUserData);
mockedUseObservedUser.mockReturnValue(mockObservedUser);
jest.mocked(useExpandableFlyoutHistory).mockReturnValue(flyoutHistory);
jest.mocked(useExpandableFlyoutState).mockReturnValue({} as unknown as ExpandableFlyoutState);
jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue);
});
it('renders', () => {

View file

@ -9,6 +9,7 @@ import React, { useCallback, useMemo } from 'react';
import type { FlyoutPanelProps } from '@kbn/expandable-flyout';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { useHasMisconfigurations } from '@kbn/cloud-security-posture/src/hooks/use_has_misconfigurations';
import { TableId } from '@kbn/securitysolution-data-table';
import { useNonClosedAlerts } from '../../../cloud_security_posture/hooks/use_non_closed_alerts';
import { useRefetchQueryById } from '../../../entity_analytics/api/hooks/use_refetch_query_by_id';
import type { Refetch } from '../../../common/types';
@ -191,10 +192,11 @@ export const UserPanel = ({
<>
<FlyoutNavigation
flyoutIsExpandable={
!isPreviewMode &&
(hasUserDetailsData || hasMisconfigurationFindings || hasNonClosedAlerts)
hasUserDetailsData || hasMisconfigurationFindings || hasNonClosedAlerts
}
expandDetails={openPanelFirstTab}
isPreviewMode={isPreviewMode}
isPreview={scopeId === TableId.rulePreview}
/>
<UserPanelHeader
userName={userName}

View file

@ -39,7 +39,7 @@ import type { HostPanelExpandableFlyoutProps } from './entity_details/host_right
import { HostPanel, HostPanelKey, HostPreviewPanelKey } from './entity_details/host_right';
import type { HostDetailsExpandableFlyoutProps } from './entity_details/host_details_left';
import { HostDetailsPanel, HostDetailsPanelKey } from './entity_details/host_details_left';
import { NetworkPanel, NetworkPanelKey } from './network_details';
import { NetworkPanel, NetworkPanelKey, NetworkPreviewPanelKey } from './network_details';
import type { AnalyzerPanelExpandableFlyoutProps } from './document_details/analyzer_panels';
import { AnalyzerPanel } from './document_details/analyzer_panels';
@ -140,6 +140,12 @@ const expandableFlyoutDocumentsPanels: ExpandableFlyoutProps['registeredPanels']
key: NetworkPanelKey,
component: (props) => <NetworkPanel {...(props as NetworkExpandableFlyoutProps).params} />,
},
{
key: NetworkPreviewPanelKey,
component: (props) => (
<NetworkPanel {...(props as NetworkExpandableFlyoutProps).params} isPreviewMode />
),
},
];
export const SECURITY_SOLUTION_ON_CLOSE_EVENT = `expandable-flyout-on-close-${Flyouts.securitySolution}`;

View file

@ -28,7 +28,7 @@ export interface PanelHeaderProps extends React.ComponentProps<typeof EuiFlyoutH
}
/**
*
* Header component for the network details flyout
*/
export const PanelHeader: FC<PanelHeaderProps> = memo(
({ ip, flowTarget, ...flyoutHeaderProps }: PanelHeaderProps) => {

View file

@ -5,19 +5,23 @@
* 2.0.
*/
import type { FC } from 'react';
import React, { memo } from 'react';
import type { FlyoutPanelProps } from '@kbn/expandable-flyout';
import { i18n } from '@kbn/i18n';
import { TableId } from '@kbn/securitysolution-data-table';
import type { FlowTargetSourceDest } from '../../../common/search_strategy';
import { PanelHeader } from './header';
import { PanelContent } from './content';
import { FlyoutNavigation } from '../shared/components/flyout_navigation';
export interface NetworkExpandableFlyoutProps extends FlyoutPanelProps {
key: 'network-details';
key: 'network-details' | 'network-preview';
params: NetworkPanelProps;
}
export const NetworkPanelKey: NetworkExpandableFlyoutProps['key'] = 'network-details';
export const NetworkPreviewPanelKey: NetworkExpandableFlyoutProps['key'] = 'network-preview';
export const NETWORK_PREVIEW_BANNER = {
title: i18n.translate('xpack.securitySolution.flyout.right.network.networkPreviewTitle', {
@ -36,18 +40,33 @@ export interface NetworkPanelProps extends Record<string, unknown> {
* Destination or source information
*/
flowTarget: FlowTargetSourceDest;
/**
* Scope ID
*/
scopeId: string;
/**
* If in preview mode, show preview banner and hide navigation
*/
isPreviewMode?: boolean;
}
/**
* Panel to be displayed in the network details expandable flyout right section
*/
export const NetworkPanel = memo(({ ip, flowTarget }: NetworkPanelProps) => {
return (
<>
<PanelHeader ip={ip} flowTarget={flowTarget} />
<PanelContent ip={ip} flowTarget={flowTarget} />
</>
);
});
export const NetworkPanel: FC<NetworkPanelProps> = memo(
({ ip, flowTarget, scopeId, isPreviewMode }) => {
return (
<>
<FlyoutNavigation
flyoutIsExpandable={false}
isPreviewMode={isPreviewMode}
isPreview={scopeId === TableId.rulePreview}
/>
<PanelHeader ip={ip} flowTarget={flowTarget} />
<PanelContent ip={ip} flowTarget={flowTarget} />
</>
);
}
);
NetworkPanel.displayName = 'NetworkPanel';

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo } from 'react';
import React, { memo, useMemo } from 'react';
import { EuiText, EuiHorizontalRule, EuiSpacer, EuiPanel } from '@elastic/eui';
import { css } from '@emotion/css';
import { FormattedMessage } from '@kbn/i18n-react';
@ -50,12 +50,23 @@ export interface RuleDetailsProps {
* Rule details content on the right section of expandable flyout
*/
export const PanelContent = memo(({ rule }: RuleDetailsProps) => {
const { ruleActionsData } =
rule != null ? getStepsData({ rule, detailsView: true }) : { ruleActionsData: null };
const { ruleActionsData } = useMemo(
() => (rule != null ? getStepsData({ rule, detailsView: true }) : { ruleActionsData: null }),
[rule]
);
const hasNotificationActions = Boolean(ruleActionsData?.actions?.length);
const hasResponseActions = Boolean(ruleActionsData?.responseActions?.length);
const hasActions = ruleActionsData != null && (hasNotificationActions || hasResponseActions);
const hasNotificationActions = useMemo(
() => Boolean(ruleActionsData?.actions?.length),
[ruleActionsData]
);
const hasResponseActions = useMemo(
() => Boolean(ruleActionsData?.responseActions?.length),
[ruleActionsData]
);
const hasActions = useMemo(
() => ruleActionsData != null && (hasNotificationActions || hasResponseActions),
[ruleActionsData, hasNotificationActions, hasResponseActions]
);
return (
<FlyoutBody>

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { memo } from 'react';
import {
EuiTitle,
EuiText,
@ -43,7 +43,7 @@ export interface PanelHeaderProps {
/**
* Title component that shows basic information of a rule. This is displayed above rule overview body
*/
export const PanelHeader: React.FC<PanelHeaderProps> = ({ rule, isSuppressed }) => {
export const PanelHeader: React.FC<PanelHeaderProps> = memo(({ rule, isSuppressed }) => {
const href = useRuleDetailsLink({ ruleId: rule.id });
return (
@ -86,6 +86,6 @@ export const PanelHeader: React.FC<PanelHeaderProps> = ({ rule, isSuppressed })
</EuiFlexGroup>
</FlyoutHeader>
);
};
});
PanelHeader.displayName = 'PanelHeader';

View file

@ -22,6 +22,16 @@ import {
import type { RuleResponse } from '../../../../common/api/detection_engine';
import { BODY_TEST_ID, LOADING_TEST_ID } from './test_ids';
import { RULE_PREVIEW_FOOTER_TEST_ID } from '../preview/test_ids';
import type {
FlyoutPanelProps,
ExpandableFlyoutState,
ExpandableFlyoutApi,
} from '@kbn/expandable-flyout';
import {
useExpandableFlyoutApi,
useExpandableFlyoutState,
useExpandableFlyoutHistory,
} from '@kbn/expandable-flyout';
jest.mock('../../document_details/shared/hooks/use_rule_details_link');
@ -31,6 +41,18 @@ jest.mock('../hooks/use_rule_details');
const mockGetStepsData = getStepsData as jest.Mock;
jest.mock('../../../detections/pages/detection_engine/rules/helpers');
jest.mock('@kbn/expandable-flyout', () => ({
useExpandableFlyoutApi: jest.fn(),
useExpandableFlyoutState: jest.fn(),
useExpandableFlyoutHistory: jest.fn(),
}));
const flyoutContextValue = {
closeLeftPanel: jest.fn(),
} as unknown as ExpandableFlyoutApi;
const flyoutHistory = [{ id: 'id1', params: {} }] as unknown as FlyoutPanelProps[];
const mockTheme = getMockTheme({ eui: { euiColorMediumShade: '#ece' } });
const rule = { name: 'rule name', description: 'rule description' } as RuleResponse;
const ERROR_MESSAGE = 'There was an error displaying data.';
@ -45,6 +67,12 @@ const renderRulePanel = (isPreviewMode = false) =>
);
describe('<RulePanel />', () => {
beforeEach(() => {
jest.mocked(useExpandableFlyoutHistory).mockReturnValue(flyoutHistory);
jest.mocked(useExpandableFlyoutState).mockReturnValue({} as unknown as ExpandableFlyoutState);
jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue);
});
it('should render rule details and its sub sections', () => {
mockUseRuleDetails.mockReturnValue({
rule,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import type { FC } from 'react';
import React, { memo } from 'react';
import type { FlyoutPanelProps } from '@kbn/expandable-flyout';
import { i18n } from '@kbn/i18n';
@ -50,14 +51,14 @@ export interface RulePanelProps extends Record<string, unknown> {
/**
* Displays a rule overview panel
*/
export const RulePanel = memo(({ ruleId, isPreviewMode }: RulePanelProps) => {
export const RulePanel: FC<RulePanelProps> = memo(({ ruleId, isPreviewMode }) => {
const { rule, loading, isExistingRule } = useRuleDetails({ ruleId });
return loading ? (
<FlyoutLoading data-test-subj={LOADING_TEST_ID} />
) : rule ? (
<>
<FlyoutNavigation flyoutIsExpandable={false} />
<FlyoutNavigation flyoutIsExpandable={false} isPreviewMode={isPreviewMode} />
<PanelHeader rule={rule} isSuppressed={!isExistingRule} />
<PanelContent rule={rule} />
{isPreviewMode && <PreviewFooter ruleId={ruleId} />}

View file

@ -0,0 +1,52 @@
/*
* 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, fireEvent } from '@testing-library/react';
import { TestProviders } from '../../../common/mock';
import {
FLYOUT_HISTORY_TEST_ID,
FLYOUT_HISTORY_BUTTON_TEST_ID,
FLYOUT_HISTORY_CONTEXT_PANEL_TEST_ID,
NO_DATA_HISTORY_ROW_TEST_ID,
} from './test_ids';
import { FlyoutHistory } from './flyout_history';
const mockedHistory = [{ id: '1' }, { id: '2' }];
describe('FlyoutHistory', () => {
it('renders', () => {
const { getByTestId, queryByTestId } = render(
<TestProviders>
<FlyoutHistory history={mockedHistory} />
</TestProviders>
);
expect(getByTestId(FLYOUT_HISTORY_TEST_ID)).toBeInTheDocument();
expect(queryByTestId(FLYOUT_HISTORY_CONTEXT_PANEL_TEST_ID)).not.toBeInTheDocument();
});
it('renders context menu when clicking the popover', () => {
const { getByTestId } = render(
<TestProviders>
<FlyoutHistory history={mockedHistory} />
</TestProviders>
);
fireEvent.click(getByTestId(FLYOUT_HISTORY_BUTTON_TEST_ID));
expect(getByTestId(FLYOUT_HISTORY_CONTEXT_PANEL_TEST_ID)).toBeInTheDocument();
});
it('render empty history message if history is empty', () => {
const { getByTestId } = render(
<TestProviders>
<FlyoutHistory history={[]} />
</TestProviders>
);
fireEvent.click(getByTestId(FLYOUT_HISTORY_BUTTON_TEST_ID));
expect(getByTestId(NO_DATA_HISTORY_ROW_TEST_ID)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,97 @@
/*
* 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 type { FC } from 'react';
import React, { memo, useMemo, useState } from 'react';
import {
EuiFlexItem,
EuiButtonEmpty,
EuiPopover,
EuiContextMenuPanel,
EuiText,
EuiContextMenuItem,
EuiTextColor,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import type { FlyoutPanelProps } from '@kbn/expandable-flyout';
import { FlyoutHistoryRow } from './flyout_history_row';
import {
FLYOUT_HISTORY_TEST_ID,
FLYOUT_HISTORY_BUTTON_TEST_ID,
FLYOUT_HISTORY_CONTEXT_PANEL_TEST_ID,
NO_DATA_HISTORY_ROW_TEST_ID,
} from './test_ids';
export interface HistoryProps {
/**
* A list of flyouts that have been opened
*/
history: FlyoutPanelProps[];
}
/**
* History of flyouts shown in top navigation
* Shows the title of previously opened flyout, and count of history of more than 1 flyout was opened
*/
export const FlyoutHistory: FC<HistoryProps> = memo(({ history }) => {
const [isPopoverOpen, setPopover] = useState(false);
const togglePopover = () => setPopover(!isPopoverOpen);
const emptyHistoryMessage = useMemo(() => {
return (
<EuiContextMenuItem key={0} data-test-subj={NO_DATA_HISTORY_ROW_TEST_ID}>
<EuiText size="s">
<EuiTextColor color="subdued">
<i>
<FormattedMessage
id="xpack.securitySolution.flyout.history.noData"
defaultMessage="No history"
/>
</i>
</EuiTextColor>
</EuiText>
</EuiContextMenuItem>
);
}, []);
const historyDropdownPanels = useMemo(
() =>
history.length > 0
? history.map((item, index) => {
return <FlyoutHistoryRow item={item} index={index} />;
})
: [emptyHistoryMessage],
[history, emptyHistoryMessage]
);
return (
<EuiFlexItem grow={false} data-test-subj={FLYOUT_HISTORY_TEST_ID}>
<EuiPopover
button={
<EuiButtonEmpty
onClick={togglePopover}
size="m"
iconType={'clockCounter'}
data-test-subj={FLYOUT_HISTORY_BUTTON_TEST_ID}
/>
}
isOpen={isPopoverOpen}
closePopover={togglePopover}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel
size="s"
items={historyDropdownPanels}
data-test-subj={FLYOUT_HISTORY_CONTEXT_PANEL_TEST_ID}
/>
</EuiPopover>
</EuiFlexItem>
);
});
FlyoutHistory.displayName = 'FlyoutHistory';

View file

@ -0,0 +1,270 @@
/*
* 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, fireEvent } from '@testing-library/react';
import {
FlyoutHistoryRow,
RuleHistoryRow,
DocumentDetailsHistoryRow,
GenericHistoryRow,
} from './flyout_history_row';
import { TestProviders } from '../../../common/mock';
import type { RuleResponse } from '../../../../common/api/detection_engine';
import { useExpandableFlyoutApi, type ExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { useRuleDetails } from '../../rule_details/hooks/use_rule_details';
import { useBasicDataFromDetailsData } from '../../document_details/shared/hooks/use_basic_data_from_details_data';
import { DocumentDetailsRightPanelKey } from '../../document_details/shared/constants/panel_keys';
import { RulePanelKey } from '../../rule_details/right';
import { UserPanelKey } from '../../entity_details/user_right';
import { HostPanelKey } from '../../entity_details/host_right';
import { NetworkPanelKey } from '../../network_details';
import {
DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID,
RULE_HISTORY_ROW_TEST_ID,
HOST_HISTORY_ROW_TEST_ID,
USER_HISTORY_ROW_TEST_ID,
NETWORK_HISTORY_ROW_TEST_ID,
GENERIC_HISTORY_ROW_TEST_ID,
} from './test_ids';
jest.mock('@kbn/expandable-flyout', () => ({
useExpandableFlyoutApi: jest.fn(),
useExpandableFlyoutState: jest.fn(),
useExpandableFlyoutHistory: jest.fn(),
ExpandableFlyoutProvider: ({ children }: React.PropsWithChildren<{}>) => <>{children}</>,
}));
jest.mock('../../../detection_engine/rule_management/logic/use_rule_with_fallback');
jest.mock('../../document_details/shared/hooks/use_basic_data_from_details_data');
jest.mock('../../rule_details/hooks/use_rule_details');
const flyoutContextValue = {
openFlyout: jest.fn(),
} as unknown as ExpandableFlyoutApi;
const rowItems = {
alert: {
id: DocumentDetailsRightPanelKey,
params: {
id: 'eventId',
indexName: 'indexName',
scopeId: 'scopeId',
},
},
rule: {
id: RulePanelKey,
params: { ruleId: 'ruleId' },
},
host: {
id: HostPanelKey,
params: { hostName: 'host name' },
},
user: {
id: UserPanelKey,
params: { userName: 'user name' },
},
network: {
id: NetworkPanelKey,
params: { ip: 'ip' },
},
};
const mockedRuleResponse = {
rule: null,
loading: false,
isExistingRule: false,
error: null,
refresh: jest.fn(),
};
describe('FlyoutHistoryRow', () => {
beforeEach(() => {
jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue);
jest.mocked(useRuleDetails).mockReturnValue({
...mockedRuleResponse,
rule: { name: 'rule name' } as RuleResponse,
});
(useBasicDataFromDetailsData as jest.Mock).mockReturnValue({ isAlert: false });
});
it('renders document details history row when key is alert', () => {
(useBasicDataFromDetailsData as jest.Mock).mockReturnValue({
isAlert: true,
ruleName: 'rule name',
});
const { getByTestId } = render(
<TestProviders>
<FlyoutHistoryRow item={rowItems.alert} index={0} />
</TestProviders>
);
expect(getByTestId(`${0}-${DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID}`)).toBeInTheDocument();
});
it('renders rule history row when key is rule', () => {
const { getByTestId } = render(
<TestProviders>
<FlyoutHistoryRow item={rowItems.rule} index={1} />
</TestProviders>
);
expect(getByTestId(`${1}-${RULE_HISTORY_ROW_TEST_ID}`)).toBeInTheDocument();
});
it('renders generic host history row when key is host', () => {
const { getByTestId } = render(
<TestProviders>
<FlyoutHistoryRow item={rowItems.host} index={2} />
</TestProviders>
);
expect(getByTestId(`${2}-${HOST_HISTORY_ROW_TEST_ID}`)).toBeInTheDocument();
expect(getByTestId(`${2}-${HOST_HISTORY_ROW_TEST_ID}`)).toHaveTextContent('Host: host name');
});
it('renders generic user history row when key is user', () => {
const { getByTestId } = render(
<TestProviders>
<FlyoutHistoryRow item={rowItems.user} index={3} />
</TestProviders>
);
expect(getByTestId(`${3}-${USER_HISTORY_ROW_TEST_ID}`)).toBeInTheDocument();
expect(getByTestId(`${3}-${USER_HISTORY_ROW_TEST_ID}`)).toHaveTextContent('User: user name');
});
it('renders generic network history row when key is network', () => {
const { getByTestId } = render(
<TestProviders>
<FlyoutHistoryRow item={rowItems.network} index={4} />
</TestProviders>
);
expect(getByTestId(`${4}-${NETWORK_HISTORY_ROW_TEST_ID}`)).toBeInTheDocument();
expect(getByTestId(`${4}-${NETWORK_HISTORY_ROW_TEST_ID}`)).toHaveTextContent('Network: ip');
});
it('renders null when key is not supported', () => {
const { container } = render(
<TestProviders>
<FlyoutHistoryRow item={{ id: 'key' }} index={5} />
</TestProviders>
);
expect(container).toBeEmptyDOMElement();
});
});
describe('DocumentDetailsHistoryRow', () => {
beforeEach(() => {
jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue);
});
it('renders alert title when isAlert is true and rule name is defined', () => {
(useBasicDataFromDetailsData as jest.Mock).mockReturnValue({
isAlert: true,
ruleName: 'rule name',
});
const { getByTestId } = render(
<TestProviders>
<DocumentDetailsHistoryRow item={rowItems.alert} index={0} />
</TestProviders>
);
expect(getByTestId(`${0}-${DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID}`)).toHaveTextContent(
'Alert: rule name'
);
});
it('renders default alert title when isAlert is true and rule name is undefined', () => {
(useBasicDataFromDetailsData as jest.Mock).mockReturnValue({ isAlert: true });
const { getByTestId } = render(
<TestProviders>
<DocumentDetailsHistoryRow item={rowItems.alert} index={0} />
</TestProviders>
);
expect(getByTestId(`${0}-${DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID}`)).toHaveTextContent(
'Alert: Document details'
);
});
it('renders event title when isAlert is false', () => {
(useBasicDataFromDetailsData as jest.Mock).mockReturnValue({ isAlert: false });
const { getByTestId } = render(
<TestProviders>
<DocumentDetailsHistoryRow item={rowItems.alert} index={0} />
</TestProviders>
);
expect(getByTestId(`${0}-${DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID}`)).toHaveTextContent(
'Event details'
);
});
it('opens document details flyout when clicked', () => {
(useBasicDataFromDetailsData as jest.Mock).mockReturnValue({ isAlert: true });
const { getByTestId } = render(
<TestProviders>
<DocumentDetailsHistoryRow item={rowItems.alert} index={0} />
</TestProviders>
);
fireEvent.click(getByTestId(`${0}-${DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID}`));
expect(flyoutContextValue.openFlyout).toHaveBeenCalledWith({ right: rowItems.alert });
});
});
describe('RuleHistoryRow', () => {
beforeEach(() => {
jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue);
jest.mocked(useRuleDetails).mockReturnValue({
rule: { name: 'rule name' } as RuleResponse,
loading: false,
isExistingRule: false,
});
});
it('renders', () => {
const { getByTestId } = render(
<TestProviders>
<RuleHistoryRow item={rowItems.rule} index={0} />
</TestProviders>
);
expect(getByTestId(`${0}-${RULE_HISTORY_ROW_TEST_ID}`)).toHaveTextContent('Rule: rule name');
expect(useRuleDetails).toHaveBeenCalledWith({ ruleId: rowItems.rule.params.ruleId });
});
it('opens rule details flyout when clicked', () => {
const { getByTestId } = render(
<TestProviders>
<RuleHistoryRow item={rowItems.rule} index={0} />
</TestProviders>
);
fireEvent.click(getByTestId(`${0}-${RULE_HISTORY_ROW_TEST_ID}`));
expect(flyoutContextValue.openFlyout).toHaveBeenCalledWith({ right: rowItems.rule });
});
});
describe('GenericHistoryRow', () => {
beforeEach(() => {
jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue);
});
it('renders', () => {
const { getByTestId } = render(
<TestProviders>
<GenericHistoryRow
item={rowItems.host}
name="Row name"
icon={'user'}
title="title"
index={0}
/>
</TestProviders>
);
expect(getByTestId(`${0}-${GENERIC_HISTORY_ROW_TEST_ID}`)).toHaveTextContent('Row name: title');
fireEvent.click(getByTestId(`${0}-${GENERIC_HISTORY_ROW_TEST_ID}`));
expect(flyoutContextValue.openFlyout).toHaveBeenCalledWith({ right: rowItems.host });
});
});

View file

@ -0,0 +1,186 @@
/*
* 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 type { FC } from 'react';
import React, { memo, useMemo, useCallback } from 'react';
import { EuiContextMenuItem, type EuiIconProps } from '@elastic/eui';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import type { FlyoutPanelProps } from '@kbn/expandable-flyout';
import { DocumentDetailsRightPanelKey } from '../../document_details/shared/constants/panel_keys';
import { useBasicDataFromDetailsData } from '../../document_details/shared/hooks/use_basic_data_from_details_data';
import { useEventDetails } from '../../document_details/shared/hooks/use_event_details';
import { getField, getAlertTitle, getEventTitle } from '../../document_details/shared/utils';
import { RulePanelKey } from '../../rule_details/right';
import { UserPanelKey } from '../../entity_details/user_right';
import { HostPanelKey } from '../../entity_details/host_right';
import { NetworkPanelKey } from '../../network_details';
import { useRuleDetails } from '../../rule_details/hooks/use_rule_details';
import {
DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID,
RULE_HISTORY_ROW_TEST_ID,
GENERIC_HISTORY_ROW_TEST_ID,
HOST_HISTORY_ROW_TEST_ID,
USER_HISTORY_ROW_TEST_ID,
NETWORK_HISTORY_ROW_TEST_ID,
} from './test_ids';
export interface FlyoutHistoryRowProps {
/**
* Flyout item to display
*/
item: FlyoutPanelProps;
/**
* Index of the flyout in the list
*/
index: number;
}
/**
* Row item for a flyout history row
*/
export const FlyoutHistoryRow: FC<FlyoutHistoryRowProps> = memo(({ item, index }) => {
switch (item.id) {
case DocumentDetailsRightPanelKey:
return <DocumentDetailsHistoryRow item={item} index={index} />;
case RulePanelKey:
return <RuleHistoryRow item={item} index={index} />;
case HostPanelKey:
return (
<GenericHistoryRow
item={item}
index={index}
title={String(item?.params?.hostName)}
icon={'storage'}
name={'Host'}
dataTestSubj={HOST_HISTORY_ROW_TEST_ID}
/>
);
case UserPanelKey:
return (
<GenericHistoryRow
item={item}
index={index}
title={String(item?.params?.userName)}
icon={'user'}
name={'User'}
dataTestSubj={USER_HISTORY_ROW_TEST_ID}
/>
);
case NetworkPanelKey:
return (
<GenericHistoryRow
item={item}
index={index}
title={String(item?.params?.ip)}
icon={'globe'}
name={'Network'}
dataTestSubj={NETWORK_HISTORY_ROW_TEST_ID}
/>
);
default:
return null;
}
});
/**
* Row item for a document details
*/
export const DocumentDetailsHistoryRow: FC<FlyoutHistoryRowProps> = memo(({ item, index }) => {
const { dataFormattedForFieldBrowser, getFieldsData } = useEventDetails({
eventId: String(item?.params?.id),
indexName: String(item?.params?.indexName),
});
const { ruleName, isAlert } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser);
const eventKind = useMemo(() => getField(getFieldsData('event.kind')), [getFieldsData]);
const eventCategory = useMemo(() => getField(getFieldsData('event.category')), [getFieldsData]);
const title = useMemo(
() =>
isAlert
? getAlertTitle({ ruleName })
: getEventTitle({ eventKind, eventCategory, getFieldsData }),
[isAlert, ruleName, eventKind, eventCategory, getFieldsData]
);
return (
<GenericHistoryRow
item={item}
index={index}
title={title}
icon={isAlert ? 'warning' : 'analyzeEvent'}
name={isAlert ? 'Alert' : 'Event'}
dataTestSubj={DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID}
/>
);
});
/**
* Row item for a rule details flyout
*/
export const RuleHistoryRow: FC<FlyoutHistoryRowProps> = memo(({ item, index }) => {
const ruleId = String(item?.params?.ruleId);
const { rule } = useRuleDetails({ ruleId });
return (
<GenericHistoryRow
item={item}
index={index}
title={rule?.name ?? ''}
icon={'indexSettings'}
name={'Rule'}
dataTestSubj={RULE_HISTORY_ROW_TEST_ID}
/>
);
});
interface GenericHistoryRowProps extends FlyoutHistoryRowProps {
/**
* Icon to display
*/
icon: EuiIconProps['type'];
/**
* Title to display
*/
title: string;
/**
* Name to display
*/
name: string;
/**
* Data test subject
*/
dataTestSubj?: string;
}
/**
* Row item for a generic history row where the title is accessible in flyout params
*/
export const GenericHistoryRow: FC<GenericHistoryRowProps> = memo(
({ item, index, title, icon, name, dataTestSubj }) => {
const { openFlyout } = useExpandableFlyoutApi();
const onClick = useCallback(() => {
openFlyout({ right: item });
}, [openFlyout, item]);
return (
<EuiContextMenuItem
key={index}
onClick={onClick}
icon={icon}
data-test-subj={`${index}-${dataTestSubj ?? GENERIC_HISTORY_ROW_TEST_ID}`}
>
<i>{`${name}: `}</i>
{title}
</EuiContextMenuItem>
);
}
);
FlyoutHistoryRow.displayName = 'FlyoutHistoryRow';
DocumentDetailsHistoryRow.displayName = 'DocumentDetailsHistoryRow';
RuleHistoryRow.displayName = 'RuleHistoryRow';
GenericHistoryRow.displayName = 'GenericHistoryRow';

View file

@ -13,13 +13,16 @@ import { FlyoutNavigation } from './flyout_navigation';
import {
COLLAPSE_DETAILS_BUTTON_TEST_ID,
EXPAND_DETAILS_BUTTON_TEST_ID,
FLYOUT_HISTORY_BUTTON_TEST_ID,
HEADER_ACTIONS_TEST_ID,
} from './test_ids';
import type { ExpandableFlyoutState } from '@kbn/expandable-flyout';
import type { ExpandableFlyoutState, FlyoutPanelProps } from '@kbn/expandable-flyout';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import {
useExpandableFlyoutApi,
type ExpandableFlyoutApi,
useExpandableFlyoutState,
useExpandableFlyoutHistory,
} from '@kbn/expandable-flyout';
const expandDetails = jest.fn();
@ -31,9 +34,12 @@ const ExpandableFlyoutTestProviders: FC<PropsWithChildren<{}>> = ({ children })
jest.mock('@kbn/expandable-flyout', () => ({
useExpandableFlyoutApi: jest.fn(),
useExpandableFlyoutState: jest.fn(),
useExpandableFlyoutHistory: jest.fn(),
ExpandableFlyoutProvider: ({ children }: React.PropsWithChildren<{}>) => <>{children}</>,
}));
jest.mock('../../../common/hooks/use_experimental_features');
const flyoutContextValue = {
closeLeftPanel: jest.fn(),
} as unknown as ExpandableFlyoutApi;
@ -42,6 +48,8 @@ describe('<FlyoutNavigation />', () => {
beforeEach(() => {
jest.mocked(useExpandableFlyoutApi).mockReturnValue(flyoutContextValue);
jest.mocked(useExpandableFlyoutState).mockReturnValue({} as unknown as ExpandableFlyoutState);
jest.mocked(useExpandableFlyoutHistory).mockReturnValue([]);
jest.mocked(useIsExperimentalFeatureEnabled).mockReturnValue(false);
});
describe('when flyout is expandable', () => {
@ -114,4 +122,62 @@ describe('<FlyoutNavigation />', () => {
expect(container).toBeEmptyDOMElement();
});
});
it('should render empty component if isPreviewMode is true', () => {
const { container } = render(
<ExpandableFlyoutTestProviders>
<FlyoutNavigation isPreviewMode={true} flyoutIsExpandable={true} />
</ExpandableFlyoutTestProviders>
);
expect(container).toBeEmptyDOMElement();
});
const flyoutHistory = [
{ id: 'id1', params: {} },
{ id: 'id2', params: {} },
] as unknown as FlyoutPanelProps[];
describe('when flyout history is enabled', () => {
beforeEach(() => {
jest.mocked(useIsExperimentalFeatureEnabled).mockReturnValue(true);
jest.mocked(useExpandableFlyoutHistory).mockReturnValue(flyoutHistory);
});
it('should render history button when there is no item in history', () => {
jest.mocked(useExpandableFlyoutHistory).mockReturnValue([]);
const { getByTestId } = render(
<ExpandableFlyoutTestProviders>
<FlyoutNavigation flyoutIsExpandable={false} />
</ExpandableFlyoutTestProviders>
);
expect(getByTestId(FLYOUT_HISTORY_BUTTON_TEST_ID)).toBeInTheDocument();
});
it('should render history button when there are more than 1 unqie item in history', () => {
const { getByTestId } = render(
<ExpandableFlyoutTestProviders>
<FlyoutNavigation flyoutIsExpandable={false} />
</ExpandableFlyoutTestProviders>
);
expect(getByTestId(FLYOUT_HISTORY_BUTTON_TEST_ID)).toBeInTheDocument();
});
it('should not render history button if in rule preview', () => {
const { container } = render(
<ExpandableFlyoutTestProviders>
<FlyoutNavigation flyoutIsExpandable={false} isPreview={true} />
</ExpandableFlyoutTestProviders>
);
expect(container).toBeEmptyDOMElement();
});
it('should render empty component if isPreviewMode is true', () => {
const { container } = render(
<ExpandableFlyoutTestProviders>
<FlyoutNavigation isPreviewMode={true} flyoutIsExpandable={true} />
</ExpandableFlyoutTestProviders>
);
expect(container).toBeEmptyDOMElement();
});
});
});

View file

@ -15,9 +15,16 @@ import {
EuiButtonEmpty,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { useExpandableFlyoutApi, useExpandableFlyoutState } from '@kbn/expandable-flyout';
import {
useExpandableFlyoutApi,
useExpandableFlyoutState,
useExpandableFlyoutHistory,
} from '@kbn/expandable-flyout';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { FlyoutHistory } from './flyout_history';
import { getProcessedHistory } from '../utils/history_utils';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import {
HEADER_ACTIONS_TEST_ID,
COLLAPSE_DETAILS_BUTTON_TEST_ID,
@ -37,6 +44,14 @@ export interface FlyoutNavigationProps {
* Optional actions to be placed on the right hand side of navigation
*/
actions?: React.ReactElement;
/**
* Boolean indicating the panel is shown in preview panel
*/
isPreviewMode?: boolean;
/**
* Boolean indicating the panel is shown in rule preview
*/
isPreview?: boolean;
}
/**
@ -44,9 +59,17 @@ export interface FlyoutNavigationProps {
* pass in a list of actions to be displayed on top.
*/
export const FlyoutNavigation: FC<FlyoutNavigationProps> = memo(
({ flyoutIsExpandable = false, expandDetails, actions }) => {
({ flyoutIsExpandable = false, expandDetails, actions, isPreviewMode, isPreview }) => {
const { euiTheme } = useEuiTheme();
const history = useExpandableFlyoutHistory();
const isFlyoutHistoryEnabled = useIsExperimentalFeatureEnabled(
'newExpandableFlyoutNavigationEnabled'
);
const historyArray = useMemo(() => getProcessedHistory({ history, maxCount: 10 }), [history]);
// Don't show history in rule preview
const hasHistory = !isPreview && isFlyoutHistoryEnabled;
const panels = useExpandableFlyoutState();
const isExpanded: boolean = !!panels.left;
@ -101,7 +124,12 @@ export const FlyoutNavigation: FC<FlyoutNavigationProps> = memo(
[expandDetails]
);
return flyoutIsExpandable || actions ? (
// do not show navigation in preview mode
if (isPreviewMode) {
return null;
}
return flyoutIsExpandable || actions || hasHistory ? (
<EuiFlyoutHeader hasBorder>
<EuiFlexGroup
direction="row"
@ -116,7 +144,30 @@ export const FlyoutNavigation: FC<FlyoutNavigationProps> = memo(
`}
>
<EuiFlexItem grow={false}>
{flyoutIsExpandable && expandDetails && (isExpanded ? collapseButton : expandButton)}
<EuiFlexGroup
direction="row"
justifyContent="flexStart"
alignItems="center"
responsive={false}
gutterSize="none"
>
{flyoutIsExpandable && expandDetails && (
<EuiFlexItem
grow={false}
css={css`
border-right: 1px ${euiTheme.colors.lightShade} solid;
padding-right: -${euiTheme.size.m};
`}
>
{isExpanded ? collapseButton : expandButton}
</EuiFlexItem>
)}
{hasHistory && (
<EuiFlexItem>
<FlyoutHistory history={historyArray} />
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
{actions && (
<EuiFlexItem grow={false} data-test-subj={HEADER_ACTIONS_TEST_ID}>

View file

@ -16,7 +16,7 @@ import { HostPreviewPanelKey } from '../../entity_details/host_right';
import { HOST_PREVIEW_BANNER } from '../../document_details/right/components/host_entity_overview';
import { UserPreviewPanelKey } from '../../entity_details/user_right';
import { USER_PREVIEW_BANNER } from '../../document_details/right/components/user_entity_overview';
import { NetworkPanelKey, NETWORK_PREVIEW_BANNER } from '../../network_details';
import { NetworkPreviewPanelKey, NETWORK_PREVIEW_BANNER } from '../../network_details';
import { RulePreviewPanelKey, RULE_PREVIEW_BANNER } from '../../rule_details/right';
import { createTelemetryServiceMock } from '../../../common/lib/telemetry/telemetry_service.mock';
@ -105,10 +105,11 @@ describe('<PreviewLink />', () => {
getByTestId('ip-link').click();
expect(mockFlyoutApi.openPreviewPanel).toHaveBeenCalledWith({
id: NetworkPanelKey,
id: NetworkPreviewPanelKey,
params: {
ip: '100:XXX:XXX',
flowTarget: 'source',
scopeId: 'scopeId',
banner: NETWORK_PREVIEW_BANNER,
},
});

View file

@ -22,7 +22,7 @@ import { HostPreviewPanelKey } from '../../entity_details/host_right';
import { HOST_PREVIEW_BANNER } from '../../document_details/right/components/host_entity_overview';
import { UserPreviewPanelKey } from '../../entity_details/user_right';
import { USER_PREVIEW_BANNER } from '../../document_details/right/components/user_entity_overview';
import { NetworkPanelKey, NETWORK_PREVIEW_BANNER } from '../../network_details';
import { NetworkPreviewPanelKey, NETWORK_PREVIEW_BANNER } from '../../network_details';
import { RulePreviewPanelKey, RULE_PREVIEW_BANNER } from '../../rule_details/right';
import { DocumentEventTypes } from '../../../common/lib/telemetry';
@ -46,9 +46,10 @@ const getPreviewParams = (
): PreviewParams | null => {
if (getEcsField(field)?.type === IP_FIELD_TYPE) {
return {
id: NetworkPanelKey,
id: NetworkPreviewPanelKey,
params: {
ip: value,
scopeId,
flowTarget: field.includes(FlowTargetSourceDest.destination)
? FlowTargetSourceDest.destination
: FlowTargetSourceDest.source,

View file

@ -36,3 +36,18 @@ export const HEADER_ACTIONS_TEST_ID = `${FLYOUT_NAVIGATION_TEST_ID}Actions` as c
export const TITLE_HEADER_ICON_TEST_ID = (dataTestSubj: string) => `${dataTestSubj}Icon`;
export const TITLE_HEADER_TEXT_TEST_ID = (dataTestSubj: string) => `${dataTestSubj}Text`;
export const TITLE_LINK_ICON_TEST_ID = (dataTestSubj: string) => `${dataTestSubj}LinkIcon`;
/* History */
export const FLYOUT_HISTORY_TEST_ID = `${PREFIX}History` as const;
export const FLYOUT_HISTORY_BUTTON_TEST_ID = `${FLYOUT_HISTORY_TEST_ID}Button` as const;
export const FLYOUT_HISTORY_CONTEXT_PANEL_TEST_ID =
`${FLYOUT_HISTORY_TEST_ID}ContextPanel` as const;
export const DOCUMENT_DETAILS_HISTORY_ROW_TEST_ID =
`${FLYOUT_HISTORY_TEST_ID}DocumentDetailsRow` as const;
export const RULE_HISTORY_ROW_TEST_ID = `${FLYOUT_HISTORY_TEST_ID}RuleRow` as const;
export const HOST_HISTORY_ROW_TEST_ID = `${FLYOUT_HISTORY_TEST_ID}HostRow` as const;
export const USER_HISTORY_ROW_TEST_ID = `${FLYOUT_HISTORY_TEST_ID}UserRow` as const;
export const NETWORK_HISTORY_ROW_TEST_ID = `${FLYOUT_HISTORY_TEST_ID}NetworkRow` as const;
export const GENERIC_HISTORY_ROW_TEST_ID = `${FLYOUT_HISTORY_TEST_ID}GenericRow` as const;
export const NO_DATA_HISTORY_ROW_TEST_ID = `${FLYOUT_HISTORY_TEST_ID}NoDataRow` as const;

View file

@ -0,0 +1,56 @@
/*
* 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 { getProcessedHistory } from './history_utils';
describe('getProcessedHistory', () => {
const simpleHistory = [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }];
const complexHistory = [
{ id: '1' },
{ id: '2' },
{ id: '1' },
{ id: '3' },
{ id: '4' },
{ id: '2' },
];
it('returns a reversed history array and removes latest entry', () => {
// input: 1, 2, 3, 4
// reverse: 4, 3, 2, 1
// remove latest: 4, 3, 2
const processedHistory = getProcessedHistory({ history: simpleHistory, maxCount: 5 });
expect(processedHistory).toEqual([{ id: '3' }, { id: '2' }, { id: '1' }]);
});
it('returns processed history with the maxCount', () => {
// input: 1, 2, 3, 4
// reverse: 4, 3, 2, 1
// remove latest: 3, 2, 1
// keep maxCount: 3, 2
const processedHistory = getProcessedHistory({ history: simpleHistory, maxCount: 2 });
expect(processedHistory).toEqual([{ id: '3' }, { id: '2' }]);
});
it('removes duplicates and reverses', () => {
// input: 1, 2, 1, 3, 4, 2
// reverse: 2, 4, 3, 1, 2, 1
// remove duplicates: 2, 4, 3, 1
// remove latest: 4, 3, 1
const processedHistory = getProcessedHistory({ history: complexHistory, maxCount: 5 });
expect(processedHistory).toEqual([{ id: '4' }, { id: '3' }, { id: '1' }]);
});
it('returns empty array if history only has one entry', () => {
const processedHistory = getProcessedHistory({ history: [{ id: '1' }], maxCount: 5 });
expect(processedHistory).toEqual([]);
});
it('returns empty array if history is empty', () => {
const processedHistory = getProcessedHistory({ history: [], maxCount: 5 });
expect(processedHistory).toEqual([]);
});
});

View file

@ -0,0 +1,31 @@
/*
* 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 type { FlyoutPanelProps } from '@kbn/expandable-flyout';
/**
* Helper function that reverses the history array,
* removes duplicates and the most recent item
* @returns a history array of maxCount length
*/
export const getProcessedHistory = ({
history,
maxCount,
}: {
history: FlyoutPanelProps[];
maxCount: number;
}): FlyoutPanelProps[] => {
// Step 1: reverse history so the most recent is first
const reversedHistory = history.slice().reverse();
// Step 2: remove duplicates
const historyArray = Array.from(new Set(reversedHistory.map((i) => JSON.stringify(i)))).map((i) =>
JSON.parse(i)
);
// Omit the first (current) entry and return array of maxCount length
return historyArray.slice(1, maxCount + 1);
};

View file

@ -113,6 +113,7 @@ describe('FormattedIp', () => {
params: {
ip: props.value,
flowTarget: 'source',
scopeId: TimelineId.active,
},
},
});

View file

@ -195,6 +195,7 @@ const AddressLinksItemComponent: React.FC<AddressLinksItemProps> = ({
id: NetworkPanelKey,
params: {
ip: address,
scopeId: eventContext.timelineID,
flowTarget: fieldName.includes(FlowTargetSourceDest.destination)
? FlowTargetSourceDest.destination
: FlowTargetSourceDest.source,

View file

@ -67,7 +67,7 @@ export const RenderRuleName: React.FC<RenderRuleNameProps> = ({
title,
value,
}) => {
const { openRightPanel } = useExpandableFlyoutApi();
const { openFlyout } = useExpandableFlyoutApi();
const eventContext = useContext(StatefulEventContext);
const ruleName = `${value}`;
@ -91,14 +91,16 @@ export const RenderRuleName: React.FC<RenderRuleNameProps> = ({
return;
}
openRightPanel({
id: RulePanelKey,
params: {
ruleId,
openFlyout({
right: {
id: RulePanelKey,
params: {
ruleId,
},
},
});
},
[navigateToApp, ruleId, search, openInNewTab, openRightPanel, eventContext, isInTimelineContext]
[navigateToApp, ruleId, search, openInNewTab, openFlyout, eventContext, isInTimelineContext]
);
const href = useMemo(

View file

@ -16,7 +16,7 @@ import { TableId } from '@kbn/securitysolution-data-table';
import { createExpandableFlyoutApiMock } from '../../../../../common/mock/expandable_flyout';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
const mockOpenRightPanel = jest.fn();
const mockOpenFlyout = jest.fn();
jest.mock('@kbn/expandable-flyout');
@ -28,7 +28,7 @@ describe('HostName', () => {
beforeEach(() => {
jest.mocked(useExpandableFlyoutApi).mockReturnValue({
...createExpandableFlyoutApiMock(),
openRightPanel: mockOpenRightPanel,
openFlyout: mockOpenFlyout,
});
});
@ -81,7 +81,7 @@ describe('HostName', () => {
wrapper.find('[data-test-subj="host-details-button"]').last().simulate('click');
await waitFor(() => {
expect(mockOpenRightPanel).not.toHaveBeenCalled();
expect(mockOpenFlyout).not.toHaveBeenCalled();
});
});
@ -103,7 +103,7 @@ describe('HostName', () => {
wrapper.find('[data-test-subj="host-details-button"]').last().simulate('click');
await waitFor(() => {
expect(mockOpenRightPanel).not.toHaveBeenCalled();
expect(mockOpenFlyout).not.toHaveBeenCalled();
});
});
@ -125,7 +125,7 @@ describe('HostName', () => {
wrapper.find('[data-test-subj="host-details-button"]').last().simulate('click');
await waitFor(() => {
expect(mockOpenRightPanel).not.toHaveBeenCalled();
expect(mockOpenFlyout).not.toHaveBeenCalled();
});
});
@ -146,13 +146,15 @@ describe('HostName', () => {
wrapper.find('[data-test-subj="host-details-button"]').last().simulate('click');
await waitFor(() => {
expect(mockOpenRightPanel).toHaveBeenCalledWith({
id: 'host-panel',
params: {
hostName: props.value,
contextID: props.contextId,
scopeId: TableId.alertsOnAlertsPage,
isDraggable: false,
expect(mockOpenFlyout).toHaveBeenCalledWith({
right: {
id: 'host-panel',
params: {
hostName: props.value,
contextID: props.contextId,
scopeId: TableId.alertsOnAlertsPage,
isDraggable: false,
},
},
});
});
@ -175,13 +177,15 @@ describe('HostName', () => {
wrapper.find('[data-test-subj="host-details-button"]').last().simulate('click');
await waitFor(() => {
expect(mockOpenRightPanel).toHaveBeenCalledWith({
id: 'host-panel',
params: {
hostName: props.value,
contextID: props.contextId,
scopeId: 'timeline-1',
isDraggable: false,
expect(mockOpenFlyout).toHaveBeenCalledWith({
right: {
id: 'host-panel',
params: {
hostName: props.value,
contextID: props.contextId,
scopeId: 'timeline-1',
isDraggable: false,
},
},
});
});

View file

@ -44,7 +44,7 @@ const HostNameComponent: React.FC<Props> = ({
title,
value,
}) => {
const { openRightPanel } = useExpandableFlyoutApi();
const { openFlyout } = useExpandableFlyoutApi();
const isInSecurityApp = useIsInSecurityApp();
@ -70,18 +70,19 @@ const HostNameComponent: React.FC<Props> = ({
}
const { timelineID } = eventContext;
openRightPanel({
id: HostPanelKey,
params: {
hostName,
contextID: contextId,
scopeId: timelineID,
isDraggable,
openFlyout({
right: {
id: HostPanelKey,
params: {
hostName,
contextID: contextId,
scopeId: timelineID,
isDraggable,
},
},
});
},
[contextId, eventContext, hostName, isDraggable, isInTimelineContext, onClick, openRightPanel]
[contextId, eventContext, hostName, isDraggable, isInTimelineContext, onClick, openFlyout]
);
// The below is explicitly defined this way as the onClick takes precedence when it and the href are both defined

View file

@ -16,7 +16,7 @@ import { TableId } from '@kbn/securitysolution-data-table';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { createExpandableFlyoutApiMock } from '../../../../../common/mock/expandable_flyout';
const mockOpenRightPanel = jest.fn();
const mockOpenFlyout = jest.fn();
jest.mock('@kbn/expandable-flyout');
@ -28,7 +28,7 @@ describe('UserName', () => {
beforeEach(() => {
jest.mocked(useExpandableFlyoutApi).mockReturnValue({
...createExpandableFlyoutApiMock(),
openRightPanel: mockOpenRightPanel,
openFlyout: mockOpenFlyout,
});
});
afterEach(() => {
@ -78,7 +78,7 @@ describe('UserName', () => {
wrapper.find('[data-test-subj="users-link-anchor"]').last().simulate('click');
await waitFor(() => {
expect(mockOpenRightPanel).not.toHaveBeenCalled();
expect(mockOpenFlyout).not.toHaveBeenCalled();
});
});
@ -100,7 +100,7 @@ describe('UserName', () => {
wrapper.find('[data-test-subj="users-link-anchor"]').last().simulate('click');
await waitFor(() => {
expect(mockOpenRightPanel).not.toHaveBeenCalled();
expect(mockOpenFlyout).not.toHaveBeenCalled();
});
});
@ -121,13 +121,15 @@ describe('UserName', () => {
wrapper.find('[data-test-subj="users-link-anchor"]').last().simulate('click');
await waitFor(() => {
expect(mockOpenRightPanel).toHaveBeenCalledWith({
id: 'user-panel',
params: {
userName: props.value,
contextID: props.contextId,
scopeId: TableId.alertsOnAlertsPage,
isDraggable: false,
expect(mockOpenFlyout).toHaveBeenCalledWith({
right: {
id: 'user-panel',
params: {
userName: props.value,
contextID: props.contextId,
scopeId: TableId.alertsOnAlertsPage,
isDraggable: false,
},
},
});
});
@ -150,13 +152,15 @@ describe('UserName', () => {
wrapper.find('[data-test-subj="users-link-anchor"]').last().simulate('click');
await waitFor(() => {
expect(mockOpenRightPanel).toHaveBeenCalledWith({
id: 'user-panel',
params: {
userName: props.value,
contextID: props.contextId,
scopeId: 'timeline-1',
isDraggable: false,
expect(mockOpenFlyout).toHaveBeenCalledWith({
right: {
id: 'user-panel',
params: {
userName: props.value,
contextID: props.contextId,
scopeId: 'timeline-1',
isDraggable: false,
},
},
});
});

View file

@ -47,7 +47,7 @@ const UserNameComponent: React.FC<Props> = ({
const eventContext = useContext(StatefulEventContext);
const userName = `${value}`;
const isInTimelineContext = userName && eventContext?.timelineID;
const { openRightPanel } = useExpandableFlyoutApi();
const { openFlyout } = useExpandableFlyoutApi();
const isInSecurityApp = useIsInSecurityApp();
@ -65,17 +65,19 @@ const UserNameComponent: React.FC<Props> = ({
const { timelineID } = eventContext;
openRightPanel({
id: UserPanelKey,
params: {
userName,
contextID: contextId,
scopeId: timelineID,
isDraggable,
openFlyout({
right: {
id: UserPanelKey,
params: {
userName,
contextID: contextId,
scopeId: timelineID,
isDraggable,
},
},
});
},
[contextId, eventContext, isDraggable, isInTimelineContext, onClick, openRightPanel, userName]
[contextId, eventContext, isDraggable, isInTimelineContext, onClick, openFlyout, userName]
);
// The below is explicitly defined this way as the onClick takes precedence when it and the href are both defined