Skip to content

Commit f0797ff

Browse files
authored
[Security Solution][Attacks/Alerts][Attacks page][Table section] Group by dropdown customization (elastic#232660) (elastic#245128)
## Summary Epic: elastic#232569 Closes elastic#232660 These changes add a way to **customize alerts table grouping component**: * possibility to hide "None" option * possibility to hide "Custom Field" option * possibility to hide "Select up to 3 groupings" title * possibility to customize the "Group by" button label There is a new `GroupSettings` interface: ```typescript export interface GroupSettings { /** * Allows to hide the None option in the group selection dropdown. */ hideNoneOption?: boolean; /** * Allows to hide the Custom field option in the group selection dropdown. */ hideCustomFieldOption?: boolean; /** * Allows to hide the title in the group selection dropdown. */ hideOptionsTitle?: boolean; /** * Allows to customize the label of the group selection dropdown. */ popoverButtonLabel?: string; } ``` Also, as part of this PR we **customize the grouping component for the Attacks table**: <img width="454" height="261" alt="Screenshot 2025-12-03 at 17 30 36" src="https://github.com/user-attachments/assets/a6450857-3dcb-43ce-9ed7-b56a9fc8d123" /> **NOTE**: Enforcing "Attack" option as always se;ected on Attacks page will be done as part of [a separate ticket](elastic#237190). ## Feature Flag > [!NOTE] > The feature is hidden behind the feature flag (in `kibana.dev.yml`): ``` feature_flags.overrides: securitySolution.attacksAlertsAlignment: true ``` ## Testing **Case 1 - Attacks page**: 1. Navigate to `Security > Detections > Attacks` 2. There should be only one grouping option available - "Attack" **Case 2 - Alerts page**: 1. Navigate to `Security > Detections > Alerts` 2. Grouping should show all the options, like None, Custom Fields, Rule Name, User Name etc. ## Video Recording https://github.com/user-attachments/assets/87aa4a31-eb1f-426b-8d07-1b528d0c47e6
1 parent f2faf26 commit f0797ff

21 files changed

Lines changed: 518 additions & 80 deletions

File tree

src/platform/packages/shared/kbn-grouping/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { getGroupingQuery, isNoneGroup, useGrouping } from './src';
1111
import type {
1212
DynamicGroupingProps,
1313
GroupOption,
14+
GroupSettings,
1415
GroupingAggregation,
1516
NamedAggregation,
1617
RawBucket,
@@ -22,6 +23,7 @@ export { getGroupingQuery, isNoneGroup, useGrouping };
2223
export type {
2324
DynamicGroupingProps,
2425
GroupOption,
26+
GroupSettings,
2527
GroupingAggregation,
2628
NamedAggregation,
2729
RawBucket,

src/platform/packages/shared/kbn-grouping/src/components/group_selector/index.test.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,4 +186,37 @@ describe('group selector', () => {
186186
});
187187
});
188188
});
189+
190+
describe('custom settings', () => {
191+
it('Does not present the "none" option when `hideNoneOption` is true', () => {
192+
const { getByTestId, queryByTestId } = render(
193+
<GroupSelector {...testProps} settings={{ hideNoneOption: true }} />
194+
);
195+
fireEvent.click(getByTestId('group-selector-dropdown'));
196+
expect(queryByTestId('panel-none')).toBeNull();
197+
});
198+
199+
it('Does not present the "Custom field" option when `hideCustomFieldOption` is true', () => {
200+
const { getByTestId, queryByTestId } = render(
201+
<GroupSelector {...testProps} settings={{ hideCustomFieldOption: true }} />
202+
);
203+
fireEvent.click(getByTestId('group-selector-dropdown'));
204+
expect(queryByTestId('panel-custom')).toBeNull();
205+
});
206+
207+
it('Renders custom button label when `popoverButtonLabel` is provided', () => {
208+
const { getByTestId } = render(
209+
<GroupSelector {...testProps} settings={{ popoverButtonLabel: 'Custom Label' }} />
210+
);
211+
expect(getByTestId('group-selector-dropdown').textContent).toBe('Custom Label');
212+
});
213+
214+
it('Does not present the title in the dropdown when `hideOptionsTitle` is true', () => {
215+
const { getByTestId, queryByTestId } = render(
216+
<GroupSelector {...testProps} settings={{ hideOptionsTitle: true }} />
217+
);
218+
fireEvent.click(getByTestId('group-selector-dropdown'));
219+
expect(queryByTestId('contextMenuPanelTitle')).not.toBeInTheDocument();
220+
});
221+
});
189222
});

