UI extensions: Refactor the registry and remove the "command" type (#65327)

* Wip

* Wip

* Wip

* Wip

* Wip
This commit is contained in:
Levente Balogh 2023-04-03 10:42:15 +02:00 committed by GitHub
parent bde77e4f79
commit 34f3878d26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1475 additions and 1547 deletions

View File

@ -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"]

View File

@ -3,7 +3,7 @@ import { ComponentType } from 'react';
import { KeyValue } from './data';
import { NavModel } from './navModel';
import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin';
import { extensionLinkConfigIsValid, type PluginExtensionCommand, type PluginExtensionLink } from './pluginExtensions';
import type { PluginExtensionLinkConfig } from './pluginExtensions';
/**
* @public
@ -50,44 +50,8 @@ export interface AppPluginMeta<T extends KeyValue = KeyValue> 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<PluginExtensionLink, 'description' | 'path' | 'title'>;
// 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<PluginExtensionCommand, 'description' | 'title'>;
export type AppPluginExtensionLinkConfig<C extends object = object> = {
title: string;
description: string;
placement: string;
path: string;
configure?: (context?: C) => Partial<AppPluginExtensionLink> | undefined;
};
export type AppPluginExtensionCommandConfig<C extends object = object> = {
title: string;
description: string;
placement: string;
handler: (context?: C, helpers?: AppPluginExtensionCommandHelpers) => void;
configure?: (context?: C) => Partial<AppPluginExtensionCommand> | undefined;
};
export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppPluginMeta<T>> {
private linkExtensions: AppPluginExtensionLinkConfig[] = [];
private commandExtensions: AppPluginExtensionCommandConfig[] = [];
private _extensionConfigs: PluginExtensionLinkConfig[] = [];
// Content under: /a/${plugin-id}/*
root?: ComponentType<AppRootProps<T>>;
@ -129,28 +93,13 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
}
}
get extensionLinks(): AppPluginExtensionLinkConfig[] {
return this.linkExtensions;
get extensionConfigs() {
return this._extensionConfigs;
}
get extensionCommands(): AppPluginExtensionCommandConfig[] {
return this.commandExtensions;
}
configureExtensionLink<Context extends object>(extension: PluginExtensionLinkConfig<Context>) {
this._extensionConfigs.push(extension as PluginExtensionLinkConfig);
configureExtensionLink<C extends object>(config: AppPluginExtensionLinkConfig<C>) {
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;
}
configureExtensionCommand<C extends object>(config: AppPluginExtensionCommandConfig<C>) {
this.commandExtensions.push(config as AppPluginExtensionCommandConfig);
return this;
}
}

View File

@ -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';

View File

@ -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<Context extends object = object, ExtraProps extends object = object> = 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<Context>
) => Partial<{ title: string; description: string } & ExtraProps> | undefined;
};
export type PluginExtensionLinkConfig<Context extends object = object> = PluginExtensionConfig<
Context,
Pick<PluginExtensionLink, 'path'>
>;
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
// --------------------------------------------------------
// Placements available in core Grafana
export enum PluginExtensionPlacements {
DashboardPanelMenu = 'grafana/dashboard/panel/menu',
}
export function assertPluginExtensionLink(
extension: PluginExtension | undefined
): asserts extension is PluginExtensionLink {
if (!isPluginExtensionLink(extension)) {
throw new Error(`extension is not a link extension`);
}
}
export type PluginExtensionPanelContext = {
pluginId: string;
id: number;
title: string;
timeRange: RawTimeRange;
timeZone: TimeZone;
dashboard: Dashboard;
targets: Target[];
};
export function isPluginExtensionCommand(extension: PluginExtension | undefined): extension is PluginExtensionCommand {
if (!extension) {
return false;
}
return extension.type === PluginExtensionTypes.command;
}
type Dashboard = {
uid: string;
title: string;
tags: string[];
};
export function assertPluginExtensionCommand(
extension: PluginExtension | undefined
): asserts extension is PluginExtensionCommand {
if (!isPluginExtensionCommand(extension)) {
throw new Error(`extension is not a command extension`);
}
}
export function extensionLinkConfigIsValid(props: {
path?: string;
description?: string;
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;
};

View File

@ -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';

View File

@ -1,22 +0,0 @@
import { RawTimeRange, TimeZone } from '@grafana/data';
type Dashboard = {
uid: string;
title: string;
tags: Readonly<Array<Readonly<string>>>;
};
type Target = {
pluginId: string;
refId: string;
};
export type PluginExtensionPanelContext = Readonly<{
pluginId: string;
id: number;
title: string;
timeRange: Readonly<RawTimeRange>;
timeZone: TimeZone;
dashboard: Readonly<Dashboard>;
targets: Readonly<Array<Readonly<Target>>>;
}>;

View File

@ -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<PluginExtensionLink, 'type'>
): PluginExtensionRegistryItem<PluginExtensionLink> {
return (context?: object) => ({
...link,
type: PluginExtensionTypes.link,
});
}

View File

@ -1,35 +0,0 @@
import { type PluginExtension } from '@grafana/data';
import { getPluginsExtensionRegistry } from './registry';
export type PluginExtensionsOptions<T extends object> = {
placement: string;
context?: T;
};
export type PluginExtensionsResult = {
extensions: PluginExtension[];
};
export function getPluginExtensions<T extends object = {}>(
options: PluginExtensionsOptions<T>
): PluginExtensionsResult {
const { placement, context } = options;
const registry = getPluginsExtensionRegistry();
const configureFuncs = registry[placement] ?? [];
const extensions = configureFuncs.reduce<PluginExtension[]>((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,
};
}

View File

@ -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();
});
});

View File

@ -0,0 +1,30 @@
import { PluginExtension } from '@grafana/data';
export type GetPluginExtensions = ({
placement,
context,
}: {
placement: string;
context?: object | Record<string | symbol, unknown>;
}) => {
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);

View File

@ -1,23 +0,0 @@
import { PluginExtension } from '@grafana/data';
export type PluginExtensionRegistryItem<T extends PluginExtension = PluginExtension, C extends object = object> = (
context?: C
) => T | undefined;
export type PluginExtensionRegistry = Record<string, PluginExtensionRegistryItem[]>;
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;
}

View File

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

View File

@ -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;
}

View File

@ -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();

View File

@ -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<PluginExtensionLink>({
(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<PluginExtensionLink>({
(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<PluginExtensionLink> = () => ({
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<PluginExtensionLink>(
{
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<PluginExtensionLink>(
{
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<PluginExtensionLink>(
{
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<PluginExtensionLink> = (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<PluginExtensionLink>(
{
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<T extends PluginExtension, C extends object = object>(
extension: T,
configure?: PluginExtensionRegistryItem<T, C>
): PluginExtensionRegistryItem<T, C> {
const defaultConfigure = () => extension;
return configure || defaultConfigure;
}

View File

@ -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<string>(dashboard.tags)),
}),
targets: Object.freeze(
panel.targets.map((t) =>
Object.freeze({
refId: t.refId,
pluginId: t.datasource?.type ?? 'unknown',
})
)
),
});
tags: Array.from<string>(dashboard.tags),
},
targets: panel.targets.map((t) => ({
refId: t.refId,
pluginId: t.datasource?.type ?? 'unknown',
})),
};
}

View File

@ -0,0 +1 @@
export const MAX_EXTENSIONS_PER_PLACEMENT_PER_PLUGIN = 2;

View File

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

View File

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

View File

@ -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<AppPluginExtensionLink>({
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<AppPluginExtensionLink>;
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<AppPluginExtensionLink>;
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<AppPluginExtensionLink>;
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();
});
});
});

View File

@ -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<T>(options: Options) {
const { pluginId, title, logger } = options;
return (configure: ConfigureFunc<T>): ConfigureFunc<T> => {
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;
}
};
};
}

View File

@ -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<AppPluginExtensionCommandHelpers['openModal']>[0]) => {
const ModalWrapper = ({ onDismiss }: ModalWrapperProps) => {
return (
<Modal title={title} isOpen onDismiss={onDismiss} onClickBackdrop={onDismiss}>
<Body onDismiss={onDismiss} />
</Modal>
);
};
return ModalWrapper;
};

View File

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

View File

@ -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<string | symbol, unknown>;
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;
}
}

View File

@ -1,15 +1,33 @@
import { PluginExtensionLinkConfig } from '@grafana/data';
import { MAX_EXTENSIONS_PER_PLACEMENT_PER_PLUGIN } from './constants';
export class PlacementsPerPlugin {
private counter: Record<string, number> = {};
private limit = 2;
private extensionsByPlacement: Record<string, string[]> = {};
allowedToAdd(placement: string): boolean {
const count = this.counter[placement] ?? 0;
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];
}
}

View File

@ -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<AppPluginExtensionLinkConfig | AppPluginExtensionCommandConfig>;
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,
};
})
);
}

View File

@ -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<PluginExtensionCommand> | 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<PluginExtensionCommand, AppPluginExtensionCommand>(extensionFactory);
const catchErrorsInConfigure = handleErrorsInConfigure<AppPluginExtensionCommand>(options);
return mapper(catchErrorsInConfigure(configure));
}
function createLinkRegistryItem(
pluginId: string,
config: AppPluginExtensionLinkConfig
): PluginExtensionRegistryItem<PluginExtensionLink> | 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<PluginExtensionLink, AppPluginExtensionLink>(extensionFactory);
const withConfigureErrorHandling = handleErrorsInConfigure<AppPluginExtensionLink>(options);
const validateLink = createLinkValidator(options);
return mapper(validateLink(withConfigureErrorHandling(configure)));
}
function createLinkFactory(pluginId: string, config: AppPluginExtensionLinkConfig) {
return (override: Partial<AppPluginExtensionLink>): 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<AppPluginExtensionCommand>, 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<T extends PluginExtension, C>(
extensionFactory: (override: Partial<C>, context?: object) => T | undefined
): (configure: ConfigureFunc<C>) => PluginExtensionRegistryItem<T> {
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 };
}

View File

@ -1,4 +1,12 @@
import type { AppPluginExtensionCommandConfig } from '@grafana/data';
import { type PluginExtensionLinkConfig } from '@grafana/data';
export type CommandHandlerFunc = AppPluginExtensionCommandConfig['handler'];
export type ConfigureFunc<T> = (context?: object) => Partial<T> | 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<string, PluginExtensionRegistryItem[]>;

View File

@ -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();
});
});
});

View File

@ -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<PluginExtensionEventHelpers['openModal']>[0]) => {
const ModalWrapper = ({ onDismiss }: ModalWrapperProps) => {
return (
<Modal title={title} isOpen onDismiss={onDismiss} onClickBackdrop={onDismiss}>
<Body onDismiss={onDismiss} />
</Modal>
);
};
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<string | symbol, unknown> | 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();
}

View File

@ -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();
});
});

View File

@ -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<AppPluginExtensionLink>): ConfigureFunc<AppPluginExtensionLink> => {
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;
}

View File

@ -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/<PLUGIN_ID>"', () => {
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);
});
});
});

View File

@ -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<string, unknown>, 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;
}
}

View File

@ -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<string, AppPluginConfig> = {}): Promise<PluginPreloadResult[]> {
@ -19,10 +18,10 @@ async function preload(config: AppPluginConfig): Promise<PluginPreloadResult> {
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 };
}
}

View File

@ -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 (
<div>
{extensions.map((extension) => {
{extensions.map((extension, i) => {
if (!isPluginExtensionLink(extension)) {
return null;
}
return (
<LinkButton href={extension.path} title={extension.description} key={extension.key}>
<LinkButton href={extension.path} title={extension.description} key={extension.id}>
{extension.title}
</LinkButton>
);