PluginExtensions: Make the extensions registry reactive (#83085)

* feat: add a reactive extension registry

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>

* feat: add hooks to work with the reactive registry

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>

* feat: start using the reactive registry

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>

* feat: update the "command palette" extension point to use the hook

* feat: update the "alerting" extension point to use the hooks

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>

* feat: update the "explore" extension point to use the hooks

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>

* feat: update the "datasources config" extension point to use the hooks

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>

* feat: update the "panel menu" extension point to use the hooks

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>

* feat: update the "pyroscope datasource" extension point to use the hooks

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>

* feat: update the "user profile page" extension point to use the hooks

* chore: update betterer

* fix: update the hooks to not re-render unnecessarily

* chore: remove the old `createPluginExtensionRegistry` impementation

* chore: add "TODO" for `PanelMenuBehaviour` extension point

* feat: update the return value of the hooks to contain a `{ isLoading }` param

* tests: add more tests for the usePluginExtensions() hook

* fix: exclude the cloud-home-app from being non-awaited

* refactor: use uuidv4() for random ID generation (for the registry object)

* fix: linting issue

* feat: use the hooks for the new alerting extension point

* feat: use `useMemo()` for `AlertInstanceAction` extension point context

---------

Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>
This commit is contained in:
Marcus Andersson
2024-04-24 09:33:16 +02:00
committed by GitHub
parent d48b5ea44d
commit 804c726413
44 changed files with 1768 additions and 736 deletions

View File

@@ -1,140 +0,0 @@
import { PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data';
import { createPluginExtensionRegistry } from './createPluginExtensionRegistry';
describe('createRegistry()', () => {
const placement1 = 'grafana/dashboard/panel/menu';
const placement2 = 'plugins/myorg-basic-app/start';
const pluginId = 'grafana-basic-app';
let link1: PluginExtensionLinkConfig, link2: PluginExtensionLinkConfig;
beforeEach(() => {
link1 = {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: placement1,
configure: jest.fn().mockReturnValue({}),
};
link2 = {
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: placement2,
configure: jest.fn().mockImplementation((context) => ({ title: context?.title })),
};
global.console.warn = jest.fn();
});
it('should be possible to register extensions', () => {
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]);
expect(Object.getOwnPropertyNames(registry)).toEqual([placement1, placement2]);
// Placement 1
expect(registry[placement1]).toHaveLength(1);
expect(registry[placement1]).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId,
config: {
...link1,
configure: expect.any(Function),
},
}),
])
);
// Placement 2
expect(registry[placement2]).toHaveLength(1);
expect(registry[placement2]).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId,
config: {
...link2,
configure: expect.any(Function),
},
}),
])
);
});
it('should not register link extensions with invalid path configured', () => {
const registry = createPluginExtensionRegistry([
{ pluginId, extensionConfigs: [{ ...link1, path: 'invalid-path' }, link2] },
]);
expect(Object.getOwnPropertyNames(registry)).toEqual([placement2]);
// Placement 2
expect(registry[placement2]).toHaveLength(1);
expect(registry[placement2]).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId,
config: {
...link2,
configure: expect.any(Function),
},
}),
])
);
});
it('should not register extensions for a plugin that had errors', () => {
const registry = createPluginExtensionRegistry([
{ pluginId, extensionConfigs: [link1, link2], error: new Error('Plugin failed to load') },
]);
expect(Object.getOwnPropertyNames(registry)).toEqual([]);
});
it('should not register an extension if it has an invalid configure() function', () => {
const registry = createPluginExtensionRegistry([
// @ts-ignore (We would like to provide an invalid configure function on purpose)
{ pluginId, extensionConfigs: [{ ...link1, configure: '...' }, link2] },
]);
expect(Object.getOwnPropertyNames(registry)).toEqual([placement2]);
// Placement 2 (checking if it still registers the extension with a valid configuration)
expect(registry[placement2]).toHaveLength(1);
expect(registry[placement2]).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId,
config: {
...link2,
configure: expect.any(Function),
},
}),
])
);
});
it('should not register an extension if it has invalid properties (empty title / description)', () => {
const registry = createPluginExtensionRegistry([
{ pluginId, extensionConfigs: [{ ...link1, title: '', description: '' }, link2] },
]);
expect(Object.getOwnPropertyNames(registry)).toEqual([placement2]);
// Placement 2 (checking if it still registers the extension with a valid configuration)
expect(registry[placement2]).toHaveLength(1);
expect(registry[placement2]).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId,
config: {
...link2,
configure: expect.any(Function),
},
}),
])
);
});
});