src/platform/packages/shared/kbn-grouping/src/components/group_selector/index.tsx

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { METRIC_TYPE } from '@kbn/analytics';
2020
import { CustomFieldPanel } from './custom_field_panel';
2121
import * as i18n from '../translations';
2222
import { StyledContextMenu } from '../styles';
23+
import type { GroupSettings } from '../../hooks/types';
2324

2425
export interface GroupSelectorProps {
2526
'data-test-subj'?: string;
@@ -35,6 +36,7 @@ export interface GroupSelectorProps {
3536
event: string | string[],
3637
count?: number | undefined
3738
) => void;
39+
settings?: GroupSettings;
3840
}
3941
const GroupSelectorComponent = ({
4042
'data-test-subj': dataTestSubj,
@@ -45,7 +47,10 @@ const GroupSelectorComponent = ({
4547
title = i18n.GROUP_BY,
4648
maxGroupingLevels = 1,
4749
onOpenTracker,
50+
settings,
4851
}: GroupSelectorProps) => {
52+
const { hideNoneOption, hideCustomFieldOption, hideOptionsTitle, popoverButtonLabel } =
53+
settings ?? {};
4954
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
5055
const isGroupSelected = useCallback(
5156
(groupKey: string) =>
@@ -65,33 +70,43 @@ const GroupSelectorComponent = ({
6570
return groupsSelected.length === maxGroupingLevels && (key ? !isGroupSelected(key) : true);
6671
};
6772

73+
const topLevelTitle =
74+
maxGroupingLevels === 1 ? i18n.SELECT_SINGLE_FIELD : i18n.SELECT_FIELD(maxGroupingLevels);
75+
6876
return [
6977
{
7078
id: 'firstPanel',
71-
title:
72-
maxGroupingLevels === 1 ? i18n.SELECT_SINGLE_FIELD : i18n.SELECT_FIELD(maxGroupingLevels),
79+
title: hideOptionsTitle ? null : topLevelTitle,
7380
items: [
74-
{
75-
'data-test-subj': 'panel-none',
76-
name: i18n.NONE,
77-
icon: isGroupSelected('none') ? 'check' : 'empty',
78-
onClick: () => onGroupChange('none'),
79-
},
81+
...(hideNoneOption
82+
? []
83+
: [
84+
{
85+
'data-test-subj': 'panel-none',
86+
name: i18n.NONE,
87+
icon: isGroupSelected('none') ? 'check' : 'empty',
88+
onClick: () => onGroupChange('none'),
89+
} as EuiContextMenuPanelItemDescriptor,
90+
]),
8091
...options.map<EuiContextMenuPanelItemDescriptor>((o) => ({
8192
'data-test-subj': `panel-${o.key}`,
8293
disabled: isOptionDisabled(o.key),
8394
name: o.label,
8495
onClick: () => onGroupChange(o.key),
8596
icon: isGroupSelected(o.key) ? 'check' : 'empty',
8697
})),
87-
{
88-
'data-test-subj': `panel-custom`,
89-
name: i18n.CUSTOM_FIELD,
90-
icon: 'empty',
91-
disabled: isOptionDisabled(),
92-
panel: 'customPanel',
93-
hasPanel: true,
94-
},
98+
...(hideCustomFieldOption
99+
? []
100+
: [
101+
{
102+
'data-test-subj': `panel-custom`,
103+
name: i18n.CUSTOM_FIELD,
104+
icon: 'empty',
105+
disabled: isOptionDisabled(),
106+
panel: 'customPanel',
107+
hasPanel: true,
108+
} as EuiContextMenuPanelItemDescriptor,
109+
]),
95110
],
96111
},
97112
{
@@ -110,7 +125,17 @@ const GroupSelectorComponent = ({
110125
),
111126
},
112127
];
113-
}, [fields, groupsSelected.length, isGroupSelected, maxGroupingLevels, onGroupChange, options]);
128+
}, [
129+
fields,
130+
groupsSelected.length,
131+
isGroupSelected,
132+
maxGroupingLevels,
133+
onGroupChange,
134+
options,
135+
hideNoneOption,
136+
hideCustomFieldOption,
137+
hideOptionsTitle,
138+
]);
114139
const selectedOptions = useMemo(
115140
() => options.filter((groupOption) => isGroupSelected(groupOption.key)),
116141
[isGroupSelected, options]
@@ -150,10 +175,10 @@ const GroupSelectorComponent = ({
150175
title={buttonLabel}
151176
size="xs"
152177
>
153-
{`${title}: ${buttonLabel}`}
178+
{popoverButtonLabel ?? `${title}: ${buttonLabel}`}
154179
</EuiButtonEmpty>
155180
);
156-
}, [groupsSelected, isGroupSelected, onButtonClick, selectedOptions, title]);
181+
}, [groupsSelected, isGroupSelected, onButtonClick, selectedOptions, title, popoverButtonLabel]);
157182

