diff --git a/packages/grafana-data/src/types/panel.ts b/packages/grafana-data/src/types/panel.ts index 99c580deeac..5f9bf8b54b4 100644 --- a/packages/grafana-data/src/types/panel.ts +++ b/packages/grafana-data/src/types/panel.ts @@ -163,7 +163,7 @@ export interface PanelOptionsEditorConfig) => void; diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 93229833e16..7bed037bb9a 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -29,6 +29,7 @@ export type PluginExtensionLink = PluginExtensionBase & { path?: string; onClick?: (event?: React.MouseEvent) => void; icon?: IconName; + category?: string; }; export type PluginExtensionComponent = PluginExtensionBase & { @@ -65,11 +66,15 @@ export type PluginExtensionLinkConfig = { path: string; onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => void; icon: IconName; + category: string; }> | undefined; // (Optional) A icon that can be displayed in the ui for the extension option. icon?: IconName; + + // (Optional) A category to be used when grouping the options in the ui + category?: string; }; export type PluginExtensionComponentConfig = { diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx index 885c598faf0..b32a6f67228 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx @@ -44,30 +44,61 @@ export class PanelHeaderMenu extends PureComponent { render() { return (
- {this.renderItems(this.props.items)} + {this.renderItems(flattenGroups(this.props.items))}
); } } +function flattenGroups(items: PanelMenuItem[]): PanelMenuItem[] { + return items.reduce((all: PanelMenuItem[], item) => { + if (Array.isArray(item.subMenu) && item.type === 'submenu') { + all.push({ + ...item, + subMenu: flattenGroups(item.subMenu), + }); + return all; + } + + if (Array.isArray(item.subMenu) && item.type === 'group') { + const { subMenu, ...rest } = item; + all.push(rest); + all.push.apply(all, flattenGroups(subMenu)); + return all; + } + + all.push(item); + return all; + }, []); +} + export function PanelHeaderMenuNew({ items }: Props) { const renderItems = (items: PanelMenuItem[]) => { - return items.map((item) => - item.type === 'divider' ? ( - - ) : ( - - ) - ); + return items.map((item) => { + switch (item.type) { + case 'divider': + return ; + case 'group': + return ( + + {item.subMenu ? renderItems(item.subMenu) : undefined} + + ); + default: + return ( + + ); + } + }); }; return {renderItems(items)}; diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx index 12d589bb68c..e9d8291a48e 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx @@ -12,35 +12,42 @@ interface Props { export const PanelHeaderMenuItem = (props: Props & PanelMenuItem) => { const [ref, setRef] = useState(null); const isSubMenu = props.type === 'submenu'; - const isDivider = props.type === 'divider'; const styles = useStyles2(getStyles); - const icon = props.iconClassName ? toIconName(props.iconClassName) : undefined; - return isDivider ? ( -
  • - ) : ( -
  • - - {icon && } - - {props.text} - {isSubMenu && } - + switch (props.type) { + case 'divider': + return
  • ; + case 'group': + return ( +
  • + {props.text} +
  • + ); + default: + return ( +
  • + + {icon && } + + {props.text} + {isSubMenu && } + - {props.shortcut && ( - - {props.shortcut} - - )} - - {props.children} -
  • - ); + {props.shortcut && ( + + {props.shortcut} + + )} + + {props.children} + + ); + } }; function getDropdownLocationCssClass(element: HTMLElement | null) { @@ -76,5 +83,10 @@ function getStyles(theme: GrafanaTheme2) { right: theme.spacing(0.5), color: theme.colors.text.secondary, }), + groupLabel: css({ + color: theme.colors.text.secondary, + fontSize: theme.typography.size.sm, + padding: theme.spacing(0.5, 1), + }), }; } diff --git a/public/app/features/dashboard/utils/getPanelMenu.test.ts b/public/app/features/dashboard/utils/getPanelMenu.test.ts index b5bf987ae18..88bbb6cf1ff 100644 --- a/public/app/features/dashboard/utils/getPanelMenu.test.ts +++ b/public/app/features/dashboard/utils/getPanelMenu.test.ts @@ -10,7 +10,7 @@ import { PluginExtensionTypes, toDataFrame, } from '@grafana/data'; -import { AngularComponent, getPluginExtensions } from '@grafana/runtime'; +import { AngularComponent, getPluginLinkExtensions } from '@grafana/runtime'; import config from 'app/core/config'; import * as actions from 'app/features/explore/state/main'; import { setStore } from 'app/store/store'; @@ -29,13 +29,15 @@ jest.mock('app/core/services/context_srv', () => ({ jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), setPluginExtensionGetter: jest.fn(), - getPluginExtensions: jest.fn(), + getPluginLinkExtensions: jest.fn(), })); +const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions); + describe('getPanelMenu()', () => { beforeEach(() => { - (getPluginExtensions as jest.Mock).mockRestore(); - (getPluginExtensions as jest.Mock).mockReturnValue({ extensions: [] }); + getPluginLinkExtensionsMock.mockRestore(); + getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] }); }); it('should return the correct panel menu items', () => { @@ -119,9 +121,10 @@ describe('getPanelMenu()', () => { describe('when extending panel menu from plugins', () => { it('should contain menu item from link extension', () => { - (getPluginExtensions as jest.Mock).mockReturnValue({ + getPluginLinkExtensionsMock.mockReturnValue({ extensions: [ { + id: '1', pluginId: '...', type: PluginExtensionTypes.link, title: 'Declare incident', @@ -147,9 +150,10 @@ describe('getPanelMenu()', () => { }); it('should truncate menu item title to 25 chars', () => { - (getPluginExtensions as jest.Mock).mockReturnValue({ + getPluginLinkExtensionsMock.mockReturnValue({ extensions: [ { + id: '1', pluginId: '...', type: PluginExtensionTypes.link, title: 'Declare incident when pressing this amazing menu item', @@ -177,9 +181,10 @@ describe('getPanelMenu()', () => { it('should pass onClick from plugin extension link to menu item', () => { const expectedOnClick = jest.fn(); - (getPluginExtensions as jest.Mock).mockReturnValue({ + getPluginLinkExtensionsMock.mockReturnValue({ extensions: [ { + id: '1', pluginId: '...', type: PluginExtensionTypes.link, title: 'Declare incident when pressing this amazing menu item', @@ -287,7 +292,126 @@ describe('getPanelMenu()', () => { data, }; - expect(getPluginExtensions).toBeCalledWith(expect.objectContaining({ context })); + expect(getPluginLinkExtensionsMock).toBeCalledWith(expect.objectContaining({ context })); + }); + + it('should contain menu item with category', () => { + getPluginLinkExtensionsMock.mockReturnValue({ + extensions: [ + { + id: '1', + pluginId: '...', + type: PluginExtensionTypes.link, + title: 'Declare incident', + description: 'Declaring an incident in the app', + path: '/a/grafana-basic-app/declare-incident', + category: 'Incident', + }, + ], + }); + + const panel = new PanelModel({}); + const dashboard = createDashboardModelFixture({}); + const menuItems = getPanelMenu(dashboard, panel); + const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu; + + expect(extensionsSubMenu).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: 'Incident', + subMenu: expect.arrayContaining([ + expect.objectContaining({ + text: 'Declare incident', + href: '/a/grafana-basic-app/declare-incident', + }), + ]), + }), + ]) + ); + }); + + it('should truncate category to 25 chars', () => { + getPluginLinkExtensionsMock.mockReturnValue({ + extensions: [ + { + id: '1', + pluginId: '...', + type: PluginExtensionTypes.link, + title: 'Declare incident', + description: 'Declaring an incident in the app', + path: '/a/grafana-basic-app/declare-incident', + category: 'Declare incident when pressing this amazing menu item', + }, + ], + }); + + const panel = new PanelModel({}); + const dashboard = createDashboardModelFixture({}); + const menuItems = getPanelMenu(dashboard, panel); + const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu; + + expect(extensionsSubMenu).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: 'Declare incident when...', + subMenu: expect.arrayContaining([ + expect.objectContaining({ + text: 'Declare incident', + href: '/a/grafana-basic-app/declare-incident', + }), + ]), + }), + ]) + ); + }); + + it('should contain menu item with category and append items without category after divider', () => { + getPluginLinkExtensionsMock.mockReturnValue({ + extensions: [ + { + id: '1', + pluginId: '...', + type: PluginExtensionTypes.link, + title: 'Declare incident', + description: 'Declaring an incident in the app', + path: '/a/grafana-basic-app/declare-incident', + category: 'Incident', + }, + { + id: '2', + pluginId: '...', + type: PluginExtensionTypes.link, + title: 'Create forecast', + description: 'Declaring an incident in the app', + path: '/a/grafana-basic-app/declare-incident', + }, + ], + }); + + const panel = new PanelModel({}); + const dashboard = createDashboardModelFixture({}); + const menuItems = getPanelMenu(dashboard, panel); + const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu; + + expect(extensionsSubMenu).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: 'Incident', + subMenu: expect.arrayContaining([ + expect.objectContaining({ + text: 'Declare incident', + href: '/a/grafana-basic-app/declare-incident', + }), + ]), + }), + expect.objectContaining({ + type: 'divider', + }), + expect.objectContaining({ + text: 'Create forecast', + }), + ]) + ); }); }); diff --git a/public/app/features/dashboard/utils/getPanelMenu.ts b/public/app/features/dashboard/utils/getPanelMenu.ts index ac58b5b3b85..00477588ee0 100644 --- a/public/app/features/dashboard/utils/getPanelMenu.ts +++ b/public/app/features/dashboard/utils/getPanelMenu.ts @@ -1,11 +1,15 @@ -import { PanelMenuItem, PluginExtensionPoints, type PluginExtensionPanelContext } from '@grafana/data'; import { - isPluginExtensionLink, + PanelMenuItem, + PluginExtensionLink, + PluginExtensionPoints, + type PluginExtensionPanelContext, +} from '@grafana/data'; +import { AngularComponent, getDataSourceSrv, - getPluginExtensions, locationService, reportInteraction, + getPluginLinkExtensions, } from '@grafana/runtime'; import { PanelCtrl } from 'app/angular/panel/panel_ctrl'; import config from 'app/core/config'; @@ -275,31 +279,18 @@ export function getPanelMenu( }); } - const { extensions } = getPluginExtensions({ + const { extensions } = getPluginLinkExtensions({ extensionPointId: PluginExtensionPoints.DashboardPanelMenu, context: createExtensionContext(panel, dashboard), limitPerPlugin: 2, }); if (extensions.length > 0 && !panel.isEditing) { - const extensionsMenu: PanelMenuItem[] = []; - - for (const extension of extensions) { - if (isPluginExtensionLink(extension)) { - extensionsMenu.push({ - text: truncateTitle(extension.title, 25), - href: extension.path, - onClick: extension.onClick, - }); - continue; - } - } - menu.push({ text: 'Extensions', iconClassName: 'plug', type: 'submenu', - subMenu: extensionsMenu, + subMenu: createExtensionSubMenu(extensions), }); } @@ -344,3 +335,53 @@ function createExtensionContext(panel: PanelModel, dashboard: DashboardModel): P data: panel.getQueryRunner().getLastResult(), }; } + +function createExtensionSubMenu(extensions: PluginExtensionLink[]): PanelMenuItem[] { + const categorized: Record = {}; + const uncategorized: PanelMenuItem[] = []; + + for (const extension of extensions) { + const category = extension.category; + + if (!category) { + uncategorized.push({ + text: truncateTitle(extension.title, 25), + href: extension.path, + onClick: extension.onClick, + }); + continue; + } + + if (!Array.isArray(categorized[category])) { + categorized[category] = []; + } + + categorized[category].push({ + text: truncateTitle(extension.title, 25), + href: extension.path, + onClick: extension.onClick, + }); + } + + const subMenu = Object.keys(categorized).reduce((subMenu: PanelMenuItem[], category) => { + subMenu.push({ + text: truncateTitle(category, 25), + type: 'group', + subMenu: categorized[category], + }); + return subMenu; + }, []); + + if (uncategorized.length > 0) { + if (subMenu.length > 0) { + subMenu.push({ + text: 'divider', + type: 'divider', + }); + } + + Array.prototype.push.apply(subMenu, uncategorized); + } + + return subMenu; +} diff --git a/public/app/features/plugins/extensions/getPluginExtensions.test.ts b/public/app/features/plugins/extensions/getPluginExtensions.test.ts index 1b921dae87e..0ad86c5f080 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.test.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.test.ts @@ -117,6 +117,7 @@ describe('getPluginExtensions()', () => { description: 'Updated description', path: `/a/${pluginId}/updated-path`, icon: 'search', + category: 'Machine Learning', })); const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); @@ -130,6 +131,7 @@ describe('getPluginExtensions()', () => { expect(extension.description).toBe('Updated description'); expect(extension.path).toBe(`/a/${pluginId}/updated-path`); expect(extension.icon).toBe('search'); + expect(extension.category).toBe('Machine Learning'); }); test('should hide the extension if it tries to override not-allowed properties with the configure() function', () => { diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index 8cccc936252..e878989ebf1 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -78,6 +78,7 @@ export const getPluginExtensions: GetExtensions = ({ context, extensionPointId, title: overrides?.title || extensionConfig.title, description: overrides?.description || extensionConfig.description, path: overrides?.path || extensionConfig.path, + category: overrides?.category || extensionConfig.category, }; extensions.push(extension); @@ -125,6 +126,7 @@ function getLinkExtensionOverrides(pluginId: string, config: PluginExtensionLink description = config.description, path = config.path, icon = config.icon, + category = config.category, ...rest } = overrides; @@ -149,6 +151,7 @@ function getLinkExtensionOverrides(pluginId: string, config: PluginExtensionLink description, path, icon, + category, }; } catch (error) { if (error instanceof Error) {