diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index bf16d4e093a..005e89554da 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -554,6 +554,7 @@ export { type PluginExtensionDataSourceConfigContext, type PluginExtensionCommandPaletteContext, type PluginExtensionOpenModalOptions, + type PluginExposedComponentConfig, } from './types/pluginExtensions'; export { type ScopeDashboardBindingSpec, diff --git a/packages/grafana-data/src/types/app.ts b/packages/grafana-data/src/types/app.ts index 5f2fbc12a68..9c16723c737 100644 --- a/packages/grafana-data/src/types/app.ts +++ b/packages/grafana-data/src/types/app.ts @@ -7,6 +7,7 @@ import { type PluginExtensionLinkConfig, PluginExtensionTypes, PluginExtensionComponentConfig, + PluginExposedComponentConfig, PluginExtensionConfig, } from './pluginExtensions'; @@ -56,6 +57,7 @@ export interface AppPluginMeta extends PluginMeta } export class AppPlugin extends GrafanaPlugin> { + private _exposedComponentConfigs: PluginExposedComponentConfig[] = []; private _extensionConfigs: PluginExtensionConfig[] = []; // Content under: /a/${plugin-id}/* @@ -98,6 +100,10 @@ export class AppPlugin extends GrafanaPlugin extends GrafanaPlugin( - componentConfig: { id: string } & Omit, 'type' | 'extensionPointId'> - ) { - const { id, ...extension } = componentConfig; - - this._extensionConfigs.push({ - ...extension, - extensionPointId: `capabilities/${id}`, - type: PluginExtensionTypes.component, - } as PluginExtensionComponentConfig); + exposeComponent(componentConfig: PluginExposedComponentConfig) { + this._exposedComponentConfigs.push(componentConfig as PluginExposedComponentConfig); return this; } @@ -165,7 +163,6 @@ export class AppPlugin extends GrafanaPlugin(extension: Omit, 'type'>) { this.addComponent({ diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 00594fad001..0c027f3b1ed 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -95,6 +95,29 @@ export type PluginExtensionComponentConfig = { extensionPointId: string; }; +export type PluginExposedComponentConfig = { + /** + * The unique identifier of the component + * Shoud be in the format of `//`. 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; +}; + export type PluginExtensionConfig = PluginExtensionLinkConfig | PluginExtensionComponentConfig; export type PluginExtensionOpenModalOptions = { diff --git a/public/app/app.ts b/public/app/app.ts index 467f8bcca92..278884385f3 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -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(); diff --git a/public/app/features/plugins/extensions/getPluginExtensions.test.tsx b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx index 51d3317496b..3038c8d7544 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx @@ -22,6 +22,7 @@ function createPluginExtensionRegistry(preloadResults: Array<{ pluginId: string; registry.register({ pluginId, extensionConfigs, + exposedComponentConfigs: [], }); } diff --git a/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.test.ts b/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.test.ts index b958016764a..b4f90414e0d 100644 --- a/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.test.ts +++ b/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.test.ts @@ -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(); diff --git a/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.ts b/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.ts index 2ef23fe4b2e..d5d29cb22e1 100644 --- a/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.ts +++ b/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.ts @@ -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); diff --git a/public/app/features/plugins/extensions/registry/ExportedComponentsRegistry.test.ts b/public/app/features/plugins/extensions/registry/ExportedComponentsRegistry.test.ts new file mode 100644 index 00000000000..64e18fe74ab --- /dev/null +++ b/public/app/features/plugins/extensions/registry/ExportedComponentsRegistry.test.ts @@ -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); + }); +}); diff --git a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts new file mode 100644 index 00000000000..f0036742fab --- /dev/null +++ b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts @@ -0,0 +1,60 @@ +import { PluginExposedComponentConfig } from '@grafana/data'; + +import { logWarning } from '../utils'; + +import { Registry, RegistryType, PluginExtensionConfigs } from './Registry'; + +export class ExposedComponentsRegistry extends Registry { + constructor(initialState: RegistryType = {}) { + super({ + initialState, + }); + } + + mapToRegistry( + registry: RegistryType, + { pluginId, configs }: PluginExtensionConfigs + ): RegistryType { + 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; + } +} diff --git a/public/app/features/plugins/extensions/registry/Registry.ts b/public/app/features/plugins/extensions/registry/Registry.ts new file mode 100644 index 00000000000..fc3a8721be3 --- /dev/null +++ b/public/app/features/plugins/extensions/registry/Registry.ts @@ -0,0 +1,57 @@ +import { Observable, ReplaySubject, Subject, firstValueFrom, map, scan, startWith } from 'rxjs'; + +import { deepFreeze } from '../utils'; + +export type PluginExtensionConfigs = { + pluginId: string; + configs: T[]; +}; + +export type RegistryItem = { + pluginId: string; + config: T; +}; + +export type RegistryType = Record>; + +type ConstructorOptions = { + initialState: RegistryType; +}; + +// This is the base-class used by the separate specific registries. +export abstract class Registry { + private resultSubject: Subject>; + private registrySubject: ReplaySubject>; + + constructor(options: ConstructorOptions) { + const { initialState } = options; + this.resultSubject = new Subject>(); + // 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>(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, item: PluginExtensionConfigs): RegistryType; + + register(result: PluginExtensionConfigs): void { + this.resultSubject.next(result); + } + + asObservable(): Observable> { + return this.registrySubject.asObservable(); + } + + getState(): Promise> { + return firstValueFrom(this.asObservable()); + } +} diff --git a/public/app/features/plugins/extensions/usePluginComponent.test.tsx b/public/app/features/plugins/extensions/usePluginComponent.test.tsx index cc1772ef3c3..46b81ad3325 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.test.tsx @@ -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: () =>
Hello World
, - }, - ], + configs: [{ id, title: 'not important', description: 'not important', component: () =>
Hello World
}], }); - 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: () =>
Hello World
, @@ -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); diff --git a/public/app/features/plugins/extensions/usePluginComponent.tsx b/public/app/features/plugins/extensions/usePluginComponent.tsx index 99a63c8c921..df2689a66ce 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.tsx @@ -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(id: string): UsePluginComponentResult { 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(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]); }; diff --git a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx index 92c3d4c7f1c..f6259d2bfeb 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx @@ -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: [], }); }); diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index 54092186677..48d690f0a02 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -37,17 +37,6 @@ export function isPluginExtensionComponentConfig( 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 { diff --git a/public/app/features/plugins/pluginPreloader.ts b/public/app/features/plugins/pluginPreloader.ts index 1a00a69716d..2fd705bdd4e 100644 --- a/public/app/features/plugins/pluginPreloader.ts +++ b/public/app/features/plugins/pluginPreloader.ts @@ -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 { 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}`); }