Plugin extensions: Introduce new registry for added components (#91877)

* add added component registry

* fix broken test

* add tests for usePluginComponents hook

* readd expose components

* add type assertion exceptions to betterer results

* use new addedComponent registry in legacy endpoints

* remove unused code

* cleanup

* revert test code

* remove commented code

* wrap in try catch

* pr feedback
This commit is contained in:
Erik Sundell 2024-08-27 11:14:04 +02:00 committed by GitHub
parent 419edef4dc
commit b648ce3acf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1171 additions and 205 deletions

View File

@ -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"]
],

View File

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

View File

@ -9,6 +9,7 @@ import {
PluginExtensionComponentConfig,
PluginExposedComponentConfig,
PluginExtensionConfig,
PluginAddedComponentConfig,
} from './pluginExtensions';
/**
@ -58,6 +59,7 @@ export interface AppPluginMeta<T extends KeyValue = KeyValue> extends PluginMeta
export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppPluginMeta<T>> {
private _exposedComponentConfigs: PluginExposedComponentConfig[] = [];
private _addedComponentConfigs: PluginAddedComponentConfig[] = [];
private _extensionConfigs: PluginExtensionConfig[] = [];
// Content under: /a/${plugin-id}/*
@ -104,6 +106,10 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
return this._exposedComponentConfigs;
}
get addedComponentConfigs() {
return this._addedComponentConfigs;
}
get extensionConfigs() {
return this._extensionConfigs;
}
@ -128,22 +134,8 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
return this;
}
addComponent<Props = {}>(
extensionConfig: { targets: string | string[] } & Omit<
PluginExtensionComponentConfig<Props>,
'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<Props = {}>(addedComponentConfig: PluginAddedComponentConfig<Props>) {
this._addedComponentConfigs.push(addedComponentConfig as PluginAddedComponentConfig);
return this;
}
@ -168,6 +160,7 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
this.addComponent({
targets: [extension.extensionPointId],
...extension,
component: extension.component as ComponentType,
});
return this;

View File

@ -95,6 +95,28 @@ export type PluginExtensionComponentConfig<Props = {}> = {
extensionPointId: string;
};
export type PluginAddedComponentConfig<Props = {}> = {
/**
* 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<Props>;
};
export type PluginExposedComponentConfig<Props = {}> = {
/**
* The unique identifier of the component

View File

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

View File

@ -16,6 +16,11 @@ export type GetPluginExtensionsOptions = {
limitPerPlugin?: number;
};
export type UsePluginComponentOptions = {
extensionPointId: string;
limitPerPlugin?: number;
};
export type GetPluginExtensionsResult<T = PluginExtension> = {
extensions: T[];
};
@ -30,6 +35,11 @@ export type UsePluginComponentResult<Props = {}> = {
isLoading: boolean;
};
export type UsePluginComponentsResult<Props = {}> = {
components: Array<React.ComponentType<Props>>;
isLoading: boolean;
};
let singleton: GetPluginExtensions | undefined;
export function setPluginExtensionGetter(instance: GetPluginExtensions): void {

View File

@ -0,0 +1,24 @@
import { GetPluginExtensionsOptions, UsePluginComponentsResult } from './getPluginExtensions';
export type UsePluginComponents<Props extends object = {}> = (
options: GetPluginExtensionsOptions
) => UsePluginComponentsResult<Props>;
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<Props extends object = {}>(
options: GetPluginExtensionsOptions
): UsePluginComponentsResult<Props> {
if (!singleton) {
throw new Error('setPluginComponentsHook(options) can only be used after the Grafana instance has started.');
}
return singleton(options) as UsePluginComponentsResult<Props>;
}

View File

@ -39,22 +39,6 @@ export function usePluginLinks(options: GetPluginExtensionsOptions): {
}, [extensions, isLoading]);
}
export function usePluginComponents<Props = {}>(
options: GetPluginExtensionsOptions
): { components: Array<React.ComponentType<Props>>; isLoading: boolean } {
const { extensions, isLoading } = usePluginExtensions(options);
return useMemo(
() => ({
components: extensions
.filter(isPluginExtensionComponent)
.map(({ component }) => component as React.ComponentType<Props>),
isLoading,
}),
[extensions, isLoading]
);
}
/**
* @deprecated Use usePluginLinks() instead.
*/

