mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
UI extensions: Refactor the registry and remove the "command"
type (#65327)
* Wip * Wip * Wip * Wip * Wip
This commit is contained in:
parent
bde77e4f79
commit
34f3878d26
@ -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"]
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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';
|
||||
|
@ -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>>>;
|
||||
}>;
|
@ -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,
|
||||
});
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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);
|
@ -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;
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
@ -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();
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
1
public/app/features/plugins/extensions/constants.ts
Normal file
1
public/app/features/plugins/extensions/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const MAX_EXTENSIONS_PER_PLACEMENT_PER_PLUGIN = 2;
|
@ -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),
|
||||
},
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
@ -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;
|
||||
};
|
@ -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
|
||||
});
|
||||
});
|
106
public/app/features/plugins/extensions/getPluginExtensions.ts
Normal file
106
public/app/features/plugins/extensions/getPluginExtensions.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
@ -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 };
|
||||
}
|
@ -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[]>;
|
||||
|
220
public/app/features/plugins/extensions/utils.test.tsx
Normal file
220
public/app/features/plugins/extensions/utils.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
111
public/app/features/plugins/extensions/utils.tsx
Normal file
111
public/app/features/plugins/extensions/utils.tsx
Normal 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();
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
252
public/app/features/plugins/extensions/validators.test.ts
Normal file
252
public/app/features/plugins/extensions/validators.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
102
public/app/features/plugins/extensions/validators.ts
Normal file
102
public/app/features/plugins/extensions/validators.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
|
Loading…
Reference in New Issue
Block a user