mirror of https://github.com/grafana/grafana
PluginExtensions: Added support for sharing functions (#98888)
* feat: add generic plugin extension functions * updated betterer. * Fixed type issues after sync with main. * Remved extensions from datasource and panel. * Added validation for extension function registry. * Added tests and validation logic for function extensions registry. * removed prop already existing on base. * fixed lint error. --------- Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>pull/100634/head
parent
fbf96916aa
commit
8a8e47fcea
@ -0,0 +1,20 @@ |
|||||||
|
import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from './getPluginExtensions'; |
||||||
|
|
||||||
|
export type UsePluginFunctions<T> = (options: UsePluginFunctionsOptions) => UsePluginFunctionsResult<T>; |
||||||
|
|
||||||
|
let singleton: UsePluginFunctions<unknown> | undefined; |
||||||
|
|
||||||
|
export function setPluginFunctionsHook(hook: UsePluginFunctions<unknown>): void { |
||||||
|
// We allow overriding the registry in tests
|
||||||
|
if (singleton && process.env.NODE_ENV !== 'test') { |
||||||
|
throw new Error('setUsePluginFunctionsHook() function should only be called once, when Grafana is starting.'); |
||||||
|
} |
||||||
|
singleton = hook; |
||||||
|
} |
||||||
|
|
||||||
|
export function usePluginFunctions<T>(options: UsePluginFunctionsOptions): UsePluginFunctionsResult<T> { |
||||||
|
if (!singleton) { |
||||||
|
throw new Error('usePluginFunctions(options) can only be used after the Grafana instance has started.'); |
||||||
|
} |
||||||
|
return singleton(options) as UsePluginFunctionsResult<T>; |
||||||
|
} |
||||||
@ -0,0 +1,677 @@ |
|||||||
|
import { firstValueFrom } from 'rxjs'; |
||||||
|
|
||||||
|
import { PluginLoadingStrategy } from '@grafana/data'; |
||||||
|
import { config } from '@grafana/runtime'; |
||||||
|
|
||||||
|
import { log } from '../logs/log'; |
||||||
|
import { resetLogMock } from '../logs/testUtils'; |
||||||
|
import { isGrafanaDevMode } from '../utils'; |
||||||
|
|
||||||
|
import { AddedFunctionsRegistry } from './AddedFunctionsRegistry'; |
||||||
|
import { MSG_CANNOT_REGISTER_READ_ONLY } from './Registry'; |
||||||
|
|
||||||
|
jest.mock('../utils', () => ({ |
||||||
|
...jest.requireActual('../utils'), |
||||||
|
|
||||||
|
// Manually set the dev mode to false
|
||||||
|
// (to make sure that by default we are testing a production scneario)
|
||||||
|
isGrafanaDevMode: jest.fn().mockReturnValue(false), |
||||||
|
})); |
||||||
|
|
||||||
|
jest.mock('../logs/log', () => { |
||||||
|
const { createLogMock } = jest.requireActual('../logs/testUtils'); |
||||||
|
const original = jest.requireActual('../logs/log'); |
||||||
|
|
||||||
|
return { |
||||||
|
...original, |
||||||
|
log: createLogMock(), |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
describe('addedFunctionsRegistry', () => { |
||||||
|
const originalApps = config.apps; |
||||||
|
const pluginId = 'grafana-basic-app'; |
||||||
|
const appPluginConfig = { |
||||||
|
id: pluginId, |
||||||
|
path: '', |
||||||
|
version: '', |
||||||
|
preload: false, |
||||||
|
angular: { |
||||||
|
detected: false, |
||||||
|
hideDeprecation: false, |
||||||
|
}, |
||||||
|
loadingStrategy: PluginLoadingStrategy.fetch, |
||||||
|
dependencies: { |
||||||
|
grafanaVersion: '8.0.0', |
||||||
|
plugins: [], |
||||||
|
extensions: { |
||||||
|
exposedComponents: [], |
||||||
|
}, |
||||||
|
}, |
||||||
|
extensions: { |
||||||
|
addedFunctions: [], |
||||||
|
addedLinks: [], |
||||||
|
addedComponents: [], |
||||||
|
exposedComponents: [], |
||||||
|
extensionPoints: [], |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
beforeEach(() => { |
||||||
|
resetLogMock(log); |
||||||
|
jest.mocked(isGrafanaDevMode).mockReturnValue(false); |
||||||
|
config.apps = { |
||||||
|
[pluginId]: appPluginConfig, |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
afterEach(() => { |
||||||
|
config.apps = originalApps; |
||||||
|
}); |
||||||
|
|
||||||
|
it('should return empty registry when no extensions registered', async () => { |
||||||
|
const addedFunctionsRegistry = new AddedFunctionsRegistry(); |
||||||
|
const observable = addedFunctionsRegistry.asObservable(); |
||||||
|
const registry = await firstValueFrom(observable); |
||||||
|
expect(registry).toEqual({}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should be possible to register function extensions in the registry', async () => { |
||||||
|
const addedFunctionsRegistry = new AddedFunctionsRegistry(); |
||||||
|
|
||||||
|
addedFunctionsRegistry.register({ |
||||||
|
pluginId, |
||||||
|
configs: [ |
||||||
|
{ |
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
targets: 'grafana/dashboard/panel/menu', |
||||||
|
fn: jest.fn(), |
||||||
|
}, |
||||||
|
{ |
||||||
|
title: 'Function 2', |
||||||
|
description: 'Function 2 description', |
||||||
|
targets: 'plugins/myorg-basic-app/start', |
||||||
|
fn: jest.fn(), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
|
||||||
|
const registry = await addedFunctionsRegistry.getState(); |
||||||
|
|
||||||
|
expect(registry).toEqual({ |
||||||
|
'grafana/dashboard/panel/menu': [ |
||||||
|
{ |
||||||
|
pluginId: pluginId, |
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
extensionPointId: 'grafana/dashboard/panel/menu', |
||||||
|
fn: expect.any(Function), |
||||||
|
}, |
||||||
|
], |
||||||
|
'plugins/myorg-basic-app/start': [ |
||||||
|
{ |
||||||
|
pluginId: pluginId, |
||||||
|
title: 'Function 2', |
||||||
|
description: 'Function 2 description', |
||||||
|
extensionPointId: 'plugins/myorg-basic-app/start', |
||||||
|
fn: expect.any(Function), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
}); |
||||||
|
it('should be possible to asynchronously register function extensions for the same placement (different plugins)', async () => { |
||||||
|
const pluginId1 = 'grafana-basic-app'; |
||||||
|
const pluginId2 = 'grafana-basic-app2'; |
||||||
|
const reactiveRegistry = new AddedFunctionsRegistry(); |
||||||
|
|
||||||
|
// Register extensions for the first plugin
|
||||||
|
reactiveRegistry.register({ |
||||||
|
pluginId: pluginId1, |
||||||
|
configs: [ |
||||||
|
{ |
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
targets: 'grafana/dashboard/panel/menu', |
||||||
|
fn: jest.fn().mockReturnValue({}), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
|
||||||
|
const registry1 = await reactiveRegistry.getState(); |
||||||
|
|
||||||
|
expect(registry1).toEqual({ |
||||||
|
'grafana/dashboard/panel/menu': [ |
||||||
|
{ |
||||||
|
pluginId: pluginId1, |
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
extensionPointId: 'grafana/dashboard/panel/menu', |
||||||
|
fn: expect.any(Function), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
|
||||||
|
// Register extensions for the second plugin to a different placement
|
||||||
|
reactiveRegistry.register({ |
||||||
|
pluginId: pluginId2, |
||||||
|
configs: [ |
||||||
|
{ |
||||||
|
title: 'Function 2', |
||||||
|
description: 'Function 2 description', |
||||||
|
targets: 'grafana/dashboard/panel/menu', |
||||||
|
fn: jest.fn().mockReturnValue({}), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
|
||||||
|
const registry2 = await reactiveRegistry.getState(); |
||||||
|
|
||||||
|
expect(registry2).toEqual({ |
||||||
|
'grafana/dashboard/panel/menu': [ |
||||||
|
{ |
||||||
|
pluginId: pluginId1, |
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
extensionPointId: 'grafana/dashboard/panel/menu', |
||||||
|
fn: expect.any(Function), |
||||||
|
}, |
||||||
|
{ |
||||||
|
pluginId: pluginId2, |
||||||
|
title: 'Function 2', |
||||||
|
description: 'Function 2 description', |
||||||
|
extensionPointId: 'grafana/dashboard/panel/menu', |
||||||
|
fn: expect.any(Function), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should be possible to asynchronously register function extensions for a different placement (different plugin)', async () => { |
||||||
|
const pluginId1 = 'grafana-basic-app'; |
||||||
|
const pluginId2 = 'grafana-basic-app2'; |
||||||
|
const reactiveRegistry = new AddedFunctionsRegistry(); |
||||||
|
|
||||||
|
// Register extensions for the first plugin
|
||||||
|
reactiveRegistry.register({ |
||||||
|
pluginId: pluginId1, |
||||||
|
configs: [ |
||||||
|
{ |
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
targets: 'grafana/dashboard/panel/menu', |
||||||
|
fn: jest.fn().mockReturnValue({}), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
|
||||||
|
const registry1 = await reactiveRegistry.getState(); |
||||||
|
|
||||||
|
expect(registry1).toEqual({ |
||||||
|
'grafana/dashboard/panel/menu': [ |
||||||
|
{ |
||||||
|
pluginId: pluginId1, |
||||||
|
|
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
extensionPointId: 'grafana/dashboard/panel/menu', |
||||||
|
fn: expect.any(Function), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
|
||||||
|
// Register extensions for the second plugin to a different placement
|
||||||
|
reactiveRegistry.register({ |
||||||
|
pluginId: pluginId2, |
||||||
|
configs: [ |
||||||
|
{ |
||||||
|
title: 'Function 2', |
||||||
|
description: 'Function 2 description', |
||||||
|
targets: 'plugins/myorg-basic-app/start', |
||||||
|
fn: jest.fn().mockReturnValue({}), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
|
||||||
|
const registry2 = await reactiveRegistry.getState(); |
||||||
|
|
||||||
|
expect(registry2).toEqual({ |
||||||
|
'grafana/dashboard/panel/menu': [ |
||||||
|
{ |
||||||
|
pluginId: pluginId1, |
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
extensionPointId: 'grafana/dashboard/panel/menu', |
||||||
|
fn: expect.any(Function), |
||||||
|
}, |
||||||
|
], |
||||||
|
'plugins/myorg-basic-app/start': [ |
||||||
|
{ |
||||||
|
pluginId: pluginId2, |
||||||
|
title: 'Function 2', |
||||||
|
description: 'Function 2 description', |
||||||
|
extensionPointId: 'plugins/myorg-basic-app/start', |
||||||
|
fn: expect.any(Function), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should be possible to asynchronously register function extensions for the same placement (same plugin)', async () => { |
||||||
|
const pluginId = 'grafana-basic-app'; |
||||||
|
const reactiveRegistry = new AddedFunctionsRegistry(); |
||||||
|
|
||||||
|
// Register extensions for the first extension point
|
||||||
|
reactiveRegistry.register({ |
||||||
|
pluginId: pluginId, |
||||||
|
configs: [ |
||||||
|
{ |
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
targets: 'grafana/dashboard/panel/menu', |
||||||
|
fn: jest.fn().mockReturnValue({}), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
|
||||||
|
// Register extensions to a different extension point
|
||||||
|
reactiveRegistry.register({ |
||||||
|
pluginId: pluginId, |
||||||
|
configs: [ |
||||||
|
{ |
||||||
|
title: 'Function 2', |
||||||
|
description: 'Function 2 description', |
||||||
|
targets: 'grafana/dashboard/panel/menu', |
||||||
|
fn: jest.fn().mockReturnValue({}), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
|
||||||
|
const registry2 = await reactiveRegistry.getState(); |
||||||
|
|
||||||
|
expect(registry2).toEqual({ |
||||||
|
'grafana/dashboard/panel/menu': [ |
||||||
|
{ |
||||||
|
pluginId: pluginId, |
||||||
|
|
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
extensionPointId: 'grafana/dashboard/panel/menu', |
||||||
|
fn: expect.any(Function), |
||||||
|
}, |
||||||
|
{ |
||||||
|
pluginId: pluginId, |
||||||
|
|
||||||
|
title: 'Function 2', |
||||||
|
description: 'Function 2 description', |
||||||
|
extensionPointId: 'grafana/dashboard/panel/menu', |
||||||
|
fn: expect.any(Function), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should be possible to asynchronously register function extensions for a different placement (same plugin)', async () => { |
||||||
|
const pluginId = 'grafana-basic-app'; |
||||||
|
const reactiveRegistry = new AddedFunctionsRegistry(); |
||||||
|
|
||||||
|
// Register extensions for the first extension point
|
||||||
|
reactiveRegistry.register({ |
||||||
|
pluginId: pluginId, |
||||||
|
configs: [ |
||||||
|
{ |
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
targets: 'grafana/dashboard/panel/menu', |
||||||
|
fn: jest.fn().mockReturnValue({}), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
|
||||||
|
// Register extensions to a different extension point
|
||||||
|
reactiveRegistry.register({ |
||||||
|
pluginId: pluginId, |
||||||
|
configs: [ |
||||||
|
{ |
||||||
|
title: 'Function 2', |
||||||
|
description: 'Function 2 description', |
||||||
|
targets: 'plugins/myorg-basic-app/start', |
||||||
|
fn: jest.fn().mockReturnValue({}), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
|
||||||
|
const registry2 = await reactiveRegistry.getState(); |
||||||
|
|
||||||
|
expect(registry2).toEqual({ |
||||||
|
'grafana/dashboard/panel/menu': [ |
||||||
|
{ |
||||||
|
pluginId: pluginId, |
||||||
|
|
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
extensionPointId: 'grafana/dashboard/panel/menu', |
||||||
|
fn: expect.any(Function), |
||||||
|
}, |
||||||
|
], |
||||||
|
'plugins/myorg-basic-app/start': [ |
||||||
|
{ |
||||||
|
pluginId: pluginId, |
||||||
|
|
||||||
|
title: 'Function 2', |
||||||
|
description: 'Function 2 description', |
||||||
|
extensionPointId: 'plugins/myorg-basic-app/start', |
||||||
|
fn: expect.any(Function), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should notify subscribers when the registry changes', async () => { |
||||||
|
const pluginId = 'grafana-basic-app'; |
||||||
|
const reactiveRegistry = new AddedFunctionsRegistry(); |
||||||
|
const observable = reactiveRegistry.asObservable(); |
||||||
|
const subscribeCallback = jest.fn(); |
||||||
|
|
||||||
|
observable.subscribe(subscribeCallback); |
||||||
|
|
||||||
|
// Register extensions for the first plugin
|
||||||
|
reactiveRegistry.register({ |
||||||
|
pluginId: pluginId, |
||||||
|
configs: [ |
||||||
|
{ |
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
targets: 'grafana/dashboard/panel/menu', |
||||||
|
fn: jest.fn().mockReturnValue({}), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
|
||||||
|
expect(subscribeCallback).toHaveBeenCalledTimes(2); |
||||||
|
|
||||||
|
// Register extensions for the first plugin
|
||||||
|
reactiveRegistry.register({ |
||||||
|
pluginId: 'another-plugin', |
||||||
|
configs: [ |
||||||
|
{ |
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
targets: 'grafana/dashboard/panel/menu', |
||||||
|
fn: jest.fn().mockReturnValue({}), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
|
||||||
|
expect(subscribeCallback).toHaveBeenCalledTimes(3); |
||||||
|
|
||||||
|
const registry = subscribeCallback.mock.calls[2][0]; |
||||||
|
|
||||||
|
expect(registry).toEqual({ |
||||||
|
'grafana/dashboard/panel/menu': [ |
||||||
|
{ |
||||||
|
pluginId: pluginId, |
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
extensionPointId: 'grafana/dashboard/panel/menu', |
||||||
|
fn: expect.any(Function), |
||||||
|
}, |
||||||
|
{ |
||||||
|
pluginId: 'another-plugin', |
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
extensionPointId: 'grafana/dashboard/panel/menu', |
||||||
|
fn: expect.any(Function), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should give the last version of the registry for new subscribers', async () => { |
||||||
|
const pluginId = 'grafana-basic-app'; |
||||||
|
const reactiveRegistry = new AddedFunctionsRegistry(); |
||||||
|
const observable = reactiveRegistry.asObservable(); |
||||||
|
const subscribeCallback = jest.fn(); |
||||||
|
|
||||||
|
reactiveRegistry.register({ |
||||||
|
pluginId: pluginId, |
||||||
|
configs: [ |
||||||
|
{ |
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
targets: 'grafana/dashboard/panel/menu', |
||||||
|
fn: jest.fn().mockReturnValue({}), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
|
||||||
|
observable.subscribe(subscribeCallback); |
||||||
|
expect(subscribeCallback).toHaveBeenCalledTimes(1); |
||||||
|
|
||||||
|
const registry = subscribeCallback.mock.calls[0][0]; |
||||||
|
|
||||||
|
expect(registry).toEqual({ |
||||||
|
'grafana/dashboard/panel/menu': [ |
||||||
|
{ |
||||||
|
pluginId: pluginId, |
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
extensionPointId: 'grafana/dashboard/panel/menu', |
||||||
|
fn: expect.any(Function), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should not register a function extension if it has an invalid fn function', () => { |
||||||
|
const pluginId = 'grafana-basic-app'; |
||||||
|
const reactiveRegistry = new AddedFunctionsRegistry(); |
||||||
|
const observable = reactiveRegistry.asObservable(); |
||||||
|
const subscribeCallback = jest.fn(); |
||||||
|
|
||||||
|
reactiveRegistry.register({ |
||||||
|
pluginId: pluginId, |
||||||
|
configs: [ |
||||||
|
{ |
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
targets: 'grafana/dashboard/panel/menu', |
||||||
|
//@ts-ignore
|
||||||
|
fn: '...', |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
|
||||||
|
expect(log.error).toHaveBeenCalled(); |
||||||
|
|
||||||
|
observable.subscribe(subscribeCallback); |
||||||
|
expect(subscribeCallback).toHaveBeenCalledTimes(1); |
||||||
|
|
||||||
|
const registry = subscribeCallback.mock.calls[0][0]; |
||||||
|
expect(registry).toEqual({}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should not register a function extension if it has invalid properties (empty title)', () => { |
||||||
|
const pluginId = 'grafana-basic-app'; |
||||||
|
const reactiveRegistry = new AddedFunctionsRegistry(); |
||||||
|
const observable = reactiveRegistry.asObservable(); |
||||||
|
const subscribeCallback = jest.fn(); |
||||||
|
|
||||||
|
reactiveRegistry.register({ |
||||||
|
pluginId: pluginId, |
||||||
|
configs: [ |
||||||
|
{ |
||||||
|
title: '', |
||||||
|
targets: 'grafana/dashboard/panel/menu', |
||||||
|
fn: jest.fn().mockReturnValue({}), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
|
||||||
|
expect(log.error).toHaveBeenCalled(); |
||||||
|
|
||||||
|
observable.subscribe(subscribeCallback); |
||||||
|
expect(subscribeCallback).toHaveBeenCalledTimes(1); |
||||||
|
|
||||||
|
const registry = subscribeCallback.mock.calls[0][0]; |
||||||
|
expect(registry).toEqual({}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should not be possible to register a function on a read-only registry', async () => { |
||||||
|
const pluginId = 'grafana-basic-app'; |
||||||
|
const registry = new AddedFunctionsRegistry(); |
||||||
|
const readOnlyRegistry = registry.readOnly(); |
||||||
|
|
||||||
|
expect(() => { |
||||||
|
readOnlyRegistry.register({ |
||||||
|
pluginId, |
||||||
|
configs: [ |
||||||
|
{ |
||||||
|
title: 'Function 2', |
||||||
|
description: 'Function 2 description', |
||||||
|
targets: 'plugins/myorg-basic-app/start', |
||||||
|
fn: jest.fn().mockReturnValue({}), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
}).toThrow(MSG_CANNOT_REGISTER_READ_ONLY); |
||||||
|
|
||||||
|
const currentState = await readOnlyRegistry.getState(); |
||||||
|
expect(Object.keys(currentState)).toHaveLength(0); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should pass down fresh registrations to the read-only version of the registry', async () => { |
||||||
|
const pluginId = 'grafana-basic-app'; |
||||||
|
const registry = new AddedFunctionsRegistry(); |
||||||
|
const readOnlyRegistry = registry.readOnly(); |
||||||
|
const subscribeCallback = jest.fn(); |
||||||
|
let readOnlyState; |
||||||
|
|
||||||
|
// Should have no extensions registered in the beginning
|
||||||
|
readOnlyState = await readOnlyRegistry.getState(); |
||||||
|
expect(Object.keys(readOnlyState)).toHaveLength(0); |
||||||
|
|
||||||
|
readOnlyRegistry.asObservable().subscribe(subscribeCallback); |
||||||
|
|
||||||
|
// Register an extension to the original (writable) registry
|
||||||
|
registry.register({ |
||||||
|
pluginId, |
||||||
|
configs: [ |
||||||
|
{ |
||||||
|
title: 'Function 2', |
||||||
|
description: 'Function 2 description', |
||||||
|
targets: 'plugins/myorg-basic-app/start', |
||||||
|
fn: jest.fn().mockReturnValue({}), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
|
||||||
|
// The read-only registry should have received the new extension
|
||||||
|
readOnlyState = await readOnlyRegistry.getState(); |
||||||
|
expect(Object.keys(readOnlyState)).toHaveLength(1); |
||||||
|
|
||||||
|
expect(subscribeCallback).toHaveBeenCalledTimes(2); |
||||||
|
expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual(['plugins/myorg-basic-app/start']); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should not register a function added by a plugin in dev-mode if the meta-info is missing from the plugin.json', async () => { |
||||||
|
// Enabling dev mode
|
||||||
|
jest.mocked(isGrafanaDevMode).mockReturnValue(true); |
||||||
|
|
||||||
|
const registry = new AddedFunctionsRegistry(); |
||||||
|
const fnConfig = { |
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
targets: 'grafana/dashboard/panel/menu', |
||||||
|
fn: jest.fn().mockReturnValue({}), |
||||||
|
}; |
||||||
|
|
||||||
|
// Make sure that the meta-info is empty
|
||||||
|
config.apps[pluginId].extensions.addedFunctions = []; |
||||||
|
|
||||||
|
registry.register({ |
||||||
|
pluginId, |
||||||
|
configs: [fnConfig], |
||||||
|
}); |
||||||
|
|
||||||
|
const currentState = await registry.getState(); |
||||||
|
|
||||||
|
expect(Object.keys(currentState)).toHaveLength(0); |
||||||
|
expect(log.error).toHaveBeenCalled(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should register a function added by core Grafana in dev-mode even if the meta-info is missing', async () => { |
||||||
|
// Enabling dev mode
|
||||||
|
jest.mocked(isGrafanaDevMode).mockReturnValue(true); |
||||||
|
|
||||||
|
const registry = new AddedFunctionsRegistry(); |
||||||
|
const fnConfig = { |
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
targets: 'grafana/dashboard/panel/menu', |
||||||
|
fn: jest.fn().mockReturnValue({}), |
||||||
|
}; |
||||||
|
|
||||||
|
registry.register({ |
||||||
|
pluginId: 'grafana', |
||||||
|
configs: [fnConfig], |
||||||
|
}); |
||||||
|
|
||||||
|
const currentState = await registry.getState(); |
||||||
|
|
||||||
|
expect(Object.keys(currentState)).toHaveLength(1); |
||||||
|
expect(log.error).not.toHaveBeenCalled(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should register a function added by a plugin in production mode even if the meta-info is missing', async () => { |
||||||
|
// Production mode
|
||||||
|
jest.mocked(isGrafanaDevMode).mockReturnValue(false); |
||||||
|
|
||||||
|
const registry = new AddedFunctionsRegistry(); |
||||||
|
const fnConfig = { |
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
targets: 'grafana/dashboard/panel/menu', |
||||||
|
fn: jest.fn().mockReturnValue({}), |
||||||
|
}; |
||||||
|
|
||||||
|
// Make sure that the meta-info is empty
|
||||||
|
config.apps[pluginId].extensions.addedFunctions = []; |
||||||
|
|
||||||
|
registry.register({ |
||||||
|
pluginId, |
||||||
|
configs: [fnConfig], |
||||||
|
}); |
||||||
|
|
||||||
|
const currentState = await registry.getState(); |
||||||
|
|
||||||
|
expect(Object.keys(currentState)).toHaveLength(1); |
||||||
|
expect(log.error).not.toHaveBeenCalled(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should register a function added by a plugin in dev-mode if the meta-info is present', async () => { |
||||||
|
// Enabling dev mode
|
||||||
|
jest.mocked(isGrafanaDevMode).mockReturnValue(true); |
||||||
|
|
||||||
|
const registry = new AddedFunctionsRegistry(); |
||||||
|
const fnConfig = { |
||||||
|
title: 'Function 1', |
||||||
|
description: 'Function 1 description', |
||||||
|
targets: ['grafana/dashboard/panel/menu'], |
||||||
|
fn: jest.fn().mockReturnValue({}), |
||||||
|
}; |
||||||
|
|
||||||
|
// Make sure that the meta-info is empty
|
||||||
|
config.apps[pluginId].extensions.addedFunctions = [fnConfig]; |
||||||
|
|
||||||
|
registry.register({ |
||||||
|
pluginId, |
||||||
|
configs: [fnConfig], |
||||||
|
}); |
||||||
|
|
||||||
|
const currentState = await registry.getState(); |
||||||
|
|
||||||
|
expect(Object.keys(currentState)).toHaveLength(1); |
||||||
|
expect(log.error).not.toHaveBeenCalled(); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,87 @@ |
|||||||
|
import { isFunction } from 'lodash'; |
||||||
|
import { ReplaySubject } from 'rxjs'; |
||||||
|
|
||||||
|
import { PluginExtensionAddedFunctionConfig } from '@grafana/data'; |
||||||
|
|
||||||
|
import * as errors from '../errors'; |
||||||
|
import { isGrafanaDevMode } from '../utils'; |
||||||
|
import { isAddedFunctionMetaInfoMissing } from '../validators'; |
||||||
|
|
||||||
|
import { PluginExtensionConfigs, Registry, RegistryType } from './Registry'; |
||||||
|
|
||||||
|
const logPrefix = 'Could not register function extension. Reason:'; |
||||||
|
|
||||||
|
export type AddedFunctionsRegistryItem = { |
||||||
|
pluginId: string; |
||||||
|
title: string; |
||||||
|
fn: unknown; |
||||||
|
description?: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export class AddedFunctionsRegistry extends Registry<AddedFunctionsRegistryItem[], PluginExtensionAddedFunctionConfig> { |
||||||
|
constructor( |
||||||
|
options: { |
||||||
|
registrySubject?: ReplaySubject<RegistryType<AddedFunctionsRegistryItem[]>>; |
||||||
|
initialState?: RegistryType<AddedFunctionsRegistryItem[]>; |
||||||
|
} = {} |
||||||
|
) { |
||||||
|
super(options); |
||||||
|
} |
||||||
|
|
||||||
|
mapToRegistry( |
||||||
|
registry: RegistryType<AddedFunctionsRegistryItem[]>, |
||||||
|
item: PluginExtensionConfigs<PluginExtensionAddedFunctionConfig> |
||||||
|
): RegistryType<AddedFunctionsRegistryItem[]> { |
||||||
|
const { pluginId, configs } = item; |
||||||
|
for (const config of configs) { |
||||||
|
const configLog = this.logger.child({ |
||||||
|
title: config.title, |
||||||
|
pluginId, |
||||||
|
}); |
||||||
|
|
||||||
|
if (!config.title) { |
||||||
|
configLog.error(`${logPrefix} ${errors.TITLE_MISSING}`); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (!isFunction(config.fn)) { |
||||||
|
configLog.error(`${logPrefix} ${errors.INVALID_EXTENSION_FUNCTION}`); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedFunctionMetaInfoMissing(pluginId, config, configLog)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
const extensionPointIds = Array.isArray(config.targets) ? config.targets : [config.targets]; |
||||||
|
for (const extensionPointId of extensionPointIds) { |
||||||
|
const pointIdLog = configLog.child({ extensionPointId }); |
||||||
|
|
||||||
|
const result = { |
||||||
|
pluginId, |
||||||
|
fn: config.fn, |
||||||
|
description: config.description, |
||||||
|
title: config.title, |
||||||
|
extensionPointId, |
||||||
|
}; |
||||||
|
|
||||||
|
pointIdLog.debug('Added function extension successfully registered'); |
||||||
|
|
||||||
|
if (!(extensionPointId in registry)) { |
||||||
|
registry[extensionPointId] = [result]; |
||||||
|
} else { |
||||||
|
registry[extensionPointId].push(result); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return registry; |
||||||
|
} |
||||||
|
|
||||||
|
// Returns a read-only version of the registry.
|
||||||
|
readOnly() { |
||||||
|
return new AddedFunctionsRegistry({ |
||||||
|
registrySubject: this.registrySubject, |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
@ -1,9 +1,11 @@ |
|||||||
import { AddedComponentsRegistry } from './AddedComponentsRegistry'; |
import { AddedComponentsRegistry } from './AddedComponentsRegistry'; |
||||||
|
import { AddedFunctionsRegistry } from './AddedFunctionsRegistry'; |
||||||
import { AddedLinksRegistry } from './AddedLinksRegistry'; |
import { AddedLinksRegistry } from './AddedLinksRegistry'; |
||||||
import { ExposedComponentsRegistry } from './ExposedComponentsRegistry'; |
import { ExposedComponentsRegistry } from './ExposedComponentsRegistry'; |
||||||
|
|
||||||
export type PluginExtensionRegistries = { |
export type PluginExtensionRegistries = { |
||||||
addedComponentsRegistry: AddedComponentsRegistry; |
addedComponentsRegistry: AddedComponentsRegistry; |
||||||
exposedComponentsRegistry: ExposedComponentsRegistry; |
exposedComponentsRegistry: ExposedComponentsRegistry; |
||||||
|
addedFunctionsRegistry: AddedFunctionsRegistry; |
||||||
addedLinksRegistry: AddedLinksRegistry; |
addedLinksRegistry: AddedLinksRegistry; |
||||||
}; |
}; |
||||||
|
|||||||
@ -0,0 +1,82 @@ |
|||||||
|
import { useMemo } from 'react'; |
||||||
|
import { useObservable } from 'react-use'; |
||||||
|
|
||||||
|
import { usePluginContext, PluginExtensionFunction, PluginExtensionTypes } from '@grafana/data'; |
||||||
|
import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from '@grafana/runtime'; |
||||||
|
|
||||||
|
import { useAddedFunctionsRegistry } from './ExtensionRegistriesContext'; |
||||||
|
import * as errors from './errors'; |
||||||
|
import { log } from './logs/log'; |
||||||
|
import { useLoadAppPlugins } from './useLoadAppPlugins'; |
||||||
|
import { generateExtensionId, getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils'; |
||||||
|
import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators'; |
||||||
|
|
||||||
|
// Returns an array of component extensions for the given extension point
|
||||||
|
export function usePluginFunctions<Signature>({ |
||||||
|
limitPerPlugin, |
||||||
|
extensionPointId, |
||||||
|
}: UsePluginFunctionsOptions): UsePluginFunctionsResult<Signature> { |
||||||
|
const registry = useAddedFunctionsRegistry(); |
||||||
|
const registryState = useObservable(registry.asObservable()); |
||||||
|
const pluginContext = usePluginContext(); |
||||||
|
const deps = getExtensionPointPluginDependencies(extensionPointId); |
||||||
|
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(deps); |
||||||
|
|
||||||
|
return useMemo(() => { |
||||||
|
// For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
|
||||||
|
const enableRestrictions = isGrafanaDevMode() && pluginContext; |
||||||
|
const results: Array<PluginExtensionFunction<Signature>> = []; |
||||||
|
const extensionsByPlugin: Record<string, number> = {}; |
||||||
|
const pluginId = pluginContext?.meta.id ?? ''; |
||||||
|
const pointLog = log.child({ |
||||||
|
pluginId, |
||||||
|
extensionPointId, |
||||||
|
}); |
||||||
|
if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) { |
||||||
|
pointLog.error(errors.INVALID_EXTENSION_POINT_ID); |
||||||
|
} |
||||||
|
|
||||||
|
if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) { |
||||||
|
pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING); |
||||||
|
return { |
||||||
|
isLoading: false, |
||||||
|
functions: [], |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
if (isLoadingAppPlugins) { |
||||||
|
return { |
||||||
|
isLoading: true, |
||||||
|
functions: [], |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
for (const registryItem of registryState?.[extensionPointId] ?? []) { |
||||||
|
const { pluginId } = registryItem; |
||||||
|
|
||||||
|
// Only limit if the `limitPerPlugin` is set
|
||||||
|
if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (extensionsByPlugin[pluginId] === undefined) { |
||||||
|
extensionsByPlugin[pluginId] = 0; |
||||||
|
} |
||||||
|
|
||||||
|
results.push({ |
||||||
|
id: generateExtensionId(pluginId, extensionPointId, registryItem.title), |
||||||
|
type: PluginExtensionTypes.function, |
||||||
|
title: registryItem.title, |
||||||
|
description: registryItem.description ?? '', |
||||||
|
pluginId: pluginId, |
||||||
|
fn: registryItem.fn as Signature, |
||||||
|
}); |
||||||
|
extensionsByPlugin[pluginId] += 1; |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
isLoading: false, |
||||||
|
functions: results, |
||||||
|
}; |
||||||
|
}, [extensionPointId, limitPerPlugin, pluginContext, registryState, isLoadingAppPlugins]); |
||||||
|
} |
||||||
Loading…
Reference in new issue