diff --git a/.betterer.results b/.betterer.results
index adbcfdd1d1e..6f5a994bf67 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -4988,9 +4988,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "11"],
[0, 0, 0, "Do not use any type assertions.", "12"]
],
- "public/app/features/plugins/extensions/getPluginExtensions.test.tsx:5381": [
- [0, 0, 0, "Unexpected any. Specify a different type.", "0"]
- ],
"public/app/features/plugins/extensions/usePluginComponents.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
diff --git a/e2e/test-plugins/grafana-extensionstest-app/pages/AddedLinks.tsx b/e2e/test-plugins/grafana-extensionstest-app/pages/AddedLinks.tsx
index dbfd00c36c4..201fa2305cd 100644
--- a/e2e/test-plugins/grafana-extensionstest-app/pages/AddedLinks.tsx
+++ b/e2e/test-plugins/grafana-extensionstest-app/pages/AddedLinks.tsx
@@ -1,25 +1,22 @@
import { PluginPage, usePluginLinks } from '@grafana/runtime';
+import { Stack } from '@grafana/ui';
+import { ActionButton } from '../components/ActionButton';
import { testIds } from '../testIds';
export const LINKS_EXTENSION_POINT_ID = 'plugins/grafana-extensionstest-app/use-plugin-links/v1';
export function AddedLinks() {
- const { links, isLoading } = usePluginLinks({ extensionPointId: LINKS_EXTENSION_POINT_ID });
+ const { links } = usePluginLinks({ extensionPointId: LINKS_EXTENSION_POINT_ID });
return (
-
- {isLoading ? (
-
Loading...
- ) : (
- links.map(({ id, title, path, onClick }) => (
-
- {title}
-
- ))
- )}
-
+
+
+ Link extensions defined with addLink and retrieved using usePluginLinks
+
+
+
);
}
diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/module.tsx b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/module.tsx
index ae1a2efe4d9..4b4dda98d1b 100644
--- a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/module.tsx
+++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/module.tsx
@@ -4,7 +4,6 @@ import { LINKS_EXTENSION_POINT_ID } from '../../pages/AddedLinks';
import { testIds } from '../../testIds';
import { App } from './components/App';
-import pluginJson from './plugin.json';
export const plugin = new AppPlugin<{}>()
.setRootPage(App)
@@ -24,5 +23,11 @@ export const plugin = new AppPlugin<{}>()
title: 'Basic link',
description: '...',
targets: [LINKS_EXTENSION_POINT_ID],
- path: `/a/${pluginJson.id}/`,
+ path: '/a/grafana-extensionexample1-app/',
+ })
+ .addLink({
+ title: 'Go to A',
+ description: 'Navigating to pluging A',
+ targets: [LINKS_EXTENSION_POINT_ID],
+ path: '/a/grafana-extensionexample1-app/',
});
diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/module.tsx b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/module.tsx
index 7c68c104597..071370cf592 100644
--- a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/module.tsx
+++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/module.tsx
@@ -1,5 +1,6 @@
import { AppPlugin } from '@grafana/data';
+import { LINKS_EXTENSION_POINT_ID } from '../../pages/AddedLinks';
import { testIds } from '../../testIds';
import { App } from './components/App';
@@ -30,4 +31,15 @@ export const plugin = new AppPlugin<{}>()
component: ({ name }: { name: string }) => (
Hello {name}!
),
+ })
+ .addLink({
+ title: 'Open from B',
+ description: 'Open a modal from plugin B',
+ targets: [LINKS_EXTENSION_POINT_ID],
+ onClick: (_, { openModal }) => {
+ openModal({
+ title: 'Modal from app B',
+ body: () => From plugin B
,
+ });
+ },
});
diff --git a/e2e/test-plugins/grafana-extensionstest-app/testIds.ts b/e2e/test-plugins/grafana-extensionstest-app/testIds.ts
index 29a9c0b0726..debed95957b 100644
--- a/e2e/test-plugins/grafana-extensionstest-app/testIds.ts
+++ b/e2e/test-plugins/grafana-extensionstest-app/testIds.ts
@@ -36,5 +36,6 @@ export const testIds = {
},
addedLinksPage: {
container: 'data-testid pg-added-links-container',
+ section1: 'use-plugin-links',
},
};
diff --git a/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts b/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts
index cfeef4bfcdf..df5f64796d4 100644
--- a/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts
+++ b/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts
@@ -3,8 +3,28 @@ import { test, expect } from '@grafana/plugin-e2e';
import pluginJson from '../plugin.json';
import { testIds } from '../testIds';
-test('path link', async ({ page }) => {
+test('should extend the actions menu with a link to a-app plugin', async ({ page }) => {
await page.goto(`/a/${pluginJson.id}/added-links`);
- await page.getByTestId(testIds.addedLinksPage.container).getByText('Basic link').click();
- await expect(page.getByTestId(testIds.appA.container)).toHaveText('Hello Grafana!');
+ const section = await page.getByTestId(testIds.addedLinksPage.section1);
+ await section.getByTestId(testIds.actions.button).click();
+ await page.getByTestId(testIds.container).getByText('Go to A').click();
+ await page.getByTestId(testIds.modal.open).click();
+ await expect(page.getByTestId(testIds.appA.container)).toBeVisible();
+});
+
+test('should extend main app with link extension from app B', async ({ page }) => {
+ await page.goto(`/a/${pluginJson.id}/added-links`);
+ const section = await page.getByTestId(testIds.addedLinksPage.section1);
+ await section.getByTestId(testIds.actions.button).click();
+ await page.getByTestId(testIds.container).getByText('Open from B').click();
+ await expect(page.getByTestId(testIds.appB.modal)).toBeVisible();
+});
+
+test('should extend main app with basic link extension from app A', async ({ page }) => {
+ await page.goto(`/a/${pluginJson.id}/added-links`);
+ const section = await page.getByTestId(testIds.addedLinksPage.section1);
+ await section.getByTestId(testIds.actions.button).click();
+ await page.getByTestId(testIds.container).getByText('Basic link').click();
+ await page.getByTestId(testIds.modal.open).click();
+ await expect(page.getByTestId(testIds.appA.container)).toBeVisible();
});
diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts
index 2db23e03fca..4d7e6ed1201 100644
--- a/packages/grafana-data/src/index.ts
+++ b/packages/grafana-data/src/index.ts
@@ -555,8 +555,9 @@ export {
type PluginExtensionDataSourceConfigContext,
type PluginExtensionCommandPaletteContext,
type PluginExtensionOpenModalOptions,
- type PluginExposedComponentConfig,
- type PluginAddedComponentConfig,
+ type PluginExtensionExposedComponentConfig,
+ type PluginExtensionAddedComponentConfig,
+ type PluginExtensionAddedLinkConfig,
} from './types/pluginExtensions';
export {
type ScopeDashboardBindingSpec,
diff --git a/packages/grafana-data/src/types/app.ts b/packages/grafana-data/src/types/app.ts
index 87756072998..6ada93ab099 100644
--- a/packages/grafana-data/src/types/app.ts
+++ b/packages/grafana-data/src/types/app.ts
@@ -5,11 +5,10 @@ import { NavModel } from './navModel';
import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin';
import {
type PluginExtensionLinkConfig,
- PluginExtensionTypes,
PluginExtensionComponentConfig,
- PluginExposedComponentConfig,
- PluginExtensionConfig,
- PluginAddedComponentConfig,
+ PluginExtensionExposedComponentConfig,
+ PluginExtensionAddedComponentConfig,
+ PluginExtensionAddedLinkConfig,
} from './pluginExtensions';
/**
@@ -58,9 +57,9 @@ export interface AppPluginMeta extends PluginMeta
}
export class AppPlugin extends GrafanaPlugin> {
- private _exposedComponentConfigs: PluginExposedComponentConfig[] = [];
- private _addedComponentConfigs: PluginAddedComponentConfig[] = [];
- private _extensionConfigs: PluginExtensionConfig[] = [];
+ private _exposedComponentConfigs: PluginExtensionExposedComponentConfig[] = [];
+ private _addedComponentConfigs: PluginExtensionAddedComponentConfig[] = [];
+ private _addedLinkConfigs: PluginExtensionAddedLinkConfig[] = [];
// Content under: /a/${plugin-id}/*
root?: ComponentType>;
@@ -110,38 +109,24 @@ export class AppPlugin extends GrafanaPlugin(
- extensionConfig: { targets: string | string[] } & Omit<
- PluginExtensionLinkConfig,
- 'type' | 'extensionPointId'
- >
- ) {
- const { targets, ...extension } = extensionConfig;
- const targetsArray = Array.isArray(targets) ? targets : [targets];
-
- targetsArray.forEach((target) => {
- this._extensionConfigs.push({
- ...extension,
- extensionPointId: target,
- type: PluginExtensionTypes.link,
- } as PluginExtensionLinkConfig);
- });
+ addLink(linkConfig: PluginExtensionAddedLinkConfig) {
+ this._addedLinkConfigs.push(linkConfig as PluginExtensionAddedLinkConfig);
return this;
}
- addComponent(addedComponentConfig: PluginAddedComponentConfig) {
- this._addedComponentConfigs.push(addedComponentConfig as PluginAddedComponentConfig);
+ addComponent(addedComponentConfig: PluginExtensionAddedComponentConfig) {
+ this._addedComponentConfigs.push(addedComponentConfig as PluginExtensionAddedComponentConfig);
return this;
}
- exposeComponent(componentConfig: PluginExposedComponentConfig) {
- this._exposedComponentConfigs.push(componentConfig as PluginExposedComponentConfig);
+ exposeComponent(componentConfig: PluginExtensionExposedComponentConfig) {
+ this._exposedComponentConfigs.push(componentConfig as PluginExtensionExposedComponentConfig);
return this;
}
diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts
index f5dd20c62aa..bdb3e06b100 100644
--- a/packages/grafana-data/src/types/pluginExtensions.ts
+++ b/packages/grafana-data/src/types/pluginExtensions.ts
@@ -23,7 +23,6 @@ type PluginExtensionBase = {
description: string;
pluginId: string;
};
-
export type PluginExtensionLink = PluginExtensionBase & {
type: PluginExtensionTypes.link;
path?: string;
@@ -41,61 +40,20 @@ export type PluginExtension = PluginExtensionLink | PluginExtensionComponent;
// Objects used for registering extensions (in app plugins)
// --------------------------------------------------------
-export type PluginExtensionLinkConfig = {
- type: PluginExtensionTypes.link;
+
+type PluginExtensionConfigBase = {
+ /**
+ * The title of the link extension
+ */
title: string;
- description: string;
-
- // A URL path that will be used as the href for the rendered link extension
- // (It is optional, because in some cases the action will be handled by the `onClick` handler instead of navigating to a new page)
- path?: string;
-
- // A function that will be called when the link is clicked
- // (It is called with the original event object)
- onClick?: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => void;
/**
- * The unique identifier of the Extension Point
- * (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum)
+ * A short description
*/
- extensionPointId: string;
-
- // (Optional) A function that can be used to configure the extension dynamically based on the extension point's context
- configure?: (context?: Readonly) =>
- | Partial<{
- title: string;
- description: string;
- path: string;
- onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => void;
- icon: IconName;
- category: string;
- }>
- | undefined;
-
- // (Optional) A icon that can be displayed in the ui for the extension option.
- icon?: IconName;
-
- // (Optional) A category to be used when grouping the options in the ui
- category?: string;
-};
-
-export type PluginExtensionComponentConfig = {
- type: PluginExtensionTypes.component;
- title: string;
description: string;
-
- // The React component that will be rendered as the extension
- // (This component receives contextual information as props when it is rendered. You can just return `null` from the component to hide it.)
- component: React.ComponentType;
-
- /**
- * The unique identifier of the Extension Point
- * (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum)
- */
- extensionPointId: string;
};
-export type PluginAddedComponentConfig = {
+export type PluginExtensionAddedComponentConfig = PluginExtensionConfigBase & {
/**
* The target extension points where the component will be added
*/
@@ -117,23 +75,54 @@ export type PluginAddedComponentConfig = {
component: React.ComponentType;
};
-export type PluginExposedComponentConfig = {
+export type PluginAddedLinksConfigureFunc = (context?: Readonly) =>
+ | Partial<{
+ title: string;
+ description: string;
+ path: string;
+ onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => void;
+ icon: IconName;
+ category: string;
+ }>
+ | undefined;
+
+export type PluginExtensionAddedLinkConfig = PluginExtensionConfigBase & {
+ /**
+ * The target extension points where the link will be added
+ */
+ targets: string | string[];
+
+ /** A URL path that will be used as the href for the rendered link extension
+ * (It is optional, because in some cases the action will be handled by the `onClick` handler instead of navigating to a new page)
+ */
+ path?: string;
+
+ /** A URL path that will be used as the href for the rendered link extension
+ * (It is optional, because in some cases the action will be handled by the `onClick` handler instead of navigating to a new page)
+ * path?: string;
+ *
+ * A function that will be called when the link is clicked
+ * (It is called with the original event object)
+ */
+ onClick?: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => void;
+
+ // (Optional) A function that can be used to configure the extension dynamically based on the extension point's context
+ configure?: PluginAddedLinksConfigureFunc;
+
+ // (Optional) A icon that can be displayed in the ui for the extension option.
+ icon?: IconName;
+
+ // (Optional) A category to be used when grouping the options in the ui
+ category?: string;
+};
+
+export type PluginExtensionExposedComponentConfig = PluginExtensionConfigBase & {
/**
* The unique identifier of the component
* Shoud be in the format of `//`. e.g. `myorg-todo-app/todo-list/v1`
*/
id: string;
- /**
- * The title of the component
- */
- title: string;
-
- /**
- * A short description of the component
- */
- description: string;
-
/**
* The React component that will be exposed to other plugins
*/
@@ -212,3 +201,61 @@ type Dashboard = {
title: string;
tags: string[];
};
+
+// deprecated types
+
+/** @deprecated - use PluginAddedComponentConfig instead */
+export type PluginExtensionLinkConfig = {
+ type: PluginExtensionTypes.link;
+ title: string;
+ description: string;
+
+ // A URL path that will be used as the href for the rendered link extension
+ // (It is optional, because in some cases the action will be handled by the `onClick` handler instead of navigating to a new page)
+ path?: string;
+
+ // A function that will be called when the link is clicked
+ // (It is called with the original event object)
+ onClick?: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => void;
+
+ /**
+ * The unique identifier of the Extension Point
+ * (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum)
+ */
+ extensionPointId: string;
+
+ // (Optional) A function that can be used to configure the extension dynamically based on the extension point's context
+ configure?: (context?: Readonly) =>
+ | Partial<{
+ title: string;
+ description: string;
+ path: string;
+ onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => void;
+ icon: IconName;
+ category: string;
+ }>
+ | undefined;
+
+ // (Optional) A icon that can be displayed in the ui for the extension option.
+ icon?: IconName;
+
+ // (Optional) A category to be used when grouping the options in the ui
+ category?: string;
+};
+
+/** @deprecated - use PluginAddedLinkConfig instead */
+export type PluginExtensionComponentConfig = {
+ type: PluginExtensionTypes.component;
+ title: string;
+ description: string;
+
+ // The React component that will be rendered as the extension
+ // (This component receives contextual information as props when it is rendered. You can just return `null` from the component to hide it.)
+ component: React.ComponentType;
+
+ /**
+ * The unique identifier of the Extension Point
+ * (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum)
+ */
+ extensionPointId: string;
+};
diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts
index e1415c47a9e..8f9aaa6281f 100644
--- a/packages/grafana-runtime/src/services/index.ts
+++ b/packages/grafana-runtime/src/services/index.ts
@@ -26,11 +26,11 @@ export {
usePluginExtensions,
usePluginLinkExtensions,
usePluginComponentExtensions,
- usePluginLinks,
} from './pluginExtensions/usePluginExtensions';
export { setPluginComponentHook, usePluginComponent } from './pluginExtensions/usePluginComponent';
export { setPluginComponentsHook, usePluginComponents } from './pluginExtensions/usePluginComponents';
+export { setPluginLinksHook, usePluginLinks } from './pluginExtensions/usePluginLinks';
export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils';
export { setCurrentUser } from './user';
diff --git a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts
index 664661dcaf6..cf6d48d7e6b 100644
--- a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts
+++ b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts
@@ -40,6 +40,17 @@ export type UsePluginComponentsResult = {
isLoading: boolean;
};
+export type UsePluginLinksOptions = {
+ extensionPointId: string;
+ context?: object | Record;
+ limitPerPlugin?: number;
+};
+
+export type UsePluginLinksResult = {
+ isLoading: boolean;
+ links: PluginExtensionLink[];
+};
+
let singleton: GetPluginExtensions | undefined;
export function setPluginExtensionGetter(instance: GetPluginExtensions): void {
diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts
index 439d8163ccf..f195707efc4 100644
--- a/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts
+++ b/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts
@@ -25,20 +25,6 @@ export function usePluginExtensions(options: GetPluginExtensionsOptions): UsePlu
return singleton(options);
}
-export function usePluginLinks(options: GetPluginExtensionsOptions): {
- links: PluginExtensionLink[];
- isLoading: boolean;
-} {
- const { extensions, isLoading } = usePluginExtensions(options);
-
- return useMemo(() => {
- return {
- links: extensions.filter(isPluginExtensionLink),
- isLoading,
- };
- }, [extensions, isLoading]);
-}
-
/**
* @deprecated Use usePluginLinks() instead.
*/
diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginLinks.ts b/packages/grafana-runtime/src/services/pluginExtensions/usePluginLinks.ts
new file mode 100644
index 00000000000..d90d88cf4f7
--- /dev/null
+++ b/packages/grafana-runtime/src/services/pluginExtensions/usePluginLinks.ts
@@ -0,0 +1,20 @@
+import { UsePluginLinksOptions, UsePluginLinksResult } from './getPluginExtensions';
+
+export type UsePluginLinks = (options: UsePluginLinksOptions) => UsePluginLinksResult;
+
+let singleton: UsePluginLinks | undefined;
+
+export function setPluginLinksHook(hook: UsePluginLinks): void {
+ // We allow overriding the registry in tests
+ if (singleton && process.env.NODE_ENV !== 'test') {
+ throw new Error('setPluginLinksHook() function should only be called once, when Grafana is starting.');
+ }
+ singleton = hook;
+}
+
+export function usePluginLinks(options: UsePluginLinksOptions): UsePluginLinksResult {
+ if (!singleton) {
+ throw new Error('setPluginLinksHook(options) can only be used after the Grafana instance has started.');
+ }
+ return singleton(options);
+}
diff --git a/public/app/app.ts b/public/app/app.ts
index 1d164a5baef..6e6141e0d86 100644
--- a/public/app/app.ts
+++ b/public/app/app.ts
@@ -41,6 +41,7 @@ import {
setPluginComponentsHook,
setCurrentUser,
setChromeHeaderHeightHook,
+ setPluginLinksHook,
} from '@grafana/runtime';
import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView';
import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer';
@@ -83,14 +84,12 @@ import { initGrafanaLive } from './features/live';
import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView';
import { PanelRenderer } from './features/panel/components/PanelRenderer';
import { DatasourceSrv } from './features/plugins/datasource_srv';
-import { getCoreExtensionConfigurations } from './features/plugins/extensions/getCoreExtensionConfigurations';
import { createPluginExtensionsGetter } from './features/plugins/extensions/getPluginExtensions';
-import { ReactivePluginExtensionsRegistry } from './features/plugins/extensions/reactivePluginExtensionRegistry';
-import { AddedComponentsRegistry } from './features/plugins/extensions/registry/AddedComponentsRegistry';
-import { ExposedComponentsRegistry } from './features/plugins/extensions/registry/ExposedComponentsRegistry';
+import { setupPluginExtensionRegistries } from './features/plugins/extensions/registry/setup';
import { createUsePluginComponent } from './features/plugins/extensions/usePluginComponent';
import { createUsePluginComponents } from './features/plugins/extensions/usePluginComponents';
import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions';
+import { createUsePluginLinks } from './features/plugins/extensions/usePluginLinks';
import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin';
import { preloadPlugins } from './features/plugins/pluginPreloader';
import { QueryRunner } from './features/query/state/QueryRunner';
@@ -213,17 +212,7 @@ export class GrafanaApp {
initWindowRuntime();
// Initialize plugin extensions
- const pluginExtensionsRegistries = {
- extensionsRegistry: new ReactivePluginExtensionsRegistry(),
- addedComponentsRegistry: new AddedComponentsRegistry(),
- exposedComponentsRegistry: new ExposedComponentsRegistry(),
- };
- pluginExtensionsRegistries.extensionsRegistry.register({
- pluginId: 'grafana',
- extensionConfigs: getCoreExtensionConfigurations(),
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
- });
+ const pluginExtensionsRegistries = setupPluginExtensionRegistries();
if (contextSrv.user.orgRole !== '') {
// The "cloud-home-app" is registering banners once it's loaded, and this can cause a rerender in the AppChrome if it's loaded after the Grafana app init.
@@ -236,18 +225,9 @@ export class GrafanaApp {
await preloadPlugins(awaitedAppPlugins, pluginExtensionsRegistries, 'frontend_awaited_plugins_preload');
}
- setPluginExtensionGetter(
- createPluginExtensionsGetter(
- pluginExtensionsRegistries.extensionsRegistry,
- pluginExtensionsRegistries.addedComponentsRegistry
- )
- );
- setPluginExtensionsHook(
- createUsePluginExtensions(
- pluginExtensionsRegistries.extensionsRegistry,
- pluginExtensionsRegistries.addedComponentsRegistry
- )
- );
+ setPluginLinksHook(createUsePluginLinks(pluginExtensionsRegistries.addedLinksRegistry));
+ setPluginExtensionGetter(createPluginExtensionsGetter(pluginExtensionsRegistries));
+ setPluginExtensionsHook(createUsePluginExtensions(pluginExtensionsRegistries));
setPluginComponentHook(createUsePluginComponent(pluginExtensionsRegistries.exposedComponentsRegistry));
setPluginComponentsHook(createUsePluginComponents(pluginExtensionsRegistries.addedComponentsRegistry));
diff --git a/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx b/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx
index 74d843248ef..5d197f79dde 100644
--- a/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx
+++ b/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx
@@ -14,20 +14,18 @@ describe('getExploreExtensionConfigs', () => {
expect(extensions).toEqual([
{
- type: 'link',
title: 'Add to dashboard',
description: 'Use the query and panel from explore and create/add it to a dashboard',
- extensionPointId: PluginExtensionPoints.ExploreToolbarAction,
+ targets: [PluginExtensionPoints.ExploreToolbarAction],
icon: 'apps',
configure: expect.any(Function),
onClick: expect.any(Function),
category: 'Dashboards',
},
{
- type: 'link',
title: 'Add correlation',
description: 'Create a correlation from this query',
- extensionPointId: PluginExtensionPoints.ExploreToolbarAction,
+ targets: [PluginExtensionPoints.ExploreToolbarAction],
icon: 'link',
configure: expect.any(Function),
onClick: expect.any(Function),
diff --git a/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx b/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx
index a23cf1fc3a8..d842e451aa0 100644
--- a/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx
+++ b/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx
@@ -1,9 +1,9 @@
-import { PluginExtensionPoints, type PluginExtensionLinkConfig } from '@grafana/data';
+import { PluginExtensionAddedLinkConfig, PluginExtensionPoints } from '@grafana/data';
import { contextSrv } from 'app/core/core';
import { dispatch } from 'app/store/store';
import { AccessControlAction } from 'app/types';
-import { createExtensionLinkConfig, logWarning } from '../../plugins/extensions/utils';
+import { createAddedLinkConfig, logWarning } from '../../plugins/extensions/utils';
import { changeCorrelationEditorDetails } from '../state/main';
import { runQueries } from '../state/query';
@@ -11,13 +11,13 @@ import { AddToDashboardForm } from './AddToDashboard/AddToDashboardForm';
import { getAddToDashboardTitle } from './AddToDashboard/getAddToDashboardTitle';
import { type PluginExtensionExploreContext } from './ToolbarExtensionPoint';
-export function getExploreExtensionConfigs(): PluginExtensionLinkConfig[] {
+export function getExploreExtensionConfigs(): PluginExtensionAddedLinkConfig[] {
try {
return [
- createExtensionLinkConfig({
+ createAddedLinkConfig({
title: 'Add to dashboard',
description: 'Use the query and panel from explore and create/add it to a dashboard',
- extensionPointId: PluginExtensionPoints.ExploreToolbarAction,
+ targets: [PluginExtensionPoints.ExploreToolbarAction],
icon: 'apps',
category: 'Dashboards',
configure: () => {
@@ -39,10 +39,10 @@ export function getExploreExtensionConfigs(): PluginExtensionLinkConfig[] {
});
},
}),
- createExtensionLinkConfig({
+ createAddedLinkConfig({
title: 'Add correlation',
description: 'Create a correlation from this query',
- extensionPointId: PluginExtensionPoints.ExploreToolbarAction,
+ targets: [PluginExtensionPoints.ExploreToolbarAction],
icon: 'link',
configure: (context) => {
return context?.shouldShowAddCorrelation ? {} : undefined;
diff --git a/public/app/features/plugins/extensions/getCoreExtensionConfigurations.ts b/public/app/features/plugins/extensions/getCoreExtensionConfigurations.ts
index 9d4578febf0..5429ada00bf 100644
--- a/public/app/features/plugins/extensions/getCoreExtensionConfigurations.ts
+++ b/public/app/features/plugins/extensions/getCoreExtensionConfigurations.ts
@@ -1,6 +1,6 @@
-import { type PluginExtensionLinkConfig } from '@grafana/data';
+import { PluginExtensionAddedLinkConfig } from '@grafana/data';
import { getExploreExtensionConfigs } from 'app/features/explore/extensions/getExploreExtensionConfigs';
-export function getCoreExtensionConfigurations(): PluginExtensionLinkConfig[] {
+export function getCoreExtensionConfigurations(): PluginExtensionAddedLinkConfig[] {
return [...getExploreExtensionConfigs()];
}
diff --git a/public/app/features/plugins/extensions/getPluginExtensions.test.tsx b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx
index d055c74d060..b3af3582ea7 100644
--- a/public/app/features/plugins/extensions/getPluginExtensions.test.tsx
+++ b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx
@@ -1,16 +1,11 @@
import * as React from 'react';
-import {
- PluginAddedComponentConfig,
- PluginExtensionComponentConfig,
- PluginExtensionLinkConfig,
- PluginExtensionTypes,
-} from '@grafana/data';
+import { PluginExtensionAddedComponentConfig, PluginExtensionAddedLinkConfig } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { getPluginExtensions } from './getPluginExtensions';
-import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
+import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { isReadOnlyProxy } from './utils';
import { assertPluginExtensionLink } from './validators';
@@ -24,19 +19,17 @@ jest.mock('@grafana/runtime', () => {
async function createRegistries(
preloadResults: Array<{
pluginId: string;
- addedComponentConfigs: PluginAddedComponentConfig[];
- extensionConfigs: any[];
+ addedComponentConfigs: PluginExtensionAddedComponentConfig[];
+ addedLinkConfigs: PluginExtensionAddedLinkConfig[];
}>
) {
- const registry = new ReactivePluginExtensionsRegistry();
+ const addedLinksRegistry = new AddedLinksRegistry();
const addedComponentsRegistry = new AddedComponentsRegistry();
- for (const { pluginId, extensionConfigs, addedComponentConfigs } of preloadResults) {
- registry.register({
+ for (const { pluginId, addedLinkConfigs, addedComponentConfigs } of preloadResults) {
+ addedLinksRegistry.register({
pluginId,
- exposedComponentConfigs: [],
- extensionConfigs,
- addedComponentConfigs: [],
+ configs: addedLinkConfigs,
});
addedComponentsRegistry.register({
pluginId,
@@ -44,39 +37,41 @@ async function createRegistries(
});
}
- return { registry: await registry.getRegistry(), addedComponentsRegistry: await addedComponentsRegistry.getState() };
+ return {
+ addedLinksRegistry: await addedLinksRegistry.getState(),
+ addedComponentsRegistry: await addedComponentsRegistry.getState(),
+ };
}
describe('getPluginExtensions()', () => {
- const extensionPoint1 = 'grafana/dashboard/panel/menu';
- const extensionPoint2 = 'plugins/myorg-basic-app/start';
- const extensionPoint3 = 'grafana/datasources/config';
+ const extensionPoint1 = 'grafana/dashboard/panel/menu/v1';
+ const extensionPoint2 = 'plugins/myorg-basic-app/start/v1';
+ const extensionPoint3 = 'grafana/datasources/config/v1';
const pluginId = 'grafana-basic-app';
// Sample extension configs that are used in the tests below
- let link1: PluginExtensionLinkConfig, link2: PluginExtensionLinkConfig, component1: PluginExtensionComponentConfig;
+ let link1: PluginExtensionAddedLinkConfig,
+ link2: PluginExtensionAddedLinkConfig,
+ component1: PluginExtensionAddedComponentConfig;
beforeEach(() => {
link1 = {
- type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
- extensionPointId: extensionPoint1,
+ targets: extensionPoint1,
configure: jest.fn().mockReturnValue({}),
};
link2 = {
- type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident`,
- extensionPointId: extensionPoint2,
+ targets: extensionPoint2,
configure: jest.fn().mockImplementation((context) => ({ title: context?.title })),
};
component1 = {
- type: PluginExtensionTypes.component,
title: 'Component 1',
description: 'Component 1 description',
- extensionPointId: extensionPoint3,
+ targets: extensionPoint3,
component: (context) => {
return Hello world!
;
},
@@ -88,7 +83,7 @@ describe('getPluginExtensions()', () => {
test('should return the extensions for the given placement', async () => {
const registries = await createRegistries([
- { pluginId, extensionConfigs: [link1, link2], addedComponentConfigs: [] },
+ { pluginId, addedLinkConfigs: [link1, link2], addedComponentConfigs: [] },
]);
const { extensions } = getPluginExtensions({
...registries,
@@ -99,7 +94,6 @@ describe('getPluginExtensions()', () => {
expect(extensions[0]).toEqual(
expect.objectContaining({
pluginId,
- type: PluginExtensionTypes.link,
title: link1.title,
description: link1.description,
path: expect.stringContaining(link1.path!),
@@ -110,7 +104,7 @@ describe('getPluginExtensions()', () => {
test('should not limit the number of extensions per plugin by default', async () => {
// Registering 3 extensions for the same plugin for the same placement
const registries = await createRegistries([
- { pluginId, extensionConfigs: [link1, link1, link1, link2], addedComponentConfigs: [] },
+ { pluginId, addedLinkConfigs: [link1, link1, link1, link2], addedComponentConfigs: [] },
]);
const { extensions } = getPluginExtensions({
...registries,
@@ -121,7 +115,6 @@ describe('getPluginExtensions()', () => {
expect(extensions[0]).toEqual(
expect.objectContaining({
pluginId,
- type: PluginExtensionTypes.link,
title: link1.title,
description: link1.description,
path: expect.stringContaining(link1.path!),
@@ -131,11 +124,11 @@ describe('getPluginExtensions()', () => {
test('should be possible to limit the number of extensions per plugin for a given placement', async () => {
const registries = await createRegistries([
- { pluginId, extensionConfigs: [link1, link1, link1, link2], addedComponentConfigs: [] },
+ { pluginId, addedLinkConfigs: [link1, link1, link1, link2], addedComponentConfigs: [] },
{
pluginId: 'my-plugin',
addedComponentConfigs: [],
- extensionConfigs: [
+ addedLinkConfigs: [
{ ...link1, path: '/a/my-plugin/declare-incident' },
{ ...link1, path: '/a/my-plugin/declare-incident' },
{ ...link1, path: '/a/my-plugin/declare-incident' },
@@ -155,7 +148,6 @@ describe('getPluginExtensions()', () => {
expect(extensions[0]).toEqual(
expect.objectContaining({
pluginId,
- type: PluginExtensionTypes.link,
title: link1.title,
description: link1.description,
path: expect.stringContaining(link1.path!),
@@ -165,7 +157,7 @@ describe('getPluginExtensions()', () => {
test('should return with an empty list if there are no extensions registered for a placement yet', async () => {
const registries = await createRegistries([
- { pluginId, extensionConfigs: [link1, link2], addedComponentConfigs: [] },
+ { pluginId, addedLinkConfigs: [link1, link2], addedComponentConfigs: [] },
]);
const { extensions } = getPluginExtensions({
...registries,
@@ -177,7 +169,7 @@ describe('getPluginExtensions()', () => {
test('should pass the context to the configure() function', async () => {
const context = { title: 'New title from the context!' };
- const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]);
+ const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 });
@@ -194,7 +186,7 @@ describe('getPluginExtensions()', () => {
category: 'Machine Learning',
}));
- const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]);
+ const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({
...registries,
extensionPointId: extensionPoint2,
@@ -220,7 +212,7 @@ describe('getPluginExtensions()', () => {
category: 'Machine Learning',
}));
- const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]);
+ const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({
...registries,
extensionPointId: extensionPoint2,
@@ -231,7 +223,7 @@ describe('getPluginExtensions()', () => {
expect(link2.configure).toHaveBeenCalledTimes(1);
expect(extension.path).toBe(
- `/a/${pluginId}/updated-path?uel_pid=grafana-basic-app&uel_epid=plugins%2Fmyorg-basic-app%2Fstart`
+ `/a/${pluginId}/updated-path?uel_pid=grafana-basic-app&uel_epid=plugins%2Fmyorg-basic-app%2Fstart%2Fv1`
);
});
@@ -248,7 +240,7 @@ describe('getPluginExtensions()', () => {
title: 'test',
}));
- const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]);
+ const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({
...registries,
extensionPointId: extensionPoint2,
@@ -265,7 +257,7 @@ describe('getPluginExtensions()', () => {
});
test('should pass a read only context to the configure() function', async () => {
const context = { title: 'New title from the context!' };
- const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]);
+ const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({
...registries,
context,
@@ -289,7 +281,7 @@ describe('getPluginExtensions()', () => {
throw new Error('Something went wrong!');
});
- const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]);
+ const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
expect(() => {
getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
@@ -309,7 +301,7 @@ describe('getPluginExtensions()', () => {
}));
const registries = await createRegistries([
- { pluginId, extensionConfigs: [link1, link2], addedComponentConfigs: [] },
+ { pluginId, addedLinkConfigs: [link1, link2], addedComponentConfigs: [] },
]);
const { extensions: extensionsAtPlacement1 } = getPluginExtensions({
...registries,
@@ -336,7 +328,7 @@ describe('getPluginExtensions()', () => {
link2.configure = jest.fn().mockImplementation(() => overrides);
- const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]);
+ const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
expect(extensions).toHaveLength(0);
@@ -347,7 +339,7 @@ describe('getPluginExtensions()', () => {
test('should skip the extension if the configure() function returns a promise', async () => {
link2.configure = jest.fn().mockImplementation(() => Promise.resolve({}));
- const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]);
+ const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
expect(extensions).toHaveLength(0);
@@ -358,7 +350,7 @@ describe('getPluginExtensions()', () => {
test('should skip (hide) the extension if the configure() function returns undefined', async () => {
link2.configure = jest.fn().mockImplementation(() => undefined);
- const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]);
+ const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
expect(extensions).toHaveLength(0);
@@ -372,7 +364,7 @@ describe('getPluginExtensions()', () => {
});
const context = {};
- const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]);
+ const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
const [extension] = extensions;
@@ -395,7 +387,7 @@ describe('getPluginExtensions()', () => {
link2.path = undefined;
link2.onClick = jest.fn().mockRejectedValue(new Error('testing'));
- const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]);
+ const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
const [extension] = extensions;
@@ -414,7 +406,7 @@ describe('getPluginExtensions()', () => {
throw new Error('Something went wrong!');
});
- const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]);
+ const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
const [extension] = extensions;
@@ -432,7 +424,7 @@ describe('getPluginExtensions()', () => {
link2.path = undefined;
link2.onClick = jest.fn();
- const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]);
+ const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 });
const [extension] = extensions;
@@ -455,7 +447,7 @@ describe('getPluginExtensions()', () => {
array: ['a'],
};
- const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]);
+ const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 });
expect(() => {
@@ -471,7 +463,7 @@ describe('getPluginExtensions()', () => {
const registries = await createRegistries([
{
pluginId,
- extensionConfigs: [
+ addedLinkConfigs: [
{
...link1,
path: undefined,
@@ -501,22 +493,19 @@ describe('getPluginExtensions()', () => {
const registries = await createRegistries([
{
pluginId,
- extensionConfigs: [],
- addedComponentConfigs: [
- {
- ...component1,
- targets: component1.extensionPointId,
- },
- ],
+ addedLinkConfigs: [],
+ addedComponentConfigs: [component1],
},
]);
- const { extensions } = getPluginExtensions({ ...registries, extensionPointId: component1.extensionPointId });
+ const { extensions } = getPluginExtensions({
+ ...registries,
+ extensionPointId: Array.isArray(component1.targets) ? component1.targets[0] : component1.targets,
+ });
expect(extensions).toHaveLength(1);
expect(extensions[0]).toEqual(
expect.objectContaining({
pluginId,
- type: PluginExtensionTypes.component,
title: component1.title,
description: component1.description,
})
@@ -527,16 +516,13 @@ describe('getPluginExtensions()', () => {
const registries = await createRegistries([
{
pluginId,
- extensionConfigs: [],
+ addedLinkConfigs: [],
addedComponentConfigs: [
- {
- ...component1,
- targets: component1.extensionPointId,
- },
+ component1,
{
title: 'Component 2',
description: 'Component 2 description',
- targets: component1.extensionPointId,
+ targets: component1.targets,
component: (context) => {
return Hello world2!
;
},
@@ -547,14 +533,13 @@ describe('getPluginExtensions()', () => {
const { extensions } = getPluginExtensions({
...registries,
limitPerPlugin: 1,
- extensionPointId: component1.extensionPointId,
+ extensionPointId: Array.isArray(component1.targets) ? component1.targets[0] : component1.targets,
});
expect(extensions).toHaveLength(1);
expect(extensions[0]).toEqual(
expect.objectContaining({
pluginId,
- type: PluginExtensionTypes.component,
title: component1.title,
description: component1.description,
})
diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts
index 6560ca8e393..241f26ca15f 100644
--- a/public/app/features/plugins/extensions/getPluginExtensions.ts
+++ b/public/app/features/plugins/extensions/getPluginExtensions.ts
@@ -4,58 +4,53 @@ import {
type PluginExtension,
PluginExtensionTypes,
type PluginExtensionLink,
- type PluginExtensionLinkConfig,
type PluginExtensionComponent,
- urlUtil,
} from '@grafana/data';
-import { GetPluginExtensions, reportInteraction } from '@grafana/runtime';
+import { GetPluginExtensions } from '@grafana/runtime';
-import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
-import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
-import type { AddedComponentsRegistryState, PluginExtensionRegistry } from './types';
+import { AddedComponentRegistryItem } from './registry/AddedComponentsRegistry';
+import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry';
+import { RegistryType } from './registry/Registry';
+import type { PluginExtensionRegistries } from './registry/types';
import {
- isPluginExtensionLinkConfig,
getReadOnlyProxy,
logWarning,
generateExtensionId,
- getEventHelpers,
wrapWithPluginContext,
+ getLinkExtensionOnClick,
+ getLinkExtensionOverrides,
+ getLinkExtensionPathWithTracking,
} from './utils';
-import { assertIsNotPromise, assertLinkPathIsValid, assertStringProps, isPromise } from './validators';
type GetExtensions = ({
context,
extensionPointId,
limitPerPlugin,
- registry,
+ addedLinksRegistry,
addedComponentsRegistry,
}: {
context?: object | Record;
extensionPointId: string;
limitPerPlugin?: number;
- registry: PluginExtensionRegistry;
- addedComponentsRegistry: AddedComponentsRegistryState;
+ addedComponentsRegistry: RegistryType | undefined;
+ addedLinksRegistry: RegistryType | undefined;
}) => { extensions: PluginExtension[] };
-export function createPluginExtensionsGetter(
- extensionRegistry: ReactivePluginExtensionsRegistry,
- addedComponentRegistry: AddedComponentsRegistry
-): GetPluginExtensions {
- let registry: PluginExtensionRegistry = { id: '', extensions: {} };
- let addedComponentsRegistryState: AddedComponentsRegistryState = {};
+export function createPluginExtensionsGetter(registries: PluginExtensionRegistries): GetPluginExtensions {
+ let addedComponentsRegistry: RegistryType;
+ let addedLinksRegistry: RegistryType>>;
- // Create a subscription to keep an copy of the registry state for use in the non-async
+ // Create registry subscriptions to keep an copy of the registry state for use in the non-async
// plugin extensions getter.
- extensionRegistry.asObservable().subscribe((r) => {
- registry = r;
+ registries.addedComponentsRegistry.asObservable().subscribe((componentsRegistry) => {
+ addedComponentsRegistry = componentsRegistry;
});
- addedComponentRegistry.asObservable().subscribe((r) => {
- addedComponentsRegistryState = r;
+ registries.addedLinksRegistry.asObservable().subscribe((linksRegistry) => {
+ addedLinksRegistry = linksRegistry;
});
- return (options) =>
- getPluginExtensions({ ...options, registry, addedComponentsRegistry: addedComponentsRegistryState });
+ return (options) => getPluginExtensions({ ...options, addedComponentsRegistry, addedLinksRegistry });
}
// Returns with a list of plugin extensions for the given extension point
@@ -63,20 +58,17 @@ export const getPluginExtensions: GetExtensions = ({
context,
extensionPointId,
limitPerPlugin,
- registry,
+ addedLinksRegistry,
addedComponentsRegistry,
}) => {
const frozenContext = context ? getReadOnlyProxy(context) : {};
- const registryItems = registry.extensions[extensionPointId] ?? [];
// We don't return the extensions separated by type, because in that case it would be much harder to define a sort-order for them.
const extensions: PluginExtension[] = [];
const extensionsByPlugin: Record = {};
- for (const registryItem of registryItems) {
+ for (const addedLink of addedLinksRegistry?.[extensionPointId] ?? []) {
try {
- const extensionConfig = registryItem.config;
- const { pluginId } = registryItem;
-
+ const { pluginId } = addedLink;
// Only limit if the `limitPerPlugin` is set
if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) {
continue;
@@ -86,34 +78,35 @@ export const getPluginExtensions: GetExtensions = ({
extensionsByPlugin[pluginId] = 0;
}
- // LINK
- if (isPluginExtensionLinkConfig(extensionConfig)) {
- // Run the configure() function with the current context, and apply the ovverides
- const overrides = getLinkExtensionOverrides(pluginId, extensionConfig, frozenContext);
+ // Run the configure() function with the current context, and apply the ovverides
+ const overrides = getLinkExtensionOverrides(pluginId, addedLink, frozenContext);
- // configure() returned an `undefined` -> hide the extension
- if (extensionConfig.configure && overrides === undefined) {
- continue;
- }
-
- const path = overrides?.path || extensionConfig.path;
- const extension: PluginExtensionLink = {
- id: generateExtensionId(pluginId, extensionConfig),
- type: PluginExtensionTypes.link,
- pluginId: pluginId,
- onClick: getLinkExtensionOnClick(pluginId, extensionConfig, frozenContext),
-
- // Configurable properties
- icon: overrides?.icon || extensionConfig.icon,
- title: overrides?.title || extensionConfig.title,
- description: overrides?.description || extensionConfig.description,
- path: isString(path) ? getLinkExtensionPathWithTracking(pluginId, path, extensionConfig) : undefined,
- category: overrides?.category || extensionConfig.category,
- };
-
- extensions.push(extension);
- extensionsByPlugin[pluginId] += 1;
+ // configure() returned an `undefined` -> hide the extension
+ if (addedLink.configure && overrides === undefined) {
+ continue;
}
+
+ const path = overrides?.path || addedLink.path;
+ const extension: PluginExtensionLink = {
+ id: generateExtensionId(pluginId, {
+ ...addedLink,
+ extensionPointId,
+ type: PluginExtensionTypes.link,
+ }),
+ type: PluginExtensionTypes.link,
+ pluginId: pluginId,
+ onClick: getLinkExtensionOnClick(pluginId, extensionPointId, addedLink, frozenContext),
+
+ // Configurable properties
+ icon: overrides?.icon || addedLink.icon,
+ title: overrides?.title || addedLink.title,
+ description: overrides?.description || addedLink.description,
+ path: isString(path) ? getLinkExtensionPathWithTracking(pluginId, path, extensionPointId) : undefined,
+ category: overrides?.category || addedLink.category,
+ };
+
+ extensions.push(extension);
+ extensionsByPlugin[pluginId] += 1;
} catch (error) {
if (error instanceof Error) {
logWarning(error.message);
@@ -121,139 +114,32 @@ export const getPluginExtensions: GetExtensions = ({
}
}
- if (extensionPointId in addedComponentsRegistry) {
- try {
- const addedComponents = addedComponentsRegistry[extensionPointId];
- for (const addedComponent of addedComponents) {
- // Only limit if the `limitPerPlugin` is set
- if (limitPerPlugin && extensionsByPlugin[addedComponent.pluginId] >= limitPerPlugin) {
- continue;
- }
-
- if (extensionsByPlugin[addedComponent.pluginId] === undefined) {
- extensionsByPlugin[addedComponent.pluginId] = 0;
- }
- const extension: PluginExtensionComponent = {
- id: generateExtensionId(addedComponent.pluginId, {
- ...addedComponent,
- extensionPointId,
- type: PluginExtensionTypes.component,
- }),
- type: PluginExtensionTypes.component,
- pluginId: addedComponent.pluginId,
- title: addedComponent.title,
- description: addedComponent.description,
- component: wrapWithPluginContext(addedComponent.pluginId, addedComponent.component),
- };
-
- extensions.push(extension);
- extensionsByPlugin[addedComponent.pluginId] += 1;
- }
- } catch (error) {
- if (error instanceof Error) {
- logWarning(error.message);
- }
+ const addedComponents = addedComponentsRegistry?.[extensionPointId] ?? [];
+ for (const addedComponent of addedComponents) {
+ // Only limit if the `limitPerPlugin` is set
+ if (limitPerPlugin && extensionsByPlugin[addedComponent.pluginId] >= limitPerPlugin) {
+ continue;
}
+
+ if (extensionsByPlugin[addedComponent.pluginId] === undefined) {
+ extensionsByPlugin[addedComponent.pluginId] = 0;
+ }
+ const extension: PluginExtensionComponent = {
+ id: generateExtensionId(addedComponent.pluginId, {
+ ...addedComponent,
+ extensionPointId,
+ type: PluginExtensionTypes.component,
+ }),
+ type: PluginExtensionTypes.component,
+ pluginId: addedComponent.pluginId,
+ title: addedComponent.title,
+ description: addedComponent.description,
+ component: wrapWithPluginContext(addedComponent.pluginId, addedComponent.component),
+ };
+
+ extensions.push(extension);
+ extensionsByPlugin[addedComponent.pluginId] += 1;
}
return { extensions };
};
-
-function getLinkExtensionOverrides(pluginId: string, config: PluginExtensionLinkConfig, context?: object) {
- try {
- const overrides = config.configure?.(context);
-
- // Hiding the extension
- if (overrides === undefined) {
- return undefined;
- }
-
- let {
- title = config.title,
- description = config.description,
- path = config.path,
- icon = config.icon,
- category = config.category,
- ...rest
- } = overrides;
-
- assertIsNotPromise(
- overrides,
- `The configure() function for "${config.title}" returned a promise, skipping updates.`
- );
-
- path && assertLinkPathIsValid(pluginId, path);
- assertStringProps({ title, description }, ['title', 'description']);
-
- if (Object.keys(rest).length > 0) {
- logWarning(
- `Extension "${config.title}", is trying to override restricted properties: ${Object.keys(rest).join(
- ', '
- )} which will be ignored.`
- );
- }
-
- return {
- title,
- description,
- path,
- icon,
- category,
- };
- } catch (error) {
- if (error instanceof Error) {
- logWarning(error.message);
- }
-
- // If there is an error, we hide the extension
- // (This seems to be safest option in case the extension is doing something wrong.)
- return undefined;
- }
-}
-
-function getLinkExtensionOnClick(
- pluginId: string,
- config: PluginExtensionLinkConfig,
- context?: object
-): ((event?: React.MouseEvent) => void) | undefined {
- const { onClick } = config;
-
- if (!onClick) {
- return;
- }
-
- return function onClickExtensionLink(event?: React.MouseEvent) {
- try {
- reportInteraction('ui_extension_link_clicked', {
- pluginId: pluginId,
- extensionPointId: config.extensionPointId,
- title: config.title,
- category: config.category,
- });
-
- const result = onClick(event, getEventHelpers(pluginId, context));
-
- if (isPromise(result)) {
- result.catch((e) => {
- if (e instanceof Error) {
- logWarning(e.message);
- }
- });
- }
- } catch (error) {
- if (error instanceof Error) {
- logWarning(error.message);
- }
- }
- };
-}
-
-function getLinkExtensionPathWithTracking(pluginId: string, path: string, config: PluginExtensionLinkConfig): string {
- return urlUtil.appendQueryToUrl(
- path,
- urlUtil.toUrlParams({
- uel_pid: pluginId,
- uel_epid: config.extensionPointId,
- })
- );
-}
diff --git a/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.test.ts b/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.test.ts
deleted file mode 100644
index 5ab6abee781..00000000000
--- a/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.test.ts
+++ /dev/null
@@ -1,718 +0,0 @@
-import { firstValueFrom } from 'rxjs';
-
-import { PluginExtensionTypes } from '@grafana/data';
-
-import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
-
-describe('createPluginExtensionsRegistry', () => {
- const consoleWarn = jest.fn();
-
- beforeEach(() => {
- global.console.warn = consoleWarn;
- consoleWarn.mockReset();
- });
-
- it('should return empty registry when no extensions registered', async () => {
- const reactiveRegistry = new ReactivePluginExtensionsRegistry();
- const observable = reactiveRegistry.asObservable();
- const registry = await firstValueFrom(observable);
- expect(registry).toEqual({
- id: '',
- extensions: {},
- });
- });
-
- it('should generate an id for the registry once we register an extension to it', async () => {
- const pluginId = 'grafana-basic-app';
- const extensionPointId = 'grafana/dashboard/panel/menu';
- const reactiveRegistry = new ReactivePluginExtensionsRegistry();
-
- reactiveRegistry.register({
- pluginId,
- extensionConfigs: [
- {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/${pluginId}/declare-incident`,
- extensionPointId,
- configure: jest.fn().mockReturnValue({}),
- },
- ],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
- });
-
- const registry = await reactiveRegistry.getRegistry();
-
- expect(registry.id).toBeDefined();
- expect(registry.extensions[extensionPointId]).toHaveLength(1);
- });
-
- it('should generate an a new id every time the registry changes', async () => {
- const pluginId = 'grafana-basic-app';
- const extensionPointId = 'grafana/dashboard/panel/menu';
- const reactiveRegistry = new ReactivePluginExtensionsRegistry();
-
- reactiveRegistry.register({
- pluginId,
- extensionConfigs: [
- {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/${pluginId}/declare-incident`,
- extensionPointId,
- configure: jest.fn().mockReturnValue({}),
- },
- ],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
- });
-
- const registry1 = await reactiveRegistry.getRegistry();
- const id1 = registry1.id;
-
- expect(id1).toBeDefined();
-
- reactiveRegistry.register({
- pluginId,
- extensionConfigs: [
- {
- type: PluginExtensionTypes.link,
- title: 'Link 2',
- description: 'Link 2 description',
- path: `/a/${pluginId}/declare-incident`,
- extensionPointId,
- configure: jest.fn().mockReturnValue({}),
- },
- ],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
- });
-
- const registry2 = await reactiveRegistry.getRegistry();
- const id2 = registry2.id;
-
- expect(id2).toBeDefined();
- expect(id2).not.toEqual(id1);
- });
-
- it('should be possible to register extensions in the registry', async () => {
- const pluginId = 'grafana-basic-app';
- const reactiveRegistry = new ReactivePluginExtensionsRegistry();
-
- reactiveRegistry.register({
- pluginId,
- extensionConfigs: [
- {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/${pluginId}/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: jest.fn().mockReturnValue({}),
- },
- {
- type: PluginExtensionTypes.link,
- title: 'Link 2',
- description: 'Link 2 description',
- path: `/a/${pluginId}/declare-incident`,
- extensionPointId: 'plugins/myorg-basic-app/start',
- configure: jest.fn().mockImplementation((context) => ({ title: context?.title })),
- },
- ],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
- });
-
- const registry = await reactiveRegistry.getRegistry();
-
- expect(registry.extensions).toEqual({
- 'grafana/dashboard/panel/menu': [
- {
- pluginId: pluginId,
- config: {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/${pluginId}/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: expect.any(Function),
- },
- },
- ],
- 'plugins/myorg-basic-app/start': [
- {
- pluginId: pluginId,
- config: {
- type: PluginExtensionTypes.link,
- title: 'Link 2',
- description: 'Link 2 description',
- path: `/a/${pluginId}/declare-incident`,
- extensionPointId: 'plugins/myorg-basic-app/start',
- configure: expect.any(Function),
- },
- },
- ],
- });
- });
-
- it('should be possible to asynchronously register extensions for the same placement (different plugins)', async () => {
- const pluginId1 = 'grafana-basic-app';
- const pluginId2 = 'grafana-basic-app2';
- const reactiveRegistry = new ReactivePluginExtensionsRegistry();
-
- // Register extensions for the first plugin
- reactiveRegistry.register({
- pluginId: pluginId1,
- extensionConfigs: [
- {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/${pluginId1}/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: jest.fn().mockReturnValue({}),
- },
- ],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
- });
-
- const registry1 = await reactiveRegistry.getRegistry();
-
- expect(registry1.extensions).toEqual({
- 'grafana/dashboard/panel/menu': [
- {
- pluginId: pluginId1,
- config: {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/${pluginId1}/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: expect.any(Function),
- },
- },
- ],
- });
-
- // Register extensions for the second plugin to a different placement
- reactiveRegistry.register({
- pluginId: pluginId2,
- extensionConfigs: [
- {
- type: PluginExtensionTypes.link,
- title: 'Link 2',
- description: 'Link 2 description',
- path: `/a/${pluginId2}/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: jest.fn().mockReturnValue({}),
- },
- ],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
- });
-
- const registry2 = await reactiveRegistry.getRegistry();
-
- expect(registry2.extensions).toEqual({
- 'grafana/dashboard/panel/menu': [
- {
- pluginId: pluginId1,
- config: {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/${pluginId1}/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: expect.any(Function),
- },
- },
- {
- pluginId: pluginId2,
- config: {
- type: PluginExtensionTypes.link,
- title: 'Link 2',
- description: 'Link 2 description',
- path: `/a/${pluginId2}/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: expect.any(Function),
- },
- },
- ],
- });
- });
-
- it('should be possible to asynchronously register extensions for a different placement (different plugin)', async () => {
- const pluginId1 = 'grafana-basic-app';
- const pluginId2 = 'grafana-basic-app2';
- const reactiveRegistry = new ReactivePluginExtensionsRegistry();
-
- // Register extensions for the first plugin
- reactiveRegistry.register({
- pluginId: pluginId1,
- extensionConfigs: [
- {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/${pluginId1}/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: jest.fn().mockReturnValue({}),
- },
- ],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
- });
-
- const registry1 = await reactiveRegistry.getRegistry();
-
- expect(registry1.extensions).toEqual({
- 'grafana/dashboard/panel/menu': [
- {
- pluginId: pluginId1,
- config: {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/${pluginId1}/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: expect.any(Function),
- },
- },
- ],
- });
-
- // Register extensions for the second plugin to a different placement
- reactiveRegistry.register({
- pluginId: pluginId2,
- extensionConfigs: [
- {
- type: PluginExtensionTypes.link,
- title: 'Link 2',
- description: 'Link 2 description',
- path: `/a/${pluginId2}/declare-incident`,
- extensionPointId: 'plugins/myorg-basic-app/start',
- configure: jest.fn().mockReturnValue({}),
- },
- ],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
- });
-
- const registry2 = await reactiveRegistry.getRegistry();
-
- expect(registry2.extensions).toEqual({
- 'grafana/dashboard/panel/menu': [
- {
- pluginId: pluginId1,
- config: {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/${pluginId1}/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: expect.any(Function),
- },
- },
- ],
- 'plugins/myorg-basic-app/start': [
- {
- pluginId: pluginId2,
- config: {
- type: PluginExtensionTypes.link,
- title: 'Link 2',
- description: 'Link 2 description',
- path: `/a/${pluginId2}/declare-incident`,
- extensionPointId: 'plugins/myorg-basic-app/start',
- configure: expect.any(Function),
- },
- },
- ],
- });
- });
-
- it('should be possible to asynchronously register extensions for the same placement (same plugin)', async () => {
- const pluginId = 'grafana-basic-app';
- const reactiveRegistry = new ReactivePluginExtensionsRegistry();
-
- // Register extensions for the first extension point
- reactiveRegistry.register({
- pluginId: pluginId,
- extensionConfigs: [
- {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/${pluginId}/declare-incident-1`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: jest.fn().mockReturnValue({}),
- },
- ],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
- });
-
- // Register extensions to a different extension point
- reactiveRegistry.register({
- pluginId: pluginId,
- extensionConfigs: [
- {
- type: PluginExtensionTypes.link,
- title: 'Link 2',
- description: 'Link 2 description',
- path: `/a/${pluginId}/declare-incident-2`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: jest.fn().mockReturnValue({}),
- },
- ],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
- });
-
- const registry2 = await reactiveRegistry.getRegistry();
-
- expect(registry2.extensions).toEqual({
- 'grafana/dashboard/panel/menu': [
- {
- pluginId: pluginId,
- config: {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/${pluginId}/declare-incident-1`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: expect.any(Function),
- },
- },
- {
- pluginId: pluginId,
- config: {
- type: PluginExtensionTypes.link,
- title: 'Link 2',
- description: 'Link 2 description',
- path: `/a/${pluginId}/declare-incident-2`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: expect.any(Function),
- },
- },
- ],
- });
- });
-
- it('should be possible to asynchronously register extensions for a different placement (same plugin)', async () => {
- const pluginId = 'grafana-basic-app';
- const reactiveRegistry = new ReactivePluginExtensionsRegistry();
-
- // Register extensions for the first extension point
- reactiveRegistry.register({
- pluginId: pluginId,
- extensionConfigs: [
- {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/${pluginId}/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: jest.fn().mockReturnValue({}),
- },
- ],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
- });
-
- // Register extensions to a different extension point
- reactiveRegistry.register({
- pluginId: pluginId,
- extensionConfigs: [
- {
- type: PluginExtensionTypes.link,
- title: 'Link 2',
- description: 'Link 2 description',
- path: `/a/${pluginId}/declare-incident`,
- extensionPointId: 'plugins/myorg-basic-app/start',
- configure: jest.fn().mockReturnValue({}),
- },
- ],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
- });
-
- const registry2 = await reactiveRegistry.getRegistry();
-
- expect(registry2.extensions).toEqual({
- 'grafana/dashboard/panel/menu': [
- {
- pluginId: pluginId,
- config: {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/${pluginId}/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: expect.any(Function),
- },
- },
- ],
- 'plugins/myorg-basic-app/start': [
- {
- pluginId: pluginId,
- config: {
- type: PluginExtensionTypes.link,
- title: 'Link 2',
- description: 'Link 2 description',
- path: `/a/${pluginId}/declare-incident`,
- extensionPointId: 'plugins/myorg-basic-app/start',
- configure: expect.any(Function),
- },
- },
- ],
- });
- });
-
- it('should notify subscribers when the registry changes', async () => {
- const pluginId = 'grafana-basic-app';
- const reactiveRegistry = new ReactivePluginExtensionsRegistry();
- const observable = reactiveRegistry.asObservable();
- const subscribeCallback = jest.fn();
-
- observable.subscribe(subscribeCallback);
-
- // Register extensions for the first plugin
- reactiveRegistry.register({
- pluginId: pluginId,
- extensionConfigs: [
- {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/${pluginId}/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: jest.fn().mockReturnValue({}),
- },
- ],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
- });
-
- expect(subscribeCallback).toHaveBeenCalledTimes(2);
-
- // Register extensions for the first plugin
- reactiveRegistry.register({
- pluginId: 'another-plugin',
- extensionConfigs: [
- {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/another-plugin/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: jest.fn().mockReturnValue({}),
- },
- ],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
- });
-
- expect(subscribeCallback).toHaveBeenCalledTimes(3);
-
- const registry = subscribeCallback.mock.calls[2][0];
-
- expect(registry.extensions).toEqual({
- 'grafana/dashboard/panel/menu': [
- {
- pluginId: pluginId,
- config: {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/${pluginId}/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: expect.any(Function),
- },
- },
- {
- pluginId: 'another-plugin',
- config: {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/another-plugin/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: expect.any(Function),
- },
- },
- ],
- });
- });
-
- it('should give the last version of the registry for new subscribers', async () => {
- const pluginId = 'grafana-basic-app';
- const reactiveRegistry = new ReactivePluginExtensionsRegistry();
- const observable = reactiveRegistry.asObservable();
- const subscribeCallback = jest.fn();
-
- reactiveRegistry.register({
- pluginId: pluginId,
- extensionConfigs: [
- {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/${pluginId}/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: jest.fn().mockReturnValue({}),
- },
- ],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
- });
-
- observable.subscribe(subscribeCallback);
- expect(subscribeCallback).toHaveBeenCalledTimes(1);
-
- const registry = subscribeCallback.mock.calls[0][0];
-
- expect(registry.extensions).toEqual({
- 'grafana/dashboard/panel/menu': [
- {
- pluginId: pluginId,
- config: {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/${pluginId}/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: expect.any(Function),
- },
- },
- ],
- });
- });
-
- it('should not register extensions for a plugin that had errors', () => {
- const pluginId = 'grafana-basic-app';
- const reactiveRegistry = new ReactivePluginExtensionsRegistry();
- const observable = reactiveRegistry.asObservable();
- const subscribeCallback = jest.fn();
-
- reactiveRegistry.register({
- error: new Error('Something is broken'),
- pluginId: pluginId,
- extensionConfigs: [
- {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/${pluginId}/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: jest.fn().mockReturnValue({}),
- },
- ],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
- });
-
- expect(consoleWarn).toHaveBeenCalled();
-
- observable.subscribe(subscribeCallback);
- expect(subscribeCallback).toHaveBeenCalledTimes(1);
-
- const registry = subscribeCallback.mock.calls[0][0];
- expect(registry.extensions).toEqual({});
- });
-
- it('should not register an extension if it has an invalid configure() function', () => {
- const pluginId = 'grafana-basic-app';
- const reactiveRegistry = new ReactivePluginExtensionsRegistry();
- const observable = reactiveRegistry.asObservable();
- const subscribeCallback = jest.fn();
-
- reactiveRegistry.register({
- pluginId: pluginId,
- extensionConfigs: [
- {
- type: PluginExtensionTypes.link,
- title: 'Link 1',
- description: 'Link 1 description',
- path: `/a/${pluginId}/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- //@ts-ignore
- configure: '...',
- },
- ],
- });
-
- expect(consoleWarn).toHaveBeenCalled();
-
- observable.subscribe(subscribeCallback);
- expect(subscribeCallback).toHaveBeenCalledTimes(1);
-
- const registry = subscribeCallback.mock.calls[0][0];
- expect(registry.extensions).toEqual({});
- });
-
- it('should not register an extension if it has invalid properties (empty title / description)', () => {
- const pluginId = 'grafana-basic-app';
- const reactiveRegistry = new ReactivePluginExtensionsRegistry();
- const observable = reactiveRegistry.asObservable();
- const subscribeCallback = jest.fn();
-
- reactiveRegistry.register({
- pluginId: pluginId,
- extensionConfigs: [
- {
- type: PluginExtensionTypes.link,
- title: '',
- description: '',
- path: `/a/${pluginId}/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: jest.fn().mockReturnValue({}),
- },
- ],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
- });
-
- expect(consoleWarn).toHaveBeenCalled();
-
- observable.subscribe(subscribeCallback);
- expect(subscribeCallback).toHaveBeenCalledTimes(1);
-
- const registry = subscribeCallback.mock.calls[0][0];
- expect(registry.extensions).toEqual({});
- });
-
- it('should not register link extensions with invalid path configured', () => {
- const pluginId = 'grafana-basic-app';
- const reactiveRegistry = new ReactivePluginExtensionsRegistry();
- const observable = reactiveRegistry.asObservable();
- const subscribeCallback = jest.fn();
-
- reactiveRegistry.register({
- pluginId: pluginId,
- extensionConfigs: [
- {
- type: PluginExtensionTypes.link,
- title: 'Title 1',
- description: 'Description 1',
- path: `/a/another-plugin/declare-incident`,
- extensionPointId: 'grafana/dashboard/panel/menu',
- configure: jest.fn().mockReturnValue({}),
- },
- ],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
- });
-
- expect(consoleWarn).toHaveBeenCalled();
-
- observable.subscribe(subscribeCallback);
- expect(subscribeCallback).toHaveBeenCalledTimes(1);
-
- const registry = subscribeCallback.mock.calls[0][0];
- expect(registry.extensions).toEqual({});
- });
-});
diff --git a/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.ts b/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.ts
deleted file mode 100644
index d5d29cb22e1..00000000000
--- a/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { Observable, ReplaySubject, Subject, firstValueFrom, map, scan, startWith } from 'rxjs';
-import { v4 as uuidv4 } from 'uuid';
-
-import { PluginPreloadResult } from '../pluginPreloader';
-
-import { PluginExtensionRegistry, PluginExtensionRegistryItem } from './types';
-import { deepFreeze, logWarning } from './utils';
-import { isPluginExtensionConfigValid } from './validators';
-
-export class ReactivePluginExtensionsRegistry {
- private resultSubject: Subject;
- private registrySubject: ReplaySubject;
-
- constructor() {
- this.resultSubject = new Subject();
- // This is the subject that we expose.
- // (It will buffer the last value on the stream - the registry - and emit it to new subscribers immediately.)
- this.registrySubject = new ReplaySubject(1);
-
- this.resultSubject
- .pipe(
- scan(resultsToRegistry, { id: '', extensions: {} }),
- // Emit an empty registry to start the stream (it is only going to do it once during construction, and then just passes down the values)
- startWith({ id: '', extensions: {} }),
- map((registry) => deepFreeze(registry))
- )
- // Emitting the new registry to `this.registrySubject`
- .subscribe(this.registrySubject);
- }
-
- register(result: PluginPreloadResult): void {
- this.resultSubject.next(result);
- }
-
- asObservable(): Observable {
- return this.registrySubject.asObservable();
- }
-
- getRegistry(): Promise {
- return firstValueFrom(this.asObservable());
- }
-}
-
-function resultsToRegistry(registry: PluginExtensionRegistry, result: PluginPreloadResult): PluginExtensionRegistry {
- const { pluginId, extensionConfigs, error } = result;
-
- // TODO: We should probably move this section to where we load the plugin since this is only used
- // to provide a log to the user.
- if (error) {
- logWarning(`"${pluginId}" plugin failed to load, skip registering its extensions.`);
- return registry;
- }
-
- for (const extensionConfig of extensionConfigs) {
- const { extensionPointId } = extensionConfig;
-
- // Check if the config is valid
- if (!extensionConfig || !isPluginExtensionConfigValid(pluginId, extensionConfig)) {
- return registry;
- }
-
- let registryItem: PluginExtensionRegistryItem = {
- config: extensionConfig,
-
- // Additional meta information about the extension
- pluginId,
- };
-
- if (!Array.isArray(registry.extensions[extensionPointId])) {
- registry.extensions[extensionPointId] = [registryItem];
- } else {
- registry.extensions[extensionPointId].push(registryItem);
- }
- }
-
- // Add a unique ID to the registry (the registry object itself is immutable)
- registry.id = uuidv4();
-
- return registry;
-}
diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts
index 01799ecce62..5d5263b5e95 100644
--- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts
+++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts
@@ -314,7 +314,7 @@ describe('AddedComponentsRegistry', () => {
expect(Object.keys(currentState)).toHaveLength(0);
});
- it('should log a warning when exposed component id is not suffixed with component version', async () => {
+ it('should log a warning when added component id is not suffixed with component version', async () => {
const registry = new AddedComponentsRegistry();
registry.register({
pluginId: 'grafana-basic-app',
@@ -322,14 +322,14 @@ describe('AddedComponentsRegistry', () => {
{
title: 'Component 1 title',
description: 'Component 1 description',
- targets: ['grafana/alerting/home'],
+ targets: ['grafana/test/home'],
component: () => React.createElement('div', null, 'Hello World1'),
},
],
});
expect(consoleWarn).toHaveBeenCalledWith(
- "[Plugin Extensions] Added component with id 'grafana/alerting/home' does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'."
+ "[Plugin Extensions] Added component with id 'grafana/test/home' does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'."
);
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(1);
diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts
index 78420c42a17..954e49b0f34 100644
--- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts
+++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts
@@ -1,7 +1,12 @@
-import { PluginAddedComponentConfig } from '@grafana/data';
+import { PluginExtensionAddedComponentConfig } from '@grafana/data';
import { logWarning, wrapWithPluginContext } from '../utils';
-import { extensionPointEndsWithVersion, isExtensionPointIdValid, isReactComponent } from '../validators';
+import {
+ extensionPointEndsWithVersion,
+ isExtensionPointIdValid,
+ isGrafanaCoreExtensionPoint,
+ isReactComponent,
+} from '../validators';
import { PluginExtensionConfigs, Registry, RegistryType } from './Registry';
@@ -12,7 +17,10 @@ export type AddedComponentRegistryItem = {
component: React.ComponentType;
};
-export class AddedComponentsRegistry extends Registry {
+export class AddedComponentsRegistry extends Registry<
+ AddedComponentRegistryItem[],
+ PluginExtensionAddedComponentConfig
+> {
constructor(initialState: RegistryType = {}) {
super({
initialState,
@@ -21,7 +29,7 @@ export class AddedComponentsRegistry extends Registry,
- item: PluginExtensionConfigs
+ item: PluginExtensionConfigs
): RegistryType {
const { pluginId, configs } = item;
@@ -52,7 +60,7 @@ export class AddedComponentsRegistry extends Registry {
+ const consoleWarn = jest.fn();
+
+ beforeEach(() => {
+ global.console.warn = consoleWarn;
+ consoleWarn.mockReset();
+ });
+
+ it('should return empty registry when no extensions registered', async () => {
+ const addedLinksRegistry = new AddedLinksRegistry();
+ 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 pluginId = 'grafana-basic-app';
+ const addedLinksRegistry = new AddedLinksRegistry();
+
+ addedLinksRegistry.register({
+ pluginId,
+ configs: [
+ {
+ title: 'Link 1',
+ description: 'Link 1 description',
+ path: `/a/${pluginId}/declare-incident`,
+ targets: 'grafana/dashboard/panel/menu',
+ configure: jest.fn().mockReturnValue({}),
+ },
+ {
+ title: 'Link 2',
+ description: 'Link 2 description',
+ path: `/a/${pluginId}/declare-incident`,
+ targets: 'plugins/myorg-basic-app/start',
+ configure: jest.fn().mockImplementation((context) => ({ title: context?.title })),
+ },
+ ],
+ });
+
+ const registry = await addedLinksRegistry.getState();
+
+ expect(registry).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId,
+ title: 'Link 1',
+ description: 'Link 1 description',
+ path: `/a/${pluginId}/declare-incident`,
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ configure: expect.any(Function),
+ },
+ ],
+ 'plugins/myorg-basic-app/start': [
+ {
+ pluginId: pluginId,
+ title: 'Link 2',
+ description: 'Link 2 description',
+ path: `/a/${pluginId}/declare-incident`,
+ extensionPointId: 'plugins/myorg-basic-app/start',
+ configure: expect.any(Function),
+ },
+ ],
+ });
+ });
+ it('should be possible to asynchronously register link extensions for the same placement (different plugins)', async () => {
+ const pluginId1 = 'grafana-basic-app';
+ const pluginId2 = 'grafana-basic-app2';
+ const reactiveRegistry = new AddedLinksRegistry();
+
+ // Register extensions for the first plugin
+ reactiveRegistry.register({
+ pluginId: pluginId1,
+ configs: [
+ {
+ title: 'Link 1',
+ description: 'Link 1 description',
+ path: `/a/${pluginId1}/declare-incident`,
+ targets: 'grafana/dashboard/panel/menu',
+ configure: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ const registry1 = await reactiveRegistry.getState();
+
+ expect(registry1).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId1,
+ title: 'Link 1',
+ description: 'Link 1 description',
+ path: `/a/${pluginId1}/declare-incident`,
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ configure: expect.any(Function),
+ },
+ ],
+ });
+
+ // Register extensions for the second plugin to a different placement
+ reactiveRegistry.register({
+ pluginId: pluginId2,
+ configs: [
+ {
+ title: 'Link 2',
+ description: 'Link 2 description',
+ path: `/a/${pluginId2}/declare-incident`,
+ targets: 'grafana/dashboard/panel/menu',
+ configure: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ const registry2 = await reactiveRegistry.getState();
+
+ expect(registry2).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId1,
+ title: 'Link 1',
+ description: 'Link 1 description',
+ path: `/a/${pluginId1}/declare-incident`,
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ configure: expect.any(Function),
+ },
+ {
+ pluginId: pluginId2,
+ title: 'Link 2',
+ description: 'Link 2 description',
+ path: `/a/${pluginId2}/declare-incident`,
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ configure: expect.any(Function),
+ },
+ ],
+ });
+ });
+
+ it('should be possible to asynchronously register link extensions for a different placement (different plugin)', async () => {
+ const pluginId1 = 'grafana-basic-app';
+ const pluginId2 = 'grafana-basic-app2';
+ const reactiveRegistry = new AddedLinksRegistry();
+
+ // Register extensions for the first plugin
+ reactiveRegistry.register({
+ pluginId: pluginId1,
+ configs: [
+ {
+ title: 'Link 1',
+ description: 'Link 1 description',
+ path: `/a/${pluginId1}/declare-incident`,
+ targets: 'grafana/dashboard/panel/menu',
+ configure: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ const registry1 = await reactiveRegistry.getState();
+
+ expect(registry1).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId1,
+
+ title: 'Link 1',
+ description: 'Link 1 description',
+ path: `/a/${pluginId1}/declare-incident`,
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ configure: expect.any(Function),
+ },
+ ],
+ });
+
+ // Register extensions for the second plugin to a different placement
+ reactiveRegistry.register({
+ pluginId: pluginId2,
+ configs: [
+ {
+ title: 'Link 2',
+ description: 'Link 2 description',
+ path: `/a/${pluginId2}/declare-incident`,
+ targets: 'plugins/myorg-basic-app/start',
+ configure: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ const registry2 = await reactiveRegistry.getState();
+
+ expect(registry2).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId1,
+ title: 'Link 1',
+ description: 'Link 1 description',
+ path: `/a/${pluginId1}/declare-incident`,
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ configure: expect.any(Function),
+ },
+ ],
+ 'plugins/myorg-basic-app/start': [
+ {
+ pluginId: pluginId2,
+ title: 'Link 2',
+ description: 'Link 2 description',
+ path: `/a/${pluginId2}/declare-incident`,
+ extensionPointId: 'plugins/myorg-basic-app/start',
+ configure: expect.any(Function),
+ },
+ ],
+ });
+ });
+
+ it('should be possible to asynchronously register link extensions for the same placement (same plugin)', async () => {
+ const pluginId = 'grafana-basic-app';
+ const reactiveRegistry = new AddedLinksRegistry();
+
+ // Register extensions for the first extension point
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Link 1',
+ description: 'Link 1 description',
+ path: `/a/${pluginId}/declare-incident-1`,
+ targets: 'grafana/dashboard/panel/menu',
+ configure: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ // Register extensions to a different extension point
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Link 2',
+ description: 'Link 2 description',
+ path: `/a/${pluginId}/declare-incident-2`,
+ targets: 'grafana/dashboard/panel/menu',
+ configure: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ const registry2 = await reactiveRegistry.getState();
+
+ expect(registry2).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId,
+
+ title: 'Link 1',
+ description: 'Link 1 description',
+ path: `/a/${pluginId}/declare-incident-1`,
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ configure: expect.any(Function),
+ },
+ {
+ pluginId: pluginId,
+
+ title: 'Link 2',
+ description: 'Link 2 description',
+ path: `/a/${pluginId}/declare-incident-2`,
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ configure: expect.any(Function),
+ },
+ ],
+ });
+ });
+
+ it('should be possible to asynchronously register link extensions for a different placement (same plugin)', async () => {
+ const pluginId = 'grafana-basic-app';
+ const reactiveRegistry = new AddedLinksRegistry();
+
+ // Register extensions for the first extension point
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Link 1',
+ description: 'Link 1 description',
+ path: `/a/${pluginId}/declare-incident`,
+ targets: 'grafana/dashboard/panel/menu',
+ configure: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ // Register extensions to a different extension point
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Link 2',
+ description: 'Link 2 description',
+ path: `/a/${pluginId}/declare-incident`,
+ targets: 'plugins/myorg-basic-app/start',
+ configure: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ const registry2 = await reactiveRegistry.getState();
+
+ expect(registry2).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId,
+
+ title: 'Link 1',
+ description: 'Link 1 description',
+ path: `/a/${pluginId}/declare-incident`,
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ configure: expect.any(Function),
+ },
+ ],
+ 'plugins/myorg-basic-app/start': [
+ {
+ pluginId: pluginId,
+
+ title: 'Link 2',
+ description: 'Link 2 description',
+ path: `/a/${pluginId}/declare-incident`,
+ extensionPointId: 'plugins/myorg-basic-app/start',
+ configure: expect.any(Function),
+ },
+ ],
+ });
+ });
+
+ it('should notify subscribers when the registry changes', async () => {
+ const pluginId = 'grafana-basic-app';
+ const reactiveRegistry = new AddedLinksRegistry();
+ const observable = reactiveRegistry.asObservable();
+ const subscribeCallback = jest.fn();
+
+ observable.subscribe(subscribeCallback);
+
+ // Register extensions for the first plugin
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Link 1',
+ description: 'Link 1 description',
+ path: `/a/${pluginId}/declare-incident`,
+ targets: 'grafana/dashboard/panel/menu',
+ configure: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ expect(subscribeCallback).toHaveBeenCalledTimes(2);
+
+ // Register extensions for the first plugin
+ reactiveRegistry.register({
+ pluginId: 'another-plugin',
+ configs: [
+ {
+ title: 'Link 1',
+ description: 'Link 1 description',
+ path: `/a/another-plugin/declare-incident`,
+ targets: 'grafana/dashboard/panel/menu',
+ configure: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ expect(subscribeCallback).toHaveBeenCalledTimes(3);
+
+ const registry = subscribeCallback.mock.calls[2][0];
+
+ expect(registry).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId,
+
+ title: 'Link 1',
+ description: 'Link 1 description',
+ path: `/a/${pluginId}/declare-incident`,
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ configure: expect.any(Function),
+ },
+ {
+ pluginId: 'another-plugin',
+
+ title: 'Link 1',
+ description: 'Link 1 description',
+ path: `/a/another-plugin/declare-incident`,
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ configure: expect.any(Function),
+ },
+ ],
+ });
+ });
+
+ it('should give the last version of the registry for new subscribers', async () => {
+ const pluginId = 'grafana-basic-app';
+ const reactiveRegistry = new AddedLinksRegistry();
+ const observable = reactiveRegistry.asObservable();
+ const subscribeCallback = jest.fn();
+
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Link 1',
+ description: 'Link 1 description',
+ path: `/a/${pluginId}/declare-incident`,
+ targets: 'grafana/dashboard/panel/menu',
+ configure: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ observable.subscribe(subscribeCallback);
+ expect(subscribeCallback).toHaveBeenCalledTimes(1);
+
+ const registry = subscribeCallback.mock.calls[0][0];
+
+ expect(registry).toEqual({
+ 'grafana/dashboard/panel/menu': [
+ {
+ pluginId: pluginId,
+
+ title: 'Link 1',
+ description: 'Link 1 description',
+ path: `/a/${pluginId}/declare-incident`,
+ extensionPointId: 'grafana/dashboard/panel/menu',
+ configure: expect.any(Function),
+ },
+ ],
+ });
+ });
+
+ it('should not register a link extension if it has an invalid configure() function', () => {
+ const pluginId = 'grafana-basic-app';
+ const reactiveRegistry = new AddedLinksRegistry();
+ const observable = reactiveRegistry.asObservable();
+ const subscribeCallback = jest.fn();
+
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Link 1',
+ description: 'Link 1 description',
+ path: `/a/${pluginId}/declare-incident`,
+ targets: 'grafana/dashboard/panel/menu',
+ //@ts-ignore
+ configure: '...',
+ },
+ ],
+ });
+
+ expect(consoleWarn).toHaveBeenCalled();
+
+ observable.subscribe(subscribeCallback);
+ expect(subscribeCallback).toHaveBeenCalledTimes(1);
+
+ const registry = subscribeCallback.mock.calls[0][0];
+ expect(registry).toEqual({});
+ });
+
+ it('should not register a link extension if it has invalid properties (empty title / description)', () => {
+ const pluginId = 'grafana-basic-app';
+ const reactiveRegistry = new AddedLinksRegistry();
+ const observable = reactiveRegistry.asObservable();
+ const subscribeCallback = jest.fn();
+
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: '',
+ description: '',
+ path: `/a/${pluginId}/declare-incident`,
+ targets: 'grafana/dashboard/panel/menu',
+ configure: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ expect(consoleWarn).toHaveBeenCalled();
+
+ observable.subscribe(subscribeCallback);
+ expect(subscribeCallback).toHaveBeenCalledTimes(1);
+
+ const registry = subscribeCallback.mock.calls[0][0];
+ expect(registry).toEqual({});
+ });
+
+ it('should not register link extensions with invalid path configured', () => {
+ const pluginId = 'grafana-basic-app';
+ const reactiveRegistry = new AddedLinksRegistry();
+ const observable = reactiveRegistry.asObservable();
+ const subscribeCallback = jest.fn();
+
+ reactiveRegistry.register({
+ pluginId: pluginId,
+ configs: [
+ {
+ title: 'Title 1',
+ description: 'Description 1',
+ path: `/a/another-plugin/declare-incident`,
+ targets: 'grafana/dashboard/panel/menu',
+ configure: jest.fn().mockReturnValue({}),
+ },
+ ],
+ });
+
+ expect(consoleWarn).toHaveBeenCalled();
+
+ observable.subscribe(subscribeCallback);
+ expect(subscribeCallback).toHaveBeenCalledTimes(1);
+
+ const registry = subscribeCallback.mock.calls[0][0];
+ expect(registry).toEqual({});
+ });
+});
diff --git a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts
new file mode 100644
index 00000000000..30b4ebbdfcc
--- /dev/null
+++ b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts
@@ -0,0 +1,98 @@
+import { IconName, PluginExtensionAddedLinkConfig } from '@grafana/data';
+import { PluginAddedLinksConfigureFunc, PluginExtensionEventHelpers } from '@grafana/data/src/types/pluginExtensions';
+
+import { logWarning } from '../utils';
+import {
+ extensionPointEndsWithVersion,
+ isConfigureFnValid,
+ isExtensionPointIdValid,
+ isGrafanaCoreExtensionPoint,
+ isLinkPathValid,
+} from '../validators';
+
+import { PluginExtensionConfigs, Registry, RegistryType } from './Registry';
+
+export type AddedLinkRegistryItem = {
+ pluginId: string;
+ extensionPointId: string;
+ title: string;
+ description: string;
+ path?: string;
+ onClick?: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => void;
+ configure?: PluginAddedLinksConfigureFunc;
+ icon?: IconName;
+ category?: string;
+};
+
+export class AddedLinksRegistry extends Registry {
+ constructor(initialState: RegistryType = {}) {
+ super({
+ initialState,
+ });
+ }
+
+ mapToRegistry(
+ registry: RegistryType,
+ item: PluginExtensionConfigs
+ ): RegistryType {
+ const { pluginId, configs } = item;
+
+ for (const config of configs) {
+ const { path, title, description, configure, onClick, targets } = config;
+ if (!title) {
+ logWarning(`Could not register added link with title '${title}'. Reason: Title is missing.`);
+ continue;
+ }
+
+ if (!description) {
+ logWarning(`Could not register added link with title '${title}'. Reason: Description is missing.`);
+ continue;
+ }
+
+ if (!isConfigureFnValid(configure)) {
+ logWarning(`Could not register added link with title '${title}'. Reason: configure is not a function.`);
+ continue;
+ }
+
+ if (!path && !onClick) {
+ logWarning(
+ `Could not register added link with title '${title}'. Reason: Either "path" or "onClick" is required.`
+ );
+ continue;
+ }
+
+ if (path && !isLinkPathValid(pluginId, path)) {
+ logWarning(
+ `Could not register added link with title '${title}'. Reason: The "path" is required and should start with "/a/${pluginId}/" (currently: "${path}"). Skipping the extension.`
+ );
+ continue;
+ }
+
+ const extensionPointIds = Array.isArray(targets) ? targets : [targets];
+ for (const extensionPointId of extensionPointIds) {
+ if (!isExtensionPointIdValid(pluginId, extensionPointId)) {
+ logWarning(
+ `Could not register added link with id '${extensionPointId}'. Reason: Target extension point id must start with grafana, plugins or plugin id.`
+ );
+ continue;
+ }
+
+ if (!isGrafanaCoreExtensionPoint(extensionPointId) && !extensionPointEndsWithVersion(extensionPointId)) {
+ logWarning(
+ `Added component with id '${extensionPointId}' does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'.`
+ );
+ }
+
+ const { targets, ...registryItem } = config;
+
+ if (!(extensionPointId in registry)) {
+ registry[extensionPointId] = [];
+ }
+
+ registry[extensionPointId].push({ ...registryItem, pluginId, extensionPointId });
+ }
+ }
+
+ return registry;
+ }
+}
diff --git a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts
index 52b8e97a9c4..40cba5bf44b 100644
--- a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts
+++ b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts
@@ -1,4 +1,4 @@
-import { PluginExposedComponentConfig } from '@grafana/data';
+import { PluginExtensionExposedComponentConfig } from '@grafana/data';
import { logWarning } from '../utils';
import { extensionPointEndsWithVersion } from '../validators';
@@ -12,7 +12,10 @@ export type ExposedComponentRegistryItem = {
component: React.ComponentType;
};
-export class ExposedComponentsRegistry extends Registry {
+export class ExposedComponentsRegistry extends Registry<
+ ExposedComponentRegistryItem,
+ PluginExtensionExposedComponentConfig
+> {
constructor(initialState: RegistryType = {}) {
super({
initialState,
@@ -21,7 +24,7 @@ export class ExposedComponentsRegistry extends Registry,
- { pluginId, configs }: PluginExtensionConfigs
+ { pluginId, configs }: PluginExtensionConfigs
): RegistryType {
if (!configs) {
return registry;
diff --git a/public/app/features/plugins/extensions/registry/setup.ts b/public/app/features/plugins/extensions/registry/setup.ts
new file mode 100644
index 00000000000..05fe0f21b7a
--- /dev/null
+++ b/public/app/features/plugins/extensions/registry/setup.ts
@@ -0,0 +1,21 @@
+import { getCoreExtensionConfigurations } from '../getCoreExtensionConfigurations';
+
+import { AddedComponentsRegistry } from './AddedComponentsRegistry';
+import { AddedLinksRegistry } from './AddedLinksRegistry';
+import { ExposedComponentsRegistry } from './ExposedComponentsRegistry';
+import { PluginExtensionRegistries } from './types';
+
+export function setupPluginExtensionRegistries(): PluginExtensionRegistries {
+ const pluginExtensionsRegistries = {
+ addedComponentsRegistry: new AddedComponentsRegistry(),
+ exposedComponentsRegistry: new ExposedComponentsRegistry(),
+ addedLinksRegistry: new AddedLinksRegistry(),
+ };
+
+ pluginExtensionsRegistries.addedLinksRegistry.register({
+ pluginId: 'grafana',
+ configs: getCoreExtensionConfigurations(),
+ });
+
+ return pluginExtensionsRegistries;
+}
diff --git a/public/app/features/plugins/extensions/registry/types.ts b/public/app/features/plugins/extensions/registry/types.ts
new file mode 100644
index 00000000000..115e859b7d9
--- /dev/null
+++ b/public/app/features/plugins/extensions/registry/types.ts
@@ -0,0 +1,9 @@
+import { AddedComponentsRegistry } from './AddedComponentsRegistry';
+import { AddedLinksRegistry } from './AddedLinksRegistry';
+import { ExposedComponentsRegistry } from './ExposedComponentsRegistry';
+
+export type PluginExtensionRegistries = {
+ addedComponentsRegistry: AddedComponentsRegistry;
+ exposedComponentsRegistry: ExposedComponentsRegistry;
+ addedLinksRegistry: AddedLinksRegistry;
+};
diff --git a/public/app/features/plugins/extensions/types.ts b/public/app/features/plugins/extensions/types.ts
deleted file mode 100644
index b5e5d1ebbc8..00000000000
--- a/public/app/features/plugins/extensions/types.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import type { PluginExtensionConfig } from '@grafana/data';
-
-import { AddedComponentRegistryItem } from './registry/AddedComponentsRegistry';
-import { RegistryType } from './registry/Registry';
-
-// The information that is stored in the registry
-export type PluginExtensionRegistryItem = {
- // Any additional meta information that we would like to store about the extension in the registry
- pluginId: string;
-
- config: PluginExtensionConfig;
-};
-
-// A map of placement names to a list of extensions
-export type PluginExtensionRegistry = {
- id: string;
- extensions: Record;
-};
-
-export type AddedComponentsRegistryState = RegistryType>>;
diff --git a/public/app/features/plugins/extensions/usePluginComponent.tsx b/public/app/features/plugins/extensions/usePluginComponent.tsx
index 269baf3df0f..8985d182aea 100644
--- a/public/app/features/plugins/extensions/usePluginComponent.tsx
+++ b/public/app/features/plugins/extensions/usePluginComponent.tsx
@@ -15,7 +15,7 @@ export function createUsePluginComponent(registry: ExposedComponentsRegistry) {
const registry = useObservable(observableRegistry);
return useMemo(() => {
- if (!registry || !registry[id]) {
+ if (!registry?.[id]) {
return {
isLoading: false,
component: null,
diff --git a/public/app/features/plugins/extensions/usePluginComponents.tsx b/public/app/features/plugins/extensions/usePluginComponents.tsx
index db3d1c6710c..b2ad9804d6b 100644
--- a/public/app/features/plugins/extensions/usePluginComponents.tsx
+++ b/public/app/features/plugins/extensions/usePluginComponents.tsx
@@ -19,16 +19,10 @@ export function createUsePluginComponents(registry: AddedComponentsRegistry) {
const registry = useObservable(observableRegistry);
return useMemo(() => {
- if (!registry || !registry[extensionPointId]) {
- return {
- isLoading: false,
- components: [],
- };
- }
const components: Array> = [];
- const registryItems = registry[extensionPointId];
const extensionsByPlugin: Record = {};
- for (const registryItem of registryItems) {
+
+ for (const registryItem of registry?.[extensionPointId] ?? []) {
const { pluginId } = registryItem;
// Only limit if the `limitPerPlugin` is set
diff --git a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx
index 886dfecdec8..17ff510f8ef 100644
--- a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx
+++ b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx
@@ -1,26 +1,28 @@
import { act } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
-import { PluginExtensionTypes } from '@grafana/data';
-
-import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
+import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
+import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
+import { PluginExtensionRegistries } from './registry/types';
import { createUsePluginExtensions } from './usePluginExtensions';
describe('usePluginExtensions()', () => {
- let reactiveRegistry: ReactivePluginExtensionsRegistry;
- let addedComponentsRegistry: AddedComponentsRegistry;
+ let registries: PluginExtensionRegistries;
beforeEach(() => {
- reactiveRegistry = new ReactivePluginExtensionsRegistry();
- addedComponentsRegistry = new AddedComponentsRegistry();
+ registries = {
+ addedComponentsRegistry: new AddedComponentsRegistry(),
+ addedLinksRegistry: new AddedLinksRegistry(),
+ exposedComponentsRegistry: new ExposedComponentsRegistry(),
+ };
});
it('should return an empty array if there are no extensions registered for the extension point', () => {
- const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry);
+ const usePluginExtensions = createUsePluginExtensions(registries);
const { result } = renderHook(() =>
usePluginExtensions({
- extensionPointId: 'foo/bar',
+ extensionPointId: 'foo/bar/v1',
})
);
@@ -28,32 +30,28 @@ describe('usePluginExtensions()', () => {
});
it('should return the plugin link extensions from the registry', () => {
- const extensionPointId = 'plugins/foo/bar';
+ const extensionPointId = 'plugins/foo/bar/v1';
const pluginId = 'my-app-plugin';
- reactiveRegistry.register({
+ registries.addedLinksRegistry.register({
pluginId,
- extensionConfigs: [
+ configs: [
{
- type: PluginExtensionTypes.link,
- extensionPointId,
+ targets: extensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
},
{
- type: PluginExtensionTypes.link,
- extensionPointId,
+ targets: extensionPointId,
title: '2',
description: '2',
path: `/a/${pluginId}/2`,
},
],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
});
- const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry);
+ const usePluginExtensions = createUsePluginExtensions(registries);
const { result } = renderHook(() => usePluginExtensions({ extensionPointId }));
expect(result.current.extensions.length).toBe(2);
@@ -62,33 +60,29 @@ describe('usePluginExtensions()', () => {
});
it('should return the plugin component extensions from the registry', () => {
- const linkExtensionPointId = 'plugins/foo/bar';
+ const linkExtensionPointId = 'plugins/foo/bar/v1';
const componentExtensionPointId = 'plugins/component/bar/v1';
const pluginId = 'my-app-plugin';
- reactiveRegistry.register({
+ registries.addedLinksRegistry.register({
pluginId,
- extensionConfigs: [
+ configs: [
{
- type: PluginExtensionTypes.link,
- extensionPointId: linkExtensionPointId,
+ targets: linkExtensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
},
{
- type: PluginExtensionTypes.link,
- extensionPointId: linkExtensionPointId,
+ targets: linkExtensionPointId,
title: '2',
description: '2',
path: `/a/${pluginId}/2`,
},
],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
});
- addedComponentsRegistry.register({
+ registries.addedComponentsRegistry.register({
pluginId,
configs: [
{
@@ -106,7 +100,7 @@ describe('usePluginExtensions()', () => {
],
});
- const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry);
+ const usePluginExtensions = createUsePluginExtensions(registries);
const { result } = renderHook(() => usePluginExtensions({ extensionPointId: componentExtensionPointId }));
expect(result.current.extensions.length).toBe(2);
@@ -115,9 +109,9 @@ describe('usePluginExtensions()', () => {
});
it('should dynamically update the extensions registered for a certain extension point', () => {
- const extensionPointId = 'plugins/foo/bar';
+ const extensionPointId = 'plugins/foo/bar/v1';
const pluginId = 'my-app-plugin';
- const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry);
+ const usePluginExtensions = createUsePluginExtensions(registries);
let { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId }));
// No extensions yet
@@ -125,26 +119,22 @@ describe('usePluginExtensions()', () => {
// Add extensions to the registry
act(() => {
- reactiveRegistry.register({
+ registries.addedLinksRegistry.register({
pluginId,
- extensionConfigs: [
+ configs: [
{
- type: PluginExtensionTypes.link,
- extensionPointId,
+ targets: extensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
},
{
- type: PluginExtensionTypes.link,
- extensionPointId,
+ targets: extensionPointId,
title: '2',
description: '2',
path: `/a/${pluginId}/2`,
},
],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
});
});
@@ -157,78 +147,81 @@ describe('usePluginExtensions()', () => {
});
it('should only render the hook once', () => {
- const spy = jest.spyOn(reactiveRegistry, 'asObservable');
- const extensionPointId = 'plugins/foo/bar';
- const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry);
+ const addedComponentsRegistrySpy = jest.spyOn(registries.addedComponentsRegistry, 'asObservable');
+ const addedLinksRegistrySpy = jest.spyOn(registries.addedLinksRegistry, 'asObservable');
+ const extensionPointId = 'plugins/foo/bar/v1';
+ const usePluginExtensions = createUsePluginExtensions(registries);
renderHook(() => usePluginExtensions({ extensionPointId }));
- expect(spy).toHaveBeenCalledTimes(1);
+ expect(addedComponentsRegistrySpy).toHaveBeenCalledTimes(1);
+ expect(addedLinksRegistrySpy).toHaveBeenCalledTimes(1);
});
- it('should return the same extensions object if the context object is the same', () => {
- const extensionPointId = 'plugins/foo/bar';
+ it('should return the same extensions object if the context object is the same', async () => {
+ const extensionPointId = 'plugins/foo/bar/v1';
const pluginId = 'my-app-plugin';
- const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry);
+ const usePluginExtensions = createUsePluginExtensions(registries);
// Add extensions to the registry
act(() => {
- reactiveRegistry.register({
+ registries.addedLinksRegistry.register({
pluginId,
- extensionConfigs: [
+ configs: [
{
- type: PluginExtensionTypes.link,
- extensionPointId,
+ targets: extensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
},
{
- type: PluginExtensionTypes.link,
- extensionPointId,
+ targets: extensionPointId,
title: '2',
description: '2',
path: `/a/${pluginId}/2`,
},
],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
});
});
// Check if it returns the same extensions object in case nothing changes
const context = {};
- const firstResults = renderHook(() => usePluginExtensions({ extensionPointId, context }));
- const secondResults = renderHook(() => usePluginExtensions({ extensionPointId, context }));
- expect(firstResults.result.current.extensions === secondResults.result.current.extensions).toBe(true);
+ const { rerender, result } = renderHook(usePluginExtensions, {
+ initialProps: {
+ extensionPointId,
+ context,
+ },
+ });
+ const firstResult = result.current;
+
+ rerender({ context, extensionPointId });
+ const secondResult = result.current;
+
+ expect(firstResult.extensions).toBe(secondResult.extensions);
});
it('should return a new extensions object if the context object is different', () => {
- const extensionPointId = 'plugins/foo/bar';
+ const extensionPointId = 'plugins/foo/bar/v1';
const pluginId = 'my-app-plugin';
- const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry);
+ const usePluginExtensions = createUsePluginExtensions(registries);
// Add extensions to the registry
act(() => {
- reactiveRegistry.register({
+ registries.addedLinksRegistry.register({
pluginId,
- extensionConfigs: [
+ configs: [
{
- type: PluginExtensionTypes.link,
- extensionPointId,
+ targets: extensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
},
{
- type: PluginExtensionTypes.link,
- extensionPointId,
+ targets: extensionPointId,
title: '2',
description: '2',
path: `/a/${pluginId}/2`,
},
],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
});
});
@@ -239,26 +232,23 @@ describe('usePluginExtensions()', () => {
});
it('should return a new extensions object if the registry changes but the context object is the same', () => {
- const extensionPointId = 'plugins/foo/bar';
+ const extensionPointId = 'plugins/foo/bar/v1';
const pluginId = 'my-app-plugin';
const context = {};
- const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry);
+ const usePluginExtensions = createUsePluginExtensions(registries);
// Add the first extension
act(() => {
- reactiveRegistry.register({
+ registries.addedLinksRegistry.register({
pluginId,
- extensionConfigs: [
+ configs: [
{
- type: PluginExtensionTypes.link,
- extensionPointId,
+ targets: extensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
},
],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
});
});
@@ -267,20 +257,17 @@ describe('usePluginExtensions()', () => {
// Add the second extension
act(() => {
- reactiveRegistry.register({
+ registries.addedLinksRegistry.register({
pluginId,
- extensionConfigs: [
+ configs: [
{
- type: PluginExtensionTypes.link,
- extensionPointId,
+ targets: extensionPointId,
// extensionPointId: 'plugins/foo/bar/zed', // A different extension point (to be sure that it's also returning a new object when the actual extension point doesn't change)
title: '2',
description: '2',
path: `/a/${pluginId}/2`,
},
],
- exposedComponentConfigs: [],
- addedComponentConfigs: [],
});
});
diff --git a/public/app/features/plugins/extensions/usePluginExtensions.tsx b/public/app/features/plugins/extensions/usePluginExtensions.tsx
index b00d4517265..62e583ad857 100644
--- a/public/app/features/plugins/extensions/usePluginExtensions.tsx
+++ b/public/app/features/plugins/extensions/usePluginExtensions.tsx
@@ -1,64 +1,40 @@
+import { useMemo } from 'react';
import { useObservable } from 'react-use';
import { PluginExtension } from '@grafana/data';
import { GetPluginExtensionsOptions, UsePluginExtensionsResult } from '@grafana/runtime';
import { getPluginExtensions } from './getPluginExtensions';
-import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
-import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
+import { PluginExtensionRegistries } from './registry/types';
-export function createUsePluginExtensions(
- extensionsRegistry: ReactivePluginExtensionsRegistry,
- addedComponentsRegistry: AddedComponentsRegistry
-) {
- const observableRegistry = extensionsRegistry.asObservable();
- const observableAddedComponentRegistry = addedComponentsRegistry.asObservable();
- const cache: {
- id: string;
- extensions: Record;
- } = {
- id: '',
- extensions: {},
- };
+export function createUsePluginExtensions(registries: PluginExtensionRegistries) {
+ const observableAddedComponentsRegistry = registries.addedComponentsRegistry.asObservable();
+ const observableAddedLinksRegistry = registries.addedLinksRegistry.asObservable();
return function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult {
- const registry = useObservable(observableRegistry);
- const addedComponentsRegistry = useObservable(observableAddedComponentRegistry);
+ const addedComponentsRegistry = useObservable(observableAddedComponentsRegistry);
+ const addedLinksRegistry = useObservable(observableAddedLinksRegistry);
- if (!registry || !addedComponentsRegistry) {
- return { extensions: [], isLoading: false };
- }
+ const { extensions } = useMemo(() => {
+ if (!addedLinksRegistry && !addedComponentsRegistry) {
+ return { extensions: [], isLoading: false };
+ }
- if (registry.id !== cache.id) {
- cache.id = registry.id;
- cache.extensions = {};
- }
-
- // `getPluginExtensions` will return a new array of objects even if it is called with the same options, as it always constructing a frozen objects.
- // Due to this we are caching the result of `getPluginExtensions` to avoid unnecessary re-renders for components that are using this hook.
- // (NOTE: we are only checking referential equality of `context` object, so it is important to not mutate the object passed to this hook.)
- const key = `${options.extensionPointId}-${options.limitPerPlugin}`;
- if (cache.extensions[key] && cache.extensions[key].context === options.context) {
- return {
- extensions: cache.extensions[key].extensions,
- isLoading: false,
- };
- }
-
- const { extensions } = getPluginExtensions({
- ...options,
- registry,
+ return getPluginExtensions({
+ extensionPointId: options.extensionPointId,
+ context: options.context,
+ limitPerPlugin: options.limitPerPlugin,
+ addedComponentsRegistry,
+ addedLinksRegistry,
+ });
+ }, [
+ addedLinksRegistry,
addedComponentsRegistry,
- });
+ options.extensionPointId,
+ options.context,
+ options.limitPerPlugin,
+ ]);
- cache.extensions[key] = {
- context: options.context,
- extensions,
- };
-
- return {
- extensions,
- isLoading: false,
- };
+ return { extensions, isLoading: false };
};
}
diff --git a/public/app/features/plugins/extensions/usePluginLinks.test.tsx b/public/app/features/plugins/extensions/usePluginLinks.test.tsx
new file mode 100644
index 00000000000..ceefed61393
--- /dev/null
+++ b/public/app/features/plugins/extensions/usePluginLinks.test.tsx
@@ -0,0 +1,118 @@
+import { act } from '@testing-library/react';
+import { renderHook } from '@testing-library/react-hooks';
+
+import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
+import { createUsePluginLinks } from './usePluginLinks';
+
+jest.mock('app/features/plugins/pluginSettings', () => ({
+ getPluginSettings: jest.fn().mockResolvedValue({
+ id: 'my-app-plugin',
+ enabled: true,
+ jsonData: {},
+ type: 'panel',
+ name: 'My App Plugin',
+ module: 'app/plugins/my-app-plugin/module',
+ }),
+}));
+
+describe('usePluginLinks()', () => {
+ let registry: AddedLinksRegistry;
+
+ beforeEach(() => {
+ registry = new AddedLinksRegistry();
+ });
+
+ it('should return an empty array if there are no link extensions registered for the extension point', () => {
+ const usePluginComponents = createUsePluginLinks(registry);
+ const { result } = renderHook(() =>
+ usePluginComponents({
+ extensionPointId: 'foo/bar',
+ })
+ );
+
+ expect(result.current.links).toEqual([]);
+ });
+
+ it('should only return the link extensions for the given extension point ids', async () => {
+ const extensionPointId = 'plugins/foo/bar/v1';
+ const pluginId = 'my-app-plugin';
+
+ registry.register({
+ pluginId,
+ configs: [
+ {
+ targets: extensionPointId,
+ title: '1',
+ description: '1',
+ path: `/a/${pluginId}/2`,
+ },
+ {
+ targets: extensionPointId,
+ title: '2',
+ description: '2',
+ path: `/a/${pluginId}/2`,
+ },
+ {
+ targets: 'plugins/another-extension/v1',
+ title: '3',
+ description: '3',
+ path: `/a/${pluginId}/3`,
+ },
+ ],
+ });
+
+ const usePluginExtensions = createUsePluginLinks(registry);
+ const { result } = renderHook(() => usePluginExtensions({ extensionPointId }));
+
+ expect(result.current.links.length).toBe(2);
+ expect(result.current.links[0].title).toBe('1');
+ expect(result.current.links[1].title).toBe('2');
+ });
+
+ it('should dynamically update the extensions registered for a certain extension point', () => {
+ const extensionPointId = 'plugins/foo/bar/v1';
+ const pluginId = 'my-app-plugin';
+ const usePluginExtensions = createUsePluginLinks(registry);
+ let { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId }));
+
+ // No extensions yet
+ expect(result.current.links.length).toBe(0);
+
+ // Add extensions to the registry
+ act(() => {
+ registry.register({
+ pluginId,
+ configs: [
+ {
+ targets: extensionPointId,
+ title: '1',
+ description: '1',
+ path: `/a/${pluginId}/2`,
+ },
+ {
+ targets: extensionPointId,
+ title: '2',
+ description: '2',
+ path: `/a/${pluginId}/2`,
+ },
+ ],
+ });
+ });
+
+ // Check if the hook returns the new extensions
+ rerender();
+
+ expect(result.current.links.length).toBe(2);
+ expect(result.current.links[0].title).toBe('1');
+ expect(result.current.links[1].title).toBe('2');
+ });
+
+ it('should only render the hook once', () => {
+ const addedLinksRegistrySpy = jest.spyOn(registry, 'asObservable');
+ const extensionPointId = 'plugins/foo/bar';
+ const usePluginLinks = createUsePluginLinks(registry);
+
+ renderHook(() => usePluginLinks({ extensionPointId }));
+ expect(addedLinksRegistrySpy).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/public/app/features/plugins/extensions/usePluginLinks.tsx b/public/app/features/plugins/extensions/usePluginLinks.tsx
new file mode 100644
index 00000000000..0350209636a
--- /dev/null
+++ b/public/app/features/plugins/extensions/usePluginLinks.tsx
@@ -0,0 +1,90 @@
+import { isString } from 'lodash';
+import { useMemo } from 'react';
+import { useObservable } from 'react-use';
+
+import { PluginExtensionLink, PluginExtensionTypes } from '@grafana/data';
+import {
+ UsePluginLinksOptions,
+ UsePluginLinksResult,
+} from '@grafana/runtime/src/services/pluginExtensions/getPluginExtensions';
+
+import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
+import {
+ generateExtensionId,
+ getLinkExtensionOnClick,
+ getLinkExtensionOverrides,
+ getLinkExtensionPathWithTracking,
+ getReadOnlyProxy,
+} from './utils';
+
+// Returns an array of component extensions for the given extension point
+export function createUsePluginLinks(registry: AddedLinksRegistry) {
+ const observableRegistry = registry.asObservable();
+
+ return function usePluginLinks({
+ limitPerPlugin,
+ extensionPointId,
+ context,
+ }: UsePluginLinksOptions): UsePluginLinksResult {
+ const registry = useObservable(observableRegistry);
+
+ return useMemo(() => {
+ if (!registry || !registry[extensionPointId]) {
+ return {
+ isLoading: false,
+ links: [],
+ };
+ }
+ const frozenContext = context ? getReadOnlyProxy(context) : {};
+ const extensions: PluginExtensionLink[] = [];
+ const extensionsByPlugin: Record = {};
+
+ for (const addedLink of registry[extensionPointId] ?? []) {
+ const { pluginId } = addedLink;
+ // Only limit if the `limitPerPlugin` is set
+ if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) {
+ continue;
+ }
+
+ if (extensionsByPlugin[pluginId] === undefined) {
+ extensionsByPlugin[pluginId] = 0;
+ }
+
+ // Run the configure() function with the current context, and apply the ovverides
+ const overrides = getLinkExtensionOverrides(pluginId, addedLink, frozenContext);
+
+ // configure() returned an `undefined` -> hide the extension
+ if (addedLink.configure && overrides === undefined) {
+ continue;
+ }
+
+ const path = overrides?.path || addedLink.path;
+ const extension: PluginExtensionLink = {
+ id: generateExtensionId(pluginId, {
+ ...addedLink,
+ extensionPointId,
+ type: PluginExtensionTypes.link,
+ }),
+ type: PluginExtensionTypes.link,
+ pluginId: pluginId,
+ onClick: getLinkExtensionOnClick(pluginId, extensionPointId, addedLink, frozenContext),
+
+ // Configurable properties
+ icon: overrides?.icon || addedLink.icon,
+ title: overrides?.title || addedLink.title,
+ description: overrides?.description || addedLink.description,
+ path: isString(path) ? getLinkExtensionPathWithTracking(pluginId, path, extensionPointId) : undefined,
+ category: overrides?.category || addedLink.category,
+ };
+
+ extensions.push(extension);
+ extensionsByPlugin[pluginId] += 1;
+ }
+
+ return {
+ isLoading: false,
+ links: extensions,
+ };
+ }, [context, extensionPointId, limitPerPlugin, registry]);
+ };
+}
diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx
index c0189806dd2..f579138e76a 100644
--- a/public/app/features/plugins/extensions/utils.test.tsx
+++ b/public/app/features/plugins/extensions/utils.test.tsx
@@ -1,18 +1,11 @@
import { render, screen } from '@testing-library/react';
import { type Unsubscribable } from 'rxjs';
-import { type PluginExtensionLinkConfig, PluginExtensionTypes, dateTime, usePluginContext } from '@grafana/data';
+import { dateTime, usePluginContext } from '@grafana/data';
import appEvents from 'app/core/app_events';
import { ShowModalReactEvent } from 'app/types/events';
-import {
- deepFreeze,
- isPluginExtensionLinkConfig,
- handleErrorsInFn,
- getReadOnlyProxy,
- getEventHelpers,
- wrapWithPluginContext,
-} from './utils';
+import { deepFreeze, handleErrorsInFn, getReadOnlyProxy, getEventHelpers, wrapWithPluginContext } from './utils';
jest.mock('app/features/plugins/pluginSettings', () => ({
...jest.requireActual('app/features/plugins/pluginSettings'),
@@ -198,28 +191,6 @@ describe('Plugin Extensions / Utils', () => {
});
});
- describe('isPluginExtensionLinkConfig()', () => {
- test('should return TRUE if the object is a command extension config', () => {
- expect(
- isPluginExtensionLinkConfig({
- type: PluginExtensionTypes.link,
- title: 'Title',
- description: 'Description',
- path: '...',
- } as PluginExtensionLinkConfig)
- ).toBe(true);
- });
- test('should return FALSE if the object is NOT a link extension', () => {
- expect(
- isPluginExtensionLinkConfig({
- title: 'Title',
- description: 'Description',
- path: '...',
- } as PluginExtensionLinkConfig)
- ).toBe(false);
- });
- });
-
describe('handleErrorsInFn()', () => {
test('should catch errors thrown by the provided function and print them as console warnings', () => {
global.console.warn = jest.fn();
diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx
index fba4871185c..8c3075dd402 100644
--- a/public/app/features/plugins/extensions/utils.tsx
+++ b/public/app/features/plugins/extensions/utils.tsx
@@ -14,12 +14,18 @@ import {
PluginContextProvider,
PluginExtensionLink,
PanelMenuItem,
+ PluginExtensionAddedLinkConfig,
+ urlUtil,
} from '@grafana/data';
+import { reportInteraction } from '@grafana/runtime';
import { Modal } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
import { ShowModalReactEvent } from 'app/types/events';
+import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry';
+import { assertIsNotPromise, assertLinkPathIsValid, assertStringProps, isPromise } from './validators';
+
export function logWarning(message: string) {
console.warn(`[Plugin Extensions] ${message}`);
}
@@ -218,11 +224,10 @@ export function isReadOnlyProxy(value: unknown): boolean {
return isRecord(value) && value[_isProxy] === true;
}
-export function createExtensionLinkConfig(
- config: Omit, 'type'>
-): PluginExtensionLinkConfig {
- const linkConfig: PluginExtensionLinkConfig = {
- type: PluginExtensionTypes.link,
+export function createAddedLinkConfig(
+ config: PluginExtensionAddedLinkConfig
+): PluginExtensionAddedLinkConfig {
+ const linkConfig: PluginExtensionAddedLinkConfig = {
...config,
};
assertLinkConfig(linkConfig);
@@ -230,12 +235,8 @@ export function createExtensionLinkConfig(
}
function assertLinkConfig(
- config: PluginExtensionLinkConfig
-): asserts config is PluginExtensionLinkConfig {
- if (config.type !== PluginExtensionTypes.link) {
- throw Error('config is not a extension link');
- }
-}
+ config: PluginExtensionAddedLinkConfig
+): asserts config is PluginExtensionAddedLinkConfig {}
export function truncateTitle(title: string, length: number): string {
if (title.length < length) {
@@ -294,3 +295,103 @@ export function createExtensionSubMenu(extensions: PluginExtensionLink[]): Panel
return subMenu;
}
+
+export function getLinkExtensionOverrides(pluginId: string, config: AddedLinkRegistryItem, context?: object) {
+ try {
+ const overrides = config.configure?.(context);
+
+ // Hiding the extension
+ if (overrides === undefined) {
+ return undefined;
+ }
+
+ let {
+ title = config.title,
+ description = config.description,
+ path = config.path,
+ icon = config.icon,
+ category = config.category,
+ ...rest
+ } = overrides;
+
+ assertIsNotPromise(
+ overrides,
+ `The configure() function for "${config.title}" returned a promise, skipping updates.`
+ );
+
+ path && assertLinkPathIsValid(pluginId, path);
+ assertStringProps({ title, description }, ['title', 'description']);
+
+ if (Object.keys(rest).length > 0) {
+ logWarning(
+ `Extension "${config.title}", is trying to override restricted properties: ${Object.keys(rest).join(
+ ', '
+ )} which will be ignored.`
+ );
+ }
+
+ return {
+ title,
+ description,
+ path,
+ icon,
+ category,
+ };
+ } catch (error) {
+ if (error instanceof Error) {
+ logWarning(error.message);
+ }
+
+ // If there is an error, we hide the extension
+ // (This seems to be safest option in case the extension is doing something wrong.)
+ return undefined;
+ }
+}
+
+export function getLinkExtensionOnClick(
+ pluginId: string,
+ extensionPointId: string,
+ config: AddedLinkRegistryItem,
+ context?: object
+): ((event?: React.MouseEvent) => void) | undefined {
+ const { onClick } = config;
+
+ if (!onClick) {
+ return;
+ }
+
+ return function onClickExtensionLink(event?: React.MouseEvent) {
+ try {
+ reportInteraction('ui_extension_link_clicked', {
+ pluginId: pluginId,
+ extensionPointId,
+ title: config.title,
+ category: config.category,
+ });
+
+ const result = onClick(event, getEventHelpers(pluginId, context));
+
+ if (isPromise(result)) {
+ result.catch((e) => {
+ if (e instanceof Error) {
+ logWarning(e.message);
+ }
+ });
+ }
+ } catch (error) {
+ if (error instanceof Error) {
+ logWarning(error.message);
+ }
+ }
+ };
+}
+
+export function getLinkExtensionPathWithTracking(pluginId: string, path: string, extensionPointId: string): string {
+ return urlUtil.appendQueryToUrl(
+ path,
+ urlUtil.toUrlParams({
+ uel_pid: pluginId,
+ uel_epid: extensionPointId,
+ })
+ );
+}
diff --git a/public/app/features/plugins/extensions/validators.test.tsx b/public/app/features/plugins/extensions/validators.test.tsx
index 761acaa6e31..c7ad7a8f271 100644
--- a/public/app/features/plugins/extensions/validators.test.tsx
+++ b/public/app/features/plugins/extensions/validators.test.tsx
@@ -1,43 +1,16 @@
import { memo } from 'react';
-import { PluginExtension, PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data';
+import { PluginExtensionAddedLinkConfig, PluginExtensionLinkConfig, PluginExtensionPoints } from '@grafana/data';
import {
assertConfigureIsValid,
assertLinkPathIsValid,
- assertExtensionPointIdIsValid,
- assertPluginExtensionLink,
assertStringProps,
- isPluginExtensionConfigValid,
+ isGrafanaCoreExtensionPoint,
isReactComponent,
} from './validators';
describe('Plugin Extension Validators', () => {
- describe('assertPluginExtensionLink()', () => {
- it('should NOT throw an error if it is a link extension', () => {
- expect(() => {
- assertPluginExtensionLink({
- id: 'id',
- pluginId: 'myorg-b-app',
- type: PluginExtensionTypes.link,
- title: 'Title',
- description: 'Description',
- path: '...',
- } as PluginExtension);
- }).not.toThrowError();
- });
-
- it('should throw an error if it is not a link extension', () => {
- expect(() => {
- assertPluginExtensionLink({
- type: PluginExtensionTypes.link,
- title: 'Title',
- description: 'Description',
- } as PluginExtension);
- }).toThrowError();
- });
- });
-
describe('assertLinkPathIsValid()', () => {
it('should not throw an error if the link path is valid', () => {
expect(() => {
@@ -80,45 +53,14 @@ describe('Plugin Extension Validators', () => {
});
});
- describe('assertExtensionPointIdIsValid()', () => {
- it('should throw an error if the extensionPointId does not have the right prefix', () => {
- expect(() => {
- assertExtensionPointIdIsValid('my-org-app', {
- type: PluginExtensionTypes.link,
- title: 'Title',
- description: 'Description',
- extensionPointId: 'wrong-extension-point-id',
- });
- }).toThrowError();
- });
-
- it('should NOT throw an error if the extensionPointId is correct', () => {
- expect(() => {
- assertExtensionPointIdIsValid('my-org-app', {
- type: PluginExtensionTypes.link,
- title: 'Title',
- description: 'Description',
- extensionPointId: 'grafana/some-page/extension-point-a',
- });
-
- assertExtensionPointIdIsValid('my-org-app', {
- type: PluginExtensionTypes.link,
- title: 'Title',
- description: 'Description',
- extensionPointId: 'plugins/my-super-plugin/some-page/extension-point-a',
- });
- }).not.toThrowError();
- });
- });
-
describe('assertConfigureIsValid()', () => {
it('should NOT throw an error if the configure() function is missing', () => {
expect(() => {
assertConfigureIsValid({
title: 'Title',
description: 'Description',
- extensionPointId: 'grafana/some-page/extension-point-a',
- } as PluginExtensionLinkConfig);
+ targets: 'grafana/some-page/extension-point-a',
+ } as PluginExtensionAddedLinkConfig);
}).not.toThrowError();
});
@@ -127,9 +69,9 @@ describe('Plugin Extension Validators', () => {
assertConfigureIsValid({
title: 'Title',
description: 'Description',
- extensionPointId: 'grafana/some-page/extension-point-a',
+ targets: 'grafana/some-page/extension-point-a',
configure: () => {},
- } as PluginExtensionLinkConfig);
+ } as PluginExtensionAddedLinkConfig);
}).not.toThrowError();
});
@@ -203,70 +145,6 @@ describe('Plugin Extension Validators', () => {
});
});
- describe('isPluginExtensionConfigValid()', () => {
- it('should return TRUE if the plugin extension configuration is valid', () => {
- const pluginId = 'my-super-plugin';
-
- expect(
- isPluginExtensionConfigValid(pluginId, {
- type: PluginExtensionTypes.link,
- title: 'Title',
- description: 'Description',
- onClick: jest.fn(),
- extensionPointId: 'grafana/some-page/extension-point-a',
- } as PluginExtensionLinkConfig)
- ).toBe(true);
-
- expect(
- isPluginExtensionConfigValid(pluginId, {
- type: PluginExtensionTypes.link,
- title: 'Title',
- description: 'Description',
- extensionPointId: 'grafana/some-page/extension-point-a',
- path: `/a/${pluginId}/page`,
- } as PluginExtensionLinkConfig)
- ).toBe(true);
- });
-
- it('should return FALSE if the plugin extension configuration is invalid', () => {
- const pluginId = 'my-super-plugin';
-
- global.console.warn = jest.fn();
-
- // Link (wrong path)
- expect(
- isPluginExtensionConfigValid(pluginId, {
- type: PluginExtensionTypes.link,
- title: 'Title',
- description: 'Description',
- extensionPointId: 'grafana/some-page/extension-point-a',
- path: '/administration/users',
- } as PluginExtensionLinkConfig)
- ).toBe(false);
-
- // Link (no path and no onClick)
- expect(
- isPluginExtensionConfigValid(pluginId, {
- type: PluginExtensionTypes.link,
- title: 'Title',
- description: 'Description',
- extensionPointId: 'grafana/some-page/extension-point-a',
- } as PluginExtensionLinkConfig)
- ).toBe(false);
-
- // Link (missing title)
- expect(
- isPluginExtensionConfigValid(pluginId, {
- type: PluginExtensionTypes.link,
- title: '',
- description: 'Description',
- extensionPointId: 'grafana/some-page/extension-point-a',
- path: `/a/${pluginId}/page`,
- } as PluginExtensionLinkConfig)
- ).toBe(false);
- });
- });
-
describe('isReactComponent()', () => {
it('should return TRUE if we pass in a valid React component', () => {
expect(isReactComponent(() => Some text
)).toBe(true);
@@ -292,4 +170,18 @@ describe('Plugin Extension Validators', () => {
expect(isReactComponent(null)).toBe(false);
});
});
+
+ describe('isGrafanaCoreExtensionPoint()', () => {
+ it('should return TRUE if we pass an PluginExtensionPoints value', () => {
+ expect(isGrafanaCoreExtensionPoint(PluginExtensionPoints.AlertingAlertingRuleAction)).toBe(true);
+ });
+
+ it('should return TRUE if we pass a string that is not listed under the PluginExtensionPoints enum', () => {
+ expect(isGrafanaCoreExtensionPoint('grafana/alerting/alertingrule/action')).toBe(true);
+ });
+
+ it('should return FALSE if we pass a string that is not listed under the PluginExtensionPoints enum', () => {
+ expect(isGrafanaCoreExtensionPoint('grafana/dashboard/alertingrule/action')).toBe(false);
+ });
+ });
});
diff --git a/public/app/features/plugins/extensions/validators.ts b/public/app/features/plugins/extensions/validators.ts
index 1584992b670..96970860973 100644
--- a/public/app/features/plugins/extensions/validators.ts
+++ b/public/app/features/plugins/extensions/validators.ts
@@ -1,13 +1,7 @@
-import type {
- PluginExtension,
- PluginExtensionConfig,
- PluginExtensionLink,
- PluginExtensionLinkConfig,
-} from '@grafana/data';
+import type { PluginExtensionAddedLinkConfig, PluginExtension, PluginExtensionLink } from '@grafana/data';
+import { PluginAddedLinksConfigureFunc, PluginExtensionPoints } from '@grafana/data/src/types/pluginExtensions';
import { isPluginExtensionLink } from '@grafana/runtime';
-import { isPluginExtensionLinkConfig, logWarning } from './utils';
-
export function assertPluginExtensionLink(
extension: PluginExtension | undefined,
errorMessage = 'extension is not a link extension'
@@ -17,15 +11,6 @@ export function assertPluginExtensionLink(
}
}
-export function assertPluginExtensionLinkConfig(
- extension: PluginExtensionLinkConfig,
- errorMessage = 'extension is not a command extension config'
-): asserts extension is PluginExtensionLinkConfig {
- if (!isPluginExtensionLinkConfig(extension)) {
- throw new Error(errorMessage);
- }
-}
-
export function assertLinkPathIsValid(pluginId: string, path: string) {
if (!isLinkPathValid(pluginId, path)) {
throw new Error(
@@ -40,18 +25,10 @@ export function assertIsReactComponent(component: React.ComponentType) {
}
}
-export function assertExtensionPointIdIsValid(pluginId: string, extension: PluginExtensionConfig) {
- if (!isExtensionPointIdValid(pluginId, extension.extensionPointId)) {
+export function assertConfigureIsValid(config: PluginExtensionAddedLinkConfig) {
+ if (!isConfigureFnValid(config.configure)) {
throw new Error(
- `Invalid extension "${extension.title}". The extensionPointId should start with either "grafana/", "plugins/" or "capabilities/${pluginId}" (currently: "${extension.extensionPointId}"). Skipping the extension.`
- );
- }
-}
-
-export function assertConfigureIsValid(extension: PluginExtensionLinkConfig) {
- if (!isConfigureFnValid(extension)) {
- throw new Error(
- `Invalid extension "${extension.title}". The "configure" property must be a function. Skipping the extension.`
+ `Invalid extension "${config.title}". The "configure" property must be a function. Skipping the extension.`
);
}
}
@@ -88,43 +65,20 @@ export function extensionPointEndsWithVersion(extensionPointId: string) {
return extensionPointId.match(/.*\/v\d+$/);
}
-export function isConfigureFnValid(extension: PluginExtensionLinkConfig) {
- return extension.configure ? typeof extension.configure === 'function' : true;
+export function isGrafanaCoreExtensionPoint(extensionPointId: string) {
+ return Object.values(PluginExtensionPoints)
+ .map((v) => v.toString())
+ .includes(extensionPointId);
+}
+
+export function isConfigureFnValid(configure?: PluginAddedLinksConfigureFunc