mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Security Solution] Alerts grouping, fix redux misuse (#156924)
This commit is contained in:
parent
28b4ec1490
commit
99d948c771
15 changed files with 754 additions and 362 deletions
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
import { act, renderHook } from '@testing-library/react-hooks';
|
||||
|
||||
import { useGetGroupSelector } from './use_get_group_selector';
|
||||
import { useGetGroupSelector, useGetGroupSelectorStateless } from './use_get_group_selector';
|
||||
import { initialState } from './state';
|
||||
import { ActionType, defaultGroup } from '..';
|
||||
import { METRIC_TYPE } from '@kbn/analytics';
|
||||
|
@ -20,255 +20,302 @@ const defaultGroupingOptions = [
|
|||
];
|
||||
const groupingId = 'test-table';
|
||||
const dispatch = jest.fn();
|
||||
const defaultArgs = {
|
||||
const onGroupChange = jest.fn();
|
||||
const statelessArgs = {
|
||||
defaultGroupingOptions,
|
||||
dispatch,
|
||||
fields: [],
|
||||
groupingId,
|
||||
fields: [],
|
||||
onGroupChange,
|
||||
maxGroupingLevels: 3,
|
||||
};
|
||||
const defaultArgs = {
|
||||
...statelessArgs,
|
||||
dispatch,
|
||||
groupingState: initialState,
|
||||
tracker: jest.fn(),
|
||||
onGroupChange: jest.fn(),
|
||||
};
|
||||
const customField = 'custom.field';
|
||||
describe('useGetGroupSelector', () => {
|
||||
|
||||
describe('Group Selector Hooks', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('Initializes a group with options', () => {
|
||||
renderHook(() => useGetGroupSelector(defaultArgs));
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: {
|
||||
id: groupingId,
|
||||
newOptionList: defaultGroupingOptions,
|
||||
},
|
||||
type: ActionType.updateGroupOptions,
|
||||
describe('useGetGroupSelector', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
it('Initializes a group with custom selected group', () => {
|
||||
renderHook(() =>
|
||||
useGetGroupSelector({
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: { [groupingId]: { ...defaultGroup, activeGroups: [customField] } },
|
||||
it('Initializes a group with options', () => {
|
||||
renderHook(() => useGetGroupSelector(defaultArgs));
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: {
|
||||
id: groupingId,
|
||||
newOptionList: defaultGroupingOptions,
|
||||
},
|
||||
})
|
||||
);
|
||||
type: ActionType.updateGroupOptions,
|
||||
});
|
||||
});
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: {
|
||||
id: groupingId,
|
||||
newOptionList: [
|
||||
...defaultGroupingOptions,
|
||||
{
|
||||
key: customField,
|
||||
label: customField,
|
||||
it('Initializes a group with custom selected group', () => {
|
||||
renderHook(() =>
|
||||
useGetGroupSelector({
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: { [groupingId]: { ...defaultGroup, activeGroups: [customField] } },
|
||||
},
|
||||
],
|
||||
},
|
||||
type: ActionType.updateGroupOptions,
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
it('On group change, removes selected group if already selected', () => {
|
||||
const testGroup = {
|
||||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroups: ['host.name'],
|
||||
},
|
||||
};
|
||||
const { result } = renderHook((props) => useGetGroupSelector(props), {
|
||||
initialProps: {
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: testGroup,
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: {
|
||||
id: groupingId,
|
||||
newOptionList: [
|
||||
...defaultGroupingOptions,
|
||||
{
|
||||
key: customField,
|
||||
label: customField,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
type: ActionType.updateGroupOptions,
|
||||
});
|
||||
});
|
||||
act(() => result.current.props.onGroupChange('host.name'));
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: {
|
||||
id: groupingId,
|
||||
activeGroups: ['none'],
|
||||
},
|
||||
type: ActionType.updateActiveGroups,
|
||||
});
|
||||
});
|
||||
it('Passes custom options to the onOptionsChange callback when it is provided', () => {
|
||||
const onOptionsChange = jest.fn();
|
||||
renderHook(() =>
|
||||
useGetGroupSelector({
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: { [groupingId]: { ...defaultGroup, activeGroups: [customField] } },
|
||||
},
|
||||
onOptionsChange,
|
||||
})
|
||||
);
|
||||
|
||||
it('On group change to none, remove all previously selected groups', () => {
|
||||
const testGroup = {
|
||||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroups: ['host.name', 'user.name'],
|
||||
},
|
||||
};
|
||||
const { result } = renderHook((props) => useGetGroupSelector(props), {
|
||||
initialProps: {
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: testGroup,
|
||||
expect(onOptionsChange).toHaveBeenCalledWith([
|
||||
...defaultGroupingOptions,
|
||||
{
|
||||
key: customField,
|
||||
label: customField,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
act(() => result.current.props.onGroupChange('none'));
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: {
|
||||
id: groupingId,
|
||||
activeGroups: ['none'],
|
||||
},
|
||||
type: ActionType.updateActiveGroups,
|
||||
});
|
||||
});
|
||||
|
||||
it('On group change, resets active page, sets active group, and leaves options alone', () => {
|
||||
const testGroup = {
|
||||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroups: ['host.name'],
|
||||
},
|
||||
};
|
||||
const { result } = renderHook((props) => useGetGroupSelector(props), {
|
||||
initialProps: {
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: testGroup,
|
||||
it('On group change, removes selected group if already selected', () => {
|
||||
const testGroup = {
|
||||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroups: ['host.name'],
|
||||
},
|
||||
},
|
||||
});
|
||||
act(() => result.current.props.onGroupChange('user.name'));
|
||||
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, {
|
||||
payload: {
|
||||
id: groupingId,
|
||||
activeGroups: ['host.name', 'user.name'],
|
||||
},
|
||||
type: ActionType.updateActiveGroups,
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('On group change, sends telemetry', () => {
|
||||
const testGroup = {
|
||||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroups: ['host.name'],
|
||||
},
|
||||
};
|
||||
const { result } = renderHook((props) => useGetGroupSelector(props), {
|
||||
initialProps: {
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: testGroup,
|
||||
},
|
||||
},
|
||||
});
|
||||
act(() => result.current.props.onGroupChange(customField));
|
||||
expect(defaultArgs.tracker).toHaveBeenCalledTimes(1);
|
||||
expect(defaultArgs.tracker).toHaveBeenCalledWith(
|
||||
METRIC_TYPE.CLICK,
|
||||
`alerts_table_group_by_test-table_${customField}`
|
||||
);
|
||||
});
|
||||
|
||||
it('On group change, executes callback', () => {
|
||||
const testGroup = {
|
||||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroups: ['host.name'],
|
||||
},
|
||||
};
|
||||
const { result } = renderHook((props) => useGetGroupSelector(props), {
|
||||
initialProps: {
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: testGroup,
|
||||
},
|
||||
},
|
||||
});
|
||||
act(() => result.current.props.onGroupChange(customField));
|
||||
expect(defaultArgs.onGroupChange).toHaveBeenCalledTimes(1);
|
||||
expect(defaultArgs.onGroupChange).toHaveBeenCalledWith({
|
||||
tableId: groupingId,
|
||||
groupByField: customField,
|
||||
});
|
||||
});
|
||||
|
||||
it('On group change to custom field, updates options', () => {
|
||||
const testGroup = {
|
||||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroups: ['host.name'],
|
||||
},
|
||||
};
|
||||
const { result, rerender } = renderHook((props) => useGetGroupSelector(props), {
|
||||
initialProps: {
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: testGroup,
|
||||
},
|
||||
},
|
||||
});
|
||||
act(() => result.current.props.onGroupChange(customField));
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
rerender({
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: {
|
||||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroups: ['host.name', customField],
|
||||
};
|
||||
const { result } = renderHook((props) => useGetGroupSelector(props), {
|
||||
initialProps: {
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: testGroup,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
act(() => result.current.props.onGroupChange('host.name'));
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: {
|
||||
id: groupingId,
|
||||
activeGroups: ['none'],
|
||||
},
|
||||
type: ActionType.updateActiveGroups,
|
||||
});
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, {
|
||||
payload: {
|
||||
newOptionList: [...defaultGroupingOptions, { label: customField, key: customField }],
|
||||
id: 'test-table',
|
||||
},
|
||||
type: ActionType.updateGroupOptions,
|
||||
|
||||
it('On group change to none, remove all previously selected groups', () => {
|
||||
const testGroup = {
|
||||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroups: ['host.name', 'user.name'],
|
||||
},
|
||||
};
|
||||
const { result } = renderHook((props) => useGetGroupSelector(props), {
|
||||
initialProps: {
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: testGroup,
|
||||
},
|
||||
},
|
||||
});
|
||||
act(() => result.current.props.onGroupChange('none'));
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: {
|
||||
id: groupingId,
|
||||
activeGroups: ['none'],
|
||||
},
|
||||
type: ActionType.updateActiveGroups,
|
||||
});
|
||||
});
|
||||
|
||||
it('On group change, resets active page, sets active group, and leaves options alone', () => {
|
||||
const testGroup = {
|
||||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroups: ['host.name'],
|
||||
},
|
||||
};
|
||||
const { result } = renderHook((props) => useGetGroupSelector(props), {
|
||||
initialProps: {
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: testGroup,
|
||||
},
|
||||
},
|
||||
});
|
||||
act(() => result.current.props.onGroupChange('user.name'));
|
||||
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, {
|
||||
payload: {
|
||||
id: groupingId,
|
||||
activeGroups: ['host.name', 'user.name'],
|
||||
},
|
||||
type: ActionType.updateActiveGroups,
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('On group change, sends telemetry', () => {
|
||||
const testGroup = {
|
||||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroups: ['host.name'],
|
||||
},
|
||||
};
|
||||
const { result } = renderHook((props) => useGetGroupSelector(props), {
|
||||
initialProps: {
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: testGroup,
|
||||
},
|
||||
},
|
||||
});
|
||||
act(() => result.current.props.onGroupChange(customField));
|
||||
expect(defaultArgs.tracker).toHaveBeenCalledTimes(1);
|
||||
expect(defaultArgs.tracker).toHaveBeenCalledWith(
|
||||
METRIC_TYPE.CLICK,
|
||||
`alerts_table_group_by_test-table_${customField}`
|
||||
);
|
||||
});
|
||||
|
||||
it('On group change, executes callback', () => {
|
||||
const testGroup = {
|
||||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroups: ['host.name'],
|
||||
},
|
||||
};
|
||||
const { result } = renderHook((props) => useGetGroupSelector(props), {
|
||||
initialProps: {
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: testGroup,
|
||||
},
|
||||
},
|
||||
});
|
||||
act(() => result.current.props.onGroupChange(customField));
|
||||
expect(defaultArgs.onGroupChange).toHaveBeenCalledTimes(1);
|
||||
expect(defaultArgs.onGroupChange).toHaveBeenCalledWith({
|
||||
tableId: groupingId,
|
||||
groupByField: customField,
|
||||
});
|
||||
});
|
||||
|
||||
it('On group change to custom field, updates options', () => {
|
||||
const testGroup = {
|
||||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroups: ['host.name'],
|
||||
},
|
||||
};
|
||||
const { result, rerender } = renderHook((props) => useGetGroupSelector(props), {
|
||||
initialProps: {
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: testGroup,
|
||||
},
|
||||
},
|
||||
});
|
||||
act(() => result.current.props.onGroupChange(customField));
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
rerender({
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: {
|
||||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroups: ['host.name', customField],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, {
|
||||
payload: {
|
||||
newOptionList: [...defaultGroupingOptions, { label: customField, key: customField }],
|
||||
id: 'test-table',
|
||||
},
|
||||
type: ActionType.updateGroupOptions,
|
||||
});
|
||||
});
|
||||
|
||||
it('Supports multiple custom fields on initial load', () => {
|
||||
const testGroup = {
|
||||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroups: ['host.name', customField, 'another.custom'],
|
||||
},
|
||||
};
|
||||
renderHook((props) => useGetGroupSelector(props), {
|
||||
initialProps: {
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: testGroup,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: {
|
||||
newOptionList: [
|
||||
...defaultGroupingOptions,
|
||||
{ label: customField, key: customField },
|
||||
{ label: 'another.custom', key: 'another.custom' },
|
||||
],
|
||||
id: 'test-table',
|
||||
},
|
||||
type: ActionType.updateGroupOptions,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Supports multiple custom fields on initial load', () => {
|
||||
const testGroup = {
|
||||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroups: ['host.name', customField, 'another.custom'],
|
||||
},
|
||||
};
|
||||
renderHook((props) => useGetGroupSelector(props), {
|
||||
initialProps: {
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: testGroup,
|
||||
},
|
||||
},
|
||||
describe('useGetGroupSelectorStateless', () => {
|
||||
it('Initializes a group with options', () => {
|
||||
const { result } = renderHook(() => useGetGroupSelectorStateless(statelessArgs));
|
||||
expect(result.current.props.groupingId).toEqual(groupingId);
|
||||
expect(result.current.props.options.length).toEqual(4);
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: {
|
||||
newOptionList: [
|
||||
...defaultGroupingOptions,
|
||||
{ label: customField, key: customField },
|
||||
{ label: 'another.custom', key: 'another.custom' },
|
||||
],
|
||||
id: 'test-table',
|
||||
},
|
||||
type: ActionType.updateGroupOptions,
|
||||
|
||||
it('On group change, removes selected group if already selected', () => {
|
||||
const { result } = renderHook(() => useGetGroupSelectorStateless(statelessArgs));
|
||||
act(() => result.current.props.onGroupChange('host.name'));
|
||||
|
||||
expect(onGroupChange).toHaveBeenCalledWith(['host.name']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -23,6 +23,7 @@ export interface UseGetGroupSelectorArgs {
|
|||
groupingId: string;
|
||||
groupingState: GroupMap;
|
||||
maxGroupingLevels?: number;
|
||||
onOptionsChange?: (newOptions: GroupOption[]) => void;
|
||||
onGroupChange?: (param: { groupByField: string; tableId: string }) => void;
|
||||
tracker?: (
|
||||
type: UiCounterMetricType,
|
||||
|
@ -31,6 +32,48 @@ export interface UseGetGroupSelectorArgs {
|
|||
) => void;
|
||||
}
|
||||
|
||||
interface UseGetGroupSelectorStateless
|
||||
extends Pick<
|
||||
UseGetGroupSelectorArgs,
|
||||
'defaultGroupingOptions' | 'groupingId' | 'fields' | 'maxGroupingLevels'
|
||||
> {
|
||||
onGroupChange: (selectedGroups: string[]) => void;
|
||||
}
|
||||
|
||||
// only use this component to use a group selector that displays when isNoneGroup is true
|
||||
// by selecting a group with the groupSelectorStateless component
|
||||
// the contents are within the grouping component and from that point
|
||||
// the grouping component will handle the group selector. When the group selector is set back to none,
|
||||
// the consumer can again use the groupSelectorStateless component to select a new group
|
||||
export const useGetGroupSelectorStateless = ({
|
||||
defaultGroupingOptions,
|
||||
groupingId,
|
||||
fields,
|
||||
onGroupChange,
|
||||
maxGroupingLevels,
|
||||
}: UseGetGroupSelectorStateless) => {
|
||||
const onChange = useCallback(
|
||||
(groupSelection: string) => {
|
||||
onGroupChange([groupSelection]);
|
||||
},
|
||||
[onGroupChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<GroupSelector
|
||||
{...{
|
||||
groupingId,
|
||||
groupsSelected: ['none'],
|
||||
'data-test-subj': 'alerts-table-group-selector',
|
||||
onGroupChange: onChange,
|
||||
fields,
|
||||
maxGroupingLevels,
|
||||
options: defaultGroupingOptions,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const useGetGroupSelector = ({
|
||||
defaultGroupingOptions,
|
||||
dispatch,
|
||||
|
@ -39,6 +82,7 @@ export const useGetGroupSelector = ({
|
|||
groupingState,
|
||||
maxGroupingLevels = 1,
|
||||
onGroupChange,
|
||||
onOptionsChange,
|
||||
tracker,
|
||||
}: UseGetGroupSelectorArgs) => {
|
||||
const { activeGroups: selectedGroups, options } =
|
||||
|
@ -59,8 +103,9 @@ export const useGetGroupSelector = ({
|
|||
const setOptions = useCallback(
|
||||
(newOptions: GroupOption[]) => {
|
||||
dispatch(groupActions.updateGroupOptions({ id: groupingId, newOptionList: newOptions }));
|
||||
onOptionsChange?.(newOptions);
|
||||
},
|
||||
[dispatch, groupingId]
|
||||
[dispatch, groupingId, onOptionsChange]
|
||||
);
|
||||
|
||||
const onChange = useCallback(
|
||||
|
@ -92,7 +137,7 @@ export const useGetGroupSelector = ({
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (options.length === 0) {
|
||||
if (options.length === 0 && defaultGroupingOptions.length > 0) {
|
||||
return setOptions(
|
||||
defaultGroupingOptions.find((o) => selectedGroups.find((selected) => selected === o.key))
|
||||
? defaultGroupingOptions
|
||||
|
|
|
@ -11,7 +11,7 @@ import React, { useCallback, useMemo, useReducer } from 'react';
|
|||
import { UiCounterMetricType } from '@kbn/analytics';
|
||||
import { groupsReducerWithStorage, initialState } from './state/reducer';
|
||||
import { GroupingProps, GroupSelectorProps, isNoneGroup } from '..';
|
||||
import { groupByIdSelector } from './state';
|
||||
import { groupActions, groupByIdSelector } from './state';
|
||||
import { useGetGroupSelector } from './use_get_group_selector';
|
||||
import { defaultGroup, GroupOption } from './types';
|
||||
import { Grouping as GroupingComponent } from '../components/grouping';
|
||||
|
@ -23,6 +23,7 @@ interface Grouping<T> {
|
|||
getGrouping: (props: DynamicGroupingProps<T>) => React.ReactElement;
|
||||
groupSelector: React.ReactElement<GroupSelectorProps>;
|
||||
selectedGroups: string[];
|
||||
setSelectedGroups: (selectedGroups: string[]) => void;
|
||||
}
|
||||
|
||||
/** Type for static grouping component props where T is the consumer `GroupingAggregation`
|
||||
|
@ -65,6 +66,7 @@ interface GroupingArgs<T> {
|
|||
* @param param { groupByField: string; tableId: string } selected group and table id
|
||||
*/
|
||||
onGroupChange?: (param: { groupByField: string; tableId: string }) => void;
|
||||
onOptionsChange?: (options: GroupOption[]) => void;
|
||||
tracker?: (
|
||||
type: UiCounterMetricType,
|
||||
event: string | string[],
|
||||
|
@ -81,6 +83,7 @@ interface GroupingArgs<T> {
|
|||
* @param groupingId Unique identifier of the grouping component. Used in local storage
|
||||
* @param maxGroupingLevels maximum group nesting levels (optional)
|
||||
* @param onGroupChange callback executed when selected group is changed, used for tracking
|
||||
* @param onOptionsChange callback executed when grouping options are changed, used for consumer grouping selector
|
||||
* @param tracker telemetry handler
|
||||
* @returns {@link Grouping} the grouping constructor { getGrouping, groupSelector, pagination, selectedGroups }
|
||||
*/
|
||||
|
@ -91,6 +94,7 @@ export const useGrouping = <T,>({
|
|||
groupingId,
|
||||
maxGroupingLevels,
|
||||
onGroupChange,
|
||||
onOptionsChange,
|
||||
tracker,
|
||||
}: GroupingArgs<T>): Grouping<T> => {
|
||||
const [groupingState, dispatch] = useReducer(groupsReducerWithStorage, initialState);
|
||||
|
@ -99,6 +103,18 @@ export const useGrouping = <T,>({
|
|||
[groupingId, groupingState]
|
||||
);
|
||||
|
||||
const setSelectedGroups = useCallback(
|
||||
(activeGroups: string[]) => {
|
||||
dispatch(
|
||||
groupActions.updateActiveGroups({
|
||||
id: groupingId,
|
||||
activeGroups,
|
||||
})
|
||||
);
|
||||
},
|
||||
[groupingId]
|
||||
);
|
||||
|
||||
const groupSelector = useGetGroupSelector({
|
||||
defaultGroupingOptions,
|
||||
dispatch,
|
||||
|
@ -107,6 +123,7 @@ export const useGrouping = <T,>({
|
|||
groupingState,
|
||||
maxGroupingLevels,
|
||||
onGroupChange,
|
||||
onOptionsChange,
|
||||
tracker,
|
||||
});
|
||||
|
||||
|
@ -135,7 +152,8 @@ export const useGrouping = <T,>({
|
|||
getGrouping,
|
||||
groupSelector,
|
||||
selectedGroups,
|
||||
setSelectedGroups,
|
||||
}),
|
||||
[getGrouping, groupSelector, selectedGroups]
|
||||
[getGrouping, groupSelector, selectedGroups, setSelectedGroups]
|
||||
);
|
||||
};
|
||||
|
|
|
@ -6,10 +6,12 @@
|
|||
*/
|
||||
|
||||
import actionCreatorFactory from 'typescript-fsa';
|
||||
import type React from 'react';
|
||||
import type { TableId } from '@kbn/securitysolution-data-table';
|
||||
|
||||
const actionCreator = actionCreatorFactory('x-pack/security_solution/groups');
|
||||
|
||||
export const updateGroupSelector = actionCreator<{
|
||||
groupSelector: React.ReactElement | null;
|
||||
}>('UPDATE_GROUP_SELECTOR');
|
||||
export const updateGroups = actionCreator<{
|
||||
activeGroups?: string[];
|
||||
tableId: TableId;
|
||||
options?: Array<{ key: string; label: string }>;
|
||||
}>('UPDATE_GROUPS');
|
||||
|
|
|
@ -6,17 +6,21 @@
|
|||
*/
|
||||
|
||||
import { reducerWithInitialState } from 'typescript-fsa-reducers';
|
||||
import { updateGroupSelector } from './actions';
|
||||
import type { GroupModel } from './types';
|
||||
import { getDefaultGroupingOptions } from '../../utils/alerts';
|
||||
import { updateGroups } from './actions';
|
||||
import type { Groups } from './types';
|
||||
|
||||
export const initialGroupingState: GroupModel = {
|
||||
groupSelector: null,
|
||||
};
|
||||
export const initialGroupingState: Groups = {};
|
||||
|
||||
export const groupsReducer = reducerWithInitialState(initialGroupingState).case(
|
||||
updateGroupSelector,
|
||||
(state, { groupSelector }) => ({
|
||||
updateGroups,
|
||||
(state, { tableId, ...rest }) => ({
|
||||
...state,
|
||||
groupSelector,
|
||||
[tableId]: {
|
||||
activeGroups: [],
|
||||
options: getDefaultGroupingOptions(tableId),
|
||||
...(state[tableId] ? state[tableId] : {}),
|
||||
...rest,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
|
|
@ -6,8 +6,7 @@
|
|||
*/
|
||||
|
||||
import { createSelector } from 'reselect';
|
||||
import type { GroupState } from './types';
|
||||
import type { GroupModel, GroupState } from './types';
|
||||
|
||||
const groupSelector = (state: GroupState) => state.groups.groupSelector;
|
||||
|
||||
export const getGroupSelector = () => createSelector(groupSelector, (selector) => selector);
|
||||
export const groupSelector = ({ groups }: GroupState, id: string): GroupModel => groups[id];
|
||||
export const groupIdSelector = () => createSelector(groupSelector, (group) => group);
|
||||
|
|
|
@ -6,9 +6,14 @@
|
|||
*/
|
||||
|
||||
export interface GroupModel {
|
||||
groupSelector: React.ReactElement | null;
|
||||
activeGroups: string[];
|
||||
options: Array<{ key: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface Groups {
|
||||
[tableId: string]: GroupModel;
|
||||
}
|
||||
|
||||
export interface GroupState {
|
||||
groups: GroupModel;
|
||||
groups: Groups;
|
||||
}
|
||||
|
|
|
@ -8,6 +8,9 @@
|
|||
import { merge } from '@kbn/std';
|
||||
import { isPlainObject } from 'lodash';
|
||||
import type { Ecs } from '@kbn/cases-plugin/common';
|
||||
import { TableId } from '@kbn/securitysolution-data-table';
|
||||
import type { GroupOption } from '@kbn/securitysolution-grouping';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const buildAlertsQuery = (alertIds: string[]) => {
|
||||
if (alertIds.length === 0) {
|
||||
|
@ -118,3 +121,47 @@ export interface Alert {
|
|||
signal: Signal;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// generates default grouping option for alerts table
|
||||
export const getDefaultGroupingOptions = (tableId: TableId): GroupOption[] => {
|
||||
if (tableId === TableId.alertsOnAlertsPage) {
|
||||
return [
|
||||
{
|
||||
label: i18n.ruleName,
|
||||
key: 'kibana.alert.rule.name',
|
||||
},
|
||||
{
|
||||
label: i18n.userName,
|
||||
key: 'user.name',
|
||||
},
|
||||
{
|
||||
label: i18n.hostName,
|
||||
key: 'host.name',
|
||||
},
|
||||
{
|
||||
label: i18n.sourceIP,
|
||||
key: 'source.ip',
|
||||
},
|
||||
];
|
||||
} else if (tableId === TableId.alertsOnRuleDetailsPage) {
|
||||
return [
|
||||
{
|
||||
label: i18n.sourceAddress,
|
||||
key: 'source.address',
|
||||
},
|
||||
{
|
||||
label: i18n.userName,
|
||||
key: 'user.name',
|
||||
},
|
||||
{
|
||||
label: i18n.hostName,
|
||||
key: 'host.name',
|
||||
},
|
||||
{
|
||||
label: i18n.destinationAddress,
|
||||
key: 'destination.address,',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
|
||||
export const ruleName = i18n.translate('xpack.securitySolution.selector.groups.ruleName.label', {
|
||||
defaultMessage: 'Rule name',
|
||||
});
|
||||
export const userName = i18n.translate('xpack.securitySolution.selector.grouping.userName.label', {
|
||||
defaultMessage: 'User name',
|
||||
});
|
||||
export const hostName = i18n.translate('xpack.securitySolution.selector.grouping.hostName.label', {
|
||||
defaultMessage: 'Host name',
|
||||
});
|
||||
export const sourceIP = i18n.translate('xpack.securitySolution.selector.grouping.sourceIP.label', {
|
||||
defaultMessage: 'Source IP',
|
||||
});
|
||||
export const sourceAddress = i18n.translate(
|
||||
'xpack.securitySolution.selector.groups.sourceAddress.label',
|
||||
{
|
||||
defaultMessage: 'Source address',
|
||||
}
|
||||
);
|
||||
|
||||
export const destinationAddress = i18n.translate(
|
||||
'xpack.securitySolution.selector.groups.destinationAddress.label',
|
||||
{
|
||||
defaultMessage: 'Destination address',
|
||||
}
|
||||
);
|
|
@ -58,8 +58,8 @@ const mockOptions = [
|
|||
{ label: 'sourceIP', key: 'source.ip' },
|
||||
];
|
||||
|
||||
jest.mock('./grouping_settings', () => {
|
||||
const actual = jest.requireActual('./grouping_settings');
|
||||
jest.mock('../../../common/utils/alerts', () => {
|
||||
const actual = jest.requireActual('../../../common/utils/alerts');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
|
@ -159,7 +159,17 @@ describe('GroupedAlertsTable', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
store = createStore(
|
||||
{
|
||||
...mockGlobalState,
|
||||
groups: {
|
||||
[testProps.tableId]: { options: mockOptions, activeGroups: ['kibana.alert.rule.name'] },
|
||||
},
|
||||
},
|
||||
SUB_PLUGINS_REDUCER,
|
||||
kibanaObservable,
|
||||
storage
|
||||
);
|
||||
(useSourcererDataView as jest.Mock).mockReturnValue({
|
||||
...sourcererDataView,
|
||||
selectedPatterns: ['myFakebeat-*'],
|
||||
|
@ -176,6 +186,7 @@ describe('GroupedAlertsTable', () => {
|
|||
});
|
||||
|
||||
it('calls the proper initial dispatch actions for groups', () => {
|
||||
store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<TestProviders store={store}>
|
||||
<GroupedAlertsTable {...testProps} />
|
||||
|
@ -185,10 +196,16 @@ describe('GroupedAlertsTable', () => {
|
|||
expect(queryByTestId('empty-results-panel')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('group-selector-dropdown')).not.toBeInTheDocument();
|
||||
expect(getByTestId('alerts-table')).toBeInTheDocument();
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatch.mock.calls[0][0].type).toEqual(
|
||||
'x-pack/security_solution/groups/UPDATE_GROUP_SELECTOR'
|
||||
);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(2);
|
||||
expect(mockDispatch.mock.calls[0][0].payload).toEqual({
|
||||
options: mockOptions,
|
||||
tableId: testProps.tableId,
|
||||
});
|
||||
expect(mockDispatch.mock.calls[1][0].payload).toEqual({
|
||||
activeGroups: ['none'],
|
||||
tableId: testProps.tableId,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders empty grouping table when group is selected without data', () => {
|
||||
|
@ -196,6 +213,7 @@ describe('GroupedAlertsTable', () => {
|
|||
jest
|
||||
.spyOn(window.localStorage, 'getItem')
|
||||
.mockReturnValue(getMockStorageState(['kibana.alert.rule.name']));
|
||||
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<TestProviders store={store}>
|
||||
<GroupedAlertsTable {...testProps} />
|
||||
|
@ -242,6 +260,20 @@ describe('GroupedAlertsTable', () => {
|
|||
jest
|
||||
.spyOn(window.localStorage, 'getItem')
|
||||
.mockReturnValue(getMockStorageState(['kibana.alert.rule.name', 'host.name']));
|
||||
store = createStore(
|
||||
{
|
||||
...mockGlobalState,
|
||||
groups: {
|
||||
[testProps.tableId]: {
|
||||
options: mockOptions,
|
||||
activeGroups: ['kibana.alert.rule.name', 'host.name'],
|
||||
},
|
||||
},
|
||||
},
|
||||
SUB_PLUGINS_REDUCER,
|
||||
kibanaObservable,
|
||||
storage
|
||||
);
|
||||
|
||||
const { getByTestId } = render(
|
||||
<TestProviders store={store}>
|
||||
|
@ -260,6 +292,20 @@ describe('GroupedAlertsTable', () => {
|
|||
jest
|
||||
.spyOn(window.localStorage, 'getItem')
|
||||
.mockReturnValue(getMockStorageState(['kibana.alert.rule.name', 'host.name', 'user.name']));
|
||||
store = createStore(
|
||||
{
|
||||
...mockGlobalState,
|
||||
groups: {
|
||||
[testProps.tableId]: {
|
||||
options: mockOptions,
|
||||
activeGroups: ['kibana.alert.rule.name', 'host.name', 'user.name'],
|
||||
},
|
||||
},
|
||||
},
|
||||
SUB_PLUGINS_REDUCER,
|
||||
kibanaObservable,
|
||||
storage
|
||||
);
|
||||
|
||||
const { getByTestId, getAllByTestId } = render(
|
||||
<TestProviders store={store}>
|
||||
|
@ -309,6 +355,20 @@ describe('GroupedAlertsTable', () => {
|
|||
jest
|
||||
.spyOn(window.localStorage, 'getItem')
|
||||
.mockReturnValue(getMockStorageState(['kibana.alert.rule.name', 'host.name', 'user.name']));
|
||||
store = createStore(
|
||||
{
|
||||
...mockGlobalState,
|
||||
groups: {
|
||||
[testProps.tableId]: {
|
||||
options: mockOptions,
|
||||
activeGroups: ['kibana.alert.rule.name', 'host.name', 'user.name'],
|
||||
},
|
||||
},
|
||||
},
|
||||
SUB_PLUGINS_REDUCER,
|
||||
kibanaObservable,
|
||||
storage
|
||||
);
|
||||
|
||||
const { getByTestId, rerender } = render(
|
||||
<TestProviders store={store}>
|
||||
|
@ -345,9 +405,20 @@ describe('GroupedAlertsTable', () => {
|
|||
});
|
||||
|
||||
it('resets only most inner group pagination when its parent groups open/close', () => {
|
||||
jest
|
||||
.spyOn(window.localStorage, 'getItem')
|
||||
.mockReturnValue(getMockStorageState(['kibana.alert.rule.name', 'host.name', 'user.name']));
|
||||
store = createStore(
|
||||
{
|
||||
...mockGlobalState,
|
||||
groups: {
|
||||
[testProps.tableId]: {
|
||||
options: mockOptions,
|
||||
activeGroups: ['kibana.alert.rule.name', 'host.name', 'user.name'],
|
||||
},
|
||||
},
|
||||
},
|
||||
SUB_PLUGINS_REDUCER,
|
||||
kibanaObservable,
|
||||
storage
|
||||
);
|
||||
|
||||
const { getByTestId } = render(
|
||||
<TestProviders store={store}>
|
||||
|
@ -397,11 +468,22 @@ describe('GroupedAlertsTable', () => {
|
|||
});
|
||||
|
||||
it(`resets innermost level's current page when that level's page size updates`, () => {
|
||||
jest
|
||||
.spyOn(window.localStorage, 'getItem')
|
||||
.mockReturnValue(getMockStorageState(['kibana.alert.rule.name', 'host.name', 'user.name']));
|
||||
store = createStore(
|
||||
{
|
||||
...mockGlobalState,
|
||||
groups: {
|
||||
[testProps.tableId]: {
|
||||
options: mockOptions,
|
||||
activeGroups: ['kibana.alert.rule.name', 'host.name', 'user.name'],
|
||||
},
|
||||
},
|
||||
},
|
||||
SUB_PLUGINS_REDUCER,
|
||||
kibanaObservable,
|
||||
storage
|
||||
);
|
||||
|
||||
const { getByTestId, getAllByTestId } = render(
|
||||
const { getByTestId } = render(
|
||||
<TestProviders store={store}>
|
||||
<GroupedAlertsTable {...testProps} />
|
||||
</TestProviders>
|
||||
|
@ -409,12 +491,10 @@ describe('GroupedAlertsTable', () => {
|
|||
|
||||
fireEvent.click(getByTestId('pagination-button-1'));
|
||||
fireEvent.click(within(getByTestId('level-0-group-0')).getByTestId('group-panel-toggle'));
|
||||
|
||||
fireEvent.click(within(getByTestId('level-0-group-0')).getByTestId('pagination-button-1'));
|
||||
fireEvent.click(within(getByTestId('level-1-group-0')).getByTestId('group-panel-toggle'));
|
||||
|
||||
const level1 = getAllByTestId('grouping-accordion-content')[1];
|
||||
fireEvent.click(within(level1).getByTestId('pagination-button-1'));
|
||||
fireEvent.click(within(getByTestId('level-1-group-0')).getByTestId('pagination-button-1'));
|
||||
fireEvent.click(
|
||||
within(getByTestId('grouping-level-2')).getByTestId('tablePaginationPopoverButton')
|
||||
);
|
||||
|
@ -441,9 +521,20 @@ describe('GroupedAlertsTable', () => {
|
|||
});
|
||||
|
||||
it(`resets outermost level's current page when that level's page size updates`, () => {
|
||||
jest
|
||||
.spyOn(window.localStorage, 'getItem')
|
||||
.mockReturnValue(getMockStorageState(['kibana.alert.rule.name', 'host.name', 'user.name']));
|
||||
store = createStore(
|
||||
{
|
||||
...mockGlobalState,
|
||||
groups: {
|
||||
[testProps.tableId]: {
|
||||
options: mockOptions,
|
||||
activeGroups: ['kibana.alert.rule.name', 'host.name', 'user.name'],
|
||||
},
|
||||
},
|
||||
},
|
||||
SUB_PLUGINS_REDUCER,
|
||||
kibanaObservable,
|
||||
storage
|
||||
);
|
||||
|
||||
const { getByTestId, getAllByTestId } = render(
|
||||
<TestProviders store={store}>
|
||||
|
|
|
@ -7,21 +7,21 @@
|
|||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import type { Filter, Query } from '@kbn/es-query';
|
||||
import type { GroupOption } from '@kbn/securitysolution-grouping';
|
||||
import { isNoneGroup, useGrouping } from '@kbn/securitysolution-grouping';
|
||||
import { isEmpty, isEqual } from 'lodash/fp';
|
||||
import type { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
import type { TableIdLiteral } from '@kbn/securitysolution-data-table';
|
||||
import { groupSelectors } from '../../../common/store/grouping';
|
||||
import type { State } from '../../../common/store';
|
||||
import { updateGroupSelector } from '../../../common/store/grouping/actions';
|
||||
import { getDefaultGroupingOptions } from '../../../common/utils/alerts';
|
||||
import { groupIdSelector } from '../../../common/store/grouping/selectors';
|
||||
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
|
||||
import { updateGroups } from '../../../common/store/grouping/actions';
|
||||
import type { Status } from '../../../../common/detection_engine/schemas/common';
|
||||
import { defaultUnit } from '../../../common/components/toolbar/unit';
|
||||
import { useSourcererDataView } from '../../../common/containers/sourcerer';
|
||||
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
|
||||
import { getDefaultGroupingOptions, renderGroupPanel, getStats } from './grouping_settings';
|
||||
import { renderGroupPanel, getStats } from './grouping_settings';
|
||||
import { useKibana } from '../../../common/lib/kibana';
|
||||
import { GroupedSubLevel } from './alerts_sub_grouping';
|
||||
import { track } from '../../../common/lib/telemetry';
|
||||
|
@ -89,7 +89,19 @@ const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> = (props)
|
|||
[telemetry]
|
||||
);
|
||||
|
||||
const { groupSelector, getGrouping, selectedGroups } = useGrouping({
|
||||
const onOptionsChange = useCallback(
|
||||
(options) => {
|
||||
dispatch(
|
||||
updateGroups({
|
||||
tableId: props.tableId,
|
||||
options,
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch, props.tableId]
|
||||
);
|
||||
|
||||
const { getGrouping, selectedGroups, setSelectedGroups } = useGrouping({
|
||||
componentProps: {
|
||||
groupPanelRenderer: renderGroupPanel,
|
||||
groupStatsRenderer: getStats,
|
||||
|
@ -101,27 +113,30 @@ const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> = (props)
|
|||
groupingId: props.tableId,
|
||||
maxGroupingLevels: MAX_GROUPING_LEVELS,
|
||||
onGroupChange,
|
||||
onOptionsChange,
|
||||
tracker: track,
|
||||
});
|
||||
|
||||
const getGroupSelector = groupSelectors.getGroupSelector();
|
||||
|
||||
const groupSelectorInRedux = useSelector((state: State) => getGroupSelector(state));
|
||||
const selectorOptions = useRef<GroupOption[]>([]);
|
||||
const groupInRedux = useDeepEqualSelector((state) => groupIdSelector()(state, props.tableId));
|
||||
useEffect(() => {
|
||||
// only ever set to `none` - siem only handles group selector when `none` is selected
|
||||
if (isNoneGroup(selectedGroups)) {
|
||||
// set active groups from selected groups
|
||||
dispatch(
|
||||
updateGroups({
|
||||
activeGroups: selectedGroups,
|
||||
tableId: props.tableId,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [dispatch, props.tableId, selectedGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isNoneGroup(selectedGroups) &&
|
||||
groupSelector.props.options.length > 0 &&
|
||||
(groupSelectorInRedux == null ||
|
||||
!isEqual(selectorOptions.current, groupSelector.props.options))
|
||||
) {
|
||||
selectorOptions.current = groupSelector.props.options;
|
||||
dispatch(updateGroupSelector({ groupSelector }));
|
||||
} else if (!isNoneGroup(selectedGroups) && groupSelectorInRedux !== null) {
|
||||
dispatch(updateGroupSelector({ groupSelector: null }));
|
||||
if (groupInRedux != null && !isNoneGroup(groupInRedux.activeGroups)) {
|
||||
// set selected groups from active groups
|
||||
setSelectedGroups(groupInRedux.activeGroups);
|
||||
}
|
||||
}, [dispatch, groupSelector, groupSelectorInRedux, selectedGroups]);
|
||||
}, [groupInRedux, setSelectedGroups]);
|
||||
|
||||
const [pageIndex, setPageIndex] = useState<number[]>(
|
||||
Array(MAX_GROUPING_LEVELS).fill(DEFAULT_PAGE_INDEX)
|
||||
|
|
|
@ -4,54 +4,8 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import type { GroupOption } from '@kbn/securitysolution-grouping';
|
||||
import { TableId } from '@kbn/securitysolution-data-table';
|
||||
import * as i18n from '../translations';
|
||||
|
||||
export * from './group_stats';
|
||||
export * from './group_panel_renderers';
|
||||
export * from './group_take_action_items';
|
||||
export * from './query_builder';
|
||||
|
||||
export const getDefaultGroupingOptions = (tableId: TableId): GroupOption[] => {
|
||||
if (tableId === TableId.alertsOnAlertsPage) {
|
||||
return [
|
||||
{
|
||||
label: i18n.ruleName,
|
||||
key: 'kibana.alert.rule.name',
|
||||
},
|
||||
{
|
||||
label: i18n.userName,
|
||||
key: 'user.name',
|
||||
},
|
||||
{
|
||||
label: i18n.hostName,
|
||||
key: 'host.name',
|
||||
},
|
||||
{
|
||||
label: i18n.sourceIP,
|
||||
key: 'source.ip',
|
||||
},
|
||||
];
|
||||
} else if (tableId === TableId.alertsOnRuleDetailsPage) {
|
||||
return [
|
||||
{
|
||||
label: i18n.sourceAddress,
|
||||
key: 'source.address',
|
||||
},
|
||||
{
|
||||
label: i18n.userName,
|
||||
key: 'user.name',
|
||||
},
|
||||
{
|
||||
label: i18n.hostName,
|
||||
key: 'host.name',
|
||||
},
|
||||
{
|
||||
label: i18n.destinationAddress,
|
||||
key: 'destination.address,',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
|
|
@ -383,32 +383,6 @@ export const STATS_GROUP_SEVERITY_MEDIUM = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const ruleName = i18n.translate('xpack.securitySolution.selector.groups.ruleName.label', {
|
||||
defaultMessage: 'Rule name',
|
||||
});
|
||||
export const userName = i18n.translate('xpack.securitySolution.selector.grouping.userName.label', {
|
||||
defaultMessage: 'User name',
|
||||
});
|
||||
export const hostName = i18n.translate('xpack.securitySolution.selector.grouping.hostName.label', {
|
||||
defaultMessage: 'Host name',
|
||||
});
|
||||
export const sourceIP = i18n.translate('xpack.securitySolution.selector.grouping.sourceIP.label', {
|
||||
defaultMessage: 'Source IP',
|
||||
});
|
||||
export const sourceAddress = i18n.translate(
|
||||
'xpack.securitySolution.selector.groups.sourceAddress.label',
|
||||
{
|
||||
defaultMessage: 'Source address',
|
||||
}
|
||||
);
|
||||
|
||||
export const destinationAddress = i18n.translate(
|
||||
'xpack.securitySolution.selector.groups.destinationAddress.label',
|
||||
{
|
||||
defaultMessage: 'Destination address',
|
||||
}
|
||||
);
|
||||
|
||||
export const INSPECT_GROUPING_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.detectionsEngine.grouping.inspectTitle',
|
||||
{
|
||||
|
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { getPersistentControlsHook } from './use_persistent_controls';
|
||||
import { TableId } from '@kbn/securitysolution-data-table';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import {
|
||||
createSecuritySolutionStorageMock,
|
||||
kibanaObservable,
|
||||
mockGlobalState,
|
||||
SUB_PLUGINS_REDUCER,
|
||||
TestProviders,
|
||||
} from '../../../common/mock';
|
||||
import { createStore } from '../../../common/store';
|
||||
import { useSourcererDataView } from '../../../common/containers/sourcerer';
|
||||
import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector';
|
||||
import { useKibana as mockUseKibana } from '../../../common/lib/kibana/__mocks__';
|
||||
import { createTelemetryServiceMock } from '../../../common/lib/telemetry/telemetry_service.mock';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
const mockedUseKibana = mockUseKibana();
|
||||
const mockedTelemetry = createTelemetryServiceMock();
|
||||
jest.mock('react-redux', () => {
|
||||
const original = jest.requireActual('react-redux');
|
||||
return {
|
||||
...original,
|
||||
useDispatch: () => mockDispatch,
|
||||
};
|
||||
});
|
||||
jest.mock('../../../common/containers/sourcerer');
|
||||
jest.mock('../../../common/hooks/use_selector');
|
||||
jest.mock('../../../common/lib/kibana', () => {
|
||||
const original = jest.requireActual('../../../common/lib/kibana');
|
||||
|
||||
return {
|
||||
...original,
|
||||
useKibana: () => ({
|
||||
...mockedUseKibana,
|
||||
services: {
|
||||
...mockedUseKibana.services,
|
||||
telemetry: mockedTelemetry,
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const sourcererDataView = {
|
||||
indicesExist: true,
|
||||
loading: false,
|
||||
indexPattern: {
|
||||
fields: [],
|
||||
},
|
||||
browserFields: {},
|
||||
};
|
||||
const mockOptions = [
|
||||
{ label: 'ruleName', key: 'kibana.alert.rule.name' },
|
||||
{ label: 'userName', key: 'user.name' },
|
||||
{ label: 'hostName', key: 'host.name' },
|
||||
{ label: 'sourceIP', key: 'source.ip' },
|
||||
];
|
||||
const tableId = TableId.test;
|
||||
|
||||
const groups = {
|
||||
[tableId]: { options: mockOptions, activeGroups: ['kibana.alert.rule.name'] },
|
||||
};
|
||||
|
||||
describe('usePersistentControls', () => {
|
||||
const { storage } = createSecuritySolutionStorageMock();
|
||||
let store: ReturnType<typeof createStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
(useDeepEqualSelector as jest.Mock).mockImplementation(() => groups[tableId]);
|
||||
(useShallowEqualSelector as jest.Mock).mockReturnValue({
|
||||
showOnlyThreatIndicatorAlerts: false,
|
||||
showBuildBlockAlerts: false,
|
||||
});
|
||||
jest.clearAllMocks();
|
||||
store = createStore(
|
||||
{
|
||||
...mockGlobalState,
|
||||
groups,
|
||||
},
|
||||
SUB_PLUGINS_REDUCER,
|
||||
kibanaObservable,
|
||||
storage
|
||||
);
|
||||
(useSourcererDataView as jest.Mock).mockReturnValue({
|
||||
...sourcererDataView,
|
||||
selectedPatterns: ['myFakebeat-*'],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(useDeepEqualSelector as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
test('Should render the group selector component and allow the user to select a grouping field', () => {
|
||||
const usePersistentControls = getPersistentControlsHook(tableId);
|
||||
const { result } = renderHook(() => usePersistentControls(), {
|
||||
wrapper: ({ children }) => <TestProviders store={store}>{children}</TestProviders>,
|
||||
});
|
||||
|
||||
const groupSelector = result.current.right.props.additionalMenuOptions[0];
|
||||
const { getByTestId } = render(<TestProviders store={store}>{groupSelector}</TestProviders>);
|
||||
|
||||
fireEvent.click(getByTestId('group-selector-dropdown'));
|
||||
fireEvent.click(getByTestId('panel-user.name'));
|
||||
expect(mockDispatch.mock.calls[0][0].payload).toEqual({
|
||||
activeGroups: ['user.name'],
|
||||
tableId,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -6,28 +6,66 @@
|
|||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import {
|
||||
dataTableSelectors,
|
||||
tableDefaults,
|
||||
dataTableActions,
|
||||
} from '@kbn/securitysolution-data-table';
|
||||
import type { ViewSelection, TableId } from '@kbn/securitysolution-data-table';
|
||||
import type { State } from '../../../common/store';
|
||||
import { useGetGroupSelectorStateless } from '@kbn/securitysolution-grouping/src/hooks/use_get_group_selector';
|
||||
import { getTelemetryEvent } from '@kbn/securitysolution-grouping/src/telemetry/const';
|
||||
import { groupIdSelector } from '../../../common/store/grouping/selectors';
|
||||
import { useSourcererDataView } from '../../../common/containers/sourcerer';
|
||||
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
|
||||
import { updateGroups } from '../../../common/store/grouping/actions';
|
||||
import { useKibana } from '../../../common/lib/kibana';
|
||||
import { METRIC_TYPE, track } from '../../../common/lib/telemetry';
|
||||
import { useDataTableFilters } from '../../../common/hooks/use_data_table_filters';
|
||||
import { useShallowEqualSelector } from '../../../common/hooks/use_selector';
|
||||
import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector';
|
||||
import { RightTopMenu } from '../../../common/components/events_viewer/right_top_menu';
|
||||
import { AdditionalFiltersAction } from '../../components/alerts_table/additional_filters_action';
|
||||
import { groupSelectors } from '../../../common/store/grouping';
|
||||
|
||||
const { changeViewMode } = dataTableActions;
|
||||
|
||||
export const getPersistentControlsHook = (tableId: TableId) => {
|
||||
const usePersistentControls = () => {
|
||||
const dispatch = useDispatch();
|
||||
const getGroupSelector = groupSelectors.getGroupSelector();
|
||||
const {
|
||||
services: { telemetry },
|
||||
} = useKibana();
|
||||
|
||||
const groupSelector = useSelector((state: State) => getGroupSelector(state));
|
||||
const { indexPattern } = useSourcererDataView(SourcererScopeName.detections);
|
||||
const { options } = useDeepEqualSelector((state) => groupIdSelector()(state, tableId)) ?? {
|
||||
options: [],
|
||||
};
|
||||
|
||||
const trackGroupChange = useCallback(
|
||||
(groupSelection: string) => {
|
||||
track?.(
|
||||
METRIC_TYPE.CLICK,
|
||||
getTelemetryEvent.groupChanged({ groupingId: tableId, selected: groupSelection })
|
||||
);
|
||||
telemetry.reportAlertsGroupingChanged({ groupByField: groupSelection, tableId });
|
||||
},
|
||||
[telemetry]
|
||||
);
|
||||
|
||||
const onGroupChange = useCallback(
|
||||
(selectedGroups: string[]) => {
|
||||
selectedGroups.forEach((g) => trackGroupChange(g));
|
||||
dispatch(updateGroups({ activeGroups: selectedGroups, tableId }));
|
||||
},
|
||||
[dispatch, trackGroupChange]
|
||||
);
|
||||
|
||||
const groupSelector = useGetGroupSelectorStateless({
|
||||
groupingId: tableId,
|
||||
onGroupChange,
|
||||
fields: indexPattern.fields,
|
||||
defaultGroupingOptions: options,
|
||||
maxGroupingLevels: 3,
|
||||
});
|
||||
|
||||
const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []);
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue