PluginExtensions: Add category to link extensions (#71074)

* adding a panel menu group item to UI extensions.

* draft

* Fixed so we support groups in the old panel menu.

* added proper styling.

* Made tests green and small refactor.

* Added tests for panel menu category.

* Will truncate the extensions category to 25 chars.

* added test for truncating category.
pull/72194/head
Marcus Andersson 2 years ago committed by GitHub
parent 689d9ed430
commit 57a54fc38a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      packages/grafana-data/src/types/panel.ts
  2. 5
      packages/grafana-data/src/types/pluginExtensions.ts
  3. 65
      public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx
  4. 62
      public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx
  5. 140
      public/app/features/dashboard/utils/getPanelMenu.test.ts
  6. 77
      public/app/features/dashboard/utils/getPanelMenu.ts
  7. 2
      public/app/features/plugins/extensions/getPluginExtensions.test.ts
  8. 3
      public/app/features/plugins/extensions/getPluginExtensions.ts

@ -163,7 +163,7 @@ export interface PanelOptionsEditorConfig<TOptions, TSettings = any, TValue = an
* @internal
*/
export interface PanelMenuItem {
type?: 'submenu' | 'divider';
type?: 'submenu' | 'divider' | 'group';
text: string;
iconClassName?: IconName;
onClick?: (event: React.MouseEvent<any>) => void;

@ -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<Context extends object = object> = {
path: string;
onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => 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<Context extends object = object> = {

@ -44,30 +44,61 @@ export class PanelHeaderMenu extends PureComponent<Props> {
render() {
return (
<div className={classnames('panel-menu-container', 'dropdown', 'open', this.props.className)}>
{this.renderItems(this.props.items)}
{this.renderItems(flattenGroups(this.props.items))}
</div>
);
}
}
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' ? (
<Menu.Divider key={item.text} />
) : (
<Menu.Item
key={item.text}
label={item.text}
icon={item.iconClassName}
childItems={item.subMenu ? renderItems(item.subMenu) : undefined}
url={item.href}
onClick={item.onClick}
shortcut={item.shortcut}
testId={selectors.components.Panels.Panel.menuItems(item.text)}
/>
)
);
return items.map((item) => {
switch (item.type) {
case 'divider':
return <Menu.Divider key={item.text} />;
case 'group':
return (
<Menu.Group key={item.text} label={item.text}>
{item.subMenu ? renderItems(item.subMenu) : undefined}
</Menu.Group>
);
default:
return (
<Menu.Item
key={item.text}
label={item.text}
icon={item.iconClassName}
childItems={item.subMenu ? renderItems(item.subMenu) : undefined}
url={item.href}
onClick={item.onClick}
shortcut={item.shortcut}
testId={selectors.components.Panels.Panel.menuItems(item.text)}
/>
);
}
});
};
return <Menu>{renderItems(items)}</Menu>;

@ -12,35 +12,42 @@ interface Props {
export const PanelHeaderMenuItem = (props: Props & PanelMenuItem) => {
const [ref, setRef] = useState<HTMLLIElement | null>(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 ? (
<li className="divider" />
) : (
<li
className={isSubMenu ? `dropdown-submenu ${getDropdownLocationCssClass(ref)}` : undefined}
ref={setRef}
data-testid={selectors.components.Panels.Panel.menuItems(props.text)}
>
<a onClick={props.onClick} href={props.href} role="menuitem">
{icon && <Icon name={icon} className={styles.menuIconClassName} />}
<span className="dropdown-item-text" aria-label={selectors.components.Panels.Panel.headerItems(props.text)}>
{props.text}
{isSubMenu && <Icon name="angle-right" className={styles.shortcutIconClassName} />}
</span>
switch (props.type) {
case 'divider':
return <li className="divider" />;
case 'group':
return (
<li>
<span className={styles.groupLabel}>{props.text}</span>
</li>
);
default:
return (
<li
className={isSubMenu ? `dropdown-submenu ${getDropdownLocationCssClass(ref)}` : undefined}
ref={setRef}
data-testid={selectors.components.Panels.Panel.menuItems(props.text)}
>
<a onClick={props.onClick} href={props.href} role="menuitem">
{icon && <Icon name={icon} className={styles.menuIconClassName} />}
<span className="dropdown-item-text" aria-label={selectors.components.Panels.Panel.headerItems(props.text)}>
{props.text}
{isSubMenu && <Icon name="angle-right" className={styles.shortcutIconClassName} />}
</span>
{props.shortcut && (
<span className="dropdown-menu-item-shortcut">
<Icon name="keyboard" className={styles.menuIconClassName} /> {props.shortcut}
</span>
)}
</a>
{props.children}
</li>
);
{props.shortcut && (
<span className="dropdown-menu-item-shortcut">
<Icon name="keyboard" className={styles.menuIconClassName} /> {props.shortcut}
</span>
)}
</a>
{props.children}
</li>
);
}
};
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),
}),
};
}

@ -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',
}),
])
);
});
});

@ -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<string, PanelMenuItem[]> = {};
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;
}

@ -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', () => {

@ -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) {

Loading…
Cancel
Save