mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[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:
parent
24a3df49f8
commit
9eee24f7bf
61 changed files with 3634 additions and 1219 deletions
|
@ -15,6 +15,7 @@ const STORYBOOKS = [
|
|||
'apm',
|
||||
'canvas',
|
||||
'cases',
|
||||
'cell_actions',
|
||||
'ci_composite',
|
||||
'cloud_chat',
|
||||
'coloring',
|
||||
|
@ -34,6 +35,7 @@ const STORYBOOKS = [
|
|||
'expression_shape',
|
||||
'expression_tagcloud',
|
||||
'fleet',
|
||||
'grouping',
|
||||
'home',
|
||||
'infra',
|
||||
'kibana_react',
|
||||
|
|
|
@ -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;
|
|
@ -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
|
3
packages/kbn-securitysolution-grouping/README.mdx
Normal file
3
packages/kbn-securitysolution-grouping/README.mdx
Normal file
|
@ -0,0 +1,3 @@
|
|||
# @kbn/securitysolution-grouping
|
||||
|
||||
Grouping component and query. Currently only consumed by security solution alerts table.
|
|
@ -6,20 +6,22 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { RawBucket, StatRenderer, getGroupingQuery, isNoneGroup, useGrouping } from './src';
|
||||
import { getGroupingQuery, isNoneGroup, useGrouping } from './src';
|
||||
import type {
|
||||
DynamicGroupingProps,
|
||||
GroupOption,
|
||||
GroupingAggregation,
|
||||
GroupingFieldTotalAggregation,
|
||||
NamedAggregation,
|
||||
RawBucket,
|
||||
StatRenderer,
|
||||
} from './src';
|
||||
|
||||
export { getGroupingQuery, isNoneGroup, useGrouping };
|
||||
|
||||
export type {
|
||||
DynamicGroupingProps,
|
||||
GroupOption,
|
||||
GroupingAggregation,
|
||||
GroupingFieldTotalAggregation,
|
||||
NamedAggregation,
|
||||
RawBucket,
|
||||
StatRenderer,
|
||||
|
|
|
@ -13,6 +13,8 @@ import { GroupStats } from './group_stats';
|
|||
const onTakeActionsOpen = jest.fn();
|
||||
const testProps = {
|
||||
bucketKey: '9nk5mo2fby',
|
||||
groupFilter: [],
|
||||
groupNumber: 0,
|
||||
onTakeActionsOpen,
|
||||
statRenderers: [
|
||||
{
|
||||
|
@ -23,7 +25,7 @@ const testProps = {
|
|||
{ title: 'Rules:', badge: { value: 2 } },
|
||||
{ title: 'Alerts:', badge: { value: 2, width: 50, color: '#a83632' } },
|
||||
],
|
||||
takeActionItems: [
|
||||
takeActionItems: () => [
|
||||
<p data-test-subj="takeActionItem-1" key={1} />,
|
||||
<p data-test-subj="takeActionItem-2" key={2} />,
|
||||
],
|
||||
|
|
|
@ -16,29 +16,44 @@ import {
|
|||
EuiToolTip,
|
||||
} from '@elastic/eui';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Filter } from '@kbn/es-query';
|
||||
import { StatRenderer } from '../types';
|
||||
import { statsContainerCss } from '../styles';
|
||||
import { TAKE_ACTION } from '../translations';
|
||||
|
||||
interface GroupStatsProps<T> {
|
||||
bucketKey: string;
|
||||
statRenderers?: StatRenderer[];
|
||||
groupFilter: Filter[];
|
||||
groupNumber: number;
|
||||
onTakeActionsOpen?: () => void;
|
||||
takeActionItems: JSX.Element[];
|
||||
statRenderers?: StatRenderer[];
|
||||
takeActionItems: (groupFilters: Filter[], groupNumber: number) => JSX.Element[];
|
||||
}
|
||||
|
||||
const GroupStatsComponent = <T,>({
|
||||
bucketKey,
|
||||
statRenderers,
|
||||
groupFilter,
|
||||
groupNumber,
|
||||
onTakeActionsOpen,
|
||||
takeActionItems,
|
||||
statRenderers,
|
||||
takeActionItems: getTakeActionItems,
|
||||
}: GroupStatsProps<T>) => {
|
||||
const [isPopoverOpen, setPopover] = useState(false);
|
||||
const [takeActionItems, setTakeActionItems] = useState<JSX.Element[]>([]);
|
||||
|
||||
const onButtonClick = useCallback(
|
||||
() => (!isPopoverOpen && onTakeActionsOpen ? onTakeActionsOpen() : setPopover(!isPopoverOpen)),
|
||||
[isPopoverOpen, onTakeActionsOpen]
|
||||
);
|
||||
const onButtonClick = useCallback(() => {
|
||||
if (!isPopoverOpen && takeActionItems.length === 0) {
|
||||
setTakeActionItems(getTakeActionItems(groupFilter, groupNumber));
|
||||
}
|
||||
return !isPopoverOpen && onTakeActionsOpen ? onTakeActionsOpen() : setPopover(!isPopoverOpen);
|
||||
}, [
|
||||
getTakeActionItems,
|
||||
groupFilter,
|
||||
groupNumber,
|
||||
isPopoverOpen,
|
||||
onTakeActionsOpen,
|
||||
takeActionItems.length,
|
||||
]);
|
||||
|
||||
const statsComponent = useMemo(
|
||||
() =>
|
||||
|
|
|
@ -55,6 +55,7 @@ const testProps = {
|
|||
},
|
||||
renderChildComponent,
|
||||
selectedGroup: 'kibana.alert.rule.name',
|
||||
onGroupClose: () => {},
|
||||
};
|
||||
|
||||
describe('grouping accordion panel', () => {
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import { EuiAccordion, EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui';
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { firstNonNullValue } from '../../helpers';
|
||||
import type { RawBucket } from '../types';
|
||||
import { createGroupFilter } from './helpers';
|
||||
|
@ -20,8 +20,9 @@ interface GroupPanelProps<T> {
|
|||
forceState?: 'open' | 'closed';
|
||||
groupBucket: RawBucket<T>;
|
||||
groupPanelRenderer?: JSX.Element;
|
||||
groupingLevel?: number;
|
||||
isLoading: boolean;
|
||||
level?: number;
|
||||
onGroupClose: () => void;
|
||||
onToggleGroup?: (isOpen: boolean, groupBucket: RawBucket<T>) => void;
|
||||
renderChildComponent: (groupFilter: Filter[]) => React.ReactElement;
|
||||
selectedGroup: string;
|
||||
|
@ -40,18 +41,30 @@ const DefaultGroupPanelRenderer = ({ title }: { title: string }) => (
|
|||
);
|
||||
|
||||
const GroupPanelComponent = <T,>({
|
||||
customAccordionButtonClassName = 'groupingAccordionForm__button',
|
||||
customAccordionButtonClassName,
|
||||
customAccordionClassName = 'groupingAccordionForm',
|
||||
extraAction,
|
||||
forceState,
|
||||
groupBucket,
|
||||
groupPanelRenderer,
|
||||
groupingLevel = 0,
|
||||
isLoading,
|
||||
level = 0,
|
||||
onGroupClose,
|
||||
onToggleGroup,
|
||||
renderChildComponent,
|
||||
selectedGroup,
|
||||
}: GroupPanelProps<T>) => {
|
||||
const lastForceState = useRef(forceState);
|
||||
useEffect(() => {
|
||||
if (lastForceState.current === 'open' && forceState === 'closed') {
|
||||
// when parent group closes, reset pagination of any child groups
|
||||
onGroupClose();
|
||||
lastForceState.current = 'closed';
|
||||
} else if (lastForceState.current === 'closed' && forceState === 'open') {
|
||||
lastForceState.current = 'open';
|
||||
}
|
||||
}, [onGroupClose, forceState, selectedGroup]);
|
||||
|
||||
const groupFieldValue = useMemo(() => firstNonNullValue(groupBucket.key), [groupBucket.key]);
|
||||
|
||||
const groupFilters = useMemo(
|
||||
|
@ -72,20 +85,21 @@ const GroupPanelComponent = <T,>({
|
|||
<EuiAccordion
|
||||
buttonClassName={customAccordionButtonClassName}
|
||||
buttonContent={
|
||||
<div className="groupingPanelRenderer">
|
||||
<div data-test-subj="group-panel-toggle" className="groupingPanelRenderer">
|
||||
{groupPanelRenderer ?? <DefaultGroupPanelRenderer title={groupFieldValue} />}
|
||||
</div>
|
||||
}
|
||||
className={customAccordionClassName}
|
||||
buttonElement="div"
|
||||
className={groupingLevel > 0 ? 'groupingAccordionFormLevel' : customAccordionClassName}
|
||||
data-test-subj="grouping-accordion"
|
||||
extraAction={extraAction}
|
||||
forceState={forceState}
|
||||
isLoading={isLoading}
|
||||
id={`group${level}-${groupFieldValue}`}
|
||||
id={`group${groupingLevel}-${groupFieldValue}`}
|
||||
onToggle={onToggle}
|
||||
paddingSize="m"
|
||||
>
|
||||
{renderChildComponent(groupFilters)}
|
||||
<span data-test-subj="grouping-accordion-content">{renderChildComponent(groupFilters)}</span>
|
||||
</EuiAccordion>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -43,7 +43,7 @@ const testProps = {
|
|||
esTypes: ['ip'],
|
||||
},
|
||||
],
|
||||
groupSelected: 'kibana.alert.rule.name',
|
||||
groupsSelected: ['kibana.alert.rule.name'],
|
||||
onGroupChange,
|
||||
options: [
|
||||
{
|
||||
|
@ -90,4 +90,38 @@ describe('group selector', () => {
|
|||
fireEvent.click(getByTestId('panel-none'));
|
||||
expect(onGroupChange).toHaveBeenCalled();
|
||||
});
|
||||
it('Labels button in correct selection order', () => {
|
||||
const { getByTestId, rerender } = render(
|
||||
<GroupSelector
|
||||
{...testProps}
|
||||
groupsSelected={[...testProps.groupsSelected, 'user.name', 'host.name']}
|
||||
/>
|
||||
);
|
||||
expect(getByTestId('group-selector-dropdown').title).toEqual('Rule name, User name, Host name');
|
||||
rerender(
|
||||
<GroupSelector
|
||||
{...testProps}
|
||||
groupsSelected={[...testProps.groupsSelected, 'host.name', 'user.name']}
|
||||
/>
|
||||
);
|
||||
expect(getByTestId('group-selector-dropdown').title).toEqual('Rule name, Host name, User name');
|
||||
});
|
||||
it('Labels button with selection not in options', () => {
|
||||
const { getByTestId } = render(
|
||||
<GroupSelector
|
||||
{...testProps}
|
||||
groupsSelected={[...testProps.groupsSelected, 'ugly.name', 'host.name']}
|
||||
/>
|
||||
);
|
||||
expect(getByTestId('group-selector-dropdown').title).toEqual('Rule name, Host name');
|
||||
});
|
||||
it('Labels button when `none` is selected', () => {
|
||||
const { getByTestId } = render(
|
||||
<GroupSelector
|
||||
{...testProps}
|
||||
groupsSelected={[...testProps.groupsSelected, 'ugly.name', 'host.name']}
|
||||
/>
|
||||
);
|
||||
expect(getByTestId('group-selector-dropdown').title).toEqual('Rule name, Host name');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -21,21 +21,27 @@ export interface GroupSelectorProps {
|
|||
'data-test-subj'?: string;
|
||||
fields: FieldSpec[];
|
||||
groupingId: string;
|
||||
groupSelected: string;
|
||||
groupsSelected: string[];
|
||||
onGroupChange: (groupSelection: string) => void;
|
||||
options: Array<{ key: string; label: string }>;
|
||||
title?: string;
|
||||
maxGroupingLevels?: number;
|
||||
}
|
||||
|
||||
const GroupSelectorComponent = ({
|
||||
'data-test-subj': dataTestSubj,
|
||||
fields,
|
||||
groupSelected = 'none',
|
||||
groupsSelected = ['none'],
|
||||
onGroupChange,
|
||||
options,
|
||||
title = i18n.GROUP_BY,
|
||||
maxGroupingLevels = 1,
|
||||
}: GroupSelectorProps) => {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const isGroupSelected = useCallback(
|
||||
(groupKey: string) =>
|
||||
!!groupsSelected.find((selectedGroupKey) => selectedGroupKey === groupKey),
|
||||
[groupsSelected]
|
||||
);
|
||||
|
||||
const panels: EuiContextMenuPanelDescriptor[] = useMemo(
|
||||
() => [
|
||||
|
@ -49,7 +55,7 @@ const GroupSelectorComponent = ({
|
|||
style={{ lineHeight: 1 }}
|
||||
>
|
||||
<EuiFlexItem grow={false} component="p" style={{ lineHeight: 1.5 }}>
|
||||
{i18n.SELECT_FIELD.toUpperCase()}
|
||||
{i18n.SELECT_FIELD(maxGroupingLevels)}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} component="span">
|
||||
<EuiBetaBadge
|
||||
|
@ -65,20 +71,23 @@ const GroupSelectorComponent = ({
|
|||
{
|
||||
'data-test-subj': 'panel-none',
|
||||
name: i18n.NONE,
|
||||
icon: groupSelected === 'none' ? 'check' : 'empty',
|
||||
icon: isGroupSelected('none') ? 'check' : 'empty',
|
||||
onClick: () => onGroupChange('none'),
|
||||
},
|
||||
...options.map<EuiContextMenuPanelItemDescriptor>((o) => ({
|
||||
'data-test-subj': `panel-${o.key}`,
|
||||
disabled: groupsSelected.length === maxGroupingLevels && !isGroupSelected(o.key),
|
||||
name: o.label,
|
||||
onClick: () => onGroupChange(o.key),
|
||||
icon: groupSelected === o.key ? 'check' : 'empty',
|
||||
icon: isGroupSelected(o.key) ? 'check' : 'empty',
|
||||
})),
|
||||
{
|
||||
'data-test-subj': `panel-custom`,
|
||||
name: i18n.CUSTOM_FIELD,
|
||||
icon: 'empty',
|
||||
disabled: groupsSelected.length === maxGroupingLevels,
|
||||
panel: 'customPanel',
|
||||
hasPanel: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -91,24 +100,35 @@ const GroupSelectorComponent = ({
|
|||
currentOptions={options.map((o) => ({ text: o.label, field: o.key }))}
|
||||
onSubmit={(field: string) => {
|
||||
onGroupChange(field);
|
||||
setIsPopoverOpen(false);
|
||||
}}
|
||||
fields={fields}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[fields, groupSelected, onGroupChange, options]
|
||||
[fields, groupsSelected.length, isGroupSelected, maxGroupingLevels, onGroupChange, options]
|
||||
);
|
||||
const selectedOption = useMemo(
|
||||
() => options.filter((groupOption) => groupOption.key === groupSelected),
|
||||
[groupSelected, options]
|
||||
const selectedOptions = useMemo(
|
||||
() => options.filter((groupOption) => isGroupSelected(groupOption.key)),
|
||||
[isGroupSelected, options]
|
||||
);
|
||||
|
||||
const onButtonClick = useCallback(() => setIsPopoverOpen((currentVal) => !currentVal), []);
|
||||
const closePopover = useCallback(() => setIsPopoverOpen(false), []);
|
||||
|
||||
const button = useMemo(
|
||||
() => (
|
||||
const button = useMemo(() => {
|
||||
// need to use groupsSelected to ensure proper selection order (selectedOptions does not handle selection order)
|
||||
const buttonLabel = isGroupSelected('none')
|
||||
? i18n.NONE
|
||||
: groupsSelected.reduce((optionsTitle, o) => {
|
||||
const selection = selectedOptions.find((opt) => opt.key === o);
|
||||
if (selection == null) {
|
||||
return optionsTitle;
|
||||
}
|
||||
return optionsTitle ? [optionsTitle, selection.label].join(', ') : selection.label;
|
||||
}, '');
|
||||
return (
|
||||
<StyledEuiButtonEmpty
|
||||
data-test-subj="group-selector-dropdown"
|
||||
flush="both"
|
||||
|
@ -116,22 +136,13 @@ const GroupSelectorComponent = ({
|
|||
iconSize="s"
|
||||
iconType="arrowDown"
|
||||
onClick={onButtonClick}
|
||||
title={
|
||||
groupSelected !== 'none' && selectedOption.length > 0
|
||||
? selectedOption[0].label
|
||||
: i18n.NONE
|
||||
}
|
||||
title={buttonLabel}
|
||||
size="xs"
|
||||
>
|
||||
{`${title}: ${
|
||||
groupSelected !== 'none' && selectedOption.length > 0
|
||||
? selectedOption[0].label
|
||||
: i18n.NONE
|
||||
}`}
|
||||
{`${title}: ${buttonLabel}`}
|
||||
</StyledEuiButtonEmpty>
|
||||
),
|
||||
[groupSelected, onButtonClick, selectedOption, title]
|
||||
);
|
||||
);
|
||||
}, [groupsSelected, isGroupSelected, onButtonClick, selectedOptions, title]);
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
|
|
|
@ -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: () => {},
|
||||
};
|
|
@ -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} />;
|
||||
};
|
|
@ -14,104 +14,15 @@ import { createGroupFilter } from './accordion_panel/helpers';
|
|||
import { METRIC_TYPE } from '@kbn/analytics';
|
||||
import { getTelemetryEvent } from '../telemetry/const';
|
||||
|
||||
import { mockGroupingProps, rule1Name, rule2Name } from './grouping.mock';
|
||||
|
||||
const renderChildComponent = jest.fn();
|
||||
const takeActionItems = jest.fn();
|
||||
const mockTracker = jest.fn();
|
||||
const rule1Name = 'Rule 1 name';
|
||||
const rule1Desc = 'Rule 1 description';
|
||||
const rule2Name = 'Rule 2 name';
|
||||
const rule2Desc = 'Rule 2 description';
|
||||
|
||||
const testProps = {
|
||||
data: {
|
||||
groupsCount: {
|
||||
value: 2,
|
||||
},
|
||||
groupByFields: {
|
||||
doc_count_error_upper_bound: 0,
|
||||
sum_other_doc_count: 0,
|
||||
buckets: [
|
||||
{
|
||||
key: [rule1Name, rule1Desc],
|
||||
key_as_string: `${rule1Name}|${rule1Desc}`,
|
||||
doc_count: 1,
|
||||
hostsCountAggregation: {
|
||||
value: 1,
|
||||
},
|
||||
ruleTags: {
|
||||
doc_count_error_upper_bound: 0,
|
||||
sum_other_doc_count: 0,
|
||||
buckets: [],
|
||||
},
|
||||
alertsCount: {
|
||||
value: 1,
|
||||
},
|
||||
severitiesSubAggregation: {
|
||||
doc_count_error_upper_bound: 0,
|
||||
sum_other_doc_count: 0,
|
||||
buckets: [
|
||||
{
|
||||
key: 'low',
|
||||
doc_count: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
countSeveritySubAggregation: {
|
||||
value: 1,
|
||||
},
|
||||
usersCountAggregation: {
|
||||
value: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: [rule2Name, rule2Desc],
|
||||
key_as_string: `${rule2Name}|${rule2Desc}`,
|
||||
doc_count: 1,
|
||||
hostsCountAggregation: {
|
||||
value: 1,
|
||||
},
|
||||
ruleTags: {
|
||||
doc_count_error_upper_bound: 0,
|
||||
sum_other_doc_count: 0,
|
||||
buckets: [],
|
||||
},
|
||||
unitsCount: {
|
||||
value: 1,
|
||||
},
|
||||
severitiesSubAggregation: {
|
||||
doc_count_error_upper_bound: 0,
|
||||
sum_other_doc_count: 0,
|
||||
buckets: [
|
||||
{
|
||||
key: 'low',
|
||||
doc_count: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
countSeveritySubAggregation: {
|
||||
value: 1,
|
||||
},
|
||||
usersCountAggregation: {
|
||||
value: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
unitsCount: {
|
||||
value: 2,
|
||||
},
|
||||
},
|
||||
groupingId: 'test-grouping-id',
|
||||
isLoading: false,
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
onChangeItemsPerPage: jest.fn(),
|
||||
onChangePage: jest.fn(),
|
||||
itemsPerPageOptions: [10, 25, 50, 100],
|
||||
},
|
||||
...mockGroupingProps,
|
||||
renderChildComponent,
|
||||
selectedGroup: 'kibana.alert.rule.name',
|
||||
takeActionItems,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
|
|
@ -21,35 +21,29 @@ import { createGroupFilter } from './accordion_panel/helpers';
|
|||
import { GroupPanel } from './accordion_panel';
|
||||
import { GroupStats } from './accordion_panel/group_stats';
|
||||
import { EmptyGroupingComponent } from './empty_results_panel';
|
||||
import { groupingContainerCss, countCss } from './styles';
|
||||
import { countCss, groupingContainerCss, groupingContainerCssLevel } from './styles';
|
||||
import { GROUPS_UNIT } from './translations';
|
||||
import type {
|
||||
GroupingAggregation,
|
||||
GroupingFieldTotalAggregation,
|
||||
GroupPanelRenderer,
|
||||
RawBucket,
|
||||
} from './types';
|
||||
import { getTelemetryEvent } from '../telemetry/const';
|
||||
import type { GroupingAggregation, GroupPanelRenderer } from './types';
|
||||
import { GroupStatsRenderer, OnGroupToggle } from './types';
|
||||
import { getTelemetryEvent } from '../telemetry/const';
|
||||
|
||||
export interface GroupingProps<T> {
|
||||
data?: GroupingAggregation<T> & GroupingFieldTotalAggregation<T>;
|
||||
groupingId: string;
|
||||
activePage: number;
|
||||
data?: GroupingAggregation<T>;
|
||||
groupPanelRenderer?: GroupPanelRenderer<T>;
|
||||
groupSelector?: JSX.Element;
|
||||
// list of custom UI components which correspond to your custom rendered metrics aggregations
|
||||
groupStatsRenderer?: GroupStatsRenderer<T>;
|
||||
groupingId: string;
|
||||
groupingLevel?: number;
|
||||
inspectButton?: JSX.Element;
|
||||
isLoading: boolean;
|
||||
itemsPerPage: number;
|
||||
onChangeGroupsItemsPerPage?: (size: number) => void;
|
||||
onChangeGroupsPage?: (index: number) => void;
|
||||
onGroupToggle?: OnGroupToggle;
|
||||
pagination: {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
onChangeItemsPerPage: (itemsPerPageNumber: number) => void;
|
||||
onChangePage: (pageNumber: number) => void;
|
||||
itemsPerPageOptions: number[];
|
||||
};
|
||||
renderChildComponent: (groupFilter: Filter[]) => React.ReactElement;
|
||||
onGroupClose: () => void;
|
||||
selectedGroup: string;
|
||||
takeActionItems: (groupFilters: Filter[], groupNumber: number) => JSX.Element[];
|
||||
tracker?: (
|
||||
|
@ -61,24 +55,29 @@ export interface GroupingProps<T> {
|
|||
}
|
||||
|
||||
const GroupingComponent = <T,>({
|
||||
activePage,
|
||||
data,
|
||||
groupingId,
|
||||
groupPanelRenderer,
|
||||
groupSelector,
|
||||
groupStatsRenderer,
|
||||
groupingId,
|
||||
groupingLevel = 0,
|
||||
inspectButton,
|
||||
isLoading,
|
||||
itemsPerPage,
|
||||
onChangeGroupsItemsPerPage,
|
||||
onChangeGroupsPage,
|
||||
onGroupClose,
|
||||
onGroupToggle,
|
||||
pagination,
|
||||
renderChildComponent,
|
||||
selectedGroup,
|
||||
takeActionItems,
|
||||
tracker,
|
||||
unit = defaultUnit,
|
||||
}: GroupingProps<T>) => {
|
||||
const [trigger, setTrigger] = useState<
|
||||
Record<string, { state: 'open' | 'closed' | undefined; selectedBucket: RawBucket<T> }>
|
||||
>({});
|
||||
const [trigger, setTrigger] = useState<Record<string, { state: 'open' | 'closed' | undefined }>>(
|
||||
{}
|
||||
);
|
||||
|
||||
const unitCount = data?.unitsCount?.value ?? 0;
|
||||
const unitCountText = useMemo(() => {
|
||||
|
@ -100,16 +99,16 @@ const GroupingComponent = <T,>({
|
|||
return (
|
||||
<span key={groupKey}>
|
||||
<GroupPanel
|
||||
onGroupClose={onGroupClose}
|
||||
extraAction={
|
||||
<GroupStats
|
||||
bucketKey={groupKey}
|
||||
takeActionItems={takeActionItems(
|
||||
createGroupFilter(selectedGroup, group),
|
||||
groupNumber
|
||||
)}
|
||||
groupFilter={createGroupFilter(selectedGroup, group)}
|
||||
groupNumber={groupNumber}
|
||||
statRenderers={
|
||||
groupStatsRenderer && groupStatsRenderer(selectedGroup, groupBucket)
|
||||
}
|
||||
takeActionItems={takeActionItems}
|
||||
/>
|
||||
}
|
||||
forceState={(trigger[groupKey] && trigger[groupKey].state) ?? 'closed'}
|
||||
|
@ -128,7 +127,6 @@ const GroupingComponent = <T,>({
|
|||
// ...trigger, -> this change will keep only one group at a time expanded and one table displayed
|
||||
[groupKey]: {
|
||||
state: isOpen ? 'open' : 'closed',
|
||||
selectedBucket: groupBucket,
|
||||
},
|
||||
});
|
||||
onGroupToggle?.({ isOpen, groupName: group, groupNumber, groupingId });
|
||||
|
@ -139,8 +137,9 @@ const GroupingComponent = <T,>({
|
|||
: () => <span />
|
||||
}
|
||||
selectedGroup={selectedGroup}
|
||||
groupingLevel={groupingLevel}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
{groupingLevel > 0 ? null : <EuiSpacer size="s" />}
|
||||
</span>
|
||||
);
|
||||
}),
|
||||
|
@ -149,7 +148,9 @@ const GroupingComponent = <T,>({
|
|||
groupPanelRenderer,
|
||||
groupStatsRenderer,
|
||||
groupingId,
|
||||
groupingLevel,
|
||||
isLoading,
|
||||
onGroupClose,
|
||||
onGroupToggle,
|
||||
renderChildComponent,
|
||||
selectedGroup,
|
||||
|
@ -159,58 +160,76 @@ const GroupingComponent = <T,>({
|
|||
]
|
||||
);
|
||||
const pageCount = useMemo(
|
||||
() => (groupCount && pagination.pageSize ? Math.ceil(groupCount / pagination.pageSize) : 1),
|
||||
[groupCount, pagination.pageSize]
|
||||
() => (groupCount ? Math.ceil(groupCount / itemsPerPage) : 1),
|
||||
[groupCount, itemsPerPage]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlexGroup
|
||||
data-test-subj="grouping-table"
|
||||
justifyContent="spaceBetween"
|
||||
alignItems="center"
|
||||
style={{ paddingBottom: 20, paddingTop: 20 }}
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
{groupCount > 0 && unitCount > 0 ? (
|
||||
<EuiFlexGroup gutterSize="none">
|
||||
<EuiFlexItem grow={false}>
|
||||
<span css={countCss} data-test-subj="unit-count">
|
||||
{unitCountText}
|
||||
</span>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<span css={countCss} data-test-subj="group-count" style={{ borderRight: 'none' }}>
|
||||
{groupCountText}
|
||||
</span>
|
||||
</EuiFlexItem>
|
||||
{groupingLevel > 0 ? null : (
|
||||
<EuiFlexGroup
|
||||
data-test-subj="grouping-table"
|
||||
justifyContent="spaceBetween"
|
||||
alignItems="center"
|
||||
style={{ paddingBottom: 20, paddingTop: 20 }}
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
{groupCount > 0 && unitCount > 0 ? (
|
||||
<EuiFlexGroup gutterSize="none">
|
||||
<EuiFlexItem grow={false}>
|
||||
<span css={countCss} data-test-subj="unit-count">
|
||||
{unitCountText}
|
||||
</span>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<span css={countCss} data-test-subj="group-count" style={{ borderRight: 'none' }}>
|
||||
{groupCountText}
|
||||
</span>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
) : null}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize="xs">
|
||||
{inspectButton && <EuiFlexItem>{inspectButton}</EuiFlexItem>}
|
||||
<EuiFlexItem>{groupSelector}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
) : null}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize="xs">
|
||||
{inspectButton && <EuiFlexItem>{inspectButton}</EuiFlexItem>}
|
||||
<EuiFlexItem>{groupSelector}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<div css={groupingContainerCss} className="eui-xScroll">
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
<div
|
||||
css={groupingLevel > 0 ? groupingContainerCssLevel : groupingContainerCss}
|
||||
className="eui-xScroll"
|
||||
>
|
||||
{isLoading && (
|
||||
<EuiProgress data-test-subj="is-loading-grouping-table" size="xs" color="accent" />
|
||||
)}
|
||||
{groupCount > 0 ? (
|
||||
<>
|
||||
{groupPanels}
|
||||
<EuiSpacer size="m" />
|
||||
<EuiTablePagination
|
||||
activePage={pagination.pageIndex}
|
||||
data-test-subj="grouping-table-pagination"
|
||||
itemsPerPage={pagination.pageSize}
|
||||
itemsPerPageOptions={pagination.itemsPerPageOptions}
|
||||
onChangeItemsPerPage={pagination.onChangeItemsPerPage}
|
||||
onChangePage={pagination.onChangePage}
|
||||
pageCount={pageCount}
|
||||
showPerPageOptions
|
||||
/>
|
||||
{groupCount > 0 && (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiTablePagination
|
||||
activePage={activePage}
|
||||
data-test-subj={`grouping-level-${groupingLevel}-pagination`}
|
||||
itemsPerPage={itemsPerPage}
|
||||
itemsPerPageOptions={[10, 25, 50, 100]}
|
||||
onChangeItemsPerPage={(pageSize: number) => {
|
||||
if (onChangeGroupsItemsPerPage) {
|
||||
onChangeGroupsItemsPerPage(pageSize);
|
||||
}
|
||||
}}
|
||||
onChangePage={(pageIndex: number) => {
|
||||
if (onChangeGroupsPage) {
|
||||
onChangeGroupsPage(pageIndex);
|
||||
}
|
||||
}}
|
||||
pageCount={pageCount}
|
||||
showPerPageOptions
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyGroupingComponent />
|
||||
|
|
|
@ -14,8 +14,9 @@ export * from './grouping';
|
|||
|
||||
/**
|
||||
* Checks if no group is selected
|
||||
* @param groupKey selected group field value
|
||||
* @param groupKeys selected group field values
|
||||
*
|
||||
* @returns {boolean} True if no group is selected
|
||||
*/
|
||||
export const isNoneGroup = (groupKey: string | null) => groupKey === NONE_GROUP_KEY;
|
||||
export const isNoneGroup = (groupKeys: string[]) =>
|
||||
!!groupKeys.find((groupKey) => groupKey === NONE_GROUP_KEY);
|
||||
|
|
|
@ -36,7 +36,7 @@ export const statsContainerCss = css`
|
|||
`;
|
||||
|
||||
export const groupingContainerCss = css`
|
||||
.euiAccordion__childWrapper .euiAccordion__padding--m {
|
||||
.groupingAccordionForm .euiAccordion__childWrapper .euiAccordion__padding--m {
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
border-left: ${euiThemeVars.euiBorderThin};
|
||||
|
@ -44,7 +44,7 @@ export const groupingContainerCss = css`
|
|||
border-bottom: ${euiThemeVars.euiBorderThin};
|
||||
border-radius: 0 0 6px 6px;
|
||||
}
|
||||
.euiAccordion__triggerWrapper {
|
||||
.groupingAccordionForm .euiAccordion__triggerWrapper {
|
||||
border-bottom: ${euiThemeVars.euiBorderThin};
|
||||
border-left: ${euiThemeVars.euiBorderThin};
|
||||
border-right: ${euiThemeVars.euiBorderThin};
|
||||
|
@ -59,8 +59,37 @@ export const groupingContainerCss = css`
|
|||
border-radius: 6px;
|
||||
min-width: 1090px;
|
||||
}
|
||||
.groupingAccordionForm__button {
|
||||
text-decoration: none !important;
|
||||
.groupingPanelRenderer {
|
||||
display: table;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
padding-right: 32px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const groupingContainerCssLevel = css`
|
||||
.groupingAccordionFormLevel .euiAccordion__childWrapper .euiAccordion__padding--m {
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: ${euiThemeVars.euiBorderThin};
|
||||
border-radius: 0;
|
||||
}
|
||||
.groupingAccordionFormLevel .euiAccordion__triggerWrapper {
|
||||
border-bottom: ${euiThemeVars.euiBorderThin};
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
min-height: 78px;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
border-radius: 0;
|
||||
}
|
||||
.groupingAccordionFormLevel {
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
border-radius: 0;
|
||||
min-width: 1090px;
|
||||
}
|
||||
.groupingPanelRenderer {
|
||||
display: table;
|
||||
|
|
|
@ -35,9 +35,11 @@ export const GROUP_BY_CUSTOM_FIELD = i18n.translate('grouping.customGroupByPanel
|
|||
defaultMessage: 'Group By Custom Field',
|
||||
});
|
||||
|
||||
export const SELECT_FIELD = i18n.translate('grouping.groupByPanelTitle', {
|
||||
defaultMessage: 'Select Field',
|
||||
});
|
||||
export const SELECT_FIELD = (groupingLevelsCount: number) =>
|
||||
i18n.translate('grouping.groupByPanelTitle', {
|
||||
values: { groupingLevelsCount },
|
||||
defaultMessage: 'Select up to {groupingLevelsCount} groupings',
|
||||
});
|
||||
|
||||
export const NONE = i18n.translate('grouping.noneGroupByOptionName', {
|
||||
defaultMessage: 'None',
|
||||
|
|
|
@ -19,7 +19,7 @@ export type RawBucket<T> = GenericBuckets & T;
|
|||
|
||||
/** Defines the shape of the aggregation returned by Elasticsearch */
|
||||
// TODO: write developer docs for these fields
|
||||
export interface GroupingAggregation<T> {
|
||||
export interface RootAggregation<T> {
|
||||
groupByFields?: {
|
||||
buckets?: Array<RawBucket<T>>;
|
||||
};
|
||||
|
@ -39,6 +39,8 @@ export type GroupingFieldTotalAggregation<T> = Record<
|
|||
}
|
||||
>;
|
||||
|
||||
export type GroupingAggregation<T> = RootAggregation<T> & GroupingFieldTotalAggregation<T>;
|
||||
|
||||
export interface BadgeMetric {
|
||||
value: number;
|
||||
color?: string;
|
||||
|
@ -67,3 +69,5 @@ export type OnGroupToggle = (params: {
|
|||
groupNumber: number;
|
||||
groupingId: string;
|
||||
}) => void;
|
||||
|
||||
export type { GroupingProps } from './grouping';
|
||||
|
|
|
@ -35,10 +35,10 @@ export const getGroupingQuery = ({
|
|||
additionalFilters = [],
|
||||
from,
|
||||
groupByFields,
|
||||
pageNumber,
|
||||
rootAggregations,
|
||||
runtimeMappings,
|
||||
size = DEFAULT_GROUP_BY_FIELD_SIZE,
|
||||
pageNumber,
|
||||
sort,
|
||||
statsAggregations,
|
||||
to,
|
||||
|
|
|
@ -24,9 +24,10 @@ export interface GroupingQueryArgs {
|
|||
additionalFilters: BoolAgg[];
|
||||
from: string;
|
||||
groupByFields: string[];
|
||||
pageNumber?: number;
|
||||
rootAggregations?: NamedAggregation[];
|
||||
runtimeMappings?: MappingRuntimeFields;
|
||||
additionalAggregationsRoot?: NamedAggregation[];
|
||||
pageNumber?: number;
|
||||
size?: number;
|
||||
sort?: Array<{ [category: string]: { order: 'asc' | 'desc' } }>;
|
||||
statsAggregations?: NamedAggregation[];
|
||||
|
|
|
@ -6,55 +6,20 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
ActionType,
|
||||
GroupOption,
|
||||
UpdateActiveGroup,
|
||||
UpdateGroupActivePage,
|
||||
UpdateGroupItemsPerPage,
|
||||
UpdateGroupOptions,
|
||||
} from '../types';
|
||||
import { ActionType, GroupOption, UpdateActiveGroups, UpdateGroupOptions } from '../types';
|
||||
|
||||
const updateActiveGroup = ({
|
||||
activeGroup,
|
||||
const updateActiveGroups = ({
|
||||
activeGroups,
|
||||
id,
|
||||
}: {
|
||||
activeGroup: string;
|
||||
activeGroups: string[];
|
||||
id: string;
|
||||
}): UpdateActiveGroup => ({
|
||||
}): UpdateActiveGroups => ({
|
||||
payload: {
|
||||
activeGroup,
|
||||
activeGroups,
|
||||
id,
|
||||
},
|
||||
type: ActionType.updateActiveGroup,
|
||||
});
|
||||
|
||||
const updateGroupActivePage = ({
|
||||
activePage,
|
||||
id,
|
||||
}: {
|
||||
activePage: number;
|
||||
id: string;
|
||||
}): UpdateGroupActivePage => ({
|
||||
payload: {
|
||||
activePage,
|
||||
id,
|
||||
},
|
||||
type: ActionType.updateGroupActivePage,
|
||||
});
|
||||
|
||||
const updateGroupItemsPerPage = ({
|
||||
itemsPerPage,
|
||||
id,
|
||||
}: {
|
||||
itemsPerPage: number;
|
||||
id: string;
|
||||
}): UpdateGroupItemsPerPage => ({
|
||||
payload: {
|
||||
itemsPerPage,
|
||||
id,
|
||||
},
|
||||
type: ActionType.updateGroupItemsPerPage,
|
||||
type: ActionType.updateActiveGroups,
|
||||
});
|
||||
|
||||
const updateGroupOptions = ({
|
||||
|
@ -72,8 +37,6 @@ const updateGroupOptions = ({
|
|||
});
|
||||
|
||||
export const groupActions = {
|
||||
updateActiveGroup,
|
||||
updateGroupActivePage,
|
||||
updateGroupItemsPerPage,
|
||||
updateActiveGroups,
|
||||
updateGroupOptions,
|
||||
};
|
||||
|
|
|
@ -24,7 +24,7 @@ const groupById = {
|
|||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: groupingOptions,
|
||||
activeGroup: 'host.name',
|
||||
activeGroups: ['host.name'],
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -54,7 +54,7 @@ describe('grouping reducer', () => {
|
|||
JSON.stringify(groupingState.groupById)
|
||||
);
|
||||
});
|
||||
it('updateActiveGroup', () => {
|
||||
it('updateActiveGroups', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useReducer(groupsReducerWithStorage, {
|
||||
...initialState,
|
||||
|
@ -62,40 +62,11 @@ describe('grouping reducer', () => {
|
|||
})
|
||||
);
|
||||
let [groupingState, dispatch] = result.current;
|
||||
expect(groupingState.groupById[groupingId].activeGroup).toEqual('host.name');
|
||||
expect(groupingState.groupById[groupingId].activeGroups).toEqual(['host.name']);
|
||||
act(() => {
|
||||
dispatch(groupActions.updateActiveGroup({ id: groupingId, activeGroup: 'user.name' }));
|
||||
dispatch(groupActions.updateActiveGroups({ id: groupingId, activeGroups: ['user.name'] }));
|
||||
});
|
||||
[groupingState, dispatch] = result.current;
|
||||
expect(groupingState.groupById[groupingId].activeGroup).toEqual('user.name');
|
||||
});
|
||||
it('updateGroupActivePage', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useReducer(groupsReducerWithStorage, {
|
||||
...initialState,
|
||||
groupById,
|
||||
})
|
||||
);
|
||||
let [groupingState, dispatch] = result.current;
|
||||
expect(groupingState.groupById[groupingId].activePage).toEqual(0);
|
||||
act(() => {
|
||||
dispatch(groupActions.updateGroupActivePage({ id: groupingId, activePage: 12 }));
|
||||
});
|
||||
[groupingState, dispatch] = result.current;
|
||||
expect(groupingState.groupById[groupingId].activePage).toEqual(12);
|
||||
});
|
||||
it('updateGroupItemsPerPage', () => {
|
||||
const { result } = renderHook(() => useReducer(groupsReducerWithStorage, initialState));
|
||||
let [groupingState, dispatch] = result.current;
|
||||
act(() => {
|
||||
dispatch(groupActions.updateGroupOptions({ id: groupingId, newOptionList: groupingOptions }));
|
||||
});
|
||||
[groupingState, dispatch] = result.current;
|
||||
expect(groupingState.groupById[groupingId].itemsPerPage).toEqual(25);
|
||||
act(() => {
|
||||
dispatch(groupActions.updateGroupItemsPerPage({ id: groupingId, itemsPerPage: 12 }));
|
||||
});
|
||||
[groupingState, dispatch] = result.current;
|
||||
expect(groupingState.groupById[groupingId].itemsPerPage).toEqual(12);
|
||||
expect(groupingState.groupById[groupingId].activeGroups).toEqual(['user.name']);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -25,8 +25,8 @@ export const initialState: GroupMap = {
|
|||
|
||||
const groupsReducer = (state: GroupMap, action: Action, groupsById: GroupsById) => {
|
||||
switch (action.type) {
|
||||
case ActionType.updateActiveGroup: {
|
||||
const { id, activeGroup } = action.payload;
|
||||
case ActionType.updateActiveGroups: {
|
||||
const { id, activeGroups } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
groupById: {
|
||||
|
@ -34,35 +34,7 @@ const groupsReducer = (state: GroupMap, action: Action, groupsById: GroupsById)
|
|||
[id]: {
|
||||
...defaultGroup,
|
||||
...groupsById[id],
|
||||
activeGroup,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
case ActionType.updateGroupActivePage: {
|
||||
const { id, activePage } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
groupById: {
|
||||
...groupsById,
|
||||
[id]: {
|
||||
...defaultGroup,
|
||||
...groupsById[id],
|
||||
activePage,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
case ActionType.updateGroupItemsPerPage: {
|
||||
const { id, itemsPerPage } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
groupById: {
|
||||
...groupsById,
|
||||
[id]: {
|
||||
...defaultGroup,
|
||||
...groupsById[id],
|
||||
itemsPerPage,
|
||||
activeGroups,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -89,22 +61,10 @@ export const groupsReducerWithStorage = (state: GroupMap, action: Action) => {
|
|||
if (storage) {
|
||||
groupsInStorage = getAllGroupsInStorage(storage);
|
||||
}
|
||||
const trackedGroupIds = Object.keys(state.groupById);
|
||||
|
||||
const adjustedStorageGroups = Object.entries(groupsInStorage).reduce(
|
||||
(acc: GroupsById, [key, group]) => ({
|
||||
...acc,
|
||||
[key]: {
|
||||
// reset page to 0 if is initial state
|
||||
...(trackedGroupIds.includes(key) ? group : { ...group, activePage: 0 }),
|
||||
},
|
||||
}),
|
||||
{} as GroupsById
|
||||
);
|
||||
|
||||
const groupsById: GroupsById = {
|
||||
...state.groupById,
|
||||
...adjustedStorageGroups,
|
||||
...groupsInStorage,
|
||||
};
|
||||
|
||||
const newState = groupsReducer(state, action, groupsById);
|
||||
|
|
|
@ -8,35 +8,21 @@
|
|||
|
||||
// action types
|
||||
export enum ActionType {
|
||||
updateActiveGroup = 'UPDATE_ACTIVE_GROUP',
|
||||
updateGroupActivePage = 'UPDATE_GROUP_ACTIVE_PAGE',
|
||||
updateGroupItemsPerPage = 'UPDATE_GROUP_ITEMS_PER_PAGE',
|
||||
updateActiveGroups = 'UPDATE_ACTIVE_GROUPS',
|
||||
updateGroupOptions = 'UPDATE_GROUP_OPTIONS',
|
||||
}
|
||||
|
||||
export interface UpdateActiveGroup {
|
||||
type: ActionType.updateActiveGroup;
|
||||
payload: { activeGroup: string; id: string };
|
||||
export interface UpdateActiveGroups {
|
||||
type: ActionType.updateActiveGroups;
|
||||
payload: { activeGroups: string[]; id: string };
|
||||
}
|
||||
|
||||
export interface UpdateGroupActivePage {
|
||||
type: ActionType.updateGroupActivePage;
|
||||
payload: { activePage: number; id: string };
|
||||
}
|
||||
export interface UpdateGroupItemsPerPage {
|
||||
type: ActionType.updateGroupItemsPerPage;
|
||||
payload: { itemsPerPage: number; id: string };
|
||||
}
|
||||
export interface UpdateGroupOptions {
|
||||
type: ActionType.updateGroupOptions;
|
||||
payload: { newOptionList: GroupOption[]; id: string };
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| UpdateActiveGroup
|
||||
| UpdateGroupActivePage
|
||||
| UpdateGroupItemsPerPage
|
||||
| UpdateGroupOptions;
|
||||
export type Action = UpdateActiveGroups | UpdateGroupOptions;
|
||||
|
||||
// state
|
||||
|
||||
|
@ -46,10 +32,8 @@ export interface GroupOption {
|
|||
}
|
||||
|
||||
export interface GroupModel {
|
||||
activeGroup: string;
|
||||
activeGroups: string[];
|
||||
options: GroupOption[];
|
||||
activePage: number;
|
||||
itemsPerPage: number;
|
||||
}
|
||||
|
||||
export interface GroupsById {
|
||||
|
@ -73,8 +57,6 @@ export interface Storage<T = any, S = void> {
|
|||
export const EMPTY_GROUP_BY_ID: GroupsById = {};
|
||||
|
||||
export const defaultGroup: GroupModel = {
|
||||
activePage: 0,
|
||||
itemsPerPage: 25,
|
||||
activeGroup: 'none',
|
||||
activeGroups: ['none'],
|
||||
options: [],
|
||||
};
|
||||
|
|
|
@ -52,7 +52,7 @@ describe('useGetGroupSelector', () => {
|
|||
useGetGroupSelector({
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: { [groupingId]: { ...defaultGroup, activeGroup: customField } },
|
||||
groupById: { [groupingId]: { ...defaultGroup, activeGroups: [customField] } },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
@ -72,12 +72,12 @@ describe('useGetGroupSelector', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('On group change, does nothing when set to prev selected group', () => {
|
||||
it('On group change, removes selected group if already selected', () => {
|
||||
const testGroup = {
|
||||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroup: 'host.name',
|
||||
activeGroups: ['host.name'],
|
||||
},
|
||||
};
|
||||
const { result } = renderHook((props) => useGetGroupSelector(props), {
|
||||
|
@ -89,7 +89,41 @@ describe('useGetGroupSelector', () => {
|
|||
},
|
||||
});
|
||||
act(() => result.current.props.onGroupChange('host.name'));
|
||||
expect(dispatch).toHaveBeenCalledTimes(0);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: {
|
||||
id: groupingId,
|
||||
activeGroups: ['none'],
|
||||
},
|
||||
type: ActionType.updateActiveGroups,
|
||||
});
|
||||
});
|
||||
|
||||
it('On group change to none, remove all previously selected groups', () => {
|
||||
const testGroup = {
|
||||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroups: ['host.name', 'user.name'],
|
||||
},
|
||||
};
|
||||
const { result } = renderHook((props) => useGetGroupSelector(props), {
|
||||
initialProps: {
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: testGroup,
|
||||
},
|
||||
},
|
||||
});
|
||||
act(() => result.current.props.onGroupChange('none'));
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: {
|
||||
id: groupingId,
|
||||
activeGroups: ['none'],
|
||||
},
|
||||
type: ActionType.updateActiveGroups,
|
||||
});
|
||||
});
|
||||
|
||||
it('On group change, resets active page, sets active group, and leaves options alone', () => {
|
||||
|
@ -97,7 +131,7 @@ describe('useGetGroupSelector', () => {
|
|||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroup: 'host.name',
|
||||
activeGroups: ['host.name'],
|
||||
},
|
||||
};
|
||||
const { result } = renderHook((props) => useGetGroupSelector(props), {
|
||||
|
@ -109,21 +143,15 @@ describe('useGetGroupSelector', () => {
|
|||
},
|
||||
});
|
||||
act(() => result.current.props.onGroupChange('user.name'));
|
||||
|
||||
expect(dispatch).toHaveBeenNthCalledWith(1, {
|
||||
payload: {
|
||||
id: groupingId,
|
||||
activePage: 0,
|
||||
activeGroups: ['host.name', 'user.name'],
|
||||
},
|
||||
type: ActionType.updateGroupActivePage,
|
||||
type: ActionType.updateActiveGroups,
|
||||
});
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, {
|
||||
payload: {
|
||||
id: groupingId,
|
||||
activeGroup: 'user.name',
|
||||
},
|
||||
type: ActionType.updateActiveGroup,
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('On group change, sends telemetry', () => {
|
||||
|
@ -131,7 +159,7 @@ describe('useGetGroupSelector', () => {
|
|||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroup: 'host.name',
|
||||
activeGroups: ['host.name'],
|
||||
},
|
||||
};
|
||||
const { result } = renderHook((props) => useGetGroupSelector(props), {
|
||||
|
@ -155,7 +183,7 @@ describe('useGetGroupSelector', () => {
|
|||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroup: 'host.name',
|
||||
activeGroups: ['host.name'],
|
||||
},
|
||||
};
|
||||
const { result } = renderHook((props) => useGetGroupSelector(props), {
|
||||
|
@ -179,10 +207,10 @@ describe('useGetGroupSelector', () => {
|
|||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroup: 'host.name',
|
||||
activeGroups: ['host.name'],
|
||||
},
|
||||
};
|
||||
const { result } = renderHook((props) => useGetGroupSelector(props), {
|
||||
const { result, rerender } = renderHook((props) => useGetGroupSelector(props), {
|
||||
initialProps: {
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
|
@ -191,17 +219,54 @@ describe('useGetGroupSelector', () => {
|
|||
},
|
||||
});
|
||||
act(() => result.current.props.onGroupChange(customField));
|
||||
expect(dispatch).toHaveBeenCalledTimes(3);
|
||||
expect(dispatch).toHaveBeenNthCalledWith(3, {
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
rerender({
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: {
|
||||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroups: ['host.name', customField],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
expect(dispatch).toHaveBeenNthCalledWith(2, {
|
||||
payload: {
|
||||
newOptionList: [...defaultGroupingOptions, { label: customField, key: customField }],
|
||||
id: 'test-table',
|
||||
},
|
||||
type: ActionType.updateGroupOptions,
|
||||
});
|
||||
});
|
||||
|
||||
it('Supports multiple custom fields on initial load', () => {
|
||||
const testGroup = {
|
||||
[groupingId]: {
|
||||
...defaultGroup,
|
||||
options: defaultGroupingOptions,
|
||||
activeGroups: ['host.name', customField, 'another.custom'],
|
||||
},
|
||||
};
|
||||
renderHook((props) => useGetGroupSelector(props), {
|
||||
initialProps: {
|
||||
...defaultArgs,
|
||||
groupingState: {
|
||||
groupById: testGroup,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: {
|
||||
id: groupingId,
|
||||
newOptionList: [
|
||||
...defaultGroupingOptions,
|
||||
{
|
||||
label: customField,
|
||||
key: customField,
|
||||
},
|
||||
{ label: customField, key: customField },
|
||||
{ label: 'another.custom', key: 'another.custom' },
|
||||
],
|
||||
id: 'test-table',
|
||||
},
|
||||
type: ActionType.updateGroupOptions,
|
||||
});
|
||||
|
|
|
@ -22,6 +22,7 @@ export interface UseGetGroupSelectorArgs {
|
|||
fields: FieldSpec[];
|
||||
groupingId: string;
|
||||
groupingState: GroupMap;
|
||||
maxGroupingLevels?: number;
|
||||
onGroupChange?: (param: { groupByField: string; tableId: string }) => void;
|
||||
tracker?: (
|
||||
type: UiCounterMetricType,
|
||||
|
@ -36,22 +37,21 @@ export const useGetGroupSelector = ({
|
|||
fields,
|
||||
groupingId,
|
||||
groupingState,
|
||||
maxGroupingLevels = 1,
|
||||
onGroupChange,
|
||||
tracker,
|
||||
}: UseGetGroupSelectorArgs) => {
|
||||
const { activeGroup: selectedGroup, options } =
|
||||
const { activeGroups: selectedGroups, options } =
|
||||
groupByIdSelector({ groups: groupingState }, groupingId) ?? defaultGroup;
|
||||
|
||||
const setGroupsActivePage = useCallback(
|
||||
(activePage: number) => {
|
||||
dispatch(groupActions.updateGroupActivePage({ id: groupingId, activePage }));
|
||||
},
|
||||
[dispatch, groupingId]
|
||||
);
|
||||
|
||||
const setSelectedGroup = useCallback(
|
||||
(activeGroup: string) => {
|
||||
dispatch(groupActions.updateActiveGroup({ id: groupingId, activeGroup }));
|
||||
const setSelectedGroups = useCallback(
|
||||
(activeGroups: string[]) => {
|
||||
dispatch(
|
||||
groupActions.updateActiveGroups({
|
||||
id: groupingId,
|
||||
activeGroups,
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch, groupingId]
|
||||
);
|
||||
|
@ -65,11 +65,20 @@ export const useGetGroupSelector = ({
|
|||
|
||||
const onChange = useCallback(
|
||||
(groupSelection: string) => {
|
||||
if (groupSelection === selectedGroup) {
|
||||
if (selectedGroups.find((selected) => selected === groupSelection)) {
|
||||
const groups = selectedGroups.filter((selectedGroup) => selectedGroup !== groupSelection);
|
||||
if (groups.length === 0) {
|
||||
setSelectedGroups(['none']);
|
||||
} else {
|
||||
setSelectedGroups(groups);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setGroupsActivePage(0);
|
||||
setSelectedGroup(groupSelection);
|
||||
|
||||
const newSelectedGroups = isNoneGroup([groupSelection])
|
||||
? [groupSelection]
|
||||
: [...selectedGroups.filter((selectedGroup) => selectedGroup !== 'none'), groupSelection];
|
||||
setSelectedGroups(newSelectedGroups);
|
||||
|
||||
// built-in telemetry: UI-counter
|
||||
tracker?.(
|
||||
|
@ -78,62 +87,57 @@ export const useGetGroupSelector = ({
|
|||
);
|
||||
|
||||
onGroupChange?.({ tableId: groupingId, groupByField: groupSelection });
|
||||
|
||||
// only update options if the new selection is a custom field
|
||||
if (
|
||||
!isNoneGroup(groupSelection) &&
|
||||
!options.find((o: GroupOption) => o.key === groupSelection)
|
||||
) {
|
||||
setOptions([
|
||||
...defaultGroupingOptions,
|
||||
{
|
||||
label: groupSelection,
|
||||
key: groupSelection,
|
||||
},
|
||||
]);
|
||||
}
|
||||
},
|
||||
[
|
||||
defaultGroupingOptions,
|
||||
groupingId,
|
||||
onGroupChange,
|
||||
options,
|
||||
selectedGroup,
|
||||
setGroupsActivePage,
|
||||
setOptions,
|
||||
setSelectedGroup,
|
||||
tracker,
|
||||
]
|
||||
[groupingId, onGroupChange, selectedGroups, setSelectedGroups, tracker]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// only set options the first time, all other updates will be taken care of by onGroupChange
|
||||
if (options.length > 0) return;
|
||||
setOptions(
|
||||
defaultGroupingOptions.find((o) => o.key === selectedGroup)
|
||||
? defaultGroupingOptions
|
||||
: [
|
||||
...defaultGroupingOptions,
|
||||
...(!isNoneGroup(selectedGroup)
|
||||
? [
|
||||
{
|
||||
if (options.length === 0) {
|
||||
return setOptions(
|
||||
defaultGroupingOptions.find((o) => selectedGroups.find((selected) => selected === o.key))
|
||||
? defaultGroupingOptions
|
||||
: [
|
||||
...defaultGroupingOptions,
|
||||
...(!isNoneGroup(selectedGroups)
|
||||
? selectedGroups.map((selectedGroup) => ({
|
||||
key: selectedGroup,
|
||||
label: selectedGroup,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
);
|
||||
}, [defaultGroupingOptions, options.length, selectedGroup, setOptions]);
|
||||
}))
|
||||
: []),
|
||||
]
|
||||
);
|
||||
}
|
||||
if (isNoneGroup(selectedGroups)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentOptionKeys = options.map((o) => o.key);
|
||||
const newOptions = [...options];
|
||||
selectedGroups.forEach((groupSelection) => {
|
||||
if (currentOptionKeys.includes(groupSelection)) {
|
||||
return;
|
||||
}
|
||||
// these are custom fields
|
||||
newOptions.push({
|
||||
label: groupSelection,
|
||||
key: groupSelection,
|
||||
});
|
||||
});
|
||||
|
||||
if (newOptions.length !== options.length) {
|
||||
setOptions(newOptions);
|
||||
}
|
||||
}, [defaultGroupingOptions, options, selectedGroups, setOptions]);
|
||||
|
||||
return (
|
||||
<GroupSelector
|
||||
{...{
|
||||
groupingId,
|
||||
groupSelected: selectedGroup,
|
||||
groupsSelected: selectedGroups,
|
||||
'data-test-subj': 'alerts-table-group-selector',
|
||||
onGroupChange: onChange,
|
||||
fields,
|
||||
maxGroupingLevels,
|
||||
options,
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -30,7 +30,6 @@ const defaultArgs = {
|
|||
groupStatsRenderer: jest.fn(),
|
||||
inspectButton: <></>,
|
||||
onGroupToggle: jest.fn(),
|
||||
renderChildComponent: () => <p data-test-subj="innerTable">{'hello'}</p>,
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -38,6 +37,9 @@ const groupingArgs = {
|
|||
data: {},
|
||||
isLoading: false,
|
||||
takeActionItems: jest.fn(),
|
||||
activePage: 0,
|
||||
itemsPerPage: 25,
|
||||
onGroupClose: () => {},
|
||||
};
|
||||
|
||||
describe('useGrouping', () => {
|
||||
|
@ -70,6 +72,8 @@ describe('useGrouping', () => {
|
|||
value: 18,
|
||||
},
|
||||
},
|
||||
renderChildComponent: () => <p data-test-subj="innerTable">{'hello'}</p>,
|
||||
selectedGroup: 'none',
|
||||
})}
|
||||
</IntlProvider>
|
||||
);
|
||||
|
@ -84,7 +88,7 @@ describe('useGrouping', () => {
|
|||
getItem.mockReturnValue(
|
||||
JSON.stringify({
|
||||
'test-table': {
|
||||
activePage: 0,
|
||||
itemsPerPageOptions: [10, 25, 50, 100],
|
||||
itemsPerPage: 25,
|
||||
activeGroup: 'kibana.alert.rule.name',
|
||||
options: defaultGroupingOptions,
|
||||
|
@ -95,7 +99,7 @@ describe('useGrouping', () => {
|
|||
const { result, waitForNextUpdate } = renderHook(() => useGrouping(defaultArgs));
|
||||
await waitForNextUpdate();
|
||||
await waitForNextUpdate();
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
const { getByTestId } = render(
|
||||
<IntlProvider locale="en">
|
||||
{result.current.getGrouping({
|
||||
...groupingArgs,
|
||||
|
@ -119,12 +123,13 @@ describe('useGrouping', () => {
|
|||
value: 18,
|
||||
},
|
||||
},
|
||||
renderChildComponent: jest.fn(),
|
||||
selectedGroup: 'test',
|
||||
})}
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
expect(getByTestId('grouping-table')).toBeInTheDocument();
|
||||
expect(queryByTestId('innerTable')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -11,8 +11,7 @@ import React, { useCallback, useMemo, useReducer } from 'react';
|
|||
import { UiCounterMetricType } from '@kbn/analytics';
|
||||
import { groupsReducerWithStorage, initialState } from './state/reducer';
|
||||
import { GroupingProps, GroupSelectorProps, isNoneGroup } from '..';
|
||||
import { useGroupingPagination } from './use_grouping_pagination';
|
||||
import { groupActions, groupByIdSelector } from './state';
|
||||
import { groupByIdSelector } from './state';
|
||||
import { useGetGroupSelector } from './use_get_group_selector';
|
||||
import { defaultGroup, GroupOption } from './types';
|
||||
import { Grouping as GroupingComponent } from '../components/grouping';
|
||||
|
@ -23,33 +22,37 @@ import { Grouping as GroupingComponent } from '../components/grouping';
|
|||
interface Grouping<T> {
|
||||
getGrouping: (props: DynamicGroupingProps<T>) => React.ReactElement;
|
||||
groupSelector: React.ReactElement<GroupSelectorProps>;
|
||||
pagination: {
|
||||
reset: () => void;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
};
|
||||
selectedGroup: string;
|
||||
selectedGroups: string[];
|
||||
}
|
||||
|
||||
/** Type for static grouping component props where T is the `GroupingAggregation`
|
||||
/** Type for static grouping component props where T is the consumer `GroupingAggregation`
|
||||
* @interface StaticGroupingProps<T>
|
||||
*/
|
||||
type StaticGroupingProps<T> = Pick<
|
||||
GroupingProps<T>,
|
||||
| 'groupPanelRenderer'
|
||||
| 'groupStatsRenderer'
|
||||
| 'inspectButton'
|
||||
| 'onGroupToggle'
|
||||
| 'renderChildComponent'
|
||||
| 'unit'
|
||||
'groupPanelRenderer' | 'groupStatsRenderer' | 'onGroupToggle' | 'unit'
|
||||
>;
|
||||
|
||||
/** Type for dynamic grouping component props where T is the `GroupingAggregation`
|
||||
/** Type for dynamic grouping component props where T is the consumer `GroupingAggregation`
|
||||
* @interface DynamicGroupingProps<T>
|
||||
*/
|
||||
type DynamicGroupingProps<T> = Pick<GroupingProps<T>, 'data' | 'isLoading' | 'takeActionItems'>;
|
||||
export type DynamicGroupingProps<T> = Pick<
|
||||
GroupingProps<T>,
|
||||
| 'activePage'
|
||||
| 'data'
|
||||
| 'groupingLevel'
|
||||
| 'inspectButton'
|
||||
| 'isLoading'
|
||||
| 'itemsPerPage'
|
||||
| 'onChangeGroupsItemsPerPage'
|
||||
| 'onChangeGroupsPage'
|
||||
| 'renderChildComponent'
|
||||
| 'onGroupClose'
|
||||
| 'selectedGroup'
|
||||
| 'takeActionItems'
|
||||
>;
|
||||
|
||||
/** Interface for configuring grouping package where T is the `GroupingAggregation`
|
||||
/** Interface for configuring grouping package where T is the consumer `GroupingAggregation`
|
||||
* @interface GroupingArgs<T>
|
||||
*/
|
||||
interface GroupingArgs<T> {
|
||||
|
@ -57,6 +60,7 @@ interface GroupingArgs<T> {
|
|||
defaultGroupingOptions: GroupOption[];
|
||||
fields: FieldSpec[];
|
||||
groupingId: string;
|
||||
maxGroupingLevels?: number;
|
||||
/** for tracking
|
||||
* @param param { groupByField: string; tableId: string } selected group and table id
|
||||
*/
|
||||
|
@ -75,21 +79,22 @@ interface GroupingArgs<T> {
|
|||
* @param defaultGroupingOptions defines the grouping options as an array of {@link GroupOption}
|
||||
* @param fields FieldSpec array serialized version of DataViewField fields. Available in the custom grouping options
|
||||
* @param groupingId Unique identifier of the grouping component. Used in local storage
|
||||
* @param maxGroupingLevels maximum group nesting levels (optional)
|
||||
* @param onGroupChange callback executed when selected group is changed, used for tracking
|
||||
* @param tracker telemetry handler
|
||||
* @returns {@link Grouping} the grouping constructor { getGrouping, groupSelector, pagination, selectedGroup }
|
||||
* @returns {@link Grouping} the grouping constructor { getGrouping, groupSelector, pagination, selectedGroups }
|
||||
*/
|
||||
export const useGrouping = <T,>({
|
||||
componentProps,
|
||||
defaultGroupingOptions,
|
||||
fields,
|
||||
groupingId,
|
||||
maxGroupingLevels,
|
||||
onGroupChange,
|
||||
tracker,
|
||||
}: GroupingArgs<T>): Grouping<T> => {
|
||||
const [groupingState, dispatch] = useReducer(groupsReducerWithStorage, initialState);
|
||||
|
||||
const { activeGroup: selectedGroup } = useMemo(
|
||||
const { activeGroups: selectedGroups } = useMemo(
|
||||
() => groupByIdSelector({ groups: groupingState }, groupingId) ?? defaultGroup,
|
||||
[groupingId, groupingState]
|
||||
);
|
||||
|
@ -100,56 +105,37 @@ export const useGrouping = <T,>({
|
|||
fields,
|
||||
groupingId,
|
||||
groupingState,
|
||||
maxGroupingLevels,
|
||||
onGroupChange,
|
||||
tracker,
|
||||
});
|
||||
|
||||
const pagination = useGroupingPagination({ groupingId, groupingState, dispatch });
|
||||
|
||||
const getGrouping = useCallback(
|
||||
/**
|
||||
*
|
||||
* @param props {@link DynamicGroupingProps}
|
||||
*/
|
||||
(props: DynamicGroupingProps<T>): React.ReactElement =>
|
||||
isNoneGroup(selectedGroup) ? (
|
||||
componentProps.renderChildComponent([])
|
||||
isNoneGroup([props.selectedGroup]) ? (
|
||||
props.renderChildComponent([])
|
||||
) : (
|
||||
<GroupingComponent
|
||||
{...componentProps}
|
||||
{...props}
|
||||
groupingId={groupingId}
|
||||
groupSelector={groupSelector}
|
||||
pagination={pagination}
|
||||
selectedGroup={selectedGroup}
|
||||
groupingId={groupingId}
|
||||
tracker={tracker}
|
||||
/>
|
||||
),
|
||||
[componentProps, groupSelector, groupingId, pagination, selectedGroup, tracker]
|
||||
[componentProps, groupSelector, groupingId, tracker]
|
||||
);
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
dispatch(groupActions.updateGroupActivePage({ id: groupingId, activePage: 0 }));
|
||||
}, [groupingId]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
getGrouping,
|
||||
groupSelector,
|
||||
selectedGroup,
|
||||
pagination: {
|
||||
reset: resetPagination,
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
},
|
||||
selectedGroups,
|
||||
}),
|
||||
[
|
||||
getGrouping,
|
||||
groupSelector,
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
resetPagination,
|
||||
selectedGroup,
|
||||
]
|
||||
[getGrouping, groupSelector, selectedGroups]
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
};
|
|
@ -6,7 +6,8 @@
|
|||
"jest",
|
||||
"node",
|
||||
"react",
|
||||
"@emotion/react/types/css-prop"
|
||||
"@emotion/react/types/css-prop",
|
||||
"@kbn/ambient-ui-types"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
|
|
|
@ -37,6 +37,7 @@ export const storybookAliases = {
|
|||
expression_shape: 'src/plugins/expression_shape/.storybook',
|
||||
expression_tagcloud: 'src/plugins/chart_expressions/expression_tagcloud/.storybook',
|
||||
fleet: 'x-pack/plugins/fleet/.storybook',
|
||||
grouping: 'packages/kbn-securitysolution-grouping/.storybook',
|
||||
home: 'src/plugins/home/.storybook',
|
||||
infra: 'x-pack/plugins/infra/.storybook',
|
||||
kibana_react: 'src/plugins/kibana_react/.storybook',
|
||||
|
|
|
@ -10,7 +10,6 @@ import React from 'react';
|
|||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { SecurityPageName } from '../../../../common/constants';
|
||||
import { useGlobalTime } from '../../containers/use_global_time';
|
||||
import {
|
||||
DEFAULT_STACK_BY_FIELD,
|
||||
DEFAULT_STACK_BY_FIELD1,
|
||||
|
@ -151,16 +150,6 @@ describe('AlertsTreemapPanel', () => {
|
|||
await waitFor(() => expect(screen.getByTestId('treemapPanel')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('invokes useGlobalTime() with false to prevent global queries from being deleted when the component unmounts', async () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
<AlertsTreemapPanel {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(useGlobalTime).toBeCalledWith(false));
|
||||
});
|
||||
|
||||
it('renders the panel with a hidden overflow-x', async () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
|
|
|
@ -80,7 +80,7 @@ const AlertsTreemapPanelComponent: React.FC<Props> = ({
|
|||
stackByWidth,
|
||||
title,
|
||||
}: Props) => {
|
||||
const { to, from, deleteQuery, setQuery } = useGlobalTime(false);
|
||||
const { to, from, deleteQuery, setQuery } = useGlobalTime();
|
||||
|
||||
// create a unique, but stable (across re-renders) query id
|
||||
const uniqueQueryId = useMemo(() => `${ALERTS_TREEMAP_ID}-${uuidv4()}`, []);
|
||||
|
|
|
@ -110,7 +110,7 @@ const StatefulTopNComponent: React.FC<Props> = ({
|
|||
value,
|
||||
}) => {
|
||||
const { uiSettings } = useKibana().services;
|
||||
const { from, deleteQuery, setQuery, to } = useGlobalTime(false);
|
||||
const { from, deleteQuery, setQuery, to } = useGlobalTime();
|
||||
|
||||
const options = getOptions(isActiveTimeline(scopeId ?? '') ? activeTimelineEventType : undefined);
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -44,7 +44,7 @@ export const useAlertPrevalence = ({
|
|||
const timelineTime = useDeepEqualSelector((state) =>
|
||||
inputsSelectors.timelineTimeRangeSelector(state)
|
||||
);
|
||||
const globalTime = useGlobalTime(false);
|
||||
const globalTime = useGlobalTime();
|
||||
let to: string | undefined;
|
||||
let from: string | undefined;
|
||||
if (ignoreTimerange === false) {
|
||||
|
|
|
@ -37,23 +37,77 @@ describe('useGlobalTime', () => {
|
|||
expect(result1.to).toBe(0);
|
||||
});
|
||||
|
||||
test('clear all queries at unmount when clearAllQuery is set to true', () => {
|
||||
const { unmount } = renderHook(() => useGlobalTime());
|
||||
test('clear query at unmount when setQuery has been called', () => {
|
||||
const { result, unmount } = renderHook(() => useGlobalTime());
|
||||
act(() => {
|
||||
result.current.setQuery({
|
||||
id: 'query-2',
|
||||
inspect: { dsl: [], response: [] },
|
||||
loading: false,
|
||||
refetch: () => {},
|
||||
searchSessionId: 'session-1',
|
||||
});
|
||||
});
|
||||
|
||||
unmount();
|
||||
expect(mockDispatch.mock.calls[0][0].type).toEqual(
|
||||
'x-pack/security_solution/local/inputs/DELETE_ALL_QUERY'
|
||||
expect(mockDispatch.mock.calls.length).toBe(2);
|
||||
expect(mockDispatch.mock.calls[1][0].type).toEqual(
|
||||
'x-pack/security_solution/local/inputs/DELETE_QUERY'
|
||||
);
|
||||
});
|
||||
|
||||
test('do NOT clear all queries at unmount when clearAllQuery is set to false.', () => {
|
||||
const { unmount } = renderHook(() => useGlobalTime(false));
|
||||
test('do NOT clear query at unmount when setQuery has not been called', () => {
|
||||
const { unmount } = renderHook(() => useGlobalTime());
|
||||
unmount();
|
||||
expect(mockDispatch.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
test('do NOT clear all queries when setting state and clearAllQuery is set to true', () => {
|
||||
const { rerender } = renderHook(() => useGlobalTime());
|
||||
act(() => rerender());
|
||||
expect(mockDispatch.mock.calls.length).toBe(0);
|
||||
test('do clears only the dismounted queries at unmount when setQuery is called', () => {
|
||||
const { result, unmount } = renderHook(() => useGlobalTime());
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({
|
||||
id: 'query-1',
|
||||
inspect: { dsl: [], response: [] },
|
||||
loading: false,
|
||||
refetch: () => {},
|
||||
searchSessionId: 'session-1',
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({
|
||||
id: 'query-2',
|
||||
inspect: { dsl: [], response: [] },
|
||||
loading: false,
|
||||
refetch: () => {},
|
||||
searchSessionId: 'session-1',
|
||||
});
|
||||
});
|
||||
|
||||
const { result: theOneWillNotBeDismounted } = renderHook(() => useGlobalTime());
|
||||
|
||||
act(() => {
|
||||
theOneWillNotBeDismounted.current.setQuery({
|
||||
id: 'query-3h',
|
||||
inspect: { dsl: [], response: [] },
|
||||
loading: false,
|
||||
refetch: () => {},
|
||||
searchSessionId: 'session-1',
|
||||
});
|
||||
});
|
||||
unmount();
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(5);
|
||||
expect(mockDispatch.mock.calls[3][0].payload.id).toEqual('query-1');
|
||||
|
||||
expect(mockDispatch.mock.calls[3][0].type).toEqual(
|
||||
'x-pack/security_solution/local/inputs/DELETE_QUERY'
|
||||
);
|
||||
|
||||
expect(mockDispatch.mock.calls[4][0].payload.id).toEqual('query-2');
|
||||
|
||||
expect(mockDispatch.mock.calls[4][0].type).toEqual(
|
||||
'x-pack/security_solution/local/inputs/DELETE_QUERY'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import { pick } from 'lodash/fp';
|
||||
import { useCallback, useState, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { InputsModelId } from '../../store/inputs/constants';
|
||||
|
@ -15,15 +15,18 @@ import { inputsSelectors } from '../../store';
|
|||
import { inputsActions } from '../../store/actions';
|
||||
import type { SetQuery, DeleteQuery } from './types';
|
||||
|
||||
export const useGlobalTime = (clearAllQuery: boolean = true) => {
|
||||
export const useGlobalTime = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { from, to } = useDeepEqualSelector((state) =>
|
||||
pick(['from', 'to'], inputsSelectors.globalTimeRangeSelector(state))
|
||||
);
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
|
||||
const queryId = useRef<string[]>([]);
|
||||
|
||||
const setQuery = useCallback(
|
||||
({ id, inspect, loading, refetch, searchSessionId }: SetQuery) =>
|
||||
({ id, inspect, loading, refetch, searchSessionId }: SetQuery) => {
|
||||
queryId.current = [...queryId.current, id];
|
||||
dispatch(
|
||||
inputsActions.setQuery({
|
||||
inputId: InputsModelId.global,
|
||||
|
@ -33,7 +36,8 @@ export const useGlobalTime = (clearAllQuery: boolean = true) => {
|
|||
refetch,
|
||||
searchSessionId,
|
||||
})
|
||||
),
|
||||
);
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
|
@ -50,13 +54,13 @@ export const useGlobalTime = (clearAllQuery: boolean = true) => {
|
|||
// This effect must not have any mutable dependencies. Otherwise, the cleanup function gets called before the component unmounts.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clearAllQuery) {
|
||||
dispatch(inputsActions.deleteAllQuery({ id: InputsModelId.global }));
|
||||
if (queryId.current.length > 0) {
|
||||
queryId.current.forEach((id) => deleteQuery({ id }));
|
||||
}
|
||||
};
|
||||
}, [dispatch, clearAllQuery]);
|
||||
}, [deleteQuery]);
|
||||
|
||||
const memoizedReturn = useMemo(
|
||||
return useMemo(
|
||||
() => ({
|
||||
isInitializing,
|
||||
from,
|
||||
|
@ -66,8 +70,6 @@ export const useGlobalTime = (clearAllQuery: boolean = true) => {
|
|||
}),
|
||||
[deleteQuery, from, isInitializing, setQuery, to]
|
||||
);
|
||||
|
||||
return memoizedReturn;
|
||||
};
|
||||
|
||||
export type GlobalTimeArgs = Omit<ReturnType<typeof useGlobalTime>, 'deleteQuery'> &
|
||||
|
|
|
@ -11,9 +11,5 @@ import type React from 'react';
|
|||
const actionCreator = actionCreatorFactory('x-pack/security_solution/groups');
|
||||
|
||||
export const updateGroupSelector = actionCreator<{
|
||||
groupSelector: React.ReactElement;
|
||||
groupSelector: React.ReactElement | null;
|
||||
}>('UPDATE_GROUP_SELECTOR');
|
||||
|
||||
export const updateSelectedGroup = actionCreator<{
|
||||
selectedGroup: string;
|
||||
}>('UPDATE_SELECTED_GROUP');
|
||||
|
|
|
@ -6,20 +6,17 @@
|
|||
*/
|
||||
|
||||
import { reducerWithInitialState } from 'typescript-fsa-reducers';
|
||||
import { updateGroupSelector, updateSelectedGroup } from './actions';
|
||||
import { updateGroupSelector } from './actions';
|
||||
import type { GroupModel } from './types';
|
||||
|
||||
export const initialGroupingState: GroupModel = {
|
||||
groupSelector: null,
|
||||
selectedGroup: null,
|
||||
};
|
||||
|
||||
export const groupsReducer = reducerWithInitialState(initialGroupingState)
|
||||
.case(updateSelectedGroup, (state, { selectedGroup }) => ({
|
||||
...state,
|
||||
selectedGroup,
|
||||
}))
|
||||
.case(updateGroupSelector, (state, { groupSelector }) => ({
|
||||
export const groupsReducer = reducerWithInitialState(initialGroupingState).case(
|
||||
updateGroupSelector,
|
||||
(state, { groupSelector }) => ({
|
||||
...state,
|
||||
groupSelector,
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
|
|
@ -11,7 +11,3 @@ import type { GroupState } from './types';
|
|||
const groupSelector = (state: GroupState) => state.groups.groupSelector;
|
||||
|
||||
export const getGroupSelector = () => createSelector(groupSelector, (selector) => selector);
|
||||
|
||||
export const selectedGroup = (state: GroupState) => state.groups.selectedGroup;
|
||||
|
||||
export const getSelectedGroup = () => createSelector(selectedGroup, (group) => group);
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
|
||||
export interface GroupModel {
|
||||
groupSelector: React.ReactElement | null;
|
||||
selectedGroup: string | null;
|
||||
}
|
||||
|
||||
export interface GroupState {
|
||||
|
|
|
@ -852,7 +852,7 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
|
|||
</Display>
|
||||
{ruleId != null && (
|
||||
<GroupedAlertsTable
|
||||
currentAlertStatusFilterValue={filterGroup}
|
||||
currentAlertStatusFilterValue={[filterGroup]}
|
||||
defaultFilters={alertMergedFilters}
|
||||
from={from}
|
||||
globalFilters={filters}
|
||||
|
|
|
@ -12,7 +12,6 @@ import { AlertsCountPanel } from '.';
|
|||
|
||||
import type { Status } from '../../../../../common/detection_engine/schemas/common';
|
||||
import { useQueryToggle } from '../../../../common/containers/query_toggle';
|
||||
import { useGlobalTime } from '../../../../common/containers/use_global_time';
|
||||
import { DEFAULT_STACK_BY_FIELD, DEFAULT_STACK_BY_FIELD1 } from '../common/config';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
import { ChartContextMenu } from '../../../pages/detection_engine/chart_panels/chart_context_menu';
|
||||
|
@ -109,18 +108,6 @@ describe('AlertsCountPanel', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('invokes useGlobalTime() with false to prevent global queries from being deleted when the component unmounts', async () => {
|
||||
await act(async () => {
|
||||
mount(
|
||||
<TestProviders>
|
||||
<AlertsCountPanel {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(useGlobalTime).toBeCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders with the specified `alignHeader` alignment', async () => {
|
||||
await act(async () => {
|
||||
const wrapper = mount(
|
||||
|
|
|
@ -84,7 +84,7 @@ export const AlertsCountPanel = memo<AlertsCountPanelProps>(
|
|||
isExpanded,
|
||||
setIsExpanded,
|
||||
}) => {
|
||||
const { to, from, deleteQuery, setQuery } = useGlobalTime(false);
|
||||
const { to, from, deleteQuery, setQuery } = useGlobalTime();
|
||||
const isChartEmbeddablesEnabled = useIsExperimentalFeatureEnabled('chartEmbeddablesEnabled');
|
||||
const isAlertsPageChartsEnabled = useIsExperimentalFeatureEnabled('alertsPageChartsEnabled');
|
||||
// create a unique, but stable (across re-renders) query id
|
||||
|
|
|
@ -152,7 +152,7 @@ export const AlertsHistogramPanel = memo<AlertsHistogramPanelProps>(
|
|||
isExpanded,
|
||||
setIsExpanded,
|
||||
}) => {
|
||||
const { to, from, deleteQuery, setQuery } = useGlobalTime(false);
|
||||
const { to, from, deleteQuery, setQuery } = useGlobalTime();
|
||||
|
||||
// create a unique, but stable (across re-renders) query id
|
||||
const uniqueQueryId = useMemo(() => `${DETECTIONS_HISTOGRAM_ID}-${uuidv4()}`, []);
|
||||
|
|
|
@ -82,7 +82,7 @@ export const useSummaryChartData: UseAlerts = ({
|
|||
signalIndexName,
|
||||
skip = false,
|
||||
}) => {
|
||||
const { to, from, deleteQuery, setQuery } = useGlobalTime(false);
|
||||
const { to, from, deleteQuery, setQuery } = useGlobalTime();
|
||||
const [updatedAt, setUpdatedAt] = useState(Date.now());
|
||||
const [items, setItems] = useState<SummaryChartsData[]>([]);
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -5,50 +5,29 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { isEmpty } from 'lodash/fp';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type { Filter, Query } from '@kbn/es-query';
|
||||
import { buildEsQuery } from '@kbn/es-query';
|
||||
import { getEsQueryConfig } from '@kbn/data-plugin/common';
|
||||
import type {
|
||||
GroupingFieldTotalAggregation,
|
||||
GroupingAggregation,
|
||||
} from '@kbn/securitysolution-grouping';
|
||||
import { useGrouping, isNoneGroup } from '@kbn/securitysolution-grouping';
|
||||
import type { GroupOption } from '@kbn/securitysolution-grouping';
|
||||
import { isNoneGroup, useGrouping } from '@kbn/securitysolution-grouping';
|
||||
import { isEmpty, isEqual } from 'lodash/fp';
|
||||
import type { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
import type { TableIdLiteral } from '@kbn/securitysolution-data-table';
|
||||
import type { AlertsGroupingAggregation } from './grouping_settings/types';
|
||||
import { groupSelectors } from '../../../common/store/grouping';
|
||||
import type { State } from '../../../common/store';
|
||||
import { updateGroupSelector } from '../../../common/store/grouping/actions';
|
||||
import type { Status } from '../../../../common/detection_engine/schemas/common';
|
||||
import { InspectButton } from '../../../common/components/inspect';
|
||||
import { defaultUnit } from '../../../common/components/toolbar/unit';
|
||||
import { useGlobalTime } from '../../../common/containers/use_global_time';
|
||||
import { combineQueries } from '../../../common/lib/kuery';
|
||||
import { useSourcererDataView } from '../../../common/containers/sourcerer';
|
||||
import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query';
|
||||
import { useKibana } from '../../../common/lib/kibana';
|
||||
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
|
||||
import { useInspectButton } from '../alerts_kpis/common/hooks';
|
||||
|
||||
import { buildTimeRangeFilter } from './helpers';
|
||||
import * as i18n from './translations';
|
||||
import { useQueryAlerts } from '../../containers/detection_engine/alerts/use_query';
|
||||
import { ALERTS_QUERY_NAMES } from '../../containers/detection_engine/alerts/constants';
|
||||
import {
|
||||
getAlertsGroupingQuery,
|
||||
getDefaultGroupingOptions,
|
||||
renderGroupPanel,
|
||||
getStats,
|
||||
useGroupTakeActionsItems,
|
||||
} from './grouping_settings';
|
||||
import { updateGroupSelector, updateSelectedGroup } from '../../../common/store/grouping/actions';
|
||||
import { getDefaultGroupingOptions, renderGroupPanel, getStats } from './grouping_settings';
|
||||
import { useKibana } from '../../../common/lib/kibana';
|
||||
import { GroupedSubLevel } from './alerts_sub_grouping';
|
||||
import { track } from '../../../common/lib/telemetry';
|
||||
|
||||
const ALERTS_GROUPING_ID = 'alerts-grouping';
|
||||
|
||||
export interface AlertsTableComponentProps {
|
||||
currentAlertStatusFilterValue?: Status;
|
||||
currentAlertStatusFilterValue?: Status[];
|
||||
defaultFilters?: Filter[];
|
||||
from: string;
|
||||
globalFilters: Filter[];
|
||||
|
@ -63,52 +42,37 @@ export interface AlertsTableComponentProps {
|
|||
to: string;
|
||||
}
|
||||
|
||||
export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
|
||||
defaultFilters = [],
|
||||
from,
|
||||
globalFilters,
|
||||
globalQuery,
|
||||
hasIndexMaintenance,
|
||||
hasIndexWrite,
|
||||
loading,
|
||||
tableId,
|
||||
to,
|
||||
runtimeMappings,
|
||||
signalIndexName,
|
||||
currentAlertStatusFilterValue,
|
||||
renderChildComponent,
|
||||
}) => {
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
const DEFAULT_PAGE_INDEX = 0;
|
||||
const MAX_GROUPING_LEVELS = 3;
|
||||
|
||||
const useStorage = (storage: Storage, tableId: string) =>
|
||||
useMemo(
|
||||
() => ({
|
||||
getStoragePageSize: (): number[] => {
|
||||
const pageSizes = storage.get(`grouping-table-${tableId}`);
|
||||
if (!pageSizes) {
|
||||
return Array(MAX_GROUPING_LEVELS).fill(DEFAULT_PAGE_SIZE);
|
||||
}
|
||||
return pageSizes;
|
||||
},
|
||||
setStoragePageSize: (pageSizes: number[]) => {
|
||||
storage.set(`grouping-table-${tableId}`, pageSizes);
|
||||
},
|
||||
}),
|
||||
[storage, tableId]
|
||||
);
|
||||
|
||||
const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> = (props) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { browserFields, indexPattern, selectedPatterns } = useSourcererDataView(
|
||||
SourcererScopeName.detections
|
||||
);
|
||||
const { indexPattern, selectedPatterns } = useSourcererDataView(SourcererScopeName.detections);
|
||||
|
||||
const {
|
||||
services: { uiSettings, telemetry },
|
||||
services: { storage, telemetry },
|
||||
} = useKibana();
|
||||
|
||||
const getGlobalQuery = useCallback(
|
||||
(customFilters: Filter[]) => {
|
||||
if (browserFields != null && indexPattern != null) {
|
||||
return combineQueries({
|
||||
config: getEsQueryConfig(uiSettings),
|
||||
dataProviders: [],
|
||||
indexPattern,
|
||||
browserFields,
|
||||
filters: [
|
||||
...(defaultFilters ?? []),
|
||||
...globalFilters,
|
||||
...customFilters,
|
||||
...buildTimeRangeFilter(from, to),
|
||||
],
|
||||
kqlQuery: globalQuery,
|
||||
kqlMode: globalQuery.language,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[browserFields, indexPattern, uiSettings, defaultFilters, globalFilters, from, to, globalQuery]
|
||||
);
|
||||
const { getStoragePageSize, setStoragePageSize } = useStorage(storage, props.tableId);
|
||||
|
||||
const { onGroupChange, onGroupToggle } = useMemo(
|
||||
() => ({
|
||||
|
@ -125,153 +89,146 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
|
|||
[telemetry]
|
||||
);
|
||||
|
||||
// create a unique, but stable (across re-renders) query id
|
||||
const uniqueQueryId = useMemo(() => `${ALERTS_GROUPING_ID}-${uuidv4()}`, []);
|
||||
|
||||
const inspect = useMemo(
|
||||
() => (
|
||||
<InspectButton queryId={uniqueQueryId} inspectIndex={0} title={i18n.INSPECT_GROUPING_TITLE} />
|
||||
),
|
||||
[uniqueQueryId]
|
||||
);
|
||||
|
||||
const { groupSelector, getGrouping, selectedGroup, pagination } = useGrouping({
|
||||
const { groupSelector, getGrouping, selectedGroups } = useGrouping({
|
||||
componentProps: {
|
||||
groupPanelRenderer: renderGroupPanel,
|
||||
groupStatsRenderer: getStats,
|
||||
inspectButton: inspect,
|
||||
onGroupToggle,
|
||||
renderChildComponent,
|
||||
unit: defaultUnit,
|
||||
},
|
||||
defaultGroupingOptions: getDefaultGroupingOptions(tableId),
|
||||
defaultGroupingOptions: getDefaultGroupingOptions(props.tableId),
|
||||
fields: indexPattern.fields,
|
||||
groupingId: tableId,
|
||||
groupingId: props.tableId,
|
||||
maxGroupingLevels: MAX_GROUPING_LEVELS,
|
||||
onGroupChange,
|
||||
tracker: track,
|
||||
});
|
||||
const resetPagination = pagination.reset;
|
||||
|
||||
const getGroupSelector = groupSelectors.getGroupSelector();
|
||||
|
||||
const groupSelectorInRedux = useSelector((state: State) => getGroupSelector(state));
|
||||
const selectorOptions = useRef<GroupOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(updateGroupSelector({ groupSelector }));
|
||||
}, [dispatch, groupSelector]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(updateSelectedGroup({ selectedGroup }));
|
||||
}, [dispatch, selectedGroup]);
|
||||
|
||||
useInvalidFilterQuery({
|
||||
id: tableId,
|
||||
filterQuery: getGlobalQuery([])?.filterQuery,
|
||||
kqlError: getGlobalQuery([])?.kqlError,
|
||||
query: globalQuery,
|
||||
startDate: from,
|
||||
endDate: to,
|
||||
});
|
||||
|
||||
const { deleteQuery, setQuery } = useGlobalTime(false);
|
||||
const additionalFilters = useMemo(() => {
|
||||
resetPagination();
|
||||
try {
|
||||
return [
|
||||
buildEsQuery(undefined, globalQuery != null ? [globalQuery] : [], [
|
||||
...(globalFilters?.filter((f) => f.meta.disabled === false) ?? []),
|
||||
...(defaultFilters ?? []),
|
||||
]),
|
||||
];
|
||||
} catch (e) {
|
||||
return [];
|
||||
if (
|
||||
isNoneGroup(selectedGroups) &&
|
||||
groupSelector.props.options.length > 0 &&
|
||||
(groupSelectorInRedux == null ||
|
||||
!isEqual(selectorOptions.current, groupSelector.props.options))
|
||||
) {
|
||||
selectorOptions.current = groupSelector.props.options;
|
||||
dispatch(updateGroupSelector({ groupSelector }));
|
||||
} else if (!isNoneGroup(selectedGroups) && groupSelectorInRedux !== null) {
|
||||
dispatch(updateGroupSelector({ groupSelector: null }));
|
||||
}
|
||||
}, [defaultFilters, globalFilters, globalQuery, resetPagination]);
|
||||
}, [dispatch, groupSelector, groupSelectorInRedux, selectedGroups]);
|
||||
|
||||
const queryGroups = useMemo(
|
||||
() =>
|
||||
getAlertsGroupingQuery({
|
||||
additionalFilters,
|
||||
selectedGroup,
|
||||
from,
|
||||
runtimeMappings,
|
||||
to,
|
||||
pageSize: pagination.pageSize,
|
||||
pageIndex: pagination.pageIndex,
|
||||
}),
|
||||
[
|
||||
additionalFilters,
|
||||
selectedGroup,
|
||||
from,
|
||||
runtimeMappings,
|
||||
to,
|
||||
pagination.pageSize,
|
||||
pagination.pageIndex,
|
||||
]
|
||||
const [pageIndex, setPageIndex] = useState<number[]>(
|
||||
Array(MAX_GROUPING_LEVELS).fill(DEFAULT_PAGE_INDEX)
|
||||
);
|
||||
const [pageSize, setPageSize] = useState<number[]>(getStoragePageSize);
|
||||
|
||||
const resetAllPagination = useCallback(() => {
|
||||
setPageIndex((curr) => curr.map(() => DEFAULT_PAGE_INDEX));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
resetAllPagination();
|
||||
}, [resetAllPagination, selectedGroups]);
|
||||
|
||||
const setPageVar = useCallback(
|
||||
(newNumber: number, groupingLevel: number, pageType: 'index' | 'size') => {
|
||||
if (pageType === 'index') {
|
||||
setPageIndex((currentIndex) => {
|
||||
const newArr = [...currentIndex];
|
||||
newArr[groupingLevel] = newNumber;
|
||||
return newArr;
|
||||
});
|
||||
}
|
||||
|
||||
if (pageType === 'size') {
|
||||
setPageSize((currentIndex) => {
|
||||
const newArr = [...currentIndex];
|
||||
newArr[groupingLevel] = newNumber;
|
||||
setStoragePageSize(newArr);
|
||||
return newArr;
|
||||
});
|
||||
}
|
||||
},
|
||||
[setStoragePageSize]
|
||||
);
|
||||
|
||||
const {
|
||||
data: alertsGroupsData,
|
||||
loading: isLoadingGroups,
|
||||
refetch,
|
||||
request,
|
||||
response,
|
||||
setQuery: setAlertsQuery,
|
||||
} = useQueryAlerts<
|
||||
{},
|
||||
GroupingAggregation<AlertsGroupingAggregation> &
|
||||
GroupingFieldTotalAggregation<AlertsGroupingAggregation>
|
||||
>({
|
||||
query: queryGroups,
|
||||
indexName: signalIndexName,
|
||||
queryName: ALERTS_QUERY_NAMES.ALERTS_GROUPING,
|
||||
skip: isNoneGroup(selectedGroup),
|
||||
const nonGroupingFilters = useRef({
|
||||
defaultFilters: props.defaultFilters,
|
||||
globalFilters: props.globalFilters,
|
||||
globalQuery: props.globalQuery,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNoneGroup(selectedGroup)) {
|
||||
setAlertsQuery(queryGroups);
|
||||
const nonGrouping = {
|
||||
defaultFilters: props.defaultFilters,
|
||||
globalFilters: props.globalFilters,
|
||||
globalQuery: props.globalQuery,
|
||||
};
|
||||
if (!isEqual(nonGroupingFilters.current, nonGrouping)) {
|
||||
resetAllPagination();
|
||||
nonGroupingFilters.current = nonGrouping;
|
||||
}
|
||||
}, [queryGroups, selectedGroup, setAlertsQuery]);
|
||||
}, [props.defaultFilters, props.globalFilters, props.globalQuery, resetAllPagination]);
|
||||
|
||||
useInspectButton({
|
||||
deleteQuery,
|
||||
loading: isLoadingGroups,
|
||||
response,
|
||||
setQuery,
|
||||
refetch,
|
||||
request,
|
||||
uniqueQueryId,
|
||||
});
|
||||
const getLevel = useCallback(
|
||||
(level: number, selectedGroup: string, parentGroupingFilter?: string) => {
|
||||
let rcc;
|
||||
if (level < selectedGroups.length - 1) {
|
||||
rcc = (groupingFilters: Filter[]) => {
|
||||
return getLevel(
|
||||
level + 1,
|
||||
selectedGroups[level + 1],
|
||||
JSON.stringify([
|
||||
...groupingFilters,
|
||||
...(parentGroupingFilter ? JSON.parse(parentGroupingFilter) : []),
|
||||
])
|
||||
);
|
||||
};
|
||||
} else {
|
||||
rcc = (groupingFilters: Filter[]) => {
|
||||
return props.renderChildComponent([
|
||||
...groupingFilters,
|
||||
...(parentGroupingFilter ? JSON.parse(parentGroupingFilter) : []),
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
||||
const takeActionItems = useGroupTakeActionsItems({
|
||||
indexName: indexPattern.title,
|
||||
currentStatus: currentAlertStatusFilterValue,
|
||||
showAlertStatusActions: hasIndexWrite && hasIndexMaintenance,
|
||||
});
|
||||
|
||||
const getTakeActionItems = useCallback(
|
||||
(groupFilters: Filter[], groupNumber: number) =>
|
||||
takeActionItems({
|
||||
query: getGlobalQuery([...(defaultFilters ?? []), ...groupFilters])?.filterQuery,
|
||||
tableId,
|
||||
groupNumber,
|
||||
selectedGroup,
|
||||
}),
|
||||
[defaultFilters, getGlobalQuery, selectedGroup, tableId, takeActionItems]
|
||||
);
|
||||
|
||||
const groupedAlerts = useMemo(
|
||||
() =>
|
||||
getGrouping({
|
||||
data: alertsGroupsData?.aggregations,
|
||||
isLoading: loading || isLoadingGroups,
|
||||
takeActionItems: getTakeActionItems,
|
||||
}),
|
||||
[alertsGroupsData?.aggregations, getGrouping, getTakeActionItems, isLoadingGroups, loading]
|
||||
const resetGroupChildrenPagination = (parentLevel: number) => {
|
||||
setPageIndex((allPages) => {
|
||||
const resetPages = allPages.splice(parentLevel + 1, allPages.length);
|
||||
return [...allPages, ...resetPages.map(() => DEFAULT_PAGE_INDEX)];
|
||||
});
|
||||
};
|
||||
return (
|
||||
<GroupedSubLevel
|
||||
{...props}
|
||||
getGrouping={getGrouping}
|
||||
groupingLevel={level}
|
||||
onGroupClose={() => resetGroupChildrenPagination(level)}
|
||||
pageIndex={pageIndex[level] ?? DEFAULT_PAGE_INDEX}
|
||||
pageSize={pageSize[level] ?? DEFAULT_PAGE_SIZE}
|
||||
parentGroupingFilter={parentGroupingFilter}
|
||||
renderChildComponent={rcc}
|
||||
selectedGroup={selectedGroup}
|
||||
setPageIndex={(newIndex: number) => setPageVar(newIndex, level, 'index')}
|
||||
setPageSize={(newSize: number) => setPageVar(newSize, level, 'size')}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[getGrouping, pageIndex, pageSize, props, selectedGroups, setPageVar]
|
||||
);
|
||||
|
||||
if (isEmpty(selectedPatterns)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return groupedAlerts;
|
||||
return getLevel(0, selectedGroups[0]);
|
||||
};
|
||||
|
||||
export const GroupedAlertsTable = React.memo(GroupedAlertsTableComponent);
|
||||
|
|
|
@ -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);
|
|
@ -30,7 +30,7 @@ describe('useGroupTakeActionsItems', () => {
|
|||
groupNumber: 0,
|
||||
selectedGroup: 'test',
|
||||
};
|
||||
it('returns array take actions items available for alerts table if showAlertStatusActions is true', async () => {
|
||||
it('returns all take actions items if showAlertStatusActions is true and currentStatus is undefined', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(
|
||||
() =>
|
||||
|
@ -47,7 +47,106 @@ describe('useGroupTakeActionsItems', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('returns empty array of take actions items available for alerts table if showAlertStatusActions is false', async () => {
|
||||
it('returns all take actions items if currentStatus is []', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(
|
||||
() =>
|
||||
useGroupTakeActionsItems({
|
||||
currentStatus: [],
|
||||
indexName: '.alerts-security.alerts-default',
|
||||
showAlertStatusActions: true,
|
||||
}),
|
||||
{
|
||||
wrapper: wrapperContainer,
|
||||
}
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
expect(result.current(getActionItemsParams).length).toEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns all take actions items if currentStatus.length > 1', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(
|
||||
() =>
|
||||
useGroupTakeActionsItems({
|
||||
currentStatus: ['open', 'closed'],
|
||||
indexName: '.alerts-security.alerts-default',
|
||||
showAlertStatusActions: true,
|
||||
}),
|
||||
{
|
||||
wrapper: wrapperContainer,
|
||||
}
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
expect(result.current(getActionItemsParams).length).toEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns acknowledged & closed take actions items if currentStatus === ["open"]', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(
|
||||
() =>
|
||||
useGroupTakeActionsItems({
|
||||
currentStatus: ['open'],
|
||||
indexName: '.alerts-security.alerts-default',
|
||||
showAlertStatusActions: true,
|
||||
}),
|
||||
{
|
||||
wrapper: wrapperContainer,
|
||||
}
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
const currentParams = result.current(getActionItemsParams);
|
||||
expect(currentParams.length).toEqual(2);
|
||||
expect(currentParams[0].key).toEqual('acknowledge');
|
||||
expect(currentParams[1].key).toEqual('close');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns open & acknowledged take actions items if currentStatus === ["closed"]', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(
|
||||
() =>
|
||||
useGroupTakeActionsItems({
|
||||
currentStatus: ['closed'],
|
||||
indexName: '.alerts-security.alerts-default',
|
||||
showAlertStatusActions: true,
|
||||
}),
|
||||
{
|
||||
wrapper: wrapperContainer,
|
||||
}
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
const currentParams = result.current(getActionItemsParams);
|
||||
expect(currentParams.length).toEqual(2);
|
||||
expect(currentParams[0].key).toEqual('open');
|
||||
expect(currentParams[1].key).toEqual('acknowledge');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns open & closed take actions items if currentStatus === ["acknowledged"]', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(
|
||||
() =>
|
||||
useGroupTakeActionsItems({
|
||||
currentStatus: ['acknowledged'],
|
||||
indexName: '.alerts-security.alerts-default',
|
||||
showAlertStatusActions: true,
|
||||
}),
|
||||
{
|
||||
wrapper: wrapperContainer,
|
||||
}
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
const currentParams = result.current(getActionItemsParams);
|
||||
expect(currentParams.length).toEqual(2);
|
||||
expect(currentParams[0].key).toEqual('open');
|
||||
expect(currentParams[1].key).toEqual('close');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty take actions items if showAlertStatusActions is false', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(
|
||||
() =>
|
||||
|
@ -63,4 +162,20 @@ describe('useGroupTakeActionsItems', () => {
|
|||
expect(result.current(getActionItemsParams).length).toEqual(0);
|
||||
});
|
||||
});
|
||||
it('returns array take actions items if showAlertStatusActions is true', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(
|
||||
() =>
|
||||
useGroupTakeActionsItems({
|
||||
indexName: '.alerts-security.alerts-default',
|
||||
showAlertStatusActions: true,
|
||||
}),
|
||||
{
|
||||
wrapper: wrapperContainer,
|
||||
}
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
expect(result.current(getActionItemsParams).length).toEqual(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { EuiContextMenuItem } from '@elastic/eui';
|
||||
import { useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import type { Status } from '../../../../../common/detection_engine/schemas/common';
|
||||
|
@ -30,8 +30,9 @@ import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
|
|||
import * as i18n from '../translations';
|
||||
import { getTelemetryEvent, METRIC_TYPE, track } from '../../../../common/lib/telemetry';
|
||||
import type { StartServices } from '../../../../types';
|
||||
|
||||
export interface TakeActionsProps {
|
||||
currentStatus?: Status;
|
||||
currentStatus?: Status[];
|
||||
indexName: string;
|
||||
showAlertStatusActions?: boolean;
|
||||
}
|
||||
|
@ -182,7 +183,7 @@ export const useGroupTakeActionsItems = ({
|
|||
]
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
return useMemo(() => {
|
||||
const getActionItems = ({
|
||||
query,
|
||||
tableId,
|
||||
|
@ -196,61 +197,89 @@ export const useGroupTakeActionsItems = ({
|
|||
}) => {
|
||||
const actionItems: JSX.Element[] = [];
|
||||
if (showAlertStatusActions) {
|
||||
if (currentStatus !== FILTER_OPEN) {
|
||||
actionItems.push(
|
||||
<EuiContextMenuItem
|
||||
key="open"
|
||||
data-test-subj="open-alert-status"
|
||||
onClick={() =>
|
||||
onClickUpdate({
|
||||
groupNumber,
|
||||
query,
|
||||
selectedGroup,
|
||||
status: FILTER_OPEN as AlertWorkflowStatus,
|
||||
tableId,
|
||||
})
|
||||
}
|
||||
>
|
||||
{BULK_ACTION_OPEN_SELECTED}
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
}
|
||||
if (currentStatus !== FILTER_ACKNOWLEDGED) {
|
||||
actionItems.push(
|
||||
<EuiContextMenuItem
|
||||
key="acknowledge"
|
||||
data-test-subj="acknowledged-alert-status"
|
||||
onClick={() =>
|
||||
onClickUpdate({
|
||||
groupNumber,
|
||||
query,
|
||||
selectedGroup,
|
||||
status: FILTER_ACKNOWLEDGED as AlertWorkflowStatus,
|
||||
tableId,
|
||||
})
|
||||
}
|
||||
>
|
||||
{BULK_ACTION_ACKNOWLEDGED_SELECTED}
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
}
|
||||
if (currentStatus !== FILTER_CLOSED) {
|
||||
actionItems.push(
|
||||
<EuiContextMenuItem
|
||||
key="close"
|
||||
data-test-subj="close-alert-status"
|
||||
onClick={() =>
|
||||
onClickUpdate({
|
||||
groupNumber,
|
||||
query,
|
||||
selectedGroup,
|
||||
status: FILTER_CLOSED as AlertWorkflowStatus,
|
||||
tableId,
|
||||
})
|
||||
}
|
||||
>
|
||||
{BULK_ACTION_CLOSE_SELECTED}
|
||||
</EuiContextMenuItem>
|
||||
if (currentStatus && currentStatus.length === 1) {
|
||||
const singleStatus = currentStatus[0];
|
||||
if (singleStatus !== FILTER_OPEN) {
|
||||
actionItems.push(
|
||||
<EuiContextMenuItem
|
||||
key="open"
|
||||
data-test-subj="open-alert-status"
|
||||
onClick={() =>
|
||||
onClickUpdate({
|
||||
groupNumber,
|
||||
query,
|
||||
selectedGroup,
|
||||
status: FILTER_OPEN as AlertWorkflowStatus,
|
||||
tableId,
|
||||
})
|
||||
}
|
||||
>
|
||||
{BULK_ACTION_OPEN_SELECTED}
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
}
|
||||
if (singleStatus !== FILTER_ACKNOWLEDGED) {
|
||||
actionItems.push(
|
||||
<EuiContextMenuItem
|
||||
key="acknowledge"
|
||||
data-test-subj="acknowledged-alert-status"
|
||||
onClick={() =>
|
||||
onClickUpdate({
|
||||
groupNumber,
|
||||
query,
|
||||
selectedGroup,
|
||||
status: FILTER_ACKNOWLEDGED as AlertWorkflowStatus,
|
||||
tableId,
|
||||
})
|
||||
}
|
||||
>
|
||||
{BULK_ACTION_ACKNOWLEDGED_SELECTED}
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
}
|
||||
if (singleStatus !== FILTER_CLOSED) {
|
||||
actionItems.push(
|
||||
<EuiContextMenuItem
|
||||
key="close"
|
||||
data-test-subj="close-alert-status"
|
||||
onClick={() =>
|
||||
onClickUpdate({
|
||||
groupNumber,
|
||||
query,
|
||||
selectedGroup,
|
||||
status: FILTER_CLOSED as AlertWorkflowStatus,
|
||||
tableId,
|
||||
})
|
||||
}
|
||||
>
|
||||
{BULK_ACTION_CLOSE_SELECTED}
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const statusArr = {
|
||||
[FILTER_OPEN]: BULK_ACTION_OPEN_SELECTED,
|
||||
[FILTER_ACKNOWLEDGED]: BULK_ACTION_ACKNOWLEDGED_SELECTED,
|
||||
[FILTER_CLOSED]: BULK_ACTION_CLOSE_SELECTED,
|
||||
};
|
||||
Object.keys(statusArr).forEach((workflowStatus) =>
|
||||
actionItems.push(
|
||||
<EuiContextMenuItem
|
||||
key={workflowStatus}
|
||||
data-test-subj={`${workflowStatus}-alert-status`}
|
||||
onClick={() =>
|
||||
onClickUpdate({
|
||||
groupNumber,
|
||||
query,
|
||||
selectedGroup,
|
||||
status: workflowStatus as AlertWorkflowStatus,
|
||||
tableId,
|
||||
})
|
||||
}
|
||||
>
|
||||
{statusArr[workflowStatus]}
|
||||
</EuiContextMenuItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -259,6 +288,4 @@ export const useGroupTakeActionsItems = ({
|
|||
|
||||
return getActionItems;
|
||||
}, [currentStatus, onClickUpdate, showAlertStatusActions]);
|
||||
|
||||
return items;
|
||||
};
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -42,8 +42,8 @@ export const getAlertsGroupingQuery = ({
|
|||
getGroupingQuery({
|
||||
additionalFilters,
|
||||
from,
|
||||
groupByFields: !isNoneGroup(selectedGroup) ? getGroupFields(selectedGroup) : [],
|
||||
statsAggregations: !isNoneGroup(selectedGroup)
|
||||
groupByFields: !isNoneGroup([selectedGroup]) ? getGroupFields(selectedGroup) : [],
|
||||
statsAggregations: !isNoneGroup([selectedGroup])
|
||||
? getAggregationsByGroupField(selectedGroup)
|
||||
: [],
|
||||
pageNumber: pageIndex * pageSize,
|
||||
|
@ -51,7 +51,7 @@ export const getAlertsGroupingQuery = ({
|
|||
{
|
||||
unitsCount: { value_count: { field: selectedGroup } },
|
||||
},
|
||||
...(!isNoneGroup(selectedGroup)
|
||||
...(!isNoneGroup([selectedGroup])
|
||||
? [{ groupsCount: { cardinality: { field: selectedGroup } } }]
|
||||
: []),
|
||||
],
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -7,7 +7,6 @@
|
|||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { isNoneGroup } from '@kbn/securitysolution-grouping';
|
||||
import {
|
||||
dataTableSelectors,
|
||||
tableDefaults,
|
||||
|
@ -29,9 +28,6 @@ export const getPersistentControlsHook = (tableId: TableId) => {
|
|||
const getGroupSelector = groupSelectors.getGroupSelector();
|
||||
|
||||
const groupSelector = useSelector((state: State) => getGroupSelector(state));
|
||||
const getSelectedGroup = groupSelectors.getSelectedGroup();
|
||||
|
||||
const selectedGroup = useSelector((state: State) => getSelectedGroup(state));
|
||||
|
||||
const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []);
|
||||
|
||||
|
@ -88,10 +84,10 @@ export const getPersistentControlsHook = (tableId: TableId) => {
|
|||
hasRightOffset={false}
|
||||
additionalFilters={additionalFiltersComponent}
|
||||
showInspect={false}
|
||||
additionalMenuOptions={isNoneGroup(selectedGroup) ? [groupSelector] : []}
|
||||
additionalMenuOptions={groupSelector != null ? [groupSelector] : []}
|
||||
/>
|
||||
),
|
||||
[tableView, handleChangeTableView, additionalFiltersComponent, groupSelector, selectedGroup]
|
||||
[tableView, handleChangeTableView, additionalFiltersComponent, groupSelector]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
@ -5,10 +5,9 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import React, { useEffect } from 'react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import '../../../common/mock/match_media';
|
||||
import {
|
||||
createSecuritySolutionStorageMock,
|
||||
|
@ -29,6 +28,10 @@ import { mockCasesContext } from '@kbn/cases-plugin/public/mocks/mock_cases_cont
|
|||
import { createFilterManagerMock } from '@kbn/data-plugin/public/query/filter_manager/filter_manager.mock';
|
||||
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
|
||||
import { createStubDataView } from '@kbn/data-views-plugin/common/data_view.stub';
|
||||
import { useListsConfig } from '../../containers/detection_engine/lists/use_lists_config';
|
||||
import type { FilterGroupProps } from '../../../common/components/filter_group/types';
|
||||
import { FilterGroup } from '../../../common/components/filter_group';
|
||||
import type { AlertsTableComponentProps } from '../../components/alerts_table/alerts_grouping';
|
||||
|
||||
// Test will fail because we will to need to mock some core services to make the test work
|
||||
// For now let's forget about SiemSearchBar and QueryBar
|
||||
|
@ -38,6 +41,27 @@ jest.mock('../../../common/components/search_bar', () => ({
|
|||
jest.mock('../../../common/components/query_bar', () => ({
|
||||
QueryBar: () => null,
|
||||
}));
|
||||
jest.mock('../../../common/hooks/use_space_id', () => ({
|
||||
useSpaceId: () => 'default',
|
||||
}));
|
||||
jest.mock('../../../common/components/filter_group');
|
||||
|
||||
const mockStatusCapture = jest.fn();
|
||||
const GroupedAlertsTable: React.FC<AlertsTableComponentProps> = ({
|
||||
currentAlertStatusFilterValue,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (currentAlertStatusFilterValue) {
|
||||
mockStatusCapture(currentAlertStatusFilterValue);
|
||||
}
|
||||
}, [currentAlertStatusFilterValue]);
|
||||
return <span />;
|
||||
};
|
||||
|
||||
jest.mock('../../components/alerts_table/alerts_grouping', () => ({
|
||||
GroupedAlertsTable,
|
||||
}));
|
||||
|
||||
jest.mock('../../containers/detection_engine/lists/use_lists_config');
|
||||
jest.mock('../../components/user_info');
|
||||
jest.mock('../../../common/containers/sourcerer');
|
||||
|
@ -158,9 +182,11 @@ jest.mock('../../../common/components/page/use_refetch_by_session');
|
|||
|
||||
describe('DetectionEnginePageComponent', () => {
|
||||
beforeAll(() => {
|
||||
(useListsConfig as jest.Mock).mockReturnValue({ loading: false, needsConfiguration: false });
|
||||
(useParams as jest.Mock).mockReturnValue({});
|
||||
(useUserData as jest.Mock).mockReturnValue([
|
||||
{
|
||||
loading: false,
|
||||
hasIndexRead: true,
|
||||
canUserREAD: true,
|
||||
},
|
||||
|
@ -170,10 +196,15 @@ describe('DetectionEnginePageComponent', () => {
|
|||
indexPattern: {},
|
||||
browserFields: mockBrowserFields,
|
||||
});
|
||||
(FilterGroup as jest.Mock).mockImplementation(() => {
|
||||
return <span />;
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders correctly', async () => {
|
||||
const wrapper = mount(
|
||||
const { getByTestId } = render(
|
||||
<TestProviders store={store}>
|
||||
<Router history={mockHistory}>
|
||||
<DetectionEnginePage />
|
||||
|
@ -181,12 +212,12 @@ describe('DetectionEnginePageComponent', () => {
|
|||
</TestProviders>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(wrapper.find('FiltersGlobal').exists()).toBe(true);
|
||||
expect(getByTestId('filter-group__loading')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the chart panels', async () => {
|
||||
const wrapper = mount(
|
||||
const { getByTestId } = render(
|
||||
<TestProviders store={store}>
|
||||
<Router history={mockHistory}>
|
||||
<DetectionEnginePage />
|
||||
|
@ -195,7 +226,119 @@ describe('DetectionEnginePageComponent', () => {
|
|||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(wrapper.find('[data-test-subj="chartPanels"]').exists()).toBe(true);
|
||||
expect(getByTestId('chartPanels')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('the pageFiltersUpdateHandler updates status when a multi status filter is passed', async () => {
|
||||
(FilterGroup as jest.Mock).mockImplementationOnce(({ onFilterChange }: FilterGroupProps) => {
|
||||
if (onFilterChange) {
|
||||
// once with status
|
||||
onFilterChange([
|
||||
{
|
||||
meta: {
|
||||
index: 'security-solution-default',
|
||||
key: 'kibana.alert.workflow_status',
|
||||
params: ['open', 'acknowledged'],
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
return <span />;
|
||||
});
|
||||
await waitFor(() => {
|
||||
render(
|
||||
<TestProviders store={store}>
|
||||
<Router history={mockHistory}>
|
||||
<DetectionEnginePage />
|
||||
</Router>
|
||||
</TestProviders>
|
||||
);
|
||||
});
|
||||
// when statusFilter updates, we call mockStatusCapture in test mocks
|
||||
expect(mockStatusCapture).toHaveBeenNthCalledWith(1, []);
|
||||
expect(mockStatusCapture).toHaveBeenNthCalledWith(2, ['open', 'acknowledged']);
|
||||
});
|
||||
|
||||
it('the pageFiltersUpdateHandler updates status when a single status filter is passed', async () => {
|
||||
(FilterGroup as jest.Mock).mockImplementationOnce(({ onFilterChange }: FilterGroupProps) => {
|
||||
if (onFilterChange) {
|
||||
// once with status
|
||||
onFilterChange([
|
||||
{
|
||||
meta: {
|
||||
index: 'security-solution-default',
|
||||
key: 'kibana.alert.workflow_status',
|
||||
disabled: false,
|
||||
},
|
||||
query: {
|
||||
match_phrase: {
|
||||
'kibana.alert.workflow_status': 'open',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
index: 'security-solution-default',
|
||||
key: 'kibana.alert.severity',
|
||||
disabled: false,
|
||||
},
|
||||
query: {
|
||||
match_phrase: {
|
||||
'kibana.alert.severity': 'low',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
return <span />;
|
||||
});
|
||||
await waitFor(() => {
|
||||
render(
|
||||
<TestProviders store={store}>
|
||||
<Router history={mockHistory}>
|
||||
<DetectionEnginePage />
|
||||
</Router>
|
||||
</TestProviders>
|
||||
);
|
||||
});
|
||||
// when statusFilter updates, we call mockStatusCapture in test mocks
|
||||
expect(mockStatusCapture).toHaveBeenNthCalledWith(1, []);
|
||||
expect(mockStatusCapture).toHaveBeenNthCalledWith(2, ['open']);
|
||||
});
|
||||
|
||||
it('the pageFiltersUpdateHandler clears status when no status filter is passed', async () => {
|
||||
(FilterGroup as jest.Mock).mockImplementationOnce(({ onFilterChange }: FilterGroupProps) => {
|
||||
if (onFilterChange) {
|
||||
// once with status
|
||||
onFilterChange([
|
||||
{
|
||||
meta: {
|
||||
index: 'security-solution-default',
|
||||
key: 'kibana.alert.severity',
|
||||
disabled: false,
|
||||
},
|
||||
query: {
|
||||
match_phrase: {
|
||||
'kibana.alert.severity': 'low',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
return <span />;
|
||||
});
|
||||
await waitFor(() => {
|
||||
render(
|
||||
<TestProviders store={store}>
|
||||
<Router history={mockHistory}>
|
||||
<DetectionEnginePage />
|
||||
</Router>
|
||||
</TestProviders>
|
||||
);
|
||||
});
|
||||
// when statusFilter updates, we call mockStatusCapture in test mocks
|
||||
expect(mockStatusCapture).toHaveBeenNthCalledWith(1, []);
|
||||
expect(mockStatusCapture).toHaveBeenNthCalledWith(2, []);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -29,7 +29,6 @@ import {
|
|||
dataTableActions,
|
||||
dataTableSelectors,
|
||||
tableDefaults,
|
||||
FILTER_OPEN,
|
||||
TableId,
|
||||
} from '@kbn/securitysolution-data-table';
|
||||
import { ALERTS_TABLE_REGISTRY_CONFIG_IDS } from '../../../../common/constants';
|
||||
|
@ -139,7 +138,7 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
|
|||
const arePageFiltersEnabled = useIsExperimentalFeatureEnabled('alertsPageFiltersEnabled');
|
||||
|
||||
// when arePageFiltersEnabled === false
|
||||
const [filterGroup, setFilterGroup] = useState<Status>(FILTER_OPEN);
|
||||
const [statusFilter, setStatusFilter] = useState<Status[]>([]);
|
||||
|
||||
const updatedAt = useShallowEqualSelector(
|
||||
(state) => (getTable(state, TableId.alertsOnAlertsPage) ?? tableDefaults).updated
|
||||
|
@ -177,8 +176,8 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
|
|||
if (arePageFiltersEnabled) {
|
||||
return detectionPageFilters;
|
||||
}
|
||||
return buildAlertStatusFilter(filterGroup);
|
||||
}, [filterGroup, detectionPageFilters, arePageFiltersEnabled]);
|
||||
return buildAlertStatusFilter(statusFilter[0] ?? 'open');
|
||||
}, [statusFilter, detectionPageFilters, arePageFiltersEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!detectionPageFilterHandler) return;
|
||||
|
@ -276,6 +275,19 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
|
|||
|
||||
const pageFiltersUpdateHandler = useCallback((newFilters: Filter[]) => {
|
||||
setDetectionPageFilters(newFilters);
|
||||
if (newFilters.length) {
|
||||
const newStatusFilter = newFilters.find(
|
||||
(filter) => filter.meta.key === 'kibana.alert.workflow_status'
|
||||
);
|
||||
if (newStatusFilter) {
|
||||
const status: Status[] = newStatusFilter.meta.params
|
||||
? (newStatusFilter.meta.params as Status[])
|
||||
: [newStatusFilter.query?.match_phrase['kibana.alert.workflow_status']];
|
||||
setStatusFilter(status);
|
||||
} else {
|
||||
setStatusFilter([]);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Callback for when open/closed filter changes
|
||||
|
@ -284,9 +296,9 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
|
|||
const timelineId = TableId.alertsOnAlertsPage;
|
||||
clearEventsLoading({ id: timelineId });
|
||||
clearEventsDeleted({ id: timelineId });
|
||||
setFilterGroup(newFilterGroup);
|
||||
setStatusFilter([newFilterGroup]);
|
||||
},
|
||||
[clearEventsLoading, clearEventsDeleted, setFilterGroup]
|
||||
[clearEventsLoading, clearEventsDeleted, setStatusFilter]
|
||||
);
|
||||
|
||||
const areDetectionPageFiltersLoading = useMemo(() => {
|
||||
|
@ -317,7 +329,7 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
|
|||
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<AlertsTableFilterGroup
|
||||
status={filterGroup}
|
||||
status={statusFilter[0] ?? 'open'}
|
||||
onFilterGroupChanged={onFilterGroupChangedCallback}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
@ -352,7 +364,7 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
|
|||
[
|
||||
arePageFiltersEnabled,
|
||||
dataViewId,
|
||||
filterGroup,
|
||||
statusFilter,
|
||||
filters,
|
||||
onFilterGroupChangedCallback,
|
||||
pageFiltersUpdateHandler,
|
||||
|
@ -462,7 +474,7 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
|
|||
<EuiSpacer size="l" />
|
||||
</Display>
|
||||
<GroupedAlertsTable
|
||||
currentAlertStatusFilterValue={filterGroup}
|
||||
currentAlertStatusFilterValue={statusFilter}
|
||||
defaultFilters={alertsTableDefaultFilters}
|
||||
from={from}
|
||||
globalFilters={filters}
|
||||
|
|
|
@ -60,7 +60,7 @@ export const EntityAnalyticsAnomalies = () => {
|
|||
|
||||
const [updatedAt, setUpdatedAt] = useState<number>(Date.now());
|
||||
const { toggleStatus, setToggleStatus } = useQueryToggle(TABLE_QUERY_ID);
|
||||
const { deleteQuery, setQuery, from, to } = useGlobalTime(false);
|
||||
const { deleteQuery, setQuery, from, to } = useGlobalTime();
|
||||
const {
|
||||
isLoading: isSearchLoading,
|
||||
data,
|
||||
|
|
|
@ -41,7 +41,7 @@ const HOST_RISK_QUERY_ID = 'hostRiskScoreKpiQuery';
|
|||
const USER_RISK_QUERY_ID = 'userRiskScoreKpiQuery';
|
||||
|
||||
export const EntityAnalyticsHeader = () => {
|
||||
const { from, to } = useGlobalTime(false);
|
||||
const { from, to } = useGlobalTime();
|
||||
const timerange = useMemo(
|
||||
() => ({
|
||||
from,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue