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:
Erik Sundell 2024-08-19 08:43:11 +02:00 committed by GitHub
parent 4a753dd2d5
commit 134467fc4a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 575 additions and 93 deletions

View File

@ -554,6 +554,7 @@ export {
type PluginExtensionDataSourceConfigContext,
type PluginExtensionCommandPaletteContext,
type PluginExtensionOpenModalOptions,
type PluginExposedComponentConfig,
} from './types/pluginExtensions';
export {
type ScopeDashboardBindingSpec,

View File

@ -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({

View File

@ -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 = {

View File

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

View File

@ -22,6 +22,7 @@ function createPluginExtensionRegistry(preloadResults: Array<{ pluginId: string;
registry.register({
pluginId,
extensionConfigs,
exposedComponentConfigs: [],
});
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -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 {

View File

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