View File

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

View File

@ -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 <div>Hello world2!</div>;
},
},
],
},
]);
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,
})
);
});

View File

@ -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<string | symbol, unknown>;
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) {

View File

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

View File

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

View File

@ -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<Props = {}> = {
pluginId: string;
title: string;
description: string;
component: React.ComponentType<Props>;
};
export class AddedComponentsRegistry extends Registry<AddedComponentRegistryItem[], PluginAddedComponentConfig> {
constructor(initialState: RegistryType<AddedComponentRegistryItem[]> = {}) {
super({
initialState,
});
}
mapToRegistry(
registry: RegistryType<AddedComponentRegistryItem[]>,
item: PluginExtensionConfigs<PluginAddedComponentConfig>
): RegistryType<AddedComponentRegistryItem[]> {
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 '<grafana|myorg-basic-app>/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;
}
}

View File

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

View File

@ -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<PluginExposedComponentConfig> {
constructor(initialState: RegistryType<PluginExposedComponentConfig> = {}) {
export type ExposedComponentRegistryItem<Props = {}> = {
pluginId: string;
title: string;
description: string;
component: React.ComponentType<Props>;
};
export class ExposedComponentsRegistry extends Registry<ExposedComponentRegistryItem, PluginExposedComponentConfig> {
constructor(initialState: RegistryType<ExposedComponentRegistryItem> = {}) {
super({
initialState,
});
}
mapToRegistry(
registry: RegistryType<PluginExposedComponentConfig>,
registry: RegistryType<ExposedComponentRegistryItem>,
{ pluginId, configs }: PluginExtensionConfigs<PluginExposedComponentConfig>
): RegistryType<PluginExposedComponentConfig> {
): RegistryType<ExposedComponentRegistryItem> {
if (!configs) {
return registry;
}
@ -29,7 +37,7 @@ export class ExposedComponentsRegistry extends Registry<PluginExposedComponentCo
continue;
}
if (!id.match(/.*\/v\d+$/)) {
if (!extensionPointEndsWithVersion(id)) {
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'.`
);
@ -52,7 +60,7 @@ export class ExposedComponentsRegistry extends Registry<PluginExposedComponentCo
continue;
}
registry[id] = { config, pluginId };
registry[id] = { ...config, pluginId };
}
return registry;

View File

@ -7,28 +7,23 @@ export type PluginExtensionConfigs<T> = {
configs: T[];
};
export type RegistryItem<T> = {
pluginId: string;
config: T;
};
export type RegistryType<T> = Record<string | symbol, RegistryItem<T>>;
export type RegistryType<T> = Record<string | symbol, T>;
type ConstructorOptions<T> = {
initialState: RegistryType<T>;
};
// This is the base-class used by the separate specific registries.
export abstract class Registry<T> {
private resultSubject: Subject<PluginExtensionConfigs<T>>;
private registrySubject: ReplaySubject<RegistryType<T>>;
export abstract class Registry<TRegistryValue, TMapType> {
private resultSubject: Subject<PluginExtensionConfigs<TMapType>>;
private registrySubject: ReplaySubject<RegistryType<TRegistryValue>>;
constructor(options: ConstructorOptions<T>) {
constructor(options: ConstructorOptions<TRegistryValue>) {
const { initialState } = options;
this.resultSubject = new Subject<PluginExtensionConfigs<T>>();
this.resultSubject = new Subject<PluginExtensionConfigs<TMapType>>();
// This is the subject that we expose.
// (It will buffer the last value on the stream - the registry - and emit it to new subscribers immediately.)
this.registrySubject = new ReplaySubject<RegistryType<T>>(1);
this.registrySubject = new ReplaySubject<RegistryType<TRegistryValue>>(1);
this.resultSubject
.pipe(
@ -41,17 +36,20 @@ export abstract class Registry<T> {
.subscribe(this.registrySubject);
}
abstract mapToRegistry(registry: RegistryType<T>, item: PluginExtensionConfigs<T>): RegistryType<T>;
abstract mapToRegistry(
registry: RegistryType<TRegistryValue>,
item: PluginExtensionConfigs<TMapType>
): RegistryType<TRegistryValue>;
register(result: PluginExtensionConfigs<T>): void {
register(result: PluginExtensionConfigs<TMapType>): void {
this.resultSubject.next(result);
}
asObservable(): Observable<RegistryType<T>> {
asObservable(): Observable<RegistryType<TRegistryValue>> {
return this.registrySubject.asObservable();
}
getState(): Promise<RegistryType<T>> {
getState(): Promise<RegistryType<TRegistryValue>> {
return firstValueFrom(this.asObservable());
}
}

View File

@ -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<string, PluginExtensionRegistryItem[]>;
};
export type AddedComponentsRegistryState = RegistryType<Array<AddedComponentRegistryItem<{}>>>;

View File

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

View File

@ -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: () => <div>Hello World1</div>,
},
{
targets: extensionPointId,
title: '2',
description: '2',
component: () => <div>Hello World2</div>,
},
{
targets: 'plugins/another-extension/v1',
title: '3',
description: '3',
component: () => <div>Hello World3</div>,
},
],
});
const usePluginComponents = createUsePluginComponents(registry);
const { result } = renderHook(() => usePluginComponents({ extensionPointId }));
expect(result.current.components.length).toBe(2);
act(() => {
render(result.current.components.map((Component, index) => <Component key={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: () => <div>Hello World1</div>,
},
{
targets: extensionPointId,
title: '2',
description: '2',
component: () => <div>Hello World2</div>,
},
{
targets: 'plugins/another-extension/v1',
title: '3',
description: '3',
component: () => <div>Hello World3</div>,
},
],
});
});
// 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: () => <div>Hello World1</div>,
},
{
targets: extensionPointId,
title: '2',
description: '2',
component: () => <div>Hello World2</div>,
},
{
targets: extensionPointId,
title: '3',
description: '3',
component: () => <div>Hello World3</div>,
},
],
});
}
});
// Check if the hook returns the new extensions
rerender();
expect(result.current.components.length).toBe(6);
});
});

View File

@ -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<Props extends object = {}>({
limitPerPlugin,
extensionPointId,
}: UsePluginComponentOptions): UsePluginComponentsResult<Props> {
const registry = useObservable(observableRegistry);
return useMemo(() => {
if (!registry || !registry[extensionPointId]) {
return {
isLoading: false,
components: [],
};
}
const components: Array<React.ComponentType<Props>> = [];
const registryItems = registry[extensionPointId];
const extensionsByPlugin: Record<string, number> = {};
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<Props>);
extensionsByPlugin[pluginId] += 1;
}
return {
isLoading: false,
components,
};
}, [extensionPointId, limitPerPlugin, registry]);
};
}

View File

@ -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: () => <div>Hello World1</div>,
},
{
targets: componentExtensionPointId,
title: 'Component 2',
description: '2',
component: () => <div>Hello World2</div>,
},
],
});
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: [],
});
});

View File

@ -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<string, { context: GetPluginExtensionsOptions['context']; extensions: PluginExtension[] }>;
@ -18,8 +23,9 @@ export function createUsePluginExtensions(extensionsRegistry: ReactivePluginExte
return function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult<PluginExtension> {
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,

View File

@ -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<Props extends object>(
extension: PluginExtensionConfig | undefined | PluginExtensionComponentConfig<Props>
): extension is PluginExtensionComponentConfig<Props> {
return typeof extension === 'object' && 'type' in extension && extension['type'] === PluginExtensionTypes.component;
}
export function handleErrorsInFn(fn: Function, errorMessagePrefix = '') {
return (...args: unknown[]) => {
try {

View File

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

View File

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