[Cloud Security] Added search bar toggle button (#206123)

This commit is contained in:
Kfir Peled 2025-01-16 20:36:01 +01:00 committed by GitHub
parent bfcffa1e76
commit 6fe4e70a66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1804 additions and 580 deletions

View file

@ -12,3 +12,8 @@ export const ACTOR_ENTITY_ID = 'actor.entity.id' as const;
export const TARGET_ENTITY_ID = 'target.entity.id' as const;
export const EVENT_ACTION = 'event.action' as const;
export const EVENT_ID = 'event.id' as const;
export const SHOW_SEARCH_BAR_BUTTON_TOUR_STORAGE_KEY =
'securitySolution.graphInvestigation:showSearchBarButtonTour' as const;
export const TOGGLE_SEARCH_BAR_STORAGE_KEY =
'securitySolution.graphInvestigation:toggleSearchBarState' as const;

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { Story } from '@storybook/react';
import type { Meta, Story } from '@storybook/react';
import { ThemeProvider, css } from '@emotion/react';
import { action } from '@storybook/addon-actions';
import { Actions as ActionsComponent, type ActionsProps } from './actions';
@ -14,8 +14,12 @@ import { Actions as ActionsComponent, type ActionsProps } from './actions';
export default {
title: 'Components/Graph Components/Additional Components',
description: 'CDR - Graph visualization',
argTypes: {},
};
argTypes: {
searchWarningMessage: {
control: 'object',
},
},
} as Meta;
const Template: Story<ActionsProps> = (props) => {
return (
@ -38,4 +42,5 @@ Actions.args = {
showToggleSearch: true,
searchFilterCounter: 0,
showInvestigateInTimeline: true,
searchToggled: false,
};

View file

@ -6,14 +6,18 @@
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { Actions, ActionsProps } from './actions';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { EuiThemeProvider } from '@elastic/eui';
import { Actions, ActionsProps } from './actions';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import {
GRAPH_ACTIONS_INVESTIGATE_IN_TIMELINE_ID,
GRAPH_ACTIONS_TOGGLE_SEARCH_ID,
} from '../test_ids';
jest.mock('react-use/lib/useLocalStorage', () => jest.fn().mockReturnValue([false, jest.fn()]));
const SEARCH_BAR_TOUR_TITLE = 'Refine your view with search';
const defaultProps: ActionsProps = {
showToggleSearch: true,
showInvestigateInTimeline: true,
@ -89,13 +93,124 @@ describe('Actions component', () => {
expect(getByText('5')).toBeInTheDocument();
});
it('renders "9" in search filter counter badge when searchFilterCounter is equal to 9', () => {
const { getByText } = renderWithProviders({ ...defaultProps, searchFilterCounter: 9 });
expect(getByText('9')).toBeInTheDocument();
it('renders "99" in search filter counter badge when searchFilterCounter is equal to 99', () => {
const { getByText } = renderWithProviders({ ...defaultProps, searchFilterCounter: 99 });
expect(getByText('99')).toBeInTheDocument();
});
it('renders "9+" in search filter counter badge when searchFilterCounter is greater than 9', () => {
const { getByText } = renderWithProviders({ ...defaultProps, searchFilterCounter: 10 });
expect(getByText('9+')).toBeInTheDocument();
it('renders "99+" in search filter counter badge when searchFilterCounter is greater than 99', () => {
const { getByText } = renderWithProviders({ ...defaultProps, searchFilterCounter: 100 });
expect(getByText('99+')).toBeInTheDocument();
});
describe('search warning message', () => {
it('should show search warning message when searchWarningMessage is provided', async () => {
const { getByTestId, getByText, container } = renderWithProviders({
...defaultProps,
searchWarningMessage: {
title: 'Warning title',
content: 'Warning content',
},
});
expect(container.querySelector('.euiBeacon')).toBeInTheDocument();
getByTestId(GRAPH_ACTIONS_TOGGLE_SEARCH_ID).focus();
await waitFor(() => {
expect(getByText('Warning title')).toBeInTheDocument();
expect(getByText('Warning content')).toBeInTheDocument();
});
});
it('should show search warning message when search button is toggled', async () => {
const { getByTestId, getByText, container } = renderWithProviders({
...defaultProps,
searchToggled: true,
searchWarningMessage: {
title: 'Warning title',
content: 'Warning content',
},
});
expect(container.querySelector('.euiBeacon')).toBeInTheDocument();
getByTestId(GRAPH_ACTIONS_TOGGLE_SEARCH_ID).focus();
await waitFor(() => {
expect(getByText('Warning title')).toBeInTheDocument();
expect(getByText('Warning content')).toBeInTheDocument();
});
});
});
describe('search bar tour', () => {
it('opens the search bar tour when searchFilterCounter is greater than 0 and shouldShowSearchBarButtonTour is true', () => {
let shouldShowSearchBarButtonTour = true;
const setShouldShowSearchBarButtonTourMock = jest.fn(
(value: boolean) => (shouldShowSearchBarButtonTour = value)
);
(useLocalStorage as jest.Mock).mockImplementation(() => [
shouldShowSearchBarButtonTour,
setShouldShowSearchBarButtonTourMock,
]);
const { getByText } = renderWithProviders({
...defaultProps,
searchFilterCounter: 3,
});
expect(getByText(SEARCH_BAR_TOUR_TITLE)).toBeInTheDocument();
expect(setShouldShowSearchBarButtonTourMock).toBeCalled();
expect(setShouldShowSearchBarButtonTourMock).toBeCalledWith(false);
});
it('does not open the search bar tour when searchFilterCounter is greater than 0 and shouldShowSearchBarButtonTour is false', () => {
const setShouldShowSearchBarButtonTourMock = jest.fn();
(useLocalStorage as jest.Mock).mockReturnValue([false, setShouldShowSearchBarButtonTourMock]);
const { queryByText } = renderWithProviders({
...defaultProps,
searchFilterCounter: 2,
});
expect(queryByText(SEARCH_BAR_TOUR_TITLE)).not.toBeInTheDocument();
expect(setShouldShowSearchBarButtonTourMock).not.toBeCalled();
});
it('should not show the tour if user already toggled the search bar', () => {
const setShouldShowSearchBarButtonTourMock = jest.fn();
(useLocalStorage as jest.Mock).mockReturnValue([true, setShouldShowSearchBarButtonTourMock]);
renderWithProviders({
...defaultProps,
searchFilterCounter: 0,
searchToggled: true,
});
expect(defaultProps.onSearchToggle).toHaveBeenCalledWith(true);
expect(setShouldShowSearchBarButtonTourMock).toBeCalled();
expect(setShouldShowSearchBarButtonTourMock).toBeCalledWith(false);
});
it('closes the search bar tour when the search toggle button is clicked', async () => {
let shouldShowSearchBarButtonTourState = true;
const setShouldShowSearchBarButtonTourMock = jest.fn(
(value: boolean) => (shouldShowSearchBarButtonTourState = value)
);
(useLocalStorage as jest.Mock).mockImplementation(() => [
shouldShowSearchBarButtonTourState,
setShouldShowSearchBarButtonTourMock,
]);
const { getByTestId, getByText, queryByText } = renderWithProviders({
...defaultProps,
searchFilterCounter: 1,
});
expect(getByText(SEARCH_BAR_TOUR_TITLE)).toBeInTheDocument();
fireEvent.click(getByTestId(GRAPH_ACTIONS_TOGGLE_SEARCH_ID));
await waitFor(() => {
expect(queryByText(SEARCH_BAR_TOUR_TITLE)).not.toBeInTheDocument();
});
expect(setShouldShowSearchBarButtonTourMock).toBeCalled();
expect(setShouldShowSearchBarButtonTourMock).toBeCalledWith(false);
});
});
});

View file

@ -16,23 +16,42 @@ import {
useEuiTheme,
EuiNotificationBadge,
EuiButton,
EuiTourStep,
EuiBeacon,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import {
GRAPH_ACTIONS_INVESTIGATE_IN_TIMELINE_ID,
GRAPH_ACTIONS_TOGGLE_SEARCH_ID,
} from '../test_ids';
import { SHOW_SEARCH_BAR_BUTTON_TOUR_STORAGE_KEY } from '../../common/constants';
const toggleSearchBarTourTitle = i18n.translate(
'securitySolutionPackages.csp.graph.controls.toggleSearchBar.tour.title',
{
defaultMessage: 'Refine your view with search',
}
);
const toggleSearchBarTourContent = i18n.translate(
'securitySolutionPackages.csp.graph.controls.toggleSearchBar.tour.content',
{
defaultMessage:
'Click here to reveal the search bar and advanced filtering options to focus on specific connections within the graph.',
}
);
const toggleSearchBarTooltip = i18n.translate(
'securitySolutionPackages.csp.graph.controls.toggleSearchBar',
'securitySolutionPackages.csp.graph.controls.toggleSearchBar.tooltip',
{
defaultMessage: 'Toggle search bar',
}
);
const investigateInTimelineTooltip = i18n.translate(
'securitySolutionPackages.csp.graph.controls.investigate',
'securitySolutionPackages.csp.graph.controls.investigateInTimeline.tooltip',
{
defaultMessage: 'Investigate in timeline',
}
@ -63,66 +82,131 @@ export interface ActionsProps extends CommonProps {
* Callback when investigate in timeline action button is clicked, ignored if investigateInTimelineComponent is provided.
*/
onInvestigateInTimeline?: () => void;
/**
* Whether search is toggled or not. Defaults value is false.
*/
searchToggled?: boolean;
/**
* Warning message to show. Defaults value is undefined.
*/
searchWarningMessage?: { title: string; content: string };
}
// eslint-disable-next-line complexity
export const Actions = ({
showToggleSearch = true,
showInvestigateInTimeline = true,
onInvestigateInTimeline,
onSearchToggle,
searchFilterCounter = 0,
searchToggled,
searchWarningMessage,
...props
}: ActionsProps) => {
const { euiTheme } = useEuiTheme();
const [searchToggled, setSearchToggled] = useState<boolean>(false);
const [isSearchBarTourOpen, setIsSearchBarTourOpen] = useState(false);
const hasSearchWarning = searchWarningMessage !== undefined && searchWarningMessage !== null;
const [shouldShowSearchBarButtonTour, setShouldShowSearchBarButtonTour] = useLocalStorage(
SHOW_SEARCH_BAR_BUTTON_TOUR_STORAGE_KEY,
true
);
if (shouldShowSearchBarButtonTour) {
if (searchFilterCounter > 0) {
setIsSearchBarTourOpen(true);
setShouldShowSearchBarButtonTour(false);
} else if (searchToggled) {
// User already used the search bar, so we don't need to show the tour
setShouldShowSearchBarButtonTour(false);
}
}
const tooltipTitle =
!isSearchBarTourOpen && hasSearchWarning ? searchWarningMessage.title : undefined;
const tooltipContent =
!isSearchBarTourOpen && hasSearchWarning
? searchWarningMessage.content
: !isSearchBarTourOpen
? toggleSearchBarTooltip
: undefined;
return (
<EuiFlexGroup direction="column" gutterSize={'none'} {...props}>
{showToggleSearch && (
<EuiFlexItem grow={false}>
<EuiToolTip content={toggleSearchBarTooltip} position="left">
<EuiButton
iconType="search"
color={searchToggled ? 'primary' : 'text'}
fill={searchToggled}
css={[
css`
position: relative;
width: 40px;
`,
!searchToggled
? css`
border: ${euiTheme.border.thin};
background-color: ${euiTheme.colors.backgroundBasePlain};
`
: undefined,
]}
minWidth={false}
size="m"
aria-label={toggleSearchBarTooltip}
data-test-subj={GRAPH_ACTIONS_TOGGLE_SEARCH_ID}
onClick={() => {
setSearchToggled((prev) => {
onSearchToggle?.(!prev);
return !prev;
});
}}
>
{searchFilterCounter > 0 && (
<EuiNotificationBadge
css={css`
position: absolute;
right: ${-4.5 + (searchToggled ? 1 : 0)}px;
bottom: ${-4.5 + (searchToggled ? 1 : 0)}px;
transition: all ${euiTheme.animation.fast} ease-in, right 0s linear,
bottom 0s linear !important;
`}
>
{searchFilterCounter > 9 ? '9+' : searchFilterCounter}
</EuiNotificationBadge>
)}
</EuiButton>
</EuiToolTip>
<EuiTourStep
anchorPosition="leftUp"
title={toggleSearchBarTourTitle}
content={toggleSearchBarTourContent}
isStepOpen={isSearchBarTourOpen}
onFinish={() => setIsSearchBarTourOpen(false)}
step={1}
stepsTotal={1}
maxWidth={350}
>
<EuiToolTip title={tooltipTitle} content={tooltipContent} position="left">
<EuiButton
iconType="search"
color={searchToggled ? 'primary' : 'text'}
fill={searchToggled}
css={[
css`
position: relative;
width: 40px;
`,
!searchToggled
? css`
border: ${euiTheme.border.thin};
background-color: ${euiTheme.colors.backgroundBasePlain};
`
: undefined,
]}
minWidth={false}
size="m"
aria-label={toggleSearchBarTooltip}
data-test-subj={GRAPH_ACTIONS_TOGGLE_SEARCH_ID}
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
onSearchToggle?.(!searchToggled);
setIsSearchBarTourOpen(false);
// After a button click we wish to remove the focus from the button so the tooltip won't appear
// Since it causes the position of the button to shift,
// the tooltip is hanging out there at the wrong position
// https://github.com/elastic/eui/issues/8266
event.currentTarget?.blur();
}}
>
{hasSearchWarning && (
<EuiBeacon
css={css`
position: absolute;
left: ${-4.5 + (searchToggled ? 1 : 0)}px;
bottom: ${14 + (searchToggled ? 1 : 0)}px;
transition: all ${euiTheme.animation.fast} ease-in, right 0s linear,
bottom 0s linear !important;
`}
color="warning"
/>
)}
{searchFilterCounter > 0 && (
<EuiNotificationBadge
css={css`
position: absolute;
right: ${-4.5 + (searchToggled ? 1 : 0)}px;
bottom: ${-4.5 + (searchToggled ? 1 : 0)}px;
transition: all ${euiTheme.animation.fast} ease-in, right 0s linear,
bottom 0s linear !important;
`}
>
{searchFilterCounter > 99 ? '99+' : searchFilterCounter}
</EuiNotificationBadge>
)}
</EuiButton>
</EuiToolTip>
</EuiTourStep>
</EuiFlexItem>
)}
{showToggleSearch && showInvestigateInTimeline && <EuiHorizontalRule margin="xs" />}
@ -135,8 +219,12 @@ export const Actions = ({
size="m"
aria-label={investigateInTimelineTooltip}
data-test-subj={GRAPH_ACTIONS_INVESTIGATE_IN_TIMELINE_ID}
onClick={() => {
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
onInvestigateInTimeline?.();
// After a button click we wish to remove the focus from the button so the tooltip won't appear
// Since it causes a modal to be opened, the tooltip is hanging out there on top of the modal
event.currentTarget?.blur();
}}
/>
</EuiToolTip>

View file

@ -6,15 +6,22 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { setProjectAnnotations } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { composeStories } from '@storybook/testing-react';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import useSessionStorage from 'react-use/lib/useSessionStorage';
import * as stories from './graph_investigation.stories';
import { type GraphInvestigationProps } from './graph_investigation';
import { GRAPH_INVESTIGATION_TEST_ID, GRAPH_ACTIONS_INVESTIGATE_IN_TIMELINE_ID } from '../test_ids';
import {
GRAPH_INVESTIGATION_TEST_ID,
GRAPH_ACTIONS_INVESTIGATE_IN_TIMELINE_ID,
GRAPH_ACTIONS_TOGGLE_SEARCH_ID,
NODE_EXPAND_BUTTON_TEST_ID,
GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID,
} from '../test_ids';
import * as previewAnnotations from '../../../.storybook/preview';
import { NOTIFICATIONS_ADD_ERROR_ACTION } from '../../../.storybook/constants';
import { USE_FETCH_GRAPH_DATA_REFRESH_ACTION } from '../mock/constants';
@ -53,9 +60,58 @@ jest.mock('../graph/constants', () => ({
ONLY_RENDER_VISIBLE_ELEMENTS: false,
}));
// By default we toggle the search bar visibility
jest.mock('react-use/lib/useSessionStorage', () => jest.fn().mockReturnValue([true, jest.fn()]));
const QUERY_PARAM_IDX = 0;
const FILTERS_PARAM_IDX = 1;
const expandNode = (container: HTMLElement, nodeId: string) => {
const nodeElement = container.querySelector(
`.react-flow__nodes .react-flow__node[data-id="${nodeId}"]`
);
expect(nodeElement).not.toBeNull();
userEvent.hover(nodeElement!);
(
nodeElement?.querySelector(
`[data-test-subj="${NODE_EXPAND_BUTTON_TEST_ID}"]`
) as HTMLButtonElement
)?.click();
};
const showActionsByNode = (container: HTMLElement, nodeId: string) => {
expandNode(container, nodeId);
const btn = screen.getByTestId(GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID);
expect(btn).toHaveTextContent('Show actions by this entity');
btn.click();
};
const hideActionsByNode = (container: HTMLElement, nodeId: string) => {
expandNode(container, nodeId);
const hideBtn = screen.getByTestId(GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID);
expect(hideBtn).toHaveTextContent('Hide actions by this entity');
hideBtn.click();
};
const disableFilter = (container: HTMLElement, filterIndex: number) => {
const filterBtn = container.querySelector(
`[data-test-subj*="filter-id-${filterIndex}"]`
) as HTMLButtonElement;
expect(filterBtn).not.toBeNull();
filterBtn.click();
const disableFilterBtn = screen.getByTestId('disableFilter');
expect(disableFilterBtn).not.toBeNull();
disableFilterBtn.click();
};
const isSearchBarVisible = (container: HTMLElement) => {
const searchBarContainer = container.querySelector('.toggled-off');
return searchBarContainer === null;
};
describe('GraphInvestigation Component', () => {
beforeEach(() => {
for (const key in actionMocks) {
@ -104,106 +160,223 @@ describe('GraphInvestigation Component', () => {
expect(mockRefresh).toHaveBeenCalledTimes(1);
});
it('calls onInvestigateInTimeline action', () => {
const onInvestigateInTimeline = jest.fn();
const { getByTestId } = renderStory({
onInvestigateInTimeline,
showInvestigateInTimeline: true,
describe('searchBar', () => {
it('shows searchBar when search button toggle is hidden', () => {
const { getByTestId, queryByTestId, container } = renderStory();
expect(queryByTestId(GRAPH_ACTIONS_TOGGLE_SEARCH_ID)).not.toBeInTheDocument();
expect(getByTestId('globalQueryBar')).toBeInTheDocument();
expect(isSearchBarVisible(container)).toBeTruthy();
});
getByTestId(GRAPH_ACTIONS_INVESTIGATE_IN_TIMELINE_ID).click();
it('toggles searchBar on click', async () => {
let searchBarToggled = false;
const setSearchBarToggled = jest.fn((value: boolean) => {
searchBarToggled = value;
});
(useSessionStorage as jest.Mock).mockImplementation(() => [
searchBarToggled,
setSearchBarToggled,
]);
const { getByTestId, container } = renderStory({
showToggleSearch: true,
});
expect(onInvestigateInTimeline).toHaveBeenCalled();
expect(onInvestigateInTimeline.mock.calls[0][QUERY_PARAM_IDX]).toEqual({
query: '',
language: 'kuery',
expect(isSearchBarVisible(container)).toBeFalsy();
// Act
getByTestId(GRAPH_ACTIONS_TOGGLE_SEARCH_ID).click();
// Assert
expect(setSearchBarToggled).lastCalledWith(true);
});
it('toggles searchBar off on click', async () => {
let searchBarToggled = true;
const setSearchBarToggled = jest.fn((value: boolean) => {
searchBarToggled = value;
});
(useSessionStorage as jest.Mock).mockImplementation(() => [
searchBarToggled,
setSearchBarToggled,
]);
const { getByTestId, container } = renderStory({
showToggleSearch: true,
});
expect(isSearchBarVisible(container)).toBeTruthy();
// Act
getByTestId(GRAPH_ACTIONS_TOGGLE_SEARCH_ID).click();
// Assert
expect(setSearchBarToggled).lastCalledWith(false);
});
it('shows filters counter when KQL filter is applied', async () => {
const { getByTestId } = renderStory({
showToggleSearch: true,
});
const queryInput = getByTestId('queryInput');
await userEvent.type(queryInput, 'host1');
const querySubmitBtn = getByTestId('querySubmitButton');
querySubmitBtn.click();
expect(getByTestId(GRAPH_ACTIONS_TOGGLE_SEARCH_ID)).toHaveTextContent('1');
});
it('shows filters counter when node filter is applied', () => {
const { getByTestId, container } = renderStory({
showToggleSearch: true,
});
expandNode(container, 'admin@example.com');
getByTestId(GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID).click();
expect(getByTestId(GRAPH_ACTIONS_TOGGLE_SEARCH_ID)).toHaveTextContent('1');
});
it('hide filters counter when node filter is toggled off', () => {
const { getByTestId, container } = renderStory({
showToggleSearch: true,
});
showActionsByNode(container, 'admin@example.com');
expect(getByTestId(GRAPH_ACTIONS_TOGGLE_SEARCH_ID)).toHaveTextContent('1');
hideActionsByNode(container, 'admin@example.com');
expect(getByTestId(GRAPH_ACTIONS_TOGGLE_SEARCH_ID)).toHaveTextContent('');
expandNode(container, 'admin@example.com');
expect(getByTestId(GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID)).toHaveTextContent(
'Show actions by this entity'
);
});
it('hide filters counter when filter is disabled', () => {
const { getByTestId, container } = renderStory({
showToggleSearch: true,
});
showActionsByNode(container, 'admin@example.com');
expect(getByTestId(GRAPH_ACTIONS_TOGGLE_SEARCH_ID)).toHaveTextContent('1');
disableFilter(container, 0);
expect(getByTestId(GRAPH_ACTIONS_TOGGLE_SEARCH_ID)).toHaveTextContent('');
expandNode(container, 'admin@example.com');
expect(getByTestId(GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID)).toHaveTextContent(
'Show actions by this entity'
);
});
expect(onInvestigateInTimeline.mock.calls[0][FILTERS_PARAM_IDX]).toEqual([
{
$state: {
store: 'appState',
},
meta: {
disabled: false,
index: '1235',
negate: false,
params: ['1', '2'].map((eventId) => ({
meta: {
controlledBy: 'graph-investigation',
field: 'event.id',
index: '1235',
key: 'event.id',
negate: false,
params: {
query: eventId,
},
type: 'phrase',
},
query: {
match_phrase: {
'event.id': eventId,
},
},
})),
type: 'combined',
relation: 'OR',
},
},
]);
});
it('query includes origin event ids onInvestigateInTimeline callback', async () => {
// Arrange
const onInvestigateInTimeline = jest.fn();
const { getByTestId } = renderStory({
onInvestigateInTimeline,
showInvestigateInTimeline: true,
});
const queryInput = getByTestId('queryInput');
await userEvent.type(queryInput, 'host1');
const querySubmitBtn = getByTestId('querySubmitButton');
querySubmitBtn.click();
describe('investigateInTimeline', () => {
it('calls onInvestigateInTimeline action', () => {
const onInvestigateInTimeline = jest.fn();
const { getByTestId } = renderStory({
onInvestigateInTimeline,
showInvestigateInTimeline: true,
});
// Act
getByTestId(GRAPH_ACTIONS_INVESTIGATE_IN_TIMELINE_ID).click();
getByTestId(GRAPH_ACTIONS_INVESTIGATE_IN_TIMELINE_ID).click();
// Assert
expect(onInvestigateInTimeline).toHaveBeenCalled();
expect(onInvestigateInTimeline.mock.calls[0][QUERY_PARAM_IDX]).toEqual({
query: '(host1) OR event.id: "1" OR event.id: "2"',
language: 'kuery',
expect(onInvestigateInTimeline).toHaveBeenCalled();
expect(onInvestigateInTimeline.mock.calls[0][QUERY_PARAM_IDX]).toEqual({
query: '',
language: 'kuery',
});
expect(onInvestigateInTimeline.mock.calls[0][FILTERS_PARAM_IDX]).toEqual([
{
$state: {
store: 'appState',
},
meta: expect.objectContaining({
disabled: false,
index: '1235',
negate: false,
controlledBy: 'graph-investigation',
params: ['1', '2'].map((eventId) => ({
meta: {
controlledBy: 'graph-investigation',
field: 'event.id',
index: '1235',
key: 'event.id',
negate: false,
params: {
query: eventId,
},
type: 'phrase',
},
query: {
match_phrase: {
'event.id': eventId,
},
},
})),
type: 'combined',
relation: 'OR',
}),
},
]);
});
expect(onInvestigateInTimeline.mock.calls[0][FILTERS_PARAM_IDX]).toEqual([
{
$state: {
store: 'appState',
},
meta: {
disabled: false,
index: '1235',
negate: false,
params: ['1', '2'].map((eventId) => ({
meta: {
controlledBy: 'graph-investigation',
field: 'event.id',
index: '1235',
key: 'event.id',
negate: false,
params: {
query: eventId,
it('query includes origin event ids onInvestigateInTimeline callback', async () => {
// Arrange
const onInvestigateInTimeline = jest.fn();
const { getByTestId } = renderStory({
onInvestigateInTimeline,
showInvestigateInTimeline: true,
});
const queryInput = getByTestId('queryInput');
await userEvent.type(queryInput, 'host1');
const querySubmitBtn = getByTestId('querySubmitButton');
querySubmitBtn.click();
// Act
getByTestId(GRAPH_ACTIONS_INVESTIGATE_IN_TIMELINE_ID).click();
// Assert
expect(onInvestigateInTimeline).toHaveBeenCalled();
expect(onInvestigateInTimeline.mock.calls[0][QUERY_PARAM_IDX]).toEqual({
query: '(host1) OR event.id: "1" OR event.id: "2"',
language: 'kuery',
});
expect(onInvestigateInTimeline.mock.calls[0][FILTERS_PARAM_IDX]).toEqual([
{
$state: {
store: 'appState',
},
meta: expect.objectContaining({
disabled: false,
index: '1235',
negate: false,
controlledBy: 'graph-investigation',
params: ['1', '2'].map((eventId) => ({
meta: {
controlledBy: 'graph-investigation',
field: 'event.id',
index: '1235',
key: 'event.id',
negate: false,
params: {
query: eventId,
},
type: 'phrase',
},
type: 'phrase',
},
query: {
match_phrase: {
'event.id': eventId,
query: {
match_phrase: {
'event.id': eventId,
},
},
},
})),
type: 'combined',
relation: 'OR',
})),
type: 'combined',
relation: 'OR',
}),
},
},
]);
]);
});
});
});

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { Story } from '@storybook/react';
import { type Meta, Story } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { GraphInvestigation, type GraphInvestigationProps } from './graph_investigation';
import {
@ -14,6 +14,12 @@ import {
ReactQueryStorybookDecorator,
} from '../../../.storybook/decorators';
import { mockDataView } from '../mock/data_view.mock';
import { SHOW_SEARCH_BAR_BUTTON_TOUR_STORAGE_KEY } from '../../common/constants';
import { MockDataProvider } from '../mock/mock_context_provider';
import {
USE_FETCH_GRAPH_DATA_ACTION,
USE_FETCH_GRAPH_DATA_REFRESH_ACTION,
} from '../mock/constants';
export default {
title: 'Components/Graph Components/Investigation',
@ -21,13 +27,44 @@ export default {
argTypes: {
showToggleSearch: {
control: { type: 'boolean' },
defaultValue: false,
},
showInvestigateInTimeline: {
control: { type: 'boolean' },
defaultValue: false,
},
shouldShowSearchBarTour: {
description: 'Toggle the button to set the initial state of showing search bar tour',
control: { type: 'boolean' },
defaultValue: true,
},
isLoading: {
control: { type: 'boolean' },
defaultValue: false,
},
},
decorators: [ReactQueryStorybookDecorator, KibanaReactStorybookDecorator],
};
decorators: [
ReactQueryStorybookDecorator,
KibanaReactStorybookDecorator,
(StoryComponent, context) => {
const { shouldShowSearchBarTour, isLoading } = context.args;
localStorage.setItem(SHOW_SEARCH_BAR_BUTTON_TOUR_STORAGE_KEY, shouldShowSearchBarTour);
const mockData = {
useFetchGraphDataMock: {
isFetching: isLoading,
refresh: action(USE_FETCH_GRAPH_DATA_REFRESH_ACTION),
log: action(USE_FETCH_GRAPH_DATA_ACTION),
},
};
return (
<MockDataProvider data={mockData}>
<StoryComponent />
</MockDataProvider>
);
},
],
} as Meta;
const hourAgo = new Date(new Date().getTime() - 60 * 60 * 1000);
const defaultProps: GraphInvestigationProps = {
@ -58,8 +95,3 @@ const Template: Story<Partial<GraphInvestigationProps>> = (props) => {
};
export const Investigation = Template.bind({});
Investigation.args = {
showToggleSearch: false,
showInvestigateInTimeline: false,
};

View file

@ -10,127 +10,30 @@ import { SearchBar } from '@kbn/unified-search-plugin/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { i18n } from '@kbn/i18n';
import type { DataView } from '@kbn/data-views-plugin/public';
import {
BooleanRelation,
buildEsQuery,
isCombinedFilter,
buildCombinedFilter,
isFilter,
FilterStateStore,
} from '@kbn/es-query';
import type { Filter, Query, TimeRange, PhraseFilter } from '@kbn/es-query';
import { buildEsQuery, isCombinedFilter } from '@kbn/es-query';
import type { Filter, Query, TimeRange } from '@kbn/es-query';
import { css } from '@emotion/react';
import { Panel } from '@xyflow/react';
import { getEsQueryConfig } from '@kbn/data-service';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiProgress } from '@elastic/eui';
import useSessionStorage from 'react-use/lib/useSessionStorage';
import { Graph, isEntityNode } from '../../..';
import { useGraphNodeExpandPopover } from './use_graph_node_expand_popover';
import { useGraphLabelExpandPopover } from './use_graph_label_expand_popover';
import { type UseFetchGraphDataParams, useFetchGraphData } from '../../hooks/use_fetch_graph_data';
import { GRAPH_INVESTIGATION_TEST_ID } from '../test_ids';
import {
ACTOR_ENTITY_ID,
EVENT_ACTION,
EVENT_ID,
RELATED_ENTITY,
TARGET_ENTITY_ID,
} from '../../common/constants';
import { Actions, type ActionsProps } from '../controls/actions';
const CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER = 'graph-investigation';
const buildPhraseFilter = (field: string, value: string, dataViewId?: string): PhraseFilter => ({
meta: {
key: field,
index: dataViewId,
negate: false,
disabled: false,
type: 'phrase',
field,
controlledBy: CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER,
params: {
query: value,
},
},
query: {
match_phrase: {
[field]: value,
},
},
});
/**
* Adds a filter to the existing list of filters based on the provided key and value.
* It will always use the first filter in the list to build a combined filter with the new filter.
*
* @param dataViewId - The ID of the data view to which the filter belongs.
* @param prev - The previous list of filters.
* @param key - The key for the filter.
* @param value - The value for the filter.
* @returns A new list of filters with the added filter.
*/
const addFilter = (dataViewId: string, prev: Filter[], key: string, value: string) => {
const [firstFilter, ...otherFilters] = prev;
if (isCombinedFilter(firstFilter) && firstFilter?.meta?.relation === BooleanRelation.OR) {
return [
{
...firstFilter,
meta: {
...firstFilter.meta,
params: [
...(Array.isArray(firstFilter.meta.params) ? firstFilter.meta.params : []),
buildPhraseFilter(key, value),
],
},
},
...otherFilters,
];
} else if (isFilter(firstFilter) && firstFilter.meta?.type !== 'custom') {
return [
buildCombinedFilter(
BooleanRelation.OR,
[firstFilter, buildPhraseFilter(key, value, dataViewId)],
{
id: dataViewId,
}
),
...otherFilters,
];
} else {
return [
{
$state: {
store: FilterStateStore.APP_STATE,
},
...buildPhraseFilter(key, value, dataViewId),
},
...prev,
];
}
};
import { EVENT_ID, TOGGLE_SEARCH_BAR_STORAGE_KEY } from '../../common/constants';
import { Actions } from '../controls/actions';
import { AnimatedSearchBarContainer, useBorder } from './styles';
import { CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER, addFilter } from './search_filters';
import { useEntityNodeExpandPopover } from './use_entity_node_expand_popover';
import { useLabelNodeExpandPopover } from './use_label_node_expand_popover';
const useGraphPopovers = (
dataViewId: string,
setSearchFilters: React.Dispatch<React.SetStateAction<Filter[]>>
setSearchFilters: React.Dispatch<React.SetStateAction<Filter[]>>,
searchFilters: Filter[]
) => {
const nodeExpandPopover = useGraphNodeExpandPopover({
onExploreRelatedEntitiesClick: (node) => {
setSearchFilters((prev) => addFilter(dataViewId, prev, RELATED_ENTITY, node.id));
},
onShowActionsByEntityClick: (node) => {
setSearchFilters((prev) => addFilter(dataViewId, prev, ACTOR_ENTITY_ID, node.id));
},
onShowActionsOnEntityClick: (node) => {
setSearchFilters((prev) => addFilter(dataViewId, prev, TARGET_ENTITY_ID, node.id));
},
});
const labelExpandPopover = useGraphLabelExpandPopover({
onShowEventsWithThisActionClick: (node) => {
setSearchFilters((prev) => addFilter(dataViewId, prev, EVENT_ACTION, node.data.label ?? ''));
},
});
const nodeExpandPopover = useEntityNodeExpandPopover(setSearchFilters, dataViewId, searchFilters);
const labelExpandPopover = useLabelNodeExpandPopover(setSearchFilters, dataViewId, searchFilters);
const openPopoverCallback = useCallback(
(cb: Function, ...args: unknown[]) => {
@ -145,6 +48,21 @@ const useGraphPopovers = (
return { nodeExpandPopover, labelExpandPopover, openPopoverCallback };
};
const NEGATED_FILTER_SEARCH_WARNING_MESSAGE = {
title: i18n.translate(
'securitySolutionPackages.csp.graph.investigation.warningNegatedFilterTitle',
{
defaultMessage: 'Filters Negated',
}
),
content: i18n.translate(
'securitySolutionPackages.csp.graph.investigation.warningNegatedFilterContent',
{
defaultMessage: 'One or more filters are negated and may not return expected results.',
}
),
};
export interface GraphInvestigationProps {
/**
* The initial state to use for the graph investigation view.
@ -211,6 +129,10 @@ export const GraphInvestigation = memo<GraphInvestigationProps>(
}: GraphInvestigationProps) => {
const [searchFilters, setSearchFilters] = useState<Filter[]>(() => []);
const [timeRange, setTimeRange] = useState<TimeRange>(initialTimeRange);
const [searchToggled, setSearchToggled] = useSessionStorage(
TOGGLE_SEARCH_BAR_STORAGE_KEY,
!showToggleSearch
);
const lastValidEsQuery = useRef<EsQuery | undefined>();
const [kquery, setKQuery] = useState<Query>(EMPTY_QUERY);
@ -229,15 +151,6 @@ export const GraphInvestigation = memo<GraphInvestigationProps>(
onInvestigateInTimeline?.(query, filters, timeRange);
}, [dataView?.id, onInvestigateInTimeline, originEventIds, kquery, searchFilters, timeRange]);
const actionsProps: ActionsProps = useMemo(
() => ({
showInvestigateInTimeline,
showToggleSearch,
onInvestigateInTimeline: onInvestigateInTimelineCallback,
}),
[onInvestigateInTimelineCallback, showInvestigateInTimeline, showToggleSearch]
);
const {
services: { uiSettings, notifications },
} = useKibana();
@ -264,12 +177,13 @@ export const GraphInvestigation = memo<GraphInvestigationProps>(
const { nodeExpandPopover, labelExpandPopover, openPopoverCallback } = useGraphPopovers(
dataView?.id ?? '',
setSearchFilters
setSearchFilters,
searchFilters
);
const nodeExpandButtonClickHandler = (...args: unknown[]) =>
openPopoverCallback(nodeExpandPopover.onNodeExpandButtonClick, ...args);
const labelExpandButtonClickHandler = (...args: unknown[]) =>
openPopoverCallback(labelExpandPopover.onLabelExpandButtonClick, ...args);
openPopoverCallback(labelExpandPopover.onNodeExpandButtonClick, ...args);
const isPopoverOpen = [nodeExpandPopover, labelExpandPopover].some(
({ state: { isOpen } }) => isOpen
);
@ -309,6 +223,31 @@ export const GraphInvestigation = memo<GraphInvestigationProps>(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.nodes]);
const searchFilterCounter = useMemo(() => {
const filtersCount = searchFilters
.filter((filter) => !filter.meta.disabled)
.reduce((sum, filter) => {
if (isCombinedFilter(filter)) {
return sum + filter.meta.params.length;
}
return sum + 1;
}, 0);
const queryCounter = kquery.query.trim().length > 0 ? 1 : 0;
return filtersCount + queryCounter;
}, [kquery.query, searchFilters]);
const searchWarningMessage =
searchFilters.filter(
(filter) =>
!filter.meta.disabled &&
filter.meta.negate &&
filter.meta.controlledBy === CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER
).length > 0
? NEGATED_FILTER_SEARCH_WARNING_MESSAGE
: undefined;
return (
<>
<EuiFlexGroup
@ -317,40 +256,54 @@ export const GraphInvestigation = memo<GraphInvestigationProps>(
gutterSize="none"
css={css`
height: 100%;
.react-flow__panel {
margin-right: 8px;
}
`}
>
{dataView && (
<EuiFlexItem grow={false}>
<SearchBar<Query>
showFilterBar={true}
showDatePicker={true}
showAutoRefreshOnly={false}
showSaveQuery={false}
showQueryInput={true}
disableQueryLanguageSwitcher={true}
isLoading={isFetching}
isAutoRefreshDisabled={true}
dateRangeFrom={timeRange.from}
dateRangeTo={timeRange.to}
query={kquery}
indexPatterns={[dataView]}
filters={searchFilters}
submitButtonStyle={'iconOnly'}
onFiltersUpdated={(newFilters) => {
setSearchFilters(newFilters);
}}
onQuerySubmit={(payload, isUpdate) => {
if (isUpdate) {
setTimeRange({ ...payload.dateRange });
setKQuery(payload.query || EMPTY_QUERY);
} else {
refresh();
}
}}
/>
<AnimatedSearchBarContainer
className={!searchToggled && showToggleSearch ? 'toggled-off' : undefined}
>
<SearchBar<Query>
showFilterBar={true}
showDatePicker={true}
showAutoRefreshOnly={false}
showSaveQuery={false}
showQueryInput={true}
disableQueryLanguageSwitcher={true}
isLoading={isFetching}
isAutoRefreshDisabled={true}
dateRangeFrom={timeRange.from}
dateRangeTo={timeRange.to}
query={kquery}
indexPatterns={[dataView]}
filters={searchFilters}
submitButtonStyle={'iconOnly'}
onFiltersUpdated={(newFilters) => {
setSearchFilters(newFilters);
}}
onQuerySubmit={(payload, isUpdate) => {
if (isUpdate) {
setTimeRange({ ...payload.dateRange });
setKQuery(payload.query || EMPTY_QUERY);
} else {
refresh();
}
}}
/>
</AnimatedSearchBarContainer>
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiFlexItem
css={css`
border-top: ${useBorder()};
position: relative;
`}
>
{isFetching && <EuiProgress size="xs" color="accent" position="absolute" />}
<Graph
css={css`
height: 100%;
@ -362,7 +315,15 @@ export const GraphInvestigation = memo<GraphInvestigationProps>(
isLocked={isPopoverOpen}
>
<Panel position="top-right">
<Actions {...actionsProps} />
<Actions
showInvestigateInTimeline={showInvestigateInTimeline}
showToggleSearch={showToggleSearch}
onInvestigateInTimeline={onInvestigateInTimelineCallback}
onSearchToggle={(isSearchToggle) => setSearchToggled(isSearchToggle)}
searchFilterCounter={searchFilterCounter}
searchToggled={searchToggled}
searchWarningMessage={searchWarningMessage}
/>
</Panel>
</Graph>
</EuiFlexItem>

View file

@ -1,52 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo } from 'react';
import { EuiListGroup } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ExpandPopoverListItem } from '../styles';
import { GraphPopover } from '../../..';
import {
GRAPH_LABEL_EXPAND_POPOVER_TEST_ID,
GRAPH_LABEL_EXPAND_POPOVER_SHOW_EVENTS_WITH_THIS_ACTION_ITEM_ID,
} from '../test_ids';
interface GraphLabelExpandPopoverProps {
isOpen: boolean;
anchorElement: HTMLElement | null;
closePopover: () => void;
onShowEventsWithThisActionClick: () => void;
}
export const GraphLabelExpandPopover = memo<GraphLabelExpandPopoverProps>(
({ isOpen, anchorElement, closePopover, onShowEventsWithThisActionClick }) => {
return (
<GraphPopover
panelPaddingSize="s"
anchorPosition="rightCenter"
isOpen={isOpen}
anchorElement={anchorElement}
closePopover={closePopover}
data-test-subj={GRAPH_LABEL_EXPAND_POPOVER_TEST_ID}
>
<EuiListGroup gutterSize="none" bordered={false} flush={true}>
<ExpandPopoverListItem
iconType="users"
label={i18n.translate(
'securitySolutionPackages.csp.graph.graphLabelExpandPopover.showEventsWithThisAction',
{ defaultMessage: 'Show events with this action' }
)}
onClick={onShowEventsWithThisActionClick}
data-test-subj={GRAPH_LABEL_EXPAND_POPOVER_SHOW_EVENTS_WITH_THIS_ACTION_ITEM_ID}
/>
</EuiListGroup>
</GraphPopover>
);
}
);
GraphLabelExpandPopover.displayName = 'GraphLabelExpandPopover';

View file

@ -1,87 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo } from 'react';
import { EuiListGroup } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ExpandPopoverListItem } from '../styles';
import { GraphPopover } from '../../..';
import {
GRAPH_NODE_EXPAND_POPOVER_TEST_ID,
GRAPH_NODE_POPOVER_SHOW_RELATED_ITEM_ID,
GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID,
GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_ITEM_ID,
} from '../test_ids';
interface GraphNodeExpandPopoverProps {
isOpen: boolean;
anchorElement: HTMLElement | null;
closePopover: () => void;
onShowRelatedEntitiesClick: () => void;
onShowActionsByEntityClick: () => void;
onShowActionsOnEntityClick: () => void;
}
export const GraphNodeExpandPopover = memo<GraphNodeExpandPopoverProps>(
({
isOpen,
anchorElement,
closePopover,
onShowRelatedEntitiesClick,
onShowActionsByEntityClick,
onShowActionsOnEntityClick,
}) => {
return (
<GraphPopover
panelPaddingSize="s"
anchorPosition="rightCenter"
isOpen={isOpen}
anchorElement={anchorElement}
closePopover={closePopover}
data-test-subj={GRAPH_NODE_EXPAND_POPOVER_TEST_ID}
>
<EuiListGroup gutterSize="none" bordered={false} flush={true}>
<ExpandPopoverListItem
iconType="users"
label={i18n.translate(
'securitySolutionPackages.csp.graph.graphNodeExpandPopover.showActionsByEntity',
{
defaultMessage: 'Show actions by this entity',
}
)}
onClick={onShowActionsByEntityClick}
data-test-subj={GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID}
/>
<ExpandPopoverListItem
iconType="storage"
label={i18n.translate(
'securitySolutionPackages.csp.graph.graphNodeExpandPopover.showActionsOnEntity',
{
defaultMessage: 'Show actions on this entity',
}
)}
onClick={onShowActionsOnEntityClick}
data-test-subj={GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_ITEM_ID}
/>
<ExpandPopoverListItem
iconType="visTagCloud"
label={i18n.translate(
'securitySolutionPackages.csp.graph.graphNodeExpandPopover.showRelatedEvents',
{
defaultMessage: 'Show related events',
}
)}
onClick={onShowRelatedEntitiesClick}
data-test-subj={GRAPH_NODE_POPOVER_SHOW_RELATED_ITEM_ID}
/>
</EuiListGroup>
</GraphPopover>
);
}
);
GraphNodeExpandPopover.displayName = 'GraphNodeExpandPopover';

View file

@ -0,0 +1,99 @@
/*
* 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, { memo } from 'react';
import { EuiHorizontalRule, EuiListGroup } from '@elastic/eui';
import { ExpandPopoverListItem } from '../styles';
import { GraphPopover } from '../../..';
/**
* Props for the ListGroupGraphPopover component.
*/
interface ListGroupGraphPopoverProps {
/**
* The data-test-subj value for the popover.
*/
testSubject: string;
/**
* Indicates whether the popover is open.
*/
isOpen: boolean;
/**
* The HTML element that the popover is anchored to.
*/
anchorElement: HTMLElement | null;
/**
* Function to close the popover.
*/
closePopover: () => void;
/**
* The action to take when the related entities toggle is clicked.
*/
items?: Array<ItemExpandPopoverListItemProps | SeparatorExpandPopoverListItemProps>;
/**
* Function to get the list of items to display in the popover.
* When provided, this function is called each time the popover is opened.
* If `items` is provided, this function is ignored.
*/
itemsFn?: () => Array<ItemExpandPopoverListItemProps | SeparatorExpandPopoverListItemProps>;
}
export interface ItemExpandPopoverListItemProps {
type: 'item';
iconType: string;
label: string;
onClick: () => void;
testSubject: string;
}
export interface SeparatorExpandPopoverListItemProps {
type: 'separator';
}
/**
* A graph popover that displays a list of items.
*/
export const ListGroupGraphPopover = memo<ListGroupGraphPopoverProps>(
({ isOpen, anchorElement, closePopover, items, itemsFn, testSubject }) => {
const listItems = items || itemsFn?.() || [];
return (
<GraphPopover
panelPaddingSize="s"
anchorPosition="rightCenter"
isOpen={isOpen}
anchorElement={anchorElement}
closePopover={closePopover}
data-test-subj={testSubject}
>
<EuiListGroup gutterSize="none" bordered={false} flush={true}>
{listItems.map((item, index) => {
if (item.type === 'separator') {
return <EuiHorizontalRule key={index} margin="xs" />;
}
return (
<ExpandPopoverListItem
key={index}
iconType={item.iconType}
label={item.label}
onClick={item.onClick}
data-test-subj={item.testSubject}
/>
);
})}
</EuiListGroup>
</GraphPopover>
);
}
);
ListGroupGraphPopover.displayName = 'ListGroupGraphPopover';

View file

@ -0,0 +1,306 @@
/*
* 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 { expect } from 'expect';
import {
type Filter,
BooleanRelation,
type CombinedFilter,
FILTERS,
isCombinedFilter,
} from '@kbn/es-query';
import type { PhraseFilter, PhraseFilterMetaParams } from '@kbn/es-query/src/filters/build_filters';
import { omit } from 'lodash';
import {
CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER,
addFilter,
containsFilter,
removeFilter,
} from './search_filters';
const dataViewId = 'test-data-view';
const buildFilterMock = (key: string, value: string, controlledBy?: string) => ({
meta: {
key,
index: dataViewId,
negate: false,
disabled: false,
type: 'phrase',
field: key,
controlledBy,
params: {
query: value,
},
},
query: {
match_phrase: {
[key]: value,
},
},
});
const buildCombinedFilterMock = (filters: Filter[], controlledBy?: string): CombinedFilter => ({
meta: {
relation: BooleanRelation.OR,
controlledBy,
params: filters,
type: FILTERS.COMBINED,
},
query: {},
});
describe('search_filters', () => {
const key = 'test-key';
const value = 'test-value';
const controlledPhraseFilter: PhraseFilter = buildFilterMock(
key,
value,
CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER
);
describe('containsFilter', () => {
it('should return true if the filter is present in the controlled phrase filter', () => {
const filters: Filter[] = [controlledPhraseFilter];
expect(containsFilter(filters, key, value)).toBe(true);
});
it('should return true if the filter is present in a phrase filter (not controlled)', () => {
const filters: Filter[] = [
buildFilterMock(key, 'another-value', CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER),
buildFilterMock(key, value),
];
expect(containsFilter(filters, key, value)).toBe(true);
});
it('should return true if the filter is present in the controlled combined filter', () => {
const filters: Filter[] = [
buildCombinedFilterMock(
[
buildFilterMock(key, 'another-value', CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER),
controlledPhraseFilter,
],
CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER
),
];
expect(containsFilter(filters, key, value)).toBe(true);
});
it('should return true if the filter is present in a combined filter (not controlled)', () => {
const filters: Filter[] = [
buildCombinedFilterMock([
buildFilterMock(key, 'another-value'),
buildFilterMock(key, value),
]),
];
expect(containsFilter(filters, key, value)).toBe(true);
});
it('should return false if the filter is not present in empty filters', () => {
const filters: Filter[] = [];
expect(containsFilter(filters, key, value)).toBe(false);
});
it('should return false if the filter is not present with controlled filter with same key and different value', () => {
const filters: Filter[] = [
buildFilterMock(key, 'different-value', CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER),
];
expect(containsFilter(filters, key, value)).toBe(false);
});
it('should return false when the combined filter with same key and different value', () => {
const filters: Filter[] = [
buildCombinedFilterMock(
[
buildFilterMock(key, 'different-value', CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER),
buildFilterMock(key, 'different-value2', CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER),
],
CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER
),
];
expect(containsFilter(filters, key, value)).toBe(false);
});
});
describe('addFilter', () => {
it('should add a new filter to an empty list', () => {
const filters: Filter[] = [];
// Act
const newFilters = addFilter(dataViewId, filters, key, value);
// Assert
expect(newFilters).toHaveLength(1);
expect(newFilters[0]).toEqual({
$state: { store: 'appState' },
meta: expect.objectContaining({
key,
params: { query: value },
controlledBy: CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER,
index: dataViewId,
disabled: false,
field: key,
negate: false,
type: 'phrase',
}),
query: {
match_phrase: {
[key]: value,
},
},
});
});
it('should add a new filter to an existing list with uncontrolled filter', () => {
const filters: Filter[] = [buildFilterMock(key, 'another-value')];
// Act
const newFilters = addFilter(dataViewId, filters, key, value);
// Assert
expect(newFilters).toHaveLength(1);
expect(newFilters[0]).toEqual({
$state: { store: 'appState' },
meta: expect.objectContaining({
params: [
{ ...filters[0], meta: { ...omit(filters[0].meta, 'disabled') } },
{
meta: expect.objectContaining({
key,
field: key,
params: { query: value },
index: dataViewId,
controlledBy: CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER,
type: 'phrase',
}),
query: {
match_phrase: {
[key]: value,
},
},
},
],
index: dataViewId,
controlledBy: CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER,
relation: 'OR',
type: 'combined',
}),
});
});
it('should combine with an existing filter', () => {
const filters: Filter[] = [controlledPhraseFilter];
// Act
const newFilters = addFilter(dataViewId, filters, key, 'new-value');
// Assert
expect(newFilters).toHaveLength(1);
expect(newFilters[0].meta.params).toHaveLength(2);
});
it('should a new filter when the existing controlled (first) filter is disabled', () => {
const filters: Filter[] = [
{ ...controlledPhraseFilter, meta: { ...controlledPhraseFilter.meta, disabled: true } },
];
// Act
const newFilters = addFilter(dataViewId, filters, key, value);
// Assert
expect(newFilters).toHaveLength(2);
expect(newFilters[0].meta.disabled).toBe(false);
expect(newFilters[1].meta.disabled).toBe(true);
});
});
describe('removeFilter', () => {
it('should return the same filters when filter not found', () => {
const filters: Filter[] = [controlledPhraseFilter];
// Act
const newFilters = removeFilter(filters, key, 'non-existent-value');
// Assert
expect(newFilters).toHaveLength(1);
expect(newFilters).toEqual(filters);
});
it('should remove a single phrase filter when present', () => {
const filters: Filter[] = [controlledPhraseFilter];
// Act
const newFilters = removeFilter(filters, key, value);
// Assert
expect(newFilters).toHaveLength(0);
});
it('should not remove any filters if the filter is not present', () => {
const filters: Filter[] = [controlledPhraseFilter];
// Act
const newFilters = removeFilter(filters, key, 'non-existent-value');
// Assert
expect(newFilters).toHaveLength(1);
});
it('should remove the correct filter from combined filter and return phrase filter', () => {
// Arrange
const combinedFilter: CombinedFilter = buildCombinedFilterMock(
[
controlledPhraseFilter,
buildFilterMock(key, 'another-value', CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER),
],
CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER
);
const filters: Filter[] = [combinedFilter];
// Act
const newFilters = removeFilter(filters, key, value);
// Assert
expect(newFilters).toHaveLength(1);
expect(isCombinedFilter(newFilters[0])).toBe(false);
expect(newFilters[0].meta.key).toBe(key);
expect((newFilters[0].meta.params as PhraseFilterMetaParams).query).toBe('another-value');
});
it('should remove the correct filter from the a combined filter that contains more than 2 filters', () => {
// Arrange
const filters: Filter[] = [
buildCombinedFilterMock(
[
buildFilterMock(key, 'another-value', CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER),
buildFilterMock(key, 'another-value1', CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER),
],
CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER
),
buildCombinedFilterMock([
buildFilterMock(key, value),
buildFilterMock(key, 'another-value'),
buildFilterMock(key, 'another-value2'),
]),
];
// Act
const newFilters = removeFilter(filters, key, value);
// Assert
expect(newFilters).toHaveLength(2);
expect(isCombinedFilter(newFilters[1])).toBe(true);
expect(newFilters[1].meta.params).toHaveLength(2);
});
});
});

View file

@ -0,0 +1,169 @@
/*
* 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 {
BooleanRelation,
FilterStateStore,
isCombinedFilter,
buildCombinedFilter,
isFilter,
} from '@kbn/es-query';
import type { Filter, PhraseFilter } from '@kbn/es-query';
import type {
CombinedFilter,
PhraseFilterMetaParams,
} from '@kbn/es-query/src/filters/build_filters';
export const CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER = 'graph-investigation';
const buildPhraseFilter = (field: string, value: string, dataViewId?: string): PhraseFilter => ({
meta: {
key: field,
index: dataViewId,
negate: false,
disabled: false,
type: 'phrase',
field,
controlledBy: CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER,
params: {
query: value,
},
},
query: {
match_phrase: {
[field]: value,
},
},
});
const filterHasKeyAndValue = (filter: Filter, key: string, value: string): boolean => {
if (isCombinedFilter(filter)) {
return filter.meta.params.some((param) => filterHasKeyAndValue(param, key, value));
}
return filter.meta.key === key && (filter.meta.params as PhraseFilterMetaParams)?.query === value;
};
/**
* Determines whether the provided filters contain a filter with the provided key and value.
*
* @param filters - The list of filters to check.
* @param key - The key to check for.
* @param value - The value to check for.
* @returns true if the filters do contain the filter, false if they don't.
*/
export const containsFilter = (filters: Filter[], key: string, value: string): boolean => {
return filters
.filter((filter) => !filter.meta.disabled)
.some((filter) => filterHasKeyAndValue(filter, key, value));
};
/**
* Adds a filter to the existing list of filters based on the provided key and value.
* It will always use the first filter in the list to build a combined filter with the new filter.
*
* @param dataViewId - The ID of the data view to which the filter belongs.
* @param prev - The previous list of filters.
* @param key - The key for the filter.
* @param value - The value for the filter.
* @returns A new list of filters with the added filter.
*/
export const addFilter = (dataViewId: string, prev: Filter[], key: string, value: string) => {
const [firstFilter, ...otherFilters] = prev;
if (
isCombinedFilter(firstFilter) &&
!firstFilter?.meta?.disabled &&
firstFilter?.meta?.relation === BooleanRelation.OR
) {
return [
{
...firstFilter,
meta: {
...firstFilter.meta,
controlledBy: CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER,
params: [
...(Array.isArray(firstFilter.meta.params) ? firstFilter.meta.params : []),
buildPhraseFilter(key, value),
],
},
},
...otherFilters,
];
} else if (
isFilter(firstFilter) &&
!firstFilter?.meta?.disabled &&
firstFilter.meta?.type !== 'custom'
) {
const combinedFilter = buildCombinedFilter(
BooleanRelation.OR,
[firstFilter, buildPhraseFilter(key, value, dataViewId)],
{
id: dataViewId,
}
);
return [
{
...combinedFilter,
meta: {
...combinedFilter.meta,
controlledBy: CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER,
},
},
...otherFilters,
];
} else {
// When the first filter is disabled or a custom filter, we just add the new filter to the list.
return [
{
$state: {
store: FilterStateStore.APP_STATE,
},
...buildPhraseFilter(key, value, dataViewId),
},
...prev,
];
}
};
const removeFilterFromCombinedFilter = (filter: CombinedFilter, key: string, value: string) => {
const newCombinedFilter = {
...filter,
meta: {
...filter.meta,
params: filter.meta.params.filter(
(param: Filter) => !filterHasKeyAndValue(param, key, value)
),
},
};
if (newCombinedFilter.meta.params.length === 1) {
return newCombinedFilter.meta.params[0];
} else if (newCombinedFilter.meta.params.length === 0) {
return null;
} else {
return newCombinedFilter;
}
};
export const removeFilter = (filters: Filter[], key: string, value: string) => {
const matchedFilter = filters.filter((filter) => filterHasKeyAndValue(filter, key, value));
if (matchedFilter.length > 0 && isCombinedFilter(matchedFilter[0])) {
const newCombinedFilter = removeFilterFromCombinedFilter(matchedFilter[0], key, value);
if (!newCombinedFilter) {
return filters.filter((filter) => filter !== matchedFilter[0]);
}
return filters.map((filter) => (filter === matchedFilter[0] ? newCombinedFilter : filter));
} else if (matchedFilter.length > 0) {
return filters.filter((filter) => filter !== matchedFilter[0]);
}
return filters;
};

View file

@ -0,0 +1,53 @@
/*
* 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 styled from '@emotion/styled';
import { euiCanAnimate, useEuiTheme } from '@elastic/eui';
export const useBorder = () => {
const { euiTheme } = useEuiTheme();
return `1px solid ${euiTheme.colors.borderBasePlain}`;
};
export const AnimatedSearchBarContainer = styled.div`
display: grid;
grid-template-rows: 1fr;
border-top: ${() => useBorder()};
padding: 16px 8px;
&.toggled-off {
padding: 0;
border-top: none;
transform: translateY(-100%);
grid-template-rows: 0fr;
}
${euiCanAnimate} {
${() => {
const { euiTheme } = useEuiTheme();
return `transition: transform ${euiTheme.animation.normal} ease,
grid-template-rows ${euiTheme.animation.normal} ease;
`;
}}
}
& > div {
overflow: hidden;
padding: 0;
${euiCanAnimate} {
${() => {
const { euiTheme } = useEuiTheme();
return `transition: padding ${euiTheme.animation.normal} ease;`;
}}
}
}
&.toggled-off > div {
padding: 0;
}
`;

View file

@ -0,0 +1,172 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback } from 'react';
import { Filter } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { useNodeExpandGraphPopover } from './use_node_expand_graph_popover';
import type { NodeProps } from '../../..';
import {
GRAPH_NODE_EXPAND_POPOVER_TEST_ID,
GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID,
GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_ITEM_ID,
GRAPH_NODE_POPOVER_SHOW_RELATED_ITEM_ID,
} from '../test_ids';
import {
ItemExpandPopoverListItemProps,
SeparatorExpandPopoverListItemProps,
} from './list_group_graph_popover';
import { ACTOR_ENTITY_ID, RELATED_ENTITY, TARGET_ENTITY_ID } from '../../common/constants';
import { addFilter, containsFilter, removeFilter } from './search_filters';
type NodeToggleAction = 'show' | 'hide';
/**
* Hook to handle the entity node expand popover.
* This hook is used to show the popover when the user clicks on the expand button of an entity node.
* The popover contains the actions to show/hide the actions by entity, actions on entity, and related entities.
*
* @param setSearchFilters - Function to set the search filters.
* @param dataViewId - The data view id.
* @param searchFilters - The search filters.
* @returns The entity node expand popover.
*/
export const useEntityNodeExpandPopover = (
setSearchFilters: React.Dispatch<React.SetStateAction<Filter[]>>,
dataViewId: string,
searchFilters: Filter[]
) => {
const onToggleExploreRelatedEntitiesClick = useCallback(
(node: NodeProps, action: NodeToggleAction) => {
if (action === 'show') {
setSearchFilters((prev) => addFilter(dataViewId, prev, RELATED_ENTITY, node.id));
} else if (action === 'hide') {
setSearchFilters((prev) => removeFilter(prev, RELATED_ENTITY, node.id));
}
},
[dataViewId, setSearchFilters]
);
const onToggleActionsByEntityClick = useCallback(
(node: NodeProps, action: NodeToggleAction) => {
if (action === 'show') {
setSearchFilters((prev) => addFilter(dataViewId, prev, ACTOR_ENTITY_ID, node.id));
} else if (action === 'hide') {
setSearchFilters((prev) => removeFilter(prev, ACTOR_ENTITY_ID, node.id));
}
},
[dataViewId, setSearchFilters]
);
const onToggleActionsOnEntityClick = useCallback(
(node: NodeProps, action: NodeToggleAction) => {
if (action === 'show') {
setSearchFilters((prev) => addFilter(dataViewId, prev, TARGET_ENTITY_ID, node.id));
} else if (action === 'hide') {
setSearchFilters((prev) => removeFilter(prev, TARGET_ENTITY_ID, node.id));
}
},
[dataViewId, setSearchFilters]
);
const itemsFn = useCallback(
(
node: NodeProps
): Array<ItemExpandPopoverListItemProps | SeparatorExpandPopoverListItemProps> => {
const actionsByEntityAction = containsFilter(searchFilters, ACTOR_ENTITY_ID, node.id)
? 'hide'
: 'show';
const actionsOnEntityAction = containsFilter(searchFilters, TARGET_ENTITY_ID, node.id)
? 'hide'
: 'show';
const relatedEntitiesAction = containsFilter(searchFilters, RELATED_ENTITY, node.id)
? 'hide'
: 'show';
return [
{
type: 'item',
iconType: 'users',
testSubject: GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID,
label:
actionsByEntityAction === 'show'
? i18n.translate(
'securitySolutionPackages.csp.graph.graphNodeExpandPopover.showActionsByEntity',
{
defaultMessage: 'Show actions by this entity',
}
)
: i18n.translate(
'securitySolutionPackages.csp.graph.graphNodeExpandPopover.hideActionsByEntity',
{
defaultMessage: 'Hide actions by this entity',
}
),
onClick: () => {
onToggleActionsByEntityClick(node, actionsByEntityAction);
},
},
{
type: 'item',
iconType: 'storage',
testSubject: GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_ITEM_ID,
label:
actionsOnEntityAction === 'show'
? i18n.translate(
'securitySolutionPackages.csp.graph.graphNodeExpandPopover.showActionsOnEntity',
{
defaultMessage: 'Show actions on this entity',
}
)
: i18n.translate(
'securitySolutionPackages.csp.graph.graphNodeExpandPopover.hideActionsOnEntity',
{
defaultMessage: 'Hide actions on this entity',
}
),
onClick: () => {
onToggleActionsOnEntityClick(node, actionsOnEntityAction);
},
},
{
type: 'item',
iconType: 'visTagCloud',
testSubject: GRAPH_NODE_POPOVER_SHOW_RELATED_ITEM_ID,
label:
relatedEntitiesAction === 'show'
? i18n.translate(
'securitySolutionPackages.csp.graph.graphNodeExpandPopover.showRelatedEntities',
{
defaultMessage: 'Show related entities',
}
)
: i18n.translate(
'securitySolutionPackages.csp.graph.graphNodeExpandPopover.hideRelatedEntities',
{
defaultMessage: 'Hide related entities',
}
),
onClick: () => {
onToggleExploreRelatedEntitiesClick(node, relatedEntitiesAction);
},
},
];
},
[
onToggleActionsByEntityClick,
onToggleActionsOnEntityClick,
onToggleExploreRelatedEntitiesClick,
searchFilters,
]
);
const entityNodeExpandPopover = useNodeExpandGraphPopover({
id: 'entity-node-expand-popover',
itemsFn,
testSubject: GRAPH_NODE_EXPAND_POPOVER_TEST_ID,
});
return entityNodeExpandPopover;
};

View file

@ -1,99 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useGraphPopover } from '../../..';
import type { ExpandButtonClickCallback, NodeProps } from '../types';
import type { PopoverActions } from '../graph/use_graph_popover';
import { GraphLabelExpandPopover } from './graph_label_expand_popover';
interface UseGraphLabelExpandPopoverArgs {
onShowEventsWithThisActionClick: (node: NodeProps) => void;
}
export const useGraphLabelExpandPopover = ({
onShowEventsWithThisActionClick,
}: UseGraphLabelExpandPopoverArgs) => {
const { id, state, actions } = useGraphPopover('label-expand-popover');
const { openPopover, closePopover } = actions;
const selectedNode = useRef<NodeProps | null>(null);
const unToggleCallbackRef = useRef<(() => void) | null>(null);
const [pendingOpen, setPendingOpen] = useState<{
node: NodeProps;
el: HTMLElement;
unToggleCallback: () => void;
} | null>(null);
const closePopoverHandler = useCallback(() => {
selectedNode.current = null;
unToggleCallbackRef.current?.();
unToggleCallbackRef.current = null;
closePopover();
}, [closePopover]);
const onLabelExpandButtonClick: ExpandButtonClickCallback = useCallback(
(e, node, unToggleCallback) => {
const lastExpandedNode = selectedNode.current?.id;
// Close the current popover if open
closePopoverHandler();
if (lastExpandedNode !== node.id) {
// Set the pending open state
setPendingOpen({ node, el: e.currentTarget, unToggleCallback });
}
},
[closePopoverHandler]
);
useEffect(() => {
// Open pending popover if the popover is not open
if (!state.isOpen && pendingOpen) {
const { node, el, unToggleCallback } = pendingOpen;
selectedNode.current = node;
unToggleCallbackRef.current = unToggleCallback;
openPopover(el);
setPendingOpen(null);
}
}, [state.isOpen, pendingOpen, openPopover]);
const PopoverComponent = memo(() => (
<GraphLabelExpandPopover
isOpen={state.isOpen}
anchorElement={state.anchorElement}
closePopover={closePopoverHandler}
onShowEventsWithThisActionClick={() => {
onShowEventsWithThisActionClick(selectedNode.current as NodeProps);
closePopoverHandler();
}}
/>
));
PopoverComponent.displayName = GraphLabelExpandPopover.displayName;
const actionsWithClose: PopoverActions = useMemo(
() => ({
...actions,
closePopover: closePopoverHandler,
}),
[actions, closePopoverHandler]
);
return useMemo(
() => ({
onLabelExpandButtonClick,
PopoverComponent,
id,
actions: actionsWithClose,
state,
}),
[PopoverComponent, actionsWithClose, id, onLabelExpandButtonClick, state]
);
};

View file

@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback } from 'react';
import { Filter } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { useNodeExpandGraphPopover } from './use_node_expand_graph_popover';
import type { NodeProps } from '../../..';
import {
GRAPH_LABEL_EXPAND_POPOVER_SHOW_EVENTS_WITH_THIS_ACTION_ITEM_ID,
GRAPH_LABEL_EXPAND_POPOVER_TEST_ID,
} from '../test_ids';
import {
ItemExpandPopoverListItemProps,
SeparatorExpandPopoverListItemProps,
} from './list_group_graph_popover';
import { EVENT_ACTION } from '../../common/constants';
import { addFilter, containsFilter, removeFilter } from './search_filters';
type NodeToggleAction = 'show' | 'hide';
/**
* Hook to handle the label node expand popover.
* This hook is used to show the popover when the user clicks on the expand button of a label node.
* The popover contains the actions to show/hide the events with this action.
*
* @param setSearchFilters - Function to set the search filters.
* @param dataViewId - The data view id.
* @param searchFilters - The search filters.
* @returns The label node expand popover.
*/
export const useLabelNodeExpandPopover = (
setSearchFilters: React.Dispatch<React.SetStateAction<Filter[]>>,
dataViewId: string,
searchFilters: Filter[]
) => {
const onShowEventsWithThisActionClick = useCallback(
(node: NodeProps, action: NodeToggleAction) => {
if (action === 'show') {
setSearchFilters((prev) =>
addFilter(dataViewId, prev, EVENT_ACTION, node.data.label ?? '')
);
} else if (action === 'hide') {
setSearchFilters((prev) => removeFilter(prev, EVENT_ACTION, node.data.label ?? ''));
}
},
[dataViewId, setSearchFilters]
);
const itemsFn = useCallback(
(
node: NodeProps
): Array<ItemExpandPopoverListItemProps | SeparatorExpandPopoverListItemProps> => {
const eventsWithThisActionToggleAction = containsFilter(
searchFilters,
EVENT_ACTION,
node.data.label ?? ''
)
? 'hide'
: 'show';
return [
{
type: 'item',
iconType: 'users',
testSubject: GRAPH_LABEL_EXPAND_POPOVER_SHOW_EVENTS_WITH_THIS_ACTION_ITEM_ID,
label:
eventsWithThisActionToggleAction === 'show'
? i18n.translate(
'securitySolutionPackages.csp.graph.graphLabelExpandPopover.showEventsWithThisAction',
{ defaultMessage: 'Show events with this action' }
)
: i18n.translate(
'securitySolutionPackages.csp.graph.graphLabelExpandPopover.hideEventsWithThisAction',
{ defaultMessage: 'Hide events with this action' }
),
onClick: () => {
onShowEventsWithThisActionClick(node, eventsWithThisActionToggleAction);
},
},
];
},
[onShowEventsWithThisActionClick, searchFilters]
);
const labelNodeExpandPopover = useNodeExpandGraphPopover({
id: 'label-node-expand-popover',
testSubject: GRAPH_LABEL_EXPAND_POPOVER_TEST_ID,
itemsFn,
});
return labelNodeExpandPopover;
};

View file

@ -8,20 +8,66 @@
import React, { memo, useCallback, useRef, useState } from 'react';
import { useGraphPopover } from '../../..';
import type { ExpandButtonClickCallback, NodeProps } from '../types';
import { GraphNodeExpandPopover } from './graph_node_expand_popover';
import {
ListGroupGraphPopover,
type ItemExpandPopoverListItemProps,
type SeparatorExpandPopoverListItemProps,
} from './list_group_graph_popover';
import type { PopoverActions, PopoverState } from '../graph/use_graph_popover';
interface UseGraphNodeExpandPopoverArgs {
onExploreRelatedEntitiesClick: (node: NodeProps) => void;
onShowActionsByEntityClick: (node: NodeProps) => void;
onShowActionsOnEntityClick: (node: NodeProps) => void;
interface UseNodeExpandGraphPopoverArgs {
/**
* The ID of the popover.
*/
id: string;
/**
* The data-test-subj value for the popover.
*/
testSubject: string;
/**
* Function to get the list of items to display in the popover.
* This function is called each time the popover is opened.
*/
itemsFn?: (
node: NodeProps
) => Array<ItemExpandPopoverListItemProps | SeparatorExpandPopoverListItemProps>;
}
export const useGraphNodeExpandPopover = ({
onExploreRelatedEntitiesClick,
onShowActionsByEntityClick,
onShowActionsOnEntityClick,
}: UseGraphNodeExpandPopoverArgs) => {
const { id, state, actions } = useGraphPopover('node-expand-popover');
export interface UseNodeExpandGraphPopoverReturn {
/**
* The ID of the popover.
*/
id: string;
/**
* Handler to open the popover when the node expand button is clicked.
*/
onNodeExpandButtonClick: ExpandButtonClickCallback;
/**
* The component that renders the popover.
*/
PopoverComponent: React.FC;
/**
* The popover actions and state.
*/
actions: PopoverActions;
/**
* The popover state.
*/
state: PopoverState;
}
export const useNodeExpandGraphPopover = ({
id,
testSubject,
itemsFn,
}: UseNodeExpandGraphPopoverArgs): UseNodeExpandGraphPopoverReturn => {
const { state, actions } = useGraphPopover(id);
const { openPopover, closePopover } = actions;
const selectedNode = useRef<NodeProps | null>(null);
@ -60,27 +106,40 @@ export const useGraphNodeExpandPopover = ({
[closePopoverHandler]
);
// Wrap the items function to add the onClick handler to the items and close the popover
const itemsFnWrapper = useCallback(() => {
const node = selectedNode.current;
if (!node) {
return [];
}
const items = itemsFn?.(node) || [];
return items.map((item) => {
if (item.type === 'item') {
return {
...item,
onClick: () => {
item.onClick();
closePopoverHandler();
},
};
}
return item;
});
}, [closePopoverHandler, itemsFn]);
// PopoverComponent is a memoized component that renders the GraphNodeExpandPopover
// It handles the display of the popover and the actions that can be performed on the node
// eslint-disable-next-line react/display-name
const PopoverComponent = memo(() => (
<GraphNodeExpandPopover
<ListGroupGraphPopover
isOpen={state.isOpen}
anchorElement={state.anchorElement}
closePopover={closePopoverHandler}
onShowRelatedEntitiesClick={() => {
onExploreRelatedEntitiesClick(selectedNode.current as NodeProps);
closePopoverHandler();
}}
onShowActionsByEntityClick={() => {
onShowActionsByEntityClick(selectedNode.current as NodeProps);
closePopoverHandler();
}}
onShowActionsOnEntityClick={() => {
onShowActionsOnEntityClick(selectedNode.current as NodeProps);
closePopoverHandler();
}}
itemsFn={itemsFnWrapper}
testSubject={testSubject}
/>
));
@ -99,9 +158,9 @@ export const useGraphNodeExpandPopover = ({
}
return {
id,
onNodeExpandButtonClick,
PopoverComponent,
id,
actions: {
...actions,
closePopover: closePopoverHandler,

View file

@ -0,0 +1,49 @@
/*
* 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, { createContext, useContext, useState } from 'react';
interface MockData {
useFetchGraphDataMock?: {
log?: (...args: unknown[]) => void;
isFetching?: boolean;
refresh?: () => void;
};
}
const MockDataContext = createContext<{
data: MockData;
setData: (newData: MockData) => void;
} | null>(null);
interface MockDataProviderProps {
data?: MockData;
children: React.ReactNode;
}
export const MockDataProvider = ({ children, data = {} }: MockDataProviderProps) => {
const [mockData, setMockData] = useState<MockData>(data);
// Synchronize data prop with state
React.useEffect(() => {
setMockData({ ...data });
}, [data]);
return (
<MockDataContext.Provider value={{ data: mockData, setData: setMockData }}>
{children}
</MockDataContext.Provider>
);
};
export const useMockDataContext = () => {
const context = useContext(MockDataContext);
if (!context) {
throw new Error('useMockDataContext must be used within a MockDataProvider');
}
return context;
};

View file

@ -6,17 +6,19 @@
*/
import { useMemo } from 'react';
import { action } from '@storybook/addon-actions';
import type { UseFetchGraphDataParams } from '../../hooks/use_fetch_graph_data';
import { USE_FETCH_GRAPH_DATA_ACTION, USE_FETCH_GRAPH_DATA_REFRESH_ACTION } from './constants';
import { useMockDataContext } from './mock_context_provider';
export const useFetchGraphData = (params: UseFetchGraphDataParams) => {
action(USE_FETCH_GRAPH_DATA_ACTION)(JSON.stringify(params));
const {
data: { useFetchGraphDataMock },
} = useMockDataContext();
useFetchGraphDataMock?.log?.(JSON.stringify(params));
return useMemo(
() => ({
isLoading: false,
isFetching: false,
isFetching: useFetchGraphDataMock?.isFetching ?? false,
isError: false,
data: {
nodes: [
@ -64,8 +66,8 @@ export const useFetchGraphData = (params: UseFetchGraphDataParams) => {
},
],
},
refresh: action(USE_FETCH_GRAPH_DATA_REFRESH_ACTION),
refresh: useFetchGraphDataMock?.refresh ?? (() => {}),
}),
[]
[useFetchGraphDataMock]
);
};

View file

@ -8,6 +8,7 @@
import React, { useCallback, useState } from 'react';
import { StyledNodeExpandButton, RoundEuiButtonIcon, ExpandButtonSize } from './styles';
import type { EntityNodeViewModel, LabelNodeViewModel } from '..';
import { NODE_EXPAND_BUTTON_TEST_ID } from '../test_ids';
export interface NodeExpandButtonProps {
x?: string;
@ -37,7 +38,7 @@ export const NodeExpandButton = ({ x, y, color, onClick }: NodeExpandButtonProps
onClick={onClickHandler}
iconSize="m"
aria-label="Open or close node actions"
data-test-subj="nodeExpandButton"
data-test-subj={NODE_EXPAND_BUTTON_TEST_ID}
/>
</StyledNodeExpandButton>
);

View file

@ -30,3 +30,5 @@ export const GRAPH_CONTROLS_ZOOM_IN_ID = `${GRAPH_INVESTIGATION_TEST_ID}ZoomIn`
export const GRAPH_CONTROLS_ZOOM_OUT_ID = `${GRAPH_INVESTIGATION_TEST_ID}ZoomOut` as const;
export const GRAPH_CONTROLS_CENTER_ID = `${GRAPH_INVESTIGATION_TEST_ID}Center` as const;
export const GRAPH_CONTROLS_FIT_VIEW_ID = `${GRAPH_INVESTIGATION_TEST_ID}FitView` as const;
export const NODE_EXPAND_BUTTON_TEST_ID = `${PREFIX}NodeExpandButton` as const;

View file

@ -98,6 +98,7 @@ export const GraphVisualization: React.FC = memo(() => {
},
}}
showInvestigateInTimeline={true}
showToggleSearch={true}
onInvestigateInTimeline={openTimelineCallback}
/>
</React.Suspense>

View file

@ -12,7 +12,7 @@ import { FtrService } from '../../functional/ftr_provider_context';
import type { QueryBarProvider } from '../services/query_bar_provider';
const GRAPH_PREVIEW_TITLE_LINK_TEST_ID = 'securitySolutionFlyoutGraphPreviewTitleLink';
const NODE_EXPAND_BUTTON_TEST_ID = 'nodeExpandButton';
const NODE_EXPAND_BUTTON_TEST_ID = 'cloudSecurityGraphNodeExpandButton';
const GRAPH_INVESTIGATION_TEST_ID = 'cloudSecurityGraphGraphInvestigation';
const GRAPH_NODE_EXPAND_POPOVER_TEST_ID = `${GRAPH_INVESTIGATION_TEST_ID}GraphNodeExpandPopover`;
const GRAPH_NODE_POPOVER_EXPLORE_RELATED_TEST_ID = `${GRAPH_INVESTIGATION_TEST_ID}ExploreRelatedEntities`;
@ -20,6 +20,7 @@ const GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_TEST_ID = `${GRAPH_INVESTIGATION_TEST_I
const GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_TEST_ID = `${GRAPH_INVESTIGATION_TEST_ID}ShowActionsOnEntity`;
const GRAPH_LABEL_EXPAND_POPOVER_TEST_ID = `${GRAPH_INVESTIGATION_TEST_ID}GraphLabelExpandPopover`;
const GRAPH_LABEL_EXPAND_POPOVER_SHOW_EVENTS_WITH_THIS_ACTION_ITEM_ID = `${GRAPH_INVESTIGATION_TEST_ID}ShowEventsWithThisAction`;
const GRAPH_ACTIONS_TOGGLE_SEARCH_ID = `${GRAPH_INVESTIGATION_TEST_ID}ToggleSearch`;
const GRAPH_ACTIONS_INVESTIGATE_IN_TIMELINE_ID = `${GRAPH_INVESTIGATION_TEST_ID}InvestigateInTimeline`;
type Filter = Parameters<FilterBarService['addFilter']>[0];
@ -44,6 +45,10 @@ export class ExpandedFlyoutGraph extends FtrService {
expect(nodes.length).to.be(expected);
}
async toggleSearchBar(): Promise<void> {
await this.testSubjects.click(GRAPH_ACTIONS_TOGGLE_SEARCH_ID);
}
async selectNode(nodeId: string): Promise<WebElementWrapper> {
await this.waitGraphIsLoaded();
const graph = await this.testSubjects.find(GRAPH_INVESTIGATION_TEST_ID);
@ -78,6 +83,16 @@ export class ExpandedFlyoutGraph extends FtrService {
await this.pageObjects.header.waitUntilLoadingHasFinished();
}
async hideActionsOnEntity(nodeId: string): Promise<void> {
await this.clickOnNodeExpandButton(nodeId);
const btnText = await this.testSubjects.getVisibleText(
GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_TEST_ID
);
expect(btnText).to.be('Hide actions on this entity');
await this.testSubjects.click(GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_TEST_ID);
await this.pageObjects.header.waitUntilLoadingHasFinished();
}
async exploreRelatedEntities(nodeId: string): Promise<void> {
await this.clickOnNodeExpandButton(nodeId);
await this.testSubjects.click(GRAPH_NODE_POPOVER_EXPLORE_RELATED_TEST_ID);
@ -90,6 +105,16 @@ export class ExpandedFlyoutGraph extends FtrService {
await this.pageObjects.header.waitUntilLoadingHasFinished();
}
async hideEventsOfSameAction(nodeId: string): Promise<void> {
await this.clickOnNodeExpandButton(nodeId, GRAPH_LABEL_EXPAND_POPOVER_TEST_ID);
const btnText = await this.testSubjects.getVisibleText(
GRAPH_LABEL_EXPAND_POPOVER_SHOW_EVENTS_WITH_THIS_ACTION_ITEM_ID
);
expect(btnText).to.be('Hide events with this action');
await this.testSubjects.click(GRAPH_LABEL_EXPAND_POPOVER_SHOW_EVENTS_WITH_THIS_ACTION_ITEM_ID);
await this.pageObjects.header.waitUntilLoadingHasFinished();
}
async expectFilterTextEquals(filterIdx: number, expected: string): Promise<void> {
const filters = await this.filterBar.getFiltersLabel();
expect(filters.length).to.be.greaterThan(filterIdx);

View file

@ -69,6 +69,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await expandedFlyoutGraph.expandGraph();
await expandedFlyoutGraph.waitGraphIsLoaded();
await expandedFlyoutGraph.assertGraphNodesNumber(3);
await expandedFlyoutGraph.toggleSearchBar();
// Show actions by entity
await expandedFlyoutGraph.showActionsByEntity('admin@example.com');
@ -110,6 +111,30 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com OR event.action: google.iam.admin.v1.CreateRole'
);
// Hide events with the same action
await expandedFlyoutGraph.hideEventsOfSameAction(
'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)outcome(success)'
);
await expandedFlyoutGraph.expectFilterTextEquals(
0,
'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com'
);
await expandedFlyoutGraph.expectFilterPreviewEquals(
0,
'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com'
);
// Hide actions on entity
await expandedFlyoutGraph.hideActionsOnEntity('admin@example.com');
await expandedFlyoutGraph.expectFilterTextEquals(
0,
'actor.entity.id: admin@example.com OR related.entity: admin@example.com'
);
await expandedFlyoutGraph.expectFilterPreviewEquals(
0,
'actor.entity.id: admin@example.com OR related.entity: admin@example.com'
);
// Clear filters
await expandedFlyoutGraph.clearAllFilters();

View file

@ -61,6 +61,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await expandedFlyoutGraph.expandGraph();
await expandedFlyoutGraph.waitGraphIsLoaded();
await expandedFlyoutGraph.assertGraphNodesNumber(3);
await expandedFlyoutGraph.toggleSearchBar();
// Show actions by entity
await expandedFlyoutGraph.showActionsByEntity('admin@example.com');
@ -102,6 +103,30 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com OR event.action: google.iam.admin.v1.CreateRole'
);
// Hide events with the same action
await expandedFlyoutGraph.hideEventsOfSameAction(
'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)outcome(success)'
);
await expandedFlyoutGraph.expectFilterTextEquals(
0,
'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com'
);
await expandedFlyoutGraph.expectFilterPreviewEquals(
0,
'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com'
);
// Hide actions on entity
await expandedFlyoutGraph.hideActionsOnEntity('admin@example.com');
await expandedFlyoutGraph.expectFilterTextEquals(
0,
'actor.entity.id: admin@example.com OR related.entity: admin@example.com'
);
await expandedFlyoutGraph.expectFilterPreviewEquals(
0,
'actor.entity.id: admin@example.com OR related.entity: admin@example.com'
);
// Clear filters
await expandedFlyoutGraph.clearAllFilters();