mirror of https://github.com/grafana/grafana
UI extensions: Refactor the registry and remove the `"command"` type (#65327)
* Wip * Wip * Wip * Wip * Wippull/65787/head
parent
bde77e4f79
commit
34f3878d26
@ -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; |
||||
} |
@ -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
|
||||
}); |
||||
}); |
@ -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 @@ |
||||
export class PlacementsPerPlugin { |
||||
private counter: Record<string, number> = {}; |
||||
private limit = 2; |
||||
import { PluginExtensionLinkConfig } from '@grafana/data'; |
||||
|
||||
import { MAX_EXTENSIONS_PER_PLACEMENT_PER_PLUGIN } from './constants'; |
||||
|
||||
allowedToAdd(placement: string): boolean { |
||||
const count = this.counter[placement] ?? 0; |
||||
export class PlacementsPerPlugin { |
||||
private extensionsByPlacement: Record<string, string[]> = {}; |
||||
|
||||
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[]>; |
||||
|
@ -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(); |
||||
}); |
||||
}); |
||||
}); |
@ -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; |
||||
} |
@ -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); |
||||
}); |
||||
}); |
||||
}); |
@ -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; |
||||
} |
||||
} |
Loading…
Reference in new issue