From 0c3d7006b0eb683b092e6609c7d1fbfc52b7fadb Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Mon, 24 Apr 2023 06:01:05 -0700 Subject: [PATCH] [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 --- .../steps/storybooks/build_and_upload.ts | 2 + .../.storybook/main.js | 9 + .../kbn-securitysolution-grouping/README.md | 3 - .../kbn-securitysolution-grouping/README.mdx | 3 + .../kbn-securitysolution-grouping/index.tsx | 8 +- .../accordion_panel/group_stats.test.tsx | 4 +- .../accordion_panel/group_stats.tsx | 31 +- .../components/accordion_panel/index.test.tsx | 1 + .../src/components/accordion_panel/index.tsx | 30 +- .../components/group_selector/index.test.tsx | 36 +- .../src/components/group_selector/index.tsx | 61 +- .../src/components/grouping.mock.tsx | 111 ++ .../src/components/grouping.stories.tsx | 28 + .../src/components/grouping.test.tsx | 95 +- .../src/components/grouping.tsx | 159 +- .../src/components/index.tsx | 5 +- .../src/components/styles.tsx | 37 +- .../src/components/translations.ts | 8 +- .../src/components/types.ts | 6 +- .../src/containers/query/index.ts | 2 +- .../src/containers/query/types.ts | 3 +- .../src/hooks/state/actions.ts | 53 +- .../src/hooks/state/reducer.test.ts | 39 +- .../src/hooks/state/reducer.ts | 48 +- .../src/hooks/types.ts | 32 +- .../src/hooks/use_get_group_selector.test.tsx | 115 +- .../src/hooks/use_get_group_selector.tsx | 116 +- .../src/hooks/use_grouping.test.tsx | 13 +- .../src/hooks/use_grouping.tsx | 80 +- .../src/hooks/use_grouping_pagination.ts | 53 - .../tsconfig.json | 3 +- src/dev/storybook/aliases.ts | 1 + .../alerts_treemap_panel/index.test.tsx | 11 - .../components/alerts_treemap_panel/index.tsx | 2 +- .../public/common/components/top_n/index.tsx | 2 +- .../alerts/use_alert_prevalence.test.ts | 46 - .../containers/alerts/use_alert_prevalence.ts | 2 +- .../containers/use_global_time/index.test.tsx | 74 +- .../containers/use_global_time/index.tsx | 22 +- .../public/common/store/grouping/actions.ts | 6 +- .../public/common/store/grouping/reducer.ts | 15 +- .../public/common/store/grouping/selectors.ts | 4 - .../public/common/store/grouping/types.ts | 1 - .../pages/rule_details/index.tsx | 2 +- .../alerts_count_panel/index.test.tsx | 13 - .../alerts_kpis/alerts_count_panel/index.tsx | 2 +- .../alerts_histogram_panel/index.tsx | 2 +- .../use_summary_chart_data.tsx | 2 +- .../alerts_table/alerts_grouping.test.tsx | 376 ++++ .../alerts_table/alerts_grouping.tsx | 341 ++-- .../alerts_table/alerts_sub_grouping.tsx | 259 +++ .../group_take_action_items.test.tsx | 119 +- .../group_take_action_items.tsx | 147 +- .../alerts_table/grouping_settings/mock.ts | 1736 +++++++++++++++++ .../grouping_settings/query_builder.ts | 6 +- .../components/alerts_table/index.test.tsx | 261 --- .../use_persistent_controls.tsx | 8 +- .../detection_engine.test.tsx | 159 +- .../detection_engine/detection_engine.tsx | 30 +- .../entity_analytics/anomalies/index.tsx | 2 +- .../entity_analytics/header/index.tsx | 2 +- 61 files changed, 3631 insertions(+), 1216 deletions(-) create mode 100644 packages/kbn-securitysolution-grouping/.storybook/main.js delete mode 100644 packages/kbn-securitysolution-grouping/README.md create mode 100644 packages/kbn-securitysolution-grouping/README.mdx create mode 100644 packages/kbn-securitysolution-grouping/src/components/grouping.mock.tsx create mode 100644 packages/kbn-securitysolution-grouping/src/components/grouping.stories.tsx delete mode 100644 packages/kbn-securitysolution-grouping/src/hooks/use_grouping_pagination.ts delete mode 100644 x-pack/plugins/security_solution/public/common/containers/alerts/use_alert_prevalence.test.ts create mode 100644 x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_grouping.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_sub_grouping.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/mock.ts delete mode 100644 x-pack/plugins/security_solution/public/detections/components/alerts_table/index.test.tsx diff --git a/.buildkite/scripts/steps/storybooks/build_and_upload.ts b/.buildkite/scripts/steps/storybooks/build_and_upload.ts index 949cb0a0ff53495..b16e75abdb8a169 100644 --- a/.buildkite/scripts/steps/storybooks/build_and_upload.ts +++ b/.buildkite/scripts/steps/storybooks/build_and_upload.ts @@ -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', diff --git a/packages/kbn-securitysolution-grouping/.storybook/main.js b/packages/kbn-securitysolution-grouping/.storybook/main.js new file mode 100644 index 000000000000000..8dc3c5d1518f4d8 --- /dev/null +++ b/packages/kbn-securitysolution-grouping/.storybook/main.js @@ -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; diff --git a/packages/kbn-securitysolution-grouping/README.md b/packages/kbn-securitysolution-grouping/README.md deleted file mode 100644 index 87b8047720a3782..000000000000000 --- a/packages/kbn-securitysolution-grouping/README.md +++ /dev/null @@ -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 diff --git a/packages/kbn-securitysolution-grouping/README.mdx b/packages/kbn-securitysolution-grouping/README.mdx new file mode 100644 index 000000000000000..b79cac381c2987e --- /dev/null +++ b/packages/kbn-securitysolution-grouping/README.mdx @@ -0,0 +1,3 @@ +# @kbn/securitysolution-grouping + +Grouping component and query. Currently only consumed by security solution alerts table. diff --git a/packages/kbn-securitysolution-grouping/index.tsx b/packages/kbn-securitysolution-grouping/index.tsx index 92d69af316e1fdb..1b83c314714b7f1 100644 --- a/packages/kbn-securitysolution-grouping/index.tsx +++ b/packages/kbn-securitysolution-grouping/index.tsx @@ -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, diff --git a/packages/kbn-securitysolution-grouping/src/components/accordion_panel/group_stats.test.tsx b/packages/kbn-securitysolution-grouping/src/components/accordion_panel/group_stats.test.tsx index 8df4c6ad6c7dcec..8ccc1c912b62de1 100644 --- a/packages/kbn-securitysolution-grouping/src/components/accordion_panel/group_stats.test.tsx +++ b/packages/kbn-securitysolution-grouping/src/components/accordion_panel/group_stats.test.tsx @@ -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: () => [

,

, ], diff --git a/packages/kbn-securitysolution-grouping/src/components/accordion_panel/group_stats.tsx b/packages/kbn-securitysolution-grouping/src/components/accordion_panel/group_stats.tsx index 00c6e7aa3a855d9..61f40982507b852 100644 --- a/packages/kbn-securitysolution-grouping/src/components/accordion_panel/group_stats.tsx +++ b/packages/kbn-securitysolution-grouping/src/components/accordion_panel/group_stats.tsx @@ -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 { bucketKey: string; - statRenderers?: StatRenderer[]; + groupFilter: Filter[]; + groupNumber: number; onTakeActionsOpen?: () => void; - takeActionItems: JSX.Element[]; + statRenderers?: StatRenderer[]; + takeActionItems: (groupFilters: Filter[], groupNumber: number) => JSX.Element[]; } const GroupStatsComponent = ({ bucketKey, - statRenderers, + groupFilter, + groupNumber, onTakeActionsOpen, - takeActionItems, + statRenderers, + takeActionItems: getTakeActionItems, }: GroupStatsProps) => { const [isPopoverOpen, setPopover] = useState(false); + const [takeActionItems, setTakeActionItems] = useState([]); - 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( () => diff --git a/packages/kbn-securitysolution-grouping/src/components/accordion_panel/index.test.tsx b/packages/kbn-securitysolution-grouping/src/components/accordion_panel/index.test.tsx index 9aa4b514371308d..828e1059471e916 100644 --- a/packages/kbn-securitysolution-grouping/src/components/accordion_panel/index.test.tsx +++ b/packages/kbn-securitysolution-grouping/src/components/accordion_panel/index.test.tsx @@ -55,6 +55,7 @@ const testProps = { }, renderChildComponent, selectedGroup: 'kibana.alert.rule.name', + onGroupClose: () => {}, }; describe('grouping accordion panel', () => { diff --git a/packages/kbn-securitysolution-grouping/src/components/accordion_panel/index.tsx b/packages/kbn-securitysolution-grouping/src/components/accordion_panel/index.tsx index 286cb18ffb6e69b..c1d55495cf785cf 100644 --- a/packages/kbn-securitysolution-grouping/src/components/accordion_panel/index.tsx +++ b/packages/kbn-securitysolution-grouping/src/components/accordion_panel/index.tsx @@ -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 { forceState?: 'open' | 'closed'; groupBucket: RawBucket; groupPanelRenderer?: JSX.Element; + groupingLevel?: number; isLoading: boolean; - level?: number; + onGroupClose: () => void; onToggleGroup?: (isOpen: boolean, groupBucket: RawBucket) => void; renderChildComponent: (groupFilter: Filter[]) => React.ReactElement; selectedGroup: string; @@ -40,18 +41,30 @@ const DefaultGroupPanelRenderer = ({ title }: { title: string }) => ( ); const GroupPanelComponent = ({ - customAccordionButtonClassName = 'groupingAccordionForm__button', + customAccordionButtonClassName, customAccordionClassName = 'groupingAccordionForm', extraAction, forceState, groupBucket, groupPanelRenderer, + groupingLevel = 0, isLoading, - level = 0, + onGroupClose, onToggleGroup, renderChildComponent, selectedGroup, }: GroupPanelProps) => { + 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 = ({ +

{groupPanelRenderer ?? }
} - 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)} + {renderChildComponent(groupFilters)} ); }; diff --git a/packages/kbn-securitysolution-grouping/src/components/group_selector/index.test.tsx b/packages/kbn-securitysolution-grouping/src/components/group_selector/index.test.tsx index daa58396df70b60..2172390f41e855b 100644 --- a/packages/kbn-securitysolution-grouping/src/components/group_selector/index.test.tsx +++ b/packages/kbn-securitysolution-grouping/src/components/group_selector/index.test.tsx @@ -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( + + ); + expect(getByTestId('group-selector-dropdown').title).toEqual('Rule name, User name, Host name'); + rerender( + + ); + expect(getByTestId('group-selector-dropdown').title).toEqual('Rule name, Host name, User name'); + }); + it('Labels button with selection not in options', () => { + const { getByTestId } = render( + + ); + expect(getByTestId('group-selector-dropdown').title).toEqual('Rule name, Host name'); + }); + it('Labels button when `none` is selected', () => { + const { getByTestId } = render( + + ); + expect(getByTestId('group-selector-dropdown').title).toEqual('Rule name, Host name'); + }); }); diff --git a/packages/kbn-securitysolution-grouping/src/components/group_selector/index.tsx b/packages/kbn-securitysolution-grouping/src/components/group_selector/index.tsx index f0274f7c73ab712..a2a876da9799217 100644 --- a/packages/kbn-securitysolution-grouping/src/components/group_selector/index.tsx +++ b/packages/kbn-securitysolution-grouping/src/components/group_selector/index.tsx @@ -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 }} > - {i18n.SELECT_FIELD.toUpperCase()} + {i18n.SELECT_FIELD(maxGroupingLevels)} onGroupChange('none'), }, ...options.map((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 ( 0 - ? selectedOption[0].label - : i18n.NONE - } + title={buttonLabel} size="xs" > - {`${title}: ${ - groupSelected !== 'none' && selectedOption.length > 0 - ? selectedOption[0].label - : i18n.NONE - }`} + {`${title}: ${buttonLabel}`} - ), - [groupSelected, onButtonClick, selectedOption, title] - ); + ); + }, [groupsSelected, isGroupSelected, onButtonClick, selectedOptions, title]); return (

{'child component'}

, + onGroupClose: () => {}, + selectedGroup: 'kibana.alert.rule.name', + takeActionItems: () => [ + {}}> + {'Mark as acknowledged'} + , + {}}> + {'Mark as closed'} + , + ], + tracker: () => {}, +}; diff --git a/packages/kbn-securitysolution-grouping/src/components/grouping.stories.tsx b/packages/kbn-securitysolution-grouping/src/components/grouping.stories.tsx new file mode 100644 index 000000000000000..b961402ee3a7cb3 --- /dev/null +++ b/packages/kbn-securitysolution-grouping/src/components/grouping.stories.tsx @@ -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 = () => { + return ; +}; diff --git a/packages/kbn-securitysolution-grouping/src/components/grouping.test.tsx b/packages/kbn-securitysolution-grouping/src/components/grouping.test.tsx index ff5a7d66a6042bd..2376614ab444c5e 100644 --- a/packages/kbn-securitysolution-grouping/src/components/grouping.test.tsx +++ b/packages/kbn-securitysolution-grouping/src/components/grouping.test.tsx @@ -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, }; diff --git a/packages/kbn-securitysolution-grouping/src/components/grouping.tsx b/packages/kbn-securitysolution-grouping/src/components/grouping.tsx index d77f1fe1a810613..625beda320d0492 100644 --- a/packages/kbn-securitysolution-grouping/src/components/grouping.tsx +++ b/packages/kbn-securitysolution-grouping/src/components/grouping.tsx @@ -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 { - data?: GroupingAggregation & GroupingFieldTotalAggregation; - groupingId: string; + activePage: number; + data?: GroupingAggregation; groupPanelRenderer?: GroupPanelRenderer; groupSelector?: JSX.Element; // list of custom UI components which correspond to your custom rendered metrics aggregations groupStatsRenderer?: GroupStatsRenderer; + 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 { } const GroupingComponent = ({ + activePage, data, - groupingId, groupPanelRenderer, groupSelector, groupStatsRenderer, + groupingId, + groupingLevel = 0, inspectButton, isLoading, + itemsPerPage, + onChangeGroupsItemsPerPage, + onChangeGroupsPage, + onGroupClose, onGroupToggle, - pagination, renderChildComponent, selectedGroup, takeActionItems, tracker, unit = defaultUnit, }: GroupingProps) => { - const [trigger, setTrigger] = useState< - Record }> - >({}); + const [trigger, setTrigger] = useState>( + {} + ); const unitCount = data?.unitsCount?.value ?? 0; const unitCountText = useMemo(() => { @@ -100,16 +99,16 @@ const GroupingComponent = ({ return ( } forceState={(trigger[groupKey] && trigger[groupKey].state) ?? 'closed'} @@ -128,7 +127,6 @@ const GroupingComponent = ({ // ...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 = ({ : () => } selectedGroup={selectedGroup} + groupingLevel={groupingLevel} /> - + {groupingLevel > 0 ? null : } ); }), @@ -149,7 +148,9 @@ const GroupingComponent = ({ groupPanelRenderer, groupStatsRenderer, groupingId, + groupingLevel, isLoading, + onGroupClose, onGroupToggle, renderChildComponent, selectedGroup, @@ -159,58 +160,76 @@ const GroupingComponent = ({ ] ); const pageCount = useMemo( - () => (groupCount && pagination.pageSize ? Math.ceil(groupCount / pagination.pageSize) : 1), - [groupCount, pagination.pageSize] + () => (groupCount ? Math.ceil(groupCount / itemsPerPage) : 1), + [groupCount, itemsPerPage] ); + return ( <> - - - {groupCount > 0 && unitCount > 0 ? ( - - - - {unitCountText} - - - - - {groupCountText} - - + {groupingLevel > 0 ? null : ( + + + {groupCount > 0 && unitCount > 0 ? ( + + + + {unitCountText} + + + + + {groupCountText} + + + + ) : null} + + + + {inspectButton && {inspectButton}} + {groupSelector} - ) : null} - - - - {inspectButton && {inspectButton}} - {groupSelector} - - - -
+ + + )} +
0 ? groupingContainerCssLevel : groupingContainerCss} + className="eui-xScroll" + > {isLoading && ( )} {groupCount > 0 ? ( <> {groupPanels} - - + {groupCount > 0 && ( + <> + + { + if (onChangeGroupsItemsPerPage) { + onChangeGroupsItemsPerPage(pageSize); + } + }} + onChangePage={(pageIndex: number) => { + if (onChangeGroupsPage) { + onChangeGroupsPage(pageIndex); + } + }} + pageCount={pageCount} + showPerPageOptions + /> + + )} ) : ( diff --git a/packages/kbn-securitysolution-grouping/src/components/index.tsx b/packages/kbn-securitysolution-grouping/src/components/index.tsx index c924da988b04e41..0d759c0be48bea0 100644 --- a/packages/kbn-securitysolution-grouping/src/components/index.tsx +++ b/packages/kbn-securitysolution-grouping/src/components/index.tsx @@ -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); diff --git a/packages/kbn-securitysolution-grouping/src/components/styles.tsx b/packages/kbn-securitysolution-grouping/src/components/styles.tsx index abf3d83db508e14..fcd2e4a61aaee91 100644 --- a/packages/kbn-securitysolution-grouping/src/components/styles.tsx +++ b/packages/kbn-securitysolution-grouping/src/components/styles.tsx @@ -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; diff --git a/packages/kbn-securitysolution-grouping/src/components/translations.ts b/packages/kbn-securitysolution-grouping/src/components/translations.ts index e3896d25b910f78..f2cb8a172dbdc64 100644 --- a/packages/kbn-securitysolution-grouping/src/components/translations.ts +++ b/packages/kbn-securitysolution-grouping/src/components/translations.ts @@ -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', diff --git a/packages/kbn-securitysolution-grouping/src/components/types.ts b/packages/kbn-securitysolution-grouping/src/components/types.ts index 8956056581cc65a..cf5f55f5c27f3eb 100644 --- a/packages/kbn-securitysolution-grouping/src/components/types.ts +++ b/packages/kbn-securitysolution-grouping/src/components/types.ts @@ -19,7 +19,7 @@ export type RawBucket = GenericBuckets & T; /** Defines the shape of the aggregation returned by Elasticsearch */ // TODO: write developer docs for these fields -export interface GroupingAggregation { +export interface RootAggregation { groupByFields?: { buckets?: Array>; }; @@ -39,6 +39,8 @@ export type GroupingFieldTotalAggregation = Record< } >; +export type GroupingAggregation = RootAggregation & GroupingFieldTotalAggregation; + 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'; diff --git a/packages/kbn-securitysolution-grouping/src/containers/query/index.ts b/packages/kbn-securitysolution-grouping/src/containers/query/index.ts index 986788bf0dfa0b4..23699c1ccf94a90 100644 --- a/packages/kbn-securitysolution-grouping/src/containers/query/index.ts +++ b/packages/kbn-securitysolution-grouping/src/containers/query/index.ts @@ -35,10 +35,10 @@ export const getGroupingQuery = ({ additionalFilters = [], from, groupByFields, - pageNumber, rootAggregations, runtimeMappings, size = DEFAULT_GROUP_BY_FIELD_SIZE, + pageNumber, sort, statsAggregations, to, diff --git a/packages/kbn-securitysolution-grouping/src/containers/query/types.ts b/packages/kbn-securitysolution-grouping/src/containers/query/types.ts index c56e26223550a26..5a8b2f822fb5c89 100644 --- a/packages/kbn-securitysolution-grouping/src/containers/query/types.ts +++ b/packages/kbn-securitysolution-grouping/src/containers/query/types.ts @@ -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[]; diff --git a/packages/kbn-securitysolution-grouping/src/hooks/state/actions.ts b/packages/kbn-securitysolution-grouping/src/hooks/state/actions.ts index 0953e6872ee91ce..e1bdb08500fa849 100644 --- a/packages/kbn-securitysolution-grouping/src/hooks/state/actions.ts +++ b/packages/kbn-securitysolution-grouping/src/hooks/state/actions.ts @@ -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, }; diff --git a/packages/kbn-securitysolution-grouping/src/hooks/state/reducer.test.ts b/packages/kbn-securitysolution-grouping/src/hooks/state/reducer.test.ts index 5348731d391285c..5a1b4112df3aa9f 100644 --- a/packages/kbn-securitysolution-grouping/src/hooks/state/reducer.test.ts +++ b/packages/kbn-securitysolution-grouping/src/hooks/state/reducer.test.ts @@ -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']); }); }); diff --git a/packages/kbn-securitysolution-grouping/src/hooks/state/reducer.ts b/packages/kbn-securitysolution-grouping/src/hooks/state/reducer.ts index 287227b5763b325..d59637b69defe28 100644 --- a/packages/kbn-securitysolution-grouping/src/hooks/state/reducer.ts +++ b/packages/kbn-securitysolution-grouping/src/hooks/state/reducer.ts @@ -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); diff --git a/packages/kbn-securitysolution-grouping/src/hooks/types.ts b/packages/kbn-securitysolution-grouping/src/hooks/types.ts index 4b5480794b30ddd..5c3e85d211eaf88 100644 --- a/packages/kbn-securitysolution-grouping/src/hooks/types.ts +++ b/packages/kbn-securitysolution-grouping/src/hooks/types.ts @@ -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 { export const EMPTY_GROUP_BY_ID: GroupsById = {}; export const defaultGroup: GroupModel = { - activePage: 0, - itemsPerPage: 25, - activeGroup: 'none', + activeGroups: ['none'], options: [], }; diff --git a/packages/kbn-securitysolution-grouping/src/hooks/use_get_group_selector.test.tsx b/packages/kbn-securitysolution-grouping/src/hooks/use_get_group_selector.test.tsx index d29313b36e518df..d741e7d15e67035 100644 --- a/packages/kbn-securitysolution-grouping/src/hooks/use_get_group_selector.test.tsx +++ b/packages/kbn-securitysolution-grouping/src/hooks/use_get_group_selector.test.tsx @@ -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,15 +89,22 @@ 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, resets active page, sets active group, and leaves options alone', () => { + it('On group change to none, remove all previously selected groups', () => { const testGroup = { [groupingId]: { ...defaultGroup, options: defaultGroupingOptions, - activeGroup: 'host.name', + activeGroups: ['host.name', 'user.name'], }, }; const { result } = renderHook((props) => useGetGroupSelector(props), { @@ -108,22 +115,43 @@ describe('useGetGroupSelector', () => { }, }, }); - act(() => result.current.props.onGroupChange('user.name')); - expect(dispatch).toHaveBeenNthCalledWith(1, { + act(() => result.current.props.onGroupChange('none')); + + expect(dispatch).toHaveBeenCalledWith({ payload: { id: groupingId, - activePage: 0, + activeGroups: ['none'], }, - type: ActionType.updateGroupActivePage, + type: ActionType.updateActiveGroups, }); - expect(dispatch).toHaveBeenNthCalledWith(2, { + }); + + it('On group change, resets active page, sets active group, and leaves options alone', () => { + const testGroup = { + [groupingId]: { + ...defaultGroup, + options: defaultGroupingOptions, + activeGroups: ['host.name'], + }, + }; + const { result } = renderHook((props) => useGetGroupSelector(props), { + initialProps: { + ...defaultArgs, + groupingState: { + groupById: testGroup, + }, + }, + }); + act(() => result.current.props.onGroupChange('user.name')); + + expect(dispatch).toHaveBeenNthCalledWith(1, { payload: { id: groupingId, - activeGroup: 'user.name', + activeGroups: ['host.name', 'user.name'], }, - type: ActionType.updateActiveGroup, + type: ActionType.updateActiveGroups, }); - 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, }); diff --git a/packages/kbn-securitysolution-grouping/src/hooks/use_get_group_selector.tsx b/packages/kbn-securitysolution-grouping/src/hooks/use_get_group_selector.tsx index e3b1c45b2733d68..05920beb37a47dd 100644 --- a/packages/kbn-securitysolution-grouping/src/hooks/use_get_group_selector.tsx +++ b/packages/kbn-securitysolution-grouping/src/hooks/use_get_group_selector.tsx @@ -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 ( diff --git a/packages/kbn-securitysolution-grouping/src/hooks/use_grouping.test.tsx b/packages/kbn-securitysolution-grouping/src/hooks/use_grouping.test.tsx index a2a9eeec8bf2042..d95ae866d2ee9e6 100644 --- a/packages/kbn-securitysolution-grouping/src/hooks/use_grouping.test.tsx +++ b/packages/kbn-securitysolution-grouping/src/hooks/use_grouping.test.tsx @@ -30,7 +30,6 @@ const defaultArgs = { groupStatsRenderer: jest.fn(), inspectButton: <>, onGroupToggle: jest.fn(), - renderChildComponent: () =>

{'hello'}

, }, }; @@ -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: () =>

{'hello'}

, + selectedGroup: 'none', })} ); @@ -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( {result.current.getGrouping({ ...groupingArgs, @@ -119,12 +123,13 @@ describe('useGrouping', () => { value: 18, }, }, + renderChildComponent: jest.fn(), + selectedGroup: 'test', })} ); expect(getByTestId('grouping-table')).toBeInTheDocument(); - expect(queryByTestId('innerTable')).not.toBeInTheDocument(); }); }); }); diff --git a/packages/kbn-securitysolution-grouping/src/hooks/use_grouping.tsx b/packages/kbn-securitysolution-grouping/src/hooks/use_grouping.tsx index 993809943252f97..5833ae8205d5963 100644 --- a/packages/kbn-securitysolution-grouping/src/hooks/use_grouping.tsx +++ b/packages/kbn-securitysolution-grouping/src/hooks/use_grouping.tsx @@ -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 { getGrouping: (props: DynamicGroupingProps) => React.ReactElement; groupSelector: React.ReactElement; - 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 */ type StaticGroupingProps = Pick< GroupingProps, - | '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 */ -type DynamicGroupingProps = Pick, 'data' | 'isLoading' | 'takeActionItems'>; +export type DynamicGroupingProps = Pick< + GroupingProps, + | '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 */ interface GroupingArgs { @@ -57,6 +60,7 @@ interface GroupingArgs { 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 { * @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 = ({ componentProps, defaultGroupingOptions, fields, groupingId, + maxGroupingLevels, onGroupChange, tracker, }: GroupingArgs): Grouping => { 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 = ({ fields, groupingId, groupingState, + maxGroupingLevels, onGroupChange, tracker, }); - const pagination = useGroupingPagination({ groupingId, groupingState, dispatch }); - const getGrouping = useCallback( /** * * @param props {@link DynamicGroupingProps} */ (props: DynamicGroupingProps): React.ReactElement => - isNoneGroup(selectedGroup) ? ( - componentProps.renderChildComponent([]) + isNoneGroup([props.selectedGroup]) ? ( + props.renderChildComponent([]) ) : ( ), - [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] ); }; diff --git a/packages/kbn-securitysolution-grouping/src/hooks/use_grouping_pagination.ts b/packages/kbn-securitysolution-grouping/src/hooks/use_grouping_pagination.ts deleted file mode 100644 index 9aa07458aaf6fc3..000000000000000 --- a/packages/kbn-securitysolution-grouping/src/hooks/use_grouping_pagination.ts +++ /dev/null @@ -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; - 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] - ); -}; diff --git a/packages/kbn-securitysolution-grouping/tsconfig.json b/packages/kbn-securitysolution-grouping/tsconfig.json index ab98ec47e3c93d3..621ba68957cf1f3 100644 --- a/packages/kbn-securitysolution-grouping/tsconfig.json +++ b/packages/kbn-securitysolution-grouping/tsconfig.json @@ -6,7 +6,8 @@ "jest", "node", "react", - "@emotion/react/types/css-prop" + "@emotion/react/types/css-prop", + "@kbn/ambient-ui-types" ] }, "include": [ diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index da762f8f5572598..c612ef05d4b7ac6 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -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', diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap_panel/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_treemap_panel/index.test.tsx index 5a3f4b3e25e0ed1..07342c4f60691eb 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap_panel/index.test.tsx @@ -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( - - - - ); - - await waitFor(() => expect(useGlobalTime).toBeCalledWith(false)); - }); - it('renders the panel with a hidden overflow-x', async () => { render( diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap_panel/index.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_treemap_panel/index.tsx index a3ba582b3c974ab..33e526932c3e6d3 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap_panel/index.tsx @@ -80,7 +80,7 @@ const AlertsTreemapPanelComponent: React.FC = ({ 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()}`, []); diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/index.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/index.tsx index e1b5f31f2d3b586..0113e0e584b54b7 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/index.tsx @@ -110,7 +110,7 @@ const StatefulTopNComponent: React.FC = ({ 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); diff --git a/x-pack/plugins/security_solution/public/common/containers/alerts/use_alert_prevalence.test.ts b/x-pack/plugins/security_solution/public/common/containers/alerts/use_alert_prevalence.test.ts deleted file mode 100644 index b1a21d9ae492d54..000000000000000 --- a/x-pack/plugins/security_solution/public/common/containers/alerts/use_alert_prevalence.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/containers/alerts/use_alert_prevalence.ts b/x-pack/plugins/security_solution/public/common/containers/alerts/use_alert_prevalence.ts index 03ac3d616935107..3e98e067bfe2dd0 100644 --- a/x-pack/plugins/security_solution/public/common/containers/alerts/use_alert_prevalence.ts +++ b/x-pack/plugins/security_solution/public/common/containers/alerts/use_alert_prevalence.ts @@ -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) { diff --git a/x-pack/plugins/security_solution/public/common/containers/use_global_time/index.test.tsx b/x-pack/plugins/security_solution/public/common/containers/use_global_time/index.test.tsx index 480ecdb3674fff2..46a2738d6247a1c 100644 --- a/x-pack/plugins/security_solution/public/common/containers/use_global_time/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/use_global_time/index.test.tsx @@ -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' + ); }); }); diff --git a/x-pack/plugins/security_solution/public/common/containers/use_global_time/index.tsx b/x-pack/plugins/security_solution/public/common/containers/use_global_time/index.tsx index dbb57d57c3e6e4f..76cd23c8efba05c 100644 --- a/x-pack/plugins/security_solution/public/common/containers/use_global_time/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/use_global_time/index.tsx @@ -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([]); + 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, 'deleteQuery'> & diff --git a/x-pack/plugins/security_solution/public/common/store/grouping/actions.ts b/x-pack/plugins/security_solution/public/common/store/grouping/actions.ts index a61186aeb0f8f1b..d78be8b03cb4d52 100644 --- a/x-pack/plugins/security_solution/public/common/store/grouping/actions.ts +++ b/x-pack/plugins/security_solution/public/common/store/grouping/actions.ts @@ -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'); diff --git a/x-pack/plugins/security_solution/public/common/store/grouping/reducer.ts b/x-pack/plugins/security_solution/public/common/store/grouping/reducer.ts index aaea793e4ca86d9..6914e4ad465fee2 100644 --- a/x-pack/plugins/security_solution/public/common/store/grouping/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/store/grouping/reducer.ts @@ -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, - })); + }) +); diff --git a/x-pack/plugins/security_solution/public/common/store/grouping/selectors.ts b/x-pack/plugins/security_solution/public/common/store/grouping/selectors.ts index eb63e256a4d9f06..126fdac8c1b36fc 100644 --- a/x-pack/plugins/security_solution/public/common/store/grouping/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/store/grouping/selectors.ts @@ -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); diff --git a/x-pack/plugins/security_solution/public/common/store/grouping/types.ts b/x-pack/plugins/security_solution/public/common/store/grouping/types.ts index 7d8fd4bc3eecab4..d2250b15722edd9 100644 --- a/x-pack/plugins/security_solution/public/common/store/grouping/types.ts +++ b/x-pack/plugins/security_solution/public/common/store/grouping/types.ts @@ -7,7 +7,6 @@ export interface GroupModel { groupSelector: React.ReactElement | null; - selectedGroup: string | null; } export interface GroupState { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx index 6e1b4fddbd167f1..90f1d38f69774c1 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx @@ -852,7 +852,7 @@ const RuleDetailsPageComponent: React.FC = ({ {ruleId != null && ( { }); }); - it('invokes useGlobalTime() with false to prevent global queries from being deleted when the component unmounts', async () => { - await act(async () => { - mount( - - - - ); - - expect(useGlobalTime).toBeCalledWith(false); - }); - }); - it('renders with the specified `alignHeader` alignment', async () => { await act(async () => { const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx index 26eb4522ed6171e..f9967a44ffb6e14 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx @@ -84,7 +84,7 @@ export const AlertsCountPanel = memo( 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 diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx index d1cb85cdf456446..e1945ca151cd8ba 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx @@ -152,7 +152,7 @@ export const AlertsHistogramPanel = memo( 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()}`, []); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data.tsx index fb5024d3c2e505e..e8d0ddd061e81f1 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data.tsx @@ -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([]); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_grouping.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_grouping.test.tsx new file mode 100644 index 000000000000000..4a57d7cac8e7375 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_grouping.test.tsx @@ -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[]) =>

