diff --git a/CHANGELOG.md b/CHANGELOG.md index cfa5aebd2ab5..2bb0133dfe6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -250,6 +250,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multi DataSource] UX enhancement on Data source management stack ([#2521](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2521)) - [Multi DataSource] UX enhancement on Update stored password modal for Data source management stack ([#2532](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2532)) - [Monaco editor] Add json worker support ([#3424](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3424)) +- Enhance grouping for context menus ([#3169](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3169)) - Replace re2 with RegExp in timeline and add unit tests ([#3908](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3908)) ### 🐛 Bug Fixes 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 d38d80d0fac0..b01d04c1608b 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 @@ -44,6 +44,8 @@ export const ContextMenuExamples: React.FC = () => {

Below examples show how context menu panels look with varying number of actions and how the actions can be grouped into different panels using grouping field. + Grouping can only be one layer deep. A group needs to have at least two items for grouping + to work. A separator is automatically added between groups.

diff --git a/src/plugins/ui_actions/public/actions/action.ts b/src/plugins/ui_actions/public/actions/action.ts index b38e2bd4e40f..76fc2ddb3bed 100644 --- a/src/plugins/ui_actions/public/actions/action.ts +++ b/src/plugins/ui_actions/public/actions/action.ts @@ -92,7 +92,7 @@ export interface Action * Returns a title to be displayed to the user. * @param context */ - getDisplayName(context: ActionExecutionContext): string; + getDisplayName(context: ActionExecutionContext): JSX.Element | string; /** * `UiComponent` to render when displaying this action as a context menu item. diff --git a/src/plugins/ui_actions/public/actions/action_internal.ts b/src/plugins/ui_actions/public/actions/action_internal.ts index fb39a01ed698..5610051dc3f7 100644 --- a/src/plugins/ui_actions/public/actions/action_internal.ts +++ b/src/plugins/ui_actions/public/actions/action_internal.ts @@ -59,7 +59,7 @@ export class ActionInternal return this.definition.getIconType(context); } - public getDisplayName(context: Context): string { + public getDisplayName(context: Context): JSX.Element | string { if (!this.definition.getDisplayName) return `Action: ${this.id}`; return this.definition.getDisplayName(context); } 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 949595d60939..b9afca9fb99c 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 @@ -36,20 +36,28 @@ const createTestAction = ({ type, dispayName, order, + grouping, }: { type?: string; dispayName: string; order?: number; + grouping?: any[]; }) => createAction({ type: type as any, // mapping doesn't matter for this test getDisplayName: () => dispayName, order, execute: async () => {}, + grouping, }); const resultMapper = (panel: EuiContextMenuPanelDescriptor) => ({ - items: panel.items ? panel.items.map((item) => ({ name: item.name })) : [], + items: panel.items + ? panel.items.map((item) => ({ + ...(item.name ? { name: item.name } : {}), + ...(item.isSeparator ? { isSeparator: true } : {}), + })) + : [], }); test('sorts items in DESC order by "order" field first, then by display name', async () => { @@ -248,3 +256,195 @@ test('hides items behind in "More" submenu if there are more than 4 actions', as ] `); }); + +test('flattening of group with only one action', async () => { + const grouping1 = [ + { + id: 'test-group', + getDisplayName: () => 'Test group', + getIconType: () => 'bell', + }, + ]; + const actions = [ + createTestAction({ + dispayName: 'Foo 1', + }), + createTestAction({ + dispayName: 'Bar 1', + grouping: grouping1, + }), + ]; + 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": "Bar 1", + }, + ], + }, + Object { + "items": Array [ + Object { + "name": "Bar 1", + }, + ], + }, + ] + `); +}); + +test('grouping with only two actions', async () => { + const grouping1 = [ + { + id: 'test-group', + getDisplayName: () => 'Test group', + getIconType: () => 'bell', + }, + ]; + const actions = [ + createTestAction({ + dispayName: 'Foo 1', + }), + createTestAction({ + dispayName: 'Bar 1', + grouping: grouping1, + }), + createTestAction({ + dispayName: 'Bar 2', + grouping: grouping1, + }), + ]; + 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 { + "items": Array [ + Object { + "name": "Bar 1", + }, + Object { + "name": "Bar 2", + }, + ], + }, + ] + `); +}); + +test('groups with deep nesting', async () => { + const grouping1 = [ + { + id: 'test-group', + getDisplayName: () => 'Test group', + getIconType: () => 'bell', + }, + ]; + const grouping2 = [ + { + id: 'test-group-2', + getDisplayName: () => 'Test group 2', + getIconType: () => 'bell', + }, + { + id: 'test-group-3', + getDisplayName: () => 'Test group 3', + getIconType: () => 'bell', + }, + ]; + + const actions = [ + createTestAction({ + dispayName: 'Foo 1', + }), + createTestAction({ + dispayName: 'Bar 1', + grouping: grouping1, + }), + createTestAction({ + dispayName: 'Bar 2', + grouping: grouping1, + }), + createTestAction({ + dispayName: 'Qux 1', + grouping: grouping2, + }), + ]; + 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": "Test group 3", + }, + ], + }, + Object { + "items": Array [ + Object { + "name": "Bar 1", + }, + Object { + "name": "Bar 2", + }, + ], + }, + Object { + "items": Array [ + Object { + "name": "Test group 3", + }, + ], + }, + Object { + "items": Array [ + Object { + "name": "Qux 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 054900f52b77..6d69be1f3faa 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 @@ -146,6 +146,7 @@ export async function buildContextMenuForActions({ closeMenu = () => {}, }: BuildContextMenuParams): Promise { const panels: Record = { + // This is the first panel which links out to all others via items property mainMenu: { id: 'mainMenu', title, @@ -157,35 +158,51 @@ export async function buildContextMenuForActions({ const context: ActionExecutionContext = { ...item.context, trigger: item.trigger }; const isCompatible = await item.action.isCompatible(context); if (!isCompatible) return; - let parentPanel = ''; - let currentPanel = ''; + + // Reference to the last/parent/upper group. + // Groups are provided in order of parent to children. + let parentGroupId = ''; + if (action.grouping) { for (let i = 0; i < action.grouping.length; i++) { const group = action.grouping[i]; - currentPanel = group.id; - if (!panels[currentPanel]) { + const groupId = group.id; + + // If a panel does not exist for the current group, then create it + if (!panels[groupId]) { const name = group.getDisplayName ? group.getDisplayName(context) : group.id; - panels[currentPanel] = { - id: currentPanel, + + // Create panel for group + panels[groupId] = { + id: groupId, title: name, items: [], _level: i, _icon: group.getIconType ? group.getIconType(context) : 'empty', }; - if (parentPanel) { - panels[parentPanel].items!.push({ + + // If there are multiple groups and this is not the first group, + // then add an item to the parent group relating to this group + if (parentGroupId) { + panels[parentGroupId].items!.push({ name, - panel: currentPanel, + panel: groupId, icon: group.getIconType ? group.getIconType(context) : 'empty', _order: group.order || 0, _title: group.getDisplayName ? group.getDisplayName(context) : '', }); } } - parentPanel = currentPanel; + + // Save the current group, because it will be used as the parent group + // for adding items to it for any additional groups in the array + parentGroupId = groupId; } } - panels[parentPanel || 'mainMenu'].items!.push({ + + // Add a context menu item for this action so it shows up on a context menu panel. + // We add this within the parent group or default to the mainMenu panel. + panels[parentGroupId || 'mainMenu'].items!.push({ name: action.MenuItem ? React.createElement(uiToReactComponent(action.MenuItem), { context }) : action.getDisplayName(context), @@ -197,8 +214,10 @@ export async function buildContextMenuForActions({ _title: action.getDisplayName(context), }); }); + await Promise.all(promises); + // For each panel, sort items by order and title for (const panel of Object.values(panels)) { const items = panel.items.filter(Boolean) as ItemDescriptor[]; panel.items = _.sortBy( @@ -208,13 +227,23 @@ export async function buildContextMenuForActions({ ); } + // On the mainMenu, before adding in items for other groups, the first 4 items are shown. + // Any additional items are hidden behind a "more" item wrapMainPanelItemsIntoSubmenu(panels, 'mainMenu'); 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) { - // TODO: Add separator line here once it is available in EUI. - // See https://github.com/elastic/eui/pull/4018 - if (panel.items.length > 3) { + // 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. + // Note: this only happens on the root level panels, not for inner groups. + if (panel.items.length > 1) { panels.mainMenu.items.push({ name: panel.title || panel.id, icon: panel._icon || 'empty',