PluginExtensions: Added onClick to link as a replacement for the command type (#65837)

* Added onClick to the link and made path optional.

* Added type to the import.

* revert(plugin-extensions): put back isPromise utility function

* Minor update to the isPromise function.

* added onClick in the panel menu item.

---------

Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com>
Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>
This commit is contained in:
Marcus Andersson 2023-04-03 17:27:55 +02:00 committed by GitHub
parent bd29071a0d
commit 8b738d770c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 191 additions and 24 deletions

View File

@ -3,7 +3,7 @@ import { ComponentType } from 'react';
import { KeyValue } from './data';
import { NavModel } from './navModel';
import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin';
import type { PluginExtensionLinkConfig } from './pluginExtensions';
import { type PluginExtensionLinkConfig, PluginExtensionTypes } from './pluginExtensions';
/**
* @public
@ -97,8 +97,11 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
return this._extensionConfigs;
}
configureExtensionLink<Context extends object>(extension: PluginExtensionLinkConfig<Context>) {
this._extensionConfigs.push(extension as PluginExtensionLinkConfig);
configureExtensionLink<Context extends object>(extension: Exclude<PluginExtensionLinkConfig<Context>, 'type'>) {
this._extensionConfigs.push({
...extension,
type: PluginExtensionTypes.link,
} as PluginExtensionLinkConfig);
return this;
}

View File

@ -17,7 +17,8 @@ export type PluginExtension = {
export type PluginExtensionLink = PluginExtension & {
type: PluginExtensionTypes.link;
path: string;
path?: string;
onClick?: (event: React.MouseEvent) => void;
};
// Objects used for registering extensions (in app plugins)
@ -40,10 +41,14 @@ export type PluginExtensionConfig<Context extends object = object, ExtraProps ex
export type PluginExtensionLinkConfig<Context extends object = object> = PluginExtensionConfig<
Context,
Pick<PluginExtensionLink, 'path'>
Pick<PluginExtensionLink, 'path'> & {
type: PluginExtensionTypes.link;
onClick?: (event: React.MouseEvent, helpers: PluginExtensionEventHelpers<Context>) => void;
}
>;
export type PluginExtensionEventHelpers = {
export type PluginExtensionEventHelpers<Context extends object = object> = {
context?: Readonly<Context>;
// Opens a modal dialog and renders the provided React component inside it
openModal: (options: {
// The title of the modal

View File

@ -15,6 +15,17 @@ describe('Plugin Extensions / Utils', () => {
path: '...',
} as PluginExtension)
).toBe(true);
expect(
isPluginExtensionLink({
id: 'id',
pluginId: 'plugin-id',
type: PluginExtensionTypes.link,
title: 'Title',
description: 'Description',
onClick: () => {},
} as PluginExtension)
).toBe(true);
});
test('should return FALSE if the object is NOT a link extension', () => {
expect(

View File

@ -4,6 +4,5 @@ export function isPluginExtensionLink(extension: PluginExtension | undefined): e
if (!extension) {
return false;
}
return extension.type === PluginExtensionTypes.link && 'path' in extension;
return extension.type === PluginExtensionTypes.link && ('path' in extension || 'onClick' in extension);
}

View File

@ -163,6 +163,31 @@ describe('getPanelMenu()', () => {
);
});
it('should pass onClick from plugin extension link to menu item', () => {
const expectedOnClick = jest.fn();
(getPluginExtensions as jest.Mock).mockReturnValue({
extensions: [
{
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Declare incident when pressing this amazing menu item',
description: 'Declaring an incident in the app',
onClick: expectedOnClick,
},
],
});
const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({});
const menuItems = getPanelMenu(dashboard, panel);
const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu;
const menuItem = extensionsSubMenu?.find((i) => (i.text = 'Declare incident when...'));
menuItem?.onClick?.({} as React.MouseEvent);
expect(expectedOnClick).toBeCalledTimes(1);
});
it('should pass context with correct values when configuring extension', () => {
const panel = new PanelModel({
type: 'timeseries',

View File

@ -291,6 +291,7 @@ export function getPanelMenu(
extensionsMenu.push({
text: truncateTitle(extension.title, 25),
href: extension.path,
onClick: extension.onClick,
});
continue;
}

View File

@ -1,4 +1,4 @@
import { PluginExtensionLinkConfig } from '@grafana/data';
import { PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data';
import { createPluginExtensionRegistry } from './createPluginExtensionRegistry';
@ -10,6 +10,7 @@ describe('createRegistry()', () => {
beforeEach(() => {
link1 = {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
@ -17,6 +18,7 @@ describe('createRegistry()', () => {
configure: jest.fn().mockReturnValue({}),
};
link2 = {
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident`,

View File

@ -12,6 +12,7 @@ describe('getPluginExtensions()', () => {
beforeEach(() => {
link1 = {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
@ -19,6 +20,7 @@ describe('getPluginExtensions()', () => {
configure: jest.fn().mockReturnValue({}),
};
link2 = {
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident`,
@ -183,4 +185,65 @@ describe('getPluginExtensions()', () => {
expect(extensions).toHaveLength(0);
expect(global.console.warn).toHaveBeenCalledTimes(0); // As this is intentional, no warning should be logged
});
test('should pass event, context and helper to extension onClick()', () => {
link2.path = undefined;
link2.onClick = jest.fn().mockImplementation(() => {
throw new Error('Something went wrong!');
});
const context = {};
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, placement: placement2 });
const [extension] = extensions;
assertPluginExtensionLink(extension);
const event = {} as React.MouseEvent;
extension.onClick?.(event);
expect(link2.onClick).toHaveBeenCalledTimes(1);
expect(link2.onClick).toHaveBeenCalledWith(
event,
expect.objectContaining({
context,
openModal: expect.any(Function),
})
);
});
test('should catch errors in async/promise-based onClick function and log them as warnings', async () => {
link2.path = undefined;
link2.onClick = jest.fn().mockRejectedValue(new Error('testing'));
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, placement: placement2 });
const [extension] = extensions;
assertPluginExtensionLink(extension);
await extension.onClick?.({} as React.MouseEvent);
expect(extensions).toHaveLength(1);
expect(link2.onClick).toHaveBeenCalledTimes(1);
expect(global.console.warn).toHaveBeenCalledTimes(1);
});
test('should catch errors in the onClick() function and log them as warnings', () => {
link2.path = undefined;
link2.onClick = jest.fn().mockImplementation(() => {
throw new Error('Something went wrong!');
});
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, placement: placement2 });
const [extension] = extensions;
assertPluginExtensionLink(extension);
extension.onClick?.({} as React.MouseEvent);
expect(link2.onClick).toHaveBeenCalledTimes(1);
expect(global.console.warn).toHaveBeenCalledTimes(1);
expect(global.console.warn).toHaveBeenCalledWith('[Plugin Extensions] Something went wrong!');
});
});

View File

@ -6,8 +6,8 @@ import {
} from '@grafana/data';
import type { PluginExtensionRegistry } from './types';
import { isPluginExtensionLinkConfig, deepFreeze, logWarning, generateExtensionId } from './utils';
import { assertIsNotPromise, assertLinkPathIsValid, assertStringProps } from './validators';
import { isPluginExtensionLinkConfig, deepFreeze, logWarning, generateExtensionId, getEventHelpers } from './utils';
import { assertIsNotPromise, assertLinkPathIsValid, assertStringProps, isPromise } from './validators';
type GetExtensions = ({
context,
@ -30,7 +30,6 @@ export const getPluginExtensions: GetExtensions = ({ context, placement, registr
try {
const extensionConfig = registryItem.config;
// LINK extension
if (isPluginExtensionLinkConfig(extensionConfig)) {
const overrides = getLinkExtensionOverrides(registryItem.pluginId, extensionConfig, frozenContext);
@ -43,6 +42,7 @@ export const getPluginExtensions: GetExtensions = ({ context, placement, registr
id: generateExtensionId(registryItem.pluginId, extensionConfig),
type: PluginExtensionTypes.link,
pluginId: registryItem.pluginId,
onClick: getLinkExtensionOnClick(extensionConfig, frozenContext),
// Configurable properties
title: overrides?.title || extensionConfig.title,
@ -78,7 +78,7 @@ function getLinkExtensionOverrides(pluginId: string, config: PluginExtensionLink
`The configure() function for "${config.title}" returned a promise, skipping updates.`
);
assertLinkPathIsValid(pluginId, path);
path && assertLinkPathIsValid(pluginId, path);
assertStringProps({ title, description }, ['title', 'description']);
if (Object.keys(rest).length > 0) {
@ -104,3 +104,32 @@ function getLinkExtensionOverrides(pluginId: string, config: PluginExtensionLink
return undefined;
}
}
function getLinkExtensionOnClick(
config: PluginExtensionLinkConfig,
context?: object
): ((event: React.MouseEvent) => void) | undefined {
const { onClick } = config;
if (!onClick) {
return;
}
return function onClickExtensionLink(event: React.MouseEvent) {
try {
const result = onClick(event, getEventHelpers(context));
if (isPromise(result)) {
result.catch((e) => {
if (e instanceof Error) {
logWarning(e.message);
}
});
}
} catch (error) {
if (error instanceof Error) {
logWarning(error.message);
}
}
};
}

View File

@ -1,4 +1,4 @@
import { PluginExtensionLinkConfig } from '@grafana/data';
import { PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data';
import { deepFreeze, isPluginExtensionLinkConfig, handleErrorsInFn } from './utils';
@ -185,6 +185,7 @@ describe('Plugin Extensions / Utils', () => {
test('should return TRUE if the object is a command extension config', () => {
expect(
isPluginExtensionLinkConfig({
type: PluginExtensionTypes.link,
title: 'Title',
description: 'Description',
path: '...',
@ -196,6 +197,7 @@ describe('Plugin Extensions / Utils', () => {
isPluginExtensionLinkConfig({
title: 'Title',
description: 'Description',
path: '...',
} as PluginExtensionLinkConfig)
).toBe(false);
});

View File

@ -4,6 +4,7 @@ import {
type PluginExtensionLinkConfig,
type PluginExtensionConfig,
type PluginExtensionEventHelpers,
PluginExtensionTypes,
} from '@grafana/data';
import { Modal } from '@grafana/ui';
import appEvents from 'app/core/app_events';
@ -16,7 +17,7 @@ export function logWarning(message: string) {
export function isPluginExtensionLinkConfig(
extension: PluginExtensionConfig | undefined
): extension is PluginExtensionLinkConfig {
return typeof extension === 'object' && 'path' in extension;
return typeof extension === 'object' && 'type' in extension && extension['type'] === PluginExtensionTypes.link;
}
export function handleErrorsInFn(fn: Function, errorMessagePrefix = '') {
@ -32,12 +33,12 @@ export function handleErrorsInFn(fn: Function, errorMessagePrefix = '') {
}
// Event helpers are designed to make it easier to trigger "core actions" from an extension event handler, e.g. opening a modal or showing a notification.
export function getEventHelpers(): PluginExtensionEventHelpers {
export function getEventHelpers(context?: Readonly<object>): PluginExtensionEventHelpers {
const openModal: PluginExtensionEventHelpers['openModal'] = ({ title, body }) => {
appEvents.publish(new ShowModalReactEvent({ component: getModalWrapper({ title, body }) }));
};
return { openModal };
return { openModal, context };
}
export type ModalWrapperProps = {

View File

@ -81,6 +81,7 @@ describe('Plugin Extension Validators', () => {
it('should throw an error if the placement does not have the right prefix', () => {
expect(() => {
assertPlacementIsValid({
type: PluginExtensionTypes.link,
title: 'Title',
description: 'Description',
path: '...',
@ -92,6 +93,7 @@ describe('Plugin Extension Validators', () => {
it('should NOT throw an error if the placement is correct', () => {
expect(() => {
assertPlacementIsValid({
type: PluginExtensionTypes.link,
title: 'Title',
description: 'Description',
path: '...',
@ -99,6 +101,7 @@ describe('Plugin Extension Validators', () => {
});
assertPlacementIsValid({
type: PluginExtensionTypes.link,
title: 'Title',
description: 'Description',
path: '...',
@ -203,18 +206,20 @@ describe('Plugin Extension Validators', () => {
describe('isPluginExtensionConfigValid()', () => {
it('should return TRUE if the plugin extension configuration is valid', () => {
const pluginId = 'my-super-plugin';
// Command
expect(
isPluginExtensionConfigValid(pluginId, {
type: PluginExtensionTypes.link,
title: 'Title',
description: 'Description',
placement: 'grafana/some-page/some-placement',
onClick: jest.fn(),
} as PluginExtensionLinkConfig)
).toBe(true);
// Link
expect(
isPluginExtensionConfigValid(pluginId, {
type: PluginExtensionTypes.link,
title: 'Title',
description: 'Description',
placement: 'grafana/some-page/some-placement',
@ -231,6 +236,7 @@ describe('Plugin Extension Validators', () => {
// Link (wrong path)
expect(
isPluginExtensionConfigValid(pluginId, {
type: PluginExtensionTypes.link,
title: 'Title',
description: 'Description',
placement: 'grafana/some-page/some-placement',
@ -238,9 +244,20 @@ describe('Plugin Extension Validators', () => {
} as PluginExtensionLinkConfig)
).toBe(false);
// Link (no path and no onClick)
expect(
isPluginExtensionConfigValid(pluginId, {
type: PluginExtensionTypes.link,
title: 'Title',
description: 'Description',
placement: 'grafana/some-page/some-placement',
} as PluginExtensionLinkConfig)
).toBe(false);
// Link (missing title)
expect(
isPluginExtensionConfigValid(pluginId, {
type: PluginExtensionTypes.link,
title: '',
description: 'Description',
placement: 'grafana/some-page/some-placement',

View File

@ -77,10 +77,6 @@ export function isStringPropValid(prop: unknown) {
return typeof prop === 'string' && prop.length > 0;
}
export function isPromise(value: unknown) {
return value instanceof Promise || (typeof value === 'object' && value !== null && 'then' in value);
}
export function isPluginExtensionConfigValid(pluginId: string, extension: PluginExtensionLinkConfig): boolean {
try {
assertStringProps(extension, ['title', 'description', 'placement']);
@ -88,7 +84,14 @@ export function isPluginExtensionConfigValid(pluginId: string, extension: Plugin
assertConfigureIsValid(extension);
if (isPluginExtensionLinkConfig(extension)) {
assertLinkPathIsValid(pluginId, extension.path);
if (!extension.path && !extension.onClick) {
logWarning(`Invalid extension "${extension.title}". Either "path" or "onClick" is required.`);
return false;
}
if (extension.path) {
assertLinkPathIsValid(pluginId, extension.path);
}
}
return true;
@ -100,3 +103,9 @@ export function isPluginExtensionConfigValid(pluginId: string, extension: Plugin
return false;
}
}
export function isPromise(value: unknown): value is Promise<unknown> {
return (
value instanceof Promise || (typeof value === 'object' && value !== null && 'then' in value && 'catch' in value)
);
}