From 473f3a4329738f130a5da5ebb3f7286b2dfadff0 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Tue, 13 Jun 2023 15:38:04 -0700 Subject: [PATCH] add category option for context menus (#4144) (#4280) * enhance grouping for context menu options Signed-off-by: David Sinclair * change log Signed-off-by: David Sinclair * remove type export Signed-off-by: David Sinclair * revert border and prevent destroy options Signed-off-by: David Sinclair * update comments for building panels Signed-off-by: David Sinclair * build panels tests and more comments Signed-off-by: David Sinclair * add category option for context menus Signed-off-by: David Sinclair * changelog Signed-off-by: David Sinclair * add order to groups Signed-off-by: David Sinclair * documentation, shorter copyrighty, minor cleanup Signed-off-by: David Sinclair * changelog Signed-off-by: David Sinclair --------- Signed-off-by: David Sinclair Signed-off-by: David Sinclair Signed-off-by: Ashish Agrawal Co-authored-by: Ashish Agrawal (cherry picked from commit cb273364086057b542a55d2bbe28631313a22df9) Signed-off-by: github-actions[bot] # Conflicts: # CHANGELOG.md Co-authored-by: github-actions[bot] --- .../context_menu_examples.tsx | 7 +- ...anel_group_options_and_context_actions.tsx | 83 ++++++++++++ src/plugins/ui_actions/README.md | 9 ++ .../build_eui_context_menu_panels.test.ts | 120 ++++++++++++++++++ .../build_eui_context_menu_panels.tsx | 75 ++++++++++- .../ui_actions/public/util/presentable.ts | 8 ++ 6 files changed, 296 insertions(+), 6 deletions(-) create mode 100644 examples/ui_actions_explorer/public/context_menu_examples/panel_group_options_and_context_actions.tsx diff --git a/examples/ui_actions_explorer/public/context_menu_examples/context_menu_examples.tsx b/examples/ui_actions_explorer/public/context_menu_examples/context_menu_examples.tsx index b01d04c1608b..1f6ba03e966b 100644 --- a/examples/ui_actions_explorer/public/context_menu_examples/context_menu_examples.tsx +++ b/examples/ui_actions_explorer/public/context_menu_examples/context_menu_examples.tsx @@ -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 ( @@ -59,7 +60,6 @@ export const ContextMenuExamples: React.FC = () => { - @@ -71,6 +71,11 @@ export const ContextMenuExamples: React.FC = () => { + + + + + ); }; diff --git a/examples/ui_actions_explorer/public/context_menu_examples/panel_group_options_and_context_actions.tsx b/examples/ui_actions_explorer/public/context_menu_examples/panel_group_options_and_context_actions.tsx new file mode 100644 index 000000000000..20dc73406c55 --- /dev/null +++ b/examples/ui_actions_explorer/public/context_menu_examples/panel_group_options_and_context_actions.tsx @@ -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 ( + setOpen((x) => !x)}>Grouping with categories} + isOpen={open} + panelPaddingSize="none" + anchorPosition="downLeft" + closePopover={() => setOpen(false)} + > + + + ); +}; diff --git a/src/plugins/ui_actions/README.md b/src/plugins/ui_actions/README.md index 28e3b2d63d2e..4431a47a06ed 100644 --- a/src/plugins/ui_actions/README.md +++ b/src/plugins/ui_actions/README.md @@ -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. diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.test.ts b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.test.ts index b9afca9fb99c..e70561bea221 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.test.ts +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.test.ts @@ -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", + }, + ], + }, + ] + `); +}); diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx index 6d69be1f3faa..81710767e0a9 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx @@ -64,6 +64,8 @@ type PanelDescriptor = EuiContextMenuPanelDescriptor & { _level?: number; _icon?: string; items: ItemDescriptor[]; + _category?: string; + _order?: number; }; const onClick = (action: Action, context: ActionExecutionContext, close: () => void) => ( @@ -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; @@ -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, @@ -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({ @@ -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); } diff --git a/src/plugins/ui_actions/public/util/presentable.ts b/src/plugins/ui_actions/public/util/presentable.ts index 428644e1c2c6..9aaeada8a16e 100644 --- a/src/plugins/ui_actions/public/util/presentable.ts +++ b/src/plugins/ui_actions/public/util/presentable.ts @@ -94,6 +94,14 @@ export interface PresentableGroup Pick, '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 = Array>;