Files
Levente Balogh 39dcff23f9 Plugin Extensions: Clean up the deprecated APIs (#102102)
* PanelMenuBehaviour: stop using the deprecated `getPluginLinkExtensions()` API

* Wip

* grafana-runtime: remove deprecated APIs `usePluginExtensions()`, `usePluginLinkExtensions()` and `usePluginComponentExtensions()`

* Wip

* Wip

* wip

* wip

* Chore: removed PluginExtensionLinkConfig

* Chore: removed PluginExtensionComponentConfig

* Chore: fixed grafana-pyroscope-datasource QueryEditor test

* Chore: fixed PublicDashboardScenePage.test.tsx

* Chore: fix PanelDataQueriesTab test

* Chore: fix PanelMenuBehavior test

* Chore: fix transformSceneToSaveModel test

* Chore: fix last type errors

* Chore: fix alerting/unified/testSetup/plugins.ts

* Chore: break out types to separate file

* feat(Extensions): expose an observable API for added links and components

* chore: prettier fixes

* Revert "chore: prettier fixes"

This reverts commit 53aa7676647a50785cb18098c6cc4c81bad5b4c7.

* Revert "feat(Extensions): expose an observable API for added links and components"

This reverts commit bdc588250e790f91a3c93c7c79448280d39fbd22.

---------

Co-authored-by: Hugo Häggmark <hugo.haggmark@gmail.com>
2025-04-09 10:24:29 +02:00

717 lines
23 KiB
TypeScript

import { memo } from 'react';
import {
PluginContextType,
PluginExtensionAddedLinkConfig,
PluginExtensionPoints,
PluginLoadingStrategy,
PluginType,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { createLogMock } from './logs/testUtils';
import {
assertConfigureIsValid,
assertLinkPathIsValid,
assertStringProps,
isAddedComponentMetaInfoMissing,
isAddedLinkMetaInfoMissing,
isExposedComponentDependencyMissing,
isExposedComponentMetaInfoMissing,
isExtensionPointIdValid,
isExtensionPointMetaInfoMissing,
isGrafanaCoreExtensionPoint,
isReactComponent,
} from './validators';
describe('Plugin Extension Validators', () => {
describe('assertLinkPathIsValid()', () => {
it('should not throw an error if the link path is valid', () => {
expect(() => {
const pluginId = 'myorg-b-app';
const extension = {
path: `/a/${pluginId}/overview`,
title: 'My Plugin',
description: 'My Plugin Description',
extensionPointId: '...',
};
assertLinkPathIsValid(pluginId, extension.path);
}).not.toThrowError();
});
it('should throw an error if the link path is pointing to a different plugin', () => {
expect(() => {
const extension = {
path: `/a/myorg-b-app/overview`,
title: 'My Plugin',
description: 'My Plugin Description',
extensionPointId: '...',
};
assertLinkPathIsValid('another-plugin-app', extension.path);
}).toThrowError();
});
it('should throw an error if the link path is not prefixed with "/a/<PLUGIN_ID>"', () => {
expect(() => {
const extension = {
path: `/some-bad-path`,
title: 'My Plugin',
description: 'My Plugin Description',
extensionPointId: '...',
};
assertLinkPathIsValid('myorg-b-app', extension.path);
}).toThrowError();
});
});
describe('assertConfigureIsValid()', () => {
it('should NOT throw an error if the configure() function is missing', () => {
expect(() => {
assertConfigureIsValid({
title: 'Title',
description: 'Description',
targets: 'grafana/some-page/extension-point-a',
} as PluginExtensionAddedLinkConfig);
}).not.toThrowError();
});
it('should NOT throw an error if the configure() function is a valid function', () => {
expect(() => {
assertConfigureIsValid({
title: 'Title',
description: 'Description',
targets: 'grafana/some-page/extension-point-a',
configure: () => {},
} as PluginExtensionAddedLinkConfig);
}).not.toThrowError();
});
it('should throw an error if the configure() function is defined but is not a function', () => {
expect(() => {
assertConfigureIsValid({
title: 'Title',
description: 'Description',
extensionPointId: 'grafana/some-page/extension-point-a',
handler: () => {},
configure: '() => {}',
} as unknown as PluginExtensionAddedLinkConfig); // We are casting to unknown to test it with a unvalid argument
}).toThrowError();
});
});
describe('assertStringProps()', () => {
it('should throw an error if any of the expected string properties is missing', () => {
expect(() => {
assertStringProps(
{
description: 'Description',
extensionPointId: 'grafana/some-page/extension-point-a',
},
['title', 'description', 'extensionPointId']
);
}).toThrowError();
});
it('should throw an error if any of the expected string properties is an empty string', () => {
expect(() => {
assertStringProps(
{
title: '',
description: 'Description',
extensionPointId: 'grafana/some-page/extension-point-a',
},
['title', 'description', 'extensionPointId']
);
}).toThrowError();
});
it('should NOT throw an error if the expected string props are present and not empty', () => {
expect(() => {
assertStringProps(
{
title: 'Title',
description: 'Description',
extensionPointId: 'grafana/some-page/extension-point-a',
},
['title', 'description', 'extensionPointId']
);
}).not.toThrowError();
});
it('should NOT throw an error if there are other existing and empty string properties, that we did not specify', () => {
expect(() => {
assertStringProps(
{
title: 'Title',
description: 'Description',
extensionPointId: 'grafana/some-page/extension-point-a',
dontCare: '',
},
['title', 'description', 'extensionPointId']
);
}).not.toThrowError();
});
});
describe('isReactComponent()', () => {
it('should return TRUE if we pass in a valid React component', () => {
expect(isReactComponent(() => <div>Some text</div>)).toBe(true);
});
it('should return TRUE if we pass in a component wrapped with React.memo()', () => {
const Component = () => <div>Some text</div>;
const wrapped = memo(() => (
<div>
<Component />
</div>
));
wrapped.displayName = 'MyComponent';
expect(isReactComponent(wrapped)).toBe(true);
});
it('should return FALSE if we pass in a valid React component', () => {
expect(isReactComponent('Foo bar')).toBe(false);
expect(isReactComponent(123)).toBe(false);
expect(isReactComponent(false)).toBe(false);
expect(isReactComponent(undefined)).toBe(false);
expect(isReactComponent(null)).toBe(false);
});
});
describe('isGrafanaCoreExtensionPoint()', () => {
it('should return TRUE if we pass an PluginExtensionPoints value', () => {
expect(isGrafanaCoreExtensionPoint(PluginExtensionPoints.AlertingAlertingRuleAction)).toBe(true);
});
it('should return TRUE if we pass a string that is not listed under the PluginExtensionPoints enum', () => {
expect(isGrafanaCoreExtensionPoint('grafana/alerting/alertingrule/action')).toBe(true);
});
it('should return FALSE if we pass a string that is not listed under the PluginExtensionPoints enum', () => {
expect(isGrafanaCoreExtensionPoint('grafana/dashboard/alertingrule/action')).toBe(false);
});
});
describe('isExtensionPointIdValid()', () => {
test.each([
// We (for now allow core Grafana extension points to run without a version)
['grafana/extension-point', ''],
['grafana/extension-point', 'grafana'],
['myorg-extensions-app/extension-point', 'myorg-extensions-app'],
['myorg-extensions-app/extension-point/v1', 'myorg-extensions-app'],
['plugins/myorg-extensions-app/extension-point/v1', 'myorg-extensions-app'],
['plugins/myorg-basic-app/start', 'myorg-basic-app'],
['myorg-extensions-app/extension-point/v1', 'myorg-extensions-app'],
['plugins/myorg-extensions-app/extension-point/v1', 'myorg-extensions-app'],
['plugins/grafana-app-observability-app/service/action', 'grafana-app-observability-app'],
['plugins/grafana-k8s-app/cluster/action', 'grafana-k8s-app'],
['plugins/grafana-oncall-app/alert-group/action', 'grafana-oncall-app'],
['plugins/grafana-oncall-app/alert-group/action/v1', 'grafana-oncall-app'],
['plugins/grafana-oncall-app/alert-group/action/v1.0.0', 'grafana-oncall-app'],
])('should return TRUE if the extension point id is valid ("%s", "%s")', (extensionPointId, pluginId) => {
expect(
isExtensionPointIdValid({
extensionPointId,
pluginId,
})
).toBe(true);
});
test.each([
[
// Plugin id mismatch
'myorg-extensions-app/extension-point/v1',
'myorgs-other-app',
],
[
// Missing plugin id prefix
'extension-point/v1',
'myorgs-extensions-app',
],
])('should return FALSE if the extension point id is invalid ("%s", "%s")', (extensionPointId, pluginId) => {
expect(
isExtensionPointIdValid({
extensionPointId,
pluginId,
})
).toBe(false);
});
});
describe('isAddedLinkMetaInfoMissing()', () => {
const originalApps = config.apps;
const pluginId = 'myorg-extensions-app';
const appPluginConfig = {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
};
const extensionConfig = {
targets: [PluginExtensionPoints.DashboardPanelMenu],
title: 'Link title',
description: 'Link description',
};
beforeEach(() => {
config.apps = {
[pluginId]: appPluginConfig,
};
});
afterEach(() => {
config.apps = originalApps;
});
it('should return FALSE if the meta-info in the plugin.json is correct', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedLinks.push(extensionConfig);
const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig, log);
expect(returnValue).toBe(false);
expect(log.error).toHaveBeenCalledTimes(0);
});
it('should return TRUE and log an error if the app config is not found', () => {
const log = createLogMock();
delete config.apps[pluginId];
const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig, log);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.error).mock.calls[0][0]).toMatch('The app plugin with plugin id');
});
it('should return TRUE and log an error if the link has no meta-info in the plugin.json', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedLinks = [];
const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig, log);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.error).mock.calls[0][0]).toMatch(
'The extension was not recorded in the plugin.json. Added link extensions must be listed in the section "extensions.addedLinks[]"'
);
});
it('should return TRUE and log an error if the "targets" do not match', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedLinks.push(extensionConfig);
const returnValue = isAddedLinkMetaInfoMissing(
pluginId,
{
...extensionConfig,
targets: [PluginExtensionPoints.DashboardPanelMenu, PluginExtensionPoints.ExploreToolbarAction],
},
log
);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.error).mock.calls[0][0]).toMatch(
'The "targets" for the registered extension does not match'
);
});
it('should return FALSE and log a warning if the "description" does not match', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedLinks.push(extensionConfig);
const returnValue = isAddedLinkMetaInfoMissing(
pluginId,
{
...extensionConfig,
description: 'Link description UPDATED',
},
log
);
expect(returnValue).toBe(false);
expect(log.warning).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch('"description" doesn\'t match');
});
});
describe('isAddedComponentMetaInfoMissing()', () => {
const originalApps = config.apps;
const pluginId = 'myorg-extensions-app';
const appPluginConfig = {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
};
const extensionConfig = {
targets: [PluginExtensionPoints.DashboardPanelMenu],
title: 'Component title',
description: 'Component description',
component: () => <div>Component content</div>,
};
beforeEach(() => {
config.apps = {
[pluginId]: appPluginConfig,
};
});
afterEach(() => {
config.apps = originalApps;
});
it('should return FALSE if the meta-info in the plugin.json is correct', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedComponents.push(extensionConfig);
const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig, log);
expect(returnValue).toBe(false);
expect(log.error).toHaveBeenCalledTimes(0);
});
it('should return TRUE and log an error if the app config is not found', () => {
const log = createLogMock();
delete config.apps[pluginId];
const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig, log);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.error).mock.calls[0][0]).toMatch('The app plugin with plugin id');
});
it('should return TRUE and log an error if the Component has no meta-info in the plugin.json', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedComponents = [];
const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig, log);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.error).mock.calls[0][0]).toMatch(
'The extension was not recorded in the plugin.json. Added component extensions must be listed in the section "extensions.addedComponents[]"'
);
});
it('should return TRUE and log an error if the "targets" do not match', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedComponents.push(extensionConfig);
const returnValue = isAddedComponentMetaInfoMissing(
pluginId,
{
...extensionConfig,
targets: [PluginExtensionPoints.ExploreToolbarAction],
},
log
);
expect(returnValue).toBe(true);
expect(jest.mocked(log.error).mock.calls[0][0]).toMatch(
'The "targets" for the registered extension does not match'
);
});
it('should return FALSE and log a warning if the "description" does not match', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedComponents.push(extensionConfig);
const returnValue = isAddedComponentMetaInfoMissing(
pluginId,
{
...extensionConfig,
description: 'UPDATED',
},
log
);
expect(returnValue).toBe(false);
expect(log.warning).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch('"description" doesn\'t match');
});
});
describe('isExposedComponentMetaInfoMissing()', () => {
const originalApps = config.apps;
const pluginId = 'myorg-extensions-app';
const appPluginConfig = {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
};
const exposedComponentConfig = {
id: `${pluginId}/component/v1`,
title: 'Exposed component',
description: 'Exposed component description',
component: () => <div>Component content</div>,
};
beforeEach(() => {
config.apps = {
[pluginId]: appPluginConfig,
};
});
afterEach(() => {
config.apps = originalApps;
});
it('should return FALSE if the meta-info in the plugin.json is correct', () => {
const log = createLogMock();
config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig);
const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig, log);
expect(returnValue).toBe(false);
expect(log.warning).toHaveBeenCalledTimes(0);
});
it('should return TRUE and log an error if the app config is not found', () => {
const log = createLogMock();
delete config.apps[pluginId];
const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig, log);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.error).mock.calls[0][0]).toMatch('The app plugin with plugin id');
});
it('should return TRUE and log an error if the exposed component has no meta-info in the plugin.json', () => {
const log = createLogMock();
config.apps[pluginId].extensions.exposedComponents = [];
const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig, log);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.error).mock.calls[0][0]).toMatch(
'The exposed component was not recorded in the plugin.json. Exposed component extensions must be listed in the section "extensions.exposedComponents[]"'
);
});
it('should return TRUE and log an error if the title does not match', () => {
const log = createLogMock();
config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig);
const returnValue = isExposedComponentMetaInfoMissing(
pluginId,
{
...exposedComponentConfig,
title: 'UPDATED',
},
log
);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.error).mock.calls[0][0]).toMatch(
'The "title" doesn\'t match the title recorded in plugin.json.'
);
});
it('should return FALSE and log a warning if the "description" does not match', () => {
const log = createLogMock();
config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig);
const returnValue = isExposedComponentMetaInfoMissing(
pluginId,
{
...exposedComponentConfig,
description: 'UPDATED',
},
log
);
expect(returnValue).toBe(false);
expect(log.warning).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch('"description" doesn\'t match');
});
});
describe('isExposedComponentDependencyMissing()', () => {
let pluginContext: PluginContextType;
const pluginId = 'myorg-extensions-app';
const exposedComponentId = `${pluginId}/component/v1`;
beforeEach(() => {
pluginContext = {
meta: {
id: pluginId,
name: 'Extensions App',
type: PluginType.app,
module: '',
baseUrl: '',
info: {
author: {
name: 'MyOrg',
},
description: 'App for testing extensions',
links: [],
logos: {
large: '',
small: '',
},
screenshots: [],
updated: '2023-10-26T18:25:01Z',
version: '1.0.0',
},
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
},
};
});
it('should return FALSE if the meta-info in the plugin.json is correct', () => {
pluginContext.meta.dependencies?.extensions.exposedComponents.push(exposedComponentId);
const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext);
expect(returnValue).toBe(false);
});
it('should return TRUE if the dependencies are missing', () => {
delete pluginContext.meta.dependencies;
const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext);
expect(returnValue).toBe(true);
});
it('should return TRUE if the exposed component id is not specified in the list of dependencies', () => {
const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext);
expect(returnValue).toBe(true);
});
});
describe('isExtensionPointMetaInfoMissing()', () => {
let pluginContext: PluginContextType;
const pluginId = 'myorg-extensions-app';
const extensionPointId = `${pluginId}/extension-point/v1`;
const extensionPointConfig = {
id: extensionPointId,
title: 'Extension point title',
description: 'Extension point description',
};
beforeEach(() => {
pluginContext = {
meta: {
id: pluginId,
name: 'Extensions App',
type: PluginType.app,
module: '',
baseUrl: '',
info: {
author: {
name: 'MyOrg',
},
description: 'App for testing extensions',
links: [],
logos: {
large: '',
small: '',
},
screenshots: [],
updated: '2023-10-26T18:25:01Z',
version: '1.0.0',
},
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
},
};
});
it('should return FALSE if the meta-info in the plugin.json is correct', () => {
pluginContext.meta.extensions?.extensionPoints.push(extensionPointConfig);
const returnValue = isExtensionPointMetaInfoMissing(extensionPointId, pluginContext);
expect(returnValue).toBe(false);
});
it('should return TRUE if the extension point id is not recorded in the plugin.json', () => {
const returnValue = isExtensionPointMetaInfoMissing(extensionPointId, pluginContext);
expect(returnValue).toBe(true);
});
});
});