158183
return (
159184
<EuiPopover
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import { groupActions } from './actions';
11+
import { ActionType } from '../types';
12+
13+
describe('groupActions', () => {
14+
describe('updateActiveGroups', () => {
15+
it('should create an action to update active groups', () => {
16+
const activeGroups = ['group1', 'group2'];
17+
const id = 'test-id';
18+
const expectedAction = {
19+
type: ActionType.updateActiveGroups,
20+
payload: {
21+
activeGroups,
22+
id,
23+
},
24+
};
25+
26+
expect(groupActions.updateActiveGroups({ activeGroups, id })).toEqual(expectedAction);
27+
});
28+
});
29+
30+
describe('updateGroupOptions', () => {
31+
it('should create an action to update group options', () => {
32+
const newOptionList = [
33+
{ key: 'key1', label: 'Label 1' },
34+
{ key: 'key2', label: 'Label 2' },
35+
];
36+
const id = 'test-id';
37+
const expectedAction = {
38+
type: ActionType.updateGroupOptions,
39+
payload: {
40+
newOptionList,
41+
id,
42+
},
43+
};
44+
45+
expect(groupActions.updateGroupOptions({ newOptionList, id })).toEqual(expectedAction);
46+
});
47+
});
48+
49+
describe('updateGroupSettings', () => {
50+
it('should create an action to update group settings', () => {
51+
const settings = {
52+
hideNoneOption: true,
53+
hideCustomFieldOption: false,
54+
hideOptionsTitle: true,
55+
popoverButtonLabel: 'Custom Label',
56+
};
57+
const id = 'test-id';
58+
const expectedAction = {
59+
type: ActionType.updateGroupSettings,
60+
payload: {
61+
settings,
62+
id,
63+
},
64+
};
65+
66+
expect(groupActions.updateGroupSettings({ settings, id })).toEqual(expectedAction);
67+
});
68+
69+
it('should create an action to update group settings with undefined settings', () => {
70+
const id = 'test-id';
71+
const expectedAction = {
72+
type: ActionType.updateGroupSettings,
73+
payload: {
74+
settings: undefined,
75+
id,
76+
},
77+
};
78+
79+
expect(groupActions.updateGroupSettings({ settings: undefined, id })).toEqual(expectedAction);
80+
});
81+
});
82+
});

src/platform/packages/shared/kbn-grouping/src/hooks/state/actions.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@
77
* License v3.0 only", or the "Server Side Public License, v 1".
88
*/
99

10-
import type { GroupOption, UpdateActiveGroups, UpdateGroupOptions } from '../types';
10+
import type {
11+
GroupOption,
12+
UpdateActiveGroups,
13+
UpdateGroupOptions,
14+
GroupSettings,
15+
UpdateGroupSettings,
16+
} from '../types';
1117
import { ActionType } from '../types';
1218

1319
const updateActiveGroups = ({
@@ -38,7 +44,19 @@ const updateGroupOptions = ({
3844
type: ActionType.updateGroupOptions,
3945
});
4046

47+
const updateGroupSettings = ({
48+
settings,
49+
id,
50+
}: {
51+
settings?: GroupSettings;
52+
id: string;
53+
}): UpdateGroupSettings => ({
54+
payload: { settings, id },
55+
type: ActionType.updateGroupSettings,
56+
});
57+
4158
export const groupActions = {
4259
updateActiveGroups,
4360
updateGroupOptions,
61+
updateGroupSettings,
4462
};

src/platform/packages/shared/kbn-grouping/src/hooks/state/reducer.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,34 @@ describe('grouping reducer', () => {
7070
[groupingState, dispatch] = result.current;
7171
expect(groupingState.groupById[groupingId].activeGroups).toEqual(['user.name']);
7272
});
73+
it('updateGroupSettings', () => {
74+
const { result } = renderHook(() =>
75+
useReducer(groupsReducerWithStorage, {
76+
...initialState,
77+
groupById,
78+
})
79+
);
80+
let [groupingState, dispatch] = result.current;
81+
expect(groupingState.groupById[groupingId].settings).toEqual(undefined);
82+
act(() => {
83+
dispatch(
84+
groupActions.updateGroupSettings({
85+
id: groupingId,
86+
settings: {
87+
hideNoneOption: true,
88+
hideCustomFieldOption: true,
89+
popoverButtonLabel: 'Group by',
90+
hideOptionsTitle: true,
91+
},
92+
})
93+
);
94+
});
95+
[groupingState, dispatch] = result.current;
96+
expect(groupingState.groupById[groupingId].settings).toEqual({
97+
hideNoneOption: true,
98+
hideCustomFieldOption: true,
99+
popoverButtonLabel: 'Group by',
100+
hideOptionsTitle: true,
101+
});
102+
});
73103
});

src/platform/packages/shared/kbn-grouping/src/hooks/state/reducer.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ const groupsReducer = (state: GroupMap, action: Action, groupsById: GroupsById)
4747
},
4848
};
4949
}
50+
case ActionType.updateGroupSettings: {
51+
const { id, settings } = action.payload;
52+
return {
53+
...state,
54+
groupById: {
55+
...groupsById,
56+
[id]: { ...defaultGroup, ...groupsById[id], settings },
57+
},
58+
};
59+
}
5060
}
5161
throw Error(`Unknown grouping action`);
5262
};

src/platform/packages/shared/kbn-grouping/src/hooks/types.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
export enum ActionType {
1212
updateActiveGroups = 'UPDATE_ACTIVE_GROUPS',
1313
updateGroupOptions = 'UPDATE_GROUP_OPTIONS',
14+
updateGroupSettings = 'UPDATE_GROUP_SETTINGS',
1415
}
1516

1617
export interface UpdateActiveGroups {
@@ -23,7 +24,12 @@ export interface UpdateGroupOptions {
2324
payload: { newOptionList: GroupOption[]; id: string };
2425
}
2526

26-
export type Action = UpdateActiveGroups | UpdateGroupOptions;
27+
export interface UpdateGroupSettings {
28+
type: ActionType.updateGroupSettings;
29+
payload: { settings?: GroupSettings; id: string };
30+
}
31+
32+
export type Action = UpdateActiveGroups | UpdateGroupOptions | UpdateGroupSettings;
2733

2834
// state
2935

@@ -32,9 +38,32 @@ export interface GroupOption {
3238
label: string;
3339
}
3440

41+
export interface GroupSettings {
42+
/**
43+
* Allows to hide the None option in the group selection dropdown.
44+
*/
45+
hideNoneOption?: boolean;
46+
/**
47+
* Allows to hide the Custom field option in the group selection dropdown.
48+
*/
49+
hideCustomFieldOption?: boolean;
50+
/**
51+
* Allows to hide the title in the group selection dropdown.
52+
*/
53+
hideOptionsTitle?: boolean;
54+
/**
55+
* Allows to customize the label of the group selection dropdown.
56+
*/
57+
popoverButtonLabel?: string;
58+
}
59+
3560
export interface GroupModel {
3661
activeGroups: string[];
3762
options: GroupOption[];
63+
/**
64+
* Allows to customize the group selection dropdown.
65+
*/
66+
settings?: GroupSettings;
3867
}
3968

4069
export interface GroupsById {

0 commit comments

Comments
 (0)