diff --git a/.betterer.results b/.betterer.results index f05cafeefa0..adbcfdd1d1e 100644 --- a/.betterer.results +++ b/.betterer.results @@ -208,7 +208,8 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"] + [0, 0, 0, "Do not use any type assertions.", "3"], + [0, 0, 0, "Do not use any type assertions.", "4"] ], "packages/grafana-data/src/types/config.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -542,9 +543,11 @@ exports[`better eslint`] = { "packages/grafana-runtime/src/services/pluginExtensions/usePluginComponent.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], + "packages/grafana-runtime/src/services/pluginExtensions/usePluginComponents.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] + [0, 0, 0, "Do not use any type assertions.", "0"] ], "packages/grafana-runtime/src/utils/DataSourceWithBackend.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] @@ -4988,6 +4991,9 @@ exports[`better eslint`] = { "public/app/features/plugins/extensions/getPluginExtensions.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], + "public/app/features/plugins/extensions/usePluginComponents.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/plugins/loader/sharedDependencies.ts:5381": [ [0, 0, 0, "* import is invalid because \'Layout,HorizontalGroup,VerticalGroup\' from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"] ], diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index e2cd45f4fb2..2db23e03fca 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -556,6 +556,7 @@ export { type PluginExtensionCommandPaletteContext, type PluginExtensionOpenModalOptions, type PluginExposedComponentConfig, + type PluginAddedComponentConfig, } 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 9c16723c737..87756072998 100644 --- a/packages/grafana-data/src/types/app.ts +++ b/packages/grafana-data/src/types/app.ts @@ -9,6 +9,7 @@ import { PluginExtensionComponentConfig, PluginExposedComponentConfig, PluginExtensionConfig, + PluginAddedComponentConfig, } from './pluginExtensions'; /** @@ -58,6 +59,7 @@ export interface AppPluginMeta extends PluginMeta export class AppPlugin extends GrafanaPlugin> { private _exposedComponentConfigs: PluginExposedComponentConfig[] = []; + private _addedComponentConfigs: PluginAddedComponentConfig[] = []; private _extensionConfigs: PluginExtensionConfig[] = []; // Content under: /a/${plugin-id}/* @@ -104,6 +106,10 @@ export class AppPlugin extends GrafanaPlugin extends GrafanaPlugin( - extensionConfig: { targets: string | string[] } & Omit< - PluginExtensionComponentConfig, - 'type' | 'extensionPointId' - > - ) { - const { targets, ...extension } = extensionConfig; - const targetsArray = Array.isArray(targets) ? targets : [targets]; - - targetsArray.forEach((target) => { - this._extensionConfigs.push({ - ...extension, - extensionPointId: target, - type: PluginExtensionTypes.component, - } as PluginExtensionComponentConfig); - }); + addComponent(addedComponentConfig: PluginAddedComponentConfig) { + this._addedComponentConfigs.push(addedComponentConfig as PluginAddedComponentConfig); return this; } @@ -168,6 +160,7 @@ export class AppPlugin extends GrafanaPlugin = { extensionPointId: string; }; +export type PluginAddedComponentConfig = { + /** + * The target extension points where the component will be added + */ + targets: string | string[]; + + /** + * The title of the component + */ + title: string; + + /** + * A short description of the component + */ + description: string; + + /** + * The React component that will added to the target extension points + */ + component: React.ComponentType; +}; + export type PluginExposedComponentConfig = { /** * The unique identifier of the component diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts index 1a2cf79cc51..e1415c47a9e 100644 --- a/packages/grafana-runtime/src/services/index.ts +++ b/packages/grafana-runtime/src/services/index.ts @@ -26,11 +26,11 @@ export { usePluginExtensions, usePluginLinkExtensions, usePluginComponentExtensions, - usePluginComponents, usePluginLinks, } from './pluginExtensions/usePluginExtensions'; export { setPluginComponentHook, usePluginComponent } from './pluginExtensions/usePluginComponent'; +export { setPluginComponentsHook, usePluginComponents } from './pluginExtensions/usePluginComponents'; export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils'; export { setCurrentUser } from './user'; diff --git a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts index 7ab1014bfcb..664661dcaf6 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts @@ -16,6 +16,11 @@ export type GetPluginExtensionsOptions = { limitPerPlugin?: number; }; +export type UsePluginComponentOptions = { + extensionPointId: string; + limitPerPlugin?: number; +}; + export type GetPluginExtensionsResult = { extensions: T[]; }; @@ -30,6 +35,11 @@ export type UsePluginComponentResult = { isLoading: boolean; }; +export type UsePluginComponentsResult = { + components: Array>; + isLoading: boolean; +}; + let singleton: GetPluginExtensions | undefined; export function setPluginExtensionGetter(instance: GetPluginExtensions): void { diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginComponents.ts b/packages/grafana-runtime/src/services/pluginExtensions/usePluginComponents.ts new file mode 100644 index 00000000000..aafe2bb8aac --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginExtensions/usePluginComponents.ts @@ -0,0 +1,24 @@ +import { GetPluginExtensionsOptions, UsePluginComponentsResult } from './getPluginExtensions'; + +export type UsePluginComponents = ( + options: GetPluginExtensionsOptions +) => UsePluginComponentsResult; + +let singleton: UsePluginComponents | undefined; + +export function setPluginComponentsHook(hook: UsePluginComponents): void { + // We allow overriding the registry in tests + if (singleton && process.env.NODE_ENV !== 'test') { + throw new Error('setPluginComponentsHook() function should only be called once, when Grafana is starting.'); + } + singleton = hook; +} + +export function usePluginComponents( + options: GetPluginExtensionsOptions +): UsePluginComponentsResult { + if (!singleton) { + throw new Error('setPluginComponentsHook(options) can only be used after the Grafana instance has started.'); + } + return singleton(options) as UsePluginComponentsResult; +} diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts index ff6bac57621..439d8163ccf 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts @@ -39,22 +39,6 @@ export function usePluginLinks(options: GetPluginExtensionsOptions): { }, [extensions, isLoading]); } -export function usePluginComponents( - options: GetPluginExtensionsOptions -): { components: Array>; isLoading: boolean } { - const { extensions, isLoading } = usePluginExtensions(options); - - return useMemo( - () => ({ - components: extensions - .filter(isPluginExtensionComponent) - .map(({ component }) => component as React.ComponentType), - isLoading, - }), - [extensions, isLoading] - ); -} - /** * @deprecated Use usePluginLinks() instead. */ diff --git a/public/app/app.ts b/public/app/app.ts index 278884385f3..1d164a5baef 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -38,6 +38,7 @@ import { setReturnToPreviousHook, setPluginExtensionsHook, setPluginComponentHook, + setPluginComponentsHook, setCurrentUser, setChromeHeaderHeightHook, } from '@grafana/runtime'; @@ -85,8 +86,10 @@ 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 { AddedComponentsRegistry } from './features/plugins/extensions/registry/AddedComponentsRegistry'; import { ExposedComponentsRegistry } from './features/plugins/extensions/registry/ExposedComponentsRegistry'; import { createUsePluginComponent } from './features/plugins/extensions/usePluginComponent'; +import { createUsePluginComponents } from './features/plugins/extensions/usePluginComponents'; import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions'; import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin'; import { preloadPlugins } from './features/plugins/pluginPreloader'; @@ -210,15 +213,18 @@ export class GrafanaApp { initWindowRuntime(); // Initialize plugin extensions - const extensionsRegistry = new ReactivePluginExtensionsRegistry(); - extensionsRegistry.register({ + const pluginExtensionsRegistries = { + extensionsRegistry: new ReactivePluginExtensionsRegistry(), + addedComponentsRegistry: new AddedComponentsRegistry(), + exposedComponentsRegistry: new ExposedComponentsRegistry(), + }; + pluginExtensionsRegistries.extensionsRegistry.register({ pluginId: 'grafana', extensionConfigs: getCoreExtensionConfigurations(), exposedComponentConfigs: [], + addedComponentConfigs: [], }); - 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. @@ -226,18 +232,24 @@ 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, exposedComponentsRegistry); - await preloadPlugins( - awaitedAppPlugins, - extensionsRegistry, - exposedComponentsRegistry, - 'frontend_awaited_plugins_preload' - ); + preloadPlugins(appPlugins, pluginExtensionsRegistries); + await preloadPlugins(awaitedAppPlugins, pluginExtensionsRegistries, 'frontend_awaited_plugins_preload'); } - setPluginExtensionGetter(createPluginExtensionsGetter(extensionsRegistry)); - setPluginExtensionsHook(createUsePluginExtensions(extensionsRegistry)); - setPluginComponentHook(createUsePluginComponent(exposedComponentsRegistry)); + setPluginExtensionGetter( + createPluginExtensionsGetter( + pluginExtensionsRegistries.extensionsRegistry, + pluginExtensionsRegistries.addedComponentsRegistry + ) + ); + setPluginExtensionsHook( + createUsePluginExtensions( + pluginExtensionsRegistries.extensionsRegistry, + pluginExtensionsRegistries.addedComponentsRegistry + ) + ); + setPluginComponentHook(createUsePluginComponent(pluginExtensionsRegistries.exposedComponentsRegistry)); + setPluginComponentsHook(createUsePluginComponents(pluginExtensionsRegistries.addedComponentsRegistry)); // 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 3038c8d7544..d055c74d060 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx @@ -1,10 +1,16 @@ import * as React from 'react'; -import { PluginExtensionComponentConfig, PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data'; +import { + PluginAddedComponentConfig, + PluginExtensionComponentConfig, + PluginExtensionLinkConfig, + PluginExtensionTypes, +} from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; import { getPluginExtensions } from './getPluginExtensions'; import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry'; +import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; import { isReadOnlyProxy } from './utils'; import { assertPluginExtensionLink } from './validators'; @@ -15,18 +21,30 @@ jest.mock('@grafana/runtime', () => { }; }); -function createPluginExtensionRegistry(preloadResults: Array<{ pluginId: string; extensionConfigs: any[] }>) { +async function createRegistries( + preloadResults: Array<{ + pluginId: string; + addedComponentConfigs: PluginAddedComponentConfig[]; + extensionConfigs: any[]; + }> +) { const registry = new ReactivePluginExtensionsRegistry(); + const addedComponentsRegistry = new AddedComponentsRegistry(); - for (const { pluginId, extensionConfigs } of preloadResults) { + for (const { pluginId, extensionConfigs, addedComponentConfigs } of preloadResults) { registry.register({ pluginId, - extensionConfigs, exposedComponentConfigs: [], + extensionConfigs, + addedComponentConfigs: [], + }); + addedComponentsRegistry.register({ + pluginId, + configs: addedComponentConfigs, }); } - return registry.getRegistry(); + return { registry: await registry.getRegistry(), addedComponentsRegistry: await addedComponentsRegistry.getState() }; } describe('getPluginExtensions()', () => { @@ -69,8 +87,13 @@ describe('getPluginExtensions()', () => { }); test('should return the extensions for the given placement', async () => { - const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]); - const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint1 }); + const registries = await createRegistries([ + { pluginId, extensionConfigs: [link1, link2], addedComponentConfigs: [] }, + ]); + const { extensions } = getPluginExtensions({ + ...registries, + extensionPointId: extensionPoint1, + }); expect(extensions).toHaveLength(1); expect(extensions[0]).toEqual( @@ -86,10 +109,13 @@ describe('getPluginExtensions()', () => { test('should not limit the number of extensions per plugin by default', async () => { // Registering 3 extensions for the same plugin for the same placement - const registry = await createPluginExtensionRegistry([ - { pluginId, extensionConfigs: [link1, link1, link1, link2] }, + const registries = await createRegistries([ + { pluginId, extensionConfigs: [link1, link1, link1, link2], addedComponentConfigs: [] }, ]); - const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint1 }); + const { extensions } = getPluginExtensions({ + ...registries, + extensionPointId: extensionPoint1, + }); expect(extensions).toHaveLength(3); expect(extensions[0]).toEqual( @@ -104,10 +130,11 @@ describe('getPluginExtensions()', () => { }); test('should be possible to limit the number of extensions per plugin for a given placement', async () => { - const registry = await createPluginExtensionRegistry([ - { pluginId, extensionConfigs: [link1, link1, link1, link2] }, + const registries = await createRegistries([ + { pluginId, extensionConfigs: [link1, link1, link1, link2], addedComponentConfigs: [] }, { pluginId: 'my-plugin', + addedComponentConfigs: [], extensionConfigs: [ { ...link1, path: '/a/my-plugin/declare-incident' }, { ...link1, path: '/a/my-plugin/declare-incident' }, @@ -118,7 +145,11 @@ describe('getPluginExtensions()', () => { ]); // Limit to 1 extension per plugin - const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint1, limitPerPlugin: 1 }); + const { extensions } = getPluginExtensions({ + ...registries, + extensionPointId: extensionPoint1, + limitPerPlugin: 1, + }); expect(extensions).toHaveLength(2); expect(extensions[0]).toEqual( @@ -133,17 +164,22 @@ describe('getPluginExtensions()', () => { }); test('should return with an empty list if there are no extensions registered for a placement yet', async () => { - const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]); - const { extensions } = getPluginExtensions({ registry, extensionPointId: 'placement-with-no-extensions' }); + const registries = await createRegistries([ + { pluginId, extensionConfigs: [link1, link2], addedComponentConfigs: [] }, + ]); + const { extensions } = getPluginExtensions({ + ...registries, + extensionPointId: 'placement-with-no-extensions', + }); expect(extensions).toEqual([]); }); test('should pass the context to the configure() function', async () => { const context = { title: 'New title from the context!' }; - const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); - getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 }); + getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 }); expect(link2.configure).toHaveBeenCalledTimes(1); expect(link2.configure).toHaveBeenCalledWith(context); @@ -158,8 +194,11 @@ describe('getPluginExtensions()', () => { category: 'Machine Learning', })); - const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); - const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); + const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const { extensions } = getPluginExtensions({ + ...registries, + extensionPointId: extensionPoint2, + }); const [extension] = extensions; assertPluginExtensionLink(extension); @@ -181,8 +220,11 @@ describe('getPluginExtensions()', () => { category: 'Machine Learning', })); - const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); - const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); + const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const { extensions } = getPluginExtensions({ + ...registries, + extensionPointId: extensionPoint2, + }); const [extension] = extensions; assertPluginExtensionLink(extension); @@ -206,8 +248,11 @@ describe('getPluginExtensions()', () => { title: 'test', })); - const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); - const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); + const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const { extensions } = getPluginExtensions({ + ...registries, + extensionPointId: extensionPoint2, + }); const [extension] = extensions; expect(link2.configure).toHaveBeenCalledTimes(1); @@ -220,8 +265,12 @@ describe('getPluginExtensions()', () => { }); test('should pass a read only context to the configure() function', async () => { const context = { title: 'New title from the context!' }; - const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); - const { extensions } = getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 }); + const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const { extensions } = getPluginExtensions({ + ...registries, + context, + extensionPointId: extensionPoint2, + }); const [extension] = extensions; const readOnlyContext = (link2.configure as jest.Mock).mock.calls[0][0]; @@ -240,10 +289,10 @@ describe('getPluginExtensions()', () => { throw new Error('Something went wrong!'); }); - const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); expect(() => { - getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); + getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); }).not.toThrow(); expect(link2.configure).toHaveBeenCalledTimes(1); @@ -259,9 +308,17 @@ describe('getPluginExtensions()', () => { path: 'invalid-path', })); - const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]); - const { extensions: extensionsAtPlacement1 } = getPluginExtensions({ registry, extensionPointId: extensionPoint1 }); - const { extensions: extensionsAtPlacement2 } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); + const registries = await createRegistries([ + { pluginId, extensionConfigs: [link1, link2], addedComponentConfigs: [] }, + ]); + const { extensions: extensionsAtPlacement1 } = getPluginExtensions({ + ...registries, + extensionPointId: extensionPoint1, + }); + const { extensions: extensionsAtPlacement2 } = getPluginExtensions({ + ...registries, + extensionPointId: extensionPoint2, + }); expect(extensionsAtPlacement1).toHaveLength(0); expect(extensionsAtPlacement2).toHaveLength(0); @@ -279,8 +336,8 @@ describe('getPluginExtensions()', () => { link2.configure = jest.fn().mockImplementation(() => overrides); - const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); - const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); + const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); expect(extensions).toHaveLength(0); expect(link2.configure).toHaveBeenCalledTimes(1); @@ -290,8 +347,8 @@ describe('getPluginExtensions()', () => { test('should skip the extension if the configure() function returns a promise', async () => { link2.configure = jest.fn().mockImplementation(() => Promise.resolve({})); - const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); - const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); + const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); expect(extensions).toHaveLength(0); expect(link2.configure).toHaveBeenCalledTimes(1); @@ -301,8 +358,8 @@ describe('getPluginExtensions()', () => { test('should skip (hide) the extension if the configure() function returns undefined', async () => { link2.configure = jest.fn().mockImplementation(() => undefined); - const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); - const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); + const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); expect(extensions).toHaveLength(0); expect(global.console.warn).toHaveBeenCalledTimes(0); // As this is intentional, no warning should be logged @@ -315,8 +372,8 @@ describe('getPluginExtensions()', () => { }); const context = {}; - const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); - const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); + const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); const [extension] = extensions; assertPluginExtensionLink(extension); @@ -338,8 +395,8 @@ describe('getPluginExtensions()', () => { link2.path = undefined; link2.onClick = jest.fn().mockRejectedValue(new Error('testing')); - const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); - const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); + const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); const [extension] = extensions; assertPluginExtensionLink(extension); @@ -357,8 +414,8 @@ describe('getPluginExtensions()', () => { throw new Error('Something went wrong!'); }); - const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); - const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); + const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); const [extension] = extensions; assertPluginExtensionLink(extension); @@ -375,8 +432,8 @@ describe('getPluginExtensions()', () => { link2.path = undefined; link2.onClick = jest.fn(); - const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); - const { extensions } = getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 }); + const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const { extensions } = getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 }); const [extension] = extensions; assertPluginExtensionLink(extension); @@ -398,8 +455,8 @@ describe('getPluginExtensions()', () => { array: ['a'], }; - const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); - getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 }); + const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 }); expect(() => { context.title = 'Updating the title'; @@ -411,7 +468,7 @@ describe('getPluginExtensions()', () => { test('should report interaction when onClick is triggered', async () => { const reportInteractionMock = jest.mocked(reportInteraction); - const registry = await createPluginExtensionRegistry([ + const registries = await createRegistries([ { pluginId, extensionConfigs: [ @@ -421,9 +478,10 @@ describe('getPluginExtensions()', () => { onClick: jest.fn(), }, ], + addedComponentConfigs: [], }, ]); - const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint1 }); + const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint1 }); const [extension] = extensions; assertPluginExtensionLink(extension); @@ -440,17 +498,65 @@ describe('getPluginExtensions()', () => { }); test('should be possible to register and get component type extensions', async () => { - const extension = component1; - const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [extension] }]); - const { extensions } = getPluginExtensions({ registry, extensionPointId: extension.extensionPointId }); + const registries = await createRegistries([ + { + pluginId, + extensionConfigs: [], + addedComponentConfigs: [ + { + ...component1, + targets: component1.extensionPointId, + }, + ], + }, + ]); + const { extensions } = getPluginExtensions({ ...registries, extensionPointId: component1.extensionPointId }); expect(extensions).toHaveLength(1); expect(extensions[0]).toEqual( expect.objectContaining({ pluginId, type: PluginExtensionTypes.component, - title: extension.title, - description: extension.description, + title: component1.title, + description: component1.description, + }) + ); + }); + + test('should honour the limitPerPlugin also for component extensions', async () => { + const registries = await createRegistries([ + { + pluginId, + extensionConfigs: [], + addedComponentConfigs: [ + { + ...component1, + targets: component1.extensionPointId, + }, + { + title: 'Component 2', + description: 'Component 2 description', + targets: component1.extensionPointId, + component: (context) => { + return
Hello world2!
; + }, + }, + ], + }, + ]); + const { extensions } = getPluginExtensions({ + ...registries, + limitPerPlugin: 1, + extensionPointId: component1.extensionPointId, + }); + + expect(extensions).toHaveLength(1); + expect(extensions[0]).toEqual( + expect.objectContaining({ + pluginId, + type: PluginExtensionTypes.component, + title: component1.title, + description: component1.description, }) ); }); diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index ec616707d8c..6560ca8e393 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -11,38 +11,38 @@ import { import { GetPluginExtensions, reportInteraction } from '@grafana/runtime'; import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry'; -import type { PluginExtensionRegistry } from './types'; +import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import type { AddedComponentsRegistryState, PluginExtensionRegistry } from './types'; import { isPluginExtensionLinkConfig, getReadOnlyProxy, logWarning, generateExtensionId, getEventHelpers, - isPluginExtensionComponentConfig, wrapWithPluginContext, } from './utils'; -import { - assertIsReactComponent, - assertIsNotPromise, - assertLinkPathIsValid, - assertStringProps, - isPromise, -} from './validators'; +import { assertIsNotPromise, assertLinkPathIsValid, assertStringProps, isPromise } from './validators'; type GetExtensions = ({ context, extensionPointId, limitPerPlugin, registry, + addedComponentsRegistry, }: { context?: object | Record; extensionPointId: string; limitPerPlugin?: number; registry: PluginExtensionRegistry; + addedComponentsRegistry: AddedComponentsRegistryState; }) => { extensions: PluginExtension[] }; -export function createPluginExtensionsGetter(extensionRegistry: ReactivePluginExtensionsRegistry): GetPluginExtensions { +export function createPluginExtensionsGetter( + extensionRegistry: ReactivePluginExtensionsRegistry, + addedComponentRegistry: AddedComponentsRegistry +): GetPluginExtensions { let registry: PluginExtensionRegistry = { id: '', extensions: {} }; + let addedComponentsRegistryState: AddedComponentsRegistryState = {}; // Create a subscription to keep an copy of the registry state for use in the non-async // plugin extensions getter. @@ -50,11 +50,22 @@ export function createPluginExtensionsGetter(extensionRegistry: ReactivePluginEx registry = r; }); - return (options) => getPluginExtensions({ ...options, registry }); + addedComponentRegistry.asObservable().subscribe((r) => { + addedComponentsRegistryState = r; + }); + + return (options) => + getPluginExtensions({ ...options, registry, addedComponentsRegistry: addedComponentsRegistryState }); } // Returns with a list of plugin extensions for the given extension point -export const getPluginExtensions: GetExtensions = ({ context, extensionPointId, limitPerPlugin, registry }) => { +export const getPluginExtensions: GetExtensions = ({ + context, + extensionPointId, + limitPerPlugin, + registry, + addedComponentsRegistry, +}) => { const frozenContext = context ? getReadOnlyProxy(context) : {}; const registryItems = registry.extensions[extensionPointId] ?? []; // We don't return the extensions separated by type, because in that case it would be much harder to define a sort-order for them. @@ -103,23 +114,40 @@ export const getPluginExtensions: GetExtensions = ({ context, extensionPointId, extensions.push(extension); extensionsByPlugin[pluginId] += 1; } + } catch (error) { + if (error instanceof Error) { + logWarning(error.message); + } + } + } - // COMPONENT - if (isPluginExtensionComponentConfig(extensionConfig)) { - assertIsReactComponent(extensionConfig.component); + if (extensionPointId in addedComponentsRegistry) { + try { + const addedComponents = addedComponentsRegistry[extensionPointId]; + for (const addedComponent of addedComponents) { + // Only limit if the `limitPerPlugin` is set + if (limitPerPlugin && extensionsByPlugin[addedComponent.pluginId] >= limitPerPlugin) { + continue; + } + if (extensionsByPlugin[addedComponent.pluginId] === undefined) { + extensionsByPlugin[addedComponent.pluginId] = 0; + } const extension: PluginExtensionComponent = { - id: generateExtensionId(registryItem.pluginId, extensionConfig), + id: generateExtensionId(addedComponent.pluginId, { + ...addedComponent, + extensionPointId, + type: PluginExtensionTypes.component, + }), type: PluginExtensionTypes.component, - pluginId: registryItem.pluginId, - - title: extensionConfig.title, - description: extensionConfig.description, - component: wrapWithPluginContext(pluginId, extensionConfig.component), + pluginId: addedComponent.pluginId, + title: addedComponent.title, + description: addedComponent.description, + component: wrapWithPluginContext(addedComponent.pluginId, addedComponent.component), }; extensions.push(extension); - extensionsByPlugin[pluginId] += 1; + extensionsByPlugin[addedComponent.pluginId] += 1; } } catch (error) { if (error instanceof Error) { diff --git a/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.test.ts b/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.test.ts index b4f90414e0d..5ab6abee781 100644 --- a/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.test.ts +++ b/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.test.ts @@ -40,6 +40,7 @@ describe('createPluginExtensionsRegistry', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); const registry = await reactiveRegistry.getRegistry(); @@ -66,6 +67,7 @@ describe('createPluginExtensionsRegistry', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); const registry1 = await reactiveRegistry.getRegistry(); @@ -86,6 +88,7 @@ describe('createPluginExtensionsRegistry', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); const registry2 = await reactiveRegistry.getRegistry(); @@ -120,6 +123,7 @@ describe('createPluginExtensionsRegistry', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); const registry = await reactiveRegistry.getRegistry(); @@ -173,6 +177,7 @@ describe('createPluginExtensionsRegistry', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); const registry1 = await reactiveRegistry.getRegistry(); @@ -207,6 +212,7 @@ describe('createPluginExtensionsRegistry', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); const registry2 = await reactiveRegistry.getRegistry(); @@ -258,6 +264,7 @@ describe('createPluginExtensionsRegistry', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); const registry1 = await reactiveRegistry.getRegistry(); @@ -292,6 +299,7 @@ describe('createPluginExtensionsRegistry', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); const registry2 = await reactiveRegistry.getRegistry(); @@ -344,6 +352,7 @@ describe('createPluginExtensionsRegistry', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); // Register extensions to a different extension point @@ -360,6 +369,7 @@ describe('createPluginExtensionsRegistry', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); const registry2 = await reactiveRegistry.getRegistry(); @@ -410,6 +420,7 @@ describe('createPluginExtensionsRegistry', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); // Register extensions to a different extension point @@ -426,6 +437,7 @@ describe('createPluginExtensionsRegistry', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); const registry2 = await reactiveRegistry.getRegistry(); @@ -482,6 +494,7 @@ describe('createPluginExtensionsRegistry', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); expect(subscribeCallback).toHaveBeenCalledTimes(2); @@ -500,6 +513,7 @@ describe('createPluginExtensionsRegistry', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); expect(subscribeCallback).toHaveBeenCalledTimes(3); @@ -553,6 +567,7 @@ describe('createPluginExtensionsRegistry', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); observable.subscribe(subscribeCallback); @@ -597,6 +612,7 @@ describe('createPluginExtensionsRegistry', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); expect(consoleWarn).toHaveBeenCalled(); @@ -657,6 +673,7 @@ describe('createPluginExtensionsRegistry', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); expect(consoleWarn).toHaveBeenCalled(); @@ -687,6 +704,7 @@ describe('createPluginExtensionsRegistry', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); expect(consoleWarn).toHaveBeenCalled(); diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts new file mode 100644 index 00000000000..01799ecce62 --- /dev/null +++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts @@ -0,0 +1,380 @@ +import React from 'react'; +import { firstValueFrom } from 'rxjs'; + +import { AddedComponentsRegistry } from './AddedComponentsRegistry'; + +describe('AddedComponentsRegistry', () => { + const consoleWarn = jest.fn(); + + beforeEach(() => { + global.console.warn = consoleWarn; + consoleWarn.mockReset(); + }); + + it('should return empty registry when no extensions registered', async () => { + const reactiveRegistry = new AddedComponentsRegistry(); + const observable = reactiveRegistry.asObservable(); + const registry = await firstValueFrom(observable); + expect(registry).toEqual({}); + }); + + it('should be possible to register added components in the registry', async () => { + const pluginId = 'grafana-basic-app'; + const id = `${pluginId}/hello-world/v1`; + const reactiveRegistry = new AddedComponentsRegistry(); + + reactiveRegistry.register({ + pluginId, + configs: [ + { + targets: [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][0]).toMatchObject({ + pluginId, + title: 'not important', + description: 'not important', + }); + }); + it('should be possible to asynchronously register component extensions for the same extension point (different plugins)', async () => { + const pluginId1 = 'grafana-basic-app'; + const pluginId2 = 'grafana-basic-app2'; + const reactiveRegistry = new AddedComponentsRegistry(); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: pluginId1, + configs: [ + { + title: 'Component 1 title', + description: 'Component 1 description', + targets: ['grafana/alerting/home'], + component: () => React.createElement('div', null, 'Hello World1'), + }, + ], + }); + + const registry1 = await reactiveRegistry.getState(); + expect(Object.keys(registry1)).toHaveLength(1); + expect(registry1['grafana/alerting/home'][0]).toMatchObject({ + pluginId: pluginId1, + title: 'Component 1 title', + description: 'Component 1 description', + }); + + // Register an extension component for the second plugin to the same extension point + reactiveRegistry.register({ + pluginId: pluginId2, + configs: [ + { + title: 'Component 2 title', + description: 'Component 2 description', + targets: ['grafana/alerting/home'], + component: () => React.createElement('div', null, 'Hello World1'), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + expect(Object.keys(registry2)).toHaveLength(1); + expect(registry2['grafana/alerting/home']).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId: pluginId1, + title: 'Component 1 title', + description: 'Component 1 description', + }), + expect.objectContaining({ + pluginId: pluginId2, + title: 'Component 2 title', + description: 'Component 2 description', + }), + ]) + ); + }); + + it('should be possible to asynchronously register component extensions for a different extension points (different plugin)', async () => { + const pluginId1 = 'grafana-basic-app'; + const pluginId2 = 'grafana-basic-app2'; + const reactiveRegistry = new AddedComponentsRegistry(); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: pluginId1, + configs: [ + { + title: 'Component 1 title', + description: 'Component 1 description', + targets: ['grafana/alerting/home'], + component: () => React.createElement('div', null, 'Hello World1'), + }, + ], + }); + + const registry1 = await reactiveRegistry.getState(); + expect(registry1).toEqual({ + 'grafana/alerting/home': expect.arrayContaining([ + expect.objectContaining({ + pluginId: pluginId1, + title: 'Component 1 title', + description: 'Component 1 description', + }), + ]), + }); + + // Register an extension component for the second plugin to a different extension point + reactiveRegistry.register({ + pluginId: pluginId2, + configs: [ + { + title: 'Component 2 title', + description: 'Component 2 description', + targets: ['grafana/user/profile/tab'], + component: () => React.createElement('div', null, 'Hello World1'), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + + expect(registry2).toEqual({ + 'grafana/alerting/home': expect.arrayContaining([ + expect.objectContaining({ + pluginId: pluginId1, + title: 'Component 1 title', + description: 'Component 1 description', + }), + ]), + 'grafana/user/profile/tab': expect.arrayContaining([ + expect.objectContaining({ + pluginId: pluginId2, + title: 'Component 2 title', + description: 'Component 2 description', + }), + ]), + }); + }); + + it('should be possible to asynchronously register component extensions for the same extension point (same plugin)', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedComponentsRegistry(); + + // Register extensions for the first extension point + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Component 1 title', + description: 'Component 1 description', + targets: ['grafana/alerting/home'], + component: () => React.createElement('div', null, 'Hello World1'), + }, + { + title: 'Component 2 title', + description: 'Component 2 description', + targets: ['grafana/alerting/home'], + component: () => React.createElement('div', null, 'Hello World2'), + }, + ], + }); + const registry1 = await reactiveRegistry.getState(); + expect(registry1).toEqual({ + 'grafana/alerting/home': expect.arrayContaining([ + expect.objectContaining({ + pluginId: pluginId, + title: 'Component 1 title', + description: 'Component 1 description', + }), + expect.objectContaining({ + pluginId: pluginId, + title: 'Component 2 title', + description: 'Component 2 description', + }), + ]), + }); + }); + + it('should be possible to register one extension component targeting multiple extension points', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedComponentsRegistry(); + + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Component 1 title', + description: 'Component 1 description', + targets: ['grafana/alerting/home', 'grafana/user/profile/tab'], + component: () => React.createElement('div', null, 'Hello World1'), + }, + ], + }); + const registry1 = await reactiveRegistry.getState(); + expect(registry1).toEqual({ + 'grafana/alerting/home': expect.arrayContaining([ + expect.objectContaining({ + pluginId: pluginId, + title: 'Component 1 title', + description: 'Component 1 description', + }), + ]), + 'grafana/user/profile/tab': expect.arrayContaining([ + expect.objectContaining({ + pluginId: pluginId, + title: 'Component 1 title', + description: 'Component 1 description', + }), + ]), + }); + }); + + it('should notify subscribers when the registry changes', async () => { + const pluginId1 = 'grafana-basic-app'; + const pluginId2 = 'another-plugin'; + const reactiveRegistry = new AddedComponentsRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + observable.subscribe(subscribeCallback); + + reactiveRegistry.register({ + pluginId: pluginId1, + configs: [ + { + title: 'Component 1 title', + description: 'Component 1 description', + targets: ['grafana/alerting/home'], + component: () => React.createElement('div', null, 'Hello World1'), + }, + ], + }); + + expect(subscribeCallback).toHaveBeenCalledTimes(2); + + reactiveRegistry.register({ + pluginId: pluginId2, + configs: [ + { + title: 'Component 2 title', + description: 'Component 2 description', + targets: ['grafana/user/profile/tab'], + component: () => React.createElement('div', null, 'Hello World2'), + }, + ], + }); + + expect(subscribeCallback).toHaveBeenCalledTimes(3); + + const registry = subscribeCallback.mock.calls[2][0]; + + expect(registry).toEqual({ + 'grafana/alerting/home': expect.arrayContaining([ + expect.objectContaining({ + pluginId: pluginId1, + title: 'Component 1 title', + description: 'Component 1 description', + }), + ]), + 'grafana/user/profile/tab': expect.arrayContaining([ + expect.objectContaining({ + pluginId: pluginId2, + title: 'Component 2 title', + description: 'Component 2 description', + }), + ]), + }); + }); + + it('should skip registering component and log a warning when id is not prefixed with plugin id or grafana', async () => { + const registry = new AddedComponentsRegistry(); + registry.register({ + pluginId: 'grafana-basic-app', + configs: [ + { + title: 'Component 1 title', + description: 'Component 1 description', + targets: ['alerting/home'], + component: () => React.createElement('div', null, 'Hello World1'), + }, + ], + }); + + expect(consoleWarn).toHaveBeenCalledWith( + "[Plugin Extensions] Could not register added component with id 'alerting/home'. Reason: The component id does not match the id naming convention. Id should be prefixed with plugin id or grafana. e.g '/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 AddedComponentsRegistry(); + registry.register({ + pluginId: 'grafana-basic-app', + configs: [ + { + title: 'Component 1 title', + description: 'Component 1 description', + targets: ['grafana/alerting/home'], + component: () => React.createElement('div', null, 'Hello World1'), + }, + ], + }); + + expect(consoleWarn).toHaveBeenCalledWith( + "[Plugin Extensions] Added component with id 'grafana/alerting/home' 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 AddedComponentsRegistry(); + registry.register({ + pluginId: 'grafana-basic-app', + configs: [ + { + title: 'Component 1 title', + description: '', + targets: ['grafana/alerting/home'], + component: () => React.createElement('div', null, 'Hello World1'), + }, + ], + }); + + expect(consoleWarn).toHaveBeenCalledWith( + "[Plugin Extensions] Could not register added component with title 'Component 1 title'. 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 AddedComponentsRegistry(); + registry.register({ + pluginId: 'grafana-basic-app', + configs: [ + { + title: 'Component 1 title', + description: '', + targets: ['grafana/alerting/home'], + component: () => React.createElement('div', null, 'Hello World1'), + }, + ], + }); + + expect(consoleWarn).toHaveBeenCalledWith( + "[Plugin Extensions] Could not register added component with title 'Component 1 title'. Reason: Description is missing." + ); + + const currentState = await registry.getState(); + expect(Object.keys(currentState)).toHaveLength(0); + }); +}); diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts new file mode 100644 index 00000000000..78420c42a17 --- /dev/null +++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts @@ -0,0 +1,78 @@ +import { PluginAddedComponentConfig } from '@grafana/data'; + +import { logWarning, wrapWithPluginContext } from '../utils'; +import { extensionPointEndsWithVersion, isExtensionPointIdValid, isReactComponent } from '../validators'; + +import { PluginExtensionConfigs, Registry, RegistryType } from './Registry'; + +export type AddedComponentRegistryItem = { + pluginId: string; + title: string; + description: string; + component: React.ComponentType; +}; + +export class AddedComponentsRegistry extends Registry { + constructor(initialState: RegistryType = {}) { + super({ + initialState, + }); + } + + mapToRegistry( + registry: RegistryType, + item: PluginExtensionConfigs + ): RegistryType { + const { pluginId, configs } = item; + + for (const config of configs) { + if (!isReactComponent(config.component)) { + logWarning( + `Could not register added component with title '${config.title}'. Reason: The provided component is not a valid React component.` + ); + continue; + } + + if (!config.title) { + logWarning(`Could not register added component with title '${config.title}'. Reason: Title is missing.`); + continue; + } + + if (!config.description) { + logWarning(`Could not register added component with title '${config.title}'. Reason: Description is missing.`); + continue; + } + + const extensionPointIds = Array.isArray(config.targets) ? config.targets : [config.targets]; + for (const extensionPointId of extensionPointIds) { + if (!isExtensionPointIdValid(pluginId, extensionPointId)) { + logWarning( + `Could not register added component with id '${extensionPointId}'. Reason: The component id does not match the id naming convention. Id should be prefixed with plugin id or grafana. e.g '/my-component-id/v1'.` + ); + continue; + } + + if (!extensionPointEndsWithVersion(extensionPointId)) { + logWarning( + `Added component with id '${extensionPointId}' 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 result = { + pluginId, + component: wrapWithPluginContext(pluginId, config.component), + description: config.description, + title: config.title, + }; + + if (!(extensionPointId in registry)) { + registry[extensionPointId] = [result]; + } else { + registry[extensionPointId].push(result); + } + } + } + + return registry; + } +} diff --git a/public/app/features/plugins/extensions/registry/ExportedComponentsRegistry.test.ts b/public/app/features/plugins/extensions/registry/ExportedComponentsRegistry.test.ts index 64e18fe74ab..fd70bb9f79e 100644 --- a/public/app/features/plugins/extensions/registry/ExportedComponentsRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/ExportedComponentsRegistry.test.ts @@ -40,11 +40,9 @@ describe('ExposedComponentsRegistry', () => { expect(Object.keys(registry)).toHaveLength(1); expect(registry[id]).toMatchObject({ pluginId, - config: { - id, - title: 'not important', - description: 'not important', - }, + id, + title: 'not important', + description: 'not important', }); }); @@ -82,9 +80,9 @@ describe('ExposedComponentsRegistry', () => { 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 }); + expect(registry[id1]).toMatchObject({ id: id1, pluginId }); + expect(registry[id2]).toMatchObject({ id: id2, pluginId }); + expect(registry[id3]).toMatchObject({ id: id3, pluginId }); }); it('should be possible to register multiple exposed components from multiple plugins', async () => { @@ -135,10 +133,10 @@ describe('ExposedComponentsRegistry', () => { 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 }); + expect(registry[id1]).toMatchObject({ id: id1, pluginId: pluginId1 }); + expect(registry[id2]).toMatchObject({ id: id2, pluginId: pluginId1 }); + expect(registry[id3]).toMatchObject({ id: id3, pluginId: pluginId2 }); + expect(registry[id4]).toMatchObject({ id: id4, pluginId: pluginId2 }); }); it('should notify subscribers when the registry changes', async () => { @@ -208,11 +206,9 @@ describe('ExposedComponentsRegistry', () => { 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', - }, + id: 'grafana-basic-app/hello-world/v1', + title: 'not important', + description: 'not important', }); }); @@ -234,9 +230,7 @@ describe('ExposedComponentsRegistry', () => { 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', - }, + id: 'grafana-basic-app1/hello-world/v1', }); registry.register({ diff --git a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts index f0036742fab..52b8e97a9c4 100644 --- a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts +++ b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts @@ -1,20 +1,28 @@ import { PluginExposedComponentConfig } from '@grafana/data'; import { logWarning } from '../utils'; +import { extensionPointEndsWithVersion } from '../validators'; import { Registry, RegistryType, PluginExtensionConfigs } from './Registry'; -export class ExposedComponentsRegistry extends Registry { - constructor(initialState: RegistryType = {}) { +export type ExposedComponentRegistryItem = { + pluginId: string; + title: string; + description: string; + component: React.ComponentType; +}; + +export class ExposedComponentsRegistry extends Registry { + constructor(initialState: RegistryType = {}) { super({ initialState, }); } mapToRegistry( - registry: RegistryType, + registry: RegistryType, { pluginId, configs }: PluginExtensionConfigs - ): RegistryType { + ): RegistryType { if (!configs) { return registry; } @@ -29,7 +37,7 @@ export class ExposedComponentsRegistry extends Registry = { configs: T[]; }; -export type RegistryItem = { - pluginId: string; - config: T; -}; - -export type RegistryType = Record>; +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>; +export abstract class Registry { + private resultSubject: Subject>; + private registrySubject: ReplaySubject>; - constructor(options: ConstructorOptions) { + constructor(options: ConstructorOptions) { const { initialState } = options; - this.resultSubject = new Subject>(); + 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.registrySubject = new ReplaySubject>(1); this.resultSubject .pipe( @@ -41,17 +36,20 @@ export abstract class Registry { .subscribe(this.registrySubject); } - abstract mapToRegistry(registry: RegistryType, item: PluginExtensionConfigs): RegistryType; + abstract mapToRegistry( + registry: RegistryType, + item: PluginExtensionConfigs + ): RegistryType; - register(result: PluginExtensionConfigs): void { + register(result: PluginExtensionConfigs): void { this.resultSubject.next(result); } - asObservable(): Observable> { + asObservable(): Observable> { return this.registrySubject.asObservable(); } - getState(): Promise> { + getState(): Promise> { return firstValueFrom(this.asObservable()); } } diff --git a/public/app/features/plugins/extensions/types.ts b/public/app/features/plugins/extensions/types.ts index 9642cc6627d..b5e5d1ebbc8 100644 --- a/public/app/features/plugins/extensions/types.ts +++ b/public/app/features/plugins/extensions/types.ts @@ -1,5 +1,8 @@ import type { PluginExtensionConfig } from '@grafana/data'; +import { AddedComponentRegistryItem } from './registry/AddedComponentsRegistry'; +import { RegistryType } from './registry/Registry'; + // The information that is stored in the registry export type PluginExtensionRegistryItem = { // Any additional meta information that we would like to store about the extension in the registry @@ -13,3 +16,5 @@ export type PluginExtensionRegistry = { id: string; extensions: Record; }; + +export type AddedComponentsRegistryState = RegistryType>>; diff --git a/public/app/features/plugins/extensions/usePluginComponent.tsx b/public/app/features/plugins/extensions/usePluginComponent.tsx index df2689a66ce..269baf3df0f 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.tsx @@ -26,7 +26,7 @@ export function createUsePluginComponent(registry: ExposedComponentsRegistry) { return { isLoading: false, - component: wrapWithPluginContext(registryItem.pluginId, registryItem.config.component), + component: wrapWithPluginContext(registryItem.pluginId, registryItem.component), }; }, [id, registry]); }; diff --git a/public/app/features/plugins/extensions/usePluginComponents.test.tsx b/public/app/features/plugins/extensions/usePluginComponents.test.tsx new file mode 100644 index 00000000000..1e40a1a4b21 --- /dev/null +++ b/public/app/features/plugins/extensions/usePluginComponents.test.tsx @@ -0,0 +1,171 @@ +import { act, render, screen } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; + +import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { createUsePluginComponents } from './usePluginComponents'; + +jest.mock('app/features/plugins/pluginSettings', () => ({ + getPluginSettings: jest.fn().mockResolvedValue({ + id: 'my-app-plugin', + enabled: true, + jsonData: {}, + type: 'panel', + name: 'My App Plugin', + module: 'app/plugins/my-app-plugin/module', + }), +})); + +describe('usePluginComponents()', () => { + let registry: AddedComponentsRegistry; + + beforeEach(() => { + registry = new AddedComponentsRegistry(); + }); + + it('should return an empty array if there are no extensions registered for the extension point', () => { + const usePluginComponents = createUsePluginComponents(registry); + const { result } = renderHook(() => + usePluginComponents({ + extensionPointId: 'foo/bar', + }) + ); + + expect(result.current.components).toEqual([]); + }); + + it('should only return the plugin extension components for the given extension point ids', async () => { + const extensionPointId = 'plugins/foo/bar/v1'; + const pluginId = 'my-app-plugin'; + + registry.register({ + pluginId, + configs: [ + { + targets: extensionPointId, + title: '1', + description: '1', + component: () =>
Hello World1
, + }, + { + targets: extensionPointId, + title: '2', + description: '2', + component: () =>
Hello World2
, + }, + { + targets: 'plugins/another-extension/v1', + title: '3', + description: '3', + component: () =>
Hello World3
, + }, + ], + }); + + const usePluginComponents = createUsePluginComponents(registry); + const { result } = renderHook(() => usePluginComponents({ extensionPointId })); + + expect(result.current.components.length).toBe(2); + + act(() => { + render(result.current.components.map((Component, index) => )); + }); + expect(await screen.findByText('Hello World1')).toBeVisible(); + expect(await screen.findByText('Hello World2')).toBeVisible(); + expect(await screen.queryByText('Hello World3')).toBeNull(); + }); + + it('should dynamically update the extensions registered for a certain extension point', () => { + const extensionPointId = 'plugins/foo/bar/v1'; + const pluginId = 'my-app-plugin'; + const usePluginComponents = createUsePluginComponents(registry); + let { result, rerender } = renderHook(() => usePluginComponents({ extensionPointId })); + + // No extensions yet + expect(result.current.components.length).toBe(0); + + // Add extensions to the registry + act(() => { + registry.register({ + pluginId, + configs: [ + { + targets: extensionPointId, + title: '1', + description: '1', + component: () =>
Hello World1
, + }, + { + targets: extensionPointId, + title: '2', + description: '2', + component: () =>
Hello World2
, + }, + { + targets: 'plugins/another-extension/v1', + title: '3', + description: '3', + component: () =>
Hello World3
, + }, + ], + }); + }); + + // Check if the hook returns the new extensions + rerender(); + + expect(result.current.components.length).toBe(2); + }); + + it('should only render the hook once', () => { + const spy = jest.spyOn(registry, 'asObservable'); + const extensionPointId = 'plugins/foo/bar'; + const usePluginComponents = createUsePluginComponents(registry); + + renderHook(() => usePluginComponents({ extensionPointId })); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should honour the limitPerPlugin arg if its set', () => { + const extensionPointId = 'plugins/foo/bar/v1'; + const plugins = ['my-app-plugin1', 'my-app-plugin2', 'my-app-plugin3']; + const usePluginComponents = createUsePluginComponents(registry); + let { result, rerender } = renderHook(() => usePluginComponents({ extensionPointId, limitPerPlugin: 2 })); + + // No extensions yet + expect(result.current.components.length).toBe(0); + + // Add extensions to the registry + act(() => { + for (let pluginId of plugins) { + registry.register({ + pluginId, + configs: [ + { + targets: extensionPointId, + title: '1', + description: '1', + component: () =>
Hello World1
, + }, + { + targets: extensionPointId, + title: '2', + description: '2', + component: () =>
Hello World2
, + }, + { + targets: extensionPointId, + title: '3', + description: '3', + component: () =>
Hello World3
, + }, + ], + }); + } + }); + + // Check if the hook returns the new extensions + rerender(); + + expect(result.current.components.length).toBe(6); + }); +}); diff --git a/public/app/features/plugins/extensions/usePluginComponents.tsx b/public/app/features/plugins/extensions/usePluginComponents.tsx new file mode 100644 index 00000000000..db3d1c6710c --- /dev/null +++ b/public/app/features/plugins/extensions/usePluginComponents.tsx @@ -0,0 +1,53 @@ +import { useMemo } from 'react'; +import { useObservable } from 'react-use'; + +import { + UsePluginComponentOptions, + UsePluginComponentsResult, +} from '@grafana/runtime/src/services/pluginExtensions/getPluginExtensions'; + +import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; + +// Returns an array of component extensions for the given extension point +export function createUsePluginComponents(registry: AddedComponentsRegistry) { + const observableRegistry = registry.asObservable(); + + return function usePluginComponents({ + limitPerPlugin, + extensionPointId, + }: UsePluginComponentOptions): UsePluginComponentsResult { + const registry = useObservable(observableRegistry); + + return useMemo(() => { + if (!registry || !registry[extensionPointId]) { + return { + isLoading: false, + components: [], + }; + } + const components: Array> = []; + const registryItems = registry[extensionPointId]; + const extensionsByPlugin: Record = {}; + for (const registryItem of registryItems) { + const { pluginId } = registryItem; + + // Only limit if the `limitPerPlugin` is set + if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) { + continue; + } + + if (extensionsByPlugin[pluginId] === undefined) { + extensionsByPlugin[pluginId] = 0; + } + + components.push(registryItem.component as React.ComponentType); + extensionsByPlugin[pluginId] += 1; + } + + return { + isLoading: false, + components, + }; + }, [extensionPointId, limitPerPlugin, registry]); + }; +} diff --git a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx index f6259d2bfeb..886dfecdec8 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx @@ -4,17 +4,20 @@ import { renderHook } from '@testing-library/react-hooks'; import { PluginExtensionTypes } from '@grafana/data'; import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry'; +import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; import { createUsePluginExtensions } from './usePluginExtensions'; describe('usePluginExtensions()', () => { let reactiveRegistry: ReactivePluginExtensionsRegistry; + let addedComponentsRegistry: AddedComponentsRegistry; beforeEach(() => { reactiveRegistry = new ReactivePluginExtensionsRegistry(); + addedComponentsRegistry = new AddedComponentsRegistry(); }); it('should return an empty array if there are no extensions registered for the extension point', () => { - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry); + const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); const { result } = renderHook(() => usePluginExtensions({ extensionPointId: 'foo/bar', @@ -24,7 +27,7 @@ describe('usePluginExtensions()', () => { expect(result.current.extensions).toEqual([]); }); - it('should return the plugin extensions from the registry', () => { + it('should return the plugin link extensions from the registry', () => { const extensionPointId = 'plugins/foo/bar'; const pluginId = 'my-app-plugin'; @@ -47,9 +50,10 @@ describe('usePluginExtensions()', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry); + const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); const { result } = renderHook(() => usePluginExtensions({ extensionPointId })); expect(result.current.extensions.length).toBe(2); @@ -57,10 +61,63 @@ describe('usePluginExtensions()', () => { expect(result.current.extensions[1].title).toBe('2'); }); + it('should return the plugin component extensions from the registry', () => { + const linkExtensionPointId = 'plugins/foo/bar'; + const componentExtensionPointId = 'plugins/component/bar/v1'; + const pluginId = 'my-app-plugin'; + + reactiveRegistry.register({ + pluginId, + extensionConfigs: [ + { + type: PluginExtensionTypes.link, + extensionPointId: linkExtensionPointId, + title: '1', + description: '1', + path: `/a/${pluginId}/2`, + }, + { + type: PluginExtensionTypes.link, + extensionPointId: linkExtensionPointId, + title: '2', + description: '2', + path: `/a/${pluginId}/2`, + }, + ], + exposedComponentConfigs: [], + addedComponentConfigs: [], + }); + + addedComponentsRegistry.register({ + pluginId, + configs: [ + { + targets: componentExtensionPointId, + title: 'Component 1', + description: '1', + component: () =>
Hello World1
, + }, + { + targets: componentExtensionPointId, + title: 'Component 2', + description: '2', + component: () =>
Hello World2
, + }, + ], + }); + + const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); + const { result } = renderHook(() => usePluginExtensions({ extensionPointId: componentExtensionPointId })); + + expect(result.current.extensions.length).toBe(2); + expect(result.current.extensions[0].title).toBe('Component 1'); + expect(result.current.extensions[1].title).toBe('Component 2'); + }); + it('should dynamically update the extensions registered for a certain extension point', () => { const extensionPointId = 'plugins/foo/bar'; const pluginId = 'my-app-plugin'; - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry); + const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); let { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId })); // No extensions yet @@ -87,6 +144,7 @@ describe('usePluginExtensions()', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); }); @@ -101,7 +159,7 @@ describe('usePluginExtensions()', () => { it('should only render the hook once', () => { const spy = jest.spyOn(reactiveRegistry, 'asObservable'); const extensionPointId = 'plugins/foo/bar'; - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry); + const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); renderHook(() => usePluginExtensions({ extensionPointId })); expect(spy).toHaveBeenCalledTimes(1); @@ -110,7 +168,7 @@ describe('usePluginExtensions()', () => { it('should return the same extensions object if the context object is the same', () => { const extensionPointId = 'plugins/foo/bar'; const pluginId = 'my-app-plugin'; - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry); + const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); // Add extensions to the registry act(() => { @@ -133,6 +191,7 @@ describe('usePluginExtensions()', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); }); @@ -146,7 +205,7 @@ describe('usePluginExtensions()', () => { it('should return a new extensions object if the context object is different', () => { const extensionPointId = 'plugins/foo/bar'; const pluginId = 'my-app-plugin'; - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry); + const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); // Add extensions to the registry act(() => { @@ -169,6 +228,7 @@ describe('usePluginExtensions()', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); }); @@ -182,7 +242,7 @@ describe('usePluginExtensions()', () => { const extensionPointId = 'plugins/foo/bar'; const pluginId = 'my-app-plugin'; const context = {}; - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry); + const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); // Add the first extension act(() => { @@ -198,6 +258,7 @@ describe('usePluginExtensions()', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); }); @@ -219,6 +280,7 @@ describe('usePluginExtensions()', () => { }, ], exposedComponentConfigs: [], + addedComponentConfigs: [], }); }); diff --git a/public/app/features/plugins/extensions/usePluginExtensions.tsx b/public/app/features/plugins/extensions/usePluginExtensions.tsx index d02deec66ec..b00d4517265 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.tsx @@ -5,9 +5,14 @@ import { GetPluginExtensionsOptions, UsePluginExtensionsResult } from '@grafana/ import { getPluginExtensions } from './getPluginExtensions'; import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry'; +import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; -export function createUsePluginExtensions(extensionsRegistry: ReactivePluginExtensionsRegistry) { +export function createUsePluginExtensions( + extensionsRegistry: ReactivePluginExtensionsRegistry, + addedComponentsRegistry: AddedComponentsRegistry +) { const observableRegistry = extensionsRegistry.asObservable(); + const observableAddedComponentRegistry = addedComponentsRegistry.asObservable(); const cache: { id: string; extensions: Record; @@ -18,8 +23,9 @@ export function createUsePluginExtensions(extensionsRegistry: ReactivePluginExte return function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult { const registry = useObservable(observableRegistry); + const addedComponentsRegistry = useObservable(observableAddedComponentRegistry); - if (!registry) { + if (!registry || !addedComponentsRegistry) { return { extensions: [], isLoading: false }; } @@ -39,7 +45,11 @@ export function createUsePluginExtensions(extensionsRegistry: ReactivePluginExte }; } - const { extensions } = getPluginExtensions({ ...options, registry }); + const { extensions } = getPluginExtensions({ + ...options, + registry, + addedComponentsRegistry, + }); cache.extensions[key] = { context: options.context, diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index 48d690f0a02..fba4871185c 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -5,7 +5,6 @@ import { useAsync } from 'react-use'; import { type PluginExtensionLinkConfig, - type PluginExtensionComponentConfig, type PluginExtensionConfig, type PluginExtensionEventHelpers, PluginExtensionTypes, @@ -31,12 +30,6 @@ export function isPluginExtensionLinkConfig( return typeof extension === 'object' && 'type' in extension && extension['type'] === PluginExtensionTypes.link; } -export function isPluginExtensionComponentConfig( - extension: PluginExtensionConfig | undefined | PluginExtensionComponentConfig -): extension is PluginExtensionComponentConfig { - return typeof extension === 'object' && 'type' in extension && extension['type'] === PluginExtensionTypes.component; -} - export function handleErrorsInFn(fn: Function, errorMessagePrefix = '') { return (...args: unknown[]) => { try { diff --git a/public/app/features/plugins/extensions/validators.ts b/public/app/features/plugins/extensions/validators.ts index d9c0a45ebe7..1584992b670 100644 --- a/public/app/features/plugins/extensions/validators.ts +++ b/public/app/features/plugins/extensions/validators.ts @@ -6,7 +6,7 @@ import type { } from '@grafana/data'; import { isPluginExtensionLink } from '@grafana/runtime'; -import { isPluginExtensionComponentConfig, isPluginExtensionLinkConfig, logWarning } from './utils'; +import { isPluginExtensionLinkConfig, logWarning } from './utils'; export function assertPluginExtensionLink( extension: PluginExtension | undefined, @@ -41,7 +41,7 @@ export function assertIsReactComponent(component: React.ComponentType) { } export function assertExtensionPointIdIsValid(pluginId: string, extension: PluginExtensionConfig) { - if (!isExtensionPointIdValid(pluginId, extension)) { + if (!isExtensionPointIdValid(pluginId, extension.extensionPointId)) { throw new Error( `Invalid extension "${extension.title}". The extensionPointId should start with either "grafana/", "plugins/" or "capabilities/${pluginId}" (currently: "${extension.extensionPointId}"). Skipping the extension.` ); @@ -76,14 +76,18 @@ export function isLinkPathValid(pluginId: string, path: string) { return Boolean(typeof path === 'string' && path.length > 0 && path.startsWith(`/a/${pluginId}/`)); } -export function isExtensionPointIdValid(pluginId: string, extension: PluginExtensionConfig) { +export function isExtensionPointIdValid(pluginId: string, extensionPointId: string) { return Boolean( - extension.extensionPointId?.startsWith('grafana/') || - extension.extensionPointId?.startsWith('plugins/') || - extension.extensionPointId?.startsWith(`capabilities/${pluginId}/`) + extensionPointId.startsWith('grafana/') || + extensionPointId?.startsWith('plugins/') || + extensionPointId?.startsWith(pluginId) ); } +export function extensionPointEndsWithVersion(extensionPointId: string) { + return extensionPointId.match(/.*\/v\d+$/); +} + export function isConfigureFnValid(extension: PluginExtensionLinkConfig) { return extension.configure ? typeof extension.configure === 'function' : true; } @@ -111,11 +115,6 @@ export function isPluginExtensionConfigValid(pluginId: string, extension: Plugin } } - // Component - if (isPluginExtensionComponentConfig(extension)) { - assertIsReactComponent(extension.component); - } - return true; } catch (error) { if (error instanceof Error) { diff --git a/public/app/features/plugins/pluginPreloader.ts b/public/app/features/plugins/pluginPreloader.ts index 2fd705bdd4e..7fa1c536896 100644 --- a/public/app/features/plugins/pluginPreloader.ts +++ b/public/app/features/plugins/pluginPreloader.ts @@ -1,9 +1,11 @@ import type { PluginExposedComponentConfig, PluginExtensionConfig } from '@grafana/data'; +import { PluginAddedComponentConfig } from '@grafana/data/src/types/pluginExtensions'; 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 { AddedComponentsRegistry } from './extensions/registry/AddedComponentsRegistry'; import { ExposedComponentsRegistry } from './extensions/registry/ExposedComponentsRegistry'; import * as pluginLoader from './plugin_loader'; @@ -12,12 +14,18 @@ export type PluginPreloadResult = { error?: unknown; extensionConfigs: PluginExtensionConfig[]; exposedComponentConfigs: PluginExposedComponentConfig[]; + addedComponentConfigs: PluginAddedComponentConfig[]; +}; + +type PluginExtensionRegistries = { + extensionsRegistry: ReactivePluginExtensionsRegistry; + addedComponentsRegistry: AddedComponentsRegistry; + exposedComponentsRegistry: ExposedComponentsRegistry; }; export async function preloadPlugins( apps: AppPluginConfig[] = [], - registry: ReactivePluginExtensionsRegistry, - exposedComponentsRegistry: ExposedComponentsRegistry, + registries: PluginExtensionRegistries, eventName = 'frontend_plugins_preload' ) { startMeasure(eventName); @@ -30,12 +38,15 @@ export async function preloadPlugins( continue; } - registry.register(preloadedPlugin); - - exposedComponentsRegistry.register({ + registries.extensionsRegistry.register(preloadedPlugin); + registries.exposedComponentsRegistry.register({ pluginId: preloadedPlugin.pluginId, configs: preloadedPlugin.exposedComponentConfigs, }); + registries.addedComponentsRegistry.register({ + pluginId: preloadedPlugin.pluginId, + configs: preloadedPlugin.addedComponentConfigs, + }); } stopMeasure(eventName); @@ -51,16 +62,16 @@ async function preload(config: AppPluginConfig): Promise { isAngular: config.angular.detected, pluginId, }); - const { extensionConfigs = [], exposedComponentConfigs = [] } = plugin; + const { extensionConfigs = [], exposedComponentConfigs = [], addedComponentConfigs = [] } = 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, exposedComponentConfigs }; + return { pluginId, extensionConfigs, exposedComponentConfigs, addedComponentConfigs }; } catch (error) { console.error(`[Plugins] Failed to preload plugin: ${path} (version: ${version})`, error); - return { pluginId, extensionConfigs: [], error, exposedComponentConfigs: [] }; + return { pluginId, extensionConfigs: [], error, exposedComponentConfigs: [], addedComponentConfigs: [] }; } finally { stopMeasure(`frontend_plugin_preload_${pluginId}`); }