diff --git a/.betterer.results b/.betterer.results index 2ef9780c95a..3a94de50eb6 100644 --- a/.betterer.results +++ b/.betterer.results @@ -324,8 +324,7 @@ 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.", "2"] + [0, 0, 0, "Do not use any type assertions.", "1"] ], "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 fc1373530c6..b7191e6c5b4 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, type PluginExtensionCommand, type PluginExtensionLink } from './pluginExtensions'; +import type { PluginExtensionLinkConfig } from './pluginExtensions'; /** * @public @@ -50,44 +50,8 @@ export interface AppPluginMeta extends PluginMeta // TODO anything specific to apps? } -/** - * 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 AppPluginExtensionLink = Pick; - -// A list of helpers that can be used in the command handler -export type AppPluginExtensionCommandHelpers = { - // Opens a modal dialog and renders the provided React component inside it - openModal: (options: { - // The title of the modal - title: string; - // A React element that will be rendered inside the modal - body: React.ElementType<{ onDismiss?: () => void }>; - }) => void; -}; - -export type AppPluginExtensionCommand = Pick; - -export type AppPluginExtensionLinkConfig = { - title: string; - description: string; - placement: string; - path: string; - configure?: (context?: C) => Partial | undefined; -}; - -export type AppPluginExtensionCommandConfig = { - title: string; - description: string; - placement: string; - handler: (context?: C, helpers?: AppPluginExtensionCommandHelpers) => void; - configure?: (context?: C) => Partial | undefined; -}; - export class AppPlugin extends GrafanaPlugin> { - private linkExtensions: AppPluginExtensionLinkConfig[] = []; - private commandExtensions: AppPluginExtensionCommandConfig[] = []; + private _extensionConfigs: PluginExtensionLinkConfig[] = []; // Content under: /a/${plugin-id}/* root?: ComponentType>; @@ -129,28 +93,13 @@ export class AppPlugin extends GrafanaPlugin(config: AppPluginExtensionLinkConfig) { - const { path, description, title, placement } = config; - - if (!extensionLinkConfigIsValid({ path, description, title, placement })) { - console.warn('[Plugins] Disabled extension because configureExtensionLink was called with an invalid object.'); - return this; - } - - this.linkExtensions.push(config as AppPluginExtensionLinkConfig); - return this; - } + configureExtensionLink(extension: PluginExtensionLinkConfig) { + this._extensionConfigs.push(extension as PluginExtensionLinkConfig); - configureExtensionCommand(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 67328bc5fd8..84498c6570e 100644 --- a/packages/grafana-data/src/types/index.ts +++ b/packages/grafana-data/src/types/index.ts @@ -53,13 +53,12 @@ export * from './slider'; export * from './accesscontrol'; export * from './icon'; export { - type PluginExtension, - type PluginExtensionLink, - isPluginExtensionLink, - assertPluginExtensionLink, - type PluginExtensionCommand, - isPluginExtensionCommand, - assertPluginExtensionCommand, PluginExtensionTypes, PluginExtensionPlacements, + type PluginExtension, + type PluginExtensionLink, + type PluginExtensionConfig, + type PluginExtensionLinkConfig, + type PluginExtensionEventHelpers, + type PluginExtensionPanelContext, } from './pluginExtensions'; diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index f366ed23a94..a5752d2ccba 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -1,21 +1,18 @@ -/** - * These types are exposed when rendering extension points - */ +import { RawTimeRange, TimeZone } from './time'; -export enum PluginExtensionPlacements { - DashboardPanelMenu = 'grafana/dashboard/panel/menu', -} +// Plugin Extensions types +// --------------------------------------- export enum PluginExtensionTypes { link = 'link', - command = 'command', } export type PluginExtension = { + id: string; type: PluginExtensionTypes; title: string; description: string; - key: number; + pluginId: string; }; export type PluginExtensionLink = PluginExtension & { @@ -23,48 +20,64 @@ export type PluginExtensionLink = PluginExtension & { path: string; }; -export type PluginExtensionCommand = PluginExtension & { - type: PluginExtensionTypes.command; - callHandlerWithContext: () => void; +// Objects used for registering extensions (in app plugins) +// -------------------------------------------------------- + +export type PluginExtensionConfig = Pick< + PluginExtension, + 'title' | 'description' +> & + ExtraProps & { + // The unique name of the placement + // Core Grafana placements are available in the `PluginExtensionPlacements` enum + placement: string; + + // (Optional) A function that can be used to configure the extension dynamically based on the placement's context + configure?: ( + context?: Readonly + ) => Partial<{ title: string; description: string } & ExtraProps> | undefined; + }; + +export type PluginExtensionLinkConfig = PluginExtensionConfig< + Context, + Pick +>; + +export type PluginExtensionEventHelpers = { + // Opens a modal dialog and renders the provided React component inside it + openModal: (options: { + // The title of the modal + title: string; + // A React element that will be rendered inside the modal + body: React.ElementType<{ onDismiss?: () => void }>; + }) => void; }; -export function isPluginExtensionLink(extension: PluginExtension | undefined): extension is PluginExtensionLink { - if (!extension) { - return false; - } - return extension.type === PluginExtensionTypes.link && 'path' in extension; -} +// Placements & Contexts +// -------------------------------------------------------- -export function assertPluginExtensionLink( - extension: PluginExtension | undefined -): asserts extension is PluginExtensionLink { - if (!isPluginExtensionLink(extension)) { - throw new Error(`extension is not a link extension`); - } +// Placements available in core Grafana +export enum PluginExtensionPlacements { + DashboardPanelMenu = 'grafana/dashboard/panel/menu', } -export function isPluginExtensionCommand(extension: PluginExtension | undefined): extension is PluginExtensionCommand { - if (!extension) { - return false; - } - return extension.type === PluginExtensionTypes.command; -} +export type PluginExtensionPanelContext = { + pluginId: string; + id: number; + title: string; + timeRange: RawTimeRange; + timeZone: TimeZone; + dashboard: Dashboard; + targets: Target[]; +}; -export function assertPluginExtensionCommand( - extension: PluginExtension | undefined -): asserts extension is PluginExtensionCommand { - if (!isPluginExtensionCommand(extension)) { - throw new Error(`extension is not a command extension`); - } -} +type Dashboard = { + uid: string; + title: string; + tags: string[]; +}; -export function extensionLinkConfigIsValid(props: { - path?: string; - description?: string; - title?: string; - placement?: string; -}) { - const valuesAreStrings = Object.values(props).every((val) => typeof val === 'string' && val.length); - const placementIsValid = props.placement?.startsWith('grafana/') || props.placement?.startsWith('plugins/'); - return valuesAreStrings && placementIsValid; -} +type Target = { + pluginId: string; + refId: string; +}; diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts index 793fe5eb363..53bed9863c5 100644 --- a/packages/grafana-runtime/src/services/index.ts +++ b/packages/grafana-runtime/src/services/index.ts @@ -8,14 +8,10 @@ export * from './legacyAngularInjector'; export * from './live'; export * from './LocationService'; export * from './appEvents'; + export { - type PluginExtensionRegistry, - type PluginExtensionRegistryItem, - setPluginsExtensionRegistry, -} from './pluginExtensions/registry'; -export { - type PluginExtensionsOptions, - type PluginExtensionsResult, + setPluginExtensionGetter, getPluginExtensions, -} from './pluginExtensions/extensions'; -export { type PluginExtensionPanelContext } from './pluginExtensions/contexts'; + type GetPluginExtensions, +} from './pluginExtensions/getPluginExtensions'; +export { isPluginExtensionLink } from './pluginExtensions/utils'; diff --git a/packages/grafana-runtime/src/services/pluginExtensions/contexts.ts b/packages/grafana-runtime/src/services/pluginExtensions/contexts.ts deleted file mode 100644 index 5af274bb38c..00000000000 --- a/packages/grafana-runtime/src/services/pluginExtensions/contexts.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { RawTimeRange, TimeZone } from '@grafana/data'; - -type Dashboard = { - uid: string; - title: string; - tags: Readonly>>; -}; - -type Target = { - pluginId: string; - refId: string; -}; - -export type PluginExtensionPanelContext = Readonly<{ - pluginId: string; - id: number; - title: string; - timeRange: Readonly; - timeZone: TimeZone; - dashboard: Readonly; - targets: Readonly>>; -}>; diff --git a/packages/grafana-runtime/src/services/pluginExtensions/extensions.test.ts b/packages/grafana-runtime/src/services/pluginExtensions/extensions.test.ts deleted file mode 100644 index ac13c016df2..00000000000 --- a/packages/grafana-runtime/src/services/pluginExtensions/extensions.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { assertPluginExtensionLink, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data'; - -import { getPluginExtensions } from './extensions'; -import { PluginExtensionRegistryItem, setPluginsExtensionRegistry } from './registry'; - -describe('getPluginExtensions', () => { - describe('when getting extensions for placement', () => { - const placement = 'grafana/dashboard/panel/menu'; - const pluginId = 'grafana-basic-app'; - - beforeAll(() => { - setPluginsExtensionRegistry({ - [placement]: [ - createRegistryLinkItem({ - title: 'Declare incident', - description: 'Declaring an incident in the app', - path: `/a/${pluginId}/declare-incident`, - key: 1, - }), - ], - 'plugins/myorg-basic-app/start': [ - createRegistryLinkItem({ - title: 'Declare incident', - description: 'Declaring an incident in the app', - path: `/a/${pluginId}/declare-incident`, - key: 2, - }), - ], - }); - }); - - it('should return extensions with correct path', () => { - const { extensions } = getPluginExtensions({ placement }); - const [extension] = extensions; - - assertPluginExtensionLink(extension); - - expect(extension.path).toBe(`/a/${pluginId}/declare-incident`); - expect(extensions.length).toBe(1); - }); - - it('should return extensions with correct description', () => { - const { extensions } = getPluginExtensions({ placement }); - const [extension] = extensions; - - assertPluginExtensionLink(extension); - - expect(extension.description).toBe('Declaring an incident in the app'); - expect(extensions.length).toBe(1); - }); - - it('should return extensions with correct title', () => { - const { extensions } = getPluginExtensions({ placement }); - const [extension] = extensions; - - assertPluginExtensionLink(extension); - - expect(extension.title).toBe('Declare incident'); - expect(extensions.length).toBe(1); - }); - - it('should return an empty array when extensions can be found', () => { - const { extensions } = getPluginExtensions({ - placement: 'plugins/not-installed-app/news', - }); - - expect(extensions.length).toBe(0); - }); - }); -}); - -function createRegistryLinkItem( - link: Omit -): PluginExtensionRegistryItem { - 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 deleted file mode 100644 index 97678d7539b..00000000000 --- a/packages/grafana-runtime/src/services/pluginExtensions/extensions.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { type PluginExtension } from '@grafana/data'; - -import { getPluginsExtensionRegistry } from './registry'; - -export type PluginExtensionsOptions = { - placement: string; - context?: T; -}; - -export type PluginExtensionsResult = { - extensions: PluginExtension[]; -}; - -export function getPluginExtensions( - options: PluginExtensionsOptions -): PluginExtensionsResult { - const { placement, context } = options; - const registry = getPluginsExtensionRegistry(); - const configureFuncs = registry[placement] ?? []; - - const extensions = configureFuncs.reduce((result, configure) => { - const extension = configure(context); - - // If the configure() function returns `undefined`, the extension is not displayed - if (extension) { - result.push(extension); - } - - return result; - }, []); - - return { - extensions: extensions, - }; -} diff --git a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.test.ts b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.test.ts new file mode 100644 index 00000000000..f741e24dbb9 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.test.ts @@ -0,0 +1,39 @@ +import { setPluginExtensionGetter, type GetPluginExtensions, getPluginExtensions } from './getPluginExtensions'; + +describe('Plugin Extensions / Get Plugin Extensions', () => { + afterEach(() => { + process.env.NODE_ENV = 'test'; + }); + + test('should always return the same extension-getter function that was previously set', () => { + const getter: GetPluginExtensions = jest.fn().mockReturnValue({ extensions: [] }); + + setPluginExtensionGetter(getter); + getPluginExtensions({ placement: 'panel-menu' }); + + expect(getter).toHaveBeenCalledTimes(1); + expect(getter).toHaveBeenCalledWith({ placement: 'panel-menu' }); + }); + + test('should throw an error when trying to redefine the app-wide extension-getter function', () => { + // By default, NODE_ENV is set to 'test' in jest.config.js, which allows to override the registry in tests. + process.env.NODE_ENV = 'production'; + + const getter: GetPluginExtensions = () => ({ extensions: [] }); + + expect(() => { + setPluginExtensionGetter(getter); + setPluginExtensionGetter(getter); + }).toThrowError(); + }); + + test('should throw an error when trying to access the extension-getter function before it was set', () => { + // "Unsetting" the registry + // @ts-ignore + setPluginExtensionGetter(undefined); + + expect(() => { + getPluginExtensions({ placement: 'panel-menu' }); + }).toThrowError(); + }); +}); diff --git a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts new file mode 100644 index 00000000000..a8c9680138f --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts @@ -0,0 +1,30 @@ +import { PluginExtension } from '@grafana/data'; + +export type GetPluginExtensions = ({ + placement, + context, +}: { + placement: string; + context?: object | Record; +}) => { + extensions: PluginExtension[]; +}; + +let singleton: GetPluginExtensions | undefined; + +export function setPluginExtensionGetter(instance: GetPluginExtensions): void { + // We allow overriding the registry in tests + if (singleton && process.env.NODE_ENV !== 'test') { + throw new Error('setPluginExtensionGetter() function should only be called once, when Grafana is starting.'); + } + singleton = instance; +} + +function getPluginExtensionGetter(): GetPluginExtensions { + if (!singleton) { + throw new Error('getPluginExtensionGetter() can only be used after the Grafana instance has started.'); + } + return singleton; +} + +export const getPluginExtensions: GetPluginExtensions = (options) => getPluginExtensionGetter()(options); diff --git a/packages/grafana-runtime/src/services/pluginExtensions/registry.ts b/packages/grafana-runtime/src/services/pluginExtensions/registry.ts deleted file mode 100644 index 98b9ccd0848..00000000000 --- a/packages/grafana-runtime/src/services/pluginExtensions/registry.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { PluginExtension } from '@grafana/data'; - -export type PluginExtensionRegistryItem = ( - context?: C -) => T | undefined; - -export type PluginExtensionRegistry = Record; - -let registry: PluginExtensionRegistry | undefined; - -export function setPluginsExtensionRegistry(instance: PluginExtensionRegistry): void { - if (registry && process.env.NODE_ENV !== 'test') { - throw new Error('setPluginsExtensionRegistry function should only be called once, when Grafana is starting.'); - } - registry = instance; -} - -export function getPluginsExtensionRegistry(): PluginExtensionRegistry { - if (!registry) { - throw new Error('getPluginsExtensionRegistry can only be used after the Grafana instance has started.'); - } - return registry; -} diff --git a/packages/grafana-runtime/src/services/pluginExtensions/utils.test.ts b/packages/grafana-runtime/src/services/pluginExtensions/utils.test.ts new file mode 100644 index 00000000000..6cc6d1444de --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginExtensions/utils.test.ts @@ -0,0 +1,39 @@ +import { PluginExtension, PluginExtensionTypes } from '@grafana/data'; + +import { isPluginExtensionLink } from './utils'; + +describe('Plugin Extensions / Utils', () => { + describe('isPluginExtensionLink()', () => { + test('should return TRUE if the object is a link extension', () => { + expect( + isPluginExtensionLink({ + id: 'id', + pluginId: 'plugin-id', + type: PluginExtensionTypes.link, + title: 'Title', + description: 'Description', + path: '...', + } as PluginExtension) + ).toBe(true); + }); + test('should return FALSE if the object is NOT a link extension', () => { + expect( + isPluginExtensionLink({ + type: PluginExtensionTypes.link, + title: 'Title', + description: 'Description', + } as PluginExtension) + ).toBe(false); + + expect( + // @ts-ignore (Right now we only have a single type of extension) + isPluginExtensionLink({ + type: 'unknown', + title: 'Title', + description: 'Description', + path: '...', + } as PluginExtension) + ).toBe(false); + }); + }); +}); diff --git a/packages/grafana-runtime/src/services/pluginExtensions/utils.ts b/packages/grafana-runtime/src/services/pluginExtensions/utils.ts new file mode 100644 index 00000000000..45aca3dbb18 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginExtensions/utils.ts @@ -0,0 +1,9 @@ +import { PluginExtension, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data'; + +export function isPluginExtensionLink(extension: PluginExtension | undefined): extension is PluginExtensionLink { + if (!extension) { + return false; + } + + return extension.type === PluginExtensionTypes.link && 'path' in extension; +} diff --git a/public/app/app.ts b/public/app/app.ts index 82728cc801c..635006fd1be 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -33,8 +33,9 @@ import { setQueryRunnerFactory, setRunRequest, setPluginImportUtils, - setPluginsExtensionRegistry, + setPluginExtensionGetter, setAppEvents, + type GetPluginExtensions, } from '@grafana/runtime'; import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView'; import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer'; @@ -72,7 +73,8 @@ import { getTimeSrv } from './features/dashboard/services/TimeSrv'; import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView'; import { PanelRenderer } from './features/panel/components/PanelRenderer'; import { DatasourceSrv } from './features/plugins/datasource_srv'; -import { createPluginExtensionRegistry } from './features/plugins/extensions/registryFactory'; +import { createPluginExtensionRegistry } from './features/plugins/extensions/createPluginExtensionRegistry'; +import { getPluginExtensions } from './features/plugins/extensions/getPluginExtensions'; import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin'; import { preloadPlugins } from './features/plugins/pluginPreloader'; import { QueryRunner } from './features/query/state/QueryRunner'; @@ -187,8 +189,9 @@ export class GrafanaApp { const preloadResults = await preloadPlugins(config.apps); // Create extension registry out of the preloaded plugins - const extensionsRegistry = createPluginExtensionRegistry(preloadResults); - setPluginsExtensionRegistry(extensionsRegistry); + const pluginExtensionGetter: GetPluginExtensions = (options) => + getPluginExtensions({ ...options, registry: createPluginExtensionRegistry(preloadResults) }); + setPluginExtensionGetter(pluginExtensionGetter); // initialize chrome service const queryParams = locationService.getSearchObject(); diff --git a/public/app/features/dashboard/utils/getPanelMenu.test.ts b/public/app/features/dashboard/utils/getPanelMenu.test.ts index 953558eb624..ad7774a69bd 100644 --- a/public/app/features/dashboard/utils/getPanelMenu.test.ts +++ b/public/app/features/dashboard/utils/getPanelMenu.test.ts @@ -1,15 +1,5 @@ -import { - PanelMenuItem, - PluginExtension, - PluginExtensionLink, - PluginExtensionTypes, - PluginExtensionPlacements, -} from '@grafana/data'; -import { - PluginExtensionPanelContext, - PluginExtensionRegistryItem, - setPluginsExtensionRegistry, -} from '@grafana/runtime'; +import { PanelMenuItem, PluginExtensionPanelContext, PluginExtensionTypes } from '@grafana/data'; +import { getPluginExtensions } from '@grafana/runtime'; import config from 'app/core/config'; import * as actions from 'app/features/explore/state/main'; import { setStore } from 'app/store/store'; @@ -25,9 +15,16 @@ jest.mock('app/core/services/context_srv', () => ({ }, })); +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + setPluginExtensionGetter: jest.fn(), + getPluginExtensions: jest.fn(), +})); + describe('getPanelMenu()', () => { beforeEach(() => { - setPluginsExtensionRegistry({}); + (getPluginExtensions as jest.Mock).mockRestore(); + (getPluginExtensions as jest.Mock).mockReturnValue({ extensions: [] }); }); it('should return the correct panel menu items', () => { @@ -111,24 +108,24 @@ describe('getPanelMenu()', () => { describe('when extending panel menu from plugins', () => { it('should contain menu item from link extension', () => { - setPluginsExtensionRegistry({ - [PluginExtensionPlacements.DashboardPanelMenu]: [ - createRegistryItem({ + (getPluginExtensions as jest.Mock).mockReturnValue({ + extensions: [ + { + pluginId: '...', type: PluginExtensionTypes.link, title: 'Declare incident', description: 'Declaring an incident in the app', path: '/a/grafana-basic-app/declare-incident', - key: 1, - }), + }, ], }); const panel = new PanelModel({}); const dashboard = createDashboardModelFixture({}); const menuItems = getPanelMenu(dashboard, panel); - const moreSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu; + const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu; - expect(moreSubMenu).toEqual( + expect(extensionsSubMenu).toEqual( expect.arrayContaining([ expect.objectContaining({ text: 'Declare incident', @@ -139,24 +136,24 @@ describe('getPanelMenu()', () => { }); it('should truncate menu item title to 25 chars', () => { - setPluginsExtensionRegistry({ - [PluginExtensionPlacements.DashboardPanelMenu]: [ - createRegistryItem({ + (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', path: '/a/grafana-basic-app/declare-incident', - key: 1, - }), + }, ], }); const panel = new PanelModel({}); const dashboard = createDashboardModelFixture({}); const menuItems = getPanelMenu(dashboard, panel); - const moreSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu; + const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu; - expect(moreSubMenu).toEqual( + expect(extensionsSubMenu).toEqual( expect.arrayContaining([ expect.objectContaining({ text: 'Declare incident when...', @@ -166,94 +163,7 @@ describe('getPanelMenu()', () => { ); }); - it('should use extension for panel menu returned by configure function', () => { - const configure: PluginExtensionRegistryItem = () => ({ - title: 'Wohoo', - type: PluginExtensionTypes.link, - description: 'Declaring an incident in the app', - path: '/a/grafana-basic-app/declare-incident', - key: 1, - }); - - setPluginsExtensionRegistry({ - [PluginExtensionPlacements.DashboardPanelMenu]: [ - createRegistryItem( - { - type: PluginExtensionTypes.link, - title: 'Declare incident when pressing this amazing menu item', - description: 'Declaring an incident in the app', - path: '/a/grafana-basic-app/declare-incident', - key: 1, - }, - configure - ), - ], - }); - - const panel = new PanelModel({}); - const dashboard = createDashboardModelFixture({}); - const menuItems = getPanelMenu(dashboard, panel); - const moreSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu; - - expect(moreSubMenu).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - text: 'Wohoo', - href: '/a/grafana-basic-app/declare-incident', - }), - ]) - ); - }); - - it('should hide menu item if configure function returns undefined', () => { - setPluginsExtensionRegistry({ - [PluginExtensionPlacements.DashboardPanelMenu]: [ - createRegistryItem( - { - type: PluginExtensionTypes.link, - title: 'Declare incident when pressing this amazing menu item', - description: 'Declaring an incident in the app', - path: '/a/grafana-basic-app/declare-incident', - key: 1, - }, - () => undefined - ), - ], - }); - - const panel = new PanelModel({}); - const dashboard = createDashboardModelFixture({}); - const menuItems = getPanelMenu(dashboard, panel); - const moreSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu; - - expect(moreSubMenu).toEqual( - expect.not.arrayContaining([ - expect.objectContaining({ - text: 'Declare incident when...', - href: '/a/grafana-basic-app/declare-incident', - }), - ]) - ); - }); - it('should pass context with correct values when configuring extension', () => { - const configure = jest.fn(); - - setPluginsExtensionRegistry({ - [PluginExtensionPlacements.DashboardPanelMenu]: [ - createRegistryItem( - { - type: PluginExtensionTypes.link, - title: 'Declare incident when pressing this amazing menu item', - description: 'Declaring an incident in the app', - path: '/a/grafana-basic-app/declare-incident', - key: 1, - }, - configure - ), - ], - }); - const panel = new PanelModel({ type: 'timeseries', id: 1, @@ -303,65 +213,7 @@ describe('getPanelMenu()', () => { }, }; - expect(configure).toBeCalledWith(context); - }); - - it('should pass context that can not be edited in configure function', () => { - const configure: PluginExtensionRegistryItem = (context) => { - // trying to change values in the context - // @ts-ignore - context.pluginId = 'changed'; - - return { - type: PluginExtensionTypes.link, - title: 'Declare incident when pressing this amazing menu item', - description: 'Declaring an incident in the app', - path: '/a/grafana-basic-app/declare-incident', - key: 1, - }; - }; - - setPluginsExtensionRegistry({ - [PluginExtensionPlacements.DashboardPanelMenu]: [ - createRegistryItem( - { - type: PluginExtensionTypes.link, - title: 'Declare incident when pressing this amazing menu item', - description: 'Declaring an incident in the app', - path: '/a/grafana-basic-app/declare-incident', - key: 1, - }, - configure - ), - ], - }); - - const panel = new PanelModel({ - type: 'timeseries', - id: 1, - title: 'My panel', - targets: [ - { - refId: 'A', - datasource: { - type: 'testdata', - }, - }, - ], - }); - - const dashboard = createDashboardModelFixture({ - timezone: 'utc', - time: { - from: 'now-5m', - to: 'now', - }, - tags: ['database', 'panel'], - uid: '123', - title: 'My dashboard', - }); - - expect(() => getPanelMenu(dashboard, panel)).toThrowError(TypeError); + expect(getPluginExtensions).toBeCalledWith(expect.objectContaining({ context })); }); }); @@ -479,11 +331,3 @@ describe('getPanelMenu()', () => { }); }); }); - -function createRegistryItem( - extension: T, - 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 c808182b1f9..c11eed7f822 100644 --- a/public/app/features/dashboard/utils/getPanelMenu.ts +++ b/public/app/features/dashboard/utils/getPanelMenu.ts @@ -1,16 +1,11 @@ +import { PanelMenuItem, PluginExtensionPlacements, type PluginExtensionPanelContext } from '@grafana/data'; import { - isPluginExtensionCommand, isPluginExtensionLink, - PanelMenuItem, - PluginExtensionPlacements, -} from '@grafana/data'; -import { AngularComponent, getDataSourceSrv, getPluginExtensions, locationService, reportInteraction, - PluginExtensionPanelContext, } from '@grafana/runtime'; import { PanelCtrl } from 'app/angular/panel/panel_ctrl'; import config from 'app/core/config'; @@ -299,14 +294,6 @@ export function getPanelMenu( }); continue; } - - if (isPluginExtensionCommand(extension)) { - extensionsMenu.push({ - text: truncateTitle(extension.title, 25), - onClick: extension.callHandlerWithContext, - }); - continue; - } } menu.push({ @@ -340,26 +327,20 @@ function truncateTitle(title: string, length: number): string { } function createExtensionContext(panel: PanelModel, dashboard: DashboardModel): PluginExtensionPanelContext { - const timeRange = Object.assign({}, dashboard.time); - - return Object.freeze({ + return { id: panel.id, pluginId: panel.type, title: panel.title, - timeRange: Object.freeze(timeRange), + timeRange: Object.assign({}, dashboard.time), timeZone: dashboard.timezone, - dashboard: Object.freeze({ + dashboard: { uid: dashboard.uid, title: dashboard.title, - tags: Object.freeze(Array.from(dashboard.tags)), - }), - targets: Object.freeze( - panel.targets.map((t) => - Object.freeze({ - refId: t.refId, - pluginId: t.datasource?.type ?? 'unknown', - }) - ) - ), - }); + tags: Array.from(dashboard.tags), + }, + targets: panel.targets.map((t) => ({ + refId: t.refId, + pluginId: t.datasource?.type ?? 'unknown', + })), + }; } diff --git a/public/app/features/plugins/extensions/constants.ts b/public/app/features/plugins/extensions/constants.ts new file mode 100644 index 00000000000..c128234677e --- /dev/null +++ b/public/app/features/plugins/extensions/constants.ts @@ -0,0 +1 @@ +export const MAX_EXTENSIONS_PER_PLACEMENT_PER_PLUGIN = 2; diff --git a/public/app/features/plugins/extensions/createPluginExtensionRegistry.test.ts b/public/app/features/plugins/extensions/createPluginExtensionRegistry.test.ts new file mode 100644 index 00000000000..7585bcb1b90 --- /dev/null +++ b/public/app/features/plugins/extensions/createPluginExtensionRegistry.test.ts @@ -0,0 +1,165 @@ +import { PluginExtensionLinkConfig } from '@grafana/data'; + +import { createPluginExtensionRegistry } from './createPluginExtensionRegistry'; + +describe('createRegistry()', () => { + const placement1 = 'grafana/dashboard/panel/menu'; + const placement2 = 'plugins/myorg-basic-app/start'; + const pluginId = 'grafana-basic-app'; + let link1: PluginExtensionLinkConfig, link2: PluginExtensionLinkConfig; + + beforeEach(() => { + link1 = { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + placement: placement1, + configure: jest.fn().mockReturnValue({}), + }; + link2 = { + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId}/declare-incident`, + placement: placement2, + configure: jest.fn().mockImplementation((context) => ({ title: context?.title })), + }; + + global.console.warn = jest.fn(); + }); + + it('should be possible to register extensions', () => { + const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]); + + expect(Object.getOwnPropertyNames(registry)).toEqual([placement1, placement2]); + + // Placement 1 + expect(registry[placement1]).toHaveLength(1); + expect(registry[placement1]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId, + config: { + ...link1, + configure: expect.any(Function), + }, + }), + ]) + ); + + // Placement 2 + expect(registry[placement2]).toHaveLength(1); + expect(registry[placement2]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId, + config: { + ...link2, + configure: expect.any(Function), + }, + }), + ]) + ); + }); + + it('should register maximum 2 extensions / plugin / placement', () => { + const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link1, link1] }]); + + expect(Object.getOwnPropertyNames(registry)).toEqual([placement1]); + + // Placement 1 + expect(registry[placement1]).toHaveLength(2); + expect(registry[placement1]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId, + config: { + ...link1, + configure: expect.any(Function), + }, + }), + expect.objectContaining({ + pluginId, + config: { + ...link1, + configure: expect.any(Function), + }, + }), + ]) + ); + }); + + it('should not register link extensions with invalid path configured', () => { + const registry = createPluginExtensionRegistry([ + { pluginId, extensionConfigs: [{ ...link1, path: 'invalid-path' }, link2] }, + ]); + + expect(Object.getOwnPropertyNames(registry)).toEqual([placement2]); + + // Placement 2 + expect(registry[placement2]).toHaveLength(1); + expect(registry[placement2]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId, + config: { + ...link2, + configure: expect.any(Function), + }, + }), + ]) + ); + }); + + it('should not register extensions for a plugin that had errors', () => { + const registry = createPluginExtensionRegistry([ + { pluginId, extensionConfigs: [link1, link2], error: new Error('Plugin failed to load') }, + ]); + + expect(Object.getOwnPropertyNames(registry)).toEqual([]); + }); + + it('should not register an extension if it has an invalid configure() function', () => { + const registry = createPluginExtensionRegistry([ + // @ts-ignore (We would like to provide an invalid configure function on purpose) + { pluginId, extensionConfigs: [{ ...link1, configure: '...' }, link2] }, + ]); + + expect(Object.getOwnPropertyNames(registry)).toEqual([placement2]); + + // Placement 2 (checking if it still registers the extension with a valid configuration) + expect(registry[placement2]).toHaveLength(1); + expect(registry[placement2]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId, + config: { + ...link2, + configure: expect.any(Function), + }, + }), + ]) + ); + }); + + it('should not register an extension if it has invalid properties (empty title / description)', () => { + const registry = createPluginExtensionRegistry([ + { pluginId, extensionConfigs: [{ ...link1, title: '', description: '' }, link2] }, + ]); + + expect(Object.getOwnPropertyNames(registry)).toEqual([placement2]); + + // Placement 2 (checking if it still registers the extension with a valid configuration) + expect(registry[placement2]).toHaveLength(1); + expect(registry[placement2]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId, + config: { + ...link2, + configure: expect.any(Function), + }, + }), + ]) + ); + }); +}); diff --git a/public/app/features/plugins/extensions/createPluginExtensionRegistry.ts b/public/app/features/plugins/extensions/createPluginExtensionRegistry.ts new file mode 100644 index 00000000000..54ab7aaf10a --- /dev/null +++ b/public/app/features/plugins/extensions/createPluginExtensionRegistry.ts @@ -0,0 +1,49 @@ +import type { PluginPreloadResult } from '../pluginPreloader'; + +import { MAX_EXTENSIONS_PER_PLACEMENT_PER_PLUGIN } from './constants'; +import { PlacementsPerPlugin } from './placementsPerPlugin'; +import type { PluginExtensionRegistryItem, PluginExtensionRegistry } from './types'; +import { deepFreeze, logWarning } from './utils'; +import { isPluginExtensionConfigValid } from './validators'; + +export function createPluginExtensionRegistry(pluginPreloadResults: PluginPreloadResult[]): PluginExtensionRegistry { + const registry: PluginExtensionRegistry = {}; + const placementsPerPlugin = new PlacementsPerPlugin(); + + for (const { pluginId, extensionConfigs, error } of pluginPreloadResults) { + if (error) { + logWarning(`"${pluginId}" plugin failed to load, skip registering its extensions.`); + continue; + } + + for (const extensionConfig of extensionConfigs) { + const { placement } = extensionConfig; + + if (!placementsPerPlugin.allowedToAdd(extensionConfig)) { + logWarning( + `"${pluginId}" plugin has reached the limit of ${MAX_EXTENSIONS_PER_PLACEMENT_PER_PLUGIN} for "${placement}", skip registering extension "${extensionConfig.title}".` + ); + continue; + } + + if (!extensionConfig || !isPluginExtensionConfigValid(pluginId, extensionConfig)) { + continue; + } + + let registryItem: PluginExtensionRegistryItem = { + config: extensionConfig, + + // Additional meta information about the extension + pluginId, + }; + + if (!Array.isArray(registry[placement])) { + registry[placement] = [registryItem]; + } else { + registry[placement].push(registryItem); + } + } + } + + return deepFreeze(registry); +} diff --git a/public/app/features/plugins/extensions/errorHandling.test.ts b/public/app/features/plugins/extensions/errorHandling.test.ts deleted file mode 100644 index 75e12ecbcf0..00000000000 --- a/public/app/features/plugins/extensions/errorHandling.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -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(), - }); - - const context = {}; - - it('should return configured link if configure is successful', () => { - const configureWithErrorHandling = errorHandler(() => { - return { - title: 'This is a new title', - }; - }); - - const configured = configureWithErrorHandling(context); - - expect(configured).toEqual({ - title: 'This is a new title', - }); - }); - - it('should return undefined if configure throws error', () => { - const configureWithErrorHandling = errorHandler(() => { - throw new Error(); - }); - - const configured = configureWithErrorHandling(context); - - expect(configured).toBeUndefined(); - }); - - it('should return undefined if configure is promise/async-based', () => { - const promisebased = (async () => {}) as ConfigureFunc; - const configureWithErrorHandling = errorHandler(promisebased); - - const configured = configureWithErrorHandling(context); - - expect(configured).toBeUndefined(); - }); - - it('should return undefined if configure is not a function', () => { - const objectbased = {} as ConfigureFunc; - const configureWithErrorHandling = errorHandler(objectbased); - - const configured = configureWithErrorHandling(context); - - expect(configured).toBeUndefined(); - }); - - it('should return undefined if configure returns other than an object', () => { - const returnString = (() => '') as ConfigureFunc; - const configureWithErrorHandling = errorHandler(returnString); - - const configured = configureWithErrorHandling(context); - - expect(configured).toBeUndefined(); - }); - - it('should return undefined if configure returns undefined', () => { - const returnUndefined = () => undefined; - const configureWithErrorHandling = errorHandler(returnUndefined); - - const configured = configureWithErrorHandling(context); - - expect(configured).toBeUndefined(); - }); - }); - - describe('error handling for command handler', () => { - const pluginId = 'grafana-basic-app'; - const errorHandler = handleErrorsInHandler({ - pluginId: pluginId, - title: 'open modal', - logger: jest.fn(), - }); - - it('should be called successfully when handler is a normal synchronous function', () => { - const handler = jest.fn(); - const handlerWithErrorHandling = errorHandler(handler); - - handlerWithErrorHandling(); - - expect(handler).toBeCalled(); - }); - - it('should not error out even if the handler throws an error', () => { - const handlerWithErrorHandling = errorHandler(() => { - throw new Error(); - }); - - expect(handlerWithErrorHandling).not.toThrowError(); - }); - - 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 deleted file mode 100644 index c7a51e32176..00000000000 --- a/public/app/features/plugins/extensions/errorHandling.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { isFunction, isObject } from 'lodash'; - -import type { CommandHandlerFunc, ConfigureFunc } from './types'; - -type Options = { - pluginId: string; - title: string; - logger: (msg: string, error?: unknown) => void; -}; - -export function handleErrorsInConfigure(options: Options) { - const { pluginId, title, logger } = options; - - return (configure: ConfigureFunc): ConfigureFunc => { - return function handleErrors(context) { - try { - if (!isFunction(configure)) { - logger(`[Plugins] ${pluginId} provided invalid configuration function for extension '${title}'.`); - return; - } - - const result = configure(context); - if (result instanceof Promise) { - logger( - `[Plugins] ${pluginId} provided an unsupported async/promise-based configureation function for extension '${title}'.` - ); - result.catch(() => {}); - return; - } - - if (!isObject(result) && typeof result !== 'undefined') { - logger(`[Plugins] ${pluginId} returned an inccorect object in configure function for extension '${title}'.`); - return; - } - - return result; - } catch (error) { - logger(`[Plugins] ${pluginId} thow an error while configure extension '${title}'`, error); - return; - } - }; - }; -} - -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/getModalWrapper.tsx b/public/app/features/plugins/extensions/getModalWrapper.tsx deleted file mode 100644 index 8961ecdf49f..00000000000 --- a/public/app/features/plugins/extensions/getModalWrapper.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; - -import { AppPluginExtensionCommandHelpers } from '@grafana/data'; -import { Modal } from '@grafana/ui'; - -export type ModalWrapperProps = { - onDismiss: () => void; -}; - -// Wraps a component with a modal. -// This way we can make sure that the modal is closable, and we also make the usage simpler. -export const getModalWrapper = ({ - // The title of the modal (appears in the header) - title, - // A component that serves the body of the modal - body: Body, -}: Parameters[0]) => { - const ModalWrapper = ({ onDismiss }: ModalWrapperProps) => { - return ( - - - - ); - }; - - return ModalWrapper; -}; diff --git a/public/app/features/plugins/extensions/getPluginExtensions.test.ts b/public/app/features/plugins/extensions/getPluginExtensions.test.ts new file mode 100644 index 00000000000..7e33a9fdb27 --- /dev/null +++ b/public/app/features/plugins/extensions/getPluginExtensions.test.ts @@ -0,0 +1,186 @@ +import { PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data'; + +import { createPluginExtensionRegistry } from './createPluginExtensionRegistry'; +import { getPluginExtensions } from './getPluginExtensions'; +import { assertPluginExtensionLink } from './validators'; + +describe('getPluginExtensions()', () => { + const placement1 = 'grafana/dashboard/panel/menu'; + const placement2 = 'plugins/myorg-basic-app/start'; + const pluginId = 'grafana-basic-app'; + let link1: PluginExtensionLinkConfig, link2: PluginExtensionLinkConfig; + + beforeEach(() => { + link1 = { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + placement: placement1, + configure: jest.fn().mockReturnValue({}), + }; + link2 = { + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId}/declare-incident`, + placement: placement2, + configure: jest.fn().mockImplementation((context) => ({ title: context?.title })), + }; + + global.console.warn = jest.fn(); + }); + + test('should return the extensions for the given placement', () => { + const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]); + const { extensions } = getPluginExtensions({ registry, placement: placement1 }); + + expect(extensions).toHaveLength(1); + expect(extensions[0]).toEqual( + expect.objectContaining({ + pluginId, + type: PluginExtensionTypes.link, + title: link1.title, + description: link1.description, + path: link1.path, + }) + ); + }); + + test('should return with an empty list if there are no extensions registered for a placement yet', () => { + const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]); + const { extensions } = getPluginExtensions({ registry, placement: 'placement-with-no-extensions' }); + + expect(extensions).toEqual([]); + }); + + test('should pass the context to the configure() function', () => { + const context = { title: 'New title from the context!' }; + const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + + getPluginExtensions({ registry, context, placement: placement2 }); + + expect(link2.configure).toHaveBeenCalledTimes(1); + expect(link2.configure).toHaveBeenCalledWith(context); + }); + + test('should be possible to update the basic properties with the configure() function', () => { + link2.configure = jest.fn().mockImplementation(() => ({ + title: 'Updated title', + description: 'Updated description', + path: `/a/${pluginId}/updated-path`, + })); + + const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const { extensions } = getPluginExtensions({ registry, placement: placement2 }); + const [extension] = extensions; + + assertPluginExtensionLink(extension); + + expect(link2.configure).toHaveBeenCalledTimes(1); + expect(extension.title).toBe('Updated title'); + expect(extension.description).toBe('Updated description'); + expect(extension.path).toBe(`/a/${pluginId}/updated-path`); + }); + + test('should hide the extension if it tries to override not-allowed properties with the configure() function', () => { + link2.configure = jest.fn().mockImplementation(() => ({ + // The following props are not allowed to override + type: 'unknown-type', + pluginId: 'another-plugin', + })); + + const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const { extensions } = getPluginExtensions({ registry, placement: placement2 }); + + expect(link2.configure).toHaveBeenCalledTimes(1); + expect(extensions).toHaveLength(0); + }); + test('should pass a frozen copy of the context to the configure() function', () => { + const context = { title: 'New title from the context!' }; + const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const { extensions } = getPluginExtensions({ registry, context, placement: placement2 }); + const [extension] = extensions; + const frozenContext = (link2.configure as jest.Mock).mock.calls[0][0]; + + assertPluginExtensionLink(extension); + + expect(link2.configure).toHaveBeenCalledTimes(1); + expect(Object.isFrozen(frozenContext)).toBe(true); + expect(() => { + frozenContext.title = 'New title'; + }).toThrow(); + expect(context.title).toBe('New title from the context!'); + }); + + test('should catch errors in the configure() function and log them as warnings', () => { + link2.configure = jest.fn().mockImplementation(() => { + throw new Error('Something went wrong!'); + }); + + const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + + expect(() => { + getPluginExtensions({ registry, placement: placement2 }); + }).not.toThrow(); + + expect(link2.configure).toHaveBeenCalledTimes(1); + expect(global.console.warn).toHaveBeenCalledTimes(1); + expect(global.console.warn).toHaveBeenCalledWith('[Plugin Extensions] Something went wrong!'); + }); + + test('should skip the link extension if the configure() function returns with an invalid path', () => { + link1.configure = jest.fn().mockImplementation(() => ({ + path: '/a/another-plugin/page-a', + })); + link2.configure = jest.fn().mockImplementation(() => ({ + path: 'invalid-path', + })); + + const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]); + const { extensions: extensionsAtPlacement1 } = getPluginExtensions({ registry, placement: placement1 }); + const { extensions: extensionsAtPlacement2 } = getPluginExtensions({ registry, placement: placement2 }); + + expect(extensionsAtPlacement1).toHaveLength(0); + expect(extensionsAtPlacement2).toHaveLength(0); + + expect(link1.configure).toHaveBeenCalledTimes(1); + expect(link2.configure).toHaveBeenCalledTimes(1); + expect(global.console.warn).toHaveBeenCalledTimes(2); + }); + + test('should skip the extension if any of the updated props returned by the configure() function are invalid', () => { + const overrides = { + title: '', // Invalid empty string for title - should be ignored + description: 'A valid description.', // This should be updated + }; + + link2.configure = jest.fn().mockImplementation(() => overrides); + + const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const { extensions } = getPluginExtensions({ registry, placement: placement2 }); + + expect(extensions).toHaveLength(0); + expect(link2.configure).toHaveBeenCalledTimes(1); + expect(global.console.warn).toHaveBeenCalledTimes(1); + }); + + test('should skip the extension if the configure() function returns a promise', () => { + link2.configure = jest.fn().mockImplementation(() => Promise.resolve({})); + + const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const { extensions } = getPluginExtensions({ registry, placement: placement2 }); + + expect(extensions).toHaveLength(0); + expect(link2.configure).toHaveBeenCalledTimes(1); + expect(global.console.warn).toHaveBeenCalledTimes(1); + }); + + test('should skip (hide) the extension if the configure() function returns undefined', () => { + link2.configure = jest.fn().mockImplementation(() => undefined); + + const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const { extensions } = getPluginExtensions({ registry, placement: placement2 }); + + expect(extensions).toHaveLength(0); + expect(global.console.warn).toHaveBeenCalledTimes(0); // As this is intentional, no warning should be logged + }); +}); diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts new file mode 100644 index 00000000000..50f3067b1b0 --- /dev/null +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -0,0 +1,106 @@ +import { + type PluginExtension, + PluginExtensionTypes, + PluginExtensionLink, + PluginExtensionLinkConfig, +} from '@grafana/data'; + +import type { PluginExtensionRegistry } from './types'; +import { isPluginExtensionLinkConfig, deepFreeze, logWarning, generateExtensionId } from './utils'; +import { assertIsNotPromise, assertLinkPathIsValid, assertStringProps } from './validators'; + +type GetExtensions = ({ + context, + placement, + registry, +}: { + context?: object | Record; + placement: string; + registry: PluginExtensionRegistry; +}) => { extensions: PluginExtension[] }; + +// Returns with a list of plugin extensions for the given placement +export const getPluginExtensions: GetExtensions = ({ context, placement, registry }) => { + const frozenContext = context ? deepFreeze(context) : {}; + const registryItems = registry[placement] ?? []; + // We don't return the extensions separated by type, because in that case it would be much harder to define a sort-order for them. + const extensions: PluginExtension[] = []; + + for (const registryItem of registryItems) { + try { + const extensionConfig = registryItem.config; + + // LINK extension + if (isPluginExtensionLinkConfig(extensionConfig)) { + const overrides = getLinkExtensionOverrides(registryItem.pluginId, extensionConfig, frozenContext); + + // Hide (configure() has returned `undefined`) + if (extensionConfig.configure && overrides === undefined) { + continue; + } + + const extension: PluginExtensionLink = { + id: generateExtensionId(registryItem.pluginId, extensionConfig), + type: PluginExtensionTypes.link, + pluginId: registryItem.pluginId, + + // Configurable properties + title: overrides?.title || extensionConfig.title, + description: overrides?.description || extensionConfig.description, + path: overrides?.path || extensionConfig.path, + }; + + extensions.push(extension); + } + } catch (error) { + if (error instanceof Error) { + logWarning(error.message); + } + } + } + + return { extensions }; +}; + +function getLinkExtensionOverrides(pluginId: string, config: PluginExtensionLinkConfig, context?: object) { + try { + const overrides = config.configure?.(context); + + // Hiding the extension + if (overrides === undefined) { + return undefined; + } + + let { title = config.title, description = config.description, path = config.path, ...rest } = overrides; + + assertIsNotPromise( + overrides, + `The configure() function for "${config.title}" returned a promise, skipping updates.` + ); + + assertLinkPathIsValid(pluginId, path); + assertStringProps({ title, description }, ['title', 'description']); + + if (Object.keys(rest).length > 0) { + throw new Error( + `Invalid extension "${config.title}". Trying to override not-allowed properties: ${Object.keys(rest).join( + ', ' + )}` + ); + } + + return { + title, + description, + path, + }; + } catch (error) { + if (error instanceof Error) { + logWarning(error.message); + } + + // If there is an error, we hide the extension + // (This seems to be safest option in case the extension is doing something wrong.) + return undefined; + } +} diff --git a/public/app/features/plugins/extensions/placementsPerPlugin.ts b/public/app/features/plugins/extensions/placementsPerPlugin.ts index 62ea2e867eb..05935e8fa3a 100644 --- a/public/app/features/plugins/extensions/placementsPerPlugin.ts +++ b/public/app/features/plugins/extensions/placementsPerPlugin.ts @@ -1,15 +1,33 @@ -export class PlacementsPerPlugin { - private counter: Record = {}; - private limit = 2; +import { PluginExtensionLinkConfig } from '@grafana/data'; + +import { MAX_EXTENSIONS_PER_PLACEMENT_PER_PLUGIN } from './constants'; - allowedToAdd(placement: string): boolean { - const count = this.counter[placement] ?? 0; +export class PlacementsPerPlugin { + private extensionsByPlacement: Record = {}; - if (count >= this.limit) { + allowedToAdd({ placement, title }: PluginExtensionLinkConfig): boolean { + if (this.countByPlacement(placement) >= MAX_EXTENSIONS_PER_PLACEMENT_PER_PLUGIN) { return false; } - this.counter[placement] = count + 1; + this.addExtensionToPlacement(placement, title); + return true; } + + addExtensionToPlacement(placement: string, extensionTitle: string) { + if (!this.extensionsByPlacement[placement]) { + this.extensionsByPlacement[placement] = []; + } + + this.extensionsByPlacement[placement].push(extensionTitle); + } + + countByPlacement(placement: string) { + return this.extensionsByPlacement[placement]?.length ?? 0; + } + + getExtensionTitlesByPlacement(placement: string) { + return this.extensionsByPlacement[placement]; + } } diff --git a/public/app/features/plugins/extensions/registryFactory.test.ts b/public/app/features/plugins/extensions/registryFactory.test.ts deleted file mode 100644 index 372efae9844..00000000000 --- a/public/app/features/plugins/extensions/registryFactory.test.ts +++ /dev/null @@ -1,537 +0,0 @@ -import { - AppPluginExtensionCommandConfig, - AppPluginExtensionLinkConfig, - assertPluginExtensionCommand, - PluginExtensionTypes, -} from '@grafana/data'; -import { PluginExtensionRegistry } from '@grafana/runtime'; - -import { createPluginExtensionRegistry } from './registryFactory'; - -const validateLink = jest.fn((configure, context) => configure?.(context)); -const configureErrorHandler = jest.fn((configure, context) => configure?.(context)); -const commandErrorHandler = jest.fn((configure, context) => configure?.(context)); - -jest.mock('./errorHandling', () => ({ - ...jest.requireActual('./errorHandling'), - handleErrorsInConfigure: jest.fn(() => { - return jest.fn((configure) => { - return jest.fn((context) => configureErrorHandler(configure, context)); - }); - }), - handleErrorsInHandler: jest.fn(() => { - return jest.fn((configure) => { - return jest.fn((context) => commandErrorHandler(configure, context)); - }); - }), -})); - -jest.mock('./validateLink', () => ({ - ...jest.requireActual('./validateLink'), - createLinkValidator: jest.fn(() => { - return jest.fn((configure) => { - return jest.fn((context) => validateLink(configure, context)); - }); - }), -})); - -describe('createPluginExtensionRegistry()', () => { - beforeEach(() => { - validateLink.mockClear(); - configureErrorHandler.mockClear(); - commandErrorHandler.mockClear(); - }); - - 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: [], - }, - ]); - - shouldHaveExtensionsAtPlacement({ configs: [linkConfig], placement: placement1, registry }); - }); - - it('should only register a link extension to a single placement', () => { - const registry = createPluginExtensionRegistry([ - { - pluginId, - linkExtensions: [linkConfig], - commandExtensions: [], - }, - ]); - - 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: [], - }, - ]); - - 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: [], - }, - { - pluginId: 'grafana-monitoring-app', - linkExtensions: [ - { ...linkConfig, placement: placement1, path: '/a/grafana-monitoring-app/incidents/declare' }, - ], - commandExtensions: [], - }, - ]); - - 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); - - // The 3rd link is being ignored - shouldHaveExtensionsAtPlacement({ - placement: linkConfig.placement, - configs: [ - { ...linkConfig, title: 'Link 1' }, - { ...linkConfig, title: 'Link 2' }, - ], - 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: [], - }, - ]); - - shouldHaveNumberOfPlacements(registry, 0); - }); - - it('should add default configure function when none provided via extension config', () => { - const registry = createPluginExtensionRegistry([ - { - pluginId, - linkExtensions: [linkConfig], - commandExtensions: [], - }, - ]); - - const [configure] = registry[linkConfig.placement]; - const configured = configure(); - - // 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 the configure function with link extension validator', () => { - const registry = createPluginExtensionRegistry([ - { - pluginId, - linkExtensions: [ - { - ...linkConfig, - configure: () => ({}), - }, - ], - commandExtensions: [], - }, - ]); - - const [configure] = registry[linkConfig.placement]; - const context = {}; - - configure(context); - - expect(validateLink).toBeCalledWith(expect.any(Function), context); - }); - - it('should wrap configure function with extension error handling', () => { - const registry = createPluginExtensionRegistry([ - { - pluginId, - linkExtensions: [ - { - ...linkConfig, - configure: () => ({}), - }, - ], - commandExtensions: [], - }, - ]); - - const [configure] = registry[linkConfig.placement]; - const context = {}; - - configure(context); - - expect(configureErrorHandler).toBeCalledWith(expect.any(Function), 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(); - }); - }); - - // Command extensions - // ------------------ - describe('when registering commands', () => { - const pluginId = 'belugacdn-app'; - // Sample command configurations to be used in tests - let commandConfig1: AppPluginExtensionCommandConfig, commandConfig2: AppPluginExtensionCommandConfig; - - beforeEach(() => { - commandConfig1 = { - placement: 'grafana/dashboard/panel/menu', - title: 'Open incident', - description: 'You can create an incident from this context', - handler: jest.fn(), - }; - commandConfig2 = { - placement: 'plugins/grafana-slo-app/slo-breached', - title: 'Open incident', - description: 'You can create an incident from this context', - handler: jest.fn(), - }; - }); - - 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 = {}; - - configure(context); - - // The error handler is wrapping (decorating) the configure function, so it can provide standard error messages - expect(configureErrorHandler).toBeCalledWith(expect.any(Function), 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: [ - { - ...commandConfig1, - configure: () => ({}), - }, - ], - }, - ]); - - const extensions = registry[commandConfig1.placement]; - const [configure] = extensions; - const context = {}; - const extension = configure(context); - - assertPluginExtensionCommand(extension); - - extension.callHandlerWithContext(); - - expect(commandErrorHandler).toBeCalledTimes(1); - expect(commandErrorHandler).toBeCalledWith(expect.any(Function), context); - expect(commandConfig1.handler).toBeCalledTimes(1); - }); - - it('should wrap handler function with extension error handling when no configure function is added', () => { - const registry = createPluginExtensionRegistry([ - { - pluginId, - linkExtensions: [], - commandExtensions: [commandConfig1], - }, - ]); - - const extensions = registry[commandConfig1.placement]; - const [configure] = extensions; - const context = {}; - const extension = configure(context); - - assertPluginExtensionCommand(extension); - - extension.callHandlerWithContext(); - - expect(commandErrorHandler).toBeCalledTimes(1); - expect(commandErrorHandler).toBeCalledWith(expect.any(Function), context); - expect(commandConfig1.handler).toBeCalledTimes(1); - }); - - it('should call the `handler()` function with the context and a `helpers` object', () => { - const registry = createPluginExtensionRegistry([ - { - pluginId, - linkExtensions: [], - commandExtensions: [commandConfig1, { ...commandConfig2, configure: () => ({}) }], - }, - ]); - - const context = {}; - const command1 = registry[commandConfig1.placement][0](context); - const command2 = registry[commandConfig2.placement][0](context); - - assertPluginExtensionCommand(command1); - assertPluginExtensionCommand(command2); - - command1.callHandlerWithContext(); - command2.callHandlerWithContext(); - - expect(commandConfig1.handler).toBeCalledTimes(1); - expect(commandConfig1.handler).toBeCalledWith(context, { - openModal: expect.any(Function), - }); - - expect(commandConfig2.handler).toBeCalledTimes(1); - expect(commandConfig2.handler).toBeCalledWith(context, { - openModal: expect.any(Function), - }); - }); - }); -}); - -// 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 deleted file mode 100644 index 916206bb214..00000000000 --- a/public/app/features/plugins/extensions/registryFactory.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { - type AppPluginExtensionCommand, - type AppPluginExtensionCommandConfig, - type AppPluginExtensionCommandHelpers, - type AppPluginExtensionLink, - type AppPluginExtensionLinkConfig, - type PluginExtension, - type PluginExtensionCommand, - type PluginExtensionLink, - PluginExtensionTypes, -} from '@grafana/data'; -import type { PluginExtensionRegistry, PluginExtensionRegistryItem } from '@grafana/runtime'; -import appEvents from 'app/core/app_events'; -import { ShowModalReactEvent } from 'app/types/events'; - -import type { PluginPreloadResult } from '../pluginPreloader'; - -import { handleErrorsInHandler, handleErrorsInConfigure } from './errorHandling'; -import { getModalWrapper } from './getModalWrapper'; -import { PlacementsPerPlugin } from './placementsPerPlugin'; -import { CommandHandlerFunc, ConfigureFunc } from './types'; -import { createLinkValidator, isValidLinkPath } from './validateLink'; - -export function createPluginExtensionRegistry(preloadResults: PluginPreloadResult[]): PluginExtensionRegistry { - const registry: PluginExtensionRegistry = {}; - - for (const result of preloadResults) { - const { pluginId, linkExtensions, commandExtensions, error } = result; - - if (error) { - continue; - } - - const placementsPerPlugin = new PlacementsPerPlugin(); - const configs = [...linkExtensions, ...commandExtensions]; - - for (const config of configs) { - const placement = config.placement; - const item = createRegistryItem(pluginId, config); - - if (!item || !placementsPerPlugin.allowedToAdd(placement)) { - continue; - } - - if (!Array.isArray(registry[placement])) { - registry[placement] = [item]; - continue; - } - - registry[placement].push(item); - } - } - - for (const item of Object.keys(registry)) { - Object.freeze(registry[item]); - } - - return Object.freeze(registry); -} - -function createRegistryItem( - pluginId: string, - 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 helpers = getCommandHelpers(); - - const options = { - pluginId: pluginId, - title: config.title, - logger: console.warn, - }; - - const handlerWithHelpers: CommandHandlerFunc = (context) => config.handler(context, helpers); - const catchErrorsInHandler = handleErrorsInHandler(options); - const handler = catchErrorsInHandler(handlerWithHelpers); - - const extensionFactory = createCommandFactory(pluginId, config, handler); - const mapper = mapToConfigure(extensionFactory); - const catchErrorsInConfigure = handleErrorsInConfigure(options); - - return mapper(catchErrorsInConfigure(configure)); -} - -function createLinkRegistryItem( - pluginId: string, - config: AppPluginExtensionLinkConfig -): PluginExtensionRegistryItem | undefined { - if (!isValidLinkPath(pluginId, config.path)) { - return undefined; - } - - const configure = config.configure ?? defaultConfigure; - const options = { pluginId: pluginId, title: config.title, logger: console.warn }; - - const extensionFactory = createLinkFactory(pluginId, config); - const mapper = mapToConfigure(extensionFactory); - const withConfigureErrorHandling = handleErrorsInConfigure(options); - const validateLink = createLinkValidator(options); - - return mapper(validateLink(withConfigureErrorHandling(configure))); -} - -function createLinkFactory(pluginId: string, config: AppPluginExtensionLinkConfig) { - return (override: Partial): 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}`), - }); - }; -} - -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), - }); - }; -} - -function mapToConfigure( - extensionFactory: (override: Partial, context?: object) => T | undefined -): (configure: ConfigureFunc) => PluginExtensionRegistryItem { - return (configure) => { - return function mapToExtension(context?: object): T | undefined { - const override = configure(context); - if (!override) { - return undefined; - } - return extensionFactory(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 {}; -} - -function getCommandHelpers() { - const openModal: AppPluginExtensionCommandHelpers['openModal'] = ({ title, body }) => { - appEvents.publish(new ShowModalReactEvent({ component: getModalWrapper({ title, body }) })); - }; - - return { openModal }; -} diff --git a/public/app/features/plugins/extensions/types.ts b/public/app/features/plugins/extensions/types.ts index 03bcceae68b..0abdc87689c 100644 --- a/public/app/features/plugins/extensions/types.ts +++ b/public/app/features/plugins/extensions/types.ts @@ -1,4 +1,12 @@ -import type { AppPluginExtensionCommandConfig } from '@grafana/data'; +import { type PluginExtensionLinkConfig } from '@grafana/data'; -export type CommandHandlerFunc = AppPluginExtensionCommandConfig['handler']; -export type ConfigureFunc = (context?: object) => Partial | undefined; +// The information that is stored in the registry +export type PluginExtensionRegistryItem = { + // Any additional meta information that we would like to store about the extension in the registry + pluginId: string; + + config: PluginExtensionLinkConfig; +}; + +// A map of placement names to a list of extensions +export type PluginExtensionRegistry = Record; diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx new file mode 100644 index 00000000000..f9534f6d65f --- /dev/null +++ b/public/app/features/plugins/extensions/utils.test.tsx @@ -0,0 +1,220 @@ +import { PluginExtensionLinkConfig } from '@grafana/data'; + +import { deepFreeze, isPluginExtensionLinkConfig, handleErrorsInFn } from './utils'; + +describe('Plugin Extensions / Utils', () => { + describe('deepFreeze()', () => { + test('should not fail when called with primitive values', () => { + // Although the type system doesn't allow to call it with primitive values, it can happen that the plugin just ignores these errors. + // In these cases, we would like to make sure that the function doesn't fail. + + // @ts-ignore + expect(deepFreeze(1)).toBe(1); + // @ts-ignore + expect(deepFreeze('foo')).toBe('foo'); + // @ts-ignore + expect(deepFreeze(true)).toBe(true); + // @ts-ignore + expect(deepFreeze(false)).toBe(false); + // @ts-ignore + expect(deepFreeze(undefined)).toBe(undefined); + // @ts-ignore + expect(deepFreeze(null)).toBe(null); + }); + + test('should freeze an object so it cannot be overriden', () => { + const obj = { + a: 1, + b: '2', + c: true, + }; + const frozen = deepFreeze(obj); + + expect(Object.isFrozen(frozen)).toBe(true); + expect(() => { + frozen.a = 234; + }).toThrow(TypeError); + }); + + test('should freeze the primitive properties of an object', () => { + const obj = { + a: 1, + b: '2', + c: true, + }; + const frozen = deepFreeze(obj); + + expect(Object.isFrozen(frozen)).toBe(true); + expect(() => { + frozen.a = 2; + frozen.b = '3'; + frozen.c = false; + }).toThrow(TypeError); + }); + + test('should return the same object (but frozen)', () => { + const obj = { + a: 1, + b: '2', + c: true, + d: { + e: { + f: 'foo', + }, + }, + }; + const frozen = deepFreeze(obj); + + expect(Object.isFrozen(frozen)).toBe(true); + expect(frozen).toEqual(obj); + }); + + test('should freeze the nested object properties', () => { + const obj = { + a: 1, + b: { + c: { + d: 2, + e: { + f: 3, + }, + }, + }, + }; + const frozen = deepFreeze(obj); + + // Check if the object is frozen + expect(Object.isFrozen(frozen)).toBe(true); + + // Trying to override a primitive property -> should fail + expect(() => { + frozen.a = 2; + }).toThrow(TypeError); + + // Trying to override an underlying object -> should fail + expect(Object.isFrozen(frozen.b)).toBe(true); + expect(() => { + // @ts-ignore + frozen.b = {}; + }).toThrow(TypeError); + + // Trying to override deeply nested properties -> should fail + expect(() => { + frozen.b.c.e.f = 12345; + }).toThrow(TypeError); + }); + + test('should not mutate the original object', () => { + const obj = { + a: 1, + b: { + c: { + d: 2, + e: { + f: 3, + }, + }, + }, + }; + deepFreeze(obj); + + // We should still be able to override the original object's properties + expect(Object.isFrozen(obj)).toBe(false); + expect(() => { + obj.b.c.d = 12345; + expect(obj.b.c.d).toBe(12345); + }).not.toThrow(); + }); + + test('should work with nested arrays as well', () => { + const obj = { + a: 1, + b: { + c: { + d: [{ e: { f: 1 } }], + }, + }, + }; + const frozen = deepFreeze(obj); + + // Should be still possible to override the original object + expect(() => { + obj.b.c.d[0].e.f = 12345; + expect(obj.b.c.d[0].e.f).toBe(12345); + }).not.toThrow(); + + // Trying to override the frozen object throws a TypeError + expect(() => { + frozen.b.c.d[0].e.f = 6789; + }).toThrow(); + + // The original object should not be mutated + expect(obj.b.c.d[0].e.f).toBe(12345); + + expect(frozen.b.c.d).toHaveLength(1); + expect(frozen.b.c.d[0].e.f).toBe(1); + }); + + test('should not blow up when called with an object that contains cycles', () => { + const obj = { + a: 1, + b: { + c: 123, + }, + }; + // @ts-ignore + obj.b.d = obj; + let frozen: typeof obj; + + // Check if it does not throw due to the cycle in the object + expect(() => { + frozen = deepFreeze(obj); + }).not.toThrow(); + + // Check if it did freeze the object + // @ts-ignore + expect(Object.isFrozen(frozen)).toBe(true); + // @ts-ignore + expect(Object.isFrozen(frozen.b)).toBe(true); + // @ts-ignore + expect(Object.isFrozen(frozen.b.d)).toBe(true); + }); + }); + + describe('isPluginExtensionLinkConfig()', () => { + test('should return TRUE if the object is a command extension config', () => { + expect( + isPluginExtensionLinkConfig({ + title: 'Title', + description: 'Description', + path: '...', + } as PluginExtensionLinkConfig) + ).toBe(true); + }); + test('should return FALSE if the object is NOT a link extension', () => { + expect( + isPluginExtensionLinkConfig({ + title: 'Title', + description: 'Description', + } as PluginExtensionLinkConfig) + ).toBe(false); + }); + }); + + describe('handleErrorsInFn()', () => { + test('should catch errors thrown by the provided function and print them as console warnings', () => { + global.console.warn = jest.fn(); + + expect(() => { + const fn = handleErrorsInFn((foo: string) => { + throw new Error('Error: ' + foo); + }); + + fn('TEST'); + + // Logs the errors + expect(console.warn).toHaveBeenCalledWith('Error: TEST'); + }).not.toThrow(); + }); + }); +}); diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx new file mode 100644 index 00000000000..da1ab7e1783 --- /dev/null +++ b/public/app/features/plugins/extensions/utils.tsx @@ -0,0 +1,111 @@ +import React from 'react'; + +import { + type PluginExtensionLinkConfig, + type PluginExtensionConfig, + type PluginExtensionEventHelpers, +} from '@grafana/data'; +import { Modal } from '@grafana/ui'; +import appEvents from 'app/core/app_events'; +import { ShowModalReactEvent } from 'app/types/events'; + +export function logWarning(message: string) { + console.warn(`[Plugin Extensions] ${message}`); +} + +export function isPluginExtensionLinkConfig( + extension: PluginExtensionConfig | undefined +): extension is PluginExtensionLinkConfig { + return typeof extension === 'object' && 'path' in extension; +} + +export function handleErrorsInFn(fn: Function, errorMessagePrefix = '') { + return (...args: unknown[]) => { + try { + return fn(...args); + } catch (e) { + if (e instanceof Error) { + console.warn(`${errorMessagePrefix}${e.message}`); + } + } + }; +} + +// 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 { + const openModal: PluginExtensionEventHelpers['openModal'] = ({ title, body }) => { + appEvents.publish(new ShowModalReactEvent({ component: getModalWrapper({ title, body }) })); + }; + + return { openModal }; +} + +export type ModalWrapperProps = { + onDismiss: () => void; +}; + +// Wraps a component with a modal. +// This way we can make sure that the modal is closable, and we also make the usage simpler. +export const getModalWrapper = ({ + // The title of the modal (appears in the header) + title, + // A component that serves the body of the modal + body: Body, +}: Parameters[0]) => { + const ModalWrapper = ({ onDismiss }: ModalWrapperProps) => { + return ( + + + + ); + }; + + return ModalWrapper; +}; + +// Deep-clones and deep-freezes an object. +// (Returns with a new object, does not modify the original object) +// +// @param `object` The object to freeze +// @param `frozenProps` A set of objects that have already been frozen (used to prevent infinite recursion) +export function deepFreeze(value?: object | Record | unknown[], frozenProps = new Map()) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return value; + } + + // Deep cloning the object to prevent freezing the original object + const clonedValue = Array.isArray(value) ? [...value] : { ...value }; + + // Prevent infinite recursion by looking for cycles inside an object + if (frozenProps.has(value)) { + return frozenProps.get(value); + } + frozenProps.set(value, clonedValue); + + const propNames = Reflect.ownKeys(clonedValue); + + for (const name of propNames) { + const prop = Array.isArray(clonedValue) ? clonedValue[Number(name)] : clonedValue[name]; + + // If the property is an object: + // 1. clone it + // 2. freeze it + if (prop && (typeof prop === 'object' || typeof prop === 'function')) { + if (Array.isArray(clonedValue)) { + clonedValue[Number(name)] = deepFreeze(prop, frozenProps); + } else { + clonedValue[name] = deepFreeze(prop, frozenProps); + } + } + } + + return Object.freeze(clonedValue); +} + +export function generateExtensionId(pluginId: string, extensionConfig: PluginExtensionConfig): string { + const str = `${pluginId}${extensionConfig.placement}${extensionConfig.title}`; + + return Array.from(str) + .reduce((s, c) => (Math.imul(31, s) + c.charCodeAt(0)) | 0, 0) + .toString(); +} diff --git a/public/app/features/plugins/extensions/validateLink.test.ts b/public/app/features/plugins/extensions/validateLink.test.ts deleted file mode 100644 index 9187b54a971..00000000000 --- a/public/app/features/plugins/extensions/validateLink.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { createLinkValidator } from './validateLink'; - -describe('extension link validator', () => { - const pluginId = 'grafana-basic-app'; - const validator = createLinkValidator({ - pluginId, - title: 'Link to something', - logger: jest.fn(), - }); - - const context = {}; - - it('should return link configuration if path is valid', () => { - const configureWithValidation = validator(() => { - return { - path: `/a/${pluginId}/other`, - }; - }); - - const configured = configureWithValidation(context); - expect(configured).toEqual({ - path: `/a/${pluginId}/other`, - }); - }); - - it('should return link configuration if path is not specified', () => { - const configureWithValidation = validator(() => { - return { - title: 'Go to page two', - }; - }); - - const configured = configureWithValidation(context); - expect(configured).toEqual({ title: 'Go to page two' }); - }); - - it('should return undefined if path is invalid', () => { - const configureWithValidation = validator(() => { - return { - path: `/other`, - }; - }); - - const configured = configureWithValidation(context); - expect(configured).toBeUndefined(); - }); - - it('should return undefined if undefined is returned from inner configure', () => { - const configureWithValidation = validator(() => { - return undefined; - }); - - const configured = configureWithValidation(context); - expect(configured).toBeUndefined(); - }); -}); diff --git a/public/app/features/plugins/extensions/validateLink.ts b/public/app/features/plugins/extensions/validateLink.ts deleted file mode 100644 index ef7626980a9..00000000000 --- a/public/app/features/plugins/extensions/validateLink.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { isString } from 'lodash'; - -import type { AppPluginExtensionLink } from '@grafana/data'; - -import type { ConfigureFunc } from './types'; - -type Options = { - pluginId: string; - title: string; - logger: (msg: string, error?: unknown) => void; -}; - -export function createLinkValidator(options: Options) { - const { pluginId, title, logger } = options; - - return (configure: ConfigureFunc): ConfigureFunc => { - return function validateLink(context) { - const configured = configure(context); - - if (!isString(configured?.path)) { - return configured; - } - - if (!isValidLinkPath(pluginId, configured?.path)) { - logger( - `[Plugins] Disabled extension '${title}' for '${pluginId}' beause configure didn't return a path with the correct prefix: '${`/a/${pluginId}/`}'` - ); - return undefined; - } - - return configured; - }; - }; -} - -export function isValidLinkPath(pluginId: string, path?: string): boolean { - return path?.startsWith(`/a/${pluginId}/`) === true; -} diff --git a/public/app/features/plugins/extensions/validators.test.ts b/public/app/features/plugins/extensions/validators.test.ts new file mode 100644 index 00000000000..e7398aefa40 --- /dev/null +++ b/public/app/features/plugins/extensions/validators.test.ts @@ -0,0 +1,252 @@ +import { PluginExtension, PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data'; + +import { + assertConfigureIsValid, + assertLinkPathIsValid, + assertPlacementIsValid, + assertPluginExtensionLink, + assertStringProps, + isPluginExtensionConfigValid, +} from './validators'; + +describe('Plugin Extension Validators', () => { + describe('assertPluginExtensionLink()', () => { + it('should NOT throw an error if it is a link extension', () => { + expect(() => { + assertPluginExtensionLink({ + id: 'id', + pluginId: 'myorg-b-app', + type: PluginExtensionTypes.link, + title: 'Title', + description: 'Description', + path: '...', + } as PluginExtension); + }).not.toThrowError(); + }); + + it('should throw an error if it is not a link extension', () => { + expect(() => { + assertPluginExtensionLink({ + type: PluginExtensionTypes.link, + title: 'Title', + description: 'Description', + } as PluginExtension); + }).toThrowError(); + }); + }); + + describe('assertLinkPathIsValid()', () => { + it('should not throw an error if the link path is valid', () => { + expect(() => { + const pluginId = 'myorg-b-app'; + const extension = { + path: `/a/${pluginId}/overview`, + title: 'My Plugin', + description: 'My Plugin Description', + placement: '...', + }; + + assertLinkPathIsValid(pluginId, extension.path); + }).not.toThrowError(); + }); + + it('should throw an error if the link path is pointing to a different plugin', () => { + expect(() => { + const extension = { + path: `/a/myorg-b-app/overview`, + title: 'My Plugin', + description: 'My Plugin Description', + placement: '...', + }; + + assertLinkPathIsValid('another-plugin-app', extension.path); + }).toThrowError(); + }); + + it('should throw an error if the link path is not prefixed with "/a/"', () => { + expect(() => { + const extension = { + path: `/some-bad-path`, + title: 'My Plugin', + description: 'My Plugin Description', + placement: '...', + }; + + assertLinkPathIsValid('myorg-b-app', extension.path); + }).toThrowError(); + }); + }); + + describe('assertPlacementIsValid()', () => { + it('should throw an error if the placement does not have the right prefix', () => { + expect(() => { + assertPlacementIsValid({ + title: 'Title', + description: 'Description', + path: '...', + placement: 'some-bad-placement', + }); + }).toThrowError(); + }); + + it('should NOT throw an error if the placement is correct', () => { + expect(() => { + assertPlacementIsValid({ + title: 'Title', + description: 'Description', + path: '...', + placement: 'grafana/some-page/some-placement', + }); + + assertPlacementIsValid({ + title: 'Title', + description: 'Description', + path: '...', + placement: 'plugins/my-super-plugin/some-page/some-placement', + }); + }).not.toThrowError(); + }); + }); + + describe('assertConfigureIsValid()', () => { + it('should NOT throw an error if the configure() function is missing', () => { + expect(() => { + assertConfigureIsValid({ + title: 'Title', + description: 'Description', + placement: 'grafana/some-page/some-placement', + } as PluginExtensionLinkConfig); + }).not.toThrowError(); + }); + + it('should NOT throw an error if the configure() function is a valid function', () => { + expect(() => { + assertConfigureIsValid({ + title: 'Title', + description: 'Description', + placement: 'grafana/some-page/some-placement', + configure: () => {}, + } as PluginExtensionLinkConfig); + }).not.toThrowError(); + }); + + it('should throw an error if the configure() function is defined but is not a function', () => { + expect(() => { + assertConfigureIsValid( + // @ts-ignore + { + title: 'Title', + description: 'Description', + placement: 'grafana/some-page/some-placement', + handler: () => {}, + configure: '() => {}', + } as PluginExtensionLinkConfig + ); + }).toThrowError(); + }); + }); + + describe('assertStringProps()', () => { + it('should throw an error if any of the expected string properties is missing', () => { + expect(() => { + assertStringProps( + { + description: 'Description', + placement: 'grafana/some-page/some-placement', + }, + ['title', 'description', 'placement'] + ); + }).toThrowError(); + }); + + it('should throw an error if any of the expected string properties is an empty string', () => { + expect(() => { + assertStringProps( + { + title: '', + description: 'Description', + placement: 'grafana/some-page/some-placement', + }, + ['title', 'description', 'placement'] + ); + }).toThrowError(); + }); + + it('should NOT throw an error if the expected string props are present and not empty', () => { + expect(() => { + assertStringProps( + { + title: 'Title', + description: 'Description', + placement: 'grafana/some-page/some-placement', + }, + ['title', 'description', 'placement'] + ); + }).not.toThrowError(); + }); + + it('should NOT throw an error if there are other existing and empty string properties, that we did not specify', () => { + expect(() => { + assertStringProps( + { + title: 'Title', + description: 'Description', + placement: 'grafana/some-page/some-placement', + dontCare: '', + }, + ['title', 'description', 'placement'] + ); + }).not.toThrowError(); + }); + }); + + describe('isPluginExtensionConfigValid()', () => { + it('should return TRUE if the plugin extension configuration is valid', () => { + const pluginId = 'my-super-plugin'; + // Command + expect( + isPluginExtensionConfigValid(pluginId, { + title: 'Title', + description: 'Description', + placement: 'grafana/some-page/some-placement', + } as PluginExtensionLinkConfig) + ).toBe(true); + + // Link + expect( + isPluginExtensionConfigValid(pluginId, { + title: 'Title', + description: 'Description', + placement: 'grafana/some-page/some-placement', + path: `/a/${pluginId}/page`, + } as PluginExtensionLinkConfig) + ).toBe(true); + }); + + it('should return FALSE if the plugin extension configuration is invalid', () => { + const pluginId = 'my-super-plugin'; + + global.console.warn = jest.fn(); + + // Link (wrong path) + expect( + isPluginExtensionConfigValid(pluginId, { + title: 'Title', + description: 'Description', + placement: 'grafana/some-page/some-placement', + path: '/administration/users', + } as PluginExtensionLinkConfig) + ).toBe(false); + + // Link (missing title) + expect( + isPluginExtensionConfigValid(pluginId, { + title: '', + description: 'Description', + placement: 'grafana/some-page/some-placement', + path: `/a/${pluginId}/page`, + } as PluginExtensionLinkConfig) + ).toBe(false); + }); + }); +}); diff --git a/public/app/features/plugins/extensions/validators.ts b/public/app/features/plugins/extensions/validators.ts new file mode 100644 index 00000000000..90886c32dc8 --- /dev/null +++ b/public/app/features/plugins/extensions/validators.ts @@ -0,0 +1,102 @@ +import type { PluginExtension, PluginExtensionLink, PluginExtensionLinkConfig } from '@grafana/data'; +import { isPluginExtensionLink } from '@grafana/runtime'; + +import { isPluginExtensionLinkConfig, logWarning } from './utils'; + +export function assertPluginExtensionLink( + extension: PluginExtension | undefined, + errorMessage = 'extension is not a link extension' +): asserts extension is PluginExtensionLink { + if (!isPluginExtensionLink(extension)) { + throw new Error(errorMessage); + } +} + +export function assertPluginExtensionLinkConfig( + extension: PluginExtensionLinkConfig, + errorMessage = 'extension is not a command extension config' +): asserts extension is PluginExtensionLinkConfig { + if (!isPluginExtensionLinkConfig(extension)) { + throw new Error(errorMessage); + } +} + +export function assertLinkPathIsValid(pluginId: string, path: string) { + if (!isLinkPathValid(pluginId, path)) { + throw new Error( + `Invalid link extension. The "path" is required and should start with "/a/${pluginId}/" (currently: "${path}"). Skipping the extension.` + ); + } +} + +export function assertPlacementIsValid(extension: PluginExtensionLinkConfig) { + if (!isPlacementValid(extension)) { + throw new Error( + `Invalid extension "${extension.title}". The placement should start with either "grafana/" or "plugins/" (currently: "${extension.placement}"). Skipping the extension.` + ); + } +} + +export function assertConfigureIsValid(extension: PluginExtensionLinkConfig) { + if (!isConfigureFnValid(extension)) { + throw new Error( + `Invalid extension "${extension.title}". The "configure" property must be a function. Skipping the extension.` + ); + } +} + +export function assertStringProps(extension: Record, props: string[]) { + for (const prop of props) { + if (!isStringPropValid(extension[prop])) { + throw new Error( + `Invalid extension "${extension.title}". Property "${prop}" must be a string and cannot be empty. Skipping the extension.` + ); + } + } +} + +export function assertIsNotPromise(value: unknown, errorMessage = 'The provided value is a Promise.'): void { + if (isPromise(value)) { + throw new Error(errorMessage); + } +} + +export function isLinkPathValid(pluginId: string, path: string) { + return Boolean(typeof path === 'string' && path.length > 0 && path.startsWith(`/a/${pluginId}/`)); +} + +export function isPlacementValid(extension: PluginExtensionLinkConfig) { + return Boolean(extension.placement?.startsWith('grafana/') || extension.placement?.startsWith('plugins/')); +} + +export function isConfigureFnValid(extension: PluginExtensionLinkConfig) { + return extension.configure ? typeof extension.configure === 'function' : true; +} + +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']); + assertPlacementIsValid(extension); + assertConfigureIsValid(extension); + + if (isPluginExtensionLinkConfig(extension)) { + assertLinkPathIsValid(pluginId, extension.path); + } + + return true; + } catch (error) { + if (error instanceof Error) { + logWarning(error.message); + } + + return false; + } +} diff --git a/public/app/features/plugins/pluginPreloader.ts b/public/app/features/plugins/pluginPreloader.ts index dbed70d0316..ce87dc5b377 100644 --- a/public/app/features/plugins/pluginPreloader.ts +++ b/public/app/features/plugins/pluginPreloader.ts @@ -1,13 +1,12 @@ -import type { AppPluginExtensionCommandConfig, AppPluginExtensionLinkConfig } from '@grafana/data'; +import type { PluginExtensionLinkConfig } from '@grafana/data'; import type { AppPluginConfig } from '@grafana/runtime'; import * as pluginLoader from './plugin_loader'; export type PluginPreloadResult = { pluginId: string; - linkExtensions: AppPluginExtensionLinkConfig[]; - commandExtensions: AppPluginExtensionCommandConfig[]; error?: unknown; + extensionConfigs: PluginExtensionLinkConfig[]; }; export async function preloadPlugins(apps: Record = {}): Promise { @@ -19,10 +18,10 @@ async function preload(config: AppPluginConfig): Promise { const { path, version, id: pluginId } = config; try { const { plugin } = await pluginLoader.importPluginModule(path, version); - const { linkExtensions = [], commandExtensions = [] } = plugin; - return { pluginId, linkExtensions, commandExtensions }; + const { extensionConfigs = [] } = plugin; + return { pluginId, extensionConfigs }; } catch (error) { console.error(`[Plugins] Failed to preload plugin: ${path} (version: ${version})`, error); - return { pluginId, linkExtensions: [], commandExtensions: [], error }; + return { pluginId, extensionConfigs: [], error }; } } diff --git a/public/app/features/sandbox/TestStuffPage.tsx b/public/app/features/sandbox/TestStuffPage.tsx index 5b984fc6ea4..64862575cba 100644 --- a/public/app/features/sandbox/TestStuffPage.tsx +++ b/public/app/features/sandbox/TestStuffPage.tsx @@ -2,15 +2,8 @@ import React, { useMemo, useState } from 'react'; import { useObservable } from 'react-use'; import AutoSizer from 'react-virtualized-auto-sizer'; -import { - ApplyFieldOverrideOptions, - dateMath, - FieldColorModeId, - isPluginExtensionLink, - NavModelItem, - PanelData, -} from '@grafana/data'; -import { getPluginExtensions } from '@grafana/runtime'; +import { ApplyFieldOverrideOptions, dateMath, FieldColorModeId, NavModelItem, PanelData } from '@grafana/data'; +import { getPluginExtensions, isPluginExtensionLink } from '@grafana/runtime'; import { DataTransformerConfig } from '@grafana/schema'; import { Button, HorizontalGroup, LinkButton, Table } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; @@ -164,12 +157,12 @@ function LinkToBasicApp({ placement }: { placement: string }) { return (
- {extensions.map((extension) => { + {extensions.map((extension, i) => { if (!isPluginExtensionLink(extension)) { return null; } return ( - + {extension.title} );