diff --git a/packages/grafana-data/src/types/app.ts b/packages/grafana-data/src/types/app.ts index b7191e6c5b4..f3ff6eafda9 100644 --- a/packages/grafana-data/src/types/app.ts +++ b/packages/grafana-data/src/types/app.ts @@ -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 extends GrafanaPlugin(extension: PluginExtensionLinkConfig) { - this._extensionConfigs.push(extension as PluginExtensionLinkConfig); + configureExtensionLink(extension: Exclude, 'type'>) { + this._extensionConfigs.push({ + ...extension, + type: PluginExtensionTypes.link, + } as PluginExtensionLinkConfig); return this; } diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index a5752d2ccba..2de8dd82782 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -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 = PluginExtensionConfig< Context, - Pick + Pick & { + type: PluginExtensionTypes.link; + onClick?: (event: React.MouseEvent, helpers: PluginExtensionEventHelpers) => void; + } >; -export type PluginExtensionEventHelpers = { +export type PluginExtensionEventHelpers = { + context?: Readonly; // Opens a modal dialog and renders the provided React component inside it openModal: (options: { // The title of the modal diff --git a/packages/grafana-runtime/src/services/pluginExtensions/utils.test.ts b/packages/grafana-runtime/src/services/pluginExtensions/utils.test.ts index 6cc6d1444de..c4c6e1f10bd 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/utils.test.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/utils.test.ts @@ -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( diff --git a/packages/grafana-runtime/src/services/pluginExtensions/utils.ts b/packages/grafana-runtime/src/services/pluginExtensions/utils.ts index 45aca3dbb18..a157057ca6d 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/utils.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/utils.ts @@ -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); } diff --git a/public/app/features/dashboard/utils/getPanelMenu.test.ts b/public/app/features/dashboard/utils/getPanelMenu.test.ts index ad7774a69bd..41c0011959c 100644 --- a/public/app/features/dashboard/utils/getPanelMenu.test.ts +++ b/public/app/features/dashboard/utils/getPanelMenu.test.ts @@ -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', diff --git a/public/app/features/dashboard/utils/getPanelMenu.ts b/public/app/features/dashboard/utils/getPanelMenu.ts index cc6e2616bb9..867f348660d 100644 --- a/public/app/features/dashboard/utils/getPanelMenu.ts +++ b/public/app/features/dashboard/utils/getPanelMenu.ts @@ -291,6 +291,7 @@ export function getPanelMenu( extensionsMenu.push({ text: truncateTitle(extension.title, 25), href: extension.path, + onClick: extension.onClick, }); continue; } diff --git a/public/app/features/plugins/extensions/createPluginExtensionRegistry.test.ts b/public/app/features/plugins/extensions/createPluginExtensionRegistry.test.ts index 7585bcb1b90..153a3d67959 100644 --- a/public/app/features/plugins/extensions/createPluginExtensionRegistry.test.ts +++ b/public/app/features/plugins/extensions/createPluginExtensionRegistry.test.ts @@ -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`, diff --git a/public/app/features/plugins/extensions/getPluginExtensions.test.ts b/public/app/features/plugins/extensions/getPluginExtensions.test.ts index 7e33a9fdb27..51ce03825db 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.test.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.test.ts @@ -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!'); + }); }); diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index 50f3067b1b0..84a5d629245 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -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); + } + } + }; +} diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx index f9534f6d65f..7c20bf2f2df 100644 --- a/public/app/features/plugins/extensions/utils.test.tsx +++ b/public/app/features/plugins/extensions/utils.test.tsx @@ -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); }); diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index da1ab7e1783..ee9622691d8 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -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): PluginExtensionEventHelpers { const openModal: PluginExtensionEventHelpers['openModal'] = ({ title, body }) => { appEvents.publish(new ShowModalReactEvent({ component: getModalWrapper({ title, body }) })); }; - return { openModal }; + return { openModal, context }; } export type ModalWrapperProps = { diff --git a/public/app/features/plugins/extensions/validators.test.ts b/public/app/features/plugins/extensions/validators.test.ts index e7398aefa40..7038b84cf41 100644 --- a/public/app/features/plugins/extensions/validators.test.ts +++ b/public/app/features/plugins/extensions/validators.test.ts @@ -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', diff --git a/public/app/features/plugins/extensions/validators.ts b/public/app/features/plugins/extensions/validators.ts index 90886c32dc8..eb9b12f9bd5 100644 --- a/public/app/features/plugins/extensions/validators.ts +++ b/public/app/features/plugins/extensions/validators.ts @@ -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 { + return ( + value instanceof Promise || (typeof value === 'object' && value !== null && 'then' in value && 'catch' in value) + ); +}