; + +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; + + 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( + + + + ); + + 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( + + + + ); + 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( + + + + ); + 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( + + + + ); + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + [ + 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( + + + + ); + + 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); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_grouping.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_grouping.tsx index e5868970f6768c2..99ae3f4ff3433ad 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_grouping.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_grouping.tsx @@ -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 = ({ - defaultFilters = [], - from, - globalFilters, - globalQuery, - hasIndexMaintenance, - hasIndexWrite, - loading, - tableId, - to, - runtimeMappings, - signalIndexName, - currentAlertStatusFilterValue, - renderChildComponent, -}) => { - const dispatch = useDispatch(); +const DEFAULT_PAGE_SIZE = 25; +const DEFAULT_PAGE_INDEX = 0; +const MAX_GROUPING_LEVELS = 3; - const { browserFields, indexPattern, selectedPatterns } = useSourcererDataView( - SourcererScopeName.detections +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 = (props) => { + const dispatch = useDispatch(); + + 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 = [telemetry] ); - // create a unique, but stable (across re-renders) query id - const uniqueQueryId = useMemo(() => `${ALERTS_GROUPING_ID}-${uuidv4()}`, []); - - const inspect = useMemo( - () => ( - - ), - [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; - useEffect(() => { - dispatch(updateGroupSelector({ groupSelector })); - }, [dispatch, groupSelector]); + const getGroupSelector = groupSelectors.getGroupSelector(); - useEffect(() => { - dispatch(updateSelectedGroup({ selectedGroup })); - }, [dispatch, selectedGroup]); - - useInvalidFilterQuery({ - id: tableId, - filterQuery: getGlobalQuery([])?.filterQuery, - kqlError: getGlobalQuery([])?.kqlError, - query: globalQuery, - startDate: from, - endDate: to, - }); + const groupSelectorInRedux = useSelector((state: State) => getGroupSelector(state)); + const selectorOptions = useRef([]); - 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 []; + useEffect(() => { + if ( + isNoneGroup(selectedGroups) && + groupSelector.props.options.length > 0 && + (groupSelectorInRedux == null || + !isEqual(selectorOptions.current, groupSelector.props.options)) + ) { + selectorOptions.current = groupSelector.props.options; + dispatch(updateGroupSelector({ groupSelector })); + } else if (!isNoneGroup(selectedGroups) && groupSelectorInRedux !== null) { + dispatch(updateGroupSelector({ groupSelector: null })); } - }, [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( + Array(MAX_GROUPING_LEVELS).fill(DEFAULT_PAGE_INDEX) ); + const [pageSize, setPageSize] = useState(getStoragePageSize); - const { - data: alertsGroupsData, - loading: isLoadingGroups, - refetch, - request, - response, - setQuery: setAlertsQuery, - } = useQueryAlerts< - {}, - GroupingAggregation & - GroupingFieldTotalAggregation - >({ - query: queryGroups, - indexName: signalIndexName, - queryName: ALERTS_QUERY_NAMES.ALERTS_GROUPING, - skip: isNoneGroup(selectedGroup), - }); + const resetAllPagination = useCallback(() => { + setPageIndex((curr) => curr.map(() => DEFAULT_PAGE_INDEX)); + }, []); useEffect(() => { - if (!isNoneGroup(selectedGroup)) { - setAlertsQuery(queryGroups); - } - }, [queryGroups, selectedGroup, setAlertsQuery]); + 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; + }); + } - useInspectButton({ - deleteQuery, - loading: isLoadingGroups, - response, - setQuery, - refetch, - request, - uniqueQueryId, - }); + if (pageType === 'size') { + setPageSize((currentIndex) => { + const newArr = [...currentIndex]; + newArr[groupingLevel] = newNumber; + setStoragePageSize(newArr); + return newArr; + }); + } + }, + [setStoragePageSize] + ); - const takeActionItems = useGroupTakeActionsItems({ - indexName: indexPattern.title, - currentStatus: currentAlertStatusFilterValue, - showAlertStatusActions: hasIndexWrite && hasIndexMaintenance, + const nonGroupingFilters = useRef({ + defaultFilters: props.defaultFilters, + globalFilters: props.globalFilters, + globalQuery: props.globalQuery, }); - const getTakeActionItems = useCallback( - (groupFilters: Filter[], groupNumber: number) => - takeActionItems({ - query: getGlobalQuery([...(defaultFilters ?? []), ...groupFilters])?.filterQuery, - tableId, - groupNumber, - selectedGroup, - }), - [defaultFilters, getGlobalQuery, selectedGroup, tableId, takeActionItems] - ); + useEffect(() => { + const nonGrouping = { + defaultFilters: props.defaultFilters, + globalFilters: props.globalFilters, + globalQuery: props.globalQuery, + }; + if (!isEqual(nonGroupingFilters.current, nonGrouping)) { + resetAllPagination(); + nonGroupingFilters.current = nonGrouping; + } + }, [props.defaultFilters, props.globalFilters, props.globalQuery, resetAllPagination]); + + 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 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 ( + 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); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_sub_grouping.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_sub_grouping.tsx new file mode 100644 index 000000000000000..cf8a8128cb166fc --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_sub_grouping.tsx @@ -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, '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 = ({ + 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>({ + 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( + () => ( + + ), + [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); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/group_take_action_items.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/group_take_action_items.test.tsx index f84305dcb3b3795..d663b2abc2c6114 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/group_take_action_items.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/group_take_action_items.test.tsx @@ -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); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/group_take_action_items.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/group_take_action_items.tsx index d2baadb99d12498..5d151d2e4cc8865 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/group_take_action_items.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/group_take_action_items.tsx @@ -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( - - onClickUpdate({ - groupNumber, - query, - selectedGroup, - status: FILTER_OPEN as AlertWorkflowStatus, - tableId, - }) - } - > - {BULK_ACTION_OPEN_SELECTED} - - ); - } - if (currentStatus !== FILTER_ACKNOWLEDGED) { - actionItems.push( - - onClickUpdate({ - groupNumber, - query, - selectedGroup, - status: FILTER_ACKNOWLEDGED as AlertWorkflowStatus, - tableId, - }) - } - > - {BULK_ACTION_ACKNOWLEDGED_SELECTED} - - ); - } - if (currentStatus !== FILTER_CLOSED) { - actionItems.push( - - onClickUpdate({ - groupNumber, - query, - selectedGroup, - status: FILTER_CLOSED as AlertWorkflowStatus, - tableId, - }) - } - > - {BULK_ACTION_CLOSE_SELECTED} - + if (currentStatus && currentStatus.length === 1) { + const singleStatus = currentStatus[0]; + if (singleStatus !== FILTER_OPEN) { + actionItems.push( + + onClickUpdate({ + groupNumber, + query, + selectedGroup, + status: FILTER_OPEN as AlertWorkflowStatus, + tableId, + }) + } + > + {BULK_ACTION_OPEN_SELECTED} + + ); + } + if (singleStatus !== FILTER_ACKNOWLEDGED) { + actionItems.push( + + onClickUpdate({ + groupNumber, + query, + selectedGroup, + status: FILTER_ACKNOWLEDGED as AlertWorkflowStatus, + tableId, + }) + } + > + {BULK_ACTION_ACKNOWLEDGED_SELECTED} + + ); + } + if (singleStatus !== FILTER_CLOSED) { + actionItems.push( + + onClickUpdate({ + groupNumber, + query, + selectedGroup, + status: FILTER_CLOSED as AlertWorkflowStatus, + tableId, + }) + } + > + {BULK_ACTION_CLOSE_SELECTED} + + ); + } + } 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( + + onClickUpdate({ + groupNumber, + query, + selectedGroup, + status: workflowStatus as AlertWorkflowStatus, + tableId, + }) + } + > + {statusArr[workflowStatus]} + + ) ); } } @@ -259,6 +288,4 @@ export const useGroupTakeActionsItems = ({ return getActionItems; }, [currentStatus, onClickUpdate, showAlertStatusActions]); - - return items; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/mock.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/mock.ts new file mode 100644 index 000000000000000..9e0b4e63715aa96 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/mock.ts @@ -0,0 +1,1736 @@ +/* + * 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 { mockAlertSearchResponse } from '../../../../common/components/alerts_treemap/lib/mocks/mock_alert_search_response'; + +export const groupingSearchResponse = { + ruleName: { + ...mockAlertSearchResponse, + hits: { + total: { + value: 6048, + relation: 'eq', + }, + max_score: null, + hits: [], + }, + aggregations: { + groupsCount: { + value: 32, + }, + groupByFields: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: ['critical hosts [Duplicate]', 'f'], + key_as_string: 'critical hosts [Duplicate]|f', + doc_count: 300, + hostsCountAggregation: { + value: 30, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 300, + }, + { + key: 'rule', + doc_count: 300, + }, + ], + }, + unitsCount: { + value: 300, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 300, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: ['critical hosts [Duplicate] [Duplicate]', 'f'], + key_as_string: 'critical hosts [Duplicate] [Duplicate]|f', + doc_count: 300, + hostsCountAggregation: { + value: 30, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 300, + }, + { + key: 'rule', + doc_count: 300, + }, + ], + }, + unitsCount: { + value: 300, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 300, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: ['high hosts [Duplicate]', 'f'], + key_as_string: 'high hosts [Duplicate]|f', + doc_count: 300, + hostsCountAggregation: { + value: 30, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 300, + }, + { + key: 'rule', + doc_count: 300, + }, + ], + }, + unitsCount: { + value: 300, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'high', + doc_count: 300, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: ['high hosts [Duplicate] [Duplicate]', 'f'], + key_as_string: 'high hosts [Duplicate] [Duplicate]|f', + doc_count: 300, + hostsCountAggregation: { + value: 30, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 300, + }, + { + key: 'rule', + doc_count: 300, + }, + ], + }, + unitsCount: { + value: 300, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'high', + doc_count: 300, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: ['low hosts [Duplicate]', 'f'], + key_as_string: 'low hosts [Duplicate]|f', + doc_count: 300, + hostsCountAggregation: { + value: 30, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 300, + }, + { + key: 'rule', + doc_count: 300, + }, + ], + }, + unitsCount: { + value: 300, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'low', + doc_count: 300, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: ['low hosts [Duplicate] [Duplicate]', 'f'], + key_as_string: 'low hosts [Duplicate] [Duplicate]|f', + doc_count: 300, + hostsCountAggregation: { + value: 30, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 300, + }, + { + key: 'rule', + doc_count: 300, + }, + ], + }, + unitsCount: { + value: 300, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'low', + doc_count: 300, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: ['medium hosts [Duplicate]', 'f'], + key_as_string: 'medium hosts [Duplicate]|f', + doc_count: 300, + hostsCountAggregation: { + value: 30, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 300, + }, + { + key: 'rule', + doc_count: 300, + }, + ], + }, + unitsCount: { + value: 300, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'medium', + doc_count: 300, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: ['medium hosts [Duplicate] [Duplicate]', 'f'], + key_as_string: 'medium hosts [Duplicate] [Duplicate]|f', + doc_count: 300, + hostsCountAggregation: { + value: 30, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 300, + }, + { + key: 'rule', + doc_count: 300, + }, + ], + }, + unitsCount: { + value: 300, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'medium', + doc_count: 300, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: ['critical users [Duplicate]', 'f'], + key_as_string: 'critical users [Duplicate]|f', + doc_count: 273, + hostsCountAggregation: { + value: 10, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 273, + }, + { + key: 'rule', + doc_count: 273, + }, + ], + }, + unitsCount: { + value: 273, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 273, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 91, + }, + }, + { + key: ['critical users [Duplicate] [Duplicate]', 'f'], + key_as_string: 'critical users [Duplicate] [Duplicate]|f', + doc_count: 273, + hostsCountAggregation: { + value: 10, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 273, + }, + { + key: 'rule', + doc_count: 273, + }, + ], + }, + unitsCount: { + value: 273, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 273, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 91, + }, + }, + { + key: ['high users [Duplicate]', 'f'], + key_as_string: 'high users [Duplicate]|f', + doc_count: 273, + hostsCountAggregation: { + value: 10, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 273, + }, + { + key: 'rule', + doc_count: 273, + }, + ], + }, + unitsCount: { + value: 273, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'high', + doc_count: 273, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 91, + }, + }, + { + key: ['high users [Duplicate] [Duplicate]', 'f'], + key_as_string: 'high users [Duplicate] [Duplicate]|f', + doc_count: 273, + hostsCountAggregation: { + value: 10, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 273, + }, + { + key: 'rule', + doc_count: 273, + }, + ], + }, + unitsCount: { + value: 273, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'high', + doc_count: 273, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 91, + }, + }, + { + key: ['low users [Duplicate]', 'f'], + key_as_string: 'low users [Duplicate]|f', + doc_count: 273, + hostsCountAggregation: { + value: 10, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 273, + }, + { + key: 'rule', + doc_count: 273, + }, + ], + }, + unitsCount: { + value: 273, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'low', + doc_count: 273, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 91, + }, + }, + { + key: ['low users [Duplicate] [Duplicate]', 'f'], + key_as_string: 'low users [Duplicate] [Duplicate]|f', + doc_count: 273, + hostsCountAggregation: { + value: 10, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 273, + }, + { + key: 'rule', + doc_count: 273, + }, + ], + }, + unitsCount: { + value: 273, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'low', + doc_count: 273, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 91, + }, + }, + { + key: ['medium users [Duplicate]', 'f'], + key_as_string: 'medium users [Duplicate]|f', + doc_count: 273, + hostsCountAggregation: { + value: 10, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 273, + }, + { + key: 'rule', + doc_count: 273, + }, + ], + }, + unitsCount: { + value: 273, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'medium', + doc_count: 273, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 91, + }, + }, + { + key: ['medium users [Duplicate] [Duplicate]', 'f'], + key_as_string: 'medium users [Duplicate] [Duplicate]|f', + doc_count: 273, + hostsCountAggregation: { + value: 10, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 273, + }, + { + key: 'rule', + doc_count: 273, + }, + ], + }, + unitsCount: { + value: 273, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'medium', + doc_count: 273, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 91, + }, + }, + { + key: ['critical hosts', 'f'], + key_as_string: 'critical hosts|f', + doc_count: 100, + hostsCountAggregation: { + value: 30, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 100, + }, + { + key: 'rule', + doc_count: 100, + }, + ], + }, + unitsCount: { + value: 100, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 100, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: ['critical hosts [Duplicate] [Duplicate] [Duplicate]', 'f'], + key_as_string: 'critical hosts [Duplicate] [Duplicate] [Duplicate]|f', + doc_count: 100, + hostsCountAggregation: { + value: 30, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 100, + }, + { + key: 'rule', + doc_count: 100, + }, + ], + }, + unitsCount: { + value: 100, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 100, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: ['high hosts', 'f'], + key_as_string: 'high hosts|f', + doc_count: 100, + hostsCountAggregation: { + value: 30, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 100, + }, + { + key: 'rule', + doc_count: 100, + }, + ], + }, + unitsCount: { + value: 100, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'high', + doc_count: 100, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: ['high hosts [Duplicate] [Duplicate] [Duplicate]', 'f'], + key_as_string: 'high hosts [Duplicate] [Duplicate] [Duplicate]|f', + doc_count: 100, + hostsCountAggregation: { + value: 30, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 100, + }, + { + key: 'rule', + doc_count: 100, + }, + ], + }, + unitsCount: { + value: 100, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'high', + doc_count: 100, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: ['low hosts ', 'f'], + key_as_string: 'low hosts |f', + doc_count: 100, + hostsCountAggregation: { + value: 30, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 100, + }, + { + key: 'rule', + doc_count: 100, + }, + ], + }, + unitsCount: { + value: 100, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'low', + doc_count: 100, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: ['low hosts [Duplicate] [Duplicate] [Duplicate]', 'f'], + key_as_string: 'low hosts [Duplicate] [Duplicate] [Duplicate]|f', + doc_count: 100, + hostsCountAggregation: { + value: 30, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 100, + }, + { + key: 'rule', + doc_count: 100, + }, + ], + }, + unitsCount: { + value: 100, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'low', + doc_count: 100, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: ['medium hosts', 'f'], + key_as_string: 'medium hosts|f', + doc_count: 100, + hostsCountAggregation: { + value: 30, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 100, + }, + { + key: 'rule', + doc_count: 100, + }, + ], + }, + unitsCount: { + value: 100, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'medium', + doc_count: 100, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: ['medium hosts [Duplicate] [Duplicate] [Duplicate]', 'f'], + key_as_string: 'medium hosts [Duplicate] [Duplicate] [Duplicate]|f', + doc_count: 100, + hostsCountAggregation: { + value: 30, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 100, + }, + { + key: 'rule', + doc_count: 100, + }, + ], + }, + unitsCount: { + value: 100, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'medium', + doc_count: 100, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: ['critical users [Duplicate] [Duplicate] [Duplicate]', 'f'], + key_as_string: 'critical users [Duplicate] [Duplicate] [Duplicate]|f', + doc_count: 91, + hostsCountAggregation: { + value: 10, + }, + ruleTags: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cool', + doc_count: 91, + }, + { + key: 'rule', + doc_count: 91, + }, + ], + }, + unitsCount: { + value: 91, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 91, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 91, + }, + }, + ], + }, + unitsCount: { + value: 6048, + }, + }, + }, + hostName: { + ...mockAlertSearchResponse, + hits: { + total: { + value: 900, + relation: 'eq', + }, + max_score: null, + hits: [], + }, + aggregations: { + groupsCount: { + value: 40, + }, + groupByFields: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'Host-f0m6ngo8fo', + doc_count: 75, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 75, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 75, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 25, + }, + }, + { + key: 'Host-4aijlqggv8', + doc_count: 63, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 63, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 63, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 21, + }, + }, + { + key: 'Host-e50lhbdm91', + doc_count: 51, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 51, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 51, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 17, + }, + }, + { + key: 'sqp', + doc_count: 42, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 42, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 42, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: 'sUl', + doc_count: 33, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 33, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 33, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: 'vLJ', + doc_count: 30, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 30, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 30, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: 'Host-n28uwmsqmd', + doc_count: 27, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 27, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 27, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 9, + }, + }, + { + key: 'JaE', + doc_count: 27, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 27, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 27, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: 'CUA', + doc_count: 24, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 24, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 24, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: 'FWT', + doc_count: 24, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 24, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 24, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: 'ZqT', + doc_count: 24, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 24, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 24, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: 'mmn', + doc_count: 24, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 24, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 24, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: 'xRS', + doc_count: 24, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 24, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 24, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: 'HiC', + doc_count: 21, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 21, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 21, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: 'Host-d7zbfvl3zz', + doc_count: 21, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 21, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 21, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 7, + }, + }, + { + key: 'Nnc', + doc_count: 21, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 21, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 21, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: 'OqH', + doc_count: 21, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 21, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 21, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: 'Vaw', + doc_count: 21, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 21, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 21, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: 'XPg', + doc_count: 21, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 21, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 21, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: 'qBS', + doc_count: 21, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 21, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 21, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: 'rwt', + doc_count: 21, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 21, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 21, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: 'xVJ', + doc_count: 21, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 21, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 21, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: 'Bxg', + doc_count: 18, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 18, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 18, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: 'efP', + doc_count: 18, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 18, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 18, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + { + key: 'qcb', + doc_count: 18, + rulesCountAggregation: { + value: 3, + }, + unitsCount: { + value: 18, + }, + severitiesSubAggregation: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'critical', + doc_count: 18, + }, + ], + }, + countSeveritySubAggregation: { + value: 1, + }, + usersCountAggregation: { + value: 0, + }, + }, + ], + }, + unitsCount: { + value: 900, + }, + }, + }, +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/query_builder.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/query_builder.ts index 624b343c14cf95f..921a8d3e3d43f17 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/query_builder.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/query_builder.ts @@ -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 } } }] : []), ], diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.test.tsx deleted file mode 100644 index 346e4b51df72d97..000000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.test.tsx +++ /dev/null @@ -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[]) =>

; - -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(); - 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( - - - - ); - 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( - - - - ); - 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( - - - - ); - 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( - - - - ); - // called on initial query definition - expect(resetPagination).toHaveBeenCalledTimes(1); - rerender( - - - - ); - expect(resetPagination).toHaveBeenCalledTimes(2); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_persistent_controls.tsx b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_persistent_controls.tsx index 78d736e99c93ea8..92f77c3e2df9171 100644 --- a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_persistent_controls.tsx +++ b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_persistent_controls.tsx @@ -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 { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx index 64e489599dd05c1..13836e658eee729 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx @@ -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 = ({ + currentAlertStatusFilterValue, +}) => { + useEffect(() => { + if (currentAlertStatusFilterValue) { + mockStatusCapture(currentAlertStatusFilterValue); + } + }, [currentAlertStatusFilterValue]); + return ; +}; + +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 ; + }); + }); + beforeEach(() => { + jest.clearAllMocks(); }); - it('renders correctly', async () => { - const wrapper = mount( + const { getByTestId } = render( @@ -181,12 +212,12 @@ describe('DetectionEnginePageComponent', () => { ); 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( @@ -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 ; + }); + await waitFor(() => { + render( + + + + + + ); + }); + // 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 ; + }); + await waitFor(() => { + render( + + + + + + ); + }); + // 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 ; + }); + await waitFor(() => { + render( + + + + + + ); }); + // when statusFilter updates, we call mockStatusCapture in test mocks + expect(mockStatusCapture).toHaveBeenNthCalledWith(1, []); + expect(mockStatusCapture).toHaveBeenNthCalledWith(2, []); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx index 0eacecd46e8fcf8..1ccfaea4584ee44 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx @@ -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 = ({ const arePageFiltersEnabled = useIsExperimentalFeatureEnabled('alertsPageFiltersEnabled'); // when arePageFiltersEnabled === false - const [filterGroup, setFilterGroup] = useState(FILTER_OPEN); + const [statusFilter, setStatusFilter] = useState([]); const updatedAt = useShallowEqualSelector( (state) => (getTable(state, TableId.alertsOnAlertsPage) ?? tableDefaults).updated @@ -177,8 +176,8 @@ const DetectionEnginePageComponent: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ @@ -352,7 +364,7 @@ const DetectionEnginePageComponent: React.FC = ({ [ arePageFiltersEnabled, dataViewId, - filterGroup, + statusFilter, filters, onFilterGroupChangedCallback, pageFiltersUpdateHandler, @@ -462,7 +474,7 @@ const DetectionEnginePageComponent: React.FC = ({ { const [updatedAt, setUpdatedAt] = useState(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, diff --git a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/header/index.tsx b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/header/index.tsx index 28e5c696d1f52c2..85cc8c004389707 100644 --- a/x-pack/plugins/security_solution/public/overview/components/entity_analytics/header/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/entity_analytics/header/index.tsx @@ -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,