diff --git a/.betterer.results b/.betterer.results index e04d60a60a3..ec5f720494e 100644 --- a/.betterer.results +++ b/.betterer.results @@ -334,7 +334,8 @@ exports[`better eslint`] = { ], "packages/grafana-data/src/types/app.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"] ], "packages/grafana-data/src/types/config.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] diff --git a/packages/grafana-data/src/types/app.ts b/packages/grafana-data/src/types/app.ts index 4bafc33f92e..49e6ef78883 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 { extensionLinkConfigIsValid, PluginExtensionLink } from './pluginExtensions'; +import { extensionLinkConfigIsValid, type PluginExtensionCommand, type PluginExtensionLink } from './pluginExtensions'; /** * @public @@ -51,23 +51,32 @@ export interface AppPluginMeta extends PluginMeta } /** - * These types are towards the plugin developer when extending Grafana or other - * plugins from the module.ts + * The `configure()` function can only update certain properties of the extension, and due to this + * it only receives a subset of the original extension object. */ -export type AppConfigureExtension = (extension: T, context: C) => Partial | undefined; - export type AppPluginExtensionLink = Pick; +export type AppPluginExtensionCommand = Pick; + export type AppPluginExtensionLinkConfig = { title: string; description: string; placement: string; path: string; - configure?: AppConfigureExtension; + configure?: (extension: AppPluginExtensionLink, context?: C) => Partial | undefined; +}; + +export type AppPluginExtensionCommandConfig = { + title: string; + description: string; + placement: string; + handler: (context?: C) => void; + configure?: (extension: AppPluginExtensionCommand, context?: C) => Partial | undefined; }; export class AppPlugin extends GrafanaPlugin> { private linkExtensions: AppPluginExtensionLinkConfig[] = []; + private commandExtensions: AppPluginExtensionCommandConfig[] = []; // Content under: /a/${plugin-id}/* root?: ComponentType>; @@ -113,6 +122,10 @@ export class AppPlugin extends GrafanaPlugin(config: AppPluginExtensionLinkConfig) { const { path, description, title, placement } = config; @@ -124,6 +137,11 @@ export class AppPlugin extends GrafanaPlugin(config: AppPluginExtensionCommandConfig) { + this.commandExtensions.push(config as AppPluginExtensionCommandConfig); + return this; + } } /** diff --git a/packages/grafana-data/src/types/index.ts b/packages/grafana-data/src/types/index.ts index d39a5b6fc9b..0a3020aff99 100644 --- a/packages/grafana-data/src/types/index.ts +++ b/packages/grafana-data/src/types/index.ts @@ -56,5 +56,9 @@ export { type PluginExtension, type PluginExtensionLink, isPluginExtensionLink, + assertPluginExtensionLink, + type PluginExtensionCommand, + isPluginExtensionCommand, + assertPluginExtensionCommand, PluginExtensionTypes, } from './pluginExtensions'; diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index e1635a9fbdc..c2a4215dd6d 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -4,6 +4,7 @@ export enum PluginExtensionTypes { link = 'link', + command = 'command', } export type PluginExtension = { @@ -18,10 +19,41 @@ export type PluginExtensionLink = PluginExtension & { path: string; }; -export function isPluginExtensionLink(extension: PluginExtension): extension is PluginExtensionLink { +export type PluginExtensionCommand = PluginExtension & { + type: PluginExtensionTypes.command; + callHandlerWithContext: () => void; +}; + +export function isPluginExtensionLink(extension: PluginExtension | undefined): extension is PluginExtensionLink { + if (!extension) { + return false; + } return extension.type === PluginExtensionTypes.link && 'path' in extension; } +export function assertPluginExtensionLink( + extension: PluginExtension | undefined +): asserts extension is PluginExtensionLink { + if (!isPluginExtensionLink(extension)) { + throw new Error(`extension is not a link extension`); + } +} + +export function isPluginExtensionCommand(extension: PluginExtension | undefined): extension is PluginExtensionCommand { + if (!extension) { + return false; + } + return extension.type === PluginExtensionTypes.command; +} + +export function assertPluginExtensionCommand( + extension: PluginExtension | undefined +): asserts extension is PluginExtensionCommand { + if (!isPluginExtensionCommand(extension)) { + throw new Error(`extension is not a command extension`); + } +} + export function extensionLinkConfigIsValid(props: { path?: string; description?: string; diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts index 7cf78413085..793fe5eb363 100644 --- a/packages/grafana-runtime/src/services/index.ts +++ b/packages/grafana-runtime/src/services/index.ts @@ -11,7 +11,6 @@ export * from './appEvents'; export { type PluginExtensionRegistry, type PluginExtensionRegistryItem, - type RegistryConfigureExtension, setPluginsExtensionRegistry, } from './pluginExtensions/registry'; export { diff --git a/packages/grafana-runtime/src/services/pluginExtensions/extensions.test.ts b/packages/grafana-runtime/src/services/pluginExtensions/extensions.test.ts index 1a17c244e32..ac13c016df2 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/extensions.test.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/extensions.test.ts @@ -1,4 +1,4 @@ -import { isPluginExtensionLink, PluginExtension, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data'; +import { assertPluginExtensionLink, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data'; import { getPluginExtensions } from './extensions'; import { PluginExtensionRegistryItem, setPluginsExtensionRegistry } from './registry'; @@ -33,7 +33,7 @@ describe('getPluginExtensions', () => { const { extensions } = getPluginExtensions({ placement }); const [extension] = extensions; - assertLinkExtension(extension); + assertPluginExtensionLink(extension); expect(extension.path).toBe(`/a/${pluginId}/declare-incident`); expect(extensions.length).toBe(1); @@ -43,7 +43,7 @@ describe('getPluginExtensions', () => { const { extensions } = getPluginExtensions({ placement }); const [extension] = extensions; - assertLinkExtension(extension); + assertPluginExtensionLink(extension); expect(extension.description).toBe('Declaring an incident in the app'); expect(extensions.length).toBe(1); @@ -53,13 +53,13 @@ describe('getPluginExtensions', () => { const { extensions } = getPluginExtensions({ placement }); const [extension] = extensions; - assertLinkExtension(extension); + assertPluginExtensionLink(extension); expect(extension.title).toBe('Declare incident'); expect(extensions.length).toBe(1); }); - it('should return an empty array when extensions cannot be found', () => { + it('should return an empty array when extensions can be found', () => { const { extensions } = getPluginExtensions({ placement: 'plugins/not-installed-app/news', }); @@ -72,17 +72,8 @@ describe('getPluginExtensions', () => { function createRegistryLinkItem( link: Omit ): PluginExtensionRegistryItem { - return { - configure: undefined, - extension: { - ...link, - type: PluginExtensionTypes.link, - }, - }; -} - -function assertLinkExtension(extension: PluginExtension): asserts extension is PluginExtensionLink { - if (!isPluginExtensionLink(extension)) { - throw new Error(`extension is not a link extension`); - } + return (context?: object) => ({ + ...link, + type: PluginExtensionTypes.link, + }); } diff --git a/packages/grafana-runtime/src/services/pluginExtensions/extensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/extensions.ts index 13052977204..a5dee9d514a 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/extensions.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/extensions.ts @@ -16,15 +16,10 @@ export function getPluginExtensions( ): PluginExtensionsResult { const { placement, context } = options; const registry = getPluginsExtensionRegistry(); - const items = registry[placement] ?? []; + const configureFuncs = registry[placement] ?? []; - const extensions = items.reduce((result, item) => { - if (!context || !item.configure) { - result.push(item.extension); - return result; - } - - const extension = item.configure(context); + const extensions = configureFuncs.reduce((result, configure) => { + const extension = configure(context); if (extension) { result.push(extension); } diff --git a/packages/grafana-runtime/src/services/pluginExtensions/registry.ts b/packages/grafana-runtime/src/services/pluginExtensions/registry.ts index 28beac6bddd..98b9ccd0848 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/registry.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/registry.ts @@ -1,14 +1,9 @@ import { PluginExtension } from '@grafana/data'; -export type RegistryConfigureExtension = ( - context: C +export type PluginExtensionRegistryItem = ( + context?: C ) => T | undefined; -export type PluginExtensionRegistryItem = { - extension: T; - configure?: RegistryConfigureExtension; -}; - export type PluginExtensionRegistry = Record; let registry: PluginExtensionRegistry | undefined; diff --git a/public/app/features/dashboard/utils/getPanelMenu.test.ts b/public/app/features/dashboard/utils/getPanelMenu.test.ts index 4bced60f435..ca637b459f1 100644 --- a/public/app/features/dashboard/utils/getPanelMenu.test.ts +++ b/public/app/features/dashboard/utils/getPanelMenu.test.ts @@ -2,7 +2,6 @@ import { PanelMenuItem, PluginExtension, PluginExtensionLink, PluginExtensionTyp import { PluginExtensionPanelContext, PluginExtensionRegistryItem, - RegistryConfigureExtension, setPluginsExtensionRegistry, } from '@grafana/runtime'; import { LoadingState } from '@grafana/schema'; @@ -194,7 +193,7 @@ describe('getPanelMenu()', () => { }); it('should use extension for panel menu returned by configure function', () => { - const configure = () => ({ + const configure: PluginExtensionRegistryItem = () => ({ title: 'Wohoo', type: PluginExtensionTypes.link, description: 'Declaring an incident in the app', @@ -334,7 +333,7 @@ describe('getPanelMenu()', () => { }); it('should pass context that can not be edited in configure function', () => { - const configure = (context: PluginExtensionPanelContext) => { + const configure: PluginExtensionRegistryItem = (context) => { // trying to change values in the context // @ts-ignore context.pluginId = 'changed'; @@ -507,18 +506,10 @@ describe('getPanelMenu()', () => { }); }); -function createRegistryItem( +function createRegistryItem( extension: T, - configure?: (context: PluginExtensionPanelContext) => T | undefined -): PluginExtensionRegistryItem { - if (!configure) { - return { - extension, - }; - } - - return { - extension, - configure: configure as RegistryConfigureExtension, - }; + configure?: PluginExtensionRegistryItem +): PluginExtensionRegistryItem { + const defaultConfigure = () => extension; + return configure || defaultConfigure; } diff --git a/public/app/features/dashboard/utils/getPanelMenu.ts b/public/app/features/dashboard/utils/getPanelMenu.ts index 80419a4c9a8..8f2a2610a7a 100644 --- a/public/app/features/dashboard/utils/getPanelMenu.ts +++ b/public/app/features/dashboard/utils/getPanelMenu.ts @@ -1,4 +1,4 @@ -import { isPluginExtensionLink, PanelMenuItem } from '@grafana/data'; +import { isPluginExtensionCommand, isPluginExtensionLink, PanelMenuItem } from '@grafana/data'; import { AngularComponent, getDataSourceSrv, @@ -297,6 +297,15 @@ export function getPanelMenu( text: truncateTitle(extension.title, 25), href: extension.path, }); + continue; + } + + if (isPluginExtensionCommand(extension)) { + subMenu.push({ + text: truncateTitle(extension.title, 25), + onClick: extension.callHandlerWithContext, + }); + continue; } } diff --git a/public/app/features/plugins/extensions/errorHandling.test.ts b/public/app/features/plugins/extensions/errorHandling.test.ts index 2b0ab145375..a6cb6bdcdcc 100644 --- a/public/app/features/plugins/extensions/errorHandling.test.ts +++ b/public/app/features/plugins/extensions/errorHandling.test.ts @@ -1,79 +1,122 @@ -import { AppConfigureExtension, AppPluginExtensionLink } from '@grafana/data'; +import { AppPluginExtensionLink } from '@grafana/data'; + +import { handleErrorsInConfigure, handleErrorsInHandler } from './errorHandling'; +import type { CommandHandlerFunc, ConfigureFunc } from './types'; + +describe('error handling for extensions', () => { + describe('error handling for configure', () => { + const pluginId = 'grafana-basic-app'; + const errorHandler = handleErrorsInConfigure({ + pluginId: pluginId, + title: 'Go to page one', + logger: jest.fn(), + }); -import { createErrorHandling } from './errorHandling'; + const context = {}; + const extension: AppPluginExtensionLink = { + title: 'Go to page one', + description: 'Will navigate the user to page one', + path: `/a/${pluginId}/one`, + }; -describe('extension error handling', () => { - const pluginId = 'grafana-basic-app'; - const errorHandler = createErrorHandling({ - pluginId: pluginId, - title: 'Go to page one', - logger: jest.fn(), - }); + it('should return configured link if configure is successful', () => { + const configureWithErrorHandling = errorHandler(() => { + return { + title: 'This is a new title', + }; + }); - const context = {}; - const extension: AppPluginExtensionLink = { - title: 'Go to page one', - description: 'Will navigate the user to page one', - path: `/a/${pluginId}/one`, - }; + const configured = configureWithErrorHandling(extension, context); - it('should return configured link if configure is successful', () => { - const configureWithErrorHandling = errorHandler(() => { - return { + expect(configured).toEqual({ title: 'This is a new title', - }; + }); }); - const configured = configureWithErrorHandling(extension, context); + it('should return undefined if configure throws error', () => { + const configureWithErrorHandling = errorHandler(() => { + throw new Error(); + }); + + const configured = configureWithErrorHandling(extension, context); - expect(configured).toEqual({ - title: 'This is a new title', + expect(configured).toBeUndefined(); }); - }); - it('should return undefined if configure throws error', () => { - const configureWithErrorHandling = errorHandler(() => { - throw new Error(); + it('should return undefined if configure is promise/async-based', () => { + const promisebased = (async () => {}) as ConfigureFunc; + const configureWithErrorHandling = errorHandler(promisebased); + + const configured = configureWithErrorHandling(extension, context); + + expect(configured).toBeUndefined(); }); - const configured = configureWithErrorHandling(extension, context); + it('should return undefined if configure is not a function', () => { + const objectbased = {} as ConfigureFunc; + const configureWithErrorHandling = errorHandler(objectbased); - expect(configured).toBeUndefined(); - }); + const configured = configureWithErrorHandling(extension, context); - it('should return undefined if configure is promise/async-based', () => { - const promisebased = (async () => {}) as AppConfigureExtension; - const configureWithErrorHandling = errorHandler(promisebased); + expect(configured).toBeUndefined(); + }); - const configured = configureWithErrorHandling(extension, context); + it('should return undefined if configure returns other than an object', () => { + const returnString = (() => '') as ConfigureFunc; + const configureWithErrorHandling = errorHandler(returnString); - expect(configured).toBeUndefined(); - }); + const configured = configureWithErrorHandling(extension, context); + + expect(configured).toBeUndefined(); + }); - it('should return undefined if configure is not a function', () => { - const objectbased = {} as AppConfigureExtension; - const configureWithErrorHandling = errorHandler(objectbased); + it('should return undefined if configure returns undefined', () => { + const returnUndefined = () => undefined; + const configureWithErrorHandling = errorHandler(returnUndefined); - const configured = configureWithErrorHandling(extension, context); + const configured = configureWithErrorHandling(extension, context); - expect(configured).toBeUndefined(); + expect(configured).toBeUndefined(); + }); }); - it('should return undefined if configure returns other than an object', () => { - const returnString = (() => '') as AppConfigureExtension; - const configureWithErrorHandling = errorHandler(returnString); + describe('error handling for command handler', () => { + const pluginId = 'grafana-basic-app'; + const errorHandler = handleErrorsInHandler({ + pluginId: pluginId, + title: 'open modal', + logger: jest.fn(), + }); - const configured = configureWithErrorHandling(extension, context); + it('should be called successfully when handler is a normal synchronous function', () => { + const handler = jest.fn(); + const handlerWithErrorHandling = errorHandler(handler); - expect(configured).toBeUndefined(); - }); + handlerWithErrorHandling(); - it('should return undefined if configure returns undefined', () => { - const returnUndefined = () => undefined; - const configureWithErrorHandling = errorHandler(returnUndefined); + expect(handler).toBeCalled(); + }); + + it('should not error out even if the handler throws an error', () => { + const handlerWithErrorHandling = errorHandler(() => { + throw new Error(); + }); - const configured = configureWithErrorHandling(extension, context); + expect(handlerWithErrorHandling).not.toThrowError(); + }); - expect(configured).toBeUndefined(); + it('should be called successfully when handler is an async function / promise', () => { + const promisebased = (async () => {}) as CommandHandlerFunc; + const configureWithErrorHandling = errorHandler(promisebased); + + expect(configureWithErrorHandling).not.toThrowError(); + }); + + it('should be called successfully when handler is not a function', () => { + const objectbased = {} as CommandHandlerFunc; + const configureWithErrorHandling = errorHandler(objectbased); + + expect(configureWithErrorHandling).not.toThrowError(); + }); }); }); diff --git a/public/app/features/plugins/extensions/errorHandling.ts b/public/app/features/plugins/extensions/errorHandling.ts index 3e7e4db4e1e..9ef7b5ef0ee 100644 --- a/public/app/features/plugins/extensions/errorHandling.ts +++ b/public/app/features/plugins/extensions/errorHandling.ts @@ -1,6 +1,6 @@ import { isFunction, isObject } from 'lodash'; -import type { AppConfigureExtension } from '@grafana/data'; +import type { CommandHandlerFunc, ConfigureFunc } from './types'; type Options = { pluginId: string; @@ -8,10 +8,10 @@ type Options = { logger: (msg: string, error?: unknown) => void; }; -export function createErrorHandling(options: Options) { +export function handleErrorsInConfigure(options: Options) { const { pluginId, title, logger } = options; - return (configure: AppConfigureExtension): AppConfigureExtension => { + return (configure: ConfigureFunc): ConfigureFunc => { return function handleErrors(extension, context) { try { if (!isFunction(configure)) { @@ -41,3 +41,32 @@ export function createErrorHandling(options: Options) { }; }; } + +export function handleErrorsInHandler(options: Options) { + const { pluginId, title, logger } = options; + + return (handler: CommandHandlerFunc): CommandHandlerFunc => { + return function handleErrors(context) { + try { + if (!isFunction(handler)) { + logger(`[Plugins] ${pluginId} provided invalid handler function for command extension '${title}'.`); + return; + } + + const result = handler(context); + if (result instanceof Promise) { + logger( + `[Plugins] ${pluginId} provided an unsupported async/promise-based handler function for command extension '${title}'.` + ); + result.catch(() => {}); + return; + } + + return result; + } catch (error) { + logger(`[Plugins] ${pluginId} thow an error while handling command extension '${title}'`, error); + return; + } + }; + }; +} diff --git a/public/app/features/plugins/extensions/placementsPerPlugin.ts b/public/app/features/plugins/extensions/placementsPerPlugin.ts new file mode 100644 index 00000000000..62ea2e867eb --- /dev/null +++ b/public/app/features/plugins/extensions/placementsPerPlugin.ts @@ -0,0 +1,15 @@ +export class PlacementsPerPlugin { + private counter: Record = {}; + private limit = 2; + + allowedToAdd(placement: string): boolean { + const count = this.counter[placement] ?? 0; + + if (count >= this.limit) { + return false; + } + + this.counter[placement] = count + 1; + return true; + } +} diff --git a/public/app/features/plugins/extensions/registryFactory.test.ts b/public/app/features/plugins/extensions/registryFactory.test.ts index 50d1eca6957..903a2e09ad4 100644 --- a/public/app/features/plugins/extensions/registryFactory.test.ts +++ b/public/app/features/plugins/extensions/registryFactory.test.ts @@ -1,15 +1,27 @@ -import { PluginExtensionTypes } from '@grafana/data'; +import { + AppPluginExtensionCommandConfig, + AppPluginExtensionLinkConfig, + assertPluginExtensionCommand, + PluginExtensionTypes, +} from '@grafana/data'; +import { PluginExtensionRegistry } from '@grafana/runtime'; import { createPluginExtensionRegistry } from './registryFactory'; const validateLink = jest.fn((configure, extension, context) => configure?.(extension, context)); -const errorHandler = jest.fn((configure, extension, context) => configure?.(extension, context)); +const configureErrorHandler = jest.fn((configure, extension, context) => configure?.(extension, context)); +const commandErrorHandler = jest.fn((configure, context) => configure?.(context)); jest.mock('./errorHandling', () => ({ ...jest.requireActual('./errorHandling'), - createErrorHandling: jest.fn(() => { + handleErrorsInConfigure: jest.fn(() => { return jest.fn((configure) => { - return jest.fn((extension, context) => errorHandler(configure, extension, context)); + return jest.fn((extension, context) => configureErrorHandler(configure, extension, context)); + }); + }), + handleErrorsInHandler: jest.fn(() => { + return jest.fn((configure) => { + return jest.fn((context) => commandErrorHandler(configure, context)); }); }), })); @@ -23,304 +35,489 @@ jest.mock('./validateLink', () => ({ }), })); -describe('Creating extensions registry', () => { +describe('createPluginExtensionRegistry()', () => { beforeEach(() => { validateLink.mockClear(); - errorHandler.mockClear(); + configureErrorHandler.mockClear(); + commandErrorHandler.mockClear(); }); - it('should register an extension', () => { - const registry = createPluginExtensionRegistry([ - { - pluginId: 'belugacdn-app', - linkExtensions: [ - { - placement: 'grafana/dashboard/panel/menu', - title: 'Open incident', - description: 'You can create an incident from this context', - path: '/a/belugacdn-app/incidents/declare', - }, - ], - }, - ]); - - const numberOfPlacements = Object.keys(registry).length; - const extensions = registry['grafana/dashboard/panel/menu']; - - expect(numberOfPlacements).toBe(1); - expect(extensions).toEqual([ - { - configure: undefined, - extension: { - title: 'Open incident', - type: PluginExtensionTypes.link, - description: 'You can create an incident from this context', - path: '/a/belugacdn-app/incidents/declare', - key: -68154691, + describe('when registering links', () => { + const placement1 = 'grafana/dashboard/panel/menu'; + const placement2 = 'plugins/grafana-slo-app/slo-breached'; + const pluginId = 'belugacdn-app'; + // Sample link configurations that can be used in tests + const linkConfig = { + placement: placement1, + title: 'Open incident', + description: 'You can create an incident from this context', + path: '/a/belugacdn-app/incidents/declare', + }; + + it('should register a link extension', () => { + const registry = createPluginExtensionRegistry([ + { + pluginId, + linkExtensions: [linkConfig], + commandExtensions: [], }, - }, - ]); - }); + ]); - it('should register extensions from one plugin with multiple placements', () => { - const registry = createPluginExtensionRegistry([ - { - pluginId: 'belugacdn-app', - linkExtensions: [ - { - placement: 'grafana/dashboard/panel/menu', - title: 'Open incident', - description: 'You can create an incident from this context', - path: '/a/belugacdn-app/incidents/declare', - }, - { - placement: 'plugins/grafana-slo-app/slo-breached', - title: 'Open incident', - description: 'You can create an incident from this context', - path: '/a/belugacdn-app/incidents/declare', - }, - ], - }, - ]); - - const numberOfPlacements = Object.keys(registry).length; - const panelExtensions = registry['grafana/dashboard/panel/menu']; - const sloExtensions = registry['plugins/grafana-slo-app/slo-breached']; - - expect(numberOfPlacements).toBe(2); - expect(panelExtensions).toEqual([ - { - configure: undefined, - extension: { - title: 'Open incident', - type: PluginExtensionTypes.link, - description: 'You can create an incident from this context', - path: '/a/belugacdn-app/incidents/declare', - key: -68154691, + shouldHaveExtensionsAtPlacement({ configs: [linkConfig], placement: placement1, registry }); + }); + + it('should only register a link extension to a single placement', () => { + const registry = createPluginExtensionRegistry([ + { + pluginId, + linkExtensions: [linkConfig], + commandExtensions: [], }, - }, - ]); - expect(sloExtensions).toEqual([ - { - configure: undefined, - extension: { - title: 'Open incident', - type: PluginExtensionTypes.link, - description: 'You can create an incident from this context', - path: '/a/belugacdn-app/incidents/declare', - key: -1638987831, + ]); + + shouldHaveNumberOfPlacements(registry, 1); + expect(registry[placement1]).toBeDefined(); + }); + + it('should register link extensions from one plugin with multiple placements', () => { + const registry = createPluginExtensionRegistry([ + { + pluginId, + linkExtensions: [ + { ...linkConfig, placement: placement1 }, + { ...linkConfig, placement: placement2 }, + ], + commandExtensions: [], }, - }, - ]); - }); + ]); - it('should register extensions from multiple plugins with multiple placements', () => { - const registry = createPluginExtensionRegistry([ - { - pluginId: 'belugacdn-app', - linkExtensions: [ - { - placement: 'grafana/dashboard/panel/menu', - title: 'Open incident', - description: 'You can create an incident from this context', - path: '/a/belugacdn-app/incidents/declare', - }, - { - placement: 'plugins/grafana-slo-app/slo-breached', - title: 'Open incident', - description: 'You can create an incident from this context', - path: '/a/belugacdn-app/incidents/declare', - }, - ], - }, - { - pluginId: 'grafana-monitoring-app', - linkExtensions: [ - { - placement: 'grafana/dashboard/panel/menu', - title: 'Open Incident', - description: 'You can create an incident from this context', - path: '/a/grafana-monitoring-app/incidents/declare', - }, - ], - }, - ]); - - const numberOfPlacements = Object.keys(registry).length; - const panelExtensions = registry['grafana/dashboard/panel/menu']; - const sloExtensions = registry['plugins/grafana-slo-app/slo-breached']; - - expect(numberOfPlacements).toBe(2); - expect(panelExtensions).toEqual([ - { - configure: undefined, - extension: { - title: 'Open incident', - type: PluginExtensionTypes.link, - description: 'You can create an incident from this context', - path: '/a/belugacdn-app/incidents/declare', - key: -68154691, + shouldHaveNumberOfPlacements(registry, 2); + shouldHaveExtensionsAtPlacement({ placement: placement1, configs: [linkConfig], registry }); + shouldHaveExtensionsAtPlacement({ placement: placement2, configs: [linkConfig], registry }); + }); + + it('should register link extensions from multiple plugins with multiple placements', () => { + const registry = createPluginExtensionRegistry([ + { + pluginId, + linkExtensions: [ + { ...linkConfig, placement: placement1 }, + { ...linkConfig, placement: placement2 }, + ], + commandExtensions: [], }, - }, - { - configure: undefined, - extension: { - title: 'Open Incident', - type: PluginExtensionTypes.link, - description: 'You can create an incident from this context', - path: '/a/grafana-monitoring-app/incidents/declare', - key: -540306829, + { + pluginId: 'grafana-monitoring-app', + linkExtensions: [ + { ...linkConfig, placement: placement1, path: '/a/grafana-monitoring-app/incidents/declare' }, + ], + commandExtensions: [], }, - }, - ]); - - expect(sloExtensions).toEqual([ - { - configure: undefined, - extension: { - title: 'Open incident', - type: PluginExtensionTypes.link, - description: 'You can create an incident from this context', - path: '/a/belugacdn-app/incidents/declare', - key: -1638987831, + ]); + + shouldHaveNumberOfPlacements(registry, 2); + shouldHaveExtensionsAtPlacement({ + placement: placement1, + configs: [linkConfig, { ...linkConfig, path: '/a/grafana-monitoring-app/incidents/declare' }], + registry, + }); + shouldHaveExtensionsAtPlacement({ placement: placement2, configs: [linkConfig], registry }); + }); + + it('should register maximum 2 extensions per plugin and placement', () => { + const registry = createPluginExtensionRegistry([ + { + pluginId, + linkExtensions: [ + { ...linkConfig, title: 'Link 1' }, + { ...linkConfig, title: 'Link 2' }, + { ...linkConfig, title: 'Link 3' }, + ], + commandExtensions: [], }, - }, - ]); - }); + ]); + + shouldHaveNumberOfPlacements(registry, 1); - it('should register maximum 2 extensions per plugin and placement', () => { - const registry = createPluginExtensionRegistry([ - { - pluginId: 'belugacdn-app', - linkExtensions: [ - { - placement: 'grafana/dashboard/panel/menu', - title: 'Open incident', - description: 'You can create an incident from this context', - path: '/a/belugacdn-app/incidents/declare', - }, - { - placement: 'grafana/dashboard/panel/menu', - title: 'Open incident 2', - description: 'You can create an incident from this context', - path: '/a/belugacdn-app/incidents/declare', - }, - { - placement: 'grafana/dashboard/panel/menu', - title: 'Open incident 3', - description: 'You can create an incident from this context', - path: '/a/belugacdn-app/incidents/declare', - }, + // The 3rd link is being ignored + shouldHaveExtensionsAtPlacement({ + placement: linkConfig.placement, + configs: [ + { ...linkConfig, title: 'Link 1' }, + { ...linkConfig, title: 'Link 2' }, ], - }, - ]); - - const numberOfPlacements = Object.keys(registry).length; - const panelExtensions = registry['grafana/dashboard/panel/menu']; - - expect(numberOfPlacements).toBe(1); - expect(panelExtensions).toEqual([ - { - configure: undefined, - extension: { - title: 'Open incident', - type: PluginExtensionTypes.link, - description: 'You can create an incident from this context', - path: '/a/belugacdn-app/incidents/declare', - key: -68154691, + registry, + }); + }); + + it('should not register link extensions with invalid path configured', () => { + const registry = createPluginExtensionRegistry([ + { + pluginId, + linkExtensions: [ + { + ...linkConfig, + path: '/incidents/declare', // invalid path, should always be prefixed with the plugin id + }, + ], + commandExtensions: [], }, - }, - { - configure: undefined, - extension: { - title: 'Open incident 2', - type: PluginExtensionTypes.link, - description: 'You can create an incident from this context', - path: '/a/belugacdn-app/incidents/declare', - key: -1072147569, + ]); + + shouldHaveNumberOfPlacements(registry, 0); + }); + + it('should add default configure function when none provided via extension config', () => { + const registry = createPluginExtensionRegistry([ + { + pluginId, + linkExtensions: [linkConfig], + commandExtensions: [], }, - }, - ]); - }); + ]); - it('should not register extensions with invalid path configured', () => { - const registry = createPluginExtensionRegistry([ - { - pluginId: 'belugacdn-app', - linkExtensions: [ - { - placement: 'grafana/dashboard/panel/menu', - title: 'Open incident', - description: 'You can create an incident from this context', - path: '/incidents/declare', - }, - ], - }, - ]); + const [configure] = registry[linkConfig.placement]; + const configured = configure(); - const numberOfPlacements = Object.keys(registry).length; - expect(numberOfPlacements).toBe(0); - }); + // The default configure() function returns the same extension config + expect(configured).toEqual({ + key: expect.any(Number), + type: PluginExtensionTypes.link, + title: linkConfig.title, + description: linkConfig.description, + path: linkConfig.path, + }); + }); - it('should wrap configure function with link extension validator', () => { - const registry = createPluginExtensionRegistry([ - { - pluginId: 'belugacdn-app', - linkExtensions: [ - { - placement: 'grafana/dashboard/panel/menu', - title: 'Open incident', - description: 'You can create an incident from this context', - path: '/a/belugacdn-app/incidents/declare', - configure: () => ({}), - }, - ], - }, - ]); + it('should wrap the configure function with link extension validator', () => { + const registry = createPluginExtensionRegistry([ + { + pluginId, + linkExtensions: [ + { + ...linkConfig, + configure: () => ({}), + }, + ], + commandExtensions: [], + }, + ]); - const extensions = registry['grafana/dashboard/panel/menu']; - const [extension] = extensions; + const [configure] = registry[linkConfig.placement]; + const context = {}; + const configurable = { + title: linkConfig.title, + description: linkConfig.description, + path: linkConfig.path, + }; - const context = {}; - const configurable = { - title: 'Open incident', - description: 'You can create an incident from this context', - path: '/a/belugacdn-app/incidents/declare', - }; + configure(context); - extension?.configure?.(context); + expect(validateLink).toBeCalledWith(expect.any(Function), configurable, context); + }); - expect(validateLink).toBeCalledWith(expect.any(Function), configurable, context); - }); + it('should wrap configure function with extension error handling', () => { + const registry = createPluginExtensionRegistry([ + { + pluginId, + linkExtensions: [ + { + ...linkConfig, + configure: () => ({}), + }, + ], + commandExtensions: [], + }, + ]); - it('should wrap configure function with extension error handling', () => { - const registry = createPluginExtensionRegistry([ - { - pluginId: 'belugacdn-app', - linkExtensions: [ - { - placement: 'grafana/dashboard/panel/menu', - title: 'Open incident', - description: 'You can create an incident from this context', - path: '/a/belugacdn-app/incidents/declare', - configure: () => ({}), - }, - ], - }, - ]); + const [configure] = registry[linkConfig.placement]; + const context = {}; + const configurable = { + title: linkConfig.title, + description: linkConfig.description, + path: linkConfig.path, + }; + + configure(context); - const extensions = registry['grafana/dashboard/panel/menu']; - const [extension] = extensions; + expect(configureErrorHandler).toBeCalledWith(expect.any(Function), configurable, context); + }); + + it('should return undefined if returned by the provided extension config', () => { + const registry = createPluginExtensionRegistry([ + { + pluginId, + linkExtensions: [ + { + ...linkConfig, + configure: () => undefined, + }, + ], + commandExtensions: [], + }, + ]); + + const [configure] = registry[linkConfig.placement]; + const context = {}; + + expect(configure(context)).toBeUndefined(); + }); + }); - const context = {}; - const configurable = { + // Command extensions + // ------------------ + describe('when registering commands', () => { + const pluginId = 'belugacdn-app'; + // Sample command configurations to be used in tests + const commandConfig1 = { + placement: 'grafana/dashboard/panel/menu', title: 'Open incident', description: 'You can create an incident from this context', - path: '/a/belugacdn-app/incidents/declare', + handler: () => {}, }; + const commandConfig2 = { + placement: 'plugins/grafana-slo-app/slo-breached', + title: 'Open incident', + description: 'You can create an incident from this context', + handler: () => {}, + }; + + it('should register a command extension', () => { + const registry = createPluginExtensionRegistry([ + { + pluginId, + linkExtensions: [], + commandExtensions: [commandConfig1], + }, + ]); + + shouldHaveNumberOfPlacements(registry, 1); + shouldHaveExtensionsAtPlacement({ + placement: commandConfig1.placement, + configs: [commandConfig1], + registry, + }); + }); + + it('should register command extensions from a SINGLE PLUGIN with MULTIPLE PLACEMENTS', () => { + const registry = createPluginExtensionRegistry([ + { + pluginId, + linkExtensions: [], + commandExtensions: [commandConfig1, commandConfig2], + }, + ]); + + shouldHaveNumberOfPlacements(registry, 2); + shouldHaveExtensionsAtPlacement({ + placement: commandConfig1.placement, + configs: [commandConfig1], + registry, + }); + shouldHaveExtensionsAtPlacement({ + placement: commandConfig2.placement, + configs: [commandConfig2], + registry, + }); + }); + + it('should register command extensions from MULTIPLE PLUGINS with MULTIPLE PLACEMENTS', () => { + const registry = createPluginExtensionRegistry([ + { + pluginId, + linkExtensions: [], + commandExtensions: [commandConfig1, commandConfig2], + }, + { + pluginId: 'grafana-monitoring-app', + linkExtensions: [], + commandExtensions: [commandConfig1], + }, + ]); + + shouldHaveNumberOfPlacements(registry, 2); + + // Both plugins register commands to the same placement + shouldHaveExtensionsAtPlacement({ + placement: commandConfig1.placement, + configs: [commandConfig1, commandConfig1], + registry, + }); + + // The 'beluga-cdn-app' plugin registers a command to an other placement as well + shouldHaveExtensionsAtPlacement({ + placement: commandConfig2.placement, + configs: [commandConfig2], + registry, + }); + }); + + it('should add default configure function when none is provided via the extension config', () => { + const registry = createPluginExtensionRegistry([ + { + pluginId, + linkExtensions: [], + commandExtensions: [commandConfig1], + }, + ]); + + const [configure] = registry[commandConfig1.placement]; + const configured = configure(); + + // The default configure() function returns the extension config as is + expect(configured).toEqual({ + type: PluginExtensionTypes.command, + key: expect.any(Number), + title: commandConfig1.title, + description: commandConfig1.description, + callHandlerWithContext: expect.any(Function), + }); + }); + + it('should wrap the configure function with error handling', () => { + const registry = createPluginExtensionRegistry([ + { + pluginId, + linkExtensions: [], + commandExtensions: [ + { + ...commandConfig1, + configure: () => ({}), + }, + ], + }, + ]); + + const [configure] = registry[commandConfig1.placement]; + const context = {}; + const configurable = { + title: commandConfig1.title, + description: commandConfig2.description, + }; + + configure(context); + + // The error handler is wrapping (decorating) the configure function, so it can provide standard error messages + expect(configureErrorHandler).toBeCalledWith(expect.any(Function), configurable, context); + }); + + it('should return undefined if returned by the provided extension config', () => { + const registry = createPluginExtensionRegistry([ + { + pluginId, + linkExtensions: [], + commandExtensions: [ + { + ...commandConfig1, + configure: () => undefined, + }, + ], + }, + ]); + + const [configure] = registry[commandConfig1.placement]; + const context = {}; + + expect(configure(context)).toBeUndefined(); + }); + + it('should wrap handler function with extension error handling', () => { + const registry = createPluginExtensionRegistry([ + { + pluginId, + linkExtensions: [], + commandExtensions: [ + { + placement: 'grafana/dashboard/panel/menu', + title: 'Open incident', + description: 'You can create an incident from this context', + handler: () => {}, + configure: () => ({}), + }, + ], + }, + ]); + + const extensions = registry['grafana/dashboard/panel/menu']; + const [configure] = extensions; + const context = {}; + const extension = configure?.(context); - extension?.configure?.(context); + assertPluginExtensionCommand(extension); - expect(errorHandler).toBeCalledWith(expect.any(Function), configurable, context); + extension.callHandlerWithContext(); + + expect(commandErrorHandler).toBeCalledWith(expect.any(Function), context); + }); + + it('should wrap handler function with extension error handling when no configure function is added', () => { + const registry = createPluginExtensionRegistry([ + { + pluginId, + linkExtensions: [], + commandExtensions: [ + { + placement: 'grafana/dashboard/panel/menu', + title: 'Open incident', + description: 'You can create an incident from this context', + handler: () => {}, + }, + ], + }, + ]); + + const extensions = registry['grafana/dashboard/panel/menu']; + const [configure] = extensions; + const context = {}; + const extension = configure?.(context); + + assertPluginExtensionCommand(extension); + + extension.callHandlerWithContext(); + + expect(commandErrorHandler).toBeCalledWith(expect.any(Function), context); + }); }); }); + +// Checks the number of total placements in the registry +function shouldHaveNumberOfPlacements(registry: PluginExtensionRegistry, numberOfPlacements: number) { + expect(Object.keys(registry).length).toBe(numberOfPlacements); +} + +// Checks if the registry has exactly the same extensions at the expected placement +function shouldHaveExtensionsAtPlacement({ + configs, + placement, + registry, +}: { + configs: Array; + placement: string; + registry: PluginExtensionRegistry; +}) { + const extensions = registry[placement].map((configure) => configure()); + + expect(extensions).toEqual( + configs.map((extension) => { + // Command extension + if ('handler' in extension) { + return { + key: expect.any(Number), + title: extension.title, + description: extension.description, + type: PluginExtensionTypes.command, + callHandlerWithContext: expect.any(Function), + }; + } + + // Link extension + return { + key: expect.any(Number), + title: extension.title, + description: extension.description, + type: PluginExtensionTypes.link, + path: extension.path, + }; + }) + ); +} diff --git a/public/app/features/plugins/extensions/registryFactory.ts b/public/app/features/plugins/extensions/registryFactory.ts index 22c20660bcb..e2bc4db28ca 100644 --- a/public/app/features/plugins/extensions/registryFactory.ts +++ b/public/app/features/plugins/extensions/registryFactory.ts @@ -1,41 +1,40 @@ import { - AppConfigureExtension, - AppPluginExtensionLink, - AppPluginExtensionLinkConfig, - PluginExtensionLink, + type AppPluginExtensionCommand, + type AppPluginExtensionCommandConfig, + type AppPluginExtensionLink, + type AppPluginExtensionLinkConfig, + type PluginExtension, + type PluginExtensionCommand, + type PluginExtensionLink, PluginExtensionTypes, } from '@grafana/data'; -import type { - PluginExtensionRegistry, - PluginExtensionRegistryItem, - RegistryConfigureExtension, -} from '@grafana/runtime'; +import type { PluginExtensionRegistry, PluginExtensionRegistryItem } from '@grafana/runtime'; -import { PluginPreloadResult } from '../pluginPreloader'; +import type { PluginPreloadResult } from '../pluginPreloader'; -import { createErrorHandling } from './errorHandling'; +import { handleErrorsInHandler, handleErrorsInConfigure } from './errorHandling'; +import { PlacementsPerPlugin } from './placementsPerPlugin'; +import { ConfigureFunc } from './types'; import { createLinkValidator, isValidLinkPath } from './validateLink'; export function createPluginExtensionRegistry(preloadResults: PluginPreloadResult[]): PluginExtensionRegistry { const registry: PluginExtensionRegistry = {}; for (const result of preloadResults) { - const pluginPlacementCount: Record = {}; - const { pluginId, linkExtensions, error } = result; + const { pluginId, linkExtensions, commandExtensions, error } = result; - if (!Array.isArray(linkExtensions) || error) { + if (error) { continue; } - for (const extension of linkExtensions) { - const placement = extension.placement; + const placementsPerPlugin = new PlacementsPerPlugin(); + const configs = [...linkExtensions, ...commandExtensions]; - pluginPlacementCount[placement] = (pluginPlacementCount[placement] ?? 0) + 1; - const item = createRegistryLink(pluginId, extension); + for (const config of configs) { + const placement = config.placement; + const item = createRegistryItem(pluginId, config); - // If there was an issue initialising the plugin, skip adding its extensions to the registry - // or if the plugin already have placed 2 items at the extension point. - if (!item || pluginPlacementCount[placement] > 2) { + if (!item || !placementsPerPlugin.allowedToAdd(placement)) { continue; } @@ -55,78 +54,124 @@ export function createPluginExtensionRegistry(preloadResults: PluginPreloadResul return Object.freeze(registry); } -function createRegistryLink( +function createRegistryItem( pluginId: string, - config: AppPluginExtensionLinkConfig -): PluginExtensionRegistryItem | undefined { - if (!isValidLinkPath(pluginId, config.path)) { - return undefined; + config: AppPluginExtensionCommandConfig | AppPluginExtensionLinkConfig +): PluginExtensionRegistryItem | undefined { + if ('handler' in config) { + return createCommandRegistryItem(pluginId, config); } + return createLinkRegistryItem(pluginId, config); +} + +function createCommandRegistryItem( + pluginId: string, + config: AppPluginExtensionCommandConfig +): PluginExtensionRegistryItem | undefined { + const configure = config.configure ?? defaultConfigure; - const id = `${pluginId}${config.placement}${config.title}`; - const extension = Object.freeze({ - type: PluginExtensionTypes.link, + const options = { + pluginId: pluginId, + title: config.title, + logger: console.warn, + }; + + const catchErrorsInHandler = handleErrorsInHandler(options); + const handler = catchErrorsInHandler(config.handler); + + const extensionFactory = createCommandFactory(pluginId, config, handler); + + const configurable: AppPluginExtensionCommand = { title: config.title, description: config.description, - key: hashKey(id), - path: config.path, - }); + }; - return Object.freeze({ - extension: extension, - configure: createLinkConfigure(pluginId, config, extension), - }); -} + const mapper = mapToConfigure(extensionFactory, configurable); + const catchErrorsInConfigure = handleErrorsInConfigure(options); -function hashKey(key: string): number { - return Array.from(key).reduce((s, c) => (Math.imul(31, s) + c.charCodeAt(0)) | 0, 0); + return mapper(catchErrorsInConfigure(configure)); } -function createLinkConfigure( +function createLinkRegistryItem( pluginId: string, - config: AppPluginExtensionLinkConfig, - extension: PluginExtensionLink -): RegistryConfigureExtension | undefined { - if (!config.configure) { + config: AppPluginExtensionLinkConfig +): PluginExtensionRegistryItem | undefined { + if (!isValidLinkPath(pluginId, config.path)) { return undefined; } - const options = { - pluginId: pluginId, + const configure = config.configure ?? defaultConfigure; + const options = { pluginId: pluginId, title: config.title, logger: console.warn }; + + const extensionFactory = createLinkFactory(pluginId, config); + + const configurable: AppPluginExtensionLink = { title: config.title, - logger: console.warn, + description: config.description, + path: config.path, }; - const mapper = mapToRegistryType(extension); - const validator = createLinkValidator(options); - const errorHandler = createErrorHandling(options); + const mapper = mapToConfigure(extensionFactory, configurable); + const withConfigureErrorHandling = handleErrorsInConfigure(options); + const validateLink = createLinkValidator(options); - return mapper(validator(errorHandler(config.configure))); + return mapper(validateLink(withConfigureErrorHandling(configure))); } -function mapToRegistryType( - extension: PluginExtensionLink -): (configure: AppConfigureExtension) => RegistryConfigureExtension { - const configurable: AppPluginExtensionLink = { - title: extension.title, - description: extension.description, - path: extension.path, +function createLinkFactory(pluginId: string, config: AppPluginExtensionLinkConfig) { + return (override: Partial, context?: object): PluginExtensionLink => { + const title = override?.title ?? config.title; + const description = override?.description ?? config.description; + const path = override?.path ?? config.path; + + return Object.freeze({ + type: PluginExtensionTypes.link, + title: title, + description: description, + path: path, + key: hashKey(`${pluginId}${config.placement}${title}`), + }); }; +} - return (configure) => { - return function mapper(context: object): PluginExtensionLink | undefined { - const configured = configure(configurable, context); +function createCommandFactory( + pluginId: string, + config: AppPluginExtensionCommandConfig, + handler: (context?: object) => void +) { + return (override: Partial, context?: object): PluginExtensionCommand => { + const title = override?.title ?? config.title; + const description = override?.description ?? config.description; + + return Object.freeze({ + type: PluginExtensionTypes.command, + title: title, + description: description, + key: hashKey(`${pluginId}${config.placement}${title}`), + callHandlerWithContext: () => handler(context), + }); + }; +} - if (!configured) { +function mapToConfigure( + commandFactory: (override: Partial, context?: object) => T | undefined, + configurable: C +): (configure: ConfigureFunc) => PluginExtensionRegistryItem { + return (configure) => { + return function mapToExtension(context?: object): T | undefined { + const override = configure(configurable, context); + if (!override) { return undefined; } - - return { - ...extension, - title: configured.title ?? extension.title, - description: configured.description ?? extension.description, - path: configured.path ?? extension.path, - }; + return commandFactory(override, context); }; }; } + +function hashKey(key: string): number { + return Array.from(key).reduce((s, c) => (Math.imul(31, s) + c.charCodeAt(0)) | 0, 0); +} + +function defaultConfigure() { + return {}; +} diff --git a/public/app/features/plugins/extensions/types.ts b/public/app/features/plugins/extensions/types.ts new file mode 100644 index 00000000000..fa04cb7b346 --- /dev/null +++ b/public/app/features/plugins/extensions/types.ts @@ -0,0 +1,4 @@ +import type { AppPluginExtensionCommandConfig } from '@grafana/data'; + +export type CommandHandlerFunc = AppPluginExtensionCommandConfig['handler']; +export type ConfigureFunc = (extension: T, context?: object) => Partial | undefined; diff --git a/public/app/features/plugins/extensions/validateLink.ts b/public/app/features/plugins/extensions/validateLink.ts index 6d74733f3cd..15b61399e03 100644 --- a/public/app/features/plugins/extensions/validateLink.ts +++ b/public/app/features/plugins/extensions/validateLink.ts @@ -1,4 +1,6 @@ -import type { AppConfigureExtension, AppPluginExtensionLink } from '@grafana/data'; +import type { AppPluginExtensionLink } from '@grafana/data'; + +import type { ConfigureFunc } from './types'; type Options = { pluginId: string; @@ -9,7 +11,7 @@ type Options = { export function createLinkValidator(options: Options) { const { pluginId, title, logger } = options; - return (configure: AppConfigureExtension): AppConfigureExtension => { + return (configure: ConfigureFunc): ConfigureFunc => { return function validateLink(link, context) { const configured = configure(link, context); diff --git a/public/app/features/plugins/pluginPreloader.ts b/public/app/features/plugins/pluginPreloader.ts index 13dc6e28111..dbed70d0316 100644 --- a/public/app/features/plugins/pluginPreloader.ts +++ b/public/app/features/plugins/pluginPreloader.ts @@ -1,4 +1,4 @@ -import { AppPluginExtensionLinkConfig } from '@grafana/data'; +import type { AppPluginExtensionCommandConfig, AppPluginExtensionLinkConfig } from '@grafana/data'; import type { AppPluginConfig } from '@grafana/runtime'; import * as pluginLoader from './plugin_loader'; @@ -6,6 +6,7 @@ import * as pluginLoader from './plugin_loader'; export type PluginPreloadResult = { pluginId: string; linkExtensions: AppPluginExtensionLinkConfig[]; + commandExtensions: AppPluginExtensionCommandConfig[]; error?: unknown; }; @@ -18,10 +19,10 @@ async function preload(config: AppPluginConfig): Promise { const { path, version, id: pluginId } = config; try { const { plugin } = await pluginLoader.importPluginModule(path, version); - const { linkExtensions = [] } = plugin; - return { pluginId, linkExtensions }; + const { linkExtensions = [], commandExtensions = [] } = plugin; + return { pluginId, linkExtensions, commandExtensions }; } catch (error) { console.error(`[Plugins] Failed to preload plugin: ${path} (version: ${version})`, error); - return { pluginId, linkExtensions: [], error }; + return { pluginId, linkExtensions: [], commandExtensions: [], error }; } }