mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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),
|
||||
},
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[]>;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user