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
26 changed files with 1171 additions and 205 deletions

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