View File

@@ -1,39 +0,0 @@
import type { PluginPreloadResult } from '../pluginPreloader';
import type { PluginExtensionRegistryItem, PluginExtensionRegistry } from './types';
import { deepFreeze, logWarning } from './utils';
import { isPluginExtensionConfigValid } from './validators';
export function createPluginExtensionRegistry(pluginPreloadResults: PluginPreloadResult[]): PluginExtensionRegistry {
const registry: PluginExtensionRegistry = {};
for (const { pluginId, extensionConfigs, error } of pluginPreloadResults) {
if (error) {
logWarning(`"${pluginId}" plugin failed to load, skip registering its extensions.`);
continue;
}
for (const extensionConfig of extensionConfigs) {
const { extensionPointId } = extensionConfig;
if (!extensionConfig || !isPluginExtensionConfigValid(pluginId, extensionConfig)) {
continue;
}
let registryItem: PluginExtensionRegistryItem = {
config: extensionConfig,
// Additional meta information about the extension
pluginId,
};
if (!Array.isArray(registry[extensionPointId])) {
registry[extensionPointId] = [registryItem];
} else {
registry[extensionPointId].push(registryItem);
}
}
}
return deepFreeze(registry);
}

View File

@@ -3,8 +3,8 @@ import React from 'react';
import { PluginExtensionComponentConfig, PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { createPluginExtensionRegistry } from './createPluginExtensionRegistry';
import { getPluginExtensions } from './getPluginExtensions';
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
import { isReadOnlyProxy } from './utils';
import { assertPluginExtensionLink } from './validators';
@@ -15,6 +15,19 @@ jest.mock('@grafana/runtime', () => {
};
});
function createPluginExtensionRegistry(preloadResults: Array<{ pluginId: string; extensionConfigs: any[] }>) {
const registry = new ReactivePluginExtensionsRegistry();
for (const { pluginId, extensionConfigs } of preloadResults) {
registry.register({
pluginId,
extensionConfigs,
});
}
return registry.getRegistry();
}
describe('getPluginExtensions()', () => {
const extensionPoint1 = 'grafana/dashboard/panel/menu';
const extensionPoint2 = 'plugins/myorg-basic-app/start';
@@ -54,8 +67,8 @@ describe('getPluginExtensions()', () => {
jest.mocked(reportInteraction).mockReset();
});
test('should return the extensions for the given placement', () => {
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]);
test('should return the extensions for the given placement', async () => {
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint1 });
expect(extensions).toHaveLength(1);
@@ -70,9 +83,11 @@ describe('getPluginExtensions()', () => {
);
});
test('should not limit the number of extensions per plugin by default', () => {
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 = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link1, link1, link2] }]);
const registry = await createPluginExtensionRegistry([
{ pluginId, extensionConfigs: [link1, link1, link1, link2] },
]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint1 });
expect(extensions).toHaveLength(3);
@@ -87,8 +102,8 @@ describe('getPluginExtensions()', () => {
);
});
test('should be possible to limit the number of extensions per plugin for a given placement', () => {
const registry = createPluginExtensionRegistry([
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] },
{
pluginId: 'my-plugin',
@@ -116,16 +131,16 @@ describe('getPluginExtensions()', () => {
);
});
test('should return with an empty list if there are no extensions registered for a placement yet', () => {
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]);
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' });
expect(extensions).toEqual([]);
});
test('should pass the context to the configure() function', () => {
test('should pass the context to the configure() function', async () => {
const context = { title: 'New title from the context!' };
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 });
@@ -133,7 +148,7 @@ describe('getPluginExtensions()', () => {
expect(link2.configure).toHaveBeenCalledWith(context);
});
test('should be possible to update the basic properties with the configure() function', () => {
test('should be possible to update the basic properties with the configure() function', async () => {
link2.configure = jest.fn().mockImplementation(() => ({
title: 'Updated title',
description: 'Updated description',
@@ -142,7 +157,7 @@ describe('getPluginExtensions()', () => {
category: 'Machine Learning',
}));
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
const [extension] = extensions;
@@ -156,7 +171,7 @@ describe('getPluginExtensions()', () => {
expect(extension.category).toBe('Machine Learning');
});
test('should append link tracking to path when running configure() function', () => {
test('should append link tracking to path when running configure() function', async () => {
link2.configure = jest.fn().mockImplementation(() => ({
title: 'Updated title',
description: 'Updated description',
@@ -165,7 +180,7 @@ describe('getPluginExtensions()', () => {
category: 'Machine Learning',
}));
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
const [extension] = extensions;
@@ -177,7 +192,7 @@ describe('getPluginExtensions()', () => {
);
});
test('should ignore restricted properties passed via the configure() function', () => {
test('should ignore restricted properties passed via the configure() function', async () => {
link2.configure = jest.fn().mockImplementation(() => ({
// The following props are not allowed to override
type: 'unknown-type',
@@ -190,7 +205,7 @@ describe('getPluginExtensions()', () => {
title: 'test',
}));
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
const [extension] = extensions;
@@ -202,9 +217,9 @@ describe('getPluginExtensions()', () => {
//@ts-ignore
expect(extension.testing).toBeUndefined();
});
test('should pass a read only context to the configure() function', () => {
test('should pass a read only context to the configure() function', async () => {
const context = { title: 'New title from the context!' };
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 });
const [extension] = extensions;
const readOnlyContext = (link2.configure as jest.Mock).mock.calls[0][0];
@@ -219,12 +234,12 @@ describe('getPluginExtensions()', () => {
expect(context.title).toBe('New title from the context!');
});
test('should catch errors in the configure() function and log them as warnings', () => {
test('should catch errors in the configure() function and log them as warnings', async () => {
link2.configure = jest.fn().mockImplementation(() => {
throw new Error('Something went wrong!');
});
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
expect(() => {
getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
@@ -235,7 +250,7 @@ describe('getPluginExtensions()', () => {
expect(global.console.warn).toHaveBeenCalledWith('[Plugin Extensions] Something went wrong!');
});
test('should skip the link extension if the configure() function returns with an invalid path', () => {
test('should skip the link extension if the configure() function returns with an invalid path', async () => {
link1.configure = jest.fn().mockImplementation(() => ({
path: '/a/another-plugin/page-a',
}));
@@ -243,7 +258,7 @@ describe('getPluginExtensions()', () => {
path: 'invalid-path',
}));
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]);
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]);
const { extensions: extensionsAtPlacement1 } = getPluginExtensions({ registry, extensionPointId: extensionPoint1 });
const { extensions: extensionsAtPlacement2 } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
@@ -255,7 +270,7 @@ describe('getPluginExtensions()', () => {
expect(global.console.warn).toHaveBeenCalledTimes(2);
});
test('should skip the extension if any of the updated props returned by the configure() function are invalid', () => {
test('should skip the extension if any of the updated props returned by the configure() function are invalid', async () => {
const overrides = {
title: '', // Invalid empty string for title - should be ignored
description: 'A valid description.', // This should be updated
@@ -263,7 +278,7 @@ describe('getPluginExtensions()', () => {
link2.configure = jest.fn().mockImplementation(() => overrides);
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
expect(extensions).toHaveLength(0);
@@ -271,10 +286,10 @@ describe('getPluginExtensions()', () => {
expect(global.console.warn).toHaveBeenCalledTimes(1);
});
test('should skip the extension if the configure() function returns a promise', () => {
test('should skip the extension if the configure() function returns a promise', async () => {
link2.configure = jest.fn().mockImplementation(() => Promise.resolve({}));
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
expect(extensions).toHaveLength(0);
@@ -282,24 +297,24 @@ describe('getPluginExtensions()', () => {
expect(global.console.warn).toHaveBeenCalledTimes(1);
});
test('should skip (hide) the extension if the configure() function returns undefined', () => {
test('should skip (hide) the extension if the configure() function returns undefined', async () => {
link2.configure = jest.fn().mockImplementation(() => undefined);
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
expect(extensions).toHaveLength(0);
expect(global.console.warn).toHaveBeenCalledTimes(0); // As this is intentional, no warning should be logged
});
test('should pass event, context and helper to extension onClick()', () => {
test('should pass event, context and helper to extension onClick()', async () => {
link2.path = undefined;
link2.onClick = jest.fn().mockImplementation(() => {
throw new Error('Something went wrong!');
});
const context = {};
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
const [extension] = extensions;
@@ -322,7 +337,7 @@ describe('getPluginExtensions()', () => {
link2.path = undefined;
link2.onClick = jest.fn().mockRejectedValue(new Error('testing'));
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
const [extension] = extensions;
@@ -335,13 +350,13 @@ describe('getPluginExtensions()', () => {
expect(global.console.warn).toHaveBeenCalledTimes(1);
});
test('should catch errors in the onClick() function and log them as warnings', () => {
test('should catch errors in the onClick() function and log them as warnings', async () => {
link2.path = undefined;
link2.onClick = jest.fn().mockImplementation(() => {
throw new Error('Something went wrong!');
});
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
const [extension] = extensions;
@@ -353,13 +368,13 @@ describe('getPluginExtensions()', () => {
expect(global.console.warn).toHaveBeenCalledWith('[Plugin Extensions] Something went wrong!');
});
test('should pass a read only context to the onClick() function', () => {
test('should pass a read only context to the onClick() function', async () => {
const context = { title: 'New title from the context!' };
link2.path = undefined;
link2.onClick = jest.fn();
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 });
const [extension] = extensions;
@@ -375,14 +390,14 @@ describe('getPluginExtensions()', () => {
}).toThrow();
});
test('should not make original context read only', () => {
test('should not make original context read only', async () => {
const context = {
title: 'New title from the context!',
nested: { title: 'title' },
array: ['a'],
};
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 });
expect(() => {
@@ -392,10 +407,10 @@ describe('getPluginExtensions()', () => {
}).not.toThrow();
});
test('should report interaction when onClick is triggered', () => {
test('should report interaction when onClick is triggered', async () => {
const reportInteractionMock = jest.mocked(reportInteraction);
const registry = createPluginExtensionRegistry([
const registry = await createPluginExtensionRegistry([
{
pluginId,
extensionConfigs: [
@@ -423,9 +438,9 @@ describe('getPluginExtensions()', () => {
});
});
test('should be possible to register and get component type extensions', () => {
test('should be possible to register and get component type extensions', async () => {
const extension = component1;
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [extension] }]);
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [extension] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extension.extensionPointId });
expect(extensions).toHaveLength(1);

View File

@@ -8,8 +8,9 @@ import {
type PluginExtensionComponent,
urlUtil,
} from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { GetPluginExtensions, reportInteraction } from '@grafana/runtime';
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
import type { PluginExtensionRegistry } from './types';
import {
isPluginExtensionLinkConfig,
@@ -40,10 +41,22 @@ type GetExtensions = ({
registry: PluginExtensionRegistry;
}) => { extensions: PluginExtension[] };
let registry: PluginExtensionRegistry = { id: '', extensions: {} };
export function createPluginExtensionsGetter(extensionRegistry: ReactivePluginExtensionsRegistry): GetPluginExtensions {
// Create a subscription to keep an copy of the registry state for use in the non-async
// plugin extensions getter.
extensionRegistry.asObservable().subscribe((r) => {
registry = r;
});
return (options) => getPluginExtensions({ ...options, registry });
}
// Returns with a list of plugin extensions for the given extension point
export const getPluginExtensions: GetExtensions = ({ context, extensionPointId, limitPerPlugin, registry }) => {
const frozenContext = context ? getReadOnlyProxy(context) : {};
const registryItems = registry[extensionPointId] ?? [];
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.
const extensions: PluginExtension[] = [];
const extensionsByPlugin: Record<string, number> = {};

View File

@@ -0,0 +1,682 @@
import { firstValueFrom } from 'rxjs';
import { PluginExtensionTypes } from '@grafana/data';
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
describe('createPluginExtensionsRegistry', () => {
const consoleWarn = jest.fn();
beforeEach(() => {
global.console.warn = consoleWarn;
consoleWarn.mockReset();
});
it('should return empty registry when no extensions registered', async () => {
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
const observable = reactiveRegistry.asObservable();
const registry = await firstValueFrom(observable);
expect(registry).toEqual({
id: '',
extensions: {},
});
});
it('should generate an id for the registry once we register an extension to it', async () => {
const pluginId = 'grafana-basic-app';
const extensionPointId = 'grafana/dashboard/panel/menu';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
reactiveRegistry.register({
pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId,
configure: jest.fn().mockReturnValue({}),
},
],
});
const registry = await reactiveRegistry.getRegistry();
expect(registry.id).toBeDefined();
expect(registry.extensions[extensionPointId]).toHaveLength(1);
});
it('should generate an a new id every time the registry changes', async () => {
const pluginId = 'grafana-basic-app';
const extensionPointId = 'grafana/dashboard/panel/menu';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
reactiveRegistry.register({
pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId,
configure: jest.fn().mockReturnValue({}),
},
],
});
const registry1 = await reactiveRegistry.getRegistry();
const id1 = registry1.id;
expect(id1).toBeDefined();
reactiveRegistry.register({
pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId,
configure: jest.fn().mockReturnValue({}),
},
],
});
const registry2 = await reactiveRegistry.getRegistry();
const id2 = registry2.id;
expect(id2).toBeDefined();
expect(id2).not.toEqual(id1);
});
it('should be possible to register extensions in the registry', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
reactiveRegistry.register({
pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
{
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'plugins/myorg-basic-app/start',
configure: jest.fn().mockImplementation((context) => ({ title: context?.title })),
},
],
});
const registry = await reactiveRegistry.getRegistry();
expect(registry.extensions).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId,
config: {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
],
'plugins/myorg-basic-app/start': [
{
pluginId: pluginId,
config: {
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'plugins/myorg-basic-app/start',
configure: expect.any(Function),
},
},
],
});
});
it('should be possible to asynchronously register extensions for the same placement (different plugins)', async () => {
const pluginId1 = 'grafana-basic-app';
const pluginId2 = 'grafana-basic-app2';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
// Register extensions for the first plugin
reactiveRegistry.register({
pluginId: pluginId1,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId1}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
const registry1 = await reactiveRegistry.getRegistry();
expect(registry1.extensions).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId1,
config: {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId1}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
],
});
// Register extensions for the second plugin to a different placement
reactiveRegistry.register({
pluginId: pluginId2,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId2}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
const registry2 = await reactiveRegistry.getRegistry();
expect(registry2.extensions).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId1,
config: {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId1}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
{
pluginId: pluginId2,
config: {
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId2}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
],
});
});
it('should be possible to asynchronously register extensions for a different placement (different plugin)', async () => {
const pluginId1 = 'grafana-basic-app';
const pluginId2 = 'grafana-basic-app2';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
// Register extensions for the first plugin
reactiveRegistry.register({
pluginId: pluginId1,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId1}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
const registry1 = await reactiveRegistry.getRegistry();
expect(registry1.extensions).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId1,
config: {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId1}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
],
});
// Register extensions for the second plugin to a different placement
reactiveRegistry.register({
pluginId: pluginId2,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId2}/declare-incident`,
extensionPointId: 'plugins/myorg-basic-app/start',
configure: jest.fn().mockReturnValue({}),
},
],
});
const registry2 = await reactiveRegistry.getRegistry();
expect(registry2.extensions).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId1,
config: {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId1}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
],
'plugins/myorg-basic-app/start': [
{
pluginId: pluginId2,
config: {
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId2}/declare-incident`,
extensionPointId: 'plugins/myorg-basic-app/start',
configure: expect.any(Function),
},
},
],
});
});
it('should be possible to asynchronously register extensions for the same placement (same plugin)', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
// Register extensions for the first extension point
reactiveRegistry.register({
pluginId: pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident-1`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
// Register extensions to a different extension point
reactiveRegistry.register({
pluginId: pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident-2`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
const registry2 = await reactiveRegistry.getRegistry();
expect(registry2.extensions).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId,
config: {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident-1`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
{
pluginId: pluginId,
config: {
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident-2`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
],
});
});
it('should be possible to asynchronously register extensions for a different placement (same plugin)', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
// Register extensions for the first extension point
reactiveRegistry.register({
pluginId: pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
// Register extensions to a different extension point
reactiveRegistry.register({
pluginId: pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'plugins/myorg-basic-app/start',
configure: jest.fn().mockReturnValue({}),
},
],
});
const registry2 = await reactiveRegistry.getRegistry();
expect(registry2.extensions).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId,
config: {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
],
'plugins/myorg-basic-app/start': [
{
pluginId: pluginId,
config: {
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'plugins/myorg-basic-app/start',
configure: expect.any(Function),
},
},
],
});
});
it('should notify subscribers when the registry changes', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
observable.subscribe(subscribeCallback);
// Register extensions for the first plugin
reactiveRegistry.register({
pluginId: pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
expect(subscribeCallback).toHaveBeenCalledTimes(2);
// Register extensions for the first plugin
reactiveRegistry.register({
pluginId: 'another-plugin',
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/another-plugin/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
expect(subscribeCallback).toHaveBeenCalledTimes(3);
const registry = subscribeCallback.mock.calls[2][0];
expect(registry.extensions).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId,
config: {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
{
pluginId: 'another-plugin',
config: {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/another-plugin/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
],
});
});
it('should give the last version of the registry for new subscribers', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
reactiveRegistry.register({
pluginId: pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
observable.subscribe(subscribeCallback);
expect(subscribeCallback).toHaveBeenCalledTimes(1);
const registry = subscribeCallback.mock.calls[0][0];
expect(registry.extensions).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId,
config: {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
],
});
});
it('should not register extensions for a plugin that had errors', () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
reactiveRegistry.register({
error: new Error('Something is broken'),
pluginId: pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
expect(consoleWarn).toHaveBeenCalled();
observable.subscribe(subscribeCallback);
expect(subscribeCallback).toHaveBeenCalledTimes(1);
const registry = subscribeCallback.mock.calls[0][0];
expect(registry.extensions).toEqual({});
});
it('should not register an extension if it has an invalid configure() function', () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
reactiveRegistry.register({
pluginId: pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
//@ts-ignore
configure: '...',
},
],
});
expect(consoleWarn).toHaveBeenCalled();
observable.subscribe(subscribeCallback);
expect(subscribeCallback).toHaveBeenCalledTimes(1);
const registry = subscribeCallback.mock.calls[0][0];
expect(registry.extensions).toEqual({});
});
it('should not register an extension if it has invalid properties (empty title / description)', () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
reactiveRegistry.register({
pluginId: pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: '',
description: '',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
expect(consoleWarn).toHaveBeenCalled();
observable.subscribe(subscribeCallback);
expect(subscribeCallback).toHaveBeenCalledTimes(1);
const registry = subscribeCallback.mock.calls[0][0];
expect(registry.extensions).toEqual({});
});
it('should not register link extensions with invalid path configured', () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
reactiveRegistry.register({
pluginId: pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Title 1',
description: 'Description 1',
path: `/a/another-plugin/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
expect(consoleWarn).toHaveBeenCalled();
observable.subscribe(subscribeCallback);
expect(subscribeCallback).toHaveBeenCalledTimes(1);
const registry = subscribeCallback.mock.calls[0][0];
expect(registry.extensions).toEqual({});
});
});

View File

@@ -0,0 +1,79 @@
import { Observable, ReplaySubject, Subject, firstValueFrom, map, scan, startWith } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { PluginPreloadResult } from '../pluginPreloader';
import { PluginExtensionRegistry, PluginExtensionRegistryItem } from './types';
import { deepFreeze, logWarning } from './utils';
import { isPluginExtensionConfigValid } from './validators';
export class ReactivePluginExtensionsRegistry {
private resultSubject: Subject<PluginPreloadResult>;
private registrySubject: ReplaySubject<PluginExtensionRegistry>;
constructor() {
this.resultSubject = new Subject<PluginPreloadResult>();
// 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<PluginExtensionRegistry>(1);
this.resultSubject
.pipe(
scan(resultsToRegistry, { id: '', extensions: {} }),
// Emit an empty registry to start the stream (it is only going to do it once during construction, and then just passes down the values)
startWith({ id: '', extensions: {} }),
map((registry) => deepFreeze(registry))
)
// Emitting the new registry to `this.registrySubject`
.subscribe(this.registrySubject);
}
register(result: PluginPreloadResult): void {
this.resultSubject.next(result);
}
asObservable(): Observable<PluginExtensionRegistry> {
return this.registrySubject.asObservable();
}
getRegistry(): Promise<PluginExtensionRegistry> {
return firstValueFrom(this.asObservable());
}
}
function resultsToRegistry(registry: PluginExtensionRegistry, result: PluginPreloadResult): PluginExtensionRegistry {
const { pluginId, extensionConfigs, error } = result;
// TODO: We should probably move this section to where we load the plugin since this is only used
// to provide a log to the user.
if (error) {
logWarning(`"${pluginId}" plugin failed to load, skip registering its extensions.`);
return registry;
}
for (const extensionConfig of extensionConfigs) {
const { extensionPointId } = extensionConfig;
if (!extensionConfig || !isPluginExtensionConfigValid(pluginId, extensionConfig)) {
return registry;
}
let registryItem: PluginExtensionRegistryItem = {
config: extensionConfig,
// Additional meta information about the extension
pluginId,
};
if (!Array.isArray(registry.extensions[extensionPointId])) {
registry.extensions[extensionPointId] = [registryItem];
} else {
registry.extensions[extensionPointId].push(registryItem);
}
}
// Add a unique ID to the registry (the registry object itself is immutable)
registry.id = uuidv4();
return registry;
}

View File

@@ -9,4 +9,7 @@ export type PluginExtensionRegistryItem = {
};
// A map of placement names to a list of extensions
export type PluginExtensionRegistry = Record<string, PluginExtensionRegistryItem[]>;
export type PluginExtensionRegistry = {
id: string;
extensions: Record<string, PluginExtensionRegistryItem[]>;
};

View File

@@ -0,0 +1,225 @@
import { act } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { PluginExtensionTypes } from '@grafana/data';
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
import { createPluginExtensionsHook } from './usePluginExtensions';
describe('usePluginExtensions()', () => {
let reactiveRegistry: ReactivePluginExtensionsRegistry;
beforeEach(() => {
reactiveRegistry = new ReactivePluginExtensionsRegistry();
});
it('should return an empty array if there are no extensions registered for the extension point', () => {
const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry);
const { result } = renderHook(() =>
usePluginExtensions({
extensionPointId: 'foo/bar',
})
);
expect(result.current.extensions).toEqual([]);
});
it('should return the plugin extensions from the registry', () => {
const extensionPointId = 'plugins/foo/bar';
const pluginId = 'my-app-plugin';
reactiveRegistry.register({
pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
extensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
},
{
type: PluginExtensionTypes.link,
extensionPointId,
title: '2',
description: '2',
path: `/a/${pluginId}/2`,
},
],
});
const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry);
const { result } = renderHook(() => usePluginExtensions({ extensionPointId }));
expect(result.current.extensions.length).toBe(2);
expect(result.current.extensions[0].title).toBe('1');
expect(result.current.extensions[1].title).toBe('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 = createPluginExtensionsHook(reactiveRegistry);
let { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId }));
// No extensions yet
expect(result.current.extensions.length).toBe(0);
// Add extensions to the registry
act(() => {
reactiveRegistry.register({
pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
extensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
},
{
type: PluginExtensionTypes.link,
extensionPointId,
title: '2',
description: '2',
path: `/a/${pluginId}/2`,
},
],
});
});
// Check if the hook returns the new extensions
rerender();
expect(result.current.extensions.length).toBe(2);
expect(result.current.extensions[0].title).toBe('1');
expect(result.current.extensions[1].title).toBe('2');
});
it('should only render the hook once', () => {
const spy = jest.spyOn(reactiveRegistry, 'asObservable');
const extensionPointId = 'plugins/foo/bar';
const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry);
renderHook(() => usePluginExtensions({ extensionPointId }));
expect(spy).toHaveBeenCalledTimes(1);
});
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 = createPluginExtensionsHook(reactiveRegistry);
// Add extensions to the registry
act(() => {
reactiveRegistry.register({
pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
extensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
},
{
type: PluginExtensionTypes.link,
extensionPointId,
title: '2',
description: '2',
path: `/a/${pluginId}/2`,
},
],
});
});
// Check if it returns the same extensions object in case nothing changes
const context = {};
const firstResults = renderHook(() => usePluginExtensions({ extensionPointId, context }));
const secondResults = renderHook(() => usePluginExtensions({ extensionPointId, context }));
expect(firstResults.result.current.extensions === secondResults.result.current.extensions).toBe(true);
});
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 = createPluginExtensionsHook(reactiveRegistry);
// Add extensions to the registry
act(() => {
reactiveRegistry.register({
pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
extensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
},
{
type: PluginExtensionTypes.link,
extensionPointId,
title: '2',
description: '2',
path: `/a/${pluginId}/2`,
},
],
});
});
// Check if it returns a different extensions object in case the context object changes
const firstResults = renderHook(() => usePluginExtensions({ extensionPointId, context: {} }));
const secondResults = renderHook(() => usePluginExtensions({ extensionPointId, context: {} }));
expect(firstResults.result.current.extensions === secondResults.result.current.extensions).toBe(false);
});
it('should return a new extensions object if the registry changes but the context object is the same', () => {
const extensionPointId = 'plugins/foo/bar';
const pluginId = 'my-app-plugin';
const context = {};
const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry);
// Add the first extension
act(() => {
reactiveRegistry.register({
pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
extensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
},
],
});
});
const { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId, context }));
const firstExtensions = result.current.extensions;
// Add the second extension
act(() => {
reactiveRegistry.register({
pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
extensionPointId,
// extensionPointId: 'plugins/foo/bar/zed', // A different extension point (to be sure that it's also returning a new object when the actual extension point doesn't change)
title: '2',
description: '2',
path: `/a/${pluginId}/2`,
},
],
});
});
rerender();
const secondExtensions = result.current.extensions;
expect(firstExtensions === secondExtensions).toBe(false);
});
});

View File

@@ -0,0 +1,54 @@
import { useObservable } from 'react-use';
import { PluginExtension } from '@grafana/data';
import { GetPluginExtensionsOptions, UsePluginExtensionsResult } from '@grafana/runtime';
import { getPluginExtensions } from './getPluginExtensions';
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
export function createPluginExtensionsHook(extensionsRegistry: ReactivePluginExtensionsRegistry) {
const observableRegistry = extensionsRegistry.asObservable();
const cache: {
id: string;
extensions: Record<string, { context: GetPluginExtensionsOptions['context']; extensions: PluginExtension[] }>;
} = {
id: '',
extensions: {},
};
return function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult<PluginExtension> {
const registry = useObservable(observableRegistry);
if (!registry) {
return { extensions: [], isLoading: false };
}
if (registry.id !== cache.id) {
cache.id = registry.id;
cache.extensions = {};
}
// `getPluginExtensions` will return a new array of objects even if it is called with the same options, as it always constructing a frozen objects.
// Due to this we are caching the result of `getPluginExtensions` to avoid unnecessary re-renders for components that are using this hook.
// (NOTE: we are only checking referential equality of `context` object, so it is important to not mutate the object passed to this hook.)
const key = `${options.extensionPointId}-${options.limitPerPlugin}`;
if (cache.extensions[key] && cache.extensions[key].context === options.context) {
return {
extensions: cache.extensions[key].extensions,
isLoading: false,
};
}
const { extensions } = getPluginExtensions({ ...options, registry });
cache.extensions[key] = {
context: options.context,
extensions,
};
return {
extensions,
isLoading: false,
};
};
}

View File

@@ -3,6 +3,7 @@ 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 * as pluginLoader from './plugin_loader';
export type PluginPreloadResult = {
@@ -11,12 +12,16 @@ export type PluginPreloadResult = {
extensionConfigs: PluginExtensionConfig[];
};
export async function preloadPlugins(apps: Record<string, AppPluginConfig> = {}): Promise<PluginPreloadResult[]> {
export async function preloadPlugins(apps: AppPluginConfig[] = [], registry: ReactivePluginExtensionsRegistry) {
startMeasure('frontend_plugins_preload');
const pluginsToPreload = Object.values(apps).filter((app) => app.preload);
const result = await Promise.all(pluginsToPreload.map(preload));
const promises = apps.filter((config) => config.preload).map((config) => preload(config));
const preloadedPlugins = await Promise.all(promises);
for (const preloadedPlugin of preloadedPlugins) {
registry.register(preloadedPlugin);
}
stopMeasure('frontend_plugins_preload');
return result;
}
async function preload(config: AppPluginConfig): Promise<PluginPreloadResult> {