mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
16c618f4d3
commit
db0cc24f2b
@ -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"]
|
||||
],
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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/',
|
||||
});
|
||||
|
@ -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>,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
@ -36,5 +36,6 @@ export const testIds = {
|
||||
},
|
||||
addedLinksPage: {
|
||||
container: 'data-testid pg-added-links-container',
|
||||
section1: 'use-plugin-links',
|
||||
},
|
||||
};
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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';
|
||||
|
@ -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 {
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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);
|
||||
}
|
@ -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));
|
||||
|
||||
|
@ -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),
|
||||
|
@ -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;
|
||||
|
@ -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()];
|
||||
}
|
||||
|
@ -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,
|
||||
})
|
||||
|
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
@ -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({});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
@ -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);
|
||||
|
@ -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'.`
|
||||
);
|
||||
|
@ -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({});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
21
public/app/features/plugins/extensions/registry/setup.ts
Normal file
21
public/app/features/plugins/extensions/registry/setup.ts
Normal 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;
|
||||
}
|
9
public/app/features/plugins/extensions/registry/types.ts
Normal file
9
public/app/features/plugins/extensions/registry/types.ts
Normal 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;
|
||||
};
|
@ -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<{}>>>;
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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 };
|
||||
};
|
||||
}
|
||||
|
118
public/app/features/plugins/extensions/usePluginLinks.test.tsx
Normal file
118
public/app/features/plugins/extensions/usePluginLinks.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
90
public/app/features/plugins/extensions/usePluginLinks.tsx
Normal file
90
public/app/features/plugins/extensions/usePluginLinks.tsx
Normal 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]);
|
||||
};
|
||||
}
|
@ -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();
|
||||
|
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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)
|
||||
|
@ -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}`);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user