[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', 'apm',
'canvas', 'canvas',
'cases', 'cases',
'cell_actions',
'ci_composite', 'ci_composite',
'cloud_chat', 'cloud_chat',
'coloring', 'coloring',
@ -34,6 +35,7 @@ const STORYBOOKS = [
'expression_shape', 'expression_shape',
'expression_tagcloud', 'expression_tagcloud',
'fleet', 'fleet',
'grouping',
'home', 'home',
'infra', 'infra',
'kibana_react', '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. * Side Public License, v 1.
*/ */
import { RawBucket, StatRenderer, getGroupingQuery, isNoneGroup, useGrouping } from './src'; import { getGroupingQuery, isNoneGroup, useGrouping } from './src';
import type { import type {
DynamicGroupingProps,
GroupOption, GroupOption,
GroupingAggregation, GroupingAggregation,
GroupingFieldTotalAggregation,
NamedAggregation, NamedAggregation,
RawBucket,
StatRenderer,
} from './src'; } from './src';
export { getGroupingQuery, isNoneGroup, useGrouping }; export { getGroupingQuery, isNoneGroup, useGrouping };
export type { export type {
DynamicGroupingProps,
GroupOption, GroupOption,
GroupingAggregation, GroupingAggregation,
GroupingFieldTotalAggregation,
NamedAggregation, NamedAggregation,
RawBucket, RawBucket,
StatRenderer, StatRenderer,

View file

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

View file

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

View file

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

View file

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

View file

@ -43,7 +43,7 @@ const testProps = {
esTypes: ['ip'], esTypes: ['ip'],
}, },
], ],
groupSelected: 'kibana.alert.rule.name', groupsSelected: ['kibana.alert.rule.name'],
onGroupChange, onGroupChange,
options: [ options: [
{ {
@ -90,4 +90,38 @@ describe('group selector', () => {
fireEvent.click(getByTestId('panel-none')); fireEvent.click(getByTestId('panel-none'));
expect(onGroupChange).toHaveBeenCalled(); 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; 'data-test-subj'?: string;
fields: FieldSpec[]; fields: FieldSpec[];
groupingId: string; groupingId: string;
groupSelected: string; groupsSelected: string[];
onGroupChange: (groupSelection: string) => void; onGroupChange: (groupSelection: string) => void;
options: Array<{ key: string; label: string }>; options: Array<{ key: string; label: string }>;
title?: string; title?: string;
maxGroupingLevels?: number;
} }
const GroupSelectorComponent = ({ const GroupSelectorComponent = ({
'data-test-subj': dataTestSubj, 'data-test-subj': dataTestSubj,
fields, fields,
groupSelected = 'none', groupsSelected = ['none'],
onGroupChange, onGroupChange,
options, options,
title = i18n.GROUP_BY, title = i18n.GROUP_BY,
maxGroupingLevels = 1,
}: GroupSelectorProps) => { }: GroupSelectorProps) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const isGroupSelected = useCallback(
(groupKey: string) =>
!!groupsSelected.find((selectedGroupKey) => selectedGroupKey === groupKey),
[groupsSelected]
);
const panels: EuiContextMenuPanelDescriptor[] = useMemo( const panels: EuiContextMenuPanelDescriptor[] = useMemo(
() => [ () => [
@ -49,7 +55,7 @@ const GroupSelectorComponent = ({
style={{ lineHeight: 1 }} style={{ lineHeight: 1 }}
> >
<EuiFlexItem grow={false} component="p" style={{ lineHeight: 1.5 }}> <EuiFlexItem grow={false} component="p" style={{ lineHeight: 1.5 }}>
{i18n.SELECT_FIELD.toUpperCase()} {i18n.SELECT_FIELD(maxGroupingLevels)}
</EuiFlexItem> </EuiFlexItem>
<EuiFlexItem grow={false} component="span"> <EuiFlexItem grow={false} component="span">
<EuiBetaBadge <EuiBetaBadge
@ -65,20 +71,23 @@ const GroupSelectorComponent = ({
{ {
'data-test-subj': 'panel-none', 'data-test-subj': 'panel-none',
name: i18n.NONE, name: i18n.NONE,
icon: groupSelected === 'none' ? 'check' : 'empty', icon: isGroupSelected('none') ? 'check' : 'empty',
onClick: () => onGroupChange('none'), onClick: () => onGroupChange('none'),
}, },
...options.map<EuiContextMenuPanelItemDescriptor>((o) => ({ ...options.map<EuiContextMenuPanelItemDescriptor>((o) => ({
'data-test-subj': `panel-${o.key}`, 'data-test-subj': `panel-${o.key}`,
disabled: groupsSelected.length === maxGroupingLevels && !isGroupSelected(o.key),
name: o.label, name: o.label,
onClick: () => onGroupChange(o.key), onClick: () => onGroupChange(o.key),
icon: groupSelected === o.key ? 'check' : 'empty', icon: isGroupSelected(o.key) ? 'check' : 'empty',
})), })),
{ {
'data-test-subj': `panel-custom`, 'data-test-subj': `panel-custom`,
name: i18n.CUSTOM_FIELD, name: i18n.CUSTOM_FIELD,
icon: 'empty', icon: 'empty',
disabled: groupsSelected.length === maxGroupingLevels,
panel: 'customPanel', panel: 'customPanel',
hasPanel: true,
}, },
], ],
}, },
@ -91,24 +100,35 @@ const GroupSelectorComponent = ({
currentOptions={options.map((o) => ({ text: o.label, field: o.key }))} currentOptions={options.map((o) => ({ text: o.label, field: o.key }))}
onSubmit={(field: string) => { onSubmit={(field: string) => {
onGroupChange(field); onGroupChange(field);
setIsPopoverOpen(false);
}} }}
fields={fields} fields={fields}
/> />
), ),
}, },
], ],
[fields, groupSelected, onGroupChange, options] [fields, groupsSelected.length, isGroupSelected, maxGroupingLevels, onGroupChange, options]
); );
const selectedOption = useMemo( const selectedOptions = useMemo(
() => options.filter((groupOption) => groupOption.key === groupSelected), () => options.filter((groupOption) => isGroupSelected(groupOption.key)),
[groupSelected, options] [isGroupSelected, options]
); );
const onButtonClick = useCallback(() => setIsPopoverOpen((currentVal) => !currentVal), []); const onButtonClick = useCallback(() => setIsPopoverOpen((currentVal) => !currentVal), []);
const closePopover = useCallback(() => setIsPopoverOpen(false), []); 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 <StyledEuiButtonEmpty
data-test-subj="group-selector-dropdown" data-test-subj="group-selector-dropdown"
flush="both" flush="both"
@ -116,22 +136,13 @@ const GroupSelectorComponent = ({
iconSize="s" iconSize="s"
iconType="arrowDown" iconType="arrowDown"
onClick={onButtonClick} onClick={onButtonClick}
title={ title={buttonLabel}
groupSelected !== 'none' && selectedOption.length > 0
? selectedOption[0].label
: i18n.NONE
}
size="xs" size="xs"
> >
{`${title}: ${ {`${title}: ${buttonLabel}`}
groupSelected !== 'none' && selectedOption.length > 0
? selectedOption[0].label
: i18n.NONE
}`}
</StyledEuiButtonEmpty> </StyledEuiButtonEmpty>
),
[groupSelected, onButtonClick, selectedOption, title]
); );
}, [groupsSelected, isGroupSelected, onButtonClick, selectedOptions, title]);
return ( return (
<EuiPopover <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 { METRIC_TYPE } from '@kbn/analytics';
import { getTelemetryEvent } from '../telemetry/const'; import { getTelemetryEvent } from '../telemetry/const';
import { mockGroupingProps, rule1Name, rule2Name } from './grouping.mock';
const renderChildComponent = jest.fn(); const renderChildComponent = jest.fn();
const takeActionItems = jest.fn(); const takeActionItems = jest.fn();
const mockTracker = 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 = { const testProps = {
data: { ...mockGroupingProps,
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],
},
renderChildComponent, renderChildComponent,
selectedGroup: 'kibana.alert.rule.name',
takeActionItems, takeActionItems,
tracker: mockTracker, tracker: mockTracker,
}; };

View file

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

View file

@ -14,8 +14,9 @@ export * from './grouping';
/** /**
* Checks if no group is selected * 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 * @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` export const groupingContainerCss = css`
.euiAccordion__childWrapper .euiAccordion__padding--m { .groupingAccordionForm .euiAccordion__childWrapper .euiAccordion__padding--m {
margin-left: 8px; margin-left: 8px;
margin-right: 8px; margin-right: 8px;
border-left: ${euiThemeVars.euiBorderThin}; border-left: ${euiThemeVars.euiBorderThin};
@ -44,7 +44,7 @@ export const groupingContainerCss = css`
border-bottom: ${euiThemeVars.euiBorderThin}; border-bottom: ${euiThemeVars.euiBorderThin};
border-radius: 0 0 6px 6px; border-radius: 0 0 6px 6px;
} }
.euiAccordion__triggerWrapper { .groupingAccordionForm .euiAccordion__triggerWrapper {
border-bottom: ${euiThemeVars.euiBorderThin}; border-bottom: ${euiThemeVars.euiBorderThin};
border-left: ${euiThemeVars.euiBorderThin}; border-left: ${euiThemeVars.euiBorderThin};
border-right: ${euiThemeVars.euiBorderThin}; border-right: ${euiThemeVars.euiBorderThin};
@ -59,8 +59,37 @@ export const groupingContainerCss = css`
border-radius: 6px; border-radius: 6px;
min-width: 1090px; min-width: 1090px;
} }
.groupingAccordionForm__button { .groupingPanelRenderer {
text-decoration: none !important; 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 { .groupingPanelRenderer {
display: table; display: table;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -24,7 +24,7 @@ const groupById = {
[groupingId]: { [groupingId]: {
...defaultGroup, ...defaultGroup,
options: groupingOptions, options: groupingOptions,
activeGroup: 'host.name', activeGroups: ['host.name'],
}, },
}; };
@ -54,7 +54,7 @@ describe('grouping reducer', () => {
JSON.stringify(groupingState.groupById) JSON.stringify(groupingState.groupById)
); );
}); });
it('updateActiveGroup', () => { it('updateActiveGroups', () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
useReducer(groupsReducerWithStorage, { useReducer(groupsReducerWithStorage, {
...initialState, ...initialState,
@ -62,40 +62,11 @@ describe('grouping reducer', () => {
}) })
); );
let [groupingState, dispatch] = result.current; let [groupingState, dispatch] = result.current;
expect(groupingState.groupById[groupingId].activeGroup).toEqual('host.name'); expect(groupingState.groupById[groupingId].activeGroups).toEqual(['host.name']);
act(() => { act(() => {
dispatch(groupActions.updateActiveGroup({ id: groupingId, activeGroup: 'user.name' })); dispatch(groupActions.updateActiveGroups({ id: groupingId, activeGroups: ['user.name'] }));
}); });
[groupingState, dispatch] = result.current; [groupingState, dispatch] = result.current;
expect(groupingState.groupById[groupingId].activeGroup).toEqual('user.name'); expect(groupingState.groupById[groupingId].activeGroups).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);
}); });
}); });

View file

@ -25,8 +25,8 @@ export const initialState: GroupMap = {
const groupsReducer = (state: GroupMap, action: Action, groupsById: GroupsById) => { const groupsReducer = (state: GroupMap, action: Action, groupsById: GroupsById) => {
switch (action.type) { switch (action.type) {
case ActionType.updateActiveGroup: { case ActionType.updateActiveGroups: {
const { id, activeGroup } = action.payload; const { id, activeGroups } = action.payload;
return { return {
...state, ...state,
groupById: { groupById: {
@ -34,35 +34,7 @@ const groupsReducer = (state: GroupMap, action: Action, groupsById: GroupsById)
[id]: { [id]: {
...defaultGroup, ...defaultGroup,
...groupsById[id], ...groupsById[id],
activeGroup, activeGroups,
},
},
};
}
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,
}, },
}, },
}; };
@ -89,22 +61,10 @@ export const groupsReducerWithStorage = (state: GroupMap, action: Action) => {
if (storage) { if (storage) {
groupsInStorage = getAllGroupsInStorage(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 = { const groupsById: GroupsById = {
...state.groupById, ...state.groupById,
...adjustedStorageGroups, ...groupsInStorage,
}; };
const newState = groupsReducer(state, action, groupsById); const newState = groupsReducer(state, action, groupsById);

View file

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

View file

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

View file

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

View file

@ -30,7 +30,6 @@ const defaultArgs = {
groupStatsRenderer: jest.fn(), groupStatsRenderer: jest.fn(),
inspectButton: <></>, inspectButton: <></>,
onGroupToggle: jest.fn(), onGroupToggle: jest.fn(),
renderChildComponent: () => <p data-test-subj="innerTable">{'hello'}</p>,
}, },
}; };
@ -38,6 +37,9 @@ const groupingArgs = {
data: {}, data: {},
isLoading: false, isLoading: false,
takeActionItems: jest.fn(), takeActionItems: jest.fn(),
activePage: 0,
itemsPerPage: 25,
onGroupClose: () => {},
}; };
describe('useGrouping', () => { describe('useGrouping', () => {
@ -70,6 +72,8 @@ describe('useGrouping', () => {
value: 18, value: 18,
}, },
}, },
renderChildComponent: () => <p data-test-subj="innerTable">{'hello'}</p>,
selectedGroup: 'none',
})} })}
</IntlProvider> </IntlProvider>
); );
@ -84,7 +88,7 @@ describe('useGrouping', () => {
getItem.mockReturnValue( getItem.mockReturnValue(
JSON.stringify({ JSON.stringify({
'test-table': { 'test-table': {
activePage: 0, itemsPerPageOptions: [10, 25, 50, 100],
itemsPerPage: 25, itemsPerPage: 25,
activeGroup: 'kibana.alert.rule.name', activeGroup: 'kibana.alert.rule.name',
options: defaultGroupingOptions, options: defaultGroupingOptions,
@ -95,7 +99,7 @@ describe('useGrouping', () => {
const { result, waitForNextUpdate } = renderHook(() => useGrouping(defaultArgs)); const { result, waitForNextUpdate } = renderHook(() => useGrouping(defaultArgs));
await waitForNextUpdate(); await waitForNextUpdate();
await waitForNextUpdate(); await waitForNextUpdate();
const { getByTestId, queryByTestId } = render( const { getByTestId } = render(
<IntlProvider locale="en"> <IntlProvider locale="en">
{result.current.getGrouping({ {result.current.getGrouping({
...groupingArgs, ...groupingArgs,
@ -119,12 +123,13 @@ describe('useGrouping', () => {
value: 18, value: 18,
}, },
}, },
renderChildComponent: jest.fn(),
selectedGroup: 'test',
})} })}
</IntlProvider> </IntlProvider>
); );
expect(getByTestId('grouping-table')).toBeInTheDocument(); 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 { UiCounterMetricType } from '@kbn/analytics';
import { groupsReducerWithStorage, initialState } from './state/reducer'; import { groupsReducerWithStorage, initialState } from './state/reducer';
import { GroupingProps, GroupSelectorProps, isNoneGroup } from '..'; import { GroupingProps, GroupSelectorProps, isNoneGroup } from '..';
import { useGroupingPagination } from './use_grouping_pagination'; import { groupByIdSelector } from './state';
import { groupActions, groupByIdSelector } from './state';
import { useGetGroupSelector } from './use_get_group_selector'; import { useGetGroupSelector } from './use_get_group_selector';
import { defaultGroup, GroupOption } from './types'; import { defaultGroup, GroupOption } from './types';
import { Grouping as GroupingComponent } from '../components/grouping'; import { Grouping as GroupingComponent } from '../components/grouping';
@ -23,33 +22,37 @@ import { Grouping as GroupingComponent } from '../components/grouping';
interface Grouping<T> { interface Grouping<T> {
getGrouping: (props: DynamicGroupingProps<T>) => React.ReactElement; getGrouping: (props: DynamicGroupingProps<T>) => React.ReactElement;
groupSelector: React.ReactElement<GroupSelectorProps>; groupSelector: React.ReactElement<GroupSelectorProps>;
pagination: { selectedGroups: string[];
reset: () => void;
pageIndex: number;
pageSize: number;
};
selectedGroup: 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> * @interface StaticGroupingProps<T>
*/ */
type StaticGroupingProps<T> = Pick< type StaticGroupingProps<T> = Pick<
GroupingProps<T>, GroupingProps<T>,
| 'groupPanelRenderer' 'groupPanelRenderer' | 'groupStatsRenderer' | 'onGroupToggle' | 'unit'
| 'groupStatsRenderer'
| 'inspectButton'
| 'onGroupToggle'
| 'renderChildComponent'
| '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> * @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>
*/ */
interface GroupingArgs<T> { interface GroupingArgs<T> {
@ -57,6 +60,7 @@ interface GroupingArgs<T> {
defaultGroupingOptions: GroupOption[]; defaultGroupingOptions: GroupOption[];
fields: FieldSpec[]; fields: FieldSpec[];
groupingId: string; groupingId: string;
maxGroupingLevels?: number;
/** for tracking /** for tracking
* @param param { groupByField: string; tableId: string } selected group and table id * @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 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 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 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 onGroupChange callback executed when selected group is changed, used for tracking
* @param tracker telemetry handler * @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,>({ export const useGrouping = <T,>({
componentProps, componentProps,
defaultGroupingOptions, defaultGroupingOptions,
fields, fields,
groupingId, groupingId,
maxGroupingLevels,
onGroupChange, onGroupChange,
tracker, tracker,
}: GroupingArgs<T>): Grouping<T> => { }: GroupingArgs<T>): Grouping<T> => {
const [groupingState, dispatch] = useReducer(groupsReducerWithStorage, initialState); const [groupingState, dispatch] = useReducer(groupsReducerWithStorage, initialState);
const { activeGroups: selectedGroups } = useMemo(
const { activeGroup: selectedGroup } = useMemo(
() => groupByIdSelector({ groups: groupingState }, groupingId) ?? defaultGroup, () => groupByIdSelector({ groups: groupingState }, groupingId) ?? defaultGroup,
[groupingId, groupingState] [groupingId, groupingState]
); );
@ -100,56 +105,37 @@ export const useGrouping = <T,>({
fields, fields,
groupingId, groupingId,
groupingState, groupingState,
maxGroupingLevels,
onGroupChange, onGroupChange,
tracker, tracker,
}); });
const pagination = useGroupingPagination({ groupingId, groupingState, dispatch });
const getGrouping = useCallback( const getGrouping = useCallback(
/** /**
* *
* @param props {@link DynamicGroupingProps} * @param props {@link DynamicGroupingProps}
*/ */
(props: DynamicGroupingProps<T>): React.ReactElement => (props: DynamicGroupingProps<T>): React.ReactElement =>
isNoneGroup(selectedGroup) ? ( isNoneGroup([props.selectedGroup]) ? (
componentProps.renderChildComponent([]) props.renderChildComponent([])
) : ( ) : (
<GroupingComponent <GroupingComponent
{...componentProps} {...componentProps}
{...props} {...props}
groupingId={groupingId}
groupSelector={groupSelector} groupSelector={groupSelector}
pagination={pagination} groupingId={groupingId}
selectedGroup={selectedGroup}
tracker={tracker} 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( return useMemo(
() => ({ () => ({
getGrouping, getGrouping,
groupSelector, groupSelector,
selectedGroup, selectedGroups,
pagination: {
reset: resetPagination,
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
},
}), }),
[ [getGrouping, groupSelector, selectedGroups]
getGrouping,
groupSelector,
pagination.pageIndex,
pagination.pageSize,
resetPagination,
selectedGroup,
]
); );
}; };

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", "jest",
"node", "node",
"react", "react",
"@emotion/react/types/css-prop" "@emotion/react/types/css-prop",
"@kbn/ambient-ui-types"
] ]
}, },
"include": [ "include": [

View file

@ -37,6 +37,7 @@ export const storybookAliases = {
expression_shape: 'src/plugins/expression_shape/.storybook', expression_shape: 'src/plugins/expression_shape/.storybook',
expression_tagcloud: 'src/plugins/chart_expressions/expression_tagcloud/.storybook', expression_tagcloud: 'src/plugins/chart_expressions/expression_tagcloud/.storybook',
fleet: 'x-pack/plugins/fleet/.storybook', fleet: 'x-pack/plugins/fleet/.storybook',
grouping: 'packages/kbn-securitysolution-grouping/.storybook',
home: 'src/plugins/home/.storybook', home: 'src/plugins/home/.storybook',
infra: 'x-pack/plugins/infra/.storybook', infra: 'x-pack/plugins/infra/.storybook',
kibana_react: 'src/plugins/kibana_react/.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 { useLocation } from 'react-router-dom';
import { SecurityPageName } from '../../../../common/constants'; import { SecurityPageName } from '../../../../common/constants';
import { useGlobalTime } from '../../containers/use_global_time';
import { import {
DEFAULT_STACK_BY_FIELD, DEFAULT_STACK_BY_FIELD,
DEFAULT_STACK_BY_FIELD1, DEFAULT_STACK_BY_FIELD1,
@ -151,16 +150,6 @@ describe('AlertsTreemapPanel', () => {
await waitFor(() => expect(screen.getByTestId('treemapPanel')).toBeInTheDocument()); 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 () => { it('renders the panel with a hidden overflow-x', async () => {
render( render(
<TestProviders> <TestProviders>

View file

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

View file

@ -110,7 +110,7 @@ const StatefulTopNComponent: React.FC<Props> = ({
value, value,
}) => { }) => {
const { uiSettings } = useKibana().services; const { uiSettings } = useKibana().services;
const { from, deleteQuery, setQuery, to } = useGlobalTime(false); const { from, deleteQuery, setQuery, to } = useGlobalTime();
const options = getOptions(isActiveTimeline(scopeId ?? '') ? activeTimelineEventType : undefined); 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) => const timelineTime = useDeepEqualSelector((state) =>
inputsSelectors.timelineTimeRangeSelector(state) inputsSelectors.timelineTimeRangeSelector(state)
); );
const globalTime = useGlobalTime(false); const globalTime = useGlobalTime();
let to: string | undefined; let to: string | undefined;
let from: string | undefined; let from: string | undefined;
if (ignoreTimerange === false) { if (ignoreTimerange === false) {

View file

@ -37,23 +37,77 @@ describe('useGlobalTime', () => {
expect(result1.to).toBe(0); expect(result1.to).toBe(0);
}); });
test('clear all queries at unmount when clearAllQuery is set to true', () => { test('clear query at unmount when setQuery has been called', () => {
const { unmount } = renderHook(() => useGlobalTime()); const { result, unmount } = renderHook(() => useGlobalTime());
act(() => {
result.current.setQuery({
id: 'query-2',
inspect: { dsl: [], response: [] },
loading: false,
refetch: () => {},
searchSessionId: 'session-1',
});
});
unmount(); unmount();
expect(mockDispatch.mock.calls[0][0].type).toEqual( expect(mockDispatch.mock.calls.length).toBe(2);
'x-pack/security_solution/local/inputs/DELETE_ALL_QUERY' 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.', () => { test('do NOT clear query at unmount when setQuery has not been called', () => {
const { unmount } = renderHook(() => useGlobalTime(false)); const { unmount } = renderHook(() => useGlobalTime());
unmount(); unmount();
expect(mockDispatch.mock.calls.length).toBe(0); expect(mockDispatch.mock.calls.length).toBe(0);
}); });
test('do NOT clear all queries when setting state and clearAllQuery is set to true', () => { test('do clears only the dismounted queries at unmount when setQuery is called', () => {
const { rerender } = renderHook(() => useGlobalTime()); const { result, unmount } = renderHook(() => useGlobalTime());
act(() => rerender());
expect(mockDispatch.mock.calls.length).toBe(0); 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 { 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 { useDispatch } from 'react-redux';
import { InputsModelId } from '../../store/inputs/constants'; import { InputsModelId } from '../../store/inputs/constants';
@ -15,15 +15,18 @@ import { inputsSelectors } from '../../store';
import { inputsActions } from '../../store/actions'; import { inputsActions } from '../../store/actions';
import type { SetQuery, DeleteQuery } from './types'; import type { SetQuery, DeleteQuery } from './types';
export const useGlobalTime = (clearAllQuery: boolean = true) => { export const useGlobalTime = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { from, to } = useDeepEqualSelector((state) => const { from, to } = useDeepEqualSelector((state) =>
pick(['from', 'to'], inputsSelectors.globalTimeRangeSelector(state)) pick(['from', 'to'], inputsSelectors.globalTimeRangeSelector(state))
); );
const [isInitializing, setIsInitializing] = useState(true); const [isInitializing, setIsInitializing] = useState(true);
const queryId = useRef<string[]>([]);
const setQuery = useCallback( const setQuery = useCallback(
({ id, inspect, loading, refetch, searchSessionId }: SetQuery) => ({ id, inspect, loading, refetch, searchSessionId }: SetQuery) => {
queryId.current = [...queryId.current, id];
dispatch( dispatch(
inputsActions.setQuery({ inputsActions.setQuery({
inputId: InputsModelId.global, inputId: InputsModelId.global,
@ -33,7 +36,8 @@ export const useGlobalTime = (clearAllQuery: boolean = true) => {
refetch, refetch,
searchSessionId, searchSessionId,
}) })
), );
},
[dispatch] [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. // This effect must not have any mutable dependencies. Otherwise, the cleanup function gets called before the component unmounts.
useEffect(() => { useEffect(() => {
return () => { return () => {
if (clearAllQuery) { if (queryId.current.length > 0) {
dispatch(inputsActions.deleteAllQuery({ id: InputsModelId.global })); queryId.current.forEach((id) => deleteQuery({ id }));
} }
}; };
}, [dispatch, clearAllQuery]); }, [deleteQuery]);
const memoizedReturn = useMemo( return useMemo(
() => ({ () => ({
isInitializing, isInitializing,
from, from,
@ -66,8 +70,6 @@ export const useGlobalTime = (clearAllQuery: boolean = true) => {
}), }),
[deleteQuery, from, isInitializing, setQuery, to] [deleteQuery, from, isInitializing, setQuery, to]
); );
return memoizedReturn;
}; };
export type GlobalTimeArgs = Omit<ReturnType<typeof useGlobalTime>, 'deleteQuery'> & 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'); const actionCreator = actionCreatorFactory('x-pack/security_solution/groups');
export const updateGroupSelector = actionCreator<{ export const updateGroupSelector = actionCreator<{
groupSelector: React.ReactElement; groupSelector: React.ReactElement | null;
}>('UPDATE_GROUP_SELECTOR'); }>('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 { reducerWithInitialState } from 'typescript-fsa-reducers';
import { updateGroupSelector, updateSelectedGroup } from './actions'; import { updateGroupSelector } from './actions';
import type { GroupModel } from './types'; import type { GroupModel } from './types';
export const initialGroupingState: GroupModel = { export const initialGroupingState: GroupModel = {
groupSelector: null, groupSelector: null,
selectedGroup: null,
}; };
export const groupsReducer = reducerWithInitialState(initialGroupingState) export const groupsReducer = reducerWithInitialState(initialGroupingState).case(
.case(updateSelectedGroup, (state, { selectedGroup }) => ({ updateGroupSelector,
...state, (state, { groupSelector }) => ({
selectedGroup,
}))
.case(updateGroupSelector, (state, { groupSelector }) => ({
...state, ...state,
groupSelector, groupSelector,
})); })
);

View file

@ -11,7 +11,3 @@ import type { GroupState } from './types';
const groupSelector = (state: GroupState) => state.groups.groupSelector; const groupSelector = (state: GroupState) => state.groups.groupSelector;
export const getGroupSelector = () => createSelector(groupSelector, (selector) => selector); 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 { export interface GroupModel {
groupSelector: React.ReactElement | null; groupSelector: React.ReactElement | null;
selectedGroup: string | null;
} }
export interface GroupState { export interface GroupState {

View file

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

View file

@ -12,7 +12,6 @@ import { AlertsCountPanel } from '.';
import type { Status } from '../../../../../common/detection_engine/schemas/common'; import type { Status } from '../../../../../common/detection_engine/schemas/common';
import { useQueryToggle } from '../../../../common/containers/query_toggle'; 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 { DEFAULT_STACK_BY_FIELD, DEFAULT_STACK_BY_FIELD1 } from '../common/config';
import { TestProviders } from '../../../../common/mock'; import { TestProviders } from '../../../../common/mock';
import { ChartContextMenu } from '../../../pages/detection_engine/chart_panels/chart_context_menu'; 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 () => { it('renders with the specified `alignHeader` alignment', async () => {
await act(async () => { await act(async () => {
const wrapper = mount( const wrapper = mount(

View file

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

View file

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

View file

@ -82,7 +82,7 @@ export const useSummaryChartData: UseAlerts = ({
signalIndexName, signalIndexName,
skip = false, skip = false,
}) => { }) => {
const { to, from, deleteQuery, setQuery } = useGlobalTime(false); const { to, from, deleteQuery, setQuery } = useGlobalTime();
const [updatedAt, setUpdatedAt] = useState(Date.now()); const [updatedAt, setUpdatedAt] = useState(Date.now());
const [items, setItems] = useState<SummaryChartsData[]>([]); 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. * 2.0.
*/ */
import { isEmpty } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { v4 as uuidv4 } from 'uuid';
import type { Filter, Query } from '@kbn/es-query'; import type { Filter, Query } from '@kbn/es-query';
import { buildEsQuery } from '@kbn/es-query'; import type { GroupOption } from '@kbn/securitysolution-grouping';
import { getEsQueryConfig } from '@kbn/data-plugin/common'; import { isNoneGroup, useGrouping } from '@kbn/securitysolution-grouping';
import type { import { isEmpty, isEqual } from 'lodash/fp';
GroupingFieldTotalAggregation, import type { Storage } from '@kbn/kibana-utils-plugin/public';
GroupingAggregation,
} from '@kbn/securitysolution-grouping';
import { useGrouping, isNoneGroup } from '@kbn/securitysolution-grouping';
import type { TableIdLiteral } from '@kbn/securitysolution-data-table'; 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 type { Status } from '../../../../common/detection_engine/schemas/common';
import { InspectButton } from '../../../common/components/inspect';
import { defaultUnit } from '../../../common/components/toolbar/unit'; 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 { 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 { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { useInspectButton } from '../alerts_kpis/common/hooks'; import { getDefaultGroupingOptions, renderGroupPanel, getStats } from './grouping_settings';
import { useKibana } from '../../../common/lib/kibana';
import { buildTimeRangeFilter } from './helpers'; import { GroupedSubLevel } from './alerts_sub_grouping';
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 { track } from '../../../common/lib/telemetry'; import { track } from '../../../common/lib/telemetry';
const ALERTS_GROUPING_ID = 'alerts-grouping';
export interface AlertsTableComponentProps { export interface AlertsTableComponentProps {
currentAlertStatusFilterValue?: Status; currentAlertStatusFilterValue?: Status[];
defaultFilters?: Filter[]; defaultFilters?: Filter[];
from: string; from: string;
globalFilters: Filter[]; globalFilters: Filter[];
@ -63,52 +42,37 @@ export interface AlertsTableComponentProps {
to: string; to: string;
} }
export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> = ({ const DEFAULT_PAGE_SIZE = 25;
defaultFilters = [], const DEFAULT_PAGE_INDEX = 0;
from, const MAX_GROUPING_LEVELS = 3;
globalFilters,
globalQuery, const useStorage = (storage: Storage, tableId: string) =>
hasIndexMaintenance, useMemo(
hasIndexWrite, () => ({
loading, getStoragePageSize: (): number[] => {
tableId, const pageSizes = storage.get(`grouping-table-${tableId}`);
to, if (!pageSizes) {
runtimeMappings, return Array(MAX_GROUPING_LEVELS).fill(DEFAULT_PAGE_SIZE);
signalIndexName, }
currentAlertStatusFilterValue, return pageSizes;
renderChildComponent, },
}) => { setStoragePageSize: (pageSizes: number[]) => {
storage.set(`grouping-table-${tableId}`, pageSizes);
},
}),
[storage, tableId]
);
const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> = (props) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { browserFields, indexPattern, selectedPatterns } = useSourcererDataView( const { indexPattern, selectedPatterns } = useSourcererDataView(SourcererScopeName.detections);
SourcererScopeName.detections
);
const { const {
services: { uiSettings, telemetry }, services: { storage, telemetry },
} = useKibana(); } = useKibana();
const getGlobalQuery = useCallback( const { getStoragePageSize, setStoragePageSize } = useStorage(storage, props.tableId);
(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 { onGroupChange, onGroupToggle } = useMemo( const { onGroupChange, onGroupToggle } = useMemo(
() => ({ () => ({
@ -125,153 +89,146 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
[telemetry] [telemetry]
); );
// create a unique, but stable (across re-renders) query id const { groupSelector, getGrouping, selectedGroups } = useGrouping({
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({
componentProps: { componentProps: {
groupPanelRenderer: renderGroupPanel, groupPanelRenderer: renderGroupPanel,
groupStatsRenderer: getStats, groupStatsRenderer: getStats,
inspectButton: inspect,
onGroupToggle, onGroupToggle,
renderChildComponent,
unit: defaultUnit, unit: defaultUnit,
}, },
defaultGroupingOptions: getDefaultGroupingOptions(tableId), defaultGroupingOptions: getDefaultGroupingOptions(props.tableId),
fields: indexPattern.fields, fields: indexPattern.fields,
groupingId: tableId, groupingId: props.tableId,
maxGroupingLevels: MAX_GROUPING_LEVELS,
onGroupChange, onGroupChange,
tracker: track, tracker: track,
}); });
const resetPagination = pagination.reset;
const getGroupSelector = groupSelectors.getGroupSelector();
const groupSelectorInRedux = useSelector((state: State) => getGroupSelector(state));
const selectorOptions = useRef<GroupOption[]>([]);
useEffect(() => { useEffect(() => {
if (
isNoneGroup(selectedGroups) &&
groupSelector.props.options.length > 0 &&
(groupSelectorInRedux == null ||
!isEqual(selectorOptions.current, groupSelector.props.options))
) {
selectorOptions.current = groupSelector.props.options;
dispatch(updateGroupSelector({ groupSelector })); dispatch(updateGroupSelector({ groupSelector }));
}, [dispatch, groupSelector]); } else if (!isNoneGroup(selectedGroups) && groupSelectorInRedux !== null) {
dispatch(updateGroupSelector({ groupSelector: null }));
}
}, [dispatch, groupSelector, groupSelectorInRedux, selectedGroups]);
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(() => { useEffect(() => {
dispatch(updateSelectedGroup({ selectedGroup })); resetAllPagination();
}, [dispatch, selectedGroup]); }, [resetAllPagination, selectedGroups]);
useInvalidFilterQuery({ const setPageVar = useCallback(
id: tableId, (newNumber: number, groupingLevel: number, pageType: 'index' | 'size') => {
filterQuery: getGlobalQuery([])?.filterQuery, if (pageType === 'index') {
kqlError: getGlobalQuery([])?.kqlError, setPageIndex((currentIndex) => {
query: globalQuery, const newArr = [...currentIndex];
startDate: from, newArr[groupingLevel] = newNumber;
endDate: to, return newArr;
}); });
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 [];
} }
}, [defaultFilters, globalFilters, globalQuery, resetPagination]);
const queryGroups = useMemo( if (pageType === 'size') {
() => setPageSize((currentIndex) => {
getAlertsGroupingQuery({ const newArr = [...currentIndex];
additionalFilters, newArr[groupingLevel] = newNumber;
selectedGroup, setStoragePageSize(newArr);
from, return newArr;
runtimeMappings, });
to, }
pageSize: pagination.pageSize, },
pageIndex: pagination.pageIndex, [setStoragePageSize]
}),
[
additionalFilters,
selectedGroup,
from,
runtimeMappings,
to,
pagination.pageSize,
pagination.pageIndex,
]
); );
const { const nonGroupingFilters = useRef({
data: alertsGroupsData, defaultFilters: props.defaultFilters,
loading: isLoadingGroups, globalFilters: props.globalFilters,
refetch, globalQuery: props.globalQuery,
request,
response,
setQuery: setAlertsQuery,
} = useQueryAlerts<
{},
GroupingAggregation<AlertsGroupingAggregation> &
GroupingFieldTotalAggregation<AlertsGroupingAggregation>
>({
query: queryGroups,
indexName: signalIndexName,
queryName: ALERTS_QUERY_NAMES.ALERTS_GROUPING,
skip: isNoneGroup(selectedGroup),
}); });
useEffect(() => { useEffect(() => {
if (!isNoneGroup(selectedGroup)) { const nonGrouping = {
setAlertsQuery(queryGroups); 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({ const getLevel = useCallback(
deleteQuery, (level: number, selectedGroup: string, parentGroupingFilter?: string) => {
loading: isLoadingGroups, let rcc;
response, if (level < selectedGroups.length - 1) {
setQuery, rcc = (groupingFilters: Filter[]) => {
refetch, return getLevel(
request, level + 1,
uniqueQueryId, selectedGroups[level + 1],
}); JSON.stringify([
...groupingFilters,
const takeActionItems = useGroupTakeActionsItems({ ...(parentGroupingFilter ? JSON.parse(parentGroupingFilter) : []),
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]
); );
};
} else {
rcc = (groupingFilters: Filter[]) => {
return props.renderChildComponent([
...groupingFilters,
...(parentGroupingFilter ? JSON.parse(parentGroupingFilter) : []),
]);
};
}
const groupedAlerts = useMemo( const resetGroupChildrenPagination = (parentLevel: number) => {
() => setPageIndex((allPages) => {
getGrouping({ const resetPages = allPages.splice(parentLevel + 1, allPages.length);
data: alertsGroupsData?.aggregations, return [...allPages, ...resetPages.map(() => DEFAULT_PAGE_INDEX)];
isLoading: loading || isLoadingGroups, });
takeActionItems: getTakeActionItems, };
}), return (
[alertsGroupsData?.aggregations, getGrouping, getTakeActionItems, isLoadingGroups, loading] <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)) { if (isEmpty(selectedPatterns)) {
return null; return null;
} }
return groupedAlerts; return getLevel(0, selectedGroups[0]);
}; };
export const GroupedAlertsTable = React.memo(GroupedAlertsTableComponent); 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, groupNumber: 0,
selectedGroup: 'test', 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 () => { await act(async () => {
const { result, waitForNextUpdate } = renderHook( 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 () => { await act(async () => {
const { result, waitForNextUpdate } = renderHook( const { result, waitForNextUpdate } = renderHook(
() => () =>
@ -63,4 +162,20 @@ describe('useGroupTakeActionsItems', () => {
expect(result.current(getActionItemsParams).length).toEqual(0); 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. * 2.0.
*/ */
import React, { useMemo, useCallback } from 'react'; import React, { useCallback, useMemo } from 'react';
import { EuiContextMenuItem } from '@elastic/eui'; import { EuiContextMenuItem } from '@elastic/eui';
import { useKibana } from '@kbn/kibana-react-plugin/public'; import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { Status } from '../../../../../common/detection_engine/schemas/common'; 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 * as i18n from '../translations';
import { getTelemetryEvent, METRIC_TYPE, track } from '../../../../common/lib/telemetry'; import { getTelemetryEvent, METRIC_TYPE, track } from '../../../../common/lib/telemetry';
import type { StartServices } from '../../../../types'; import type { StartServices } from '../../../../types';
export interface TakeActionsProps { export interface TakeActionsProps {
currentStatus?: Status; currentStatus?: Status[];
indexName: string; indexName: string;
showAlertStatusActions?: boolean; showAlertStatusActions?: boolean;
} }
@ -182,7 +183,7 @@ export const useGroupTakeActionsItems = ({
] ]
); );
const items = useMemo(() => { return useMemo(() => {
const getActionItems = ({ const getActionItems = ({
query, query,
tableId, tableId,
@ -196,7 +197,9 @@ export const useGroupTakeActionsItems = ({
}) => { }) => {
const actionItems: JSX.Element[] = []; const actionItems: JSX.Element[] = [];
if (showAlertStatusActions) { if (showAlertStatusActions) {
if (currentStatus !== FILTER_OPEN) { if (currentStatus && currentStatus.length === 1) {
const singleStatus = currentStatus[0];
if (singleStatus !== FILTER_OPEN) {
actionItems.push( actionItems.push(
<EuiContextMenuItem <EuiContextMenuItem
key="open" key="open"
@ -215,7 +218,7 @@ export const useGroupTakeActionsItems = ({
</EuiContextMenuItem> </EuiContextMenuItem>
); );
} }
if (currentStatus !== FILTER_ACKNOWLEDGED) { if (singleStatus !== FILTER_ACKNOWLEDGED) {
actionItems.push( actionItems.push(
<EuiContextMenuItem <EuiContextMenuItem
key="acknowledge" key="acknowledge"
@ -234,7 +237,7 @@ export const useGroupTakeActionsItems = ({
</EuiContextMenuItem> </EuiContextMenuItem>
); );
} }
if (currentStatus !== FILTER_CLOSED) { if (singleStatus !== FILTER_CLOSED) {
actionItems.push( actionItems.push(
<EuiContextMenuItem <EuiContextMenuItem
key="close" key="close"
@ -253,12 +256,36 @@ export const useGroupTakeActionsItems = ({
</EuiContextMenuItem> </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>
)
);
}
} }
return actionItems; return actionItems;
}; };
return getActionItems; return getActionItems;
}, [currentStatus, onClickUpdate, showAlertStatusActions]); }, [currentStatus, onClickUpdate, showAlertStatusActions]);
return items;
}; };

View file

@ -42,8 +42,8 @@ export const getAlertsGroupingQuery = ({
getGroupingQuery({ getGroupingQuery({
additionalFilters, additionalFilters,
from, from,
groupByFields: !isNoneGroup(selectedGroup) ? getGroupFields(selectedGroup) : [], groupByFields: !isNoneGroup([selectedGroup]) ? getGroupFields(selectedGroup) : [],
statsAggregations: !isNoneGroup(selectedGroup) statsAggregations: !isNoneGroup([selectedGroup])
? getAggregationsByGroupField(selectedGroup) ? getAggregationsByGroupField(selectedGroup)
: [], : [],
pageNumber: pageIndex * pageSize, pageNumber: pageIndex * pageSize,
@ -51,7 +51,7 @@ export const getAlertsGroupingQuery = ({
{ {
unitsCount: { value_count: { field: selectedGroup } }, unitsCount: { value_count: { field: selectedGroup } },
}, },
...(!isNoneGroup(selectedGroup) ...(!isNoneGroup([selectedGroup])
? [{ groupsCount: { cardinality: { field: 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 React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { isNoneGroup } from '@kbn/securitysolution-grouping';
import { import {
dataTableSelectors, dataTableSelectors,
tableDefaults, tableDefaults,
@ -29,9 +28,6 @@ export const getPersistentControlsHook = (tableId: TableId) => {
const getGroupSelector = groupSelectors.getGroupSelector(); const getGroupSelector = groupSelectors.getGroupSelector();
const groupSelector = useSelector((state: State) => getGroupSelector(state)); const groupSelector = useSelector((state: State) => getGroupSelector(state));
const getSelectedGroup = groupSelectors.getSelectedGroup();
const selectedGroup = useSelector((state: State) => getSelectedGroup(state));
const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []); const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []);
@ -88,10 +84,10 @@ export const getPersistentControlsHook = (tableId: TableId) => {
hasRightOffset={false} hasRightOffset={false}
additionalFilters={additionalFiltersComponent} additionalFilters={additionalFiltersComponent}
showInspect={false} showInspect={false}
additionalMenuOptions={isNoneGroup(selectedGroup) ? [groupSelector] : []} additionalMenuOptions={groupSelector != null ? [groupSelector] : []}
/> />
), ),
[tableView, handleChangeTableView, additionalFiltersComponent, groupSelector, selectedGroup] [tableView, handleChangeTableView, additionalFiltersComponent, groupSelector]
); );
return { return {

View file

@ -5,10 +5,9 @@
* 2.0. * 2.0.
*/ */
import React from 'react'; import React, { useEffect } from 'react';
import { mount } from 'enzyme'; import { render, waitFor } from '@testing-library/react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { waitFor } from '@testing-library/react';
import '../../../common/mock/match_media'; import '../../../common/mock/match_media';
import { import {
createSecuritySolutionStorageMock, 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 { createFilterManagerMock } from '@kbn/data-plugin/public/query/filter_manager/filter_manager.mock';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { createStubDataView } from '@kbn/data-views-plugin/common/data_view.stub'; 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 // 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 // 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', () => ({ jest.mock('../../../common/components/query_bar', () => ({
QueryBar: () => null, 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('../../containers/detection_engine/lists/use_lists_config');
jest.mock('../../components/user_info'); jest.mock('../../components/user_info');
jest.mock('../../../common/containers/sourcerer'); jest.mock('../../../common/containers/sourcerer');
@ -158,9 +182,11 @@ jest.mock('../../../common/components/page/use_refetch_by_session');
describe('DetectionEnginePageComponent', () => { describe('DetectionEnginePageComponent', () => {
beforeAll(() => { beforeAll(() => {
(useListsConfig as jest.Mock).mockReturnValue({ loading: false, needsConfiguration: false });
(useParams as jest.Mock).mockReturnValue({}); (useParams as jest.Mock).mockReturnValue({});
(useUserData as jest.Mock).mockReturnValue([ (useUserData as jest.Mock).mockReturnValue([
{ {
loading: false,
hasIndexRead: true, hasIndexRead: true,
canUserREAD: true, canUserREAD: true,
}, },
@ -170,10 +196,15 @@ describe('DetectionEnginePageComponent', () => {
indexPattern: {}, indexPattern: {},
browserFields: mockBrowserFields, browserFields: mockBrowserFields,
}); });
(FilterGroup as jest.Mock).mockImplementation(() => {
return <span />;
});
});
beforeEach(() => {
jest.clearAllMocks();
}); });
it('renders correctly', async () => { it('renders correctly', async () => {
const wrapper = mount( const { getByTestId } = render(
<TestProviders store={store}> <TestProviders store={store}>
<Router history={mockHistory}> <Router history={mockHistory}>
<DetectionEnginePage /> <DetectionEnginePage />
@ -181,12 +212,12 @@ describe('DetectionEnginePageComponent', () => {
</TestProviders> </TestProviders>
); );
await waitFor(() => { await waitFor(() => {
expect(wrapper.find('FiltersGlobal').exists()).toBe(true); expect(getByTestId('filter-group__loading')).toBeInTheDocument();
}); });
}); });
it('renders the chart panels', async () => { it('renders the chart panels', async () => {
const wrapper = mount( const { getByTestId } = render(
<TestProviders store={store}> <TestProviders store={store}>
<Router history={mockHistory}> <Router history={mockHistory}>
<DetectionEnginePage /> <DetectionEnginePage />
@ -195,7 +226,119 @@ describe('DetectionEnginePageComponent', () => {
); );
await waitFor(() => { 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, dataTableActions,
dataTableSelectors, dataTableSelectors,
tableDefaults, tableDefaults,
FILTER_OPEN,
TableId, TableId,
} from '@kbn/securitysolution-data-table'; } from '@kbn/securitysolution-data-table';
import { ALERTS_TABLE_REGISTRY_CONFIG_IDS } from '../../../../common/constants'; import { ALERTS_TABLE_REGISTRY_CONFIG_IDS } from '../../../../common/constants';
@ -139,7 +138,7 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
const arePageFiltersEnabled = useIsExperimentalFeatureEnabled('alertsPageFiltersEnabled'); const arePageFiltersEnabled = useIsExperimentalFeatureEnabled('alertsPageFiltersEnabled');
// when arePageFiltersEnabled === false // when arePageFiltersEnabled === false
const [filterGroup, setFilterGroup] = useState<Status>(FILTER_OPEN); const [statusFilter, setStatusFilter] = useState<Status[]>([]);
const updatedAt = useShallowEqualSelector( const updatedAt = useShallowEqualSelector(
(state) => (getTable(state, TableId.alertsOnAlertsPage) ?? tableDefaults).updated (state) => (getTable(state, TableId.alertsOnAlertsPage) ?? tableDefaults).updated
@ -177,8 +176,8 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
if (arePageFiltersEnabled) { if (arePageFiltersEnabled) {
return detectionPageFilters; return detectionPageFilters;
} }
return buildAlertStatusFilter(filterGroup); return buildAlertStatusFilter(statusFilter[0] ?? 'open');
}, [filterGroup, detectionPageFilters, arePageFiltersEnabled]); }, [statusFilter, detectionPageFilters, arePageFiltersEnabled]);
useEffect(() => { useEffect(() => {
if (!detectionPageFilterHandler) return; if (!detectionPageFilterHandler) return;
@ -276,6 +275,19 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
const pageFiltersUpdateHandler = useCallback((newFilters: Filter[]) => { const pageFiltersUpdateHandler = useCallback((newFilters: Filter[]) => {
setDetectionPageFilters(newFilters); 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 // Callback for when open/closed filter changes
@ -284,9 +296,9 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
const timelineId = TableId.alertsOnAlertsPage; const timelineId = TableId.alertsOnAlertsPage;
clearEventsLoading({ id: timelineId }); clearEventsLoading({ id: timelineId });
clearEventsDeleted({ id: timelineId }); clearEventsDeleted({ id: timelineId });
setFilterGroup(newFilterGroup); setStatusFilter([newFilterGroup]);
}, },
[clearEventsLoading, clearEventsDeleted, setFilterGroup] [clearEventsLoading, clearEventsDeleted, setStatusFilter]
); );
const areDetectionPageFiltersLoading = useMemo(() => { const areDetectionPageFiltersLoading = useMemo(() => {
@ -317,7 +329,7 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween"> <EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<AlertsTableFilterGroup <AlertsTableFilterGroup
status={filterGroup} status={statusFilter[0] ?? 'open'}
onFilterGroupChanged={onFilterGroupChangedCallback} onFilterGroupChanged={onFilterGroupChangedCallback}
/> />
</EuiFlexItem> </EuiFlexItem>
@ -352,7 +364,7 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
[ [
arePageFiltersEnabled, arePageFiltersEnabled,
dataViewId, dataViewId,
filterGroup, statusFilter,
filters, filters,
onFilterGroupChangedCallback, onFilterGroupChangedCallback,
pageFiltersUpdateHandler, pageFiltersUpdateHandler,
@ -462,7 +474,7 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
<EuiSpacer size="l" /> <EuiSpacer size="l" />
</Display> </Display>
<GroupedAlertsTable <GroupedAlertsTable
currentAlertStatusFilterValue={filterGroup} currentAlertStatusFilterValue={statusFilter}
defaultFilters={alertsTableDefaultFilters} defaultFilters={alertsTableDefaultFilters}
from={from} from={from}
globalFilters={filters} globalFilters={filters}

View file

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

View file

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