[Security Solution] Multi level grouping for alerts table (#152862)

## Multi Level Grouping

Resolves https://github.com/elastic/kibana/issues/150516
Resolves https://github.com/elastic/kibana/issues/150514

Implements multi level grouping in Alerts table and Rule details table.
Supports 3 levels deep.


https://user-images.githubusercontent.com/6935300/232547389-7d778f69-d96d-4bd8-8560-f5ddd9fe8060.mov

### Test plan


https://docs.google.com/document/d/15oseanNzF-u-Xeoahy1IVxI4oV3wOuO8VhA886cA1U8/edit#

### To do

- [Cypress](https://github.com/elastic/kibana/issues/150666)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Steph Milovic <stephanie.milovic@elastic.co>
This commit is contained in:
Yuliia Naumenko 2023-04-24 06:01:05 -07:00 committed by GitHub
parent 24a3df49f8
commit 9eee24f7bf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 3634 additions and 1219 deletions

View file

@ -15,6 +15,7 @@ const STORYBOOKS = [
'apm',
'canvas',
'cases',
'cell_actions',
'ci_composite',
'cloud_chat',
'coloring',
@ -34,6 +35,7 @@ const STORYBOOKS = [
'expression_shape',
'expression_tagcloud',
'fleet',
'grouping',
'home',
'infra',
'kibana_react',

View file

@ -0,0 +1,9 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = require('@kbn/storybook').defaultConfig;

View file

@ -1,3 +0,0 @@
# @kbn/securitysolution-grouping
Grouping component and query. Currently only consumed by security solution alerts table. Package is a WIP. Refactoring to make generic https://github.com/elastic/kibana/issues/152491

View file

@ -0,0 +1,3 @@
# @kbn/securitysolution-grouping
Grouping component and query. Currently only consumed by security solution alerts table.

View file

@ -6,20 +6,22 @@
* Side Public License, v 1.
*/
import { RawBucket, StatRenderer, getGroupingQuery, isNoneGroup, useGrouping } from './src';
import { getGroupingQuery, isNoneGroup, useGrouping } from './src';
import type {
DynamicGroupingProps,
GroupOption,
GroupingAggregation,
GroupingFieldTotalAggregation,
NamedAggregation,
RawBucket,
StatRenderer,
} from './src';
export { getGroupingQuery, isNoneGroup, useGrouping };
export type {
DynamicGroupingProps,
GroupOption,
GroupingAggregation,
GroupingFieldTotalAggregation,
NamedAggregation,
RawBucket,
StatRenderer,

View file

@ -13,6 +13,8 @@ import { GroupStats } from './group_stats';
const onTakeActionsOpen = jest.fn();
const testProps = {
bucketKey: '9nk5mo2fby',
groupFilter: [],
groupNumber: 0,
onTakeActionsOpen,
statRenderers: [
{
@ -23,7 +25,7 @@ const testProps = {
{ title: 'Rules:', badge: { value: 2 } },
{ title: 'Alerts:', badge: { value: 2, width: 50, color: '#a83632' } },
],
takeActionItems: [
takeActionItems: () => [
<p data-test-subj="takeActionItem-1" key={1} />,
<p data-test-subj="takeActionItem-2" key={2} />,
],

View file

@ -16,29 +16,44 @@ import {
EuiToolTip,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { Filter } from '@kbn/es-query';
import { StatRenderer } from '../types';
import { statsContainerCss } from '../styles';
import { TAKE_ACTION } from '../translations';
interface GroupStatsProps<T> {
bucketKey: string;
statRenderers?: StatRenderer[];
groupFilter: Filter[];
groupNumber: number;
onTakeActionsOpen?: () => void;
takeActionItems: JSX.Element[];
statRenderers?: StatRenderer[];
takeActionItems: (groupFilters: Filter[], groupNumber: number) => JSX.Element[];
}
const GroupStatsComponent = <T,>({
bucketKey,
statRenderers,
groupFilter,
groupNumber,
onTakeActionsOpen,
takeActionItems,
statRenderers,
takeActionItems: getTakeActionItems,
}: GroupStatsProps<T>) => {
const [isPopoverOpen, setPopover] = useState(false);
const [takeActionItems, setTakeActionItems] = useState<JSX.Element[]>([]);
const onButtonClick = useCallback(
() => (!isPopoverOpen && onTakeActionsOpen ? onTakeActionsOpen() : setPopover(!isPopoverOpen)),
[isPopoverOpen, onTakeActionsOpen]
);
const onButtonClick = useCallback(() => {
if (!isPopoverOpen && takeActionItems.length === 0) {
setTakeActionItems(getTakeActionItems(groupFilter, groupNumber));
}
return !isPopoverOpen && onTakeActionsOpen ? onTakeActionsOpen() : setPopover(!isPopoverOpen);
}, [
getTakeActionItems,
groupFilter,
groupNumber,
isPopoverOpen,
onTakeActionsOpen,
takeActionItems.length,
]);
const statsComponent = useMemo(
() =>

View file

@ -55,6 +55,7 @@ const testProps = {
},
renderChildComponent,
selectedGroup: 'kibana.alert.rule.name',
onGroupClose: () => {},
};
describe('grouping accordion panel', () => {

View file

@ -8,7 +8,7 @@
import { EuiAccordion, EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui';
import type { Filter } from '@kbn/es-query';
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { firstNonNullValue } from '../../helpers';
import type { RawBucket } from '../types';
import { createGroupFilter } from './helpers';
@ -20,8 +20,9 @@ interface GroupPanelProps<T> {
forceState?: 'open' | 'closed';
groupBucket: RawBucket<T>;
groupPanelRenderer?: JSX.Element;
groupingLevel?: number;
isLoading: boolean;
level?: number;
onGroupClose: () => void;
onToggleGroup?: (isOpen: boolean, groupBucket: RawBucket<T>) => void;
renderChildComponent: (groupFilter: Filter[]) => React.ReactElement;
selectedGroup: string;
@ -40,18 +41,30 @@ const DefaultGroupPanelRenderer = ({ title }: { title: string }) => (
);
const GroupPanelComponent = <T,>({
customAccordionButtonClassName = 'groupingAccordionForm__button',
customAccordionButtonClassName,
customAccordionClassName = 'groupingAccordionForm',
extraAction,
forceState,
groupBucket,
groupPanelRenderer,
groupingLevel = 0,
isLoading,
level = 0,
onGroupClose,
onToggleGroup,
renderChildComponent,
selectedGroup,
}: GroupPanelProps<T>) => {
const lastForceState = useRef(forceState);
useEffect(() => {
if (lastForceState.current === 'open' && forceState === 'closed') {
// when parent group closes, reset pagination of any child groups
onGroupClose();
lastForceState.current = 'closed';
} else if (lastForceState.current === 'closed' && forceState === 'open') {
lastForceState.current = 'open';
}
}, [onGroupClose, forceState, selectedGroup]);
const groupFieldValue = useMemo(() => firstNonNullValue(groupBucket.key), [groupBucket.key]);
const groupFilters = useMemo(
@ -72,20 +85,21 @@ const GroupPanelComponent = <T,>({
<EuiAccordion
buttonClassName={customAccordionButtonClassName}
buttonContent={
<div className="groupingPanelRenderer">
<div data-test-subj="group-panel-toggle" className="groupingPanelRenderer">
{groupPanelRenderer ?? <DefaultGroupPanelRenderer title={groupFieldValue} />}
</div>
}
className={customAccordionClassName}
buttonElement="div"
className={groupingLevel > 0 ? 'groupingAccordionFormLevel' : customAccordionClassName}
data-test-subj="grouping-accordion"
extraAction={extraAction}
forceState={forceState}
isLoading={isLoading}
id={`group${level}-${groupFieldValue}`}
id={`group${groupingLevel}-${groupFieldValue}`}
onToggle={onToggle}
paddingSize="m"
>
{renderChildComponent(groupFilters)}
<span data-test-subj="grouping-accordion-content">{renderChildComponent(groupFilters)}</span>
</EuiAccordion>
);
};

View file

@ -43,7 +43,7 @@ const testProps = {
esTypes: ['ip'],
},
],
groupSelected: 'kibana.alert.rule.name',
groupsSelected: ['kibana.alert.rule.name'],
onGroupChange,
options: [
{
@ -90,4 +90,38 @@ describe('group selector', () => {
fireEvent.click(getByTestId('panel-none'));
expect(onGroupChange).toHaveBeenCalled();
});
it('Labels button in correct selection order', () => {
const { getByTestId, rerender } = render(
<GroupSelector
{...testProps}
groupsSelected={[...testProps.groupsSelected, 'user.name', 'host.name']}
/>
);
expect(getByTestId('group-selector-dropdown').title).toEqual('Rule name, User name, Host name');
rerender(
<GroupSelector
{...testProps}
groupsSelected={[...testProps.groupsSelected, 'host.name', 'user.name']}
/>
);
expect(getByTestId('group-selector-dropdown').title).toEqual('Rule name, Host name, User name');
});
it('Labels button with selection not in options', () => {
const { getByTestId } = render(
<GroupSelector
{...testProps}
groupsSelected={[...testProps.groupsSelected, 'ugly.name', 'host.name']}
/>
);
expect(getByTestId('group-selector-dropdown').title).toEqual('Rule name, Host name');
});
it('Labels button when `none` is selected', () => {
const { getByTestId } = render(
<GroupSelector
{...testProps}
groupsSelected={[...testProps.groupsSelected, 'ugly.name', 'host.name']}
/>
);
expect(getByTestId('group-selector-dropdown').title).toEqual('Rule name, Host name');
});
});

View file

@ -21,21 +21,27 @@ export interface GroupSelectorProps {
'data-test-subj'?: string;
fields: FieldSpec[];
groupingId: string;
groupSelected: string;
groupsSelected: string[];
onGroupChange: (groupSelection: string) => void;
options: Array<{ key: string; label: string }>;
title?: string;
maxGroupingLevels?: number;
}
const GroupSelectorComponent = ({
'data-test-subj': dataTestSubj,
fields,
groupSelected = 'none',
groupsSelected = ['none'],
onGroupChange,
options,
title = i18n.GROUP_BY,
maxGroupingLevels = 1,
}: GroupSelectorProps) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const isGroupSelected = useCallback(
(groupKey: string) =>
!!groupsSelected.find((selectedGroupKey) => selectedGroupKey === groupKey),
[groupsSelected]
);
const panels: EuiContextMenuPanelDescriptor[] = useMemo(
() => [
@ -49,7 +55,7 @@ const GroupSelectorComponent = ({
style={{ lineHeight: 1 }}
>
<EuiFlexItem grow={false} component="p" style={{ lineHeight: 1.5 }}>
{i18n.SELECT_FIELD.toUpperCase()}
{i18n.SELECT_FIELD(maxGroupingLevels)}
</EuiFlexItem>
<EuiFlexItem grow={false} component="span">
<EuiBetaBadge
@ -65,20 +71,23 @@ const GroupSelectorComponent = ({
{
'data-test-subj': 'panel-none',
name: i18n.NONE,
icon: groupSelected === 'none' ? 'check' : 'empty',
icon: isGroupSelected('none') ? 'check' : 'empty',
onClick: () => onGroupChange('none'),
},
...options.map<EuiContextMenuPanelItemDescriptor>((o) => ({
'data-test-subj': `panel-${o.key}`,
disabled: groupsSelected.length === maxGroupingLevels && !isGroupSelected(o.key),
name: o.label,
onClick: () => onGroupChange(o.key),
icon: groupSelected === o.key ? 'check' : 'empty',
icon: isGroupSelected(o.key) ? 'check' : 'empty',
})),
{
'data-test-subj': `panel-custom`,
name: i18n.CUSTOM_FIELD,
icon: 'empty',
disabled: groupsSelected.length === maxGroupingLevels,
panel: 'customPanel',
hasPanel: true,
},
],
},
@ -91,24 +100,35 @@ const GroupSelectorComponent = ({
currentOptions={options.map((o) => ({ text: o.label, field: o.key }))}
onSubmit={(field: string) => {
onGroupChange(field);
setIsPopoverOpen(false);
}}
fields={fields}
/>
),
},
],
[fields, groupSelected, onGroupChange, options]
[fields, groupsSelected.length, isGroupSelected, maxGroupingLevels, onGroupChange, options]
);
const selectedOption = useMemo(
() => options.filter((groupOption) => groupOption.key === groupSelected),
[groupSelected, options]
const selectedOptions = useMemo(
() => options.filter((groupOption) => isGroupSelected(groupOption.key)),
[isGroupSelected, options]
);
const onButtonClick = useCallback(() => setIsPopoverOpen((currentVal) => !currentVal), []);
const closePopover = useCallback(() => setIsPopoverOpen(false), []);
const button = useMemo(
() => (
const button = useMemo(() => {
// need to use groupsSelected to ensure proper selection order (selectedOptions does not handle selection order)
const buttonLabel = isGroupSelected('none')
? i18n.NONE
: groupsSelected.reduce((optionsTitle, o) => {
const selection = selectedOptions.find((opt) => opt.key === o);
if (selection == null) {
return optionsTitle;
}
return optionsTitle ? [optionsTitle, selection.label].join(', ') : selection.label;
}, '');
return (
<StyledEuiButtonEmpty
data-test-subj="group-selector-dropdown"
flush="both"
@ -116,22 +136,13 @@ const GroupSelectorComponent = ({
iconSize="s"
iconType="arrowDown"
onClick={onButtonClick}
title={
groupSelected !== 'none' && selectedOption.length > 0
? selectedOption[0].label
: i18n.NONE
}
title={buttonLabel}
size="xs"
>
{`${title}: ${
groupSelected !== 'none' && selectedOption.length > 0
? selectedOption[0].label
: i18n.NONE
}`}
{`${title}: ${buttonLabel}`}
</StyledEuiButtonEmpty>
),
[groupSelected, onButtonClick, selectedOption, title]
);
);
}, [groupsSelected, isGroupSelected, onButtonClick, selectedOptions, title]);
return (
<EuiPopover

View file

@ -0,0 +1,111 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
export const rule1Name = 'Rule 1 name';
const rule1Desc = 'Rule 1 description';
export const rule2Name = 'Rule 2 name';
const rule2Desc = 'Rule 2 description';
export const mockGroupingProps = {
activePage: 0,
data: {
groupsCount: {
value: 2,
},
groupByFields: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: [rule1Name, rule1Desc],
key_as_string: `${rule1Name}|${rule1Desc}`,
doc_count: 1,
hostsCountAggregation: {
value: 1,
},
ruleTags: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [],
},
alertsCount: {
value: 1,
},
severitiesSubAggregation: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'low',
doc_count: 1,
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 1,
},
},
{
key: [rule2Name, rule2Desc],
key_as_string: `${rule2Name}|${rule2Desc}`,
doc_count: 1,
hostsCountAggregation: {
value: 1,
},
ruleTags: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [],
},
unitsCount: {
value: 1,
},
severitiesSubAggregation: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'low',
doc_count: 1,
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 1,
},
},
],
},
unitsCount: {
value: 2,
},
},
groupingId: 'test-grouping-id',
isLoading: false,
itemsPerPage: 25,
renderChildComponent: () => <p>{'child component'}</p>,
onGroupClose: () => {},
selectedGroup: 'kibana.alert.rule.name',
takeActionItems: () => [
<EuiContextMenuItem key="acknowledged" onClick={() => {}}>
{'Mark as acknowledged'}
</EuiContextMenuItem>,
<EuiContextMenuItem key="closed" onClick={() => {}}>
{'Mark as closed'}
</EuiContextMenuItem>,
],
tracker: () => {},
};

View file

@ -0,0 +1,28 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import type { Story } from '@storybook/react';
import { mockGroupingProps } from './grouping.mock';
import { Grouping } from './grouping';
import readme from '../../README.mdx';
export default {
component: Grouping,
title: 'Grouping',
description: 'A group of accordion components that each renders a given child component',
parameters: {
docs: {
page: readme,
},
},
};
export const Emtpy: Story<void> = () => {
return <Grouping {...mockGroupingProps} />;
};

View file

@ -14,104 +14,15 @@ import { createGroupFilter } from './accordion_panel/helpers';
import { METRIC_TYPE } from '@kbn/analytics';
import { getTelemetryEvent } from '../telemetry/const';
import { mockGroupingProps, rule1Name, rule2Name } from './grouping.mock';
const renderChildComponent = jest.fn();
const takeActionItems = jest.fn();
const mockTracker = jest.fn();
const rule1Name = 'Rule 1 name';
const rule1Desc = 'Rule 1 description';
const rule2Name = 'Rule 2 name';
const rule2Desc = 'Rule 2 description';
const testProps = {
data: {
groupsCount: {
value: 2,
},
groupByFields: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: [rule1Name, rule1Desc],
key_as_string: `${rule1Name}|${rule1Desc}`,
doc_count: 1,
hostsCountAggregation: {
value: 1,
},
ruleTags: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [],
},
alertsCount: {
value: 1,
},
severitiesSubAggregation: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'low',
doc_count: 1,
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 1,
},
},
{
key: [rule2Name, rule2Desc],
key_as_string: `${rule2Name}|${rule2Desc}`,
doc_count: 1,
hostsCountAggregation: {
value: 1,
},
ruleTags: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [],
},
unitsCount: {
value: 1,
},
severitiesSubAggregation: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'low',
doc_count: 1,
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 1,
},
},
],
},
unitsCount: {
value: 2,
},
},
groupingId: 'test-grouping-id',
isLoading: false,
pagination: {
pageIndex: 0,
pageSize: 25,
onChangeItemsPerPage: jest.fn(),
onChangePage: jest.fn(),
itemsPerPageOptions: [10, 25, 50, 100],
},
...mockGroupingProps,
renderChildComponent,
selectedGroup: 'kibana.alert.rule.name',
takeActionItems,
tracker: mockTracker,
};

View file

@ -21,35 +21,29 @@ import { createGroupFilter } from './accordion_panel/helpers';
import { GroupPanel } from './accordion_panel';
import { GroupStats } from './accordion_panel/group_stats';
import { EmptyGroupingComponent } from './empty_results_panel';
import { groupingContainerCss, countCss } from './styles';
import { countCss, groupingContainerCss, groupingContainerCssLevel } from './styles';
import { GROUPS_UNIT } from './translations';
import type {
GroupingAggregation,
GroupingFieldTotalAggregation,
GroupPanelRenderer,
RawBucket,
} from './types';
import { getTelemetryEvent } from '../telemetry/const';
import type { GroupingAggregation, GroupPanelRenderer } from './types';
import { GroupStatsRenderer, OnGroupToggle } from './types';
import { getTelemetryEvent } from '../telemetry/const';
export interface GroupingProps<T> {
data?: GroupingAggregation<T> & GroupingFieldTotalAggregation<T>;
groupingId: string;
activePage: number;
data?: GroupingAggregation<T>;
groupPanelRenderer?: GroupPanelRenderer<T>;
groupSelector?: JSX.Element;
// list of custom UI components which correspond to your custom rendered metrics aggregations
groupStatsRenderer?: GroupStatsRenderer<T>;
groupingId: string;
groupingLevel?: number;
inspectButton?: JSX.Element;
isLoading: boolean;
itemsPerPage: number;
onChangeGroupsItemsPerPage?: (size: number) => void;
onChangeGroupsPage?: (index: number) => void;
onGroupToggle?: OnGroupToggle;
pagination: {
pageIndex: number;
pageSize: number;
onChangeItemsPerPage: (itemsPerPageNumber: number) => void;
onChangePage: (pageNumber: number) => void;
itemsPerPageOptions: number[];
};
renderChildComponent: (groupFilter: Filter[]) => React.ReactElement;
onGroupClose: () => void;
selectedGroup: string;
takeActionItems: (groupFilters: Filter[], groupNumber: number) => JSX.Element[];
tracker?: (
@ -61,24 +55,29 @@ export interface GroupingProps<T> {
}
const GroupingComponent = <T,>({
activePage,
data,
groupingId,
groupPanelRenderer,
groupSelector,
groupStatsRenderer,
groupingId,
groupingLevel = 0,
inspectButton,
isLoading,
itemsPerPage,
onChangeGroupsItemsPerPage,
onChangeGroupsPage,
onGroupClose,
onGroupToggle,
pagination,
renderChildComponent,
selectedGroup,
takeActionItems,
tracker,
unit = defaultUnit,
}: GroupingProps<T>) => {
const [trigger, setTrigger] = useState<
Record<string, { state: 'open' | 'closed' | undefined; selectedBucket: RawBucket<T> }>
>({});
const [trigger, setTrigger] = useState<Record<string, { state: 'open' | 'closed' | undefined }>>(
{}
);
const unitCount = data?.unitsCount?.value ?? 0;
const unitCountText = useMemo(() => {
@ -100,16 +99,16 @@ const GroupingComponent = <T,>({
return (
<span key={groupKey}>
<GroupPanel
onGroupClose={onGroupClose}
extraAction={
<GroupStats
bucketKey={groupKey}
takeActionItems={takeActionItems(
createGroupFilter(selectedGroup, group),
groupNumber
)}
groupFilter={createGroupFilter(selectedGroup, group)}
groupNumber={groupNumber}
statRenderers={
groupStatsRenderer && groupStatsRenderer(selectedGroup, groupBucket)
}
takeActionItems={takeActionItems}
/>
}
forceState={(trigger[groupKey] && trigger[groupKey].state) ?? 'closed'}
@ -128,7 +127,6 @@ const GroupingComponent = <T,>({
// ...trigger, -> this change will keep only one group at a time expanded and one table displayed
[groupKey]: {
state: isOpen ? 'open' : 'closed',
selectedBucket: groupBucket,
},
});
onGroupToggle?.({ isOpen, groupName: group, groupNumber, groupingId });
@ -139,8 +137,9 @@ const GroupingComponent = <T,>({
: () => <span />
}
selectedGroup={selectedGroup}
groupingLevel={groupingLevel}
/>
<EuiSpacer size="s" />
{groupingLevel > 0 ? null : <EuiSpacer size="s" />}
</span>
);
}),
@ -149,7 +148,9 @@ const GroupingComponent = <T,>({
groupPanelRenderer,
groupStatsRenderer,
groupingId,
groupingLevel,
isLoading,
onGroupClose,
onGroupToggle,
renderChildComponent,
selectedGroup,
@ -159,58 +160,76 @@ const GroupingComponent = <T,>({
]
);
const pageCount = useMemo(
() => (groupCount && pagination.pageSize ? Math.ceil(groupCount / pagination.pageSize) : 1),
[groupCount, pagination.pageSize]
() => (groupCount ? Math.ceil(groupCount / itemsPerPage) : 1),
[groupCount, itemsPerPage]
);
return (
<>
<EuiFlexGroup
data-test-subj="grouping-table"
justifyContent="spaceBetween"
alignItems="center"
style={{ paddingBottom: 20, paddingTop: 20 }}
>
<EuiFlexItem grow={false}>
{groupCount > 0 && unitCount > 0 ? (
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow={false}>
<span css={countCss} data-test-subj="unit-count">
{unitCountText}
</span>
</EuiFlexItem>
<EuiFlexItem>
<span css={countCss} data-test-subj="group-count" style={{ borderRight: 'none' }}>
{groupCountText}
</span>
</EuiFlexItem>
{groupingLevel > 0 ? null : (
<EuiFlexGroup
data-test-subj="grouping-table"
justifyContent="spaceBetween"
alignItems="center"
style={{ paddingBottom: 20, paddingTop: 20 }}
>
<EuiFlexItem grow={false}>
{groupCount > 0 && unitCount > 0 ? (
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow={false}>
<span css={countCss} data-test-subj="unit-count">
{unitCountText}
</span>
</EuiFlexItem>
<EuiFlexItem>
<span css={countCss} data-test-subj="group-count" style={{ borderRight: 'none' }}>
{groupCountText}
</span>
</EuiFlexItem>
</EuiFlexGroup>
) : null}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs">
{inspectButton && <EuiFlexItem>{inspectButton}</EuiFlexItem>}
<EuiFlexItem>{groupSelector}</EuiFlexItem>
</EuiFlexGroup>
) : null}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs">
{inspectButton && <EuiFlexItem>{inspectButton}</EuiFlexItem>}
<EuiFlexItem>{groupSelector}</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<div css={groupingContainerCss} className="eui-xScroll">
</EuiFlexItem>
</EuiFlexGroup>
)}
<div
css={groupingLevel > 0 ? groupingContainerCssLevel : groupingContainerCss}
className="eui-xScroll"
>
{isLoading && (
<EuiProgress data-test-subj="is-loading-grouping-table" size="xs" color="accent" />
)}
{groupCount > 0 ? (
<>
{groupPanels}
<EuiSpacer size="m" />
<EuiTablePagination
activePage={pagination.pageIndex}
data-test-subj="grouping-table-pagination"
itemsPerPage={pagination.pageSize}
itemsPerPageOptions={pagination.itemsPerPageOptions}
onChangeItemsPerPage={pagination.onChangeItemsPerPage}
onChangePage={pagination.onChangePage}
pageCount={pageCount}
showPerPageOptions
/>
{groupCount > 0 && (
<>
<EuiSpacer size="m" />
<EuiTablePagination
activePage={activePage}
data-test-subj={`grouping-level-${groupingLevel}-pagination`}
itemsPerPage={itemsPerPage}
itemsPerPageOptions={[10, 25, 50, 100]}
onChangeItemsPerPage={(pageSize: number) => {
if (onChangeGroupsItemsPerPage) {
onChangeGroupsItemsPerPage(pageSize);
}
}}
onChangePage={(pageIndex: number) => {
if (onChangeGroupsPage) {
onChangeGroupsPage(pageIndex);
}
}}
pageCount={pageCount}
showPerPageOptions
/>
</>
)}
</>
) : (
<EmptyGroupingComponent />

View file

@ -14,8 +14,9 @@ export * from './grouping';
/**
* Checks if no group is selected
* @param groupKey selected group field value
* @param groupKeys selected group field values
*
* @returns {boolean} True if no group is selected
*/
export const isNoneGroup = (groupKey: string | null) => groupKey === NONE_GROUP_KEY;
export const isNoneGroup = (groupKeys: string[]) =>
!!groupKeys.find((groupKey) => groupKey === NONE_GROUP_KEY);

View file

@ -36,7 +36,7 @@ export const statsContainerCss = css`
`;
export const groupingContainerCss = css`
.euiAccordion__childWrapper .euiAccordion__padding--m {
.groupingAccordionForm .euiAccordion__childWrapper .euiAccordion__padding--m {
margin-left: 8px;
margin-right: 8px;
border-left: ${euiThemeVars.euiBorderThin};
@ -44,7 +44,7 @@ export const groupingContainerCss = css`
border-bottom: ${euiThemeVars.euiBorderThin};
border-radius: 0 0 6px 6px;
}
.euiAccordion__triggerWrapper {
.groupingAccordionForm .euiAccordion__triggerWrapper {
border-bottom: ${euiThemeVars.euiBorderThin};
border-left: ${euiThemeVars.euiBorderThin};
border-right: ${euiThemeVars.euiBorderThin};
@ -59,8 +59,37 @@ export const groupingContainerCss = css`
border-radius: 6px;
min-width: 1090px;
}
.groupingAccordionForm__button {
text-decoration: none !important;
.groupingPanelRenderer {
display: table;
table-layout: fixed;
width: 100%;
padding-right: 32px;
}
`;
export const groupingContainerCssLevel = css`
.groupingAccordionFormLevel .euiAccordion__childWrapper .euiAccordion__padding--m {
margin-left: 8px;
margin-right: 8px;
border-left: none;
border-right: none;
border-bottom: ${euiThemeVars.euiBorderThin};
border-radius: 0;
}
.groupingAccordionFormLevel .euiAccordion__triggerWrapper {
border-bottom: ${euiThemeVars.euiBorderThin};
border-left: none;
border-right: none;
min-height: 78px;
padding-left: 16px;
padding-right: 16px;
border-radius: 0;
}
.groupingAccordionFormLevel {
border-top: none;
border-bottom: none;
border-radius: 0;
min-width: 1090px;
}
.groupingPanelRenderer {
display: table;

View file

@ -35,9 +35,11 @@ export const GROUP_BY_CUSTOM_FIELD = i18n.translate('grouping.customGroupByPanel
defaultMessage: 'Group By Custom Field',
});
export const SELECT_FIELD = i18n.translate('grouping.groupByPanelTitle', {
defaultMessage: 'Select Field',
});
export const SELECT_FIELD = (groupingLevelsCount: number) =>
i18n.translate('grouping.groupByPanelTitle', {
values: { groupingLevelsCount },
defaultMessage: 'Select up to {groupingLevelsCount} groupings',
});
export const NONE = i18n.translate('grouping.noneGroupByOptionName', {
defaultMessage: 'None',

View file

@ -19,7 +19,7 @@ export type RawBucket<T> = GenericBuckets & T;
/** Defines the shape of the aggregation returned by Elasticsearch */
// TODO: write developer docs for these fields
export interface GroupingAggregation<T> {
export interface RootAggregation<T> {
groupByFields?: {
buckets?: Array<RawBucket<T>>;
};
@ -39,6 +39,8 @@ export type GroupingFieldTotalAggregation<T> = Record<
}
>;
export type GroupingAggregation<T> = RootAggregation<T> & GroupingFieldTotalAggregation<T>;
export interface BadgeMetric {
value: number;
color?: string;
@ -67,3 +69,5 @@ export type OnGroupToggle = (params: {
groupNumber: number;
groupingId: string;
}) => void;
export type { GroupingProps } from './grouping';

View file

@ -35,10 +35,10 @@ export const getGroupingQuery = ({
additionalFilters = [],
from,
groupByFields,
pageNumber,
rootAggregations,
runtimeMappings,
size = DEFAULT_GROUP_BY_FIELD_SIZE,
pageNumber,
sort,
statsAggregations,
to,

View file

@ -24,9 +24,10 @@ export interface GroupingQueryArgs {
additionalFilters: BoolAgg[];
from: string;
groupByFields: string[];
pageNumber?: number;
rootAggregations?: NamedAggregation[];
runtimeMappings?: MappingRuntimeFields;
additionalAggregationsRoot?: NamedAggregation[];
pageNumber?: number;
size?: number;
sort?: Array<{ [category: string]: { order: 'asc' | 'desc' } }>;
statsAggregations?: NamedAggregation[];

View file

@ -6,55 +6,20 @@
* Side Public License, v 1.
*/
import {
ActionType,
GroupOption,
UpdateActiveGroup,
UpdateGroupActivePage,
UpdateGroupItemsPerPage,
UpdateGroupOptions,
} from '../types';
import { ActionType, GroupOption, UpdateActiveGroups, UpdateGroupOptions } from '../types';
const updateActiveGroup = ({
activeGroup,
const updateActiveGroups = ({
activeGroups,
id,
}: {
activeGroup: string;
activeGroups: string[];
id: string;
}): UpdateActiveGroup => ({
}): UpdateActiveGroups => ({
payload: {
activeGroup,
activeGroups,
id,
},
type: ActionType.updateActiveGroup,
});
const updateGroupActivePage = ({
activePage,
id,
}: {
activePage: number;
id: string;
}): UpdateGroupActivePage => ({
payload: {
activePage,
id,
},
type: ActionType.updateGroupActivePage,
});
const updateGroupItemsPerPage = ({
itemsPerPage,
id,
}: {
itemsPerPage: number;
id: string;
}): UpdateGroupItemsPerPage => ({
payload: {
itemsPerPage,
id,
},
type: ActionType.updateGroupItemsPerPage,
type: ActionType.updateActiveGroups,
});
const updateGroupOptions = ({
@ -72,8 +37,6 @@ const updateGroupOptions = ({
});
export const groupActions = {
updateActiveGroup,
updateGroupActivePage,
updateGroupItemsPerPage,
updateActiveGroups,
updateGroupOptions,
};

View file

@ -24,7 +24,7 @@ const groupById = {
[groupingId]: {
...defaultGroup,
options: groupingOptions,
activeGroup: 'host.name',
activeGroups: ['host.name'],
},
};
@ -54,7 +54,7 @@ describe('grouping reducer', () => {
JSON.stringify(groupingState.groupById)
);
});
it('updateActiveGroup', () => {
it('updateActiveGroups', () => {
const { result } = renderHook(() =>
useReducer(groupsReducerWithStorage, {
...initialState,
@ -62,40 +62,11 @@ describe('grouping reducer', () => {
})
);
let [groupingState, dispatch] = result.current;
expect(groupingState.groupById[groupingId].activeGroup).toEqual('host.name');
expect(groupingState.groupById[groupingId].activeGroups).toEqual(['host.name']);
act(() => {
dispatch(groupActions.updateActiveGroup({ id: groupingId, activeGroup: 'user.name' }));
dispatch(groupActions.updateActiveGroups({ id: groupingId, activeGroups: ['user.name'] }));
});
[groupingState, dispatch] = result.current;
expect(groupingState.groupById[groupingId].activeGroup).toEqual('user.name');
});
it('updateGroupActivePage', () => {
const { result } = renderHook(() =>
useReducer(groupsReducerWithStorage, {
...initialState,
groupById,
})
);
let [groupingState, dispatch] = result.current;
expect(groupingState.groupById[groupingId].activePage).toEqual(0);
act(() => {
dispatch(groupActions.updateGroupActivePage({ id: groupingId, activePage: 12 }));
});
[groupingState, dispatch] = result.current;
expect(groupingState.groupById[groupingId].activePage).toEqual(12);
});
it('updateGroupItemsPerPage', () => {
const { result } = renderHook(() => useReducer(groupsReducerWithStorage, initialState));
let [groupingState, dispatch] = result.current;
act(() => {
dispatch(groupActions.updateGroupOptions({ id: groupingId, newOptionList: groupingOptions }));
});
[groupingState, dispatch] = result.current;
expect(groupingState.groupById[groupingId].itemsPerPage).toEqual(25);
act(() => {
dispatch(groupActions.updateGroupItemsPerPage({ id: groupingId, itemsPerPage: 12 }));
});
[groupingState, dispatch] = result.current;
expect(groupingState.groupById[groupingId].itemsPerPage).toEqual(12);
expect(groupingState.groupById[groupingId].activeGroups).toEqual(['user.name']);
});
});

View file

@ -25,8 +25,8 @@ export const initialState: GroupMap = {
const groupsReducer = (state: GroupMap, action: Action, groupsById: GroupsById) => {
switch (action.type) {
case ActionType.updateActiveGroup: {
const { id, activeGroup } = action.payload;
case ActionType.updateActiveGroups: {
const { id, activeGroups } = action.payload;
return {
...state,
groupById: {
@ -34,35 +34,7 @@ const groupsReducer = (state: GroupMap, action: Action, groupsById: GroupsById)
[id]: {
...defaultGroup,
...groupsById[id],
activeGroup,
},
},
};
}
case ActionType.updateGroupActivePage: {
const { id, activePage } = action.payload;
return {
...state,
groupById: {
...groupsById,
[id]: {
...defaultGroup,
...groupsById[id],
activePage,
},
},
};
}
case ActionType.updateGroupItemsPerPage: {
const { id, itemsPerPage } = action.payload;
return {
...state,
groupById: {
...groupsById,
[id]: {
...defaultGroup,
...groupsById[id],
itemsPerPage,
activeGroups,
},
},
};
@ -89,22 +61,10 @@ export const groupsReducerWithStorage = (state: GroupMap, action: Action) => {
if (storage) {
groupsInStorage = getAllGroupsInStorage(storage);
}
const trackedGroupIds = Object.keys(state.groupById);
const adjustedStorageGroups = Object.entries(groupsInStorage).reduce(
(acc: GroupsById, [key, group]) => ({
...acc,
[key]: {
// reset page to 0 if is initial state
...(trackedGroupIds.includes(key) ? group : { ...group, activePage: 0 }),
},
}),
{} as GroupsById
);
const groupsById: GroupsById = {
...state.groupById,
...adjustedStorageGroups,
...groupsInStorage,
};
const newState = groupsReducer(state, action, groupsById);

View file

@ -8,35 +8,21 @@
// action types
export enum ActionType {
updateActiveGroup = 'UPDATE_ACTIVE_GROUP',
updateGroupActivePage = 'UPDATE_GROUP_ACTIVE_PAGE',
updateGroupItemsPerPage = 'UPDATE_GROUP_ITEMS_PER_PAGE',
updateActiveGroups = 'UPDATE_ACTIVE_GROUPS',
updateGroupOptions = 'UPDATE_GROUP_OPTIONS',
}
export interface UpdateActiveGroup {
type: ActionType.updateActiveGroup;
payload: { activeGroup: string; id: string };
export interface UpdateActiveGroups {
type: ActionType.updateActiveGroups;
payload: { activeGroups: string[]; id: string };
}
export interface UpdateGroupActivePage {
type: ActionType.updateGroupActivePage;
payload: { activePage: number; id: string };
}
export interface UpdateGroupItemsPerPage {
type: ActionType.updateGroupItemsPerPage;
payload: { itemsPerPage: number; id: string };
}
export interface UpdateGroupOptions {
type: ActionType.updateGroupOptions;
payload: { newOptionList: GroupOption[]; id: string };
}
export type Action =
| UpdateActiveGroup
| UpdateGroupActivePage
| UpdateGroupItemsPerPage
| UpdateGroupOptions;
export type Action = UpdateActiveGroups | UpdateGroupOptions;
// state
@ -46,10 +32,8 @@ export interface GroupOption {
}
export interface GroupModel {
activeGroup: string;
activeGroups: string[];
options: GroupOption[];
activePage: number;
itemsPerPage: number;
}
export interface GroupsById {
@ -73,8 +57,6 @@ export interface Storage<T = any, S = void> {
export const EMPTY_GROUP_BY_ID: GroupsById = {};
export const defaultGroup: GroupModel = {
activePage: 0,
itemsPerPage: 25,
activeGroup: 'none',
activeGroups: ['none'],
options: [],
};

View file

@ -52,7 +52,7 @@ describe('useGetGroupSelector', () => {
useGetGroupSelector({
...defaultArgs,
groupingState: {
groupById: { [groupingId]: { ...defaultGroup, activeGroup: customField } },
groupById: { [groupingId]: { ...defaultGroup, activeGroups: [customField] } },
},
})
);
@ -72,12 +72,12 @@ describe('useGetGroupSelector', () => {
});
});
it('On group change, does nothing when set to prev selected group', () => {
it('On group change, removes selected group if already selected', () => {
const testGroup = {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroup: 'host.name',
activeGroups: ['host.name'],
},
};
const { result } = renderHook((props) => useGetGroupSelector(props), {
@ -89,7 +89,41 @@ describe('useGetGroupSelector', () => {
},
});
act(() => result.current.props.onGroupChange('host.name'));
expect(dispatch).toHaveBeenCalledTimes(0);
expect(dispatch).toHaveBeenCalledWith({
payload: {
id: groupingId,
activeGroups: ['none'],
},
type: ActionType.updateActiveGroups,
});
});
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', () => {
@ -97,7 +131,7 @@ describe('useGetGroupSelector', () => {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroup: 'host.name',
activeGroups: ['host.name'],
},
};
const { result } = renderHook((props) => useGetGroupSelector(props), {
@ -109,21 +143,15 @@ describe('useGetGroupSelector', () => {
},
});
act(() => result.current.props.onGroupChange('user.name'));
expect(dispatch).toHaveBeenNthCalledWith(1, {
payload: {
id: groupingId,
activePage: 0,
activeGroups: ['host.name', 'user.name'],
},
type: ActionType.updateGroupActivePage,
type: ActionType.updateActiveGroups,
});
expect(dispatch).toHaveBeenNthCalledWith(2, {
payload: {
id: groupingId,
activeGroup: 'user.name',
},
type: ActionType.updateActiveGroup,
});
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenCalledTimes(1);
});
it('On group change, sends telemetry', () => {
@ -131,7 +159,7 @@ describe('useGetGroupSelector', () => {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroup: 'host.name',
activeGroups: ['host.name'],
},
};
const { result } = renderHook((props) => useGetGroupSelector(props), {
@ -155,7 +183,7 @@ describe('useGetGroupSelector', () => {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroup: 'host.name',
activeGroups: ['host.name'],
},
};
const { result } = renderHook((props) => useGetGroupSelector(props), {
@ -179,10 +207,10 @@ describe('useGetGroupSelector', () => {
[groupingId]: {
...defaultGroup,
options: defaultGroupingOptions,
activeGroup: 'host.name',
activeGroups: ['host.name'],
},
};
const { result } = renderHook((props) => useGetGroupSelector(props), {
const { result, rerender } = renderHook((props) => useGetGroupSelector(props), {
initialProps: {
...defaultArgs,
groupingState: {
@ -191,17 +219,54 @@ describe('useGetGroupSelector', () => {
},
});
act(() => result.current.props.onGroupChange(customField));
expect(dispatch).toHaveBeenCalledTimes(3);
expect(dispatch).toHaveBeenNthCalledWith(3, {
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: {
id: groupingId,
newOptionList: [
...defaultGroupingOptions,
{
label: customField,
key: customField,
},
{ label: customField, key: customField },
{ label: 'another.custom', key: 'another.custom' },
],
id: 'test-table',
},
type: ActionType.updateGroupOptions,
});

View file

@ -22,6 +22,7 @@ export interface UseGetGroupSelectorArgs {
fields: FieldSpec[];
groupingId: string;
groupingState: GroupMap;
maxGroupingLevels?: number;
onGroupChange?: (param: { groupByField: string; tableId: string }) => void;
tracker?: (
type: UiCounterMetricType,
@ -36,22 +37,21 @@ export const useGetGroupSelector = ({
fields,
groupingId,
groupingState,
maxGroupingLevels = 1,
onGroupChange,
tracker,
}: UseGetGroupSelectorArgs) => {
const { activeGroup: selectedGroup, options } =
const { activeGroups: selectedGroups, options } =
groupByIdSelector({ groups: groupingState }, groupingId) ?? defaultGroup;
const setGroupsActivePage = useCallback(
(activePage: number) => {
dispatch(groupActions.updateGroupActivePage({ id: groupingId, activePage }));
},
[dispatch, groupingId]
);
const setSelectedGroup = useCallback(
(activeGroup: string) => {
dispatch(groupActions.updateActiveGroup({ id: groupingId, activeGroup }));
const setSelectedGroups = useCallback(
(activeGroups: string[]) => {
dispatch(
groupActions.updateActiveGroups({
id: groupingId,
activeGroups,
})
);
},
[dispatch, groupingId]
);
@ -65,11 +65,20 @@ export const useGetGroupSelector = ({
const onChange = useCallback(
(groupSelection: string) => {
if (groupSelection === selectedGroup) {
if (selectedGroups.find((selected) => selected === groupSelection)) {
const groups = selectedGroups.filter((selectedGroup) => selectedGroup !== groupSelection);
if (groups.length === 0) {
setSelectedGroups(['none']);
} else {
setSelectedGroups(groups);
}
return;
}
setGroupsActivePage(0);
setSelectedGroup(groupSelection);
const newSelectedGroups = isNoneGroup([groupSelection])
? [groupSelection]
: [...selectedGroups.filter((selectedGroup) => selectedGroup !== 'none'), groupSelection];
setSelectedGroups(newSelectedGroups);
// built-in telemetry: UI-counter
tracker?.(
@ -78,62 +87,57 @@ export const useGetGroupSelector = ({
);
onGroupChange?.({ tableId: groupingId, groupByField: groupSelection });
// only update options if the new selection is a custom field
if (
!isNoneGroup(groupSelection) &&
!options.find((o: GroupOption) => o.key === groupSelection)
) {
setOptions([
...defaultGroupingOptions,
{
label: groupSelection,
key: groupSelection,
},
]);
}
},
[
defaultGroupingOptions,
groupingId,
onGroupChange,
options,
selectedGroup,
setGroupsActivePage,
setOptions,
setSelectedGroup,
tracker,
]
[groupingId, onGroupChange, selectedGroups, setSelectedGroups, tracker]
);
useEffect(() => {
// only set options the first time, all other updates will be taken care of by onGroupChange
if (options.length > 0) return;
setOptions(
defaultGroupingOptions.find((o) => o.key === selectedGroup)
? defaultGroupingOptions
: [
...defaultGroupingOptions,
...(!isNoneGroup(selectedGroup)
? [
{
if (options.length === 0) {
return setOptions(
defaultGroupingOptions.find((o) => selectedGroups.find((selected) => selected === o.key))
? defaultGroupingOptions
: [
...defaultGroupingOptions,
...(!isNoneGroup(selectedGroups)
? selectedGroups.map((selectedGroup) => ({
key: selectedGroup,
label: selectedGroup,
},
]
: []),
]
);
}, [defaultGroupingOptions, options.length, selectedGroup, setOptions]);
}))
: []),
]
);
}
if (isNoneGroup(selectedGroups)) {
return;
}
const currentOptionKeys = options.map((o) => o.key);
const newOptions = [...options];
selectedGroups.forEach((groupSelection) => {
if (currentOptionKeys.includes(groupSelection)) {
return;
}
// these are custom fields
newOptions.push({
label: groupSelection,
key: groupSelection,
});
});
if (newOptions.length !== options.length) {
setOptions(newOptions);
}
}, [defaultGroupingOptions, options, selectedGroups, setOptions]);
return (
<GroupSelector
{...{
groupingId,
groupSelected: selectedGroup,
groupsSelected: selectedGroups,
'data-test-subj': 'alerts-table-group-selector',
onGroupChange: onChange,
fields,
maxGroupingLevels,
options,
}}
/>

View file

@ -30,7 +30,6 @@ const defaultArgs = {
groupStatsRenderer: jest.fn(),
inspectButton: <></>,
onGroupToggle: jest.fn(),
renderChildComponent: () => <p data-test-subj="innerTable">{'hello'}</p>,
},
};
@ -38,6 +37,9 @@ const groupingArgs = {
data: {},
isLoading: false,
takeActionItems: jest.fn(),
activePage: 0,
itemsPerPage: 25,
onGroupClose: () => {},
};
describe('useGrouping', () => {
@ -70,6 +72,8 @@ describe('useGrouping', () => {
value: 18,
},
},
renderChildComponent: () => <p data-test-subj="innerTable">{'hello'}</p>,
selectedGroup: 'none',
})}
</IntlProvider>
);
@ -84,7 +88,7 @@ describe('useGrouping', () => {
getItem.mockReturnValue(
JSON.stringify({
'test-table': {
activePage: 0,
itemsPerPageOptions: [10, 25, 50, 100],
itemsPerPage: 25,
activeGroup: 'kibana.alert.rule.name',
options: defaultGroupingOptions,
@ -95,7 +99,7 @@ describe('useGrouping', () => {
const { result, waitForNextUpdate } = renderHook(() => useGrouping(defaultArgs));
await waitForNextUpdate();
await waitForNextUpdate();
const { getByTestId, queryByTestId } = render(
const { getByTestId } = render(
<IntlProvider locale="en">
{result.current.getGrouping({
...groupingArgs,
@ -119,12 +123,13 @@ describe('useGrouping', () => {
value: 18,
},
},
renderChildComponent: jest.fn(),
selectedGroup: 'test',
})}
</IntlProvider>
);
expect(getByTestId('grouping-table')).toBeInTheDocument();
expect(queryByTestId('innerTable')).not.toBeInTheDocument();
});
});
});

View file

@ -11,8 +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 { useGroupingPagination } from './use_grouping_pagination';
import { groupActions, groupByIdSelector } from './state';
import { groupByIdSelector } from './state';
import { useGetGroupSelector } from './use_get_group_selector';
import { defaultGroup, GroupOption } from './types';
import { Grouping as GroupingComponent } from '../components/grouping';
@ -23,33 +22,37 @@ import { Grouping as GroupingComponent } from '../components/grouping';
interface Grouping<T> {
getGrouping: (props: DynamicGroupingProps<T>) => React.ReactElement;
groupSelector: React.ReactElement<GroupSelectorProps>;
pagination: {
reset: () => void;
pageIndex: number;
pageSize: number;
};
selectedGroup: string;
selectedGroups: string[];
}
/** Type for static grouping component props where T is the `GroupingAggregation`
/** Type for static grouping component props where T is the consumer `GroupingAggregation`
* @interface StaticGroupingProps<T>
*/
type StaticGroupingProps<T> = Pick<
GroupingProps<T>,
| 'groupPanelRenderer'
| 'groupStatsRenderer'
| 'inspectButton'
| 'onGroupToggle'
| 'renderChildComponent'
| 'unit'
'groupPanelRenderer' | 'groupStatsRenderer' | 'onGroupToggle' | 'unit'
>;
/** Type for dynamic grouping component props where T is the `GroupingAggregation`
/** Type for dynamic grouping component props where T is the consumer `GroupingAggregation`
* @interface DynamicGroupingProps<T>
*/
type DynamicGroupingProps<T> = Pick<GroupingProps<T>, 'data' | 'isLoading' | 'takeActionItems'>;
export type DynamicGroupingProps<T> = Pick<
GroupingProps<T>,
| 'activePage'
| 'data'
| 'groupingLevel'
| 'inspectButton'
| 'isLoading'
| 'itemsPerPage'
| 'onChangeGroupsItemsPerPage'
| 'onChangeGroupsPage'
| 'renderChildComponent'
| 'onGroupClose'
| 'selectedGroup'
| 'takeActionItems'
>;
/** Interface for configuring grouping package where T is the `GroupingAggregation`
/** Interface for configuring grouping package where T is the consumer `GroupingAggregation`
* @interface GroupingArgs<T>
*/
interface GroupingArgs<T> {
@ -57,6 +60,7 @@ interface GroupingArgs<T> {
defaultGroupingOptions: GroupOption[];
fields: FieldSpec[];
groupingId: string;
maxGroupingLevels?: number;
/** for tracking
* @param param { groupByField: string; tableId: string } selected group and table id
*/
@ -75,21 +79,22 @@ interface GroupingArgs<T> {
* @param defaultGroupingOptions defines the grouping options as an array of {@link GroupOption}
* @param fields FieldSpec array serialized version of DataViewField fields. Available in the custom grouping options
* @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 tracker telemetry handler
* @returns {@link Grouping} the grouping constructor { getGrouping, groupSelector, pagination, selectedGroup }
* @returns {@link Grouping} the grouping constructor { getGrouping, groupSelector, pagination, selectedGroups }
*/
export const useGrouping = <T,>({
componentProps,
defaultGroupingOptions,
fields,
groupingId,
maxGroupingLevels,
onGroupChange,
tracker,
}: GroupingArgs<T>): Grouping<T> => {
const [groupingState, dispatch] = useReducer(groupsReducerWithStorage, initialState);
const { activeGroup: selectedGroup } = useMemo(
const { activeGroups: selectedGroups } = useMemo(
() => groupByIdSelector({ groups: groupingState }, groupingId) ?? defaultGroup,
[groupingId, groupingState]
);
@ -100,56 +105,37 @@ export const useGrouping = <T,>({
fields,
groupingId,
groupingState,
maxGroupingLevels,
onGroupChange,
tracker,
});
const pagination = useGroupingPagination({ groupingId, groupingState, dispatch });
const getGrouping = useCallback(
/**
*
* @param props {@link DynamicGroupingProps}
*/
(props: DynamicGroupingProps<T>): React.ReactElement =>
isNoneGroup(selectedGroup) ? (
componentProps.renderChildComponent([])
isNoneGroup([props.selectedGroup]) ? (
props.renderChildComponent([])
) : (
<GroupingComponent
{...componentProps}
{...props}
groupingId={groupingId}
groupSelector={groupSelector}
pagination={pagination}
selectedGroup={selectedGroup}
groupingId={groupingId}
tracker={tracker}
/>
),
[componentProps, groupSelector, groupingId, pagination, selectedGroup, tracker]
[componentProps, groupSelector, groupingId, tracker]
);
const resetPagination = useCallback(() => {
dispatch(groupActions.updateGroupActivePage({ id: groupingId, activePage: 0 }));
}, [groupingId]);
return useMemo(
() => ({
getGrouping,
groupSelector,
selectedGroup,
pagination: {
reset: resetPagination,
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
},
selectedGroups,
}),
[
getGrouping,
groupSelector,
pagination.pageIndex,
pagination.pageSize,
resetPagination,
selectedGroup,
]
[getGrouping, groupSelector, selectedGroups]
);
};

View file

@ -1,53 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useCallback, useMemo } from 'react';
import { groupActions, groupByIdSelector } from './state';
import { Action, defaultGroup, GroupMap } from './types';
export interface UseGroupingPaginationArgs {
dispatch: React.Dispatch<Action>;
groupingId: string;
groupingState: GroupMap;
}
export const useGroupingPagination = ({
groupingId,
groupingState,
dispatch,
}: UseGroupingPaginationArgs) => {
const { activePage, itemsPerPage } =
groupByIdSelector({ groups: groupingState }, groupingId) ?? defaultGroup;
const setGroupsActivePage = useCallback(
(newActivePage: number) => {
dispatch(groupActions.updateGroupActivePage({ id: groupingId, activePage: newActivePage }));
},
[dispatch, groupingId]
);
const setGroupsItemsPerPage = useCallback(
(newItemsPerPage: number) => {
dispatch(
groupActions.updateGroupItemsPerPage({ id: groupingId, itemsPerPage: newItemsPerPage })
);
},
[dispatch, groupingId]
);
return useMemo(
() => ({
pageIndex: activePage,
pageSize: itemsPerPage,
onChangeItemsPerPage: setGroupsItemsPerPage,
onChangePage: setGroupsActivePage,
itemsPerPageOptions: [10, 25, 50, 100],
}),
[activePage, itemsPerPage, setGroupsActivePage, setGroupsItemsPerPage]
);
};

View file

@ -6,7 +6,8 @@
"jest",
"node",
"react",
"@emotion/react/types/css-prop"
"@emotion/react/types/css-prop",
"@kbn/ambient-ui-types"
]
},
"include": [

View file

@ -37,6 +37,7 @@ export const storybookAliases = {
expression_shape: 'src/plugins/expression_shape/.storybook',
expression_tagcloud: 'src/plugins/chart_expressions/expression_tagcloud/.storybook',
fleet: 'x-pack/plugins/fleet/.storybook',
grouping: 'packages/kbn-securitysolution-grouping/.storybook',
home: 'src/plugins/home/.storybook',
infra: 'x-pack/plugins/infra/.storybook',
kibana_react: 'src/plugins/kibana_react/.storybook',

View file

@ -10,7 +10,6 @@ import React from 'react';
import { useLocation } from 'react-router-dom';
import { SecurityPageName } from '../../../../common/constants';
import { useGlobalTime } from '../../containers/use_global_time';
import {
DEFAULT_STACK_BY_FIELD,
DEFAULT_STACK_BY_FIELD1,
@ -151,16 +150,6 @@ describe('AlertsTreemapPanel', () => {
await waitFor(() => expect(screen.getByTestId('treemapPanel')).toBeInTheDocument());
});
it('invokes useGlobalTime() with false to prevent global queries from being deleted when the component unmounts', async () => {
render(
<TestProviders>
<AlertsTreemapPanel {...defaultProps} />
</TestProviders>
);
await waitFor(() => expect(useGlobalTime).toBeCalledWith(false));
});
it('renders the panel with a hidden overflow-x', async () => {
render(
<TestProviders>

View file

@ -80,7 +80,7 @@ const AlertsTreemapPanelComponent: React.FC<Props> = ({
stackByWidth,
title,
}: Props) => {
const { to, from, deleteQuery, setQuery } = useGlobalTime(false);
const { to, from, deleteQuery, setQuery } = useGlobalTime();
// create a unique, but stable (across re-renders) query id
const uniqueQueryId = useMemo(() => `${ALERTS_TREEMAP_ID}-${uuidv4()}`, []);

View file

@ -110,7 +110,7 @@ const StatefulTopNComponent: React.FC<Props> = ({
value,
}) => {
const { uiSettings } = useKibana().services;
const { from, deleteQuery, setQuery, to } = useGlobalTime(false);
const { from, deleteQuery, setQuery, to } = useGlobalTime();
const options = getOptions(isActiveTimeline(scopeId ?? '') ? activeTimelineEventType : undefined);

View file

@ -1,46 +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 { renderHook } from '@testing-library/react-hooks';
import { TestProviders } from '../../mock';
import { useAlertPrevalence } from './use_alert_prevalence';
import { useGlobalTime } from '../use_global_time';
const from = '2022-07-28T08:20:18.966Z';
const to = '2022-07-28T08:20:18.966Z';
jest.mock('../use_global_time', () => {
const actual = jest.requireActual('../use_global_time');
return {
...actual,
useGlobalTime: jest
.fn()
.mockReturnValue({ from, to, setQuery: jest.fn(), deleteQuery: jest.fn() }),
};
});
describe('useAlertPrevalence', () => {
beforeEach(() => jest.resetAllMocks());
it('invokes useGlobalTime() with false to prevent global queries from being deleted when the component unmounts', () => {
renderHook(
() =>
useAlertPrevalence({
field: 'host.name',
value: ['Host-byc3w6qlpo'],
isActiveTimelines: false,
signalIndexName: null,
includeAlertIds: false,
}),
{
wrapper: TestProviders,
}
);
expect(useGlobalTime).toBeCalledWith(false);
});
});

View file

@ -44,7 +44,7 @@ export const useAlertPrevalence = ({
const timelineTime = useDeepEqualSelector((state) =>
inputsSelectors.timelineTimeRangeSelector(state)
);
const globalTime = useGlobalTime(false);
const globalTime = useGlobalTime();
let to: string | undefined;
let from: string | undefined;
if (ignoreTimerange === false) {

View file

@ -37,23 +37,77 @@ describe('useGlobalTime', () => {
expect(result1.to).toBe(0);
});
test('clear all queries at unmount when clearAllQuery is set to true', () => {
const { unmount } = renderHook(() => useGlobalTime());
test('clear query at unmount when setQuery has been called', () => {
const { result, unmount } = renderHook(() => useGlobalTime());
act(() => {
result.current.setQuery({
id: 'query-2',
inspect: { dsl: [], response: [] },
loading: false,
refetch: () => {},
searchSessionId: 'session-1',
});
});
unmount();
expect(mockDispatch.mock.calls[0][0].type).toEqual(
'x-pack/security_solution/local/inputs/DELETE_ALL_QUERY'
expect(mockDispatch.mock.calls.length).toBe(2);
expect(mockDispatch.mock.calls[1][0].type).toEqual(
'x-pack/security_solution/local/inputs/DELETE_QUERY'
);
});
test('do NOT clear all queries at unmount when clearAllQuery is set to false.', () => {
const { unmount } = renderHook(() => useGlobalTime(false));
test('do NOT clear query at unmount when setQuery has not been called', () => {
const { unmount } = renderHook(() => useGlobalTime());
unmount();
expect(mockDispatch.mock.calls.length).toBe(0);
});
test('do NOT clear all queries when setting state and clearAllQuery is set to true', () => {
const { rerender } = renderHook(() => useGlobalTime());
act(() => rerender());
expect(mockDispatch.mock.calls.length).toBe(0);
test('do clears only the dismounted queries at unmount when setQuery is called', () => {
const { result, unmount } = renderHook(() => useGlobalTime());
act(() => {
result.current.setQuery({
id: 'query-1',
inspect: { dsl: [], response: [] },
loading: false,
refetch: () => {},
searchSessionId: 'session-1',
});
});
act(() => {
result.current.setQuery({
id: 'query-2',
inspect: { dsl: [], response: [] },
loading: false,
refetch: () => {},
searchSessionId: 'session-1',
});
});
const { result: theOneWillNotBeDismounted } = renderHook(() => useGlobalTime());
act(() => {
theOneWillNotBeDismounted.current.setQuery({
id: 'query-3h',
inspect: { dsl: [], response: [] },
loading: false,
refetch: () => {},
searchSessionId: 'session-1',
});
});
unmount();
expect(mockDispatch).toHaveBeenCalledTimes(5);
expect(mockDispatch.mock.calls[3][0].payload.id).toEqual('query-1');
expect(mockDispatch.mock.calls[3][0].type).toEqual(
'x-pack/security_solution/local/inputs/DELETE_QUERY'
);
expect(mockDispatch.mock.calls[4][0].payload.id).toEqual('query-2');
expect(mockDispatch.mock.calls[4][0].type).toEqual(
'x-pack/security_solution/local/inputs/DELETE_QUERY'
);
});
});

View file

@ -6,7 +6,7 @@
*/
import { pick } from 'lodash/fp';
import { useCallback, useState, useEffect, useMemo } from 'react';
import { useCallback, useState, useEffect, useMemo, useRef } from 'react';
import { useDispatch } from 'react-redux';
import { InputsModelId } from '../../store/inputs/constants';
@ -15,15 +15,18 @@ import { inputsSelectors } from '../../store';
import { inputsActions } from '../../store/actions';
import type { SetQuery, DeleteQuery } from './types';
export const useGlobalTime = (clearAllQuery: boolean = true) => {
export const useGlobalTime = () => {
const dispatch = useDispatch();
const { from, to } = useDeepEqualSelector((state) =>
pick(['from', 'to'], inputsSelectors.globalTimeRangeSelector(state))
);
const [isInitializing, setIsInitializing] = useState(true);
const queryId = useRef<string[]>([]);
const setQuery = useCallback(
({ id, inspect, loading, refetch, searchSessionId }: SetQuery) =>
({ id, inspect, loading, refetch, searchSessionId }: SetQuery) => {
queryId.current = [...queryId.current, id];
dispatch(
inputsActions.setQuery({
inputId: InputsModelId.global,
@ -33,7 +36,8 @@ export const useGlobalTime = (clearAllQuery: boolean = true) => {
refetch,
searchSessionId,
})
),
);
},
[dispatch]
);
@ -50,13 +54,13 @@ export const useGlobalTime = (clearAllQuery: boolean = true) => {
// This effect must not have any mutable dependencies. Otherwise, the cleanup function gets called before the component unmounts.
useEffect(() => {
return () => {
if (clearAllQuery) {
dispatch(inputsActions.deleteAllQuery({ id: InputsModelId.global }));
if (queryId.current.length > 0) {
queryId.current.forEach((id) => deleteQuery({ id }));
}
};
}, [dispatch, clearAllQuery]);
}, [deleteQuery]);
const memoizedReturn = useMemo(
return useMemo(
() => ({
isInitializing,
from,
@ -66,8 +70,6 @@ export const useGlobalTime = (clearAllQuery: boolean = true) => {
}),
[deleteQuery, from, isInitializing, setQuery, to]
);
return memoizedReturn;
};
export type GlobalTimeArgs = Omit<ReturnType<typeof useGlobalTime>, 'deleteQuery'> &

View file

@ -11,9 +11,5 @@ import type React from 'react';
const actionCreator = actionCreatorFactory('x-pack/security_solution/groups');
export const updateGroupSelector = actionCreator<{
groupSelector: React.ReactElement;
groupSelector: React.ReactElement | null;
}>('UPDATE_GROUP_SELECTOR');
export const updateSelectedGroup = actionCreator<{
selectedGroup: string;
}>('UPDATE_SELECTED_GROUP');

View file

@ -6,20 +6,17 @@
*/
import { reducerWithInitialState } from 'typescript-fsa-reducers';
import { updateGroupSelector, updateSelectedGroup } from './actions';
import { updateGroupSelector } from './actions';
import type { GroupModel } from './types';
export const initialGroupingState: GroupModel = {
groupSelector: null,
selectedGroup: null,
};
export const groupsReducer = reducerWithInitialState(initialGroupingState)
.case(updateSelectedGroup, (state, { selectedGroup }) => ({
...state,
selectedGroup,
}))
.case(updateGroupSelector, (state, { groupSelector }) => ({
export const groupsReducer = reducerWithInitialState(initialGroupingState).case(
updateGroupSelector,
(state, { groupSelector }) => ({
...state,
groupSelector,
}));
})
);

View file

@ -11,7 +11,3 @@ import type { GroupState } from './types';
const groupSelector = (state: GroupState) => state.groups.groupSelector;
export const getGroupSelector = () => createSelector(groupSelector, (selector) => selector);
export const selectedGroup = (state: GroupState) => state.groups.selectedGroup;
export const getSelectedGroup = () => createSelector(selectedGroup, (group) => group);

View file

@ -7,7 +7,6 @@
export interface GroupModel {
groupSelector: React.ReactElement | null;
selectedGroup: string | null;
}
export interface GroupState {

View file

@ -852,7 +852,7 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
</Display>
{ruleId != null && (
<GroupedAlertsTable
currentAlertStatusFilterValue={filterGroup}
currentAlertStatusFilterValue={[filterGroup]}
defaultFilters={alertMergedFilters}
from={from}
globalFilters={filters}

View file

@ -12,7 +12,6 @@ import { AlertsCountPanel } from '.';
import type { Status } from '../../../../../common/detection_engine/schemas/common';
import { useQueryToggle } from '../../../../common/containers/query_toggle';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { DEFAULT_STACK_BY_FIELD, DEFAULT_STACK_BY_FIELD1 } from '../common/config';
import { TestProviders } from '../../../../common/mock';
import { ChartContextMenu } from '../../../pages/detection_engine/chart_panels/chart_context_menu';
@ -109,18 +108,6 @@ describe('AlertsCountPanel', () => {
});
});
it('invokes useGlobalTime() with false to prevent global queries from being deleted when the component unmounts', async () => {
await act(async () => {
mount(
<TestProviders>
<AlertsCountPanel {...defaultProps} />
</TestProviders>
);
expect(useGlobalTime).toBeCalledWith(false);
});
});
it('renders with the specified `alignHeader` alignment', async () => {
await act(async () => {
const wrapper = mount(

View file

@ -84,7 +84,7 @@ export const AlertsCountPanel = memo<AlertsCountPanelProps>(
isExpanded,
setIsExpanded,
}) => {
const { to, from, deleteQuery, setQuery } = useGlobalTime(false);
const { to, from, deleteQuery, setQuery } = useGlobalTime();
const isChartEmbeddablesEnabled = useIsExperimentalFeatureEnabled('chartEmbeddablesEnabled');
const isAlertsPageChartsEnabled = useIsExperimentalFeatureEnabled('alertsPageChartsEnabled');
// create a unique, but stable (across re-renders) query id

View file

@ -152,7 +152,7 @@ export const AlertsHistogramPanel = memo<AlertsHistogramPanelProps>(
isExpanded,
setIsExpanded,
}) => {
const { to, from, deleteQuery, setQuery } = useGlobalTime(false);
const { to, from, deleteQuery, setQuery } = useGlobalTime();
// create a unique, but stable (across re-renders) query id
const uniqueQueryId = useMemo(() => `${DETECTIONS_HISTOGRAM_ID}-${uuidv4()}`, []);

View file

@ -82,7 +82,7 @@ export const useSummaryChartData: UseAlerts = ({
signalIndexName,
skip = false,
}) => {
const { to, from, deleteQuery, setQuery } = useGlobalTime(false);
const { to, from, deleteQuery, setQuery } = useGlobalTime();
const [updatedAt, setUpdatedAt] = useState(Date.now());
const [items, setItems] = useState<SummaryChartsData[]>([]);

View file

@ -0,0 +1,376 @@
/*
* 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 { fireEvent, render, within } from '@testing-library/react';
import type { Filter } from '@kbn/es-query';
import useResizeObserver from 'use-resize-observer/polyfilled';
import '../../../common/mock/match_media';
import {
createSecuritySolutionStorageMock,
kibanaObservable,
mockGlobalState,
SUB_PLUGINS_REDUCER,
TestProviders,
} from '../../../common/mock';
import type { AlertsTableComponentProps } from './alerts_grouping';
import { GroupedAlertsTable } from './alerts_grouping';
import { TableId } from '@kbn/securitysolution-data-table';
import { useSourcererDataView } from '../../../common/containers/sourcerer';
import type { UseFieldBrowserOptionsProps } from '../../../timelines/components/fields_browser';
import { createStore } from '../../../common/store';
import { useKibana as mockUseKibana } from '../../../common/lib/kibana/__mocks__';
import { createTelemetryServiceMock } from '../../../common/lib/telemetry/telemetry_service.mock';
import { useQueryAlerts } from '../../containers/detection_engine/alerts/use_query';
import { groupingSearchResponse } from './grouping_settings/mock';
jest.mock('../../containers/detection_engine/alerts/use_query');
jest.mock('../../../common/containers/sourcerer');
jest.mock('../../../common/utils/normalize_time_range');
jest.mock('../../../common/containers/use_global_time', () => ({
useGlobalTime: jest.fn().mockReturnValue({
from: '2020-07-07T08:20:18.966Z',
isInitializing: false,
to: '2020-07-08T08:20:18.966Z',
setQuery: jest.fn(),
}),
}));
const mockOptions = [
{ label: 'ruleName', key: 'kibana.alert.rule.name' },
{ label: 'userName', key: 'user.name' },
{ label: 'hostName', key: 'host.name' },
{ label: 'sourceIP', key: 'source.ip' },
];
//
jest.mock('./grouping_settings', () => {
const actual = jest.requireActual('./grouping_settings');
return {
...actual,
getDefaultGroupingOptions: () => mockOptions,
};
});
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});
const mockUseFieldBrowserOptions = jest.fn();
jest.mock('../../../timelines/components/fields_browser', () => ({
useFieldBrowserOptions: (props: UseFieldBrowserOptionsProps) => mockUseFieldBrowserOptions(props),
}));
const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock;
jest.mock('use-resize-observer/polyfilled');
mockUseResizeObserver.mockImplementation(() => ({}));
const mockedUseKibana = mockUseKibana();
const mockedTelemetry = createTelemetryServiceMock();
jest.mock('../../../common/lib/kibana', () => {
const original = jest.requireActual('../../../common/lib/kibana');
return {
...original,
useKibana: () => ({
...mockedUseKibana,
services: {
...mockedUseKibana.services,
telemetry: mockedTelemetry,
},
}),
};
});
jest.mock('./timeline_actions/use_add_bulk_to_timeline', () => ({
useAddBulkToTimelineAction: jest.fn(() => {}),
}));
const sourcererDataView = {
indicesExist: true,
loading: false,
indexPattern: {
fields: [],
},
browserFields: {},
};
const renderChildComponent = (groupingFilters: Filter[]) => <p data-test-subj="alerts-table" />;
const testProps: AlertsTableComponentProps = {
defaultFilters: [],
from: '2020-07-07T08:20:18.966Z',
globalFilters: [],
globalQuery: {
query: 'query',
language: 'language',
},
hasIndexMaintenance: true,
hasIndexWrite: true,
loading: false,
renderChildComponent,
runtimeMappings: {},
signalIndexName: 'test',
tableId: TableId.test,
to: '2020-07-08T08:20:18.966Z',
};
const mockUseQueryAlerts = useQueryAlerts as jest.Mock;
const mockQueryResponse = {
loading: false,
data: {},
setQuery: () => {},
response: '',
request: '',
refetch: () => {},
};
const getMockStorageState = (groups: string[] = ['none']) =>
JSON.stringify({
[testProps.tableId]: {
activeGroups: groups,
options: mockOptions,
},
});
describe('GroupedAlertsTable', () => {
const { storage } = createSecuritySolutionStorageMock();
let store: ReturnType<typeof createStore>;
beforeEach(() => {
jest.clearAllMocks();
store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
(useSourcererDataView as jest.Mock).mockReturnValue({
...sourcererDataView,
selectedPatterns: ['myFakebeat-*'],
});
mockUseQueryAlerts.mockImplementation((i) => {
if (i.skip) {
return mockQueryResponse;
}
if (i.query.aggs.groupByFields.multi_terms != null) {
return {
...mockQueryResponse,
data: groupingSearchResponse.ruleName,
};
}
return {
...mockQueryResponse,
data: i.query.aggs.groupByFields.terms.field != null ? groupingSearchResponse.hostName : {},
};
});
});
it('calls the proper initial dispatch actions for groups', () => {
const { getByTestId, queryByTestId } = render(
<TestProviders store={store}>
<GroupedAlertsTable {...testProps} />
</TestProviders>
);
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'
);
});
it('renders empty grouping table when group is selected without data', async () => {
mockUseQueryAlerts.mockReturnValue(mockQueryResponse);
jest
.spyOn(window.localStorage, 'getItem')
.mockReturnValue(getMockStorageState(['kibana.alert.rule.name']));
const { getByTestId, queryByTestId } = render(
<TestProviders store={store}>
<GroupedAlertsTable {...testProps} />
</TestProviders>
);
expect(queryByTestId('alerts-table')).not.toBeInTheDocument();
expect(getByTestId('empty-results-panel')).toBeInTheDocument();
});
it('renders grouping table in first accordion level when single group is selected', async () => {
jest
.spyOn(window.localStorage, 'getItem')
.mockReturnValue(getMockStorageState(['kibana.alert.rule.name']));
const { getAllByTestId } = render(
<TestProviders store={store}>
<GroupedAlertsTable {...testProps} />
</TestProviders>
);
fireEvent.click(getAllByTestId('group-panel-toggle')[0]);
const level0 = getAllByTestId('grouping-accordion-content')[0];
expect(within(level0).getByTestId('alerts-table')).toBeInTheDocument();
});
it('renders grouping table in second accordion level when 2 groups are selected', async () => {
jest
.spyOn(window.localStorage, 'getItem')
.mockReturnValue(getMockStorageState(['kibana.alert.rule.name', 'host.name']));
const { getAllByTestId } = render(
<TestProviders store={store}>
<GroupedAlertsTable {...testProps} />
</TestProviders>
);
fireEvent.click(getAllByTestId('group-panel-toggle')[0]);
const level0 = getAllByTestId('grouping-accordion-content')[0];
expect(within(level0).queryByTestId('alerts-table')).not.toBeInTheDocument();
fireEvent.click(within(level0).getAllByTestId('group-panel-toggle')[0]);
const level1 = within(getAllByTestId('grouping-accordion-content')[1]);
expect(level1.getByTestId('alerts-table')).toBeInTheDocument();
});
it('resets all levels pagination when selected group changes', async () => {
jest
.spyOn(window.localStorage, 'getItem')
.mockReturnValue(getMockStorageState(['kibana.alert.rule.name', 'host.name', 'user.name']));
const { getByTestId, getAllByTestId } = render(
<TestProviders store={store}>
<GroupedAlertsTable {...testProps} />
</TestProviders>
);
fireEvent.click(getByTestId('pagination-button-1'));
fireEvent.click(getAllByTestId('group-panel-toggle')[0]);
const level0 = getAllByTestId('grouping-accordion-content')[0];
fireEvent.click(within(level0).getByTestId('pagination-button-1'));
fireEvent.click(within(level0).getAllByTestId('group-panel-toggle')[0]);
const level1 = getAllByTestId('grouping-accordion-content')[1];
fireEvent.click(within(level1).getByTestId('pagination-button-1'));
[
getByTestId('grouping-level-0-pagination'),
getByTestId('grouping-level-1-pagination'),
getByTestId('grouping-level-2-pagination'),
].forEach((pagination) => {
expect(
within(pagination).getByTestId('pagination-button-0').getAttribute('aria-current')
).toEqual(null);
expect(
within(pagination).getByTestId('pagination-button-1').getAttribute('aria-current')
).toEqual('true');
});
fireEvent.click(getAllByTestId('group-selector-dropdown')[0]);
fireEvent.click(getAllByTestId('panel-user.name')[0]);
[
getByTestId('grouping-level-0-pagination'),
getByTestId('grouping-level-1-pagination'),
// level 2 has been removed with the group selection change
].forEach((pagination) => {
expect(
within(pagination).getByTestId('pagination-button-0').getAttribute('aria-current')
).toEqual('true');
expect(
within(pagination).getByTestId('pagination-button-1').getAttribute('aria-current')
).toEqual(null);
});
});
it('resets all levels pagination when global query updates', async () => {
jest
.spyOn(window.localStorage, 'getItem')
.mockReturnValue(getMockStorageState(['kibana.alert.rule.name', 'host.name', 'user.name']));
const { getByTestId, getAllByTestId, rerender } = render(
<TestProviders store={store}>
<GroupedAlertsTable {...testProps} />
</TestProviders>
);
fireEvent.click(getByTestId('pagination-button-1'));
fireEvent.click(getAllByTestId('group-panel-toggle')[0]);
const level0 = getAllByTestId('grouping-accordion-content')[0];
fireEvent.click(within(level0).getByTestId('pagination-button-1'));
fireEvent.click(within(level0).getAllByTestId('group-panel-toggle')[0]);
const level1 = getAllByTestId('grouping-accordion-content')[1];
fireEvent.click(within(level1).getByTestId('pagination-button-1'));
rerender(
<TestProviders store={store}>
<GroupedAlertsTable
{...{ ...testProps, globalQuery: { query: 'updated', language: 'language' } }}
/>
</TestProviders>
);
[
getByTestId('grouping-level-0-pagination'),
getByTestId('grouping-level-1-pagination'),
getByTestId('grouping-level-2-pagination'),
].forEach((pagination) => {
expect(
within(pagination).getByTestId('pagination-button-0').getAttribute('aria-current')
).toEqual('true');
expect(
within(pagination).getByTestId('pagination-button-1').getAttribute('aria-current')
).toEqual(null);
});
});
it('resets only most inner group pagination when its parent groups open/close', async () => {
jest
.spyOn(window.localStorage, 'getItem')
.mockReturnValue(getMockStorageState(['kibana.alert.rule.name', 'host.name', 'user.name']));
const { getByTestId, getAllByTestId } = render(
<TestProviders store={store}>
<GroupedAlertsTable {...testProps} />
</TestProviders>
);
fireEvent.click(getByTestId('pagination-button-1'));
fireEvent.click(getAllByTestId('group-panel-toggle')[0]);
const level0 = getAllByTestId('grouping-accordion-content')[0];
fireEvent.click(within(level0).getByTestId('pagination-button-1'));
fireEvent.click(within(level0).getAllByTestId('group-panel-toggle')[0]);
const level1 = getAllByTestId('grouping-accordion-content')[1];
fireEvent.click(within(level1).getByTestId('pagination-button-1'));
fireEvent.click(within(level0).getAllByTestId('group-panel-toggle')[28]);
[
getByTestId('grouping-level-0-pagination'),
getByTestId('grouping-level-1-pagination'),
].forEach((pagination) => {
expect(
within(pagination).getByTestId('pagination-button-0').getAttribute('aria-current')
).toEqual(null);
expect(
within(pagination).getByTestId('pagination-button-1').getAttribute('aria-current')
).toEqual('true');
});
expect(
within(getByTestId('grouping-level-2-pagination'))
.getByTestId('pagination-button-0')
.getAttribute('aria-current')
).toEqual('true');
expect(
within(getByTestId('grouping-level-2-pagination'))
.getByTestId('pagination-button-1')
.getAttribute('aria-current')
).toEqual(null);
});
});

View file

@ -5,50 +5,29 @@
* 2.0.
*/
import { isEmpty } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
import { useDispatch } from 'react-redux';
import { v4 as uuidv4 } from 'uuid';
import { useDispatch, useSelector } from 'react-redux';
import type { Filter, Query } from '@kbn/es-query';
import { buildEsQuery } from '@kbn/es-query';
import { getEsQueryConfig } from '@kbn/data-plugin/common';
import type {
GroupingFieldTotalAggregation,
GroupingAggregation,
} from '@kbn/securitysolution-grouping';
import { useGrouping, isNoneGroup } from '@kbn/securitysolution-grouping';
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 type { AlertsGroupingAggregation } from './grouping_settings/types';
import { groupSelectors } from '../../../common/store/grouping';
import type { State } from '../../../common/store';
import { updateGroupSelector } from '../../../common/store/grouping/actions';
import type { Status } from '../../../../common/detection_engine/schemas/common';
import { InspectButton } from '../../../common/components/inspect';
import { defaultUnit } from '../../../common/components/toolbar/unit';
import { useGlobalTime } from '../../../common/containers/use_global_time';
import { combineQueries } from '../../../common/lib/kuery';
import { useSourcererDataView } from '../../../common/containers/sourcerer';
import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query';
import { useKibana } from '../../../common/lib/kibana';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { useInspectButton } from '../alerts_kpis/common/hooks';
import { buildTimeRangeFilter } from './helpers';
import * as i18n from './translations';
import { useQueryAlerts } from '../../containers/detection_engine/alerts/use_query';
import { ALERTS_QUERY_NAMES } from '../../containers/detection_engine/alerts/constants';
import {
getAlertsGroupingQuery,
getDefaultGroupingOptions,
renderGroupPanel,
getStats,
useGroupTakeActionsItems,
} from './grouping_settings';
import { updateGroupSelector, updateSelectedGroup } from '../../../common/store/grouping/actions';
import { getDefaultGroupingOptions, renderGroupPanel, getStats } from './grouping_settings';
import { useKibana } from '../../../common/lib/kibana';
import { GroupedSubLevel } from './alerts_sub_grouping';
import { track } from '../../../common/lib/telemetry';
const ALERTS_GROUPING_ID = 'alerts-grouping';
export interface AlertsTableComponentProps {
currentAlertStatusFilterValue?: Status;
currentAlertStatusFilterValue?: Status[];
defaultFilters?: Filter[];
from: string;
globalFilters: Filter[];
@ -63,52 +42,37 @@ export interface AlertsTableComponentProps {
to: string;
}
export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
defaultFilters = [],
from,
globalFilters,
globalQuery,
hasIndexMaintenance,
hasIndexWrite,
loading,
tableId,
to,
runtimeMappings,
signalIndexName,
currentAlertStatusFilterValue,
renderChildComponent,
}) => {
const DEFAULT_PAGE_SIZE = 25;
const DEFAULT_PAGE_INDEX = 0;
const MAX_GROUPING_LEVELS = 3;
const useStorage = (storage: Storage, tableId: string) =>
useMemo(
() => ({
getStoragePageSize: (): number[] => {
const pageSizes = storage.get(`grouping-table-${tableId}`);
if (!pageSizes) {
return Array(MAX_GROUPING_LEVELS).fill(DEFAULT_PAGE_SIZE);
}
return pageSizes;
},
setStoragePageSize: (pageSizes: number[]) => {
storage.set(`grouping-table-${tableId}`, pageSizes);
},
}),
[storage, tableId]
);
const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> = (props) => {
const dispatch = useDispatch();
const { browserFields, indexPattern, selectedPatterns } = useSourcererDataView(
SourcererScopeName.detections
);
const { indexPattern, selectedPatterns } = useSourcererDataView(SourcererScopeName.detections);
const {
services: { uiSettings, telemetry },
services: { storage, telemetry },
} = useKibana();
const getGlobalQuery = useCallback(
(customFilters: Filter[]) => {
if (browserFields != null && indexPattern != null) {
return combineQueries({
config: getEsQueryConfig(uiSettings),
dataProviders: [],
indexPattern,
browserFields,
filters: [
...(defaultFilters ?? []),
...globalFilters,
...customFilters,
...buildTimeRangeFilter(from, to),
],
kqlQuery: globalQuery,
kqlMode: globalQuery.language,
});
}
return null;
},
[browserFields, indexPattern, uiSettings, defaultFilters, globalFilters, from, to, globalQuery]
);
const { getStoragePageSize, setStoragePageSize } = useStorage(storage, props.tableId);
const { onGroupChange, onGroupToggle } = useMemo(
() => ({
@ -125,153 +89,146 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
[telemetry]
);
// create a unique, but stable (across re-renders) query id
const uniqueQueryId = useMemo(() => `${ALERTS_GROUPING_ID}-${uuidv4()}`, []);
const inspect = useMemo(
() => (
<InspectButton queryId={uniqueQueryId} inspectIndex={0} title={i18n.INSPECT_GROUPING_TITLE} />
),
[uniqueQueryId]
);
const { groupSelector, getGrouping, selectedGroup, pagination } = useGrouping({
const { groupSelector, getGrouping, selectedGroups } = useGrouping({
componentProps: {
groupPanelRenderer: renderGroupPanel,
groupStatsRenderer: getStats,
inspectButton: inspect,
onGroupToggle,
renderChildComponent,
unit: defaultUnit,
},
defaultGroupingOptions: getDefaultGroupingOptions(tableId),
defaultGroupingOptions: getDefaultGroupingOptions(props.tableId),
fields: indexPattern.fields,
groupingId: tableId,
groupingId: props.tableId,
maxGroupingLevels: MAX_GROUPING_LEVELS,
onGroupChange,
tracker: track,
});
const resetPagination = pagination.reset;
const getGroupSelector = groupSelectors.getGroupSelector();
const groupSelectorInRedux = useSelector((state: State) => getGroupSelector(state));
const selectorOptions = useRef<GroupOption[]>([]);
useEffect(() => {
dispatch(updateGroupSelector({ groupSelector }));
}, [dispatch, groupSelector]);
useEffect(() => {
dispatch(updateSelectedGroup({ selectedGroup }));
}, [dispatch, selectedGroup]);
useInvalidFilterQuery({
id: tableId,
filterQuery: getGlobalQuery([])?.filterQuery,
kqlError: getGlobalQuery([])?.kqlError,
query: globalQuery,
startDate: from,
endDate: to,
});
const { deleteQuery, setQuery } = useGlobalTime(false);
const additionalFilters = useMemo(() => {
resetPagination();
try {
return [
buildEsQuery(undefined, globalQuery != null ? [globalQuery] : [], [
...(globalFilters?.filter((f) => f.meta.disabled === false) ?? []),
...(defaultFilters ?? []),
]),
];
} catch (e) {
return [];
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 }));
}
}, [defaultFilters, globalFilters, globalQuery, resetPagination]);
}, [dispatch, groupSelector, groupSelectorInRedux, selectedGroups]);
const queryGroups = useMemo(
() =>
getAlertsGroupingQuery({
additionalFilters,
selectedGroup,
from,
runtimeMappings,
to,
pageSize: pagination.pageSize,
pageIndex: pagination.pageIndex,
}),
[
additionalFilters,
selectedGroup,
from,
runtimeMappings,
to,
pagination.pageSize,
pagination.pageIndex,
]
const [pageIndex, setPageIndex] = useState<number[]>(
Array(MAX_GROUPING_LEVELS).fill(DEFAULT_PAGE_INDEX)
);
const [pageSize, setPageSize] = useState<number[]>(getStoragePageSize);
const resetAllPagination = useCallback(() => {
setPageIndex((curr) => curr.map(() => DEFAULT_PAGE_INDEX));
}, []);
useEffect(() => {
resetAllPagination();
}, [resetAllPagination, selectedGroups]);
const setPageVar = useCallback(
(newNumber: number, groupingLevel: number, pageType: 'index' | 'size') => {
if (pageType === 'index') {
setPageIndex((currentIndex) => {
const newArr = [...currentIndex];
newArr[groupingLevel] = newNumber;
return newArr;
});
}
if (pageType === 'size') {
setPageSize((currentIndex) => {
const newArr = [...currentIndex];
newArr[groupingLevel] = newNumber;
setStoragePageSize(newArr);
return newArr;
});
}
},
[setStoragePageSize]
);
const {
data: alertsGroupsData,
loading: isLoadingGroups,
refetch,
request,
response,
setQuery: setAlertsQuery,
} = useQueryAlerts<
{},
GroupingAggregation<AlertsGroupingAggregation> &
GroupingFieldTotalAggregation<AlertsGroupingAggregation>
>({
query: queryGroups,
indexName: signalIndexName,
queryName: ALERTS_QUERY_NAMES.ALERTS_GROUPING,
skip: isNoneGroup(selectedGroup),
const nonGroupingFilters = useRef({
defaultFilters: props.defaultFilters,
globalFilters: props.globalFilters,
globalQuery: props.globalQuery,
});
useEffect(() => {
if (!isNoneGroup(selectedGroup)) {
setAlertsQuery(queryGroups);
const nonGrouping = {
defaultFilters: props.defaultFilters,
globalFilters: props.globalFilters,
globalQuery: props.globalQuery,
};
if (!isEqual(nonGroupingFilters.current, nonGrouping)) {
resetAllPagination();
nonGroupingFilters.current = nonGrouping;
}
}, [queryGroups, selectedGroup, setAlertsQuery]);
}, [props.defaultFilters, props.globalFilters, props.globalQuery, resetAllPagination]);
useInspectButton({
deleteQuery,
loading: isLoadingGroups,
response,
setQuery,
refetch,
request,
uniqueQueryId,
});
const getLevel = useCallback(
(level: number, selectedGroup: string, parentGroupingFilter?: string) => {
let rcc;
if (level < selectedGroups.length - 1) {
rcc = (groupingFilters: Filter[]) => {
return getLevel(
level + 1,
selectedGroups[level + 1],
JSON.stringify([
...groupingFilters,
...(parentGroupingFilter ? JSON.parse(parentGroupingFilter) : []),
])
);
};
} else {
rcc = (groupingFilters: Filter[]) => {
return props.renderChildComponent([
...groupingFilters,
...(parentGroupingFilter ? JSON.parse(parentGroupingFilter) : []),
]);
};
}
const takeActionItems = useGroupTakeActionsItems({
indexName: indexPattern.title,
currentStatus: currentAlertStatusFilterValue,
showAlertStatusActions: hasIndexWrite && hasIndexMaintenance,
});
const getTakeActionItems = useCallback(
(groupFilters: Filter[], groupNumber: number) =>
takeActionItems({
query: getGlobalQuery([...(defaultFilters ?? []), ...groupFilters])?.filterQuery,
tableId,
groupNumber,
selectedGroup,
}),
[defaultFilters, getGlobalQuery, selectedGroup, tableId, takeActionItems]
);
const groupedAlerts = useMemo(
() =>
getGrouping({
data: alertsGroupsData?.aggregations,
isLoading: loading || isLoadingGroups,
takeActionItems: getTakeActionItems,
}),
[alertsGroupsData?.aggregations, getGrouping, getTakeActionItems, isLoadingGroups, loading]
const resetGroupChildrenPagination = (parentLevel: number) => {
setPageIndex((allPages) => {
const resetPages = allPages.splice(parentLevel + 1, allPages.length);
return [...allPages, ...resetPages.map(() => DEFAULT_PAGE_INDEX)];
});
};
return (
<GroupedSubLevel
{...props}
getGrouping={getGrouping}
groupingLevel={level}
onGroupClose={() => resetGroupChildrenPagination(level)}
pageIndex={pageIndex[level] ?? DEFAULT_PAGE_INDEX}
pageSize={pageSize[level] ?? DEFAULT_PAGE_SIZE}
parentGroupingFilter={parentGroupingFilter}
renderChildComponent={rcc}
selectedGroup={selectedGroup}
setPageIndex={(newIndex: number) => setPageVar(newIndex, level, 'index')}
setPageSize={(newSize: number) => setPageVar(newSize, level, 'size')}
/>
);
},
[getGrouping, pageIndex, pageSize, props, selectedGroups, setPageVar]
);
if (isEmpty(selectedPatterns)) {
return null;
}
return groupedAlerts;
return getLevel(0, selectedGroups[0]);
};
export const GroupedAlertsTable = React.memo(GroupedAlertsTableComponent);

View file

@ -0,0 +1,259 @@
/*
* 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, useEffect, useMemo } from 'react';
import { v4 as uuidv4 } from 'uuid';
import type { Filter, Query } from '@kbn/es-query';
import { buildEsQuery } from '@kbn/es-query';
import type { GroupingAggregation } from '@kbn/securitysolution-grouping';
import { isNoneGroup } from '@kbn/securitysolution-grouping';
import { getEsQueryConfig } from '@kbn/data-plugin/common';
import type { DynamicGroupingProps } from '@kbn/securitysolution-grouping/src';
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
import type { TableIdLiteral } from '@kbn/securitysolution-data-table';
import { combineQueries } from '../../../common/lib/kuery';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import type { AlertsGroupingAggregation } from './grouping_settings/types';
import type { Status } from '../../../../common/detection_engine/schemas/common';
import { InspectButton } from '../../../common/components/inspect';
import { useSourcererDataView } from '../../../common/containers/sourcerer';
import { useKibana } from '../../../common/lib/kibana';
import { useGlobalTime } from '../../../common/containers/use_global_time';
import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query';
import { useInspectButton } from '../alerts_kpis/common/hooks';
import { buildTimeRangeFilter } from './helpers';
import * as i18n from './translations';
import { useQueryAlerts } from '../../containers/detection_engine/alerts/use_query';
import { ALERTS_QUERY_NAMES } from '../../containers/detection_engine/alerts/constants';
import { getAlertsGroupingQuery, useGroupTakeActionsItems } from './grouping_settings';
const ALERTS_GROUPING_ID = 'alerts-grouping';
interface OwnProps {
currentAlertStatusFilterValue?: Status[];
defaultFilters?: Filter[];
from: string;
getGrouping: (
props: Omit<DynamicGroupingProps<AlertsGroupingAggregation>, 'groupSelector' | 'pagination'>
) => React.ReactElement;
globalFilters: Filter[];
globalQuery: Query;
groupingLevel?: number;
hasIndexMaintenance: boolean;
hasIndexWrite: boolean;
loading: boolean;
onGroupClose: () => void;
pageIndex: number;
pageSize: number;
parentGroupingFilter?: string;
renderChildComponent: (groupingFilters: Filter[]) => React.ReactElement;
runtimeMappings: MappingRuntimeFields;
selectedGroup: string;
setPageIndex: (newIndex: number) => void;
setPageSize: (newSize: number) => void;
signalIndexName: string | null;
tableId: TableIdLiteral;
to: string;
}
export type AlertsTableComponentProps = OwnProps;
export const GroupedSubLevelComponent: React.FC<AlertsTableComponentProps> = ({
currentAlertStatusFilterValue,
defaultFilters = [],
from,
getGrouping,
globalFilters,
globalQuery,
groupingLevel,
hasIndexMaintenance,
hasIndexWrite,
loading,
onGroupClose,
pageIndex,
pageSize,
parentGroupingFilter,
renderChildComponent,
runtimeMappings,
selectedGroup,
setPageIndex,
setPageSize,
signalIndexName,
tableId,
to,
}) => {
const {
services: { uiSettings },
} = useKibana();
const { browserFields, indexPattern } = useSourcererDataView(SourcererScopeName.detections);
const getGlobalQuery = useCallback(
(customFilters: Filter[]) => {
if (browserFields != null && indexPattern != null) {
return combineQueries({
config: getEsQueryConfig(uiSettings),
dataProviders: [],
indexPattern,
browserFields,
filters: [
...(defaultFilters ?? []),
...globalFilters,
...customFilters,
...(parentGroupingFilter ? JSON.parse(parentGroupingFilter) : []),
...buildTimeRangeFilter(from, to),
],
kqlQuery: globalQuery,
kqlMode: globalQuery.language,
});
}
return null;
},
[
browserFields,
defaultFilters,
from,
globalFilters,
globalQuery,
indexPattern,
parentGroupingFilter,
to,
uiSettings,
]
);
const additionalFilters = useMemo(() => {
try {
return [
buildEsQuery(undefined, globalQuery != null ? [globalQuery] : [], [
...(globalFilters?.filter((f) => f.meta.disabled === false) ?? []),
...(defaultFilters ?? []),
...(parentGroupingFilter ? JSON.parse(parentGroupingFilter) : []),
]),
];
} catch (e) {
return [];
}
}, [defaultFilters, globalFilters, globalQuery, parentGroupingFilter]);
const queryGroups = useMemo(() => {
return getAlertsGroupingQuery({
additionalFilters,
selectedGroup,
from,
runtimeMappings,
to,
pageSize,
pageIndex,
});
}, [additionalFilters, from, pageIndex, pageSize, runtimeMappings, selectedGroup, to]);
const emptyGlobalQuery = useMemo(() => getGlobalQuery([]), [getGlobalQuery]);
useInvalidFilterQuery({
id: tableId,
filterQuery: emptyGlobalQuery?.filterQuery,
kqlError: emptyGlobalQuery?.kqlError,
query: globalQuery,
startDate: from,
endDate: to,
});
const {
data: alertsGroupsData,
loading: isLoadingGroups,
refetch,
request,
response,
setQuery: setAlertsQuery,
} = useQueryAlerts<{}, GroupingAggregation<AlertsGroupingAggregation>>({
query: queryGroups,
indexName: signalIndexName,
queryName: ALERTS_QUERY_NAMES.ALERTS_GROUPING,
skip: isNoneGroup([selectedGroup]),
});
useEffect(() => {
if (!isNoneGroup([selectedGroup])) {
setAlertsQuery(queryGroups);
}
}, [queryGroups, selectedGroup, setAlertsQuery]);
const { deleteQuery, setQuery } = useGlobalTime();
// create a unique, but stable (across re-renders) query id
const uniqueQueryId = useMemo(() => `${ALERTS_GROUPING_ID}-${uuidv4()}`, []);
useInspectButton({
deleteQuery,
loading: isLoadingGroups,
refetch,
request,
response,
setQuery,
uniqueQueryId,
});
const inspect = useMemo(
() => (
<InspectButton queryId={uniqueQueryId} inspectIndex={0} title={i18n.INSPECT_GROUPING_TITLE} />
),
[uniqueQueryId]
);
const takeActionItems = useGroupTakeActionsItems({
indexName: indexPattern.title,
currentStatus: currentAlertStatusFilterValue,
showAlertStatusActions: hasIndexWrite && hasIndexMaintenance,
});
const getTakeActionItems = useCallback(
(groupFilters: Filter[], groupNumber: number) =>
takeActionItems({
groupNumber,
query: getGlobalQuery([...(defaultFilters ?? []), ...groupFilters])?.filterQuery,
selectedGroup,
tableId,
}),
[defaultFilters, getGlobalQuery, selectedGroup, tableId, takeActionItems]
);
return useMemo(
() =>
getGrouping({
activePage: pageIndex,
data: alertsGroupsData?.aggregations,
groupingLevel,
inspectButton: inspect,
isLoading: loading || isLoadingGroups,
itemsPerPage: pageSize,
onChangeGroupsItemsPerPage: (size: number) => setPageSize(size),
onChangeGroupsPage: (index) => setPageIndex(index),
renderChildComponent,
onGroupClose,
selectedGroup,
takeActionItems: getTakeActionItems,
}),
[
alertsGroupsData?.aggregations,
getGrouping,
getTakeActionItems,
groupingLevel,
inspect,
isLoadingGroups,
loading,
pageIndex,
pageSize,
renderChildComponent,
onGroupClose,
selectedGroup,
setPageIndex,
setPageSize,
]
);
};
export const GroupedSubLevel = React.memo(GroupedSubLevelComponent);

View file

@ -30,7 +30,7 @@ describe('useGroupTakeActionsItems', () => {
groupNumber: 0,
selectedGroup: 'test',
};
it('returns array take actions items available for alerts table if showAlertStatusActions is true', async () => {
it('returns all take actions items if showAlertStatusActions is true and currentStatus is undefined', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(
() =>
@ -47,7 +47,106 @@ describe('useGroupTakeActionsItems', () => {
});
});
it('returns empty array of take actions items available for alerts table if showAlertStatusActions is false', async () => {
it('returns all take actions items if currentStatus is []', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(
() =>
useGroupTakeActionsItems({
currentStatus: [],
indexName: '.alerts-security.alerts-default',
showAlertStatusActions: true,
}),
{
wrapper: wrapperContainer,
}
);
await waitForNextUpdate();
expect(result.current(getActionItemsParams).length).toEqual(3);
});
});
it('returns all take actions items if currentStatus.length > 1', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(
() =>
useGroupTakeActionsItems({
currentStatus: ['open', 'closed'],
indexName: '.alerts-security.alerts-default',
showAlertStatusActions: true,
}),
{
wrapper: wrapperContainer,
}
);
await waitForNextUpdate();
expect(result.current(getActionItemsParams).length).toEqual(3);
});
});
it('returns acknowledged & closed take actions items if currentStatus === ["open"]', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(
() =>
useGroupTakeActionsItems({
currentStatus: ['open'],
indexName: '.alerts-security.alerts-default',
showAlertStatusActions: true,
}),
{
wrapper: wrapperContainer,
}
);
await waitForNextUpdate();
const currentParams = result.current(getActionItemsParams);
expect(currentParams.length).toEqual(2);
expect(currentParams[0].key).toEqual('acknowledge');
expect(currentParams[1].key).toEqual('close');
});
});
it('returns open & acknowledged take actions items if currentStatus === ["closed"]', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(
() =>
useGroupTakeActionsItems({
currentStatus: ['closed'],
indexName: '.alerts-security.alerts-default',
showAlertStatusActions: true,
}),
{
wrapper: wrapperContainer,
}
);
await waitForNextUpdate();
const currentParams = result.current(getActionItemsParams);
expect(currentParams.length).toEqual(2);
expect(currentParams[0].key).toEqual('open');
expect(currentParams[1].key).toEqual('acknowledge');
});
});
it('returns open & closed take actions items if currentStatus === ["acknowledged"]', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(
() =>
useGroupTakeActionsItems({
currentStatus: ['acknowledged'],
indexName: '.alerts-security.alerts-default',
showAlertStatusActions: true,
}),
{
wrapper: wrapperContainer,
}
);
await waitForNextUpdate();
const currentParams = result.current(getActionItemsParams);
expect(currentParams.length).toEqual(2);
expect(currentParams[0].key).toEqual('open');
expect(currentParams[1].key).toEqual('close');
});
});
it('returns empty take actions items if showAlertStatusActions is false', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(
() =>
@ -63,4 +162,20 @@ describe('useGroupTakeActionsItems', () => {
expect(result.current(getActionItemsParams).length).toEqual(0);
});
});
it('returns array take actions items if showAlertStatusActions is true', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook(
() =>
useGroupTakeActionsItems({
indexName: '.alerts-security.alerts-default',
showAlertStatusActions: true,
}),
{
wrapper: wrapperContainer,
}
);
await waitForNextUpdate();
expect(result.current(getActionItemsParams).length).toEqual(3);
});
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useMemo, useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { Status } from '../../../../../common/detection_engine/schemas/common';
@ -30,8 +30,9 @@ import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import * as i18n from '../translations';
import { getTelemetryEvent, METRIC_TYPE, track } from '../../../../common/lib/telemetry';
import type { StartServices } from '../../../../types';
export interface TakeActionsProps {
currentStatus?: Status;
currentStatus?: Status[];
indexName: string;
showAlertStatusActions?: boolean;
}
@ -182,7 +183,7 @@ export const useGroupTakeActionsItems = ({
]
);
const items = useMemo(() => {
return useMemo(() => {
const getActionItems = ({
query,
tableId,
@ -196,61 +197,89 @@ export const useGroupTakeActionsItems = ({
}) => {
const actionItems: JSX.Element[] = [];
if (showAlertStatusActions) {
if (currentStatus !== FILTER_OPEN) {
actionItems.push(
<EuiContextMenuItem
key="open"
data-test-subj="open-alert-status"
onClick={() =>
onClickUpdate({
groupNumber,
query,
selectedGroup,
status: FILTER_OPEN as AlertWorkflowStatus,
tableId,
})
}
>
{BULK_ACTION_OPEN_SELECTED}
</EuiContextMenuItem>
);
}
if (currentStatus !== FILTER_ACKNOWLEDGED) {
actionItems.push(
<EuiContextMenuItem
key="acknowledge"
data-test-subj="acknowledged-alert-status"
onClick={() =>
onClickUpdate({
groupNumber,
query,
selectedGroup,
status: FILTER_ACKNOWLEDGED as AlertWorkflowStatus,
tableId,
})
}
>
{BULK_ACTION_ACKNOWLEDGED_SELECTED}
</EuiContextMenuItem>
);
}
if (currentStatus !== FILTER_CLOSED) {
actionItems.push(
<EuiContextMenuItem
key="close"
data-test-subj="close-alert-status"
onClick={() =>
onClickUpdate({
groupNumber,
query,
selectedGroup,
status: FILTER_CLOSED as AlertWorkflowStatus,
tableId,
})
}
>
{BULK_ACTION_CLOSE_SELECTED}
</EuiContextMenuItem>
if (currentStatus && currentStatus.length === 1) {
const singleStatus = currentStatus[0];
if (singleStatus !== FILTER_OPEN) {
actionItems.push(
<EuiContextMenuItem
key="open"
data-test-subj="open-alert-status"
onClick={() =>
onClickUpdate({
groupNumber,
query,
selectedGroup,
status: FILTER_OPEN as AlertWorkflowStatus,
tableId,
})
}
>
{BULK_ACTION_OPEN_SELECTED}
</EuiContextMenuItem>
);
}
if (singleStatus !== FILTER_ACKNOWLEDGED) {
actionItems.push(
<EuiContextMenuItem
key="acknowledge"
data-test-subj="acknowledged-alert-status"
onClick={() =>
onClickUpdate({
groupNumber,
query,
selectedGroup,
status: FILTER_ACKNOWLEDGED as AlertWorkflowStatus,
tableId,
})
}
>
{BULK_ACTION_ACKNOWLEDGED_SELECTED}
</EuiContextMenuItem>
);
}
if (singleStatus !== FILTER_CLOSED) {
actionItems.push(
<EuiContextMenuItem
key="close"
data-test-subj="close-alert-status"
onClick={() =>
onClickUpdate({
groupNumber,
query,
selectedGroup,
status: FILTER_CLOSED as AlertWorkflowStatus,
tableId,
})
}
>
{BULK_ACTION_CLOSE_SELECTED}
</EuiContextMenuItem>
);
}
} else {
const statusArr = {
[FILTER_OPEN]: BULK_ACTION_OPEN_SELECTED,
[FILTER_ACKNOWLEDGED]: BULK_ACTION_ACKNOWLEDGED_SELECTED,
[FILTER_CLOSED]: BULK_ACTION_CLOSE_SELECTED,
};
Object.keys(statusArr).forEach((workflowStatus) =>
actionItems.push(
<EuiContextMenuItem
key={workflowStatus}
data-test-subj={`${workflowStatus}-alert-status`}
onClick={() =>
onClickUpdate({
groupNumber,
query,
selectedGroup,
status: workflowStatus as AlertWorkflowStatus,
tableId,
})
}
>
{statusArr[workflowStatus]}
</EuiContextMenuItem>
)
);
}
}
@ -259,6 +288,4 @@ export const useGroupTakeActionsItems = ({
return getActionItems;
}, [currentStatus, onClickUpdate, showAlertStatusActions]);
return items;
};

View file

@ -42,8 +42,8 @@ export const getAlertsGroupingQuery = ({
getGroupingQuery({
additionalFilters,
from,
groupByFields: !isNoneGroup(selectedGroup) ? getGroupFields(selectedGroup) : [],
statsAggregations: !isNoneGroup(selectedGroup)
groupByFields: !isNoneGroup([selectedGroup]) ? getGroupFields(selectedGroup) : [],
statsAggregations: !isNoneGroup([selectedGroup])
? getAggregationsByGroupField(selectedGroup)
: [],
pageNumber: pageIndex * pageSize,
@ -51,7 +51,7 @@ export const getAlertsGroupingQuery = ({
{
unitsCount: { value_count: { field: selectedGroup } },
},
...(!isNoneGroup(selectedGroup)
...(!isNoneGroup([selectedGroup])
? [{ groupsCount: { cardinality: { field: selectedGroup } } }]
: []),
],

View file

@ -1,261 +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 from 'react';
import { render } from '@testing-library/react';
import type { Filter } from '@kbn/es-query';
import useResizeObserver from 'use-resize-observer/polyfilled';
import '../../../common/mock/match_media';
import {
createSecuritySolutionStorageMock,
kibanaObservable,
mockGlobalState,
SUB_PLUGINS_REDUCER,
TestProviders,
} from '../../../common/mock';
import type { AlertsTableComponentProps } from './alerts_grouping';
import { GroupedAlertsTableComponent } from './alerts_grouping';
import { TableId } from '@kbn/securitysolution-data-table';
import { useSourcererDataView } from '../../../common/containers/sourcerer';
import type { UseFieldBrowserOptionsProps } from '../../../timelines/components/fields_browser';
import { mockCasesContext } from '@kbn/cases-plugin/public/mocks/mock_cases_context';
import { mockTimelines } from '../../../common/mock/mock_timelines_plugin';
import { createFilterManagerMock } from '@kbn/data-plugin/public/query/filter_manager/filter_manager.mock';
import type { State } from '../../../common/store';
import { createStore } from '../../../common/store';
import { createStartServicesMock } from '../../../common/lib/kibana/kibana_react.mock';
import { isNoneGroup, useGrouping } from '@kbn/securitysolution-grouping';
jest.mock('@kbn/securitysolution-grouping');
jest.mock('../../../common/containers/sourcerer');
jest.mock('../../../common/containers/use_global_time', () => ({
useGlobalTime: jest.fn().mockReturnValue({
from: '2020-07-07T08:20:18.966Z',
isInitializing: false,
to: '2020-07-08T08:20:18.966Z',
setQuery: jest.fn(),
}),
}));
jest.mock('./grouping_settings', () => ({
getAlertsGroupingQuery: jest.fn(),
getDefaultGroupingOptions: () => [
{ label: 'ruleName', key: 'kibana.alert.rule.name' },
{ label: 'userName', key: 'user.name' },
{ label: 'hostName', key: 'host.name' },
{ label: 'sourceIP', key: 'source.ip' },
],
getSelectedGroupBadgeMetrics: jest.fn(),
getSelectedGroupButtonContent: jest.fn(),
getSelectedGroupCustomMetrics: jest.fn(),
useGroupTakeActionsItems: jest.fn(),
}));
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});
jest.mock('../../../common/utils/normalize_time_range');
const mockUseFieldBrowserOptions = jest.fn();
jest.mock('../../../timelines/components/fields_browser', () => ({
useFieldBrowserOptions: (props: UseFieldBrowserOptionsProps) => mockUseFieldBrowserOptions(props),
}));
const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock;
jest.mock('use-resize-observer/polyfilled');
mockUseResizeObserver.mockImplementation(() => ({}));
const mockFilterManager = createFilterManagerMock();
const mockKibanaServices = createStartServicesMock();
jest.mock('../../../common/lib/kibana', () => {
const original = jest.requireActual('../../../common/lib/kibana');
return {
...original,
useUiSetting$: jest.fn().mockReturnValue([]),
useKibana: () => ({
services: {
...mockKibanaServices,
application: {
navigateToUrl: jest.fn(),
capabilities: {
siem: { crud_alerts: true, read_alerts: true },
},
},
cases: {
ui: { getCasesContext: mockCasesContext },
},
uiSettings: {
get: jest.fn(),
},
timelines: { ...mockTimelines },
data: {
query: {
filterManager: mockFilterManager,
},
},
docLinks: {
links: {
siem: {
privileges: 'link',
},
},
},
storage: {
get: jest.fn(),
set: jest.fn(),
},
triggerActionsUi: {
getAlertsStateTable: jest.fn(() => <></>),
alertsTableConfigurationRegistry: {},
},
},
}),
useToasts: jest.fn().mockReturnValue({
addError: jest.fn(),
addSuccess: jest.fn(),
addWarning: jest.fn(),
remove: jest.fn(),
}),
};
});
const state: State = {
...mockGlobalState,
};
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const groupingStore = createStore(
{
...state,
groups: {
groupSelector: <></>,
selectedGroup: 'host.name',
},
},
SUB_PLUGINS_REDUCER,
kibanaObservable,
storage
);
jest.mock('./timeline_actions/use_add_bulk_to_timeline', () => ({
useAddBulkToTimelineAction: jest.fn(() => {}),
}));
const sourcererDataView = {
indicesExist: true,
loading: false,
indexPattern: {
fields: [],
},
browserFields: {},
};
const renderChildComponent = (groupingFilters: Filter[]) => <p data-test-subj="alerts-table" />;
const testProps: AlertsTableComponentProps = {
defaultFilters: [],
from: '2020-07-07T08:20:18.966Z',
globalFilters: [],
globalQuery: {
query: 'query',
language: 'language',
},
hasIndexMaintenance: true,
hasIndexWrite: true,
loading: false,
renderChildComponent,
runtimeMappings: {},
signalIndexName: 'test',
tableId: TableId.test,
to: '2020-07-08T08:20:18.966Z',
};
const resetPagination = jest.fn();
describe('GroupedAlertsTable', () => {
const getGrouping = jest.fn().mockReturnValue(<span data-test-subj={'grouping-table'} />);
beforeEach(() => {
jest.clearAllMocks();
(useSourcererDataView as jest.Mock).mockReturnValue({
...sourcererDataView,
selectedPatterns: ['myFakebeat-*'],
});
(isNoneGroup as jest.Mock).mockReturnValue(true);
(useGrouping as jest.Mock).mockReturnValue({
groupSelector: <></>,
getGrouping,
selectedGroup: 'host.name',
pagination: { pageSize: 1, pageIndex: 0, reset: resetPagination },
});
});
it('calls the proper initial dispatch actions for groups', () => {
render(
<TestProviders store={store}>
<GroupedAlertsTableComponent {...testProps} />
</TestProviders>
);
expect(mockDispatch).toHaveBeenCalledTimes(2);
expect(mockDispatch.mock.calls[0][0].type).toEqual(
'x-pack/security_solution/groups/UPDATE_GROUP_SELECTOR'
);
expect(mockDispatch.mock.calls[1][0].type).toEqual(
'x-pack/security_solution/groups/UPDATE_SELECTED_GROUP'
);
});
it('renders grouping table', async () => {
(isNoneGroup as jest.Mock).mockReturnValue(false);
const { getByTestId } = render(
<TestProviders store={groupingStore}>
<GroupedAlertsTableComponent {...testProps} />
</TestProviders>
);
expect(getByTestId('grouping-table')).toBeInTheDocument();
expect(getGrouping.mock.calls[0][0].isLoading).toEqual(false);
});
it('renders loading when expected', () => {
(isNoneGroup as jest.Mock).mockReturnValue(false);
render(
<TestProviders store={groupingStore}>
<GroupedAlertsTableComponent {...testProps} loading={true} />
</TestProviders>
);
expect(getGrouping.mock.calls[0][0].isLoading).toEqual(true);
});
it('resets grouping pagination when global query updates', () => {
(isNoneGroup as jest.Mock).mockReturnValue(false);
const { rerender } = render(
<TestProviders store={groupingStore}>
<GroupedAlertsTableComponent {...testProps} />
</TestProviders>
);
// called on initial query definition
expect(resetPagination).toHaveBeenCalledTimes(1);
rerender(
<TestProviders store={groupingStore}>
<GroupedAlertsTableComponent
{...{ ...testProps, globalQuery: { query: 'updated', language: 'language' } }}
/>
</TestProviders>
);
expect(resetPagination).toHaveBeenCalledTimes(2);
});
});

View file

@ -7,7 +7,6 @@
import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { isNoneGroup } from '@kbn/securitysolution-grouping';
import {
dataTableSelectors,
tableDefaults,
@ -29,9 +28,6 @@ export const getPersistentControlsHook = (tableId: TableId) => {
const getGroupSelector = groupSelectors.getGroupSelector();
const groupSelector = useSelector((state: State) => getGroupSelector(state));
const getSelectedGroup = groupSelectors.getSelectedGroup();
const selectedGroup = useSelector((state: State) => getSelectedGroup(state));
const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []);
@ -88,10 +84,10 @@ export const getPersistentControlsHook = (tableId: TableId) => {
hasRightOffset={false}
additionalFilters={additionalFiltersComponent}
showInspect={false}
additionalMenuOptions={isNoneGroup(selectedGroup) ? [groupSelector] : []}
additionalMenuOptions={groupSelector != null ? [groupSelector] : []}
/>
),
[tableView, handleChangeTableView, additionalFiltersComponent, groupSelector, selectedGroup]
[tableView, handleChangeTableView, additionalFiltersComponent, groupSelector]
);
return {

View file

@ -5,10 +5,9 @@
* 2.0.
*/
import React from 'react';
import { mount } from 'enzyme';
import React, { useEffect } from 'react';
import { render, waitFor } from '@testing-library/react';
import { useParams } from 'react-router-dom';
import { waitFor } from '@testing-library/react';
import '../../../common/mock/match_media';
import {
createSecuritySolutionStorageMock,
@ -29,6 +28,10 @@ import { mockCasesContext } from '@kbn/cases-plugin/public/mocks/mock_cases_cont
import { createFilterManagerMock } from '@kbn/data-plugin/public/query/filter_manager/filter_manager.mock';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { createStubDataView } from '@kbn/data-views-plugin/common/data_view.stub';
import { useListsConfig } from '../../containers/detection_engine/lists/use_lists_config';
import type { FilterGroupProps } from '../../../common/components/filter_group/types';
import { FilterGroup } from '../../../common/components/filter_group';
import type { AlertsTableComponentProps } from '../../components/alerts_table/alerts_grouping';
// Test will fail because we will to need to mock some core services to make the test work
// For now let's forget about SiemSearchBar and QueryBar
@ -38,6 +41,27 @@ jest.mock('../../../common/components/search_bar', () => ({
jest.mock('../../../common/components/query_bar', () => ({
QueryBar: () => null,
}));
jest.mock('../../../common/hooks/use_space_id', () => ({
useSpaceId: () => 'default',
}));
jest.mock('../../../common/components/filter_group');
const mockStatusCapture = jest.fn();
const GroupedAlertsTable: React.FC<AlertsTableComponentProps> = ({
currentAlertStatusFilterValue,
}) => {
useEffect(() => {
if (currentAlertStatusFilterValue) {
mockStatusCapture(currentAlertStatusFilterValue);
}
}, [currentAlertStatusFilterValue]);
return <span />;
};
jest.mock('../../components/alerts_table/alerts_grouping', () => ({
GroupedAlertsTable,
}));
jest.mock('../../containers/detection_engine/lists/use_lists_config');
jest.mock('../../components/user_info');
jest.mock('../../../common/containers/sourcerer');
@ -158,9 +182,11 @@ jest.mock('../../../common/components/page/use_refetch_by_session');
describe('DetectionEnginePageComponent', () => {
beforeAll(() => {
(useListsConfig as jest.Mock).mockReturnValue({ loading: false, needsConfiguration: false });
(useParams as jest.Mock).mockReturnValue({});
(useUserData as jest.Mock).mockReturnValue([
{
loading: false,
hasIndexRead: true,
canUserREAD: true,
},
@ -170,10 +196,15 @@ describe('DetectionEnginePageComponent', () => {
indexPattern: {},
browserFields: mockBrowserFields,
});
(FilterGroup as jest.Mock).mockImplementation(() => {
return <span />;
});
});
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly', async () => {
const wrapper = mount(
const { getByTestId } = render(
<TestProviders store={store}>
<Router history={mockHistory}>
<DetectionEnginePage />
@ -181,12 +212,12 @@ describe('DetectionEnginePageComponent', () => {
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('FiltersGlobal').exists()).toBe(true);
expect(getByTestId('filter-group__loading')).toBeInTheDocument();
});
});
it('renders the chart panels', async () => {
const wrapper = mount(
const { getByTestId } = render(
<TestProviders store={store}>
<Router history={mockHistory}>
<DetectionEnginePage />
@ -195,7 +226,119 @@ describe('DetectionEnginePageComponent', () => {
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="chartPanels"]').exists()).toBe(true);
expect(getByTestId('chartPanels')).toBeInTheDocument();
});
});
it('the pageFiltersUpdateHandler updates status when a multi status filter is passed', async () => {
(FilterGroup as jest.Mock).mockImplementationOnce(({ onFilterChange }: FilterGroupProps) => {
if (onFilterChange) {
// once with status
onFilterChange([
{
meta: {
index: 'security-solution-default',
key: 'kibana.alert.workflow_status',
params: ['open', 'acknowledged'],
},
},
]);
}
return <span />;
});
await waitFor(() => {
render(
<TestProviders store={store}>
<Router history={mockHistory}>
<DetectionEnginePage />
</Router>
</TestProviders>
);
});
// when statusFilter updates, we call mockStatusCapture in test mocks
expect(mockStatusCapture).toHaveBeenNthCalledWith(1, []);
expect(mockStatusCapture).toHaveBeenNthCalledWith(2, ['open', 'acknowledged']);
});
it('the pageFiltersUpdateHandler updates status when a single status filter is passed', async () => {
(FilterGroup as jest.Mock).mockImplementationOnce(({ onFilterChange }: FilterGroupProps) => {
if (onFilterChange) {
// once with status
onFilterChange([
{
meta: {
index: 'security-solution-default',
key: 'kibana.alert.workflow_status',
disabled: false,
},
query: {
match_phrase: {
'kibana.alert.workflow_status': 'open',
},
},
},
{
meta: {
index: 'security-solution-default',
key: 'kibana.alert.severity',
disabled: false,
},
query: {
match_phrase: {
'kibana.alert.severity': 'low',
},
},
},
]);
}
return <span />;
});
await waitFor(() => {
render(
<TestProviders store={store}>
<Router history={mockHistory}>
<DetectionEnginePage />
</Router>
</TestProviders>
);
});
// when statusFilter updates, we call mockStatusCapture in test mocks
expect(mockStatusCapture).toHaveBeenNthCalledWith(1, []);
expect(mockStatusCapture).toHaveBeenNthCalledWith(2, ['open']);
});
it('the pageFiltersUpdateHandler clears status when no status filter is passed', async () => {
(FilterGroup as jest.Mock).mockImplementationOnce(({ onFilterChange }: FilterGroupProps) => {
if (onFilterChange) {
// once with status
onFilterChange([
{
meta: {
index: 'security-solution-default',
key: 'kibana.alert.severity',
disabled: false,
},
query: {
match_phrase: {
'kibana.alert.severity': 'low',
},
},
},
]);
}
return <span />;
});
await waitFor(() => {
render(
<TestProviders store={store}>
<Router history={mockHistory}>
<DetectionEnginePage />
</Router>
</TestProviders>
);
});
// when statusFilter updates, we call mockStatusCapture in test mocks
expect(mockStatusCapture).toHaveBeenNthCalledWith(1, []);
expect(mockStatusCapture).toHaveBeenNthCalledWith(2, []);
});
});

View file

@ -29,7 +29,6 @@ import {
dataTableActions,
dataTableSelectors,
tableDefaults,
FILTER_OPEN,
TableId,
} from '@kbn/securitysolution-data-table';
import { ALERTS_TABLE_REGISTRY_CONFIG_IDS } from '../../../../common/constants';
@ -139,7 +138,7 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
const arePageFiltersEnabled = useIsExperimentalFeatureEnabled('alertsPageFiltersEnabled');
// when arePageFiltersEnabled === false
const [filterGroup, setFilterGroup] = useState<Status>(FILTER_OPEN);
const [statusFilter, setStatusFilter] = useState<Status[]>([]);
const updatedAt = useShallowEqualSelector(
(state) => (getTable(state, TableId.alertsOnAlertsPage) ?? tableDefaults).updated
@ -177,8 +176,8 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
if (arePageFiltersEnabled) {
return detectionPageFilters;
}
return buildAlertStatusFilter(filterGroup);
}, [filterGroup, detectionPageFilters, arePageFiltersEnabled]);
return buildAlertStatusFilter(statusFilter[0] ?? 'open');
}, [statusFilter, detectionPageFilters, arePageFiltersEnabled]);
useEffect(() => {
if (!detectionPageFilterHandler) return;
@ -276,6 +275,19 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
const pageFiltersUpdateHandler = useCallback((newFilters: Filter[]) => {
setDetectionPageFilters(newFilters);
if (newFilters.length) {
const newStatusFilter = newFilters.find(
(filter) => filter.meta.key === 'kibana.alert.workflow_status'
);
if (newStatusFilter) {
const status: Status[] = newStatusFilter.meta.params
? (newStatusFilter.meta.params as Status[])
: [newStatusFilter.query?.match_phrase['kibana.alert.workflow_status']];
setStatusFilter(status);
} else {
setStatusFilter([]);
}
}
}, []);
// Callback for when open/closed filter changes
@ -284,9 +296,9 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
const timelineId = TableId.alertsOnAlertsPage;
clearEventsLoading({ id: timelineId });
clearEventsDeleted({ id: timelineId });
setFilterGroup(newFilterGroup);
setStatusFilter([newFilterGroup]);
},
[clearEventsLoading, clearEventsDeleted, setFilterGroup]
[clearEventsLoading, clearEventsDeleted, setStatusFilter]
);
const areDetectionPageFiltersLoading = useMemo(() => {
@ -317,7 +329,7 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<AlertsTableFilterGroup
status={filterGroup}
status={statusFilter[0] ?? 'open'}
onFilterGroupChanged={onFilterGroupChangedCallback}
/>
</EuiFlexItem>
@ -352,7 +364,7 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
[
arePageFiltersEnabled,
dataViewId,
filterGroup,
statusFilter,
filters,
onFilterGroupChangedCallback,
pageFiltersUpdateHandler,
@ -462,7 +474,7 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
<EuiSpacer size="l" />
</Display>
<GroupedAlertsTable
currentAlertStatusFilterValue={filterGroup}
currentAlertStatusFilterValue={statusFilter}
defaultFilters={alertsTableDefaultFilters}
from={from}
globalFilters={filters}

View file

@ -60,7 +60,7 @@ export const EntityAnalyticsAnomalies = () => {
const [updatedAt, setUpdatedAt] = useState<number>(Date.now());
const { toggleStatus, setToggleStatus } = useQueryToggle(TABLE_QUERY_ID);
const { deleteQuery, setQuery, from, to } = useGlobalTime(false);
const { deleteQuery, setQuery, from, to } = useGlobalTime();
const {
isLoading: isSearchLoading,
data,

View file

@ -41,7 +41,7 @@ const HOST_RISK_QUERY_ID = 'hostRiskScoreKpiQuery';
const USER_RISK_QUERY_ID = 'userRiskScoreKpiQuery';
export const EntityAnalyticsHeader = () => {
const { from, to } = useGlobalTime(false);
const { from, to } = useGlobalTime();
const timerange = useMemo(
() => ({
from,