mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Plugin Extensions: Introduce new registry for exposed components (#91748)
* refactor app plugin internals * add base registry and exposed components registry * refactor usePluginComponent hook * change type name * fix hook * remove comments * fix broken tests * add more tests * remove link and component related changes * use right id format * add title prop * remove comments * rename registry * make exportedComponentsConfigs required * fix broken test * cleanup tests * fix prop name * remove capability related code * rename exported to exposed * refactor(extensions): make registry types generic * Update public/app/features/plugins/extensions/registry/ExportedComponentsRegistry.test.ts Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com> * fix levitate error --------- Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>
This commit is contained in:
parent
4a753dd2d5
commit
134467fc4a
@ -554,6 +554,7 @@ export {
|
||||
type PluginExtensionDataSourceConfigContext,
|
||||
type PluginExtensionCommandPaletteContext,
|
||||
type PluginExtensionOpenModalOptions,
|
||||
type PluginExposedComponentConfig,
|
||||
} from './types/pluginExtensions';
|
||||
export {
|
||||
type ScopeDashboardBindingSpec,
|
||||
|
@ -7,6 +7,7 @@ import {
|
||||
type PluginExtensionLinkConfig,
|
||||
PluginExtensionTypes,
|
||||
PluginExtensionComponentConfig,
|
||||
PluginExposedComponentConfig,
|
||||
PluginExtensionConfig,
|
||||
} from './pluginExtensions';
|
||||
|
||||
@ -56,6 +57,7 @@ export interface AppPluginMeta<T extends KeyValue = KeyValue> extends PluginMeta
|
||||
}
|
||||
|
||||
export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppPluginMeta<T>> {
|
||||
private _exposedComponentConfigs: PluginExposedComponentConfig[] = [];
|
||||
private _extensionConfigs: PluginExtensionConfig[] = [];
|
||||
|
||||
// Content under: /a/${plugin-id}/*
|
||||
@ -98,6 +100,10 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
|
||||
}
|
||||
}
|
||||
|
||||
get exposedComponentConfigs() {
|
||||
return this._exposedComponentConfigs;
|
||||
}
|
||||
|
||||
get extensionConfigs() {
|
||||
return this._extensionConfigs;
|
||||
}
|
||||
@ -142,16 +148,8 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
|
||||
return this;
|
||||
}
|
||||
|
||||
exposeComponent<Props = {}>(
|
||||
componentConfig: { id: string } & Omit<PluginExtensionComponentConfig<Props>, 'type' | 'extensionPointId'>
|
||||
) {
|
||||
const { id, ...extension } = componentConfig;
|
||||
|
||||
this._extensionConfigs.push({
|
||||
...extension,
|
||||
extensionPointId: `capabilities/${id}`,
|
||||
type: PluginExtensionTypes.component,
|
||||
} as PluginExtensionComponentConfig);
|
||||
exposeComponent<Props = {}>(componentConfig: PluginExposedComponentConfig<Props>) {
|
||||
this._exposedComponentConfigs.push(componentConfig as PluginExposedComponentConfig);
|
||||
|
||||
return this;
|
||||
}
|
||||
@ -165,7 +163,6 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @deprecated Use .addComponent() instead */
|
||||
configureExtensionComponent<Props = {}>(extension: Omit<PluginExtensionComponentConfig<Props>, 'type'>) {
|
||||
this.addComponent({
|
||||
|
@ -95,6 +95,29 @@ export type PluginExtensionComponentConfig<Props = {}> = {
|
||||
extensionPointId: string;
|
||||
};
|
||||
|
||||
export type PluginExposedComponentConfig<Props = {}> = {
|
||||
/**
|
||||
* The unique identifier of the component
|
||||
* Shoud be in the format of `<pluginId>/<componentName>/<componentVersion>`. e.g. `myorg-todo-app/todo-list/v1`
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The title of the component
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* A short description of the component
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* The React component that will be exposed to other plugins
|
||||
*/
|
||||
component: React.ComponentType<Props>;
|
||||
};
|
||||
|
||||
export type PluginExtensionConfig = PluginExtensionLinkConfig | PluginExtensionComponentConfig;
|
||||
|
||||
export type PluginExtensionOpenModalOptions = {
|
||||
|
@ -85,6 +85,7 @@ import { DatasourceSrv } from './features/plugins/datasource_srv';
|
||||
import { getCoreExtensionConfigurations } from './features/plugins/extensions/getCoreExtensionConfigurations';
|
||||
import { createPluginExtensionsGetter } from './features/plugins/extensions/getPluginExtensions';
|
||||
import { ReactivePluginExtensionsRegistry } from './features/plugins/extensions/reactivePluginExtensionRegistry';
|
||||
import { ExposedComponentsRegistry } from './features/plugins/extensions/registry/ExposedComponentsRegistry';
|
||||
import { createUsePluginComponent } from './features/plugins/extensions/usePluginComponent';
|
||||
import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions';
|
||||
import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin';
|
||||
@ -213,8 +214,11 @@ export class GrafanaApp {
|
||||
extensionsRegistry.register({
|
||||
pluginId: 'grafana',
|
||||
extensionConfigs: getCoreExtensionConfigurations(),
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
|
||||
const exposedComponentsRegistry = new ExposedComponentsRegistry();
|
||||
|
||||
if (contextSrv.user.orgRole !== '') {
|
||||
// The "cloud-home-app" is registering banners once it's loaded, and this can cause a rerender in the AppChrome if it's loaded after the Grafana app init.
|
||||
// TODO: remove the following exception once the issue mentioned above is fixed.
|
||||
@ -222,13 +226,18 @@ export class GrafanaApp {
|
||||
const awaitedAppPlugins = Object.values(config.apps).filter((app) => awaitedAppPluginIds.includes(app.id));
|
||||
const appPlugins = Object.values(config.apps).filter((app) => !awaitedAppPluginIds.includes(app.id));
|
||||
|
||||
preloadPlugins(appPlugins, extensionsRegistry);
|
||||
await preloadPlugins(awaitedAppPlugins, extensionsRegistry, 'frontend_awaited_plugins_preload');
|
||||
preloadPlugins(appPlugins, extensionsRegistry, exposedComponentsRegistry);
|
||||
await preloadPlugins(
|
||||
awaitedAppPlugins,
|
||||
extensionsRegistry,
|
||||
exposedComponentsRegistry,
|
||||
'frontend_awaited_plugins_preload'
|
||||
);
|
||||
}
|
||||
|
||||
setPluginExtensionGetter(createPluginExtensionsGetter(extensionsRegistry));
|
||||
setPluginExtensionsHook(createUsePluginExtensions(extensionsRegistry));
|
||||
setPluginComponentHook(createUsePluginComponent(extensionsRegistry));
|
||||
setPluginComponentHook(createUsePluginComponent(exposedComponentsRegistry));
|
||||
|
||||
// initialize chrome service
|
||||
const queryParams = locationService.getSearchObject();
|
||||
|
@ -22,6 +22,7 @@ function createPluginExtensionRegistry(preloadResults: Array<{ pluginId: string;
|
||||
registry.register({
|
||||
pluginId,
|
||||
extensionConfigs,
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -39,6 +39,7 @@ describe('createPluginExtensionsRegistry', () => {
|
||||
configure: jest.fn().mockReturnValue({}),
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
|
||||
const registry = await reactiveRegistry.getRegistry();
|
||||
@ -64,6 +65,7 @@ describe('createPluginExtensionsRegistry', () => {
|
||||
configure: jest.fn().mockReturnValue({}),
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
|
||||
const registry1 = await reactiveRegistry.getRegistry();
|
||||
@ -83,6 +85,7 @@ describe('createPluginExtensionsRegistry', () => {
|
||||
configure: jest.fn().mockReturnValue({}),
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
|
||||
const registry2 = await reactiveRegistry.getRegistry();
|
||||
@ -116,6 +119,7 @@ describe('createPluginExtensionsRegistry', () => {
|
||||
configure: jest.fn().mockImplementation((context) => ({ title: context?.title })),
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
|
||||
const registry = await reactiveRegistry.getRegistry();
|
||||
@ -168,6 +172,7 @@ describe('createPluginExtensionsRegistry', () => {
|
||||
configure: jest.fn().mockReturnValue({}),
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
|
||||
const registry1 = await reactiveRegistry.getRegistry();
|
||||
@ -201,6 +206,7 @@ describe('createPluginExtensionsRegistry', () => {
|
||||
configure: jest.fn().mockReturnValue({}),
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
|
||||
const registry2 = await reactiveRegistry.getRegistry();
|
||||
@ -251,6 +257,7 @@ describe('createPluginExtensionsRegistry', () => {
|
||||
configure: jest.fn().mockReturnValue({}),
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
|
||||
const registry1 = await reactiveRegistry.getRegistry();
|
||||
@ -284,6 +291,7 @@ describe('createPluginExtensionsRegistry', () => {
|
||||
configure: jest.fn().mockReturnValue({}),
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
|
||||
const registry2 = await reactiveRegistry.getRegistry();
|
||||
@ -335,6 +343,7 @@ describe('createPluginExtensionsRegistry', () => {
|
||||
configure: jest.fn().mockReturnValue({}),
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
|
||||
// Register extensions to a different extension point
|
||||
@ -350,6 +359,7 @@ describe('createPluginExtensionsRegistry', () => {
|
||||
configure: jest.fn().mockReturnValue({}),
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
|
||||
const registry2 = await reactiveRegistry.getRegistry();
|
||||
@ -399,6 +409,7 @@ describe('createPluginExtensionsRegistry', () => {
|
||||
configure: jest.fn().mockReturnValue({}),
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
|
||||
// Register extensions to a different extension point
|
||||
@ -414,6 +425,7 @@ describe('createPluginExtensionsRegistry', () => {
|
||||
configure: jest.fn().mockReturnValue({}),
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
|
||||
const registry2 = await reactiveRegistry.getRegistry();
|
||||
@ -469,6 +481,7 @@ describe('createPluginExtensionsRegistry', () => {
|
||||
configure: jest.fn().mockReturnValue({}),
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
|
||||
expect(subscribeCallback).toHaveBeenCalledTimes(2);
|
||||
@ -486,6 +499,7 @@ describe('createPluginExtensionsRegistry', () => {
|
||||
configure: jest.fn().mockReturnValue({}),
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
|
||||
expect(subscribeCallback).toHaveBeenCalledTimes(3);
|
||||
@ -538,6 +552,7 @@ describe('createPluginExtensionsRegistry', () => {
|
||||
configure: jest.fn().mockReturnValue({}),
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
|
||||
observable.subscribe(subscribeCallback);
|
||||
@ -581,6 +596,7 @@ describe('createPluginExtensionsRegistry', () => {
|
||||
configure: jest.fn().mockReturnValue({}),
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
|
||||
expect(consoleWarn).toHaveBeenCalled();
|
||||
@ -640,6 +656,7 @@ describe('createPluginExtensionsRegistry', () => {
|
||||
configure: jest.fn().mockReturnValue({}),
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
|
||||
expect(consoleWarn).toHaveBeenCalled();
|
||||
@ -669,6 +686,7 @@ describe('createPluginExtensionsRegistry', () => {
|
||||
configure: jest.fn().mockReturnValue({}),
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
|
||||
expect(consoleWarn).toHaveBeenCalled();
|
||||
|
@ -4,7 +4,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { PluginPreloadResult } from '../pluginPreloader';
|
||||
|
||||
import { PluginExtensionRegistry, PluginExtensionRegistryItem } from './types';
|
||||
import { deepFreeze, isPluginCapability, logWarning } from './utils';
|
||||
import { deepFreeze, logWarning } from './utils';
|
||||
import { isPluginExtensionConfigValid } from './validators';
|
||||
|
||||
export class ReactivePluginExtensionsRegistry {
|
||||
@ -54,21 +54,6 @@ function resultsToRegistry(registry: PluginExtensionRegistry, result: PluginPrel
|
||||
for (const extensionConfig of extensionConfigs) {
|
||||
const { extensionPointId } = extensionConfig;
|
||||
|
||||
// Change the extension point id for capabilities
|
||||
if (isPluginCapability(extensionConfig)) {
|
||||
const regex = /capabilities\/([a-zA-Z0-9_.\-\/]+)$/;
|
||||
const match = regex.exec(extensionPointId);
|
||||
|
||||
if (!match) {
|
||||
logWarning(
|
||||
`"${pluginId}" plugin has an invalid capability ID: ${extensionPointId.replace('capabilities/', '')} (It must be a string)`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
extensionConfig.extensionPointId = `capabilities/${match[1]}`;
|
||||
}
|
||||
|
||||
// Check if the config is valid
|
||||
if (!extensionConfig || !isPluginExtensionConfigValid(pluginId, extensionConfig)) {
|
||||
return registry;
|
||||
@ -81,12 +66,7 @@ function resultsToRegistry(registry: PluginExtensionRegistry, result: PluginPrel
|
||||
pluginId,
|
||||
};
|
||||
|
||||
// Capability (only a single value per identifier, can be overriden)
|
||||
if (isPluginCapability(extensionConfig)) {
|
||||
registry.extensions[extensionPointId] = [registryItem];
|
||||
}
|
||||
// Extension (multiple extensions per extension point identifier)
|
||||
else if (!Array.isArray(registry.extensions[extensionPointId])) {
|
||||
if (!Array.isArray(registry.extensions[extensionPointId])) {
|
||||
registry.extensions[extensionPointId] = [registryItem];
|
||||
} else {
|
||||
registry.extensions[extensionPointId].push(registryItem);
|
||||
|
@ -0,0 +1,348 @@
|
||||
import React from 'react';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { ExposedComponentsRegistry } from './ExposedComponentsRegistry';
|
||||
|
||||
describe('ExposedComponentsRegistry', () => {
|
||||
const consoleWarn = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
global.console.warn = consoleWarn;
|
||||
consoleWarn.mockReset();
|
||||
});
|
||||
|
||||
it('should return empty registry when no exposed components have been registered', async () => {
|
||||
const reactiveRegistry = new ExposedComponentsRegistry();
|
||||
const observable = reactiveRegistry.asObservable();
|
||||
const registry = await firstValueFrom(observable);
|
||||
expect(registry).toEqual({});
|
||||
});
|
||||
|
||||
it('should be possible to register exposed components in the registry', async () => {
|
||||
const pluginId = 'grafana-basic-app';
|
||||
const id = `${pluginId}/hello-world/v1`;
|
||||
const reactiveRegistry = new ExposedComponentsRegistry();
|
||||
|
||||
reactiveRegistry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
id,
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => React.createElement('div', null, 'Hello World'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const registry = await reactiveRegistry.getState();
|
||||
|
||||
expect(Object.keys(registry)).toHaveLength(1);
|
||||
expect(registry[id]).toMatchObject({
|
||||
pluginId,
|
||||
config: {
|
||||
id,
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should be possible to register multiple exposed components at one time', async () => {
|
||||
const pluginId = 'grafana-basic-app';
|
||||
const id1 = `${pluginId}/hello-world1/v1`;
|
||||
const id2 = `${pluginId}/hello-world2/v1`;
|
||||
const id3 = `${pluginId}/hello-world3/v1`;
|
||||
const reactiveRegistry = new ExposedComponentsRegistry();
|
||||
|
||||
reactiveRegistry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
id: id1,
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
{
|
||||
id: id2,
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => React.createElement('div', null, 'Hello World2'),
|
||||
},
|
||||
{
|
||||
id: id3,
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => React.createElement('div', null, 'Hello World3'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const registry = await reactiveRegistry.getState();
|
||||
|
||||
expect(Object.keys(registry)).toHaveLength(3);
|
||||
expect(registry[id1]).toMatchObject({ config: { id: id1 }, pluginId });
|
||||
expect(registry[id2]).toMatchObject({ config: { id: id2 }, pluginId });
|
||||
expect(registry[id3]).toMatchObject({ config: { id: id3 }, pluginId });
|
||||
});
|
||||
|
||||
it('should be possible to register multiple exposed components from multiple plugins', async () => {
|
||||
const pluginId1 = 'grafana-basic-app1';
|
||||
const pluginId2 = 'grafana-basic-app2';
|
||||
const id1 = `${pluginId1}/hello-world1/v1`;
|
||||
const id2 = `${pluginId1}/hello-world2/v1`;
|
||||
const id3 = `${pluginId2}/hello-world1/v1`;
|
||||
const id4 = `${pluginId2}/hello-world2/v1`;
|
||||
const reactiveRegistry = new ExposedComponentsRegistry();
|
||||
|
||||
reactiveRegistry.register({
|
||||
pluginId: pluginId1,
|
||||
configs: [
|
||||
{
|
||||
id: id1,
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
{
|
||||
id: id2,
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => React.createElement('div', null, 'Hello World2'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
reactiveRegistry.register({
|
||||
pluginId: pluginId2,
|
||||
configs: [
|
||||
{
|
||||
id: id3,
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => React.createElement('div', null, 'Hello World3'),
|
||||
},
|
||||
{
|
||||
id: id4,
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => React.createElement('div', null, 'Hello World4'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const registry = await reactiveRegistry.getState();
|
||||
|
||||
expect(Object.keys(registry)).toHaveLength(4);
|
||||
expect(registry[id1]).toMatchObject({ config: { id: id1 }, pluginId: pluginId1 });
|
||||
expect(registry[id2]).toMatchObject({ config: { id: id2 }, pluginId: pluginId1 });
|
||||
expect(registry[id3]).toMatchObject({ config: { id: id3 }, pluginId: pluginId2 });
|
||||
expect(registry[id4]).toMatchObject({ config: { id: id4 }, pluginId: pluginId2 });
|
||||
});
|
||||
|
||||
it('should notify subscribers when the registry changes', async () => {
|
||||
const registry = new ExposedComponentsRegistry();
|
||||
const observable = registry.asObservable();
|
||||
const subscribeCallback = jest.fn();
|
||||
|
||||
observable.subscribe(subscribeCallback);
|
||||
|
||||
// Register extensions for the first plugin
|
||||
registry.register({
|
||||
pluginId: 'grafana-basic-app1',
|
||||
configs: [
|
||||
{
|
||||
id: 'grafana-basic-app1/hello-world/v1',
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(subscribeCallback).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Register exposed components for the second plugin
|
||||
registry.register({
|
||||
pluginId: 'grafana-basic-app2',
|
||||
configs: [
|
||||
{
|
||||
id: 'grafana-basic-app2/hello-world/v1',
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(subscribeCallback).toHaveBeenCalledTimes(3);
|
||||
|
||||
const mock = subscribeCallback.mock.calls[2][0];
|
||||
expect(mock).toHaveProperty('grafana-basic-app1/hello-world/v1');
|
||||
expect(mock).toHaveProperty('grafana-basic-app2/hello-world/v1');
|
||||
});
|
||||
|
||||
it('should give the last version of the registry for new subscribers', async () => {
|
||||
const registry = new ExposedComponentsRegistry();
|
||||
const observable = registry.asObservable();
|
||||
const subscribeCallback = jest.fn();
|
||||
|
||||
// Register extensions for the first plugin
|
||||
registry.register({
|
||||
pluginId: 'grafana-basic-app',
|
||||
configs: [
|
||||
{
|
||||
id: 'grafana-basic-app/hello-world/v1',
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
observable.subscribe(subscribeCallback);
|
||||
expect(subscribeCallback).toHaveBeenCalledTimes(1);
|
||||
|
||||
const mock = subscribeCallback.mock.calls[0][0];
|
||||
|
||||
expect(mock['grafana-basic-app/hello-world/v1']).toMatchObject({
|
||||
pluginId: 'grafana-basic-app',
|
||||
config: {
|
||||
id: 'grafana-basic-app/hello-world/v1',
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should log a warning if another component with the same id already exists in the registry', async () => {
|
||||
const registry = new ExposedComponentsRegistry();
|
||||
registry.register({
|
||||
pluginId: 'grafana-basic-app1',
|
||||
configs: [
|
||||
{
|
||||
id: 'grafana-basic-app1/hello-world/v1',
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const currentState1 = await registry.getState();
|
||||
expect(Object.keys(currentState1)).toHaveLength(1);
|
||||
expect(currentState1['grafana-basic-app1/hello-world/v1']).toMatchObject({
|
||||
pluginId: 'grafana-basic-app1',
|
||||
config: {
|
||||
id: 'grafana-basic-app1/hello-world/v1',
|
||||
},
|
||||
});
|
||||
|
||||
registry.register({
|
||||
pluginId: 'grafana-basic-app2',
|
||||
configs: [
|
||||
{
|
||||
id: 'grafana-basic-app1/hello-world/v1', // incorrectly scoped
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(consoleWarn).toHaveBeenCalledWith(
|
||||
"[Plugin Extensions] Could not register exposed component with id 'grafana-basic-app1/hello-world/v1'. Reason: The component id does not match the id naming convention. Id should be prefixed with plugin id. e.g 'myorg-basic-app/my-component-id/v1'."
|
||||
);
|
||||
const currentState2 = await registry.getState();
|
||||
expect(Object.keys(currentState2)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should skip registering component and log a warning when id is not prefixed with plugin id', async () => {
|
||||
const registry = new ExposedComponentsRegistry();
|
||||
registry.register({
|
||||
pluginId: 'grafana-basic-app1',
|
||||
configs: [
|
||||
{
|
||||
id: 'hello-world/v1',
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(consoleWarn).toHaveBeenCalledWith(
|
||||
"[Plugin Extensions] Could not register exposed component with id 'hello-world/v1'. Reason: The component id does not match the id naming convention. Id should be prefixed with plugin id. e.g 'myorg-basic-app/my-component-id/v1'."
|
||||
);
|
||||
const currentState = await registry.getState();
|
||||
expect(Object.keys(currentState)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should log a warning when exposed component id is not suffixed with component version', async () => {
|
||||
const registry = new ExposedComponentsRegistry();
|
||||
registry.register({
|
||||
pluginId: 'grafana-basic-app1',
|
||||
configs: [
|
||||
{
|
||||
id: 'grafana-basic-app1/hello-world',
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(consoleWarn).toHaveBeenCalledWith(
|
||||
"[Plugin Extensions] Exposed component with id 'grafana-basic-app1/hello-world' does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'."
|
||||
);
|
||||
const currentState = await registry.getState();
|
||||
expect(Object.keys(currentState)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not register component when description is missing', async () => {
|
||||
const registry = new ExposedComponentsRegistry();
|
||||
|
||||
registry.register({
|
||||
pluginId: 'grafana-basic-app',
|
||||
configs: [
|
||||
{
|
||||
id: 'grafana-basic-app/hello-world/v1',
|
||||
title: 'not important',
|
||||
description: '',
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(consoleWarn).toHaveBeenCalledWith(
|
||||
"[Plugin Extensions] Could not register exposed component with id 'grafana-basic-app/hello-world/v1'. Reason: Description is missing."
|
||||
);
|
||||
|
||||
const currentState = await registry.getState();
|
||||
expect(Object.keys(currentState)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not register component when title is missing', async () => {
|
||||
const registry = new ExposedComponentsRegistry();
|
||||
|
||||
registry.register({
|
||||
pluginId: 'grafana-basic-app',
|
||||
configs: [
|
||||
{
|
||||
id: 'grafana-basic-app/hello-world/v1',
|
||||
title: '',
|
||||
description: 'not important',
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(consoleWarn).toHaveBeenCalledWith(
|
||||
"[Plugin Extensions] Could not register exposed component with id 'grafana-basic-app/hello-world/v1'. Reason: Title is missing."
|
||||
);
|
||||
|
||||
const currentState = await registry.getState();
|
||||
expect(Object.keys(currentState)).toHaveLength(0);
|
||||
});
|
||||
});
|
@ -0,0 +1,60 @@
|
||||
import { PluginExposedComponentConfig } from '@grafana/data';
|
||||
|
||||
import { logWarning } from '../utils';
|
||||
|
||||
import { Registry, RegistryType, PluginExtensionConfigs } from './Registry';
|
||||
|
||||
export class ExposedComponentsRegistry extends Registry<PluginExposedComponentConfig> {
|
||||
constructor(initialState: RegistryType<PluginExposedComponentConfig> = {}) {
|
||||
super({
|
||||
initialState,
|
||||
});
|
||||
}
|
||||
|
||||
mapToRegistry(
|
||||
registry: RegistryType<PluginExposedComponentConfig>,
|
||||
{ pluginId, configs }: PluginExtensionConfigs<PluginExposedComponentConfig>
|
||||
): RegistryType<PluginExposedComponentConfig> {
|
||||
if (!configs) {
|
||||
return registry;
|
||||
}
|
||||
|
||||
for (const config of configs) {
|
||||
const { id, description, title } = config;
|
||||
|
||||
if (!id.startsWith(pluginId)) {
|
||||
logWarning(
|
||||
`Could not register exposed component with id '${id}'. Reason: The component id does not match the id naming convention. Id should be prefixed with plugin id. e.g 'myorg-basic-app/my-component-id/v1'.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!id.match(/.*\/v\d+$/)) {
|
||||
logWarning(
|
||||
`Exposed component with id '${id}' does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'.`
|
||||
);
|
||||
}
|
||||
|
||||
if (registry[id]) {
|
||||
logWarning(
|
||||
`Could not register exposed component with id '${id}'. Reason: An exposed component with the same id already exists.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
logWarning(`Could not register exposed component with id '${id}'. Reason: Title is missing.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!description) {
|
||||
logWarning(`Could not register exposed component with id '${id}'. Reason: Description is missing.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
registry[id] = { config, pluginId };
|
||||
}
|
||||
|
||||
return registry;
|
||||
}
|
||||
}
|
57
public/app/features/plugins/extensions/registry/Registry.ts
Normal file
57
public/app/features/plugins/extensions/registry/Registry.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { Observable, ReplaySubject, Subject, firstValueFrom, map, scan, startWith } from 'rxjs';
|
||||
|
||||
import { deepFreeze } from '../utils';
|
||||
|
||||
export type PluginExtensionConfigs<T> = {
|
||||
pluginId: string;
|
||||
configs: T[];
|
||||
};
|
||||
|
||||
export type RegistryItem<T> = {
|
||||
pluginId: string;
|
||||
config: T;
|
||||
};
|
||||
|
||||
export type RegistryType<T> = Record<string | symbol, RegistryItem<T>>;
|
||||
|
||||
type ConstructorOptions<T> = {
|
||||
initialState: RegistryType<T>;
|
||||
};
|
||||
|
||||
// This is the base-class used by the separate specific registries.
|
||||
export abstract class Registry<T> {
|
||||
private resultSubject: Subject<PluginExtensionConfigs<T>>;
|
||||
private registrySubject: ReplaySubject<RegistryType<T>>;
|
||||
|
||||
constructor(options: ConstructorOptions<T>) {
|
||||
const { initialState } = options;
|
||||
this.resultSubject = new Subject<PluginExtensionConfigs<T>>();
|
||||
// This is the subject that we expose.
|
||||
// (It will buffer the last value on the stream - the registry - and emit it to new subscribers immediately.)
|
||||
this.registrySubject = new ReplaySubject<RegistryType<T>>(1);
|
||||
|
||||
this.resultSubject
|
||||
.pipe(
|
||||
scan(this.mapToRegistry, initialState),
|
||||
// Emit an empty registry to start the stream (it is only going to do it once during construction, and then just passes down the values)
|
||||
startWith(initialState),
|
||||
map((registry) => deepFreeze(registry))
|
||||
)
|
||||
// Emitting the new registry to `this.registrySubject`
|
||||
.subscribe(this.registrySubject);
|
||||
}
|
||||
|
||||
abstract mapToRegistry(registry: RegistryType<T>, item: PluginExtensionConfigs<T>): RegistryType<T>;
|
||||
|
||||
register(result: PluginExtensionConfigs<T>): void {
|
||||
this.resultSubject.next(result);
|
||||
}
|
||||
|
||||
asObservable(): Observable<RegistryType<T>> {
|
||||
return this.registrySubject.asObservable();
|
||||
}
|
||||
|
||||
getState(): Promise<RegistryType<T>> {
|
||||
return firstValueFrom(this.asObservable());
|
||||
}
|
||||
}
|
@ -1,9 +1,7 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
|
||||
import { PluginExtensionTypes } from '@grafana/data';
|
||||
|
||||
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
|
||||
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
|
||||
import { createUsePluginComponent } from './usePluginComponent';
|
||||
|
||||
jest.mock('app/features/plugins/pluginSettings', () => ({
|
||||
@ -18,14 +16,14 @@ jest.mock('app/features/plugins/pluginSettings', () => ({
|
||||
}));
|
||||
|
||||
describe('usePluginComponent()', () => {
|
||||
let reactiveRegistry: ReactivePluginExtensionsRegistry;
|
||||
let registry: ExposedComponentsRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
reactiveRegistry = new ReactivePluginExtensionsRegistry();
|
||||
registry = new ExposedComponentsRegistry();
|
||||
});
|
||||
|
||||
it('should return null if there are no component exposed for the id', () => {
|
||||
const usePluginComponent = createUsePluginComponent(reactiveRegistry);
|
||||
const usePluginComponent = createUsePluginComponent(registry);
|
||||
const { result } = renderHook(() => usePluginComponent('foo/bar'));
|
||||
|
||||
expect(result.current.component).toEqual(null);
|
||||
@ -33,23 +31,15 @@ describe('usePluginComponent()', () => {
|
||||
});
|
||||
|
||||
it('should return component, that can be rendered, from the registry', async () => {
|
||||
const id = 'my-app-plugin/foo/bar';
|
||||
const id = 'my-app-plugin/foo/bar/v1';
|
||||
const pluginId = 'my-app-plugin';
|
||||
|
||||
reactiveRegistry.register({
|
||||
registry.register({
|
||||
pluginId,
|
||||
extensionConfigs: [
|
||||
{
|
||||
extensionPointId: `capabilities/${id}`,
|
||||
type: PluginExtensionTypes.component,
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => <div>Hello World</div>,
|
||||
},
|
||||
],
|
||||
configs: [{ id, title: 'not important', description: 'not important', component: () => <div>Hello World</div> }],
|
||||
});
|
||||
|
||||
const usePluginComponent = createUsePluginComponent(reactiveRegistry);
|
||||
const usePluginComponent = createUsePluginComponent(registry);
|
||||
const { result } = renderHook(() => usePluginComponent(id));
|
||||
const Component = result.current.component;
|
||||
|
||||
@ -63,9 +53,9 @@ describe('usePluginComponent()', () => {
|
||||
});
|
||||
|
||||
it('should dynamically update when component is registered to the registry', async () => {
|
||||
const id = 'my-app-plugin/foo/bar';
|
||||
const id = 'my-app-plugin/foo/bar/v1';
|
||||
const pluginId = 'my-app-plugin';
|
||||
const usePluginComponent = createUsePluginComponent(reactiveRegistry);
|
||||
const usePluginComponent = createUsePluginComponent(registry);
|
||||
const { result, rerender } = renderHook(() => usePluginComponent(id));
|
||||
|
||||
// No extensions yet
|
||||
@ -74,12 +64,11 @@ describe('usePluginComponent()', () => {
|
||||
|
||||
// Add extensions to the registry
|
||||
act(() => {
|
||||
reactiveRegistry.register({
|
||||
registry.register({
|
||||
pluginId,
|
||||
extensionConfigs: [
|
||||
configs: [
|
||||
{
|
||||
extensionPointId: `capabilities/${id}`,
|
||||
type: PluginExtensionTypes.component,
|
||||
id,
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => <div>Hello World</div>,
|
||||
@ -103,9 +92,9 @@ describe('usePluginComponent()', () => {
|
||||
});
|
||||
|
||||
it('should only render the hook once', () => {
|
||||
const spy = jest.spyOn(reactiveRegistry, 'asObservable');
|
||||
const spy = jest.spyOn(registry, 'asObservable');
|
||||
const id = 'my-app-plugin/foo/bar';
|
||||
const usePluginComponent = createUsePluginComponent(reactiveRegistry);
|
||||
const usePluginComponent = createUsePluginComponent(registry);
|
||||
|
||||
renderHook(() => usePluginComponent(id));
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
|
@ -3,39 +3,30 @@ import { useObservable } from 'react-use';
|
||||
|
||||
import { UsePluginComponentResult } from '@grafana/runtime';
|
||||
|
||||
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
|
||||
import { isPluginExtensionComponentConfig, wrapWithPluginContext } from './utils';
|
||||
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
|
||||
import { wrapWithPluginContext } from './utils';
|
||||
|
||||
// Returns a component exposed by a plugin.
|
||||
// (Exposed components can be defined in plugins by calling .exposeComponent() on the AppPlugin instance.)
|
||||
export function createUsePluginComponent(extensionsRegistry: ReactivePluginExtensionsRegistry) {
|
||||
const observableRegistry = extensionsRegistry.asObservable();
|
||||
export function createUsePluginComponent(registry: ExposedComponentsRegistry) {
|
||||
const observableRegistry = registry.asObservable();
|
||||
|
||||
return function usePluginComponent<Props extends object = {}>(id: string): UsePluginComponentResult<Props> {
|
||||
const registry = useObservable(observableRegistry);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!registry) {
|
||||
if (!registry || !registry[id]) {
|
||||
return {
|
||||
isLoading: false,
|
||||
component: null,
|
||||
};
|
||||
}
|
||||
|
||||
const registryId = `capabilities/${id}`;
|
||||
const registryItems = registry.extensions[registryId];
|
||||
const registryItem = Array.isArray(registryItems) ? registryItems[0] : null;
|
||||
|
||||
if (registryItem && isPluginExtensionComponentConfig<Props>(registryItem.config)) {
|
||||
return {
|
||||
isLoading: false,
|
||||
component: wrapWithPluginContext(registryItem.pluginId, registryItem.config.component),
|
||||
};
|
||||
}
|
||||
const registryItem = registry[id];
|
||||
|
||||
return {
|
||||
isLoading: false,
|
||||
component: null,
|
||||
component: wrapWithPluginContext(registryItem.pluginId, registryItem.config.component),
|
||||
};
|
||||
}, [id, registry]);
|
||||
};
|
||||
|
@ -46,6 +46,7 @@ describe('usePluginExtensions()', () => {
|
||||
path: `/a/${pluginId}/2`,
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
|
||||
const usePluginExtensions = createUsePluginExtensions(reactiveRegistry);
|
||||
@ -85,6 +86,7 @@ describe('usePluginExtensions()', () => {
|
||||
path: `/a/${pluginId}/2`,
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
});
|
||||
|
||||
@ -130,6 +132,7 @@ describe('usePluginExtensions()', () => {
|
||||
path: `/a/${pluginId}/2`,
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
});
|
||||
|
||||
@ -165,6 +168,7 @@ describe('usePluginExtensions()', () => {
|
||||
path: `/a/${pluginId}/2`,
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
});
|
||||
|
||||
@ -193,6 +197,7 @@ describe('usePluginExtensions()', () => {
|
||||
path: `/a/${pluginId}/2`,
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
});
|
||||
|
||||
@ -213,6 +218,7 @@ describe('usePluginExtensions()', () => {
|
||||
path: `/a/${pluginId}/2`,
|
||||
},
|
||||
],
|
||||
exposedComponentConfigs: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -37,17 +37,6 @@ export function isPluginExtensionComponentConfig<Props extends object>(
|
||||
return typeof extension === 'object' && 'type' in extension && extension['type'] === PluginExtensionTypes.component;
|
||||
}
|
||||
|
||||
export function isPluginCapability(
|
||||
extension: PluginExtensionConfig | undefined
|
||||
): extension is PluginExtensionComponentConfig {
|
||||
return (
|
||||
typeof extension === 'object' &&
|
||||
'type' in extension &&
|
||||
extension['type'] === PluginExtensionTypes.component &&
|
||||
extension.extensionPointId.startsWith('capabilities/')
|
||||
);
|
||||
}
|
||||
|
||||
export function handleErrorsInFn(fn: Function, errorMessagePrefix = '') {
|
||||
return (...args: unknown[]) => {
|
||||
try {
|
||||
|
@ -1,20 +1,23 @@
|
||||
import type { PluginExtensionConfig } from '@grafana/data';
|
||||
import type { PluginExposedComponentConfig, PluginExtensionConfig } from '@grafana/data';
|
||||
import type { AppPluginConfig } from '@grafana/runtime';
|
||||
import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
|
||||
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
|
||||
|
||||
import { ReactivePluginExtensionsRegistry } from './extensions/reactivePluginExtensionRegistry';
|
||||
import { ExposedComponentsRegistry } from './extensions/registry/ExposedComponentsRegistry';
|
||||
import * as pluginLoader from './plugin_loader';
|
||||
|
||||
export type PluginPreloadResult = {
|
||||
pluginId: string;
|
||||
error?: unknown;
|
||||
extensionConfigs: PluginExtensionConfig[];
|
||||
exposedComponentConfigs: PluginExposedComponentConfig[];
|
||||
};
|
||||
|
||||
export async function preloadPlugins(
|
||||
apps: AppPluginConfig[] = [],
|
||||
registry: ReactivePluginExtensionsRegistry,
|
||||
exposedComponentsRegistry: ExposedComponentsRegistry,
|
||||
eventName = 'frontend_plugins_preload'
|
||||
) {
|
||||
startMeasure(eventName);
|
||||
@ -22,7 +25,17 @@ export async function preloadPlugins(
|
||||
const preloadedPlugins = await Promise.all(promises);
|
||||
|
||||
for (const preloadedPlugin of preloadedPlugins) {
|
||||
if (preloadedPlugin.error) {
|
||||
console.error(`[Plugins] Skip loading extensions for "${preloadedPlugin.pluginId}" due to an error.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
registry.register(preloadedPlugin);
|
||||
|
||||
exposedComponentsRegistry.register({
|
||||
pluginId: preloadedPlugin.pluginId,
|
||||
configs: preloadedPlugin.exposedComponentConfigs,
|
||||
});
|
||||
}
|
||||
|
||||
stopMeasure(eventName);
|
||||
@ -38,16 +51,16 @@ async function preload(config: AppPluginConfig): Promise<PluginPreloadResult> {
|
||||
isAngular: config.angular.detected,
|
||||
pluginId,
|
||||
});
|
||||
const { extensionConfigs = [] } = plugin;
|
||||
const { extensionConfigs = [], exposedComponentConfigs = [] } = plugin;
|
||||
|
||||
// Fetching meta-information for the preloaded app plugin and caching it for later.
|
||||
// (The function below returns a promise, but it's not awaited for a reason: we don't want to block the preload process, we would only like to cache the result for later.)
|
||||
getPluginSettings(pluginId);
|
||||
|
||||
return { pluginId, extensionConfigs };
|
||||
return { pluginId, extensionConfigs, exposedComponentConfigs };
|
||||
} catch (error) {
|
||||
console.error(`[Plugins] Failed to preload plugin: ${path} (version: ${version})`, error);
|
||||
return { pluginId, extensionConfigs: [], error };
|
||||
return { pluginId, extensionConfigs: [], error, exposedComponentConfigs: [] };
|
||||
} finally {
|
||||
stopMeasure(`frontend_plugin_preload_${pluginId}`);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user