Skip to content

Commit

Permalink
add category option for context menus (opensearch-project#4144)
Browse files Browse the repository at this point in the history
* enhance grouping for context menu options

Signed-off-by: David Sinclair <[email protected]>

* change log

Signed-off-by: David Sinclair <[email protected]>

* remove type export

Signed-off-by: David Sinclair <[email protected]>

* revert border and prevent destroy options

Signed-off-by: David Sinclair <[email protected]>

* update comments for building panels

Signed-off-by: David Sinclair <[email protected]>

* build panels tests and more comments

Signed-off-by: David Sinclair <[email protected]>

* add category option for context menus

Signed-off-by: David Sinclair <[email protected]>

* changelog

Signed-off-by: David Sinclair <[email protected]>

* add order to groups

Signed-off-by: David Sinclair <[email protected]>

* documentation, shorter copyrighty, minor cleanup

Signed-off-by: David Sinclair <[email protected]>

* changelog

Signed-off-by: David Sinclair <[email protected]>

---------

Signed-off-by: David Sinclair <[email protected]>
Signed-off-by: David Sinclair <[email protected]>
Signed-off-by: Ashish Agrawal <[email protected]>
Co-authored-by: Ashish Agrawal <[email protected]>
  • Loading branch information
sikhote and lezzago authored Jun 6, 2023
1 parent 5c5de03 commit cb27336
Show file tree
Hide file tree
Showing 7 changed files with 297 additions and 6 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
- [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.
- 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))
- Replace re2 with RegExp in timeline and add unit tests ([#3908](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3908))
- Add category option within groups for context menus ([#4144](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4144))

### 🐛 Bug Fixes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { PanelViewWithSharingLong } from './panel_view_with_sharing_long';
import { PanelEdit } from './panel_edit';
import { PanelEditWithDrilldowns } from './panel_edit_with_drilldowns';
import { PanelEditWithDrilldownsAndContextActions } from './panel_edit_with_drilldowns_and_context_actions';
import { PanelGroupOptionsAndContextActions } from './panel_group_options_and_context_actions';

export const ContextMenuExamples: React.FC = () => {
return (
Expand All @@ -59,7 +60,6 @@ export const ContextMenuExamples: React.FC = () => {
<PanelViewWithSharingLong />
</EuiFlexItem>
</EuiFlexGroup>

<EuiFlexGroup>
<EuiFlexItem>
<PanelEdit />
Expand All @@ -71,6 +71,11 @@ export const ContextMenuExamples: React.FC = () => {
<PanelEditWithDrilldownsAndContextActions />
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<PanelGroupOptionsAndContextActions />
</EuiFlexItem>
</EuiFlexGroup>
</EuiText>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import * as React from 'react';
import { EuiButton, EuiContextMenu, EuiPopover } from '@elastic/eui';
import useAsync from 'react-use/lib/useAsync';
import { buildContextMenuForActions, Action } from '../../../../src/plugins/ui_actions/public';
import { sampleAction } from './util';

export const PanelGroupOptionsAndContextActions: React.FC = () => {
const [open, setOpen] = React.useState(false);

const context = {};
const trigger: any = 'TEST_TRIGGER';
const drilldownGrouping: Action['grouping'] = [
{
id: 'drilldowns',
getDisplayName: () => 'Uncategorized group',
getIconType: () => 'popout',
order: 20,
},
];
const exampleGroup: Action['grouping'] = [
{
id: 'example',
getDisplayName: () => 'Example group',
getIconType: () => 'cloudStormy',
order: 20,
category: 'visAug',
},
];
const alertingGroup: Action['grouping'] = [
{
id: 'alerting',
getDisplayName: () => 'Alerting',
getIconType: () => 'cloudStormy',
order: 20,
category: 'visAug',
},
];
const anomaliesGroup: Action['grouping'] = [
{
id: 'anomalies',
getDisplayName: () => 'Anomalies',
getIconType: () => 'cloudStormy',
order: 30,
category: 'visAug',
},
];
const actions = [
sampleAction('test-1', 100, 'Edit visualization', 'pencil'),
sampleAction('test-2', 99, 'Clone panel', 'partial'),

sampleAction('test-9', 10, 'Create drilldown', 'plusInCircle', drilldownGrouping),
sampleAction('test-10', 9, 'Manage drilldowns', 'list', drilldownGrouping),

sampleAction('test-11', 10, 'Example action', 'dashboardApp', exampleGroup),
sampleAction('test-11', 10, 'Alertin action 1', 'dashboardApp', alertingGroup),
sampleAction('test-12', 9, 'Alertin action 2', 'dashboardApp', alertingGroup),
sampleAction('test-13', 8, 'Anomalies 1', 'cloudStormy', anomaliesGroup),
sampleAction('test-14', 7, 'Anomalies 2', 'link', anomaliesGroup),
];

const panels = useAsync(() =>
buildContextMenuForActions({
actions: actions.map((action) => ({ action, context, trigger })),
})
);

return (
<EuiPopover
button={<EuiButton onClick={() => setOpen((x) => !x)}>Grouping with categories</EuiButton>}
isOpen={open}
panelPaddingSize="none"
anchorPosition="downLeft"
closePopover={() => setOpen(false)}
>
<EuiContextMenu initialPanelId={'mainMenu'} panels={panels.value} />
</EuiPopover>
);
};
9 changes: 9 additions & 0 deletions src/plugins/ui_actions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,12 @@ Use the UI actions explorer in the Developer examples to learn more about the se
```sh
yarn start --run-examples
```

## Action Properties

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.

- `order` is used when there is more than one action matched to a trigger and within context menus. Higher numbers are displayed first.
- `getDisplayName` is a function that can return either a string or a JSX element. Returning a JSX element allows flexibility with formatting.
- `getIconType` can be used to add an icon before the display name.
- `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.
Original file line number Diff line number Diff line change
Expand Up @@ -448,3 +448,123 @@ test('groups with deep nesting', async () => {
]
`);
});

// Tests with:
// a regular action
// a group with 2 actions uncategorized
// a group with 2 actions with a category of "test-category" and low order of 10
// a group with 1 actions with a category of "test-category" and high order of 20
test('groups with categories and order', async () => {
const grouping1 = [
{
id: 'test-group',
getDisplayName: () => 'Test group',
getIconType: () => 'bell',
},
];
const grouping2 = [
{
id: 'test-group-2',
getDisplayName: () => 'Test group 2',
getIconType: () => 'bell',
category: 'test-category',
order: 10,
},
];
const grouping3 = [
{
id: 'test-group-3',
getDisplayName: () => 'Test group 3',
getIconType: () => 'bell',
category: 'test-category',
order: 20,
},
];

const actions = [
createTestAction({
dispayName: 'Foo 1',
}),
createTestAction({
dispayName: 'Bar 1',
grouping: grouping1,
}),
createTestAction({
dispayName: 'Bar 2',
grouping: grouping1,
}),
createTestAction({
dispayName: 'Qux 1',
grouping: grouping2,
}),
createTestAction({
dispayName: 'Qux 2',
grouping: grouping2,
}),
// It is expected that, because there is only 1 action within this group,
// it will be added to the mainMenu as a single item, but next to other
// groups of the same category. When a group has a category, but only one
// item, we just add that single item; otherwise, we add a link to the group
createTestAction({
dispayName: 'Waldo 1',
grouping: grouping3,
}),
];
const menu = await buildContextMenuForActions({
actions: actions.map((action) => ({ action, context: {}, trigger: 'TEST' as any })),
});

expect(menu.map(resultMapper)).toMatchInlineSnapshot(`
Array [
Object {
"items": Array [
Object {
"name": "Foo 1",
},
Object {
"isSeparator": true,
},
Object {
"name": "Test group",
},
Object {
"isSeparator": true,
},
Object {
"name": "Waldo 1",
},
Object {
"name": "Test group 2",
},
],
},
Object {
"items": Array [
Object {
"name": "Bar 1",
},
Object {
"name": "Bar 2",
},
],
},
Object {
"items": Array [
Object {
"name": "Qux 1",
},
Object {
"name": "Qux 2",
},
],
},
Object {
"items": Array [
Object {
"name": "Waldo 1",
},
],
},
]
`);
});
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ type PanelDescriptor = EuiContextMenuPanelDescriptor & {
_level?: number;
_icon?: string;
items: ItemDescriptor[];
_category?: string;
_order?: number;
};

const onClick = (action: Action, context: ActionExecutionContext<object>, close: () => void) => (
Expand Down Expand Up @@ -125,7 +127,7 @@ const removeItemMetaFields = (items: ItemDescriptor[]): EuiContextMenuPanelItemD
const removePanelMetaFields = (panels: PanelDescriptor[]): EuiContextMenuPanelDescriptor[] => {
const euiPanels: EuiContextMenuPanelDescriptor[] = [];
for (const panel of panels) {
const { _level: omit, _icon: omit2, ...rest } = panel;
const { _level: omit, _icon: omit2, _category: omit3, _order: omit4, ...rest } = panel;
euiPanels.push({ ...rest, items: removeItemMetaFields(rest.items) });
}
return euiPanels;
Expand Down Expand Up @@ -179,6 +181,8 @@ export async function buildContextMenuForActions({
items: [],
_level: i,
_icon: group.getIconType ? group.getIconType(context) : 'empty',
_category: group.category,
_order: group.order,
};

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

// This will be used to store items that eventually are placed into the
// mainMenu panel. Specifying a category allows for placing groups into the
// mainMenu so they appear without the separator between them.
const categories = {};

for (const panel of Object.values(panels)) {
// If the panel is a root-level panel, such as the parent of a group,
// then create mainMenu item for this panel
if (panel._level === 0) {
// Do nothing if not root-level panel, such as the parent of a group
if (panel._level !== 0) {
continue;
}

// Proceed to create mainMenu item for this panel

// If a category is specified, store either a link to the panel or the
// item within to that category. We will deal with the category after
// looping through all panels.
if (panel._category) {
// Create array to store category items
if (!categories[panel._category]) {
categories[panel._category] = [];
}

// If multiple items in the panel, store a link to this panel into the category.
// Otherwise, just store the single item into the category.
if (panel.items.length > 1) {
categories[panel._category].push({
order: panel._order,
items: [
{
name: panel.title || panel.id,
icon: panel._icon || 'empty',
panel: panel.id,
},
],
});
} else {
categories[panel._category].push({
order: panel._order || 0,
items: panel.items,
});
}
} else {
// If no category, continue with adding items to the mainMenu

// Add separator with unique key if needed
if (panels.mainMenu.items.length) {
panels.mainMenu.items.push({ isSeparator: true, key: `${panel.id}separator` });
}

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

// For each category, add a separator before each one and then add category items.
// This is for the mainMenu panel.
Object.keys(categories).forEach((key) => {
// Get the items sorted by group order, allowing for groups within categories
// to be ordered. A category consists of an order and its items.
// Higher orders are sorted to the top.
const sortedEntries = categories[key].sort((a, b) => b.order - a.order);
const sortedItems = sortedEntries.reduce(
(items, category) => [...items, ...category.items],
[]
);

// Add separator with unique key if needed
if (panels.mainMenu.items.length) {
panels.mainMenu.items.push({ isSeparator: true, key: `${key}separator` });
}

panels.mainMenu.items.push(...sortedItems);
});

const panelList = Object.values(panels);

return removePanelMetaFields(panelList);
}
8 changes: 8 additions & 0 deletions src/plugins/ui_actions/public/util/presentable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ export interface PresentableGroup<Context extends object = object>
Pick<Presentable<Context>, 'getDisplayName' | 'getDisplayNameTooltip' | 'getIconType' | 'order'>
> {
id: string;
/**
* This allows groups to be categorized with other groups. Within a UI action
* context menu, this means that an item, which links to a group, will be
* placed in the menu adjacent to similar items that link to groups of the
* same category.
* See PanelGroupOptionsAndContextActions example to learn more.
*/
category?: string;
}

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

0 comments on commit cb27336

Please sign in to comment.