Skip to content

Commit 59d5356

Browse files
enhance grouping for context menu options (#3924) (#4128)
* enhance grouping for context menu options * build panels tests and more comments 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: Josh Romero <rmerqg@amazon.com> Co-authored-by: Josh Romero <rmerqg@amazon.com> (cherry picked from commit 1524784) Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> # Conflicts: # CHANGELOG.md Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 128b20b commit 59d5356

File tree

5 files changed

+248
-17
lines changed

5 files changed

+248
-17
lines changed

examples/ui_actions_explorer/public/context_menu_examples/context_menu_examples.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export const ContextMenuExamples: React.FC = () => {
4444
<p>
4545
Below examples show how context menu panels look with varying number of actions and how the
4646
actions can be grouped into different panels using <EuiCode>grouping</EuiCode> field.
47+
Grouping can only be one layer deep. A group needs to have at least two items for grouping
48+
to work. A separator is automatically added between groups.
4749
</p>
4850

4951
<EuiFlexGroup>

src/plugins/ui_actions/public/actions/action.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export interface Action<Context extends BaseContext = {}, T = ActionType>
9292
* Returns a title to be displayed to the user.
9393
* @param context
9494
*/
95-
getDisplayName(context: ActionExecutionContext<Context>): string;
95+
getDisplayName(context: ActionExecutionContext<Context>): JSX.Element | string;
9696

9797
/**
9898
* `UiComponent` to render when displaying this action as a context menu item.

src/plugins/ui_actions/public/actions/action_internal.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export class ActionInternal<A extends ActionDefinition = ActionDefinition>
5959
return this.definition.getIconType(context);
6060
}
6161

62-
public getDisplayName(context: Context<A>): string {
62+
public getDisplayName(context: Context<A>): JSX.Element | string {
6363
if (!this.definition.getDisplayName) return `Action: ${this.id}`;
6464
return this.definition.getDisplayName(context);
6565
}

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

+201-1
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,28 @@ const createTestAction = ({
3636
type,
3737
dispayName,
3838
order,
39+
grouping,
3940
}: {
4041
type?: string;
4142
dispayName: string;
4243
order?: number;
44+
grouping?: any[];
4345
}) =>
4446
createAction({
4547
type: type as any, // mapping doesn't matter for this test
4648
getDisplayName: () => dispayName,
4749
order,
4850
execute: async () => {},
51+
grouping,
4952
});
5053

5154
const resultMapper = (panel: EuiContextMenuPanelDescriptor) => ({
52-
items: panel.items ? panel.items.map((item) => ({ name: item.name })) : [],
55+
items: panel.items
56+
? panel.items.map((item) => ({
57+
...(item.name ? { name: item.name } : {}),
58+
...(item.isSeparator ? { isSeparator: true } : {}),
59+
}))
60+
: [],
5361
});
5462

5563
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
248256
]
249257
`);
250258
});
259+
260+
test('flattening of group with only one action', async () => {
261+
const grouping1 = [
262+
{
263+
id: 'test-group',
264+
getDisplayName: () => 'Test group',
265+
getIconType: () => 'bell',
266+
},
267+
];
268+
const actions = [
269+
createTestAction({
270+
dispayName: 'Foo 1',
271+
}),
272+
createTestAction({
273+
dispayName: 'Bar 1',
274+
grouping: grouping1,
275+
}),
276+
];
277+
const menu = await buildContextMenuForActions({
278+
actions: actions.map((action) => ({ action, context: {}, trigger: 'TEST' as any })),
279+
});
280+
281+
expect(menu.map(resultMapper)).toMatchInlineSnapshot(`
282+
Array [
283+
Object {
284+
"items": Array [
285+
Object {
286+
"name": "Foo 1",
287+
},
288+
Object {
289+
"isSeparator": true,
290+
},
291+
Object {
292+
"name": "Bar 1",
293+
},
294+
],
295+
},
296+
Object {
297+
"items": Array [
298+
Object {
299+
"name": "Bar 1",
300+
},
301+
],
302+
},
303+
]
304+
`);
305+
});
306+
307+
test('grouping with only two actions', async () => {
308+
const grouping1 = [
309+
{
310+
id: 'test-group',
311+
getDisplayName: () => 'Test group',
312+
getIconType: () => 'bell',
313+
},
314+
];
315+
const actions = [
316+
createTestAction({
317+
dispayName: 'Foo 1',
318+
}),
319+
createTestAction({
320+
dispayName: 'Bar 1',
321+
grouping: grouping1,
322+
}),
323+
createTestAction({
324+
dispayName: 'Bar 2',
325+
grouping: grouping1,
326+
}),
327+
];
328+
const menu = await buildContextMenuForActions({
329+
actions: actions.map((action) => ({ action, context: {}, trigger: 'TEST' as any })),
330+
});
331+
332+
expect(menu.map(resultMapper)).toMatchInlineSnapshot(`
333+
Array [
334+
Object {
335+
"items": Array [
336+
Object {
337+
"name": "Foo 1",
338+
},
339+
Object {
340+
"isSeparator": true,
341+
},
342+
Object {
343+
"name": "Test group",
344+
},
345+
],
346+
},
347+
Object {
348+
"items": Array [
349+
Object {
350+
"name": "Bar 1",
351+
},
352+
Object {
353+
"name": "Bar 2",
354+
},
355+
],
356+
},
357+
]
358+
`);
359+
});
360+
361+
test('groups with deep nesting', async () => {
362+
const grouping1 = [
363+
{
364+
id: 'test-group',
365+
getDisplayName: () => 'Test group',
366+
getIconType: () => 'bell',
367+
},
368+
];
369+
const grouping2 = [
370+
{
371+
id: 'test-group-2',
372+
getDisplayName: () => 'Test group 2',
373+
getIconType: () => 'bell',
374+
},
375+
{
376+
id: 'test-group-3',
377+
getDisplayName: () => 'Test group 3',
378+
getIconType: () => 'bell',
379+
},
380+
];
381+
382+
const actions = [
383+
createTestAction({
384+
dispayName: 'Foo 1',
385+
}),
386+
createTestAction({
387+
dispayName: 'Bar 1',
388+
grouping: grouping1,
389+
}),
390+
createTestAction({
391+
dispayName: 'Bar 2',
392+
grouping: grouping1,
393+
}),
394+
createTestAction({
395+
dispayName: 'Qux 1',
396+
grouping: grouping2,
397+
}),
398+
];
399+
const menu = await buildContextMenuForActions({
400+
actions: actions.map((action) => ({ action, context: {}, trigger: 'TEST' as any })),
401+
});
402+
403+
expect(menu.map(resultMapper)).toMatchInlineSnapshot(`
404+
Array [
405+
Object {
406+
"items": Array [
407+
Object {
408+
"name": "Foo 1",
409+
},
410+
Object {
411+
"isSeparator": true,
412+
},
413+
Object {
414+
"name": "Test group",
415+
},
416+
Object {
417+
"isSeparator": true,
418+
},
419+
Object {
420+
"name": "Test group 3",
421+
},
422+
],
423+
},
424+
Object {
425+
"items": Array [
426+
Object {
427+
"name": "Bar 1",
428+
},
429+
Object {
430+
"name": "Bar 2",
431+
},
432+
],
433+
},
434+
Object {
435+
"items": Array [
436+
Object {
437+
"name": "Test group 3",
438+
},
439+
],
440+
},
441+
Object {
442+
"items": Array [
443+
Object {
444+
"name": "Qux 1",
445+
},
446+
],
447+
},
448+
]
449+
`);
450+
});

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

+43-14
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export async function buildContextMenuForActions({
146146
closeMenu = () => {},
147147
}: BuildContextMenuParams): Promise<EuiContextMenuPanelDescriptor[]> {
148148
const panels: Record<string, PanelDescriptor> = {
149+
// This is the first panel which links out to all others via items property
149150
mainMenu: {
150151
id: 'mainMenu',
151152
title,
@@ -157,35 +158,51 @@ export async function buildContextMenuForActions({
157158
const context: ActionExecutionContext<object> = { ...item.context, trigger: item.trigger };
158159
const isCompatible = await item.action.isCompatible(context);
159160
if (!isCompatible) return;
160-
let parentPanel = '';
161-
let currentPanel = '';
161+
162+
// Reference to the last/parent/upper group.
163+
// Groups are provided in order of parent to children.
164+
let parentGroupId = '';
165+
162166
if (action.grouping) {
163167
for (let i = 0; i < action.grouping.length; i++) {
164168
const group = action.grouping[i];
165-
currentPanel = group.id;
166-
if (!panels[currentPanel]) {
169+
const groupId = group.id;
170+
171+
// If a panel does not exist for the current group, then create it
172+
if (!panels[groupId]) {
167173
const name = group.getDisplayName ? group.getDisplayName(context) : group.id;
168-
panels[currentPanel] = {
169-
id: currentPanel,
174+
175+
// Create panel for group
176+
panels[groupId] = {
177+
id: groupId,
170178
title: name,
171179
items: [],
172180
_level: i,
173181
_icon: group.getIconType ? group.getIconType(context) : 'empty',
174182
};
175-
if (parentPanel) {
176-
panels[parentPanel].items!.push({
183+
184+
// If there are multiple groups and this is not the first group,
185+
// then add an item to the parent group relating to this group
186+
if (parentGroupId) {
187+
panels[parentGroupId].items!.push({
177188
name,
178-
panel: currentPanel,
189+
panel: groupId,
179190
icon: group.getIconType ? group.getIconType(context) : 'empty',
180191
_order: group.order || 0,
181192
_title: group.getDisplayName ? group.getDisplayName(context) : '',
182193
});
183194
}
184195
}
185-
parentPanel = currentPanel;
196+
197+
// Save the current group, because it will be used as the parent group
198+
// for adding items to it for any additional groups in the array
199+
parentGroupId = groupId;
186200
}
187201
}
188-
panels[parentPanel || 'mainMenu'].items!.push({
202+
203+
// Add a context menu item for this action so it shows up on a context menu panel.
204+
// We add this within the parent group or default to the mainMenu panel.
205+
panels[parentGroupId || 'mainMenu'].items!.push({
189206
name: action.MenuItem
190207
? React.createElement(uiToReactComponent(action.MenuItem), { context })
191208
: action.getDisplayName(context),
@@ -197,8 +214,10 @@ export async function buildContextMenuForActions({
197214
_title: action.getDisplayName(context),
198215
});
199216
});
217+
200218
await Promise.all(promises);
201219

220+
// For each panel, sort items by order and title
202221
for (const panel of Object.values(panels)) {
203222
const items = panel.items.filter(Boolean) as ItemDescriptor[];
204223
panel.items = _.sortBy(
@@ -208,13 +227,23 @@ export async function buildContextMenuForActions({
208227
);
209228
}
210229

230+
// On the mainMenu, before adding in items for other groups, the first 4 items are shown.
231+
// Any additional items are hidden behind a "more" item
211232
wrapMainPanelItemsIntoSubmenu(panels, 'mainMenu');
212233

213234
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
214237
if (panel._level === 0) {
215-
// TODO: Add separator line here once it is available in EUI.
216-
// See https://github.com/elastic/eui/pull/4018
217-
if (panel.items.length > 3) {
238+
// Add separator with unique key if needed
239+
if (panels.mainMenu.items.length) {
240+
panels.mainMenu.items.push({ isSeparator: true, key: `${panel.id}separator` });
241+
}
242+
243+
// 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.
245+
// Note: this only happens on the root level panels, not for inner groups.
246+
if (panel.items.length > 1) {
218247
panels.mainMenu.items.push({
219248
name: panel.title || panel.id,
220249
icon: panel._icon || 'empty',

0 commit comments

Comments
 (0)