Plugins: replace config.apps in extension registries (#116581)

* Plugins: replace `config.apps` in extension registries

* fix validator tests

* fix readonly initialization

* fix registry tests

* fix pluginImporter tests

* wip

* chore: fix registry tests

* chore: refactor tests

* chore: refactor test fixture

* chore: fix getPluginExtensions.test.tsx

* chore: fix all remaining red tests

* chore: refactor public/app/features/plugins/extensions/getPluginExtensions.test.tsx

* chore: fix imports

* chore: more refactor

* chore: adds error handling

* chore: update after PR feedback

* chore: use getCachedPromise function

* chore: undo test

---------

Co-authored-by: Erik Sundell <erik.sundell87@gmail.com>
This commit is contained in:
Hugo Häggmark 2026-02-03 08:17:05 +01:00 committed by GitHub
parent 971ce0ce47
commit 4b34d5ea67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 762 additions and 909 deletions

View file

@ -2764,56 +2764,6 @@
"count": 1
}
},
"public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts": {
"no-restricted-syntax": {
"count": 6
}
},
"public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts": {
"no-restricted-syntax": {
"count": 6
}
},
"public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts": {
"no-restricted-syntax": {
"count": 6
}
},
"public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.test.ts": {
"no-restricted-syntax": {
"count": 6
}
},
"public/app/features/plugins/extensions/usePluginComponent.test.tsx": {
"no-restricted-syntax": {
"count": 3
}
},
"public/app/features/plugins/extensions/usePluginComponents.test.tsx": {
"no-restricted-syntax": {
"count": 2
}
},
"public/app/features/plugins/extensions/usePluginFunctions.test.tsx": {
"no-restricted-syntax": {
"count": 2
}
},
"public/app/features/plugins/extensions/usePluginLinks.test.tsx": {
"no-restricted-syntax": {
"count": 2
}
},
"public/app/features/plugins/extensions/validators.test.tsx": {
"no-restricted-syntax": {
"count": 30
}
},
"public/app/features/plugins/extensions/validators.ts": {
"no-restricted-syntax": {
"count": 4
}
},
"public/app/features/plugins/sandbox/distortions.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1

View file

@ -17,7 +17,8 @@ import { RouteDescriptor } from './core/navigation/types';
import { ThemeProvider } from './core/utils/ConfigProvider';
import { LiveConnectionWarning } from './features/live/LiveConnectionWarning';
import { ExtensionRegistriesProvider } from './features/plugins/extensions/ExtensionRegistriesContext';
import { pluginExtensionRegistries } from './features/plugins/extensions/registry/setup';
import { getPluginExtensionRegistries } from './features/plugins/extensions/registry/setup';
import { PluginExtensionRegistries } from './features/plugins/extensions/registry/types';
import { ScopesContextProvider } from './features/scopes/ScopesContextProvider';
import { RouterWrapper } from './routes/RoutesWrapper';
@ -27,6 +28,7 @@ interface AppWrapperProps {
interface AppWrapperState {
ready?: boolean;
registries?: PluginExtensionRegistries;
}
/** Used by enterprise */
@ -55,7 +57,8 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
}
async componentDidMount() {
this.setState({ ready: true });
const registries = await getPluginExtensionRegistries();
this.setState({ ready: true, registries });
this.removePreloader();
// clear any old icon caches
@ -93,7 +96,7 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
render() {
const { context } = this.props;
const { ready } = this.state;
const { ready, registries } = this.state;
navigationLogger('AppWrapper', false, 'rendering');
@ -125,7 +128,7 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
>
<MaybeTimeRangeProvider>
<ScopesContextProvider>
<ExtensionRegistriesProvider registries={pluginExtensionRegistries}>
<ExtensionRegistriesProvider registries={registries}>
<ExtensionSidebarContextProvider>
<UNSAFE_PortalProvider getContainer={getPortalContainer}>
<GlobalStyles />

View file

@ -95,6 +95,7 @@ import {
getObservablePluginComponents,
getObservablePluginLinks,
} from './features/plugins/extensions/getPluginExtensions';
import { getPluginExtensionRegistries } from './features/plugins/extensions/registry/setup';
import { usePluginComponent } from './features/plugins/extensions/usePluginComponent';
import { usePluginComponents } from './features/plugins/extensions/usePluginComponents';
import { usePluginFunctions } from './features/plugins/extensions/usePluginFunctions';
@ -262,6 +263,8 @@ export class GrafanaApp {
preloadPlugins(await getAppPluginsToPreload());
}
getPluginExtensionRegistries();
setHelpNavItemHook(useHelpNode);
setPluginLinksHook(usePluginLinks);
setPluginComponentHook(usePluginComponent);

View file

@ -26,7 +26,8 @@ import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/co
import { InspectTab } from 'app/features/inspector/types';
import { getScenePanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
import { createPluginExtensionsGetter } from 'app/features/plugins/extensions/getPluginExtensions';
import { pluginExtensionRegistries } from 'app/features/plugins/extensions/registry/setup';
import { getPluginExtensionRegistries } from 'app/features/plugins/extensions/registry/setup';
import { PluginExtensionRegistries } from 'app/features/plugins/extensions/registry/types';
import { GetPluginExtensions } from 'app/features/plugins/extensions/types';
import { createExtensionSubMenu } from 'app/features/plugins/extensions/utils';
import { dispatch } from 'app/store/store';
@ -47,12 +48,12 @@ import { PanelTimeRangeDrawer } from './panel-timerange/PanelTimeRangeDrawer';
let getPluginExtensions: GetPluginExtensions;
function setupGetPluginExtensions() {
function setupGetPluginExtensions(registries: PluginExtensionRegistries) {
if (getPluginExtensions) {
return getPluginExtensions;
}
getPluginExtensions = createPluginExtensionsGetter(pluginExtensionRegistries);
getPluginExtensions = createPluginExtensionsGetter(registries);
return getPluginExtensions;
}
@ -300,7 +301,8 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
});
}
setupGetPluginExtensions();
const registries = await getPluginExtensionRegistries();
setupGetPluginExtensions(registries);
const { extensions } = getPluginExtensions({
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,

View file

@ -89,10 +89,10 @@ function renderUnderRouter(page = '') {
appPluginNavItem.parentItem = appsSection;
const registries = {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
addedFunctionsRegistry: new AddedFunctionsRegistry(),
addedComponentsRegistry: new AddedComponentsRegistry([]),
exposedComponentsRegistry: new ExposedComponentsRegistry([]),
addedLinksRegistry: new AddedLinksRegistry([]),
addedFunctionsRegistry: new AddedFunctionsRegistry([]),
};
const pagePath = page ? `/${page}` : '';
const route = {

View file

@ -8,7 +8,7 @@ import { ExposedComponentsRegistry } from 'app/features/plugins/extensions/regis
import { PluginExtensionRegistries } from './registry/types';
export interface ExtensionRegistriesContextType {
registries: PluginExtensionRegistries;
registries?: PluginExtensionRegistries;
}
// Using a different context for each registry to avoid unnecessary re-renders
@ -53,6 +53,10 @@ export const ExtensionRegistriesProvider = ({
registries,
children,
}: PropsWithChildren<ExtensionRegistriesContextType>) => {
if (!registries) {
return null;
}
return (
<AddedLinksRegistryContext.Provider value={registries.addedLinksRegistry}>
<AddedComponentsRegistryContext.Provider value={registries.addedComponentsRegistry}>

View file

@ -17,8 +17,10 @@ import {
import { log } from './logs/log';
import { resetLogMock } from './logs/testUtils';
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { pluginExtensionRegistries } from './registry/setup';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { getPluginExtensionRegistries } from './registry/setup';
import { isReadOnlyProxy } from './utils';
import { assertPluginExtensionLink } from './validators';
@ -39,6 +41,13 @@ jest.mock('./logs/log', () => {
};
});
jest.mock('./registry/setup', () => ({
...jest.requireActual('./registry/setup'),
getPluginExtensionRegistries: jest.fn(),
}));
const getPluginExtensionRegistriesMock = jest.mocked(getPluginExtensionRegistries);
async function createRegistries(
preloadResults: Array<{
pluginId: string;
@ -46,8 +55,8 @@ async function createRegistries(
addedLinkConfigs: PluginExtensionAddedLinkConfig[];
}>
) {
const addedLinksRegistry = new AddedLinksRegistry();
const addedComponentsRegistry = new AddedComponentsRegistry();
const addedLinksRegistry = new AddedLinksRegistry([]);
const addedComponentsRegistry = new AddedComponentsRegistry([]);
for (const { pluginId, addedLinkConfigs, addedComponentConfigs } of preloadResults) {
addedLinksRegistry.register({
@ -552,11 +561,27 @@ describe('getPluginExtensions()', () => {
describe('getObservablePluginExtensions()', () => {
const extensionPointId = 'grafana/dashboard/panel/menu/v1';
const pluginId = 'grafana-basic-app';
let addedLinksRegistry: AddedLinksRegistry;
let addedComponentsRegistry: AddedComponentsRegistry;
let addedFunctionsRegistry: AddedFunctionsRegistry;
let exposedComponentsRegistry: ExposedComponentsRegistry;
beforeEach(() => {
pluginExtensionRegistries.addedLinksRegistry = new AddedLinksRegistry();
pluginExtensionRegistries.addedComponentsRegistry = new AddedComponentsRegistry();
pluginExtensionRegistries.addedLinksRegistry.register({
addedLinksRegistry = new AddedLinksRegistry([]);
addedComponentsRegistry = new AddedComponentsRegistry([]);
addedFunctionsRegistry = new AddedFunctionsRegistry([]);
exposedComponentsRegistry = new ExposedComponentsRegistry([]);
const registries = {
addedComponentsRegistry,
addedFunctionsRegistry,
addedLinksRegistry,
exposedComponentsRegistry,
};
getPluginExtensionRegistriesMock.mockResolvedValue(registries);
addedLinksRegistry.register({
pluginId,
configs: [
{
@ -569,7 +594,7 @@ describe('getObservablePluginExtensions()', () => {
],
});
pluginExtensionRegistries.addedComponentsRegistry.register({
addedComponentsRegistry.register({
pluginId,
configs: [
{
@ -599,7 +624,7 @@ describe('getObservablePluginExtensions()', () => {
const observable = getObservablePluginExtensions({ extensionPointId }).pipe(take(2));
setTimeout(() => {
pluginExtensionRegistries.addedLinksRegistry.register({
addedLinksRegistry.register({
pluginId,
configs: [
{
@ -631,11 +656,27 @@ describe('getObservablePluginExtensions()', () => {
describe('getObservablePluginLinks()', () => {
const extensionPointId = 'grafana/dashboard/panel/menu/v1';
const pluginId = 'grafana-basic-app';
let addedLinksRegistry: AddedLinksRegistry;
let addedComponentsRegistry: AddedComponentsRegistry;
let addedFunctionsRegistry: AddedFunctionsRegistry;
let exposedComponentsRegistry: ExposedComponentsRegistry;
beforeEach(() => {
pluginExtensionRegistries.addedLinksRegistry = new AddedLinksRegistry();
pluginExtensionRegistries.addedComponentsRegistry = new AddedComponentsRegistry();
pluginExtensionRegistries.addedLinksRegistry.register({
beforeEach(async () => {
addedLinksRegistry = new AddedLinksRegistry([]);
addedComponentsRegistry = new AddedComponentsRegistry([]);
addedFunctionsRegistry = new AddedFunctionsRegistry([]);
exposedComponentsRegistry = new ExposedComponentsRegistry([]);
const registries = {
addedComponentsRegistry,
addedFunctionsRegistry,
addedLinksRegistry,
exposedComponentsRegistry,
};
getPluginExtensionRegistriesMock.mockResolvedValue(registries);
addedLinksRegistry.register({
pluginId,
configs: [
{
@ -648,7 +689,7 @@ describe('getObservablePluginLinks()', () => {
],
});
pluginExtensionRegistries.addedComponentsRegistry.register({
addedComponentsRegistry.register({
pluginId,
configs: [
{
@ -685,7 +726,7 @@ describe('getObservablePluginLinks()', () => {
it('should be possible to receive the last state of the registry', async () => {
// Register a new link
pluginExtensionRegistries.addedLinksRegistry.register({
addedLinksRegistry.register({
pluginId,
configs: [
{
@ -709,8 +750,12 @@ describe('getObservablePluginLinks()', () => {
});
it('should receive an empty array if there are no links', async () => {
pluginExtensionRegistries.addedLinksRegistry = new AddedLinksRegistry();
pluginExtensionRegistries.addedComponentsRegistry = new AddedComponentsRegistry();
getPluginExtensionRegistriesMock.mockResolvedValue({
addedLinksRegistry: new AddedLinksRegistry([]),
addedComponentsRegistry: new AddedComponentsRegistry([]),
addedFunctionsRegistry: new AddedFunctionsRegistry([]),
exposedComponentsRegistry: new ExposedComponentsRegistry([]),
});
const observable = getObservablePluginLinks({ extensionPointId }).pipe(first());
const links = await firstValueFrom(observable);
@ -722,11 +767,27 @@ describe('getObservablePluginLinks()', () => {
describe('getObservablePluginComponents()', () => {
const extensionPointId = 'grafana/dashboard/panel/menu/v1';
const pluginId = 'grafana-basic-app';
let addedLinksRegistry: AddedLinksRegistry;
let addedComponentsRegistry: AddedComponentsRegistry;
let addedFunctionsRegistry: AddedFunctionsRegistry;
let exposedComponentsRegistry: ExposedComponentsRegistry;
beforeEach(() => {
pluginExtensionRegistries.addedLinksRegistry = new AddedLinksRegistry();
pluginExtensionRegistries.addedComponentsRegistry = new AddedComponentsRegistry();
pluginExtensionRegistries.addedLinksRegistry.register({
beforeEach(async () => {
addedLinksRegistry = new AddedLinksRegistry([]);
addedComponentsRegistry = new AddedComponentsRegistry([]);
addedFunctionsRegistry = new AddedFunctionsRegistry([]);
exposedComponentsRegistry = new ExposedComponentsRegistry([]);
const registries = {
addedComponentsRegistry,
addedFunctionsRegistry,
addedLinksRegistry,
exposedComponentsRegistry,
};
getPluginExtensionRegistriesMock.mockResolvedValue(registries);
addedLinksRegistry.register({
pluginId,
configs: [
{
@ -739,7 +800,7 @@ describe('getObservablePluginComponents()', () => {
],
});
pluginExtensionRegistries.addedComponentsRegistry.register({
addedComponentsRegistry.register({
pluginId,
configs: [
{
@ -776,7 +837,7 @@ describe('getObservablePluginComponents()', () => {
it('should be possible to receive the last state of the registry', async () => {
// Register a new component
pluginExtensionRegistries.addedComponentsRegistry.register({
addedComponentsRegistry.register({
pluginId,
configs: [
{
@ -801,8 +862,12 @@ describe('getObservablePluginComponents()', () => {
});
it('should receive an empty array if there are no components', async () => {
pluginExtensionRegistries.addedLinksRegistry = new AddedLinksRegistry();
pluginExtensionRegistries.addedComponentsRegistry = new AddedComponentsRegistry();
getPluginExtensionRegistriesMock.mockResolvedValue({
addedLinksRegistry: new AddedLinksRegistry([]),
addedComponentsRegistry: new AddedComponentsRegistry([]),
addedFunctionsRegistry: new AddedFunctionsRegistry([]),
exposedComponentsRegistry: new ExposedComponentsRegistry([]),
});
const observable = getObservablePluginComponents({ extensionPointId }).pipe(first());
const components = await firstValueFrom(observable);

View file

@ -1,5 +1,5 @@
import { isString } from 'lodash';
import { combineLatest, map, Observable } from 'rxjs';
import { combineLatest, from, map, Observable, switchMap } from 'rxjs';
import {
PluginExtensionTypes,
@ -13,7 +13,7 @@ import { log } from './logs/log';
import { AddedComponentRegistryItem } from './registry/AddedComponentsRegistry';
import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry';
import { RegistryType } from './registry/Registry';
import { pluginExtensionRegistries } from './registry/setup';
import { getPluginExtensionRegistries } from './registry/setup';
import type { PluginExtensionRegistries } from './registry/types';
import { GetExtensions, GetExtensionsOptions, GetPluginExtensions } from './types';
import {
@ -38,22 +38,25 @@ export const getObservablePluginExtensions = (
options: Omit<GetExtensionsOptions, 'addedComponentsRegistry' | 'addedLinksRegistry'>
): Observable<ReturnType<GetExtensions>> => {
const { extensionPointId } = options;
const { addedComponentsRegistry, addedLinksRegistry } = pluginExtensionRegistries;
return combineLatest([
addedComponentsRegistry.asObservableSlice((state) => state[extensionPointId]),
addedLinksRegistry.asObservableSlice((state) => state[extensionPointId]),
]).pipe(
map(([components, links]) =>
getPluginExtensions({
...options,
addedComponentsRegistry: {
[extensionPointId]: components,
},
addedLinksRegistry: {
[extensionPointId]: links,
},
})
return from(getPluginExtensionRegistries()).pipe(
switchMap((registries) =>
combineLatest([
registries.addedComponentsRegistry.asObservableSlice((state) => state[extensionPointId]),
registries.addedLinksRegistry.asObservableSlice((state) => state[extensionPointId]),
]).pipe(
map(([components, links]) =>
getPluginExtensions({
...options,
addedComponentsRegistry: {
[extensionPointId]: components,
},
addedLinksRegistry: {
[extensionPointId]: links,
},
})
)
)
)
);
};

View file

@ -1,11 +1,11 @@
import React from 'react';
import { firstValueFrom, take } from 'rxjs';
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
import { AppPluginConfig } from '@grafana/data';
import { log } from '../logs/log';
import { resetLogMock } from '../logs/testUtils';
import { basicApp } from '../test-fixtures/config.apps';
import { isGrafanaDevMode } from '../utils';
import { AddedComponentsRegistry } from './AddedComponentsRegistry';
@ -30,48 +30,17 @@ jest.mock('../logs/log', () => {
});
describe('AddedComponentsRegistry', () => {
const originalApps = config.apps;
const pluginId = 'grafana-basic-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: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
};
const pluginId = basicApp.id;
const apps = [basicApp];
const createRegistry = async (override: AppPluginConfig[] = apps) => new AddedComponentsRegistry(override);
beforeEach(() => {
resetLogMock(log);
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
config.apps = {
[pluginId]: appPluginConfig,
};
});
afterEach(() => {
config.apps = originalApps;
});
it('should return empty registry when no extensions registered', async () => {
const reactiveRegistry = new AddedComponentsRegistry();
const reactiveRegistry = await createRegistry();
const observable = reactiveRegistry.asObservable();
const registry = await firstValueFrom(observable);
expect(registry).toEqual({});
@ -79,7 +48,7 @@ describe('AddedComponentsRegistry', () => {
it('should be possible to register added components in the registry', async () => {
const extensionPointId = `${pluginId}/hello-world/v1`;
const reactiveRegistry = new AddedComponentsRegistry();
const reactiveRegistry = await createRegistry();
reactiveRegistry.register({
pluginId,
@ -107,7 +76,7 @@ describe('AddedComponentsRegistry', () => {
const pluginId1 = 'grafana-basic-app';
const pluginId2 = 'grafana-basic-app2';
const extensionPointId = 'grafana/alerting/home';
const reactiveRegistry = new AddedComponentsRegistry();
const reactiveRegistry = await createRegistry();
// Register extensions for the first plugin
reactiveRegistry.register({
@ -166,7 +135,7 @@ describe('AddedComponentsRegistry', () => {
const pluginId2 = 'grafana-basic-app2';
const extensionPointId1 = 'grafana/alerting/home';
const extensionPointId2 = 'grafana/user/profile/tab';
const reactiveRegistry = new AddedComponentsRegistry();
const reactiveRegistry = await createRegistry();
// Register extensions for the first plugin
reactiveRegistry.register({
@ -226,7 +195,7 @@ describe('AddedComponentsRegistry', () => {
});
it('should be possible to asynchronously register component extensions for the same extension point (same plugin)', async () => {
const reactiveRegistry = new AddedComponentsRegistry();
const reactiveRegistry = await createRegistry();
const extensionPointId = 'grafana/alerting/home';
// Register extensions for the first extension point
@ -265,7 +234,7 @@ describe('AddedComponentsRegistry', () => {
});
it('should be possible to register one extension component targeting multiple extension points', async () => {
const reactiveRegistry = new AddedComponentsRegistry();
const reactiveRegistry = await createRegistry();
const extensionPointId1 = 'grafana/alerting/home';
const extensionPointId2 = 'grafana/user/profile/tab';
@ -304,7 +273,7 @@ describe('AddedComponentsRegistry', () => {
const pluginId2 = 'myorg-extensions-app';
const extensionPointId1 = 'grafana/alerting/home';
const extensionPointId2 = 'grafana/user/profile/tab';
const reactiveRegistry = new AddedComponentsRegistry();
const reactiveRegistry = await createRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
@ -359,7 +328,7 @@ describe('AddedComponentsRegistry', () => {
});
it('should not register component when title is missing', async () => {
const registry = new AddedComponentsRegistry();
const registry = await createRegistry();
const extensionPointId = 'grafana/alerting/home';
registry.register({
@ -381,7 +350,7 @@ describe('AddedComponentsRegistry', () => {
});
it('should not be possible to register a component on a read-only registry', async () => {
const registry = new AddedComponentsRegistry();
const registry = await createRegistry();
const readOnlyRegistry = registry.readOnly();
const extensionPointId = 'grafana/alerting/home';
@ -405,7 +374,7 @@ describe('AddedComponentsRegistry', () => {
it('should pass down fresh registrations to the read-only version of the registry', async () => {
const pluginId = 'grafana-basic-app';
const registry = new AddedComponentsRegistry();
const registry = await createRegistry();
const readOnlyRegistry = registry.readOnly();
const subscribeCallback = jest.fn();
let readOnlyState;
@ -441,7 +410,7 @@ describe('AddedComponentsRegistry', () => {
// Enabling dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const registry = new AddedComponentsRegistry();
const registry = await createRegistry();
const componentConfig = {
title: 'Component title',
description: 'Component description',
@ -449,9 +418,6 @@ describe('AddedComponentsRegistry', () => {
component: () => React.createElement('div', null, 'Hello World1'),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedComponents = [];
registry.register({
pluginId,
configs: [componentConfig],
@ -467,7 +433,7 @@ describe('AddedComponentsRegistry', () => {
// Enabling dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const registry = new AddedComponentsRegistry();
const registry = await createRegistry();
const componentConfig = {
title: 'Component title',
description: 'Component description',
@ -490,7 +456,7 @@ describe('AddedComponentsRegistry', () => {
// Production mode
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
const registry = new AddedComponentsRegistry();
const registry = await createRegistry();
const componentConfig = {
title: 'Component title',
description: 'Component description',
@ -498,9 +464,6 @@ describe('AddedComponentsRegistry', () => {
component: () => React.createElement('div', null, 'Hello World1'),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedComponents = [];
registry.register({
pluginId,
configs: [componentConfig],
@ -516,16 +479,18 @@ describe('AddedComponentsRegistry', () => {
// Enabling dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const registry = new AddedComponentsRegistry();
const componentConfig = {
title: 'Component title',
description: 'Component description',
targets: ['grafana/alerting/home'],
component: () => React.createElement('div', null, 'Hello World1'),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedComponents = [componentConfig];
const { description, targets, title } = componentConfig;
const app = {
...basicApp,
extensions: { ...basicApp.extensions, addedComponents: [{ description, targets, title }] },
};
const registry = await createRegistry([app]);
registry.register({
pluginId,
@ -540,7 +505,7 @@ describe('AddedComponentsRegistry', () => {
describe('asObservableSlice', () => {
it('should return the selected slice from the registry', async () => {
const registry = new AddedComponentsRegistry();
const registry = await createRegistry();
const extensionPointId = 'grafana/alerting/home';
registry.register({
@ -568,7 +533,7 @@ describe('AddedComponentsRegistry', () => {
});
it('should return undefined when the selected key does not exist', async () => {
const registry = new AddedComponentsRegistry();
const registry = await createRegistry();
const observable = registry.asObservableSlice((state) => state['non-existent-key']).pipe(take(1));
await expect(observable).toEmitValuesWith((received) => {
@ -578,7 +543,7 @@ describe('AddedComponentsRegistry', () => {
});
it('should only emit when the selected slice changes', async () => {
const registry = new AddedComponentsRegistry();
const registry = await createRegistry();
const extensionPointId = 'grafana/alerting/home';
const subscribeCallback = jest.fn();
@ -641,7 +606,7 @@ describe('AddedComponentsRegistry', () => {
});
it('should deep freeze the selected slice', async () => {
const registry = new AddedComponentsRegistry();
const registry = await createRegistry();
const extensionPointId = 'grafana/alerting/home';
registry.register({
@ -669,7 +634,7 @@ describe('AddedComponentsRegistry', () => {
});
it('should work with read-only registries', async () => {
const registry = new AddedComponentsRegistry();
const registry = await createRegistry();
const readOnlyRegistry = registry.readOnly();
const extensionPointId = 'grafana/alerting/home';
@ -698,7 +663,7 @@ describe('AddedComponentsRegistry', () => {
});
it('should emit immediately to new subscribers with the current slice value', async () => {
const registry = new AddedComponentsRegistry();
const registry = await createRegistry();
const extensionPointId = 'grafana/alerting/home';
registry.register({
@ -725,7 +690,7 @@ describe('AddedComponentsRegistry', () => {
});
it('should not emit when Object.is returns true for the same value', async () => {
const registry = new AddedComponentsRegistry();
const registry = await createRegistry();
const extensionPointId = 'grafana/alerting/home';
const subscribeCallback = jest.fn();

View file

@ -1,6 +1,6 @@
import { ReplaySubject } from 'rxjs';
import { PluginExtensionAddedComponentConfig } from '@grafana/data';
import { AppPluginConfig, PluginExtensionAddedComponentConfig } from '@grafana/data';
import * as errors from '../errors';
import { isGrafanaDevMode, wrapWithPluginContext } from '../utils';
@ -22,12 +22,13 @@ export class AddedComponentsRegistry extends Registry<
PluginExtensionAddedComponentConfig
> {
constructor(
apps: AppPluginConfig[],
options: {
registrySubject?: ReplaySubject<RegistryType<AddedComponentRegistryItem[]>>;
initialState?: RegistryType<AddedComponentRegistryItem[]>;
} = {}
) {
super(options);
super(apps, options);
}
mapToRegistry(
@ -51,7 +52,7 @@ export class AddedComponentsRegistry extends Registry<
if (
pluginId !== 'grafana' &&
isGrafanaDevMode() &&
isAddedComponentMetaInfoMissing(pluginId, config, configLog)
isAddedComponentMetaInfoMissing(pluginId, config, configLog, this.apps)
) {
continue;
}
@ -85,7 +86,7 @@ export class AddedComponentsRegistry extends Registry<
// Returns a read-only version of the registry.
readOnly() {
return new AddedComponentsRegistry({
return new AddedComponentsRegistry(this.apps, {
registrySubject: this.registrySubject,
});
}

View file

@ -1,10 +1,10 @@
import { firstValueFrom, take } from 'rxjs';
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
import { AppPluginConfig } from '@grafana/data';
import { log } from '../logs/log';
import { resetLogMock } from '../logs/testUtils';
import { basicApp } from '../test-fixtures/config.apps';
import { isGrafanaDevMode } from '../utils';
import { AddedFunctionsRegistry } from './AddedFunctionsRegistry';
@ -29,55 +29,24 @@ jest.mock('../logs/log', () => {
});
describe('addedFunctionsRegistry', () => {
const originalApps = config.apps;
const pluginId = 'grafana-basic-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: {
addedFunctions: [],
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
},
};
const pluginId = basicApp.id;
const apps = [basicApp];
const createRegistry = async (override: AppPluginConfig[] = apps) => new AddedFunctionsRegistry(override);
beforeEach(() => {
resetLogMock(log);
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
config.apps = {
[pluginId]: appPluginConfig,
};
});
afterEach(() => {
config.apps = originalApps;
});
it('should return empty registry when no extensions registered', async () => {
const addedFunctionsRegistry = new AddedFunctionsRegistry();
const addedFunctionsRegistry = await createRegistry();
const observable = addedFunctionsRegistry.asObservable();
const registry = await firstValueFrom(observable);
expect(registry).toEqual({});
});
it('should be possible to register function extensions in the registry', async () => {
const addedFunctionsRegistry = new AddedFunctionsRegistry();
const addedFunctionsRegistry = await createRegistry();
addedFunctionsRegistry.register({
pluginId,
@ -122,7 +91,7 @@ describe('addedFunctionsRegistry', () => {
});
it('should not emit when registering with empty configs', async () => {
const addedFunctionsRegistry = new AddedFunctionsRegistry();
const addedFunctionsRegistry = await createRegistry();
const observable = addedFunctionsRegistry.asObservable();
const subscribeCallback = jest.fn();
@ -147,7 +116,7 @@ describe('addedFunctionsRegistry', () => {
});
it('should not change registry state when registering with empty configs after previous registrations', async () => {
const addedFunctionsRegistry = new AddedFunctionsRegistry();
const addedFunctionsRegistry = await createRegistry();
// First register some extensions
addedFunctionsRegistry.register({
@ -178,7 +147,7 @@ describe('addedFunctionsRegistry', () => {
it('should be possible to asynchronously register function extensions for the same placement (different plugins)', async () => {
const pluginId1 = 'grafana-basic-app';
const pluginId2 = 'grafana-basic-app2';
const reactiveRegistry = new AddedFunctionsRegistry();
const reactiveRegistry = await createRegistry();
// Register extensions for the first plugin
reactiveRegistry.register({
@ -245,7 +214,7 @@ describe('addedFunctionsRegistry', () => {
it('should be possible to asynchronously register function extensions for a different placement (different plugin)', async () => {
const pluginId1 = 'grafana-basic-app';
const pluginId2 = 'grafana-basic-app2';
const reactiveRegistry = new AddedFunctionsRegistry();
const reactiveRegistry = await createRegistry();
// Register extensions for the first plugin
reactiveRegistry.register({
@ -314,7 +283,7 @@ describe('addedFunctionsRegistry', () => {
it('should be possible to asynchronously register function extensions for the same placement (same plugin)', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new AddedFunctionsRegistry();
const reactiveRegistry = await createRegistry();
// Register extensions for the first extension point
reactiveRegistry.register({
@ -368,7 +337,7 @@ describe('addedFunctionsRegistry', () => {
it('should be possible to asynchronously register function extensions for a different placement (same plugin)', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new AddedFunctionsRegistry();
const reactiveRegistry = await createRegistry();
// Register extensions for the first extension point
reactiveRegistry.register({
@ -424,7 +393,7 @@ describe('addedFunctionsRegistry', () => {
it('should notify subscribers when the registry changes', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new AddedFunctionsRegistry();
const reactiveRegistry = await createRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
@ -484,7 +453,7 @@ describe('addedFunctionsRegistry', () => {
it('should give the last version of the registry for new subscribers', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new AddedFunctionsRegistry();
const reactiveRegistry = await createRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
@ -518,9 +487,9 @@ describe('addedFunctionsRegistry', () => {
});
});
it('should not register a function extension if it has an invalid fn function', () => {
it('should not register a function extension if it has an invalid fn function', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new AddedFunctionsRegistry();
const reactiveRegistry = await createRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
@ -546,9 +515,9 @@ describe('addedFunctionsRegistry', () => {
expect(registry).toEqual({});
});
it('should not register a function extension if it has invalid properties (empty title)', () => {
it('should not register a function extension if it has invalid properties (empty title)', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new AddedFunctionsRegistry();
const reactiveRegistry = await createRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
@ -574,7 +543,7 @@ describe('addedFunctionsRegistry', () => {
it('should not be possible to register a function on a read-only registry', async () => {
const pluginId = 'grafana-basic-app';
const registry = new AddedFunctionsRegistry();
const registry = await createRegistry();
const readOnlyRegistry = registry.readOnly();
expect(() => {
@ -597,7 +566,7 @@ describe('addedFunctionsRegistry', () => {
it('should pass down fresh registrations to the read-only version of the registry', async () => {
const pluginId = 'grafana-basic-app';
const registry = new AddedFunctionsRegistry();
const registry = await createRegistry();
const readOnlyRegistry = registry.readOnly();
const subscribeCallback = jest.fn();
let readOnlyState;
@ -633,7 +602,7 @@ describe('addedFunctionsRegistry', () => {
// Enabling dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const registry = new AddedFunctionsRegistry();
const registry = await createRegistry();
const fnConfig = {
title: 'Function 1',
description: 'Function 1 description',
@ -641,9 +610,6 @@ describe('addedFunctionsRegistry', () => {
fn: jest.fn().mockReturnValue({}),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedFunctions = [];
registry.register({
pluginId,
configs: [fnConfig],
@ -659,7 +625,7 @@ describe('addedFunctionsRegistry', () => {
// Enabling dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const registry = new AddedFunctionsRegistry();
const registry = await createRegistry();
const fnConfig = {
title: 'Function 1',
description: 'Function 1 description',
@ -682,7 +648,7 @@ describe('addedFunctionsRegistry', () => {
// Production mode
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
const registry = new AddedFunctionsRegistry();
const registry = await createRegistry();
const fnConfig = {
title: 'Function 1',
description: 'Function 1 description',
@ -690,9 +656,6 @@ describe('addedFunctionsRegistry', () => {
fn: jest.fn().mockReturnValue({}),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedFunctions = [];
registry.register({
pluginId,
configs: [fnConfig],
@ -708,16 +671,18 @@ describe('addedFunctionsRegistry', () => {
// Enabling dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const registry = new AddedFunctionsRegistry();
const fnConfig = {
title: 'Function 1',
description: 'Function 1 description',
targets: ['grafana/dashboard/panel/menu'],
fn: jest.fn().mockReturnValue({}),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedFunctions = [fnConfig];
const { description, targets, title } = fnConfig;
const app = {
...basicApp,
extensions: { ...basicApp.extensions, addedFunctions: [{ description, targets, title }] },
};
const registry = await createRegistry([app]);
registry.register({
pluginId,
@ -732,7 +697,7 @@ describe('addedFunctionsRegistry', () => {
describe('asObservableSlice', () => {
it('should return the selected slice from the registry', async () => {
const registry = new AddedFunctionsRegistry();
const registry = await createRegistry();
const extensionPointId = 'grafana/dashboard/panel/menu';
registry.register({
@ -760,7 +725,7 @@ describe('addedFunctionsRegistry', () => {
});
it('should return undefined when the selected key does not exist', async () => {
const registry = new AddedFunctionsRegistry();
const registry = await createRegistry();
const observable = registry.asObservableSlice((state) => state['non-existent-key']).pipe(take(1));
await expect(observable).toEmitValuesWith((received) => {
@ -770,7 +735,7 @@ describe('addedFunctionsRegistry', () => {
});
it('should only emit when the selected slice changes (distinctUntilChanged)', async () => {
const registry = new AddedFunctionsRegistry();
const registry = await createRegistry();
const extensionPointId = 'grafana/dashboard/panel/menu';
const subscribeCallback = jest.fn();
@ -833,7 +798,7 @@ describe('addedFunctionsRegistry', () => {
});
it('should deep freeze the selected slice', async () => {
const registry = new AddedFunctionsRegistry();
const registry = await createRegistry();
const extensionPointId = 'grafana/dashboard/panel/menu';
registry.register({
@ -861,7 +826,7 @@ describe('addedFunctionsRegistry', () => {
});
it('should work with read-only registries', async () => {
const registry = new AddedFunctionsRegistry();
const registry = await createRegistry();
const readOnlyRegistry = registry.readOnly();
const extensionPointId = 'grafana/dashboard/panel/menu';
@ -890,7 +855,7 @@ describe('addedFunctionsRegistry', () => {
});
it('should emit immediately to new subscribers with the current slice value', async () => {
const registry = new AddedFunctionsRegistry();
const registry = await createRegistry();
const extensionPointId = 'grafana/dashboard/panel/menu';
registry.register({

View file

@ -1,7 +1,7 @@
import { isFunction } from 'lodash';
import { ReplaySubject } from 'rxjs';
import { PluginExtensionAddedFunctionConfig } from '@grafana/data';
import { AppPluginConfig, PluginExtensionAddedFunctionConfig } from '@grafana/data';
import * as errors from '../errors';
import { isGrafanaDevMode } from '../utils';
@ -20,12 +20,13 @@ export type AddedFunctionsRegistryItem<Signature = unknown> = {
export class AddedFunctionsRegistry extends Registry<AddedFunctionsRegistryItem[], PluginExtensionAddedFunctionConfig> {
constructor(
apps: AppPluginConfig[],
options: {
registrySubject?: ReplaySubject<RegistryType<AddedFunctionsRegistryItem[]>>;
initialState?: RegistryType<AddedFunctionsRegistryItem[]>;
} = {}
) {
super(options);
super(apps, options);
}
mapToRegistry(
@ -49,7 +50,11 @@ export class AddedFunctionsRegistry extends Registry<AddedFunctionsRegistryItem[
continue;
}
if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedFunctionMetaInfoMissing(pluginId, config, configLog)) {
if (
pluginId !== 'grafana' &&
isGrafanaDevMode() &&
isAddedFunctionMetaInfoMissing(pluginId, config, configLog, this.apps)
) {
continue;
}
@ -78,7 +83,7 @@ export class AddedFunctionsRegistry extends Registry<AddedFunctionsRegistryItem[
// Returns a read-only version of the registry.
readOnly() {
return new AddedFunctionsRegistry({
return new AddedFunctionsRegistry(this.apps, {
registrySubject: this.registrySubject,
});
}

View file

@ -1,10 +1,10 @@
import { firstValueFrom, take } from 'rxjs';
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
import { AppPluginConfig } from '@grafana/data';
import { log } from '../logs/log';
import { resetLogMock } from '../logs/testUtils';
import { basicApp } from '../test-fixtures/config.apps';
import { isGrafanaDevMode } from '../utils';
import { AddedLinksRegistry } from './AddedLinksRegistry';
@ -29,55 +29,24 @@ jest.mock('../logs/log', () => {
});
describe('AddedLinksRegistry', () => {
const originalApps = config.apps;
const pluginId = 'grafana-basic-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: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
};
const pluginId = basicApp.id;
const apps = [basicApp];
const createRegistry = async (override: AppPluginConfig[] = apps) => new AddedLinksRegistry(override);
beforeEach(() => {
beforeEach(async () => {
resetLogMock(log);
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
config.apps = {
[pluginId]: appPluginConfig,
};
});
afterEach(() => {
config.apps = originalApps;
});
it('should return empty registry when no extensions registered', async () => {
const addedLinksRegistry = new AddedLinksRegistry();
const addedLinksRegistry = await createRegistry();
const observable = addedLinksRegistry.asObservable();
const registry = await firstValueFrom(observable);
expect(registry).toEqual({});
});
it('should be possible to register link extensions in the registry', async () => {
const addedLinksRegistry = new AddedLinksRegistry();
const addedLinksRegistry = await createRegistry();
addedLinksRegistry.register({
pluginId,
@ -129,7 +98,7 @@ describe('AddedLinksRegistry', () => {
it('should be possible to asynchronously register link extensions for the same placement (different plugins)', async () => {
const pluginId1 = 'grafana-basic-app';
const pluginId2 = 'grafana-basic-app2';
const reactiveRegistry = new AddedLinksRegistry();
const reactiveRegistry = await createRegistry();
// Register extensions for the first plugin
reactiveRegistry.register({
@ -201,7 +170,7 @@ describe('AddedLinksRegistry', () => {
it('should be possible to asynchronously register link extensions for a different placement (different plugin)', async () => {
const pluginId1 = 'grafana-basic-app';
const pluginId2 = 'grafana-basic-app2';
const reactiveRegistry = new AddedLinksRegistry();
const reactiveRegistry = await createRegistry();
// Register extensions for the first plugin
reactiveRegistry.register({
@ -275,7 +244,7 @@ describe('AddedLinksRegistry', () => {
it('should be possible to asynchronously register link extensions for the same placement (same plugin)', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new AddedLinksRegistry();
const reactiveRegistry = await createRegistry();
// Register extensions for the first extension point
reactiveRegistry.register({
@ -333,7 +302,7 @@ describe('AddedLinksRegistry', () => {
it('should be possible to asynchronously register link extensions for a different placement (same plugin)', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new AddedLinksRegistry();
const reactiveRegistry = await createRegistry();
// Register extensions for the first extension point
reactiveRegistry.register({
@ -393,7 +362,7 @@ describe('AddedLinksRegistry', () => {
it('should notify subscribers when the registry changes', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new AddedLinksRegistry();
const reactiveRegistry = await createRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
@ -459,7 +428,7 @@ describe('AddedLinksRegistry', () => {
it('should give the last version of the registry for new subscribers', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new AddedLinksRegistry();
const reactiveRegistry = await createRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
@ -496,9 +465,9 @@ describe('AddedLinksRegistry', () => {
});
});
it('should not register a link extension if it has an invalid configure() function', () => {
it('should not register a link extension if it has an invalid configure() function', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new AddedLinksRegistry();
const reactiveRegistry = await createRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
@ -525,9 +494,9 @@ describe('AddedLinksRegistry', () => {
expect(registry).toEqual({});
});
it('should not register a link extension if it has invalid properties (empty title / description)', () => {
it('should not register a link extension if it has invalid properties (empty title / description)', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new AddedLinksRegistry();
const reactiveRegistry = await createRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
@ -555,7 +524,7 @@ describe('AddedLinksRegistry', () => {
it('should not be possible to register a link on a read-only registry', async () => {
const pluginId = 'grafana-basic-app';
const registry = new AddedLinksRegistry();
const registry = await createRegistry();
const readOnlyRegistry = registry.readOnly();
expect(() => {
@ -579,7 +548,7 @@ describe('AddedLinksRegistry', () => {
it('should pass down fresh registrations to the read-only version of the registry', async () => {
const pluginId = 'grafana-basic-app';
const registry = new AddedLinksRegistry();
const registry = await createRegistry();
const readOnlyRegistry = registry.readOnly();
const subscribeCallback = jest.fn();
let readOnlyState;
@ -616,7 +585,7 @@ describe('AddedLinksRegistry', () => {
// Enabling dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const registry = new AddedLinksRegistry();
const registry = await createRegistry();
const linkConfig = {
title: 'Link 1',
description: 'Link 1 description',
@ -625,9 +594,6 @@ describe('AddedLinksRegistry', () => {
configure: jest.fn().mockReturnValue({}),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedLinks = [];
registry.register({
pluginId,
configs: [linkConfig],
@ -643,7 +609,7 @@ describe('AddedLinksRegistry', () => {
// Enabling dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const registry = new AddedLinksRegistry();
const registry = await createRegistry();
const linkConfig = {
title: 'Link 1',
description: 'Link 1 description',
@ -667,7 +633,7 @@ describe('AddedLinksRegistry', () => {
// Production mode
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
const registry = new AddedLinksRegistry();
const registry = await createRegistry();
const linkConfig = {
title: 'Link 1',
description: 'Link 1 description',
@ -676,9 +642,6 @@ describe('AddedLinksRegistry', () => {
configure: jest.fn().mockReturnValue({}),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedLinks = [];
registry.register({
pluginId,
configs: [linkConfig],
@ -694,7 +657,6 @@ describe('AddedLinksRegistry', () => {
// Enabling dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const registry = new AddedLinksRegistry();
const linkConfig = {
title: 'Link 1',
description: 'Link 1 description',
@ -702,9 +664,12 @@ describe('AddedLinksRegistry', () => {
targets: ['grafana/dashboard/panel/menu'],
configure: jest.fn().mockReturnValue({}),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedLinks = [linkConfig];
const { description, targets, title } = linkConfig;
const app = {
...basicApp,
extensions: { ...basicApp.extensions, addedLinks: [{ description, targets, title }] },
};
const registry = await createRegistry([app]);
registry.register({
pluginId,
@ -719,7 +684,7 @@ describe('AddedLinksRegistry', () => {
describe('asObservableSlice', () => {
it('should return the selected slice from the registry', async () => {
const registry = new AddedLinksRegistry();
const registry = await createRegistry();
const extensionPointId = 'grafana/dashboard/panel/menu';
registry.register({
@ -748,7 +713,7 @@ describe('AddedLinksRegistry', () => {
});
it('should return undefined when the selected key does not exist', async () => {
const registry = new AddedLinksRegistry();
const registry = await createRegistry();
const observable = registry.asObservableSlice((state) => state['non-existent-key']).pipe(take(1));
await expect(observable).toEmitValuesWith((received) => {
@ -758,7 +723,7 @@ describe('AddedLinksRegistry', () => {
});
it('should only emit when the selected slice changes (distinctUntilChanged)', async () => {
const registry = new AddedLinksRegistry();
const registry = await createRegistry();
const extensionPointId = 'grafana/dashboard/panel/menu';
const subscribeCallback = jest.fn();
@ -824,7 +789,7 @@ describe('AddedLinksRegistry', () => {
});
it('should deep freeze the selected slice', async () => {
const registry = new AddedLinksRegistry();
const registry = await createRegistry();
const extensionPointId = 'grafana/dashboard/panel/menu';
registry.register({
@ -853,7 +818,7 @@ describe('AddedLinksRegistry', () => {
});
it('should work with read-only registries', async () => {
const registry = new AddedLinksRegistry();
const registry = await createRegistry();
const readOnlyRegistry = registry.readOnly();
const extensionPointId = 'grafana/dashboard/panel/menu';
@ -883,7 +848,7 @@ describe('AddedLinksRegistry', () => {
});
it('should emit immediately to new subscribers with the current slice value', async () => {
const registry = new AddedLinksRegistry();
const registry = await createRegistry();
const extensionPointId = 'grafana/dashboard/panel/menu';
registry.register({

View file

@ -1,6 +1,6 @@
import { ReplaySubject } from 'rxjs';
import { IconName, PluginExtensionAddedLinkConfig } from '@grafana/data';
import { AppPluginConfig, IconName, PluginExtensionAddedLinkConfig } from '@grafana/data';
import { PluginAddedLinksConfigureFunc, PluginExtensionEventHelpers } from '@grafana/data/internal';
import * as errors from '../errors';
@ -26,12 +26,13 @@ export type AddedLinkRegistryItem<Context extends object = object> = {
export class AddedLinksRegistry extends Registry<AddedLinkRegistryItem[], PluginExtensionAddedLinkConfig> {
constructor(
apps: AppPluginConfig[],
options: {
registrySubject?: ReplaySubject<RegistryType<AddedLinkRegistryItem[]>>;
initialState?: RegistryType<AddedLinkRegistryItem[]>;
} = {}
) {
super(options);
super(apps, options);
}
mapToRegistry(
@ -66,7 +67,11 @@ export class AddedLinksRegistry extends Registry<AddedLinkRegistryItem[], Plugin
continue;
}
if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedLinkMetaInfoMissing(pluginId, config, configLog)) {
if (
pluginId !== 'grafana' &&
isGrafanaDevMode() &&
isAddedLinkMetaInfoMissing(pluginId, config, configLog, this.apps)
) {
continue;
}
@ -90,7 +95,7 @@ export class AddedLinksRegistry extends Registry<AddedLinkRegistryItem[], Plugin
// Returns a read-only version of the registry.
readOnly() {
return new AddedLinksRegistry({
return new AddedLinksRegistry(this.apps, {
registrySubject: this.registrySubject,
});
}

View file

@ -1,11 +1,11 @@
import React from 'react';
import { firstValueFrom, take } from 'rxjs';
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
import { AppPluginConfig } from '@grafana/data';
import { log } from '../logs/log';
import { resetLogMock } from '../logs/testUtils';
import { basicApp } from '../test-fixtures/config.apps';
import { isGrafanaDevMode } from '../utils';
import { ExposedComponentsRegistry } from './ExposedComponentsRegistry';
@ -30,48 +30,17 @@ jest.mock('../logs/log', () => {
});
describe('ExposedComponentsRegistry', () => {
const originalApps = config.apps;
const pluginId = 'grafana-basic-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: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
};
const pluginId = basicApp.id;
const apps = [basicApp];
const createRegistry = async (override: AppPluginConfig[] = apps) => new ExposedComponentsRegistry(override);
beforeEach(() => {
resetLogMock(log);
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
config.apps = {
[pluginId]: appPluginConfig,
};
});
afterEach(() => {
config.apps = originalApps;
});
it('should return empty registry when no exposed components have been registered', async () => {
const reactiveRegistry = new ExposedComponentsRegistry();
const reactiveRegistry = await createRegistry();
const observable = reactiveRegistry.asObservable();
const registry = await firstValueFrom(observable);
expect(registry).toEqual({});
@ -80,7 +49,7 @@ describe('ExposedComponentsRegistry', () => {
it('should be possible to register exposed components in the registry', async () => {
const pluginId = 'grafana-basic-app';
const id = `${pluginId}/hello-world/v1`;
const reactiveRegistry = new ExposedComponentsRegistry();
const reactiveRegistry = await createRegistry();
reactiveRegistry.register({
pluginId,
@ -110,7 +79,7 @@ describe('ExposedComponentsRegistry', () => {
const id1 = `${pluginId}/hello-world1/v1`;
const id2 = `${pluginId}/hello-world2/v1`;
const id3 = `${pluginId}/hello-world3/v1`;
const reactiveRegistry = new ExposedComponentsRegistry();
const reactiveRegistry = await createRegistry();
reactiveRegistry.register({
pluginId,
@ -151,7 +120,7 @@ describe('ExposedComponentsRegistry', () => {
const id2 = `${pluginId1}/hello-world2/v1`;
const id3 = `${pluginId2}/hello-world1/v1`;
const id4 = `${pluginId2}/hello-world2/v1`;
const reactiveRegistry = new ExposedComponentsRegistry();
const reactiveRegistry = await createRegistry();
reactiveRegistry.register({
pluginId: pluginId1,
@ -199,7 +168,7 @@ describe('ExposedComponentsRegistry', () => {
});
it('should notify subscribers when the registry changes', async () => {
const registry = new ExposedComponentsRegistry();
const registry = await createRegistry();
const observable = registry.asObservable();
const subscribeCallback = jest.fn();
@ -241,7 +210,7 @@ describe('ExposedComponentsRegistry', () => {
});
it('should give the last version of the registry for new subscribers', async () => {
const registry = new ExposedComponentsRegistry();
const registry = await createRegistry();
const observable = registry.asObservable();
const subscribeCallback = jest.fn();
@ -272,7 +241,7 @@ describe('ExposedComponentsRegistry', () => {
});
it('should log an error if another component with the same id already exists in the registry', async () => {
const registry = new ExposedComponentsRegistry();
const registry = await createRegistry();
registry.register({
pluginId: 'grafana-basic-app1',
configs: [
@ -312,7 +281,7 @@ describe('ExposedComponentsRegistry', () => {
});
it('should skip registering component and log an error when id is not prefixed with plugin id', async () => {
const registry = new ExposedComponentsRegistry();
const registry = await createRegistry();
registry.register({
pluginId: 'grafana-basic-app1',
configs: [
@ -333,7 +302,7 @@ describe('ExposedComponentsRegistry', () => {
});
it('should not register component when title is missing', async () => {
const registry = new ExposedComponentsRegistry();
const registry = await createRegistry();
registry.register({
pluginId: 'grafana-basic-app',
@ -355,7 +324,7 @@ describe('ExposedComponentsRegistry', () => {
it('should not be possible to register a component on a read-only registry', async () => {
const pluginId = 'grafana-basic-app';
const registry = new ExposedComponentsRegistry();
const registry = await createRegistry();
const readOnlyRegistry = registry.readOnly();
expect(() => {
@ -378,7 +347,7 @@ describe('ExposedComponentsRegistry', () => {
it('should pass down fresh registrations to the read-only version of the registry', async () => {
const pluginId = 'grafana-basic-app';
const registry = new ExposedComponentsRegistry();
const registry = await createRegistry();
const readOnlyRegistry = registry.readOnly();
const subscribeCallback = jest.fn();
let readOnlyState;
@ -414,7 +383,6 @@ describe('ExposedComponentsRegistry', () => {
// Enabling dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const registry = new ExposedComponentsRegistry();
const componentConfig = {
id: `${pluginId}/exposed-component/v1`,
title: 'Component title',
@ -422,9 +390,7 @@ describe('ExposedComponentsRegistry', () => {
component: () => React.createElement('div', null, 'Hello World1'),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.exposedComponents = [];
const registry = await createRegistry();
registry.register({
pluginId,
configs: [componentConfig],
@ -440,7 +406,6 @@ describe('ExposedComponentsRegistry', () => {
// Enabling dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const registry = new ExposedComponentsRegistry();
const componentConfig = {
id: `${pluginId}/exposed-component/v1`,
title: 'Component title',
@ -448,6 +413,7 @@ describe('ExposedComponentsRegistry', () => {
component: () => React.createElement('div', null, 'Hello World1'),
};
const registry = await createRegistry();
registry.register({
pluginId: 'grafana',
configs: [componentConfig],
@ -463,7 +429,6 @@ describe('ExposedComponentsRegistry', () => {
// Production mode
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
const registry = new ExposedComponentsRegistry();
const componentConfig = {
id: `${pluginId}/exposed-component/v1`,
title: 'Component title',
@ -471,9 +436,7 @@ describe('ExposedComponentsRegistry', () => {
component: () => React.createElement('div', null, 'Hello World1'),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.exposedComponents = [];
const registry = await createRegistry();
registry.register({
pluginId,
configs: [componentConfig],
@ -489,17 +452,19 @@ describe('ExposedComponentsRegistry', () => {
// Enabling dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const registry = new ExposedComponentsRegistry();
const componentConfig = {
id: `${pluginId}/exposed-component/v1`,
title: 'Component title',
description: 'Component description',
component: () => React.createElement('div', null, 'Hello World1'),
};
const { description, id, title } = componentConfig;
const app = {
...basicApp,
extensions: { ...basicApp.extensions, exposedComponents: [{ description, id, title }] },
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.exposedComponents = [componentConfig];
const registry = await createRegistry([app]);
registry.register({
pluginId,
configs: [componentConfig],
@ -513,7 +478,7 @@ describe('ExposedComponentsRegistry', () => {
describe('asObservableSlice', () => {
it('should return the selected exposed component from the registry', async () => {
const registry = new ExposedComponentsRegistry();
const registry = await createRegistry();
const componentId = 'test-plugin/exposed-component/v1';
registry.register({
@ -540,7 +505,7 @@ describe('ExposedComponentsRegistry', () => {
});
it('should deep freeze exposed components', async () => {
const registry = new ExposedComponentsRegistry();
const registry = await createRegistry();
const componentId = 'test-plugin/exposed-component/v1';
registry.register({
@ -566,7 +531,7 @@ describe('ExposedComponentsRegistry', () => {
});
it('should only emit when the selected exposed component changes', async () => {
const registry = new ExposedComponentsRegistry();
const registry = await createRegistry();
const componentId1 = 'test-plugin/component1/v1';
const componentId2 = 'test-plugin/component2/v1';
const subscribeCallback = jest.fn();

View file

@ -1,6 +1,6 @@
import { ReplaySubject } from 'rxjs';
import { PluginExtensionExposedComponentConfig } from '@grafana/data';
import { AppPluginConfig, PluginExtensionExposedComponentConfig } from '@grafana/data';
import * as errors from '../errors';
import { isGrafanaDevMode } from '../utils';
@ -22,12 +22,13 @@ export class ExposedComponentsRegistry extends Registry<
PluginExtensionExposedComponentConfig
> {
constructor(
apps: AppPluginConfig[],
options: {
registrySubject?: ReplaySubject<RegistryType<ExposedComponentRegistryItem>>;
initialState?: RegistryType<ExposedComponentRegistryItem>;
} = {}
) {
super(options);
super(apps, options);
}
mapToRegistry(
@ -65,7 +66,7 @@ export class ExposedComponentsRegistry extends Registry<
if (
pluginId !== 'grafana' &&
isGrafanaDevMode() &&
isExposedComponentMetaInfoMissing(pluginId, config, pointIdLog)
isExposedComponentMetaInfoMissing(pluginId, config, pointIdLog, this.apps)
) {
continue;
}
@ -80,7 +81,7 @@ export class ExposedComponentsRegistry extends Registry<
// Returns a read-only version of the registry.
readOnly() {
return new ExposedComponentsRegistry({
return new ExposedComponentsRegistry(this.apps, {
registrySubject: this.registrySubject,
});
}

View file

@ -5,7 +5,7 @@ import { AddedComponentsRegistry } from './AddedComponentsRegistry';
describe('Registry.asObservableSlice ', () => {
describe('Shared behaviour', () => {
it('should handle selecting a non-existent key that later gets added', async () => {
const registry = new AddedComponentsRegistry();
const registry = new AddedComponentsRegistry([]);
const extensionPointId = 'grafana/alerting/home';
const subscribeCallback = jest.fn();
@ -35,7 +35,7 @@ describe('Registry.asObservableSlice ', () => {
});
it('should handle multiple subscribers independently', async () => {
const registry = new AddedComponentsRegistry();
const registry = new AddedComponentsRegistry([]);
const extensionPointId1 = 'grafana/alerting/home';
const extensionPointId2 = 'grafana/other/point';
const callback1 = jest.fn();

View file

@ -1,5 +1,7 @@
import { Observable, ReplaySubject, Subject, distinctUntilChanged, firstValueFrom, map, scan, startWith } from 'rxjs';
import { AppPluginConfig } from '@grafana/data';
import { ExtensionsLog, log } from '../logs/log';
import { deepFreeze } from '../utils';
@ -25,11 +27,14 @@ export abstract class Registry<TRegistryValue extends object | unknown[] | Recor
// (It will buffer the last value on the stream - the registry - and emit it to new subscribers immediately.)
protected registrySubject: ReplaySubject<RegistryType<TRegistryValue>>;
constructor(options: {
registrySubject?: ReplaySubject<RegistryType<TRegistryValue>>;
initialState?: RegistryType<TRegistryValue>;
log?: ExtensionsLog;
}) {
constructor(
protected apps: AppPluginConfig[],
options: {
registrySubject?: ReplaySubject<RegistryType<TRegistryValue>>;
initialState?: RegistryType<TRegistryValue>;
log?: ExtensionsLog;
}
) {
this.resultSubject = new Subject<PluginExtensionConfigs<TMapType>>();
this.logger = options.log ?? log;
this.isReadOnly = false;

View file

@ -0,0 +1,72 @@
import { MonitoringLogger } from '@grafana/runtime';
import { getAppPluginMetas, invalidateCache, setLogger } from '@grafana/runtime/internal';
import { getPluginExtensionRegistries } from './setup';
jest.mock('@grafana/runtime/internal', () => ({
...jest.requireActual('@grafana/runtime/internal'),
getAppPluginMetas: jest.fn(),
}));
const getAppPluginMetasMock = jest.mocked(getAppPluginMetas);
let logger: MonitoringLogger;
describe('getPluginExtensionRegistries', () => {
beforeEach(() => {
jest.resetAllMocks();
invalidateCache();
getAppPluginMetasMock.mockResolvedValue([]);
logger = {
logDebug: jest.fn(),
logError: jest.fn(),
logInfo: jest.fn(),
logMeasurement: jest.fn(),
logWarning: jest.fn(),
};
setLogger(logger);
});
test('should only call getAppPluginMetas once', async () => {
const promise1 = getPluginExtensionRegistries();
const promise2 = getPluginExtensionRegistries();
await Promise.all([promise1, promise2]);
expect(getAppPluginMetasMock).toHaveBeenCalledTimes(1);
});
test('should return the same promise instance for concurrent calls', async () => {
const promise1 = getPluginExtensionRegistries();
const promise2 = getPluginExtensionRegistries();
const promise3 = getPluginExtensionRegistries();
expect(promise1).toStrictEqual(promise2);
expect(promise2).toStrictEqual(promise3);
expect(promise3).toStrictEqual(promise1);
const [first, second, third] = await Promise.all([promise1, promise2, promise3]);
expect(first).toBe(second);
expect(second).toBe(third);
expect(third).toBe(first);
});
test('should not cache promise if getAppPluginMetas throws', async () => {
getAppPluginMetasMock.mockRejectedValueOnce(new Error('Some error'));
const first = await getPluginExtensionRegistries();
const second = await getPluginExtensionRegistries();
const third = await getPluginExtensionRegistries();
expect(getAppPluginMetasMock).toHaveBeenCalledTimes(2); // first + second (because first throws), third is cached
expect(first).not.toBe(second);
expect(first).not.toBe(third);
expect(second).toBe(third);
expect(logger.logError).toHaveBeenCalledTimes(1);
expect(logger.logError).toHaveBeenCalledWith(new Error('Something failed while resolving a cached promise'), {
message: 'Some error',
stack: expect.any(String),
key: 'initPluginExtensionRegistries',
});
});
});

View file

@ -1,4 +1,6 @@
import { PluginExtensionExposedComponents } from '@grafana/data';
/* eslint-disable @grafana/i18n/no-untranslated-strings */
import { AppPluginConfig, PluginExtensionExposedComponents } from '@grafana/data';
import { getAppPluginMetas, getCachedPromise } from '@grafana/runtime/internal';
import CentralAlertHistorySceneExposedComponent from 'app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistorySceneExposedComponent';
import { CreateAlertFromPanelExposedComponent } from 'app/features/alerting/unified/extensions/CreateAlertFromPanelExposedComponent';
import { AddToDashboardFormExposedComponent } from 'app/features/dashboard-scene/addToDashboard/AddToDashboardFormExposedComponent';
@ -12,50 +14,69 @@ import { AddedLinksRegistry } from './AddedLinksRegistry';
import { ExposedComponentsRegistry } from './ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './types';
export const addedComponentsRegistry = new AddedComponentsRegistry();
export const exposedComponentsRegistry = new ExposedComponentsRegistry();
export const addedLinksRegistry = new AddedLinksRegistry();
export const addedFunctionsRegistry = new AddedFunctionsRegistry();
export const pluginExtensionRegistries: PluginExtensionRegistries = {
addedComponentsRegistry,
exposedComponentsRegistry,
addedLinksRegistry,
addedFunctionsRegistry,
};
function initRegistries(apps: AppPluginConfig[]): PluginExtensionRegistries {
const addedComponentsRegistry = new AddedComponentsRegistry(apps);
const exposedComponentsRegistry = new ExposedComponentsRegistry(apps);
const addedLinksRegistry = new AddedLinksRegistry(apps);
const addedFunctionsRegistry = new AddedFunctionsRegistry(apps);
return { addedComponentsRegistry, addedFunctionsRegistry, addedLinksRegistry, exposedComponentsRegistry };
}
// Registering core extension links
addedLinksRegistry.register({
pluginId: 'grafana',
configs: getCoreExtensionConfigurations(),
});
function registerCoreExtensions({ addedLinksRegistry, exposedComponentsRegistry }: PluginExtensionRegistries) {
// Registering core extension links
addedLinksRegistry.register({
pluginId: 'grafana',
configs: getCoreExtensionConfigurations(),
});
// Registering core exposed components
exposedComponentsRegistry.register({
pluginId: 'grafana',
configs: [
{
id: PluginExtensionExposedComponents.CentralAlertHistorySceneV1,
title: 'Central alert history scene',
description: 'Central alert history scene',
component: CentralAlertHistorySceneExposedComponent,
},
{
id: PluginExtensionExposedComponents.AddToDashboardFormV1,
title: 'Add to dashboard form',
description: 'Add to dashboard form',
component: AddToDashboardFormExposedComponent,
},
{
id: PluginExtensionExposedComponents.CreateAlertFromPanelV1,
title: 'Create alert from panel',
description: 'Modal to create an alert rule from panel data',
component: CreateAlertFromPanelExposedComponent,
},
{
id: PluginExtensionExposedComponents.OpenQueryLibraryV1,
title: 'Access to the Query Library',
description: 'Access to the Query Library',
component: OpenQueryLibraryExposedComponent,
},
],
});
// Registering core exposed components
exposedComponentsRegistry.register({
pluginId: 'grafana',
configs: [
{
id: PluginExtensionExposedComponents.CentralAlertHistorySceneV1,
title: 'Central alert history scene',
description: 'Central alert history scene',
component: CentralAlertHistorySceneExposedComponent,
},
{
id: PluginExtensionExposedComponents.AddToDashboardFormV1,
title: 'Add to dashboard form',
description: 'Add to dashboard form',
component: AddToDashboardFormExposedComponent,
},
{
id: PluginExtensionExposedComponents.CreateAlertFromPanelV1,
title: 'Create alert from panel',
description: 'Modal to create an alert rule from panel data',
component: CreateAlertFromPanelExposedComponent,
},
{
id: PluginExtensionExposedComponents.OpenQueryLibraryV1,
title: 'Access to the Query Library',
description: 'Access to the Query Library',
component: OpenQueryLibraryExposedComponent,
},
],
});
}
async function initPluginExtensionRegistries(): Promise<PluginExtensionRegistries> {
const apps = await getAppPluginMetas();
const registries = initRegistries(apps);
registerCoreExtensions(registries);
return registries;
}
/**
* Gets the plugin extension registries, initializing them on first call.
* This function is safe to call concurrently - multiple simultaneous calls will
* all receive the same Promise instance, ensuring only one initialization.
* If initialization (including getAppPluginMetas) fails, the error is logged and
* empty plugin extension registries are returned as a fallback.
* @returns Promise resolving to the plugin extension registries
*/
export async function getPluginExtensionRegistries(): Promise<PluginExtensionRegistries> {
return getCachedPromise(initPluginExtensionRegistries, { defaultValue: initRegistries([]) });
}

View file

@ -21,10 +21,10 @@ describe('useRegistrySlice', () => {
beforeEach(() => {
registries = {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
addedFunctionsRegistry: new AddedFunctionsRegistry(),
addedComponentsRegistry: new AddedComponentsRegistry([]),
exposedComponentsRegistry: new ExposedComponentsRegistry([]),
addedLinksRegistry: new AddedLinksRegistry([]),
addedFunctionsRegistry: new AddedFunctionsRegistry([]),
};
wrapper = ({ children }: { children: React.ReactNode }) => (

View file

@ -1,9 +1,7 @@
import { cloneDeep } from 'lodash';
import { AngularMeta, AppPluginConfig, PluginLoadingStrategy } from '@grafana/data';
import { AppPluginMetas } from '@grafana/runtime/internal';
const app: AppPluginConfig = cloneDeep({
const app: AppPluginConfig = structuredClone({
id: 'myorg-someplugin-app',
path: 'public/plugins/myorg-someplugin-app/module.js',
version: '1.0.0',
@ -29,7 +27,7 @@ const app: AppPluginConfig = cloneDeep({
buildMode: 'production',
});
export const metas: AppPluginMetas = cloneDeep({
export const metas: AppPluginMetas = structuredClone({
'grafana-exploretraces-app': {
id: 'grafana-exploretraces-app',
path: 'public/plugins/grafana-exploretraces-app/module.js',
@ -483,7 +481,7 @@ export const metas: AppPluginMetas = cloneDeep({
export const apps = Object.values(metas);
export const genericAppPluginConfig: Omit<AppPluginConfig, 'id'> = {
export const genericAppPluginConfig: Omit<AppPluginConfig, 'id'> = structuredClone({
path: '',
version: '',
preload: false,
@ -506,4 +504,9 @@ export const genericAppPluginConfig: Omit<AppPluginConfig, 'id'> = {
exposedComponents: [],
extensionPoints: [],
},
};
});
export const basicApp: AppPluginConfig = structuredClone({
...genericAppPluginConfig,
id: 'grafana-basic-app',
});

View file

@ -1,7 +1,7 @@
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
import type { JSX } from 'react';
import { PluginContextProvider, PluginLoadingStrategy, PluginMeta, PluginType } from '@grafana/data';
import { AppPluginConfig, PluginContextProvider, PluginMeta, PluginType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
@ -12,6 +12,7 @@ import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
import { basicApp } from './test-fixtures/config.apps';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { usePluginComponent } from './usePluginComponent';
import { isGrafanaDevMode } from './utils';
@ -51,8 +52,7 @@ describe('usePluginComponent()', () => {
let registries: PluginExtensionRegistries;
let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element;
let pluginMeta: PluginMeta;
const originalApps = config.apps;
const pluginId = 'myorg-extensions-app';
const pluginId = basicApp.id;
const exposedComponentId = `${pluginId}/exposed-component/v1`;
const exposedComponentConfig = {
id: exposedComponentId,
@ -61,39 +61,23 @@ describe('usePluginComponent()', () => {
component: () => <div>Hello World</div>,
};
const appPluginConfig = {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
...basicApp,
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
...basicApp.extensions,
// This is necessary, so we can register exposed components to the registry during the tests
// (Otherwise the registry would reject it in the imitated production mode)
exposedComponents: [exposedComponentConfig],
extensionPoints: [],
},
};
let apps: AppPluginConfig[];
beforeEach(() => {
apps = [appPluginConfig];
registries = {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
addedFunctionsRegistry: new AddedFunctionsRegistry(),
addedComponentsRegistry: new AddedComponentsRegistry(apps),
exposedComponentsRegistry: new ExposedComponentsRegistry(apps),
addedLinksRegistry: new AddedLinksRegistry(apps),
addedFunctionsRegistry: new AddedFunctionsRegistry(apps),
};
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
@ -135,19 +119,11 @@ describe('usePluginComponent()', () => {
},
};
config.apps = {
[pluginId]: appPluginConfig,
};
wrapper = ({ children }: { children: React.ReactNode }) => (
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
);
});
afterEach(() => {
config.apps = originalApps;
});
it('should return null if there are no component exposed for the id', () => {
const { result } = renderHook(() => usePluginComponent('foo/bar'), { wrapper });
@ -383,7 +359,7 @@ describe('usePluginComponent()', () => {
// Should log an error in dev mode
expect(log.error).toHaveBeenCalledWith(
'Attempted to mutate object property "c" from extension with id myorg-extensions-app and version unknown',
'Attempted to mutate object property "c" from extension with id grafana-basic-app and version unknown',
{
stack: expect.any(String),
}
@ -439,7 +415,7 @@ describe('usePluginComponent()', () => {
// Should log a warning
expect(log.warning).toHaveBeenCalledWith(
'Attempted to mutate object property "c" from extension with id myorg-extensions-app and version unknown',
'Attempted to mutate object property "c" from extension with id grafana-basic-app and version unknown',
{
stack: expect.any(String),
}

View file

@ -1,13 +1,7 @@
import { act, render, renderHook, screen } from '@testing-library/react';
import React, { type JSX } from 'react';
import {
PluginContextProvider,
PluginExtensionPoints,
PluginLoadingStrategy,
PluginMeta,
PluginType,
} from '@grafana/data';
import { AppPluginConfig, PluginContextProvider, PluginExtensionPoints, PluginMeta, PluginType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
@ -19,6 +13,7 @@ import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
import { basicApp } from './test-fixtures/config.apps';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { usePluginComponents } from './usePluginComponents';
import { isGrafanaDevMode } from './utils';
@ -62,9 +57,10 @@ describe('usePluginComponents()', () => {
let registries: PluginExtensionRegistries;
let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element;
let pluginMeta: PluginMeta;
const pluginId = 'myorg-extensions-app';
const pluginId = basicApp.id;
const extensionPointId = `${pluginId}/extension-point/v1`;
const originalBuildInfoEnv = config.buildInfo.env;
let apps: AppPluginConfig[];
beforeEach(() => {
config.buildInfo.env = originalBuildInfoEnv;
@ -72,11 +68,12 @@ describe('usePluginComponents()', () => {
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
resetLogMock(log);
apps = [basicApp];
registries = {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
addedFunctionsRegistry: new AddedFunctionsRegistry(),
addedComponentsRegistry: new AddedComponentsRegistry(apps),
exposedComponentsRegistry: new ExposedComponentsRegistry(apps),
addedLinksRegistry: new AddedLinksRegistry(apps),
addedFunctionsRegistry: new AddedFunctionsRegistry(apps),
};
pluginMeta = {
@ -115,32 +112,6 @@ describe('usePluginComponents()', () => {
},
};
config.apps[pluginId] = {
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: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
};
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider meta={pluginMeta}>
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
@ -235,14 +206,14 @@ describe('usePluginComponents()', () => {
pluginId,
title: '1',
description: '1',
id: '-1921123020',
id: '1982424218',
type: 'component',
});
expect(result.current.components[1].meta).toEqual({
pluginId,
title: '2',
description: '2',
id: '-1921123019',
id: '1982424219',
type: 'component',
});
});
@ -303,7 +274,7 @@ describe('usePluginComponents()', () => {
// Should also render the component if it wants to change the props
expect(() => render(<Component foo={originalFoo} override />)).not.toThrow();
expect(log.error).toHaveBeenCalledWith(
`Attempted to mutate object property "foo4" from extension with id myorg-extensions-app and version unknown`,
`Attempted to mutate object property "foo4" from extension with id grafana-basic-app and version unknown`,
{
stack: expect.any(String),
}
@ -367,7 +338,7 @@ describe('usePluginComponents()', () => {
// Should also render the component if it wants to change the props
expect(() => render(<Component foo={originalFoo} override />)).not.toThrow();
expect(log.warning).toHaveBeenCalledWith(
`Attempted to mutate object property "foo4" from extension with id myorg-extensions-app and version unknown`,
`Attempted to mutate object property "foo4" from extension with id grafana-basic-app and version unknown`,
{
stack: expect.any(String),
}
@ -506,8 +477,9 @@ describe('usePluginComponents()', () => {
component: () => <div>Component</div>,
};
// The `AddedComponentsRegistry` is validating if the link is registered in the plugin metadata (config.apps).
config.apps[pluginId].extensions.addedComponents = [componentConfig];
registries.addedComponentsRegistry = new AddedComponentsRegistry([
{ ...apps[0], extensions: { ...apps[0].extensions, addedComponents: [componentConfig] } },
]);
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider

View file

@ -1,14 +1,7 @@
import { act, renderHook } from '@testing-library/react';
import type { JSX } from 'react';
import {
PluginContextProvider,
PluginExtensionPoints,
PluginLoadingStrategy,
PluginMeta,
PluginType,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { AppPluginConfig, PluginContextProvider, PluginExtensionPoints, PluginMeta, PluginType } from '@grafana/data';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import * as errors from './errors';
@ -19,6 +12,7 @@ import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
import { basicApp } from './test-fixtures/config.apps';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { usePluginFunctions } from './usePluginFunctions';
import { isGrafanaDevMode } from './utils';
@ -57,17 +51,19 @@ describe('usePluginFunctions()', () => {
let registries: PluginExtensionRegistries;
let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element;
let pluginMeta: PluginMeta;
const pluginId = 'myorg-extensions-app';
const pluginId = basicApp.id;
const extensionPointId = `${pluginId}/extension-point/v1`;
let apps: AppPluginConfig[];
beforeEach(() => {
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
apps = [basicApp];
registries = {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
addedFunctionsRegistry: new AddedFunctionsRegistry(),
addedComponentsRegistry: new AddedComponentsRegistry(apps),
exposedComponentsRegistry: new ExposedComponentsRegistry(apps),
addedLinksRegistry: new AddedLinksRegistry(apps),
addedFunctionsRegistry: new AddedFunctionsRegistry(apps),
};
resetLogMock(log);
@ -107,32 +103,6 @@ describe('usePluginFunctions()', () => {
},
};
config.apps[pluginId] = {
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: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
};
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider meta={pluginMeta}>
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
@ -328,8 +298,9 @@ describe('usePluginFunctions()', () => {
fn: () => 'function1',
};
// The `AddedFunctionsRegistry` is validating if the function is registered in the plugin metadata (config.apps).
config.apps[pluginId].extensions.addedFunctions = [functionConfig];
registries.addedFunctionsRegistry = new AddedFunctionsRegistry([
{ ...apps[0], extensions: { ...apps[0].extensions!, addedFunctions: [functionConfig] } },
]);
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider

View file

@ -1,14 +1,7 @@
import { act, renderHook } from '@testing-library/react';
import type { JSX } from 'react';
import {
PluginContextProvider,
PluginExtensionPoints,
PluginLoadingStrategy,
PluginMeta,
PluginType,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { AppPluginConfig, PluginContextProvider, PluginExtensionPoints, PluginMeta, PluginType } from '@grafana/data';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import * as errors from './errors';
@ -19,6 +12,7 @@ import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
import { basicApp } from './test-fixtures/config.apps';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { usePluginLinks } from './usePluginLinks';
import { isGrafanaDevMode } from './utils';
@ -57,17 +51,20 @@ describe('usePluginLinks()', () => {
let registries: PluginExtensionRegistries;
let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element;
let pluginMeta: PluginMeta;
const pluginId = 'myorg-extensions-app';
const pluginId = basicApp.id;
const extensionPointId = `${pluginId}/extension-point/v1`;
let apps: AppPluginConfig[];
beforeEach(() => {
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
apps = [basicApp];
registries = {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
addedFunctionsRegistry: new AddedFunctionsRegistry(),
addedComponentsRegistry: new AddedComponentsRegistry(apps),
exposedComponentsRegistry: new ExposedComponentsRegistry(apps),
addedLinksRegistry: new AddedLinksRegistry(apps),
addedFunctionsRegistry: new AddedFunctionsRegistry(apps),
};
resetLogMock(log);
@ -107,32 +104,6 @@ describe('usePluginLinks()', () => {
},
};
config.apps[pluginId] = {
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: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
};
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider meta={pluginMeta}>
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
@ -268,8 +239,9 @@ describe('usePluginLinks()', () => {
path: `/a/${pluginId}/2`,
};
// The `AddedLinksRegistry` is validating if the link is registered in the plugin metadata (config.apps).
config.apps[pluginId].extensions.addedLinks = [linkConfig];
registries.addedLinksRegistry = new AddedLinksRegistry([
{ ...apps[0], extensions: { ...apps[0].extensions!, addedLinks: [linkConfig] } },
]);
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider

View file

@ -1,15 +1,15 @@
import { memo } from 'react';
import {
AppPluginConfig,
PluginContextType,
PluginExtensionAddedLinkConfig,
PluginExtensionPoints,
PluginLoadingStrategy,
PluginType,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { createLogMock } from './logs/testUtils';
import { basicApp } from './test-fixtures/config.apps';
import {
assertConfigureIsValid,
assertStringProps,
@ -23,6 +23,72 @@ import {
isReactComponent,
} from './validators';
const PLUGIN_ID = 'myorg-extensions-app';
function createAppPluginConfig(
pluginId: string,
overrides: Partial<AppPluginConfig> = {},
extensionOverrides: Partial<AppPluginConfig['extensions']> = {}
): AppPluginConfig {
return {
...basicApp,
extensions: {
...basicApp.extensions,
...extensionOverrides,
},
id: pluginId,
...overrides,
};
}
function createPluginContext(
pluginId: string,
overrides: {
extensions?: Partial<PluginContextType['meta']['extensions']>;
dependencies?: Partial<PluginContextType['meta']['dependencies']>;
} = {}
): PluginContextType {
return {
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: [],
...overrides.extensions,
},
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
...overrides.dependencies,
},
},
};
}
describe('Plugin Extension Validators', () => {
describe('assertConfigureIsValid()', () => {
it('should NOT throw an error if the configure() function is missing', () => {
@ -130,7 +196,7 @@ describe('Plugin Extension Validators', () => {
expect(isReactComponent(wrapped)).toBe(true);
});
it('should return FALSE if we pass in a valid React component', () => {
it('should return FALSE if we pass in an invalid React component', () => {
expect(isReactComponent('Foo bar')).toBe(false);
expect(isReactComponent(123)).toBe(false);
expect(isReactComponent(false)).toBe(false);
@ -209,7 +275,7 @@ describe('Plugin Extension Validators', () => {
).toBe(false);
});
it('should return FALSE true if the extension point id is set by a core plugin', () => {
it('should return TRUE if the extension point id is set by a core plugin', () => {
expect(
isExtensionPointIdValid({
extensionPointId: 'traces',
@ -223,54 +289,17 @@ describe('Plugin Extension Validators', () => {
});
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 apps = [createAppPluginConfig(PLUGIN_ID, {}, { addedLinks: [extensionConfig] })];
const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig, log);
const returnValue = isAddedLinkMetaInfoMissing(PLUGIN_ID, extensionConfig, log, apps);
expect(returnValue).toBe(false);
expect(log.error).toHaveBeenCalledTimes(0);
@ -278,9 +307,9 @@ describe('Plugin Extension Validators', () => {
it('should return TRUE and log an error if the app config is not found', () => {
const log = createLogMock();
delete config.apps[pluginId];
const apps: AppPluginConfig[] = [];
const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig, log);
const returnValue = isAddedLinkMetaInfoMissing(PLUGIN_ID, extensionConfig, log, apps);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
@ -289,9 +318,9 @@ describe('Plugin Extension Validators', () => {
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 apps = [createAppPluginConfig(PLUGIN_ID, {}, { addedLinks: [] })];
const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig, log);
const returnValue = isAddedLinkMetaInfoMissing(PLUGIN_ID, extensionConfig, log, apps);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
@ -302,15 +331,16 @@ describe('Plugin Extension Validators', () => {
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 apps = [createAppPluginConfig(PLUGIN_ID, {}, { addedLinks: [extensionConfig] })];
const returnValue = isAddedLinkMetaInfoMissing(
pluginId,
PLUGIN_ID,
{
...extensionConfig,
targets: [PluginExtensionPoints.DashboardPanelMenu, PluginExtensionPoints.ExploreToolbarAction],
},
log
log,
apps
);
expect(returnValue).toBe(true);
@ -322,15 +352,16 @@ describe('Plugin Extension Validators', () => {
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 apps = [createAppPluginConfig(PLUGIN_ID, {}, { addedLinks: [extensionConfig] })];
const returnValue = isAddedLinkMetaInfoMissing(
pluginId,
PLUGIN_ID,
{
...extensionConfig,
description: 'Link description UPDATED',
},
log
log,
apps
);
expect(returnValue).toBe(false);
@ -340,14 +371,13 @@ describe('Plugin Extension Validators', () => {
it('should return FALSE with links with the same title but different targets', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedLinks.push(extensionConfig);
const extensionConfig2 = {
...extensionConfig,
targets: [PluginExtensionPoints.ExploreToolbarAction],
};
config.apps[pluginId].extensions.addedLinks.push(extensionConfig2);
const apps = [createAppPluginConfig(PLUGIN_ID, {}, { addedLinks: [extensionConfig, extensionConfig2] })];
const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig2, log);
const returnValue = isAddedLinkMetaInfoMissing(PLUGIN_ID, extensionConfig2, log, apps);
expect(returnValue).toBe(false);
expect(log.error).toHaveBeenCalledTimes(0);
@ -355,33 +385,6 @@ describe('Plugin Extension Validators', () => {
});
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',
@ -389,21 +392,11 @@ describe('Plugin Extension Validators', () => {
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 apps = [createAppPluginConfig(PLUGIN_ID, {}, { addedComponents: [extensionConfig] })];
const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig, log);
const returnValue = isAddedComponentMetaInfoMissing(PLUGIN_ID, extensionConfig, log, apps);
expect(returnValue).toBe(false);
expect(log.error).toHaveBeenCalledTimes(0);
@ -411,9 +404,9 @@ describe('Plugin Extension Validators', () => {
it('should return TRUE and log an error if the app config is not found', () => {
const log = createLogMock();
delete config.apps[pluginId];
const apps: AppPluginConfig[] = [];
const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig, log);
const returnValue = isAddedComponentMetaInfoMissing(PLUGIN_ID, extensionConfig, log, apps);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
@ -422,9 +415,9 @@ describe('Plugin Extension Validators', () => {
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 apps = [createAppPluginConfig(PLUGIN_ID, {}, { addedComponents: [] })];
const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig, log);
const returnValue = isAddedComponentMetaInfoMissing(PLUGIN_ID, extensionConfig, log, apps);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
@ -435,15 +428,16 @@ describe('Plugin Extension Validators', () => {
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 apps = [createAppPluginConfig(PLUGIN_ID, {}, { addedComponents: [extensionConfig] })];
const returnValue = isAddedComponentMetaInfoMissing(
pluginId,
PLUGIN_ID,
{
...extensionConfig,
targets: [PluginExtensionPoints.ExploreToolbarAction],
},
log
log,
apps
);
expect(returnValue).toBe(true);
@ -454,15 +448,16 @@ describe('Plugin Extension Validators', () => {
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 apps = [createAppPluginConfig(PLUGIN_ID, {}, { addedComponents: [extensionConfig] })];
const returnValue = isAddedComponentMetaInfoMissing(
pluginId,
PLUGIN_ID,
{
...extensionConfig,
description: 'UPDATED',
},
log
log,
apps
);
expect(returnValue).toBe(false);
@ -472,14 +467,13 @@ describe('Plugin Extension Validators', () => {
it('should return FALSE with components with the same title but different targets', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedComponents.push(extensionConfig);
const extensionConfig2 = {
...extensionConfig,
targets: [PluginExtensionPoints.ExploreToolbarAction],
};
config.apps[pluginId].extensions.addedComponents.push(extensionConfig2);
const apps = [createAppPluginConfig(PLUGIN_ID, {}, { addedComponents: [extensionConfig, extensionConfig2] })];
const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig2, log);
const returnValue = isAddedComponentMetaInfoMissing(PLUGIN_ID, extensionConfig2, log, apps);
expect(returnValue).toBe(false);
expect(log.error).toHaveBeenCalledTimes(0);
@ -487,55 +481,18 @@ describe('Plugin Extension Validators', () => {
});
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`,
id: `${PLUGIN_ID}/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 apps = [createAppPluginConfig(PLUGIN_ID, {}, { exposedComponents: [exposedComponentConfig] })];
const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig, log);
const returnValue = isExposedComponentMetaInfoMissing(PLUGIN_ID, exposedComponentConfig, log, apps);
expect(returnValue).toBe(false);
expect(log.warning).toHaveBeenCalledTimes(0);
@ -543,9 +500,9 @@ describe('Plugin Extension Validators', () => {
it('should return TRUE and log an error if the app config is not found', () => {
const log = createLogMock();
delete config.apps[pluginId];
const apps: AppPluginConfig[] = [];
const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig, log);
const returnValue = isExposedComponentMetaInfoMissing(PLUGIN_ID, exposedComponentConfig, log, apps);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
@ -554,9 +511,9 @@ describe('Plugin Extension Validators', () => {
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 apps = [createAppPluginConfig(PLUGIN_ID, {}, { exposedComponents: [] })];
const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig, log);
const returnValue = isExposedComponentMetaInfoMissing(PLUGIN_ID, exposedComponentConfig, log, apps);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
@ -567,15 +524,16 @@ describe('Plugin Extension Validators', () => {
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 apps = [createAppPluginConfig(PLUGIN_ID, {}, { exposedComponents: [exposedComponentConfig] })];
const returnValue = isExposedComponentMetaInfoMissing(
pluginId,
PLUGIN_ID,
{
...exposedComponentConfig,
title: 'UPDATED',
},
log
log,
apps
);
expect(returnValue).toBe(true);
@ -587,15 +545,16 @@ describe('Plugin Extension Validators', () => {
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 apps = [createAppPluginConfig(PLUGIN_ID, {}, { exposedComponents: [exposedComponentConfig] })];
const returnValue = isExposedComponentMetaInfoMissing(
pluginId,
PLUGIN_ID,
{
...exposedComponentConfig,
description: 'UPDATED',
},
log
log,
apps
);
expect(returnValue).toBe(false);
@ -605,14 +564,15 @@ describe('Plugin Extension Validators', () => {
it('should return FALSE with components with the same title but different targets', () => {
const log = createLogMock();
config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig);
const exposedComponentConfig2 = {
...exposedComponentConfig,
targets: [PluginExtensionPoints.ExploreToolbarAction],
};
config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig2);
const apps = [
createAppPluginConfig(PLUGIN_ID, {}, { exposedComponents: [exposedComponentConfig, exposedComponentConfig2] }),
];
const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig2, log);
const returnValue = isExposedComponentMetaInfoMissing(PLUGIN_ID, exposedComponentConfig2, log, apps);
expect(returnValue).toBe(false);
expect(log.error).toHaveBeenCalledTimes(0);
@ -620,113 +580,54 @@ describe('Plugin Extension Validators', () => {
});
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: [],
},
},
},
};
});
const exposedComponentId = `${PLUGIN_ID}/component/v1`;
it('should return FALSE if the meta-info in the plugin.json is correct', () => {
pluginContext.meta.dependencies?.extensions.exposedComponents.push(exposedComponentId);
const pluginContext = createPluginContext(PLUGIN_ID, {
dependencies: {
extensions: {
exposedComponents: [exposedComponentId],
},
},
});
const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext);
expect(returnValue).toBe(false);
});
it('should return TRUE if the dependencies are missing', () => {
const pluginContext = createPluginContext(PLUGIN_ID);
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 pluginContext = createPluginContext(PLUGIN_ID);
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 extensionPointId = `${PLUGIN_ID}/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 pluginContext = createPluginContext(PLUGIN_ID, {
extensions: {
extensionPoints: [extensionPointConfig],
},
});
const returnValue = isExtensionPointMetaInfoMissing(extensionPointId, pluginContext);
@ -734,7 +635,10 @@ describe('Plugin Extension Validators', () => {
});
it('should return TRUE if the extension point id is not recorded in the plugin.json', () => {
const pluginContext = createPluginContext(PLUGIN_ID);
const returnValue = isExtensionPointMetaInfoMissing(extensionPointId, pluginContext);
expect(returnValue).toBe(true);
});
});

View file

@ -8,9 +8,10 @@ import {
type PluginExtensionAddedFunctionConfig,
PluginExtensionPoints,
PluginExtensionPointPatterns,
AppPluginConfig,
} from '@grafana/data';
import { PluginAddedLinksConfigureFunc } from '@grafana/data/internal';
import { config, isPluginExtensionLink } from '@grafana/runtime';
import { isPluginExtensionLink } from '@grafana/runtime';
import * as errors from './errors';
import { ExtensionsLog } from './logs/log';
@ -148,10 +149,11 @@ export const isExposedComponentDependencyMissing = (id: string, pluginContext: P
export const isAddedLinkMetaInfoMissing = (
pluginId: string,
metaInfo: PluginExtensionAddedLinkConfig,
log: ExtensionsLog
log: ExtensionsLog,
apps: AppPluginConfig[]
) => {
const logPrefix = 'Could not register link extension. Reason:';
const app = config.apps[pluginId];
const app = apps.find((a) => a.id === pluginId);
const pluginJsonMetaInfo = app ? app.extensions.addedLinks.filter(({ title }) => title === metaInfo.title) : null;
if (!app) {
@ -180,10 +182,11 @@ export const isAddedLinkMetaInfoMissing = (
export const isAddedFunctionMetaInfoMissing = (
pluginId: string,
metaInfo: PluginExtensionAddedFunctionConfig,
log: ExtensionsLog
log: ExtensionsLog,
apps: AppPluginConfig[]
) => {
const logPrefix = 'Could not register function extension. Reason:';
const app = config.apps[pluginId];
const app = apps.find((a) => a.id === pluginId);
const pluginJsonMetaInfo = app ? app.extensions.addedFunctions.filter(({ title }) => title === metaInfo.title) : null;
if (!app) {
@ -212,10 +215,11 @@ export const isAddedFunctionMetaInfoMissing = (
export const isAddedComponentMetaInfoMissing = (
pluginId: string,
metaInfo: PluginExtensionAddedComponentConfig,
log: ExtensionsLog
log: ExtensionsLog,
apps: AppPluginConfig[]
) => {
const logPrefix = 'Could not register component extension. Reason:';
const app = config.apps[pluginId];
const app = apps.find((a) => a.id === pluginId);
const pluginJsonMetaInfo = app
? app.extensions.addedComponents.filter(({ title }) => title === metaInfo.title)
: null;
@ -246,10 +250,11 @@ export const isAddedComponentMetaInfoMissing = (
export const isExposedComponentMetaInfoMissing = (
pluginId: string,
metaInfo: PluginExtensionExposedComponentConfig,
log: ExtensionsLog
log: ExtensionsLog,
apps: AppPluginConfig[]
) => {
const logPrefix = 'Could not register exposed component extension. Reason:';
const app = config.apps[pluginId];
const app = apps.find((a) => a.id === pluginId);
const pluginJsonMetaInfo = app ? app.extensions.exposedComponents.filter(({ id }) => id === metaInfo.id) : null;
if (!app) {

View file

@ -10,21 +10,50 @@ import {
PluginType,
} from '@grafana/data';
import {
addedComponentsRegistry,
addedFunctionsRegistry,
addedLinksRegistry,
exposedComponentsRegistry,
} from '../extensions/registry/setup';
import { AddedComponentsRegistry } from '../extensions/registry/AddedComponentsRegistry';
import { AddedFunctionsRegistry } from '../extensions/registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from '../extensions/registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from '../extensions/registry/ExposedComponentsRegistry';
import { pluginsLogger } from '../utils';
import * as importPluginModule from './importPluginModule';
import { pluginImporter, clearCaches } from './pluginImporter';
jest.mock('../extensions/registry/setup', () => ({
...jest.requireActual('../extensions/registry/setup'),
getPluginExtensionRegistries: jest.fn(),
}));
const { getPluginExtensionRegistries } = jest.requireMock('../extensions/registry/setup');
const getPluginExtensionRegistriesMock = jest.mocked(getPluginExtensionRegistries);
describe('pluginImporter', () => {
let exposedComponentsRegistry: ExposedComponentsRegistry;
let addedComponentsRegistry: AddedComponentsRegistry;
let addedLinksRegistry: AddedLinksRegistry;
let addedFunctionsRegistry: AddedFunctionsRegistry;
beforeEach(() => {
jest.clearAllMocks();
clearCaches();
addedComponentsRegistry = new AddedComponentsRegistry([]);
addedFunctionsRegistry = new AddedFunctionsRegistry([]);
addedLinksRegistry = new AddedLinksRegistry([]);
exposedComponentsRegistry = new ExposedComponentsRegistry([]);
addedComponentsRegistry.register = jest.fn();
addedFunctionsRegistry.register = jest.fn();
addedLinksRegistry.register = jest.fn();
exposedComponentsRegistry.register = jest.fn();
const registries = {
addedComponentsRegistry,
addedFunctionsRegistry,
addedLinksRegistry,
exposedComponentsRegistry,
};
getPluginExtensionRegistriesMock.mockResolvedValue(registries);
});
describe('importPanel', () => {
@ -217,12 +246,7 @@ describe('pluginImporter', () => {
addedFunctionConfigs: [{}],
},
};
const exposedComponentsRegistrySpy = jest
.spyOn(exposedComponentsRegistry, 'register')
.mockImplementation(() => {});
const addedComponentsRegistrySpy = jest.spyOn(addedComponentsRegistry, 'register').mockImplementation(() => {});
const addedLinksRegistrySpy = jest.spyOn(addedLinksRegistry, 'register').mockImplementation(() => {});
const addedFunctionsRegistrySpy = jest.spyOn(addedFunctionsRegistry, 'register').mockImplementation(() => {});
const spy = jest.spyOn(importPluginModule, 'importPluginModule').mockResolvedValue({ ...plugin });
const result = await pluginImporter.importApp({ ...appPlugin });
@ -237,10 +261,23 @@ describe('pluginImporter', () => {
});
expect(init).toHaveBeenCalledWith({ ...appPlugin });
expect(setComponentsFromLegacyExports).toHaveBeenCalledWith({ ...plugin });
expect(exposedComponentsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] });
expect(addedComponentsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] });
expect(addedLinksRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] });
expect(addedFunctionsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] });
expect(addedComponentsRegistry.register).toHaveBeenCalledWith({
pluginId: 'test-plugin',
configs: [{}],
});
expect(addedLinksRegistry.register).toHaveBeenCalledWith({
pluginId: 'test-plugin',
configs: [{}],
});
expect(addedFunctionsRegistry.register).toHaveBeenCalledWith({
pluginId: 'test-plugin',
configs: [{}],
});
expect(exposedComponentsRegistry.register).toHaveBeenCalledWith({
pluginId: 'test-plugin',
configs: [{}],
});
expect(result).toEqual({
...appPlugin,
@ -268,12 +305,7 @@ describe('pluginImporter', () => {
addedFunctionConfigs: [{}],
},
};
const exposedComponentsRegistrySpy = jest
.spyOn(exposedComponentsRegistry, 'register')
.mockImplementation(() => {});
const addedComponentsRegistrySpy = jest.spyOn(addedComponentsRegistry, 'register').mockImplementation(() => {});
const addedLinksRegistrySpy = jest.spyOn(addedLinksRegistry, 'register').mockImplementation(() => {});
const addedFunctionsRegistrySpy = jest.spyOn(addedFunctionsRegistry, 'register').mockImplementation(() => {});
const spy = jest.spyOn(importPluginModule, 'importPluginModule').mockResolvedValue({ ...plugin });
const meta = { ...appPlugin, loadingStrategy: PluginLoadingStrategy.script };
@ -289,10 +321,23 @@ describe('pluginImporter', () => {
});
expect(init).toHaveBeenCalledWith({ ...meta });
expect(setComponentsFromLegacyExports).toHaveBeenCalledWith({ ...plugin });
expect(exposedComponentsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] });
expect(addedComponentsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] });
expect(addedLinksRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] });
expect(addedFunctionsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] });
expect(addedComponentsRegistry.register).toHaveBeenCalledWith({
pluginId: 'test-plugin',
configs: [{}],
});
expect(addedLinksRegistry.register).toHaveBeenCalledWith({
pluginId: 'test-plugin',
configs: [{}],
});
expect(addedFunctionsRegistry.register).toHaveBeenCalledWith({
pluginId: 'test-plugin',
configs: [{}],
});
expect(exposedComponentsRegistry.register).toHaveBeenCalledWith({
pluginId: 'test-plugin',
configs: [{}],
});
expect(result).toEqual({
...appPlugin,
@ -307,12 +352,6 @@ describe('pluginImporter', () => {
});
it('should import an empty app plugin if missing exported plugin', async () => {
const exposedComponentsRegistrySpy = jest
.spyOn(exposedComponentsRegistry, 'register')
.mockImplementation(() => {});
const addedComponentsRegistrySpy = jest.spyOn(addedComponentsRegistry, 'register').mockImplementation(() => {});
const addedLinksRegistrySpy = jest.spyOn(addedLinksRegistry, 'register').mockImplementation(() => {});
const addedFunctionsRegistrySpy = jest.spyOn(addedFunctionsRegistry, 'register').mockImplementation(() => {});
const spy = jest.spyOn(importPluginModule, 'importPluginModule').mockResolvedValue({});
const result = await pluginImporter.importApp({ ...appPlugin });
@ -325,10 +364,24 @@ describe('pluginImporter', () => {
moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b',
translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' },
});
expect(exposedComponentsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [] });
expect(addedComponentsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [] });
expect(addedLinksRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [] });
expect(addedFunctionsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [] });
expect(addedComponentsRegistry.register).toHaveBeenCalledWith({
pluginId: 'test-plugin',
configs: [],
});
expect(addedLinksRegistry.register).toHaveBeenCalledWith({
pluginId: 'test-plugin',
configs: [],
});
expect(addedFunctionsRegistry.register).toHaveBeenCalledWith({
pluginId: 'test-plugin',
configs: [],
});
expect(exposedComponentsRegistry.register).toHaveBeenCalledWith({
pluginId: 'test-plugin',
configs: [],
});
expect(result).toEqual({ ...new AppPlugin(), meta: { ...appPlugin } });
});

View file

@ -16,12 +16,7 @@ import { config } from '@grafana/runtime';
import { GenericDataSourcePlugin } from 'app/features/datasources/types';
import { getPanelPluginLoadError } from 'app/features/panel/components/PanelPluginError';
import {
addedComponentsRegistry,
addedFunctionsRegistry,
addedLinksRegistry,
exposedComponentsRegistry,
} from '../extensions/registry/setup';
import { getPluginExtensionRegistries } from '../extensions/registry/setup';
import { pluginsLogger } from '../utils';
import { importPluginModule } from './importPluginModule';
@ -98,6 +93,8 @@ const appPluginPostImport: PostImportStrategy<AppPlugin, AppPluginMeta> = async
plugin.meta = meta;
plugin.setComponentsFromLegacyExports(pluginExports);
const { exposedComponentsRegistry, addedComponentsRegistry, addedFunctionsRegistry, addedLinksRegistry } =
await getPluginExtensionRegistries();
exposedComponentsRegistry.register({ pluginId: meta.id, configs: plugin.exposedComponentConfigs || [] });
addedComponentsRegistry.register({ pluginId: meta.id, configs: plugin.addedComponentConfigs || [] });
addedLinksRegistry.register({ pluginId: meta.id, configs: plugin.addedLinkConfigs || [] });