Plugin extensions: Introduce new registry for added links (#92343)

* add added component registry

* fix broken test

* add tests for usePluginComponents hook

* readd expose components

* add type assertion exceptions to betterer results

* use new addedComponent registry in legacy endpoints

* remove unused code

* cleanup

* revert test code

* remove commented code

* initial commit

* refactor sync method and hook

* fix tests

* subscribe to the correct registry

* remove old registry

* cleanup types

* add use usePluginLinks hook

* add more tests

* fix import order

* fix typo

* fix and temporarly skip failing tests

* wip

* add hook tests

* add more tests

* remove old hook

* fix versioning

* add version to all extension point ids

* remove cleanup

* remove unused imports

* revert touched file

* fix test

* test: remove hook creation

* catch init error

* send error to faro

* fix broken hook

* comment out call hook initialization

* use the right import ofr isString

* remove unused import

* remove registryState type

* pr feedback

* Update public/app/features/plugins/extensions/validators.test.tsx

Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>

* Update public/app/features/plugins/extensions/validators.test.tsx

Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>

* remove no longer relevant comment

* fix broken tests

* Fixed test to verify that the memotization works properly.

* simplify hooks

---------

Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>
Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>
This commit is contained in:
Erik Sundell 2024-08-30 10:09:01 +02:00 committed by GitHub
parent 16c618f4d3
commit db0cc24f2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 1498 additions and 1639 deletions

View File

@ -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"]
],

View File

@ -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 (
<PluginPage>
<div data-testid={testIds.addedLinksPage.container}>
{isLoading ? (
<div>Loading...</div>
) : (
links.map(({ id, title, path, onClick }) => (
<a href={path} title={title} key={id} onClick={onClick}>
{title}
</a>
))
)}
</div>
<Stack direction={'column'} gap={4} data-testid={testIds.addedLinksPage.container}>
<section data-testid={testIds.addedLinksPage.section1}>
<h3>Link extensions defined with addLink and retrieved using usePluginLinks</h3>
<ActionButton extensions={links} />
</section>
</Stack>
</PluginPage>
);
}

View File

@ -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/',
});

View File

@ -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 }) => (
<div data-testid={testIds.appB.reusableAddedComponent}>Hello {name}!</div>
),
})
.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: () => <div data-testid={testIds.appB.modal}>From plugin B</div>,
});
},
});

View File

@ -36,5 +36,6 @@ export const testIds = {
},
addedLinksPage: {
container: 'data-testid pg-added-links-container',
section1: 'use-plugin-links',
},
};

View File

@ -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();
});

View File

@ -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,

View File

@ -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<T extends KeyValue = KeyValue> extends PluginMeta
}
export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppPluginMeta<T>> {
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<AppRootProps<T>>;
@ -110,38 +109,24 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
return this._addedComponentConfigs;
}
get extensionConfigs() {
return this._extensionConfigs;
get addedLinkConfigs() {
return this._addedLinkConfigs;
}
addLink<Context extends object>(
extensionConfig: { targets: string | string[] } & Omit<
PluginExtensionLinkConfig<Context>,
'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<Context extends object>(linkConfig: PluginExtensionAddedLinkConfig<Context>) {
this._addedLinkConfigs.push(linkConfig as PluginExtensionAddedLinkConfig);
return this;
}
addComponent<Props = {}>(addedComponentConfig: PluginAddedComponentConfig<Props>) {
this._addedComponentConfigs.push(addedComponentConfig as PluginAddedComponentConfig);
addComponent<Props = {}>(addedComponentConfig: PluginExtensionAddedComponentConfig<Props>) {
this._addedComponentConfigs.push(addedComponentConfig as PluginExtensionAddedComponentConfig);
return this;
}
exposeComponent<Props = {}>(componentConfig: PluginExposedComponentConfig<Props>) {
this._exposedComponentConfigs.push(componentConfig as PluginExposedComponentConfig);
exposeComponent<Props = {}>(componentConfig: PluginExtensionExposedComponentConfig<Props>) {
this._exposedComponentConfigs.push(componentConfig as PluginExtensionExposedComponentConfig);
return this;
}

View File

@ -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<Context extends object = object> = {
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<Context>) => 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<Context>) =>
| Partial<{
title: string;
description: string;
path: string;
onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => 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<Props = {}> = {
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<Props>;
/**
* The unique identifier of the Extension Point
* (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum)
*/
extensionPointId: string;
};
export type PluginAddedComponentConfig<Props = {}> = {
export type PluginExtensionAddedComponentConfig<Props = {}> = PluginExtensionConfigBase & {
/**
* The target extension points where the component will be added
*/
@ -117,23 +75,54 @@ export type PluginAddedComponentConfig<Props = {}> = {
component: React.ComponentType<Props>;
};
export type PluginExposedComponentConfig<Props = {}> = {
export type PluginAddedLinksConfigureFunc<Context extends object> = (context?: Readonly<Context>) =>
| Partial<{
title: string;
description: string;
path: string;
onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => void;
icon: IconName;
category: string;
}>
| undefined;
export type PluginExtensionAddedLinkConfig<Context extends object = object> = 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<Context>) => void;
// (Optional) A function that can be used to configure the extension dynamically based on the extension point's context
configure?: PluginAddedLinksConfigureFunc<Context>;
// (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<Props = {}> = PluginExtensionConfigBase & {
/**
* The unique identifier of the component
* Shoud be in the format of `<pluginId>/<componentName>/<componentVersion>`. 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<Context extends object = object> = {
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<Context>) => 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<Context>) =>
| Partial<{
title: string;
description: string;
path: string;
onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => 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<Props = {}> = {
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<Props>;
/**
* The unique identifier of the Extension Point
* (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum)
*/
extensionPointId: string;
};

View File

@ -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';

View File

@ -40,6 +40,17 @@ export type UsePluginComponentsResult<Props = {}> = {
isLoading: boolean;
};
export type UsePluginLinksOptions = {
extensionPointId: string;
context?: object | Record<string | symbol, unknown>;
limitPerPlugin?: number;
};
export type UsePluginLinksResult = {
isLoading: boolean;
links: PluginExtensionLink[];
};
let singleton: GetPluginExtensions | undefined;
export function setPluginExtensionGetter(instance: GetPluginExtensions): void {

View File

@ -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.
*/

View File

@ -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);
}

View File

@ -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));

View File

@ -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),

View File

@ -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<PluginExtensionExploreContext>({
createAddedLinkConfig<PluginExtensionExploreContext>({
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<PluginExtensionExploreContext>({
createAddedLinkConfig<PluginExtensionExploreContext>({
title: 'Add correlation',
description: 'Create a correlation from this query',
extensionPointId: PluginExtensionPoints.ExploreToolbarAction,
targets: [PluginExtensionPoints.ExploreToolbarAction],
icon: 'link',
configure: (context) => {
return context?.shouldShowAddCorrelation ? {} : undefined;

View File

@ -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()];
}

View File

@ -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 <div>Hello world!</div>;
},
@ -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 <div>Hello world2!</div>;
},
@ -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,
})

View File

@ -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<string | symbol, unknown>;
extensionPointId: string;
limitPerPlugin?: number;
registry: PluginExtensionRegistry;
addedComponentsRegistry: AddedComponentsRegistryState;
addedComponentsRegistry: RegistryType<AddedComponentRegistryItem[]> | undefined;
addedLinksRegistry: RegistryType<AddedLinkRegistryItem[]> | 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<AddedComponentRegistryItem[]>;
let addedLinksRegistry: RegistryType<Array<AddedLinkRegistryItem<object>>>;
// 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<string, number> = {};
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,
})
);
}

View File

@ -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({});
});
});

View File

@ -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<PluginPreloadResult>;
private registrySubject: ReplaySubject<PluginExtensionRegistry>;
constructor() {
this.resultSubject = new Subject<PluginPreloadResult>();
// This is the subject that we expose.
// (It will buffer the last value on the stream - the registry - and emit it to new subscribers immediately.)
this.registrySubject = new ReplaySubject<PluginExtensionRegistry>(1);
this.resultSubject
.pipe(
scan(resultsToRegistry, { id: '', extensions: {} }),
// Emit an empty registry to start the stream (it is only going to do it once during construction, and then just passes down the values)
startWith({ id: '', extensions: {} }),
map((registry) => deepFreeze(registry))
)
// Emitting the new registry to `this.registrySubject`
.subscribe(this.registrySubject);
}
register(result: PluginPreloadResult): void {
this.resultSubject.next(result);
}
asObservable(): Observable<PluginExtensionRegistry> {
return this.registrySubject.asObservable();
}
getRegistry(): Promise<PluginExtensionRegistry> {
return firstValueFrom(this.asObservable());
}
}
function resultsToRegistry(registry: PluginExtensionRegistry, result: PluginPreloadResult): PluginExtensionRegistry {
const { pluginId, extensionConfigs, error } = result;
// TODO: We should probably move this section to where we load the plugin since this is only used
// to provide a log to the user.
if (error) {
logWarning(`"${pluginId}" plugin failed to load, skip registering its extensions.`);
return registry;
}
for (const extensionConfig of extensionConfigs) {
const { extensionPointId } = extensionConfig;
// 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;
}

View File

@ -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);

View File

@ -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<Props = {}> = {
component: React.ComponentType<Props>;
};
export class AddedComponentsRegistry extends Registry<AddedComponentRegistryItem[], PluginAddedComponentConfig> {
export class AddedComponentsRegistry extends Registry<
AddedComponentRegistryItem[],
PluginExtensionAddedComponentConfig
> {
constructor(initialState: RegistryType<AddedComponentRegistryItem[]> = {}) {
super({
initialState,
@ -21,7 +29,7 @@ export class AddedComponentsRegistry extends Registry<AddedComponentRegistryItem
mapToRegistry(
registry: RegistryType<AddedComponentRegistryItem[]>,
item: PluginExtensionConfigs<PluginAddedComponentConfig>
item: PluginExtensionConfigs<PluginExtensionAddedComponentConfig>
): RegistryType<AddedComponentRegistryItem[]> {
const { pluginId, configs } = item;
@ -52,7 +60,7 @@ export class AddedComponentsRegistry extends Registry<AddedComponentRegistryItem
continue;
}
if (!extensionPointEndsWithVersion(extensionPointId)) {
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'.`
);

View File

@ -0,0 +1,523 @@
import { firstValueFrom } from 'rxjs';
import { AddedLinksRegistry } from './AddedLinksRegistry';
describe('AddedLinksRegistry', () => {
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({});
});
});

View File

@ -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<Context extends object = object> = {
pluginId: string;
extensionPointId: string;
title: string;
description: string;
path?: string;
onClick?: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => void;
configure?: PluginAddedLinksConfigureFunc<Context>;
icon?: IconName;
category?: string;
};
export class AddedLinksRegistry extends Registry<AddedLinkRegistryItem[], PluginExtensionAddedLinkConfig> {
constructor(initialState: RegistryType<AddedLinkRegistryItem[]> = {}) {
super({
initialState,
});
}
mapToRegistry(
registry: RegistryType<AddedLinkRegistryItem[]>,
item: PluginExtensionConfigs<PluginExtensionAddedLinkConfig>
): RegistryType<AddedLinkRegistryItem[]> {
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;
}
}

View File

@ -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<Props = {}> = {
component: React.ComponentType<Props>;
};
export class ExposedComponentsRegistry extends Registry<ExposedComponentRegistryItem, PluginExposedComponentConfig> {
export class ExposedComponentsRegistry extends Registry<
ExposedComponentRegistryItem,
PluginExtensionExposedComponentConfig
> {
constructor(initialState: RegistryType<ExposedComponentRegistryItem> = {}) {
super({
initialState,
@ -21,7 +24,7 @@ export class ExposedComponentsRegistry extends Registry<ExposedComponentRegistry
mapToRegistry(
registry: RegistryType<ExposedComponentRegistryItem>,
{ pluginId, configs }: PluginExtensionConfigs<PluginExposedComponentConfig>
{ pluginId, configs }: PluginExtensionConfigs<PluginExtensionExposedComponentConfig>
): RegistryType<ExposedComponentRegistryItem> {
if (!configs) {
return registry;

View File

@ -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;
}

View File

@ -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;
};

View File

@ -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<string, PluginExtensionRegistryItem[]>;
};
export type AddedComponentsRegistryState = RegistryType<Array<AddedComponentRegistryItem<{}>>>;

View File

@ -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,

View File

@ -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<React.ComponentType<Props>> = [];
const registryItems = registry[extensionPointId];
const extensionsByPlugin: Record<string, number> = {};
for (const registryItem of registryItems) {
for (const registryItem of registry?.[extensionPointId] ?? []) {
const { pluginId } = registryItem;
// Only limit if the `limitPerPlugin` is set

View File

@ -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: [],
});
});

View File

@ -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<string, { context: GetPluginExtensionsOptions['context']; extensions: PluginExtension[] }>;
} = {
id: '',
extensions: {},
};
export function createUsePluginExtensions(registries: PluginExtensionRegistries) {
const observableAddedComponentsRegistry = registries.addedComponentsRegistry.asObservable();
const observableAddedLinksRegistry = registries.addedLinksRegistry.asObservable();
return function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult<PluginExtension> {
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 };
};
}

View File

@ -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);
});
});

View File

@ -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<string, number> = {};
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]);
};
}

View File

@ -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();

View File

@ -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<T extends object>(
config: Omit<PluginExtensionLinkConfig<T>, 'type'>
): PluginExtensionLinkConfig {
const linkConfig: PluginExtensionLinkConfig<T> = {
type: PluginExtensionTypes.link,
export function createAddedLinkConfig<T extends object>(
config: PluginExtensionAddedLinkConfig<T>
): PluginExtensionAddedLinkConfig {
const linkConfig: PluginExtensionAddedLinkConfig<T> = {
...config,
};
assertLinkConfig(linkConfig);
@ -230,12 +235,8 @@ export function createExtensionLinkConfig<T extends object>(
}
function assertLinkConfig<T extends object>(
config: PluginExtensionLinkConfig<T>
): asserts config is PluginExtensionLinkConfig {
if (config.type !== PluginExtensionTypes.link) {
throw Error('config is not a extension link');
}
}
config: PluginExtensionAddedLinkConfig<T>
): 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,
})
);
}

View File

@ -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(() => <div>Some text</div>)).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);
});
});
});

View File

@ -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<object> | undefined) {
return configure ? typeof configure === 'function' : true;
}
export function isStringPropValid(prop: unknown) {
return typeof prop === 'string' && prop.length > 0;
}
export function isPluginExtensionConfigValid(pluginId: string, extension: PluginExtensionConfig): boolean {
try {
assertStringProps(extension, ['title', 'description', 'extensionPointId']);
assertExtensionPointIdIsValid(pluginId, extension);
// Link
if (isPluginExtensionLinkConfig(extension)) {
assertConfigureIsValid(extension);
if (!extension.path && !extension.onClick) {
logWarning(`Invalid extension "${extension.title}". Either "path" or "onClick" is required.`);
return false;
}
if (extension.path) {
assertLinkPathIsValid(pluginId, extension.path);
}
}
return true;
} catch (error) {
if (error instanceof Error) {
logWarning(error.message);
}
return false;
}
}
export function isPromise(value: unknown): value is Promise<unknown> {
return (
value instanceof Promise || (typeof value === 'object' && value !== null && 'then' in value && 'catch' in value)

View File

@ -1,26 +1,18 @@
import type { PluginExposedComponentConfig, PluginExtensionConfig } from '@grafana/data';
import { PluginAddedComponentConfig } from '@grafana/data/src/types/pluginExtensions';
import type { PluginExtensionAddedLinkConfig, PluginExtensionExposedComponentConfig } from '@grafana/data';
import { PluginExtensionAddedComponentConfig } from '@grafana/data/src/types/pluginExtensions';
import type { AppPluginConfig } from '@grafana/runtime';
import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
import { ReactivePluginExtensionsRegistry } from './extensions/reactivePluginExtensionRegistry';
import { AddedComponentsRegistry } from './extensions/registry/AddedComponentsRegistry';
import { ExposedComponentsRegistry } from './extensions/registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './extensions/registry/types';
import * as pluginLoader from './plugin_loader';
export type PluginPreloadResult = {
pluginId: string;
error?: unknown;
extensionConfigs: PluginExtensionConfig[];
exposedComponentConfigs: PluginExposedComponentConfig[];
addedComponentConfigs: PluginAddedComponentConfig[];
};
type PluginExtensionRegistries = {
extensionsRegistry: ReactivePluginExtensionsRegistry;
addedComponentsRegistry: AddedComponentsRegistry;
exposedComponentsRegistry: ExposedComponentsRegistry;
exposedComponentConfigs: PluginExtensionExposedComponentConfig[];
addedComponentConfigs?: PluginExtensionAddedComponentConfig[];
addedLinkConfigs?: PluginExtensionAddedLinkConfig[];
};
export async function preloadPlugins(
@ -38,14 +30,17 @@ export async function preloadPlugins(
continue;
}
registries.extensionsRegistry.register(preloadedPlugin);
registries.exposedComponentsRegistry.register({
pluginId: preloadedPlugin.pluginId,
configs: preloadedPlugin.exposedComponentConfigs,
});
registries.addedComponentsRegistry.register({
pluginId: preloadedPlugin.pluginId,
configs: preloadedPlugin.addedComponentConfigs,
configs: preloadedPlugin.addedComponentConfigs || [],
});
registries.addedLinksRegistry.register({
pluginId: preloadedPlugin.pluginId,
configs: preloadedPlugin.addedLinkConfigs || [],
});
}
@ -62,16 +57,22 @@ async function preload(config: AppPluginConfig): Promise<PluginPreloadResult> {
isAngular: config.angular.detected,
pluginId,
});
const { extensionConfigs = [], exposedComponentConfigs = [], addedComponentConfigs = [] } = plugin;
const { exposedComponentConfigs = [], addedComponentConfigs = [], addedLinkConfigs = [] } = plugin;
// Fetching meta-information for the preloaded app plugin and caching it for later.
// (The function below returns a promise, but it's not awaited for a reason: we don't want to block the preload process, we would only like to cache the result for later.)
getPluginSettings(pluginId);
return { pluginId, extensionConfigs, exposedComponentConfigs, addedComponentConfigs };
return { pluginId, exposedComponentConfigs, addedComponentConfigs, addedLinkConfigs };
} catch (error) {
console.error(`[Plugins] Failed to preload plugin: ${path} (version: ${version})`, error);
return { pluginId, extensionConfigs: [], error, exposedComponentConfigs: [], addedComponentConfigs: [] };
return {
pluginId,
error,
exposedComponentConfigs: [],
addedComponentConfigs: [],
addedLinkConfigs: [],
};
} finally {
stopMeasure(`frontend_plugin_preload_${pluginId}`);
}