Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add category option for context menus #4144

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(`
ashwin-pc marked this conversation as resolved.
Show resolved Hide resolved
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",
},
],
},
]
`);
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we are also updating docs when we touch a previously undocumented service, can you add documentation to the UIActions readme considering that we are updating it now? Look at how Embeddable did the same.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also describe the difference between grouping and categories in that since its not obvious until you get into the weeds with UIActions

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added some changes to expand on this a bit within the readme for ui actions. There is also some information here: src/plugins/ui_actions/public/util/presentable.ts, which should help a bit with more detail. Ultimately though, I think the live example will be the most helpful.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, thanks for adding that. The reason I usually ask for the readme is because not many devs know about or use the live example.

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>>;