Skip to content

Commit cb27336

Browse files
sikhotelezzago
andauthored
add category option for context menus (#4144)
* enhance grouping for context menu options Signed-off-by: David Sinclair <david@sinclair.tech> * change log Signed-off-by: David Sinclair <david@sinclair.tech> * remove type export Signed-off-by: David Sinclair <david@sinclair.tech> * revert border and prevent destroy options Signed-off-by: David Sinclair <david@sinclair.tech> * update comments for building panels Signed-off-by: David Sinclair <dsincla@rei.com> * build panels tests and more comments Signed-off-by: David Sinclair <dsincla@rei.com> * add category option for context menus Signed-off-by: David Sinclair <dsincla@rei.com> * changelog Signed-off-by: David Sinclair <dsincla@rei.com> * add order to groups Signed-off-by: David Sinclair <dsincla@rei.com> * documentation, shorter copyrighty, minor cleanup Signed-off-by: David Sinclair <dsincla@rei.com> * changelog Signed-off-by: David Sinclair <dsincla@rei.com> --------- Signed-off-by: David Sinclair <david@sinclair.tech> Signed-off-by: David Sinclair <dsincla@rei.com> Signed-off-by: Ashish Agrawal <ashish81394@gmail.com> Co-authored-by: Ashish Agrawal <ashish81394@gmail.com>
1 parent 5c5de03 commit cb27336

File tree

7 files changed

+297
-6
lines changed

7 files changed

+297
-6
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
2020
- [Multiple DataSource] Add support for SigV4 authentication ([#3058](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3058)). Backwards-compatible feature included in v2.6.0 release.
2121
- Add plugin manifest config to define OpenSearch plugin dependency and verify if it is installed on the cluster ([#3116](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3116))
2222
- Replace re2 with RegExp in timeline and add unit tests ([#3908](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3908))
23+
- Add category option within groups for context menus ([#4144](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4144))
2324

2425
### 🐛 Bug Fixes
2526

examples/ui_actions_explorer/public/context_menu_examples/context_menu_examples.tsx

+6-1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { PanelViewWithSharingLong } from './panel_view_with_sharing_long';
3636
import { PanelEdit } from './panel_edit';
3737
import { PanelEditWithDrilldowns } from './panel_edit_with_drilldowns';
3838
import { PanelEditWithDrilldownsAndContextActions } from './panel_edit_with_drilldowns_and_context_actions';
39+
import { PanelGroupOptionsAndContextActions } from './panel_group_options_and_context_actions';
3940

4041
export const ContextMenuExamples: React.FC = () => {
4142
return (
@@ -59,7 +60,6 @@ export const ContextMenuExamples: React.FC = () => {
5960
<PanelViewWithSharingLong />
6061
</EuiFlexItem>
6162
</EuiFlexGroup>
62-
6363
<EuiFlexGroup>
6464
<EuiFlexItem>
6565
<PanelEdit />
@@ -71,6 +71,11 @@ export const ContextMenuExamples: React.FC = () => {
7171
<PanelEditWithDrilldownsAndContextActions />
7272
</EuiFlexItem>
7373
</EuiFlexGroup>
74+
<EuiFlexGroup>
75+
<EuiFlexItem>
76+
<PanelGroupOptionsAndContextActions />
77+
</EuiFlexItem>
78+
</EuiFlexGroup>
7479
</EuiText>
7580
);
7681
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import * as React from 'react';
7+
import { EuiButton, EuiContextMenu, EuiPopover } from '@elastic/eui';
8+
import useAsync from 'react-use/lib/useAsync';
9+
import { buildContextMenuForActions, Action } from '../../../../src/plugins/ui_actions/public';
10+
import { sampleAction } from './util';
11+
12+
export const PanelGroupOptionsAndContextActions: React.FC = () => {
13+
const [open, setOpen] = React.useState(false);
14+
15+
const context = {};
16+
const trigger: any = 'TEST_TRIGGER';
17+
const drilldownGrouping: Action['grouping'] = [
18+
{
19+
id: 'drilldowns',
20+
getDisplayName: () => 'Uncategorized group',
21+
getIconType: () => 'popout',
22+
order: 20,
23+
},
24+
];
25+
const exampleGroup: Action['grouping'] = [
26+
{
27+
id: 'example',
28+
getDisplayName: () => 'Example group',
29+
getIconType: () => 'cloudStormy',
30+
order: 20,
31+
category: 'visAug',
32+
},
33+
];
34+
const alertingGroup: Action['grouping'] = [
35+
{
36+
id: 'alerting',
37+
getDisplayName: () => 'Alerting',
38+
getIconType: () => 'cloudStormy',
39+
order: 20,
40+
category: 'visAug',
41+
},
42+
];
43+
const anomaliesGroup: Action['grouping'] = [
44+
{
45+
id: 'anomalies',
46+
getDisplayName: () => 'Anomalies',
47+
getIconType: () => 'cloudStormy',
48+
order: 30,
49+
category: 'visAug',
50+
},
51+
];
52+
const actions = [
53+
sampleAction('test-1', 100, 'Edit visualization', 'pencil'),
54+
sampleAction('test-2', 99, 'Clone panel', 'partial'),
55+
56+
sampleAction('test-9', 10, 'Create drilldown', 'plusInCircle', drilldownGrouping),
57+
sampleAction('test-10', 9, 'Manage drilldowns', 'list', drilldownGrouping),
58+
59+
sampleAction('test-11', 10, 'Example action', 'dashboardApp', exampleGroup),
60+
sampleAction('test-11', 10, 'Alertin action 1', 'dashboardApp', alertingGroup),
61+
sampleAction('test-12', 9, 'Alertin action 2', 'dashboardApp', alertingGroup),
62+
sampleAction('test-13', 8, 'Anomalies 1', 'cloudStormy', anomaliesGroup),
63+
sampleAction('test-14', 7, 'Anomalies 2', 'link', anomaliesGroup),
64+
];
65+
66+
const panels = useAsync(() =>
67+
buildContextMenuForActions({
68+
actions: actions.map((action) => ({ action, context, trigger })),
69+
})
70+
);
71+
72+
return (
73+
<EuiPopover
74+
button={<EuiButton onClick={() => setOpen((x) => !x)}>Grouping with categories</EuiButton>}
75+
isOpen={open}
76+
panelPaddingSize="none"
77+
anchorPosition="downLeft"
78+
closePopover={() => setOpen(false)}
79+
>
80+
<EuiContextMenu initialPanelId={'mainMenu'} panels={panels.value} />
81+
</EuiPopover>
82+
);
83+
};

src/plugins/ui_actions/README.md

+9
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,12 @@ Use the UI actions explorer in the Developer examples to learn more about the se
9797
```sh
9898
yarn start --run-examples
9999
```
100+
101+
## Action Properties
102+
103+
Refer to [./public/actions/action.ts](./public/actions/action.ts) for all properties, keeping in mind it extends the [presentable](./public/util/presentable.ts) interface. Here are some properties that provide special functionality and customization.
104+
105+
- `order` is used when there is more than one action matched to a trigger and within context menus. Higher numbers are displayed first.
106+
- `getDisplayName` is a function that can return either a string or a JSX element. Returning a JSX element allows flexibility with formatting.
107+
- `getIconType` can be used to add an icon before the display name.
108+
- `grouping` determines where this item should appear as a submenu. Each group can also contain a category, which is used within context menus to organize similar groups into the same section of the menu. See examples explorer for more details about what this looks like within a context menu.

src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.test.ts

+120
Original file line numberDiff line numberDiff line change
@@ -448,3 +448,123 @@ test('groups with deep nesting', async () => {
448448
]
449449
`);
450450
});
451+
452+
// Tests with:
453+
// a regular action
454+
// a group with 2 actions uncategorized
455+
// a group with 2 actions with a category of "test-category" and low order of 10
456+
// a group with 1 actions with a category of "test-category" and high order of 20
457+
test('groups with categories and order', async () => {
458+
const grouping1 = [
459+
{
460+
id: 'test-group',
461+
getDisplayName: () => 'Test group',
462+
getIconType: () => 'bell',
463+
},
464+
];
465+
const grouping2 = [
466+
{
467+
id: 'test-group-2',
468+
getDisplayName: () => 'Test group 2',
469+
getIconType: () => 'bell',
470+
category: 'test-category',
471+
order: 10,
472+
},
473+
];
474+
const grouping3 = [
475+
{
476+
id: 'test-group-3',
477+
getDisplayName: () => 'Test group 3',
478+
getIconType: () => 'bell',
479+
category: 'test-category',
480+
order: 20,
481+
},
482+
];
483+
484+
const actions = [
485+
createTestAction({
486+
dispayName: 'Foo 1',
487+
}),
488+
createTestAction({
489+
dispayName: 'Bar 1',
490+
grouping: grouping1,
491+
}),
492+
createTestAction({
493+
dispayName: 'Bar 2',
494+
grouping: grouping1,
495+
}),
496+
createTestAction({
497+
dispayName: 'Qux 1',
498+
grouping: grouping2,
499+
}),
500+
createTestAction({
501+
dispayName: 'Qux 2',
502+
grouping: grouping2,
503+
}),
504+
// It is expected that, because there is only 1 action within this group,
505+
// it will be added to the mainMenu as a single item, but next to other
506+
// groups of the same category. When a group has a category, but only one
507+
// item, we just add that single item; otherwise, we add a link to the group
508+
createTestAction({
509+
dispayName: 'Waldo 1',
510+
grouping: grouping3,
511+
}),
512+
];
513+
const menu = await buildContextMenuForActions({
514+
actions: actions.map((action) => ({ action, context: {}, trigger: 'TEST' as any })),
515+
});
516+
517+
expect(menu.map(resultMapper)).toMatchInlineSnapshot(`
518+
Array [
519+
Object {
520+
"items": Array [
521+
Object {
522+
"name": "Foo 1",
523+
},
524+
Object {
525+
"isSeparator": true,
526+
},
527+
Object {
528+
"name": "Test group",
529+
},
530+
Object {
531+
"isSeparator": true,
532+
},
533+
Object {
534+
"name": "Waldo 1",
535+
},
536+
Object {
537+
"name": "Test group 2",
538+
},
539+
],
540+
},
541+
Object {
542+
"items": Array [
543+
Object {
544+
"name": "Bar 1",
545+
},
546+
Object {
547+
"name": "Bar 2",
548+
},
549+
],
550+
},
551+
Object {
552+
"items": Array [
553+
Object {
554+
"name": "Qux 1",
555+
},
556+
Object {
557+
"name": "Qux 2",
558+
},
559+
],
560+
},
561+
Object {
562+
"items": Array [
563+
Object {
564+
"name": "Waldo 1",
565+
},
566+
],
567+
},
568+
]
569+
`);
570+
});

src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx

+70-5
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ type PanelDescriptor = EuiContextMenuPanelDescriptor & {
6464
_level?: number;
6565
_icon?: string;
6666
items: ItemDescriptor[];
67+
_category?: string;
68+
_order?: number;
6769
};
6870

6971
const onClick = (action: Action, context: ActionExecutionContext<object>, close: () => void) => (
@@ -125,7 +127,7 @@ const removeItemMetaFields = (items: ItemDescriptor[]): EuiContextMenuPanelItemD
125127
const removePanelMetaFields = (panels: PanelDescriptor[]): EuiContextMenuPanelDescriptor[] => {
126128
const euiPanels: EuiContextMenuPanelDescriptor[] = [];
127129
for (const panel of panels) {
128-
const { _level: omit, _icon: omit2, ...rest } = panel;
130+
const { _level: omit, _icon: omit2, _category: omit3, _order: omit4, ...rest } = panel;
129131
euiPanels.push({ ...rest, items: removeItemMetaFields(rest.items) });
130132
}
131133
return euiPanels;
@@ -179,6 +181,8 @@ export async function buildContextMenuForActions({
179181
items: [],
180182
_level: i,
181183
_icon: group.getIconType ? group.getIconType(context) : 'empty',
184+
_category: group.category,
185+
_order: group.order,
182186
};
183187

184188
// If there are multiple groups and this is not the first group,
@@ -231,17 +235,57 @@ export async function buildContextMenuForActions({
231235
// Any additional items are hidden behind a "more" item
232236
wrapMainPanelItemsIntoSubmenu(panels, 'mainMenu');
233237

238+
// This will be used to store items that eventually are placed into the
239+
// mainMenu panel. Specifying a category allows for placing groups into the
240+
// mainMenu so they appear without the separator between them.
241+
const categories = {};
242+
234243
for (const panel of Object.values(panels)) {
235-
// If the panel is a root-level panel, such as the parent of a group,
236-
// then create mainMenu item for this panel
237-
if (panel._level === 0) {
244+
// Do nothing if not root-level panel, such as the parent of a group
245+
if (panel._level !== 0) {
246+
continue;
247+
}
248+
249+
// Proceed to create mainMenu item for this panel
250+
251+
// If a category is specified, store either a link to the panel or the
252+
// item within to that category. We will deal with the category after
253+
// looping through all panels.
254+
if (panel._category) {
255+
// Create array to store category items
256+
if (!categories[panel._category]) {
257+
categories[panel._category] = [];
258+
}
259+
260+
// If multiple items in the panel, store a link to this panel into the category.
261+
// Otherwise, just store the single item into the category.
262+
if (panel.items.length > 1) {
263+
categories[panel._category].push({
264+
order: panel._order,
265+
items: [
266+
{
267+
name: panel.title || panel.id,
268+
icon: panel._icon || 'empty',
269+
panel: panel.id,
270+
},
271+
],
272+
});
273+
} else {
274+
categories[panel._category].push({
275+
order: panel._order || 0,
276+
items: panel.items,
277+
});
278+
}
279+
} else {
280+
// If no category, continue with adding items to the mainMenu
281+
238282
// Add separator with unique key if needed
239283
if (panels.mainMenu.items.length) {
240284
panels.mainMenu.items.push({ isSeparator: true, key: `${panel.id}separator` });
241285
}
242286

243287
// If a panel has more than one child, then allow items to be grouped
244-
// and link to it in the mainMenu. Otherwise, flatten the group.
288+
// and link to it in the mainMenu. Otherwise, link to the single item.
245289
// Note: this only happens on the root level panels, not for inner groups.
246290
if (panel.items.length > 1) {
247291
panels.mainMenu.items.push({
@@ -255,6 +299,27 @@ export async function buildContextMenuForActions({
255299
}
256300
}
257301

302+
// For each category, add a separator before each one and then add category items.
303+
// This is for the mainMenu panel.
304+
Object.keys(categories).forEach((key) => {
305+
// Get the items sorted by group order, allowing for groups within categories
306+
// to be ordered. A category consists of an order and its items.
307+
// Higher orders are sorted to the top.
308+
const sortedEntries = categories[key].sort((a, b) => b.order - a.order);
309+
const sortedItems = sortedEntries.reduce(
310+
(items, category) => [...items, ...category.items],
311+
[]
312+
);
313+
314+
// Add separator with unique key if needed
315+
if (panels.mainMenu.items.length) {
316+
panels.mainMenu.items.push({ isSeparator: true, key: `${key}separator` });
317+
}
318+
319+
panels.mainMenu.items.push(...sortedItems);
320+
});
321+
258322
const panelList = Object.values(panels);
323+
259324
return removePanelMetaFields(panelList);
260325
}

src/plugins/ui_actions/public/util/presentable.ts

+8
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,14 @@ export interface PresentableGroup<Context extends object = object>
9494
Pick<Presentable<Context>, 'getDisplayName' | 'getDisplayNameTooltip' | 'getIconType' | 'order'>
9595
> {
9696
id: string;
97+
/**
98+
* This allows groups to be categorized with other groups. Within a UI action
99+
* context menu, this means that an item, which links to a group, will be
100+
* placed in the menu adjacent to similar items that link to groups of the
101+
* same category.
102+
* See PanelGroupOptionsAndContextActions example to learn more.
103+
*/
104+
category?: string;
97105
}
98106

99107
export type PresentableGrouping<Context extends object = object> = Array<PresentableGroup<Context>>;

0 commit comments

Comments
 (0)