mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Cloud Security] Added search bar toggle button (#206123)
This commit is contained in:
parent
bfcffa1e76
commit
6fe4e70a66
25 changed files with 1804 additions and 580 deletions
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
}),
|
||||
},
|
||||
},
|
||||
]);
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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';
|
|
@ -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';
|
|
@ -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';
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
`;
|
|
@ -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;
|
||||
};
|
|
@ -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]
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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,
|
|
@ -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;
|
||||
};
|
|
@ -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]
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -98,6 +98,7 @@ export const GraphVisualization: React.FC = memo(() => {
|
|||
},
|
||||
}}
|
||||
showInvestigateInTimeline={true}
|
||||
showToggleSearch={true}
|
||||
onInvestigateInTimeline={openTimelineCallback}
|
||||
/>
|
||||
</React.Suspense>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue