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, "Unexpected any. Specify a different type.", "11"],
|
||||||
[0, 0, 0, "Do not use any type assertions.", "12"]
|
[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": [
|
"public/app/features/plugins/extensions/usePluginComponents.tsx:5381": [
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||||
],
|
],
|
||||||
|
@ -1,25 +1,22 @@
|
|||||||
import { PluginPage, usePluginLinks } from '@grafana/runtime';
|
import { PluginPage, usePluginLinks } from '@grafana/runtime';
|
||||||
|
import { Stack } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { ActionButton } from '../components/ActionButton';
|
||||||
import { testIds } from '../testIds';
|
import { testIds } from '../testIds';
|
||||||
|
|
||||||
export const LINKS_EXTENSION_POINT_ID = 'plugins/grafana-extensionstest-app/use-plugin-links/v1';
|
export const LINKS_EXTENSION_POINT_ID = 'plugins/grafana-extensionstest-app/use-plugin-links/v1';
|
||||||
|
|
||||||
export function AddedLinks() {
|
export function AddedLinks() {
|
||||||
const { links, isLoading } = usePluginLinks({ extensionPointId: LINKS_EXTENSION_POINT_ID });
|
const { links } = usePluginLinks({ extensionPointId: LINKS_EXTENSION_POINT_ID });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PluginPage>
|
<PluginPage>
|
||||||
<div data-testid={testIds.addedLinksPage.container}>
|
<Stack direction={'column'} gap={4} data-testid={testIds.addedLinksPage.container}>
|
||||||
{isLoading ? (
|
<section data-testid={testIds.addedLinksPage.section1}>
|
||||||
<div>Loading...</div>
|
<h3>Link extensions defined with addLink and retrieved using usePluginLinks</h3>
|
||||||
) : (
|
<ActionButton extensions={links} />
|
||||||
links.map(({ id, title, path, onClick }) => (
|
</section>
|
||||||
<a href={path} title={title} key={id} onClick={onClick}>
|
</Stack>
|
||||||
{title}
|
|
||||||
</a>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PluginPage>
|
</PluginPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,6 @@ import { LINKS_EXTENSION_POINT_ID } from '../../pages/AddedLinks';
|
|||||||
import { testIds } from '../../testIds';
|
import { testIds } from '../../testIds';
|
||||||
|
|
||||||
import { App } from './components/App';
|
import { App } from './components/App';
|
||||||
import pluginJson from './plugin.json';
|
|
||||||
|
|
||||||
export const plugin = new AppPlugin<{}>()
|
export const plugin = new AppPlugin<{}>()
|
||||||
.setRootPage(App)
|
.setRootPage(App)
|
||||||
@ -24,5 +23,11 @@ export const plugin = new AppPlugin<{}>()
|
|||||||
title: 'Basic link',
|
title: 'Basic link',
|
||||||
description: '...',
|
description: '...',
|
||||||
targets: [LINKS_EXTENSION_POINT_ID],
|
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 { AppPlugin } from '@grafana/data';
|
||||||
|
|
||||||
|
import { LINKS_EXTENSION_POINT_ID } from '../../pages/AddedLinks';
|
||||||
import { testIds } from '../../testIds';
|
import { testIds } from '../../testIds';
|
||||||
|
|
||||||
import { App } from './components/App';
|
import { App } from './components/App';
|
||||||
@ -30,4 +31,15 @@ export const plugin = new AppPlugin<{}>()
|
|||||||
component: ({ name }: { name: string }) => (
|
component: ({ name }: { name: string }) => (
|
||||||
<div data-testid={testIds.appB.reusableAddedComponent}>Hello {name}!</div>
|
<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: {
|
addedLinksPage: {
|
||||||
container: 'data-testid pg-added-links-container',
|
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 pluginJson from '../plugin.json';
|
||||||
import { testIds } from '../testIds';
|
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.goto(`/a/${pluginJson.id}/added-links`);
|
||||||
await page.getByTestId(testIds.addedLinksPage.container).getByText('Basic link').click();
|
const section = await page.getByTestId(testIds.addedLinksPage.section1);
|
||||||
await expect(page.getByTestId(testIds.appA.container)).toHaveText('Hello Grafana!');
|
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 PluginExtensionDataSourceConfigContext,
|
||||||
type PluginExtensionCommandPaletteContext,
|
type PluginExtensionCommandPaletteContext,
|
||||||
type PluginExtensionOpenModalOptions,
|
type PluginExtensionOpenModalOptions,
|
||||||
type PluginExposedComponentConfig,
|
type PluginExtensionExposedComponentConfig,
|
||||||
type PluginAddedComponentConfig,
|
type PluginExtensionAddedComponentConfig,
|
||||||
|
type PluginExtensionAddedLinkConfig,
|
||||||
} from './types/pluginExtensions';
|
} from './types/pluginExtensions';
|
||||||
export {
|
export {
|
||||||
type ScopeDashboardBindingSpec,
|
type ScopeDashboardBindingSpec,
|
||||||
|
@ -5,11 +5,10 @@ import { NavModel } from './navModel';
|
|||||||
import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin';
|
import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin';
|
||||||
import {
|
import {
|
||||||
type PluginExtensionLinkConfig,
|
type PluginExtensionLinkConfig,
|
||||||
PluginExtensionTypes,
|
|
||||||
PluginExtensionComponentConfig,
|
PluginExtensionComponentConfig,
|
||||||
PluginExposedComponentConfig,
|
PluginExtensionExposedComponentConfig,
|
||||||
PluginExtensionConfig,
|
PluginExtensionAddedComponentConfig,
|
||||||
PluginAddedComponentConfig,
|
PluginExtensionAddedLinkConfig,
|
||||||
} from './pluginExtensions';
|
} 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>> {
|
export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppPluginMeta<T>> {
|
||||||
private _exposedComponentConfigs: PluginExposedComponentConfig[] = [];
|
private _exposedComponentConfigs: PluginExtensionExposedComponentConfig[] = [];
|
||||||
private _addedComponentConfigs: PluginAddedComponentConfig[] = [];
|
private _addedComponentConfigs: PluginExtensionAddedComponentConfig[] = [];
|
||||||
private _extensionConfigs: PluginExtensionConfig[] = [];
|
private _addedLinkConfigs: PluginExtensionAddedLinkConfig[] = [];
|
||||||
|
|
||||||
// Content under: /a/${plugin-id}/*
|
// Content under: /a/${plugin-id}/*
|
||||||
root?: ComponentType<AppRootProps<T>>;
|
root?: ComponentType<AppRootProps<T>>;
|
||||||
@ -110,38 +109,24 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
|
|||||||
return this._addedComponentConfigs;
|
return this._addedComponentConfigs;
|
||||||
}
|
}
|
||||||
|
|
||||||
get extensionConfigs() {
|
get addedLinkConfigs() {
|
||||||
return this._extensionConfigs;
|
return this._addedLinkConfigs;
|
||||||
}
|
}
|
||||||
|
|
||||||
addLink<Context extends object>(
|
addLink<Context extends object>(linkConfig: PluginExtensionAddedLinkConfig<Context>) {
|
||||||
extensionConfig: { targets: string | string[] } & Omit<
|
this._addedLinkConfigs.push(linkConfig as PluginExtensionAddedLinkConfig);
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
addComponent<Props = {}>(addedComponentConfig: PluginAddedComponentConfig<Props>) {
|
addComponent<Props = {}>(addedComponentConfig: PluginExtensionAddedComponentConfig<Props>) {
|
||||||
this._addedComponentConfigs.push(addedComponentConfig as PluginAddedComponentConfig);
|
this._addedComponentConfigs.push(addedComponentConfig as PluginExtensionAddedComponentConfig);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
exposeComponent<Props = {}>(componentConfig: PluginExposedComponentConfig<Props>) {
|
exposeComponent<Props = {}>(componentConfig: PluginExtensionExposedComponentConfig<Props>) {
|
||||||
this._exposedComponentConfigs.push(componentConfig as PluginExposedComponentConfig);
|
this._exposedComponentConfigs.push(componentConfig as PluginExtensionExposedComponentConfig);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,6 @@ type PluginExtensionBase = {
|
|||||||
description: string;
|
description: string;
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PluginExtensionLink = PluginExtensionBase & {
|
export type PluginExtensionLink = PluginExtensionBase & {
|
||||||
type: PluginExtensionTypes.link;
|
type: PluginExtensionTypes.link;
|
||||||
path?: string;
|
path?: string;
|
||||||
@ -41,61 +40,20 @@ export type PluginExtension = PluginExtensionLink | PluginExtensionComponent;
|
|||||||
|
|
||||||
// Objects used for registering extensions (in app plugins)
|
// 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;
|
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
|
* A short description
|
||||||
* (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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PluginExtensionComponentConfig<Props = {}> = {
|
|
||||||
type: PluginExtensionTypes.component;
|
|
||||||
title: string;
|
|
||||||
description: 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
|
* The target extension points where the component will be added
|
||||||
*/
|
*/
|
||||||
@ -117,23 +75,54 @@ export type PluginAddedComponentConfig<Props = {}> = {
|
|||||||
component: React.ComponentType<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
|
* The unique identifier of the component
|
||||||
* Shoud be in the format of `<pluginId>/<componentName>/<componentVersion>`. e.g. `myorg-todo-app/todo-list/v1`
|
* Shoud be in the format of `<pluginId>/<componentName>/<componentVersion>`. e.g. `myorg-todo-app/todo-list/v1`
|
||||||
*/
|
*/
|
||||||
id: string;
|
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
|
* The React component that will be exposed to other plugins
|
||||||
*/
|
*/
|
||||||
@ -212,3 +201,61 @@ type Dashboard = {
|
|||||||
title: string;
|
title: string;
|
||||||
tags: 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,
|
usePluginExtensions,
|
||||||
usePluginLinkExtensions,
|
usePluginLinkExtensions,
|
||||||
usePluginComponentExtensions,
|
usePluginComponentExtensions,
|
||||||
usePluginLinks,
|
|
||||||
} from './pluginExtensions/usePluginExtensions';
|
} from './pluginExtensions/usePluginExtensions';
|
||||||
|
|
||||||
export { setPluginComponentHook, usePluginComponent } from './pluginExtensions/usePluginComponent';
|
export { setPluginComponentHook, usePluginComponent } from './pluginExtensions/usePluginComponent';
|
||||||
export { setPluginComponentsHook, usePluginComponents } from './pluginExtensions/usePluginComponents';
|
export { setPluginComponentsHook, usePluginComponents } from './pluginExtensions/usePluginComponents';
|
||||||
|
export { setPluginLinksHook, usePluginLinks } from './pluginExtensions/usePluginLinks';
|
||||||
|
|
||||||
export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils';
|
export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils';
|
||||||
export { setCurrentUser } from './user';
|
export { setCurrentUser } from './user';
|
||||||
|
@ -40,6 +40,17 @@ export type UsePluginComponentsResult<Props = {}> = {
|
|||||||
isLoading: boolean;
|
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;
|
let singleton: GetPluginExtensions | undefined;
|
||||||
|
|
||||||
export function setPluginExtensionGetter(instance: GetPluginExtensions): void {
|
export function setPluginExtensionGetter(instance: GetPluginExtensions): void {
|
||||||
|
@ -25,20 +25,6 @@ export function usePluginExtensions(options: GetPluginExtensionsOptions): UsePlu
|
|||||||
return singleton(options);
|
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.
|
* @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,
|
setPluginComponentsHook,
|
||||||
setCurrentUser,
|
setCurrentUser,
|
||||||
setChromeHeaderHeightHook,
|
setChromeHeaderHeightHook,
|
||||||
|
setPluginLinksHook,
|
||||||
} from '@grafana/runtime';
|
} from '@grafana/runtime';
|
||||||
import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView';
|
import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView';
|
||||||
import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer';
|
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 { PanelDataErrorView } from './features/panel/components/PanelDataErrorView';
|
||||||
import { PanelRenderer } from './features/panel/components/PanelRenderer';
|
import { PanelRenderer } from './features/panel/components/PanelRenderer';
|
||||||
import { DatasourceSrv } from './features/plugins/datasource_srv';
|
import { DatasourceSrv } from './features/plugins/datasource_srv';
|
||||||
import { getCoreExtensionConfigurations } from './features/plugins/extensions/getCoreExtensionConfigurations';
|
|
||||||
import { createPluginExtensionsGetter } from './features/plugins/extensions/getPluginExtensions';
|
import { createPluginExtensionsGetter } from './features/plugins/extensions/getPluginExtensions';
|
||||||
import { ReactivePluginExtensionsRegistry } from './features/plugins/extensions/reactivePluginExtensionRegistry';
|
import { setupPluginExtensionRegistries } from './features/plugins/extensions/registry/setup';
|
||||||
import { AddedComponentsRegistry } from './features/plugins/extensions/registry/AddedComponentsRegistry';
|
|
||||||
import { ExposedComponentsRegistry } from './features/plugins/extensions/registry/ExposedComponentsRegistry';
|
|
||||||
import { createUsePluginComponent } from './features/plugins/extensions/usePluginComponent';
|
import { createUsePluginComponent } from './features/plugins/extensions/usePluginComponent';
|
||||||
import { createUsePluginComponents } from './features/plugins/extensions/usePluginComponents';
|
import { createUsePluginComponents } from './features/plugins/extensions/usePluginComponents';
|
||||||
import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions';
|
import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions';
|
||||||
|
import { createUsePluginLinks } from './features/plugins/extensions/usePluginLinks';
|
||||||
import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin';
|
import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin';
|
||||||
import { preloadPlugins } from './features/plugins/pluginPreloader';
|
import { preloadPlugins } from './features/plugins/pluginPreloader';
|
||||||
import { QueryRunner } from './features/query/state/QueryRunner';
|
import { QueryRunner } from './features/query/state/QueryRunner';
|
||||||
@ -213,17 +212,7 @@ export class GrafanaApp {
|
|||||||
initWindowRuntime();
|
initWindowRuntime();
|
||||||
|
|
||||||
// Initialize plugin extensions
|
// Initialize plugin extensions
|
||||||
const pluginExtensionsRegistries = {
|
const pluginExtensionsRegistries = setupPluginExtensionRegistries();
|
||||||
extensionsRegistry: new ReactivePluginExtensionsRegistry(),
|
|
||||||
addedComponentsRegistry: new AddedComponentsRegistry(),
|
|
||||||
exposedComponentsRegistry: new ExposedComponentsRegistry(),
|
|
||||||
};
|
|
||||||
pluginExtensionsRegistries.extensionsRegistry.register({
|
|
||||||
pluginId: 'grafana',
|
|
||||||
extensionConfigs: getCoreExtensionConfigurations(),
|
|
||||||
exposedComponentConfigs: [],
|
|
||||||
addedComponentConfigs: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (contextSrv.user.orgRole !== '') {
|
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.
|
// 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');
|
await preloadPlugins(awaitedAppPlugins, pluginExtensionsRegistries, 'frontend_awaited_plugins_preload');
|
||||||
}
|
}
|
||||||
|
|
||||||
setPluginExtensionGetter(
|
setPluginLinksHook(createUsePluginLinks(pluginExtensionsRegistries.addedLinksRegistry));
|
||||||
createPluginExtensionsGetter(
|
setPluginExtensionGetter(createPluginExtensionsGetter(pluginExtensionsRegistries));
|
||||||
pluginExtensionsRegistries.extensionsRegistry,
|
setPluginExtensionsHook(createUsePluginExtensions(pluginExtensionsRegistries));
|
||||||
pluginExtensionsRegistries.addedComponentsRegistry
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setPluginExtensionsHook(
|
|
||||||
createUsePluginExtensions(
|
|
||||||
pluginExtensionsRegistries.extensionsRegistry,
|
|
||||||
pluginExtensionsRegistries.addedComponentsRegistry
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setPluginComponentHook(createUsePluginComponent(pluginExtensionsRegistries.exposedComponentsRegistry));
|
setPluginComponentHook(createUsePluginComponent(pluginExtensionsRegistries.exposedComponentsRegistry));
|
||||||
setPluginComponentsHook(createUsePluginComponents(pluginExtensionsRegistries.addedComponentsRegistry));
|
setPluginComponentsHook(createUsePluginComponents(pluginExtensionsRegistries.addedComponentsRegistry));
|
||||||
|
|
||||||
|
@ -14,20 +14,18 @@ describe('getExploreExtensionConfigs', () => {
|
|||||||
|
|
||||||
expect(extensions).toEqual([
|
expect(extensions).toEqual([
|
||||||
{
|
{
|
||||||
type: 'link',
|
|
||||||
title: 'Add to dashboard',
|
title: 'Add to dashboard',
|
||||||
description: 'Use the query and panel from explore and create/add it to a dashboard',
|
description: 'Use the query and panel from explore and create/add it to a dashboard',
|
||||||
extensionPointId: PluginExtensionPoints.ExploreToolbarAction,
|
targets: [PluginExtensionPoints.ExploreToolbarAction],
|
||||||
icon: 'apps',
|
icon: 'apps',
|
||||||
configure: expect.any(Function),
|
configure: expect.any(Function),
|
||||||
onClick: expect.any(Function),
|
onClick: expect.any(Function),
|
||||||
category: 'Dashboards',
|
category: 'Dashboards',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'link',
|
|
||||||
title: 'Add correlation',
|
title: 'Add correlation',
|
||||||
description: 'Create a correlation from this query',
|
description: 'Create a correlation from this query',
|
||||||
extensionPointId: PluginExtensionPoints.ExploreToolbarAction,
|
targets: [PluginExtensionPoints.ExploreToolbarAction],
|
||||||
icon: 'link',
|
icon: 'link',
|
||||||
configure: expect.any(Function),
|
configure: expect.any(Function),
|
||||||
onClick: 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 { contextSrv } from 'app/core/core';
|
||||||
import { dispatch } from 'app/store/store';
|
import { dispatch } from 'app/store/store';
|
||||||
import { AccessControlAction } from 'app/types';
|
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 { changeCorrelationEditorDetails } from '../state/main';
|
||||||
import { runQueries } from '../state/query';
|
import { runQueries } from '../state/query';
|
||||||
|
|
||||||
@ -11,13 +11,13 @@ import { AddToDashboardForm } from './AddToDashboard/AddToDashboardForm';
|
|||||||
import { getAddToDashboardTitle } from './AddToDashboard/getAddToDashboardTitle';
|
import { getAddToDashboardTitle } from './AddToDashboard/getAddToDashboardTitle';
|
||||||
import { type PluginExtensionExploreContext } from './ToolbarExtensionPoint';
|
import { type PluginExtensionExploreContext } from './ToolbarExtensionPoint';
|
||||||
|
|
||||||
export function getExploreExtensionConfigs(): PluginExtensionLinkConfig[] {
|
export function getExploreExtensionConfigs(): PluginExtensionAddedLinkConfig[] {
|
||||||
try {
|
try {
|
||||||
return [
|
return [
|
||||||
createExtensionLinkConfig<PluginExtensionExploreContext>({
|
createAddedLinkConfig<PluginExtensionExploreContext>({
|
||||||
title: 'Add to dashboard',
|
title: 'Add to dashboard',
|
||||||
description: 'Use the query and panel from explore and create/add it to a dashboard',
|
description: 'Use the query and panel from explore and create/add it to a dashboard',
|
||||||
extensionPointId: PluginExtensionPoints.ExploreToolbarAction,
|
targets: [PluginExtensionPoints.ExploreToolbarAction],
|
||||||
icon: 'apps',
|
icon: 'apps',
|
||||||
category: 'Dashboards',
|
category: 'Dashboards',
|
||||||
configure: () => {
|
configure: () => {
|
||||||
@ -39,10 +39,10 @@ export function getExploreExtensionConfigs(): PluginExtensionLinkConfig[] {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
createExtensionLinkConfig<PluginExtensionExploreContext>({
|
createAddedLinkConfig<PluginExtensionExploreContext>({
|
||||||
title: 'Add correlation',
|
title: 'Add correlation',
|
||||||
description: 'Create a correlation from this query',
|
description: 'Create a correlation from this query',
|
||||||
extensionPointId: PluginExtensionPoints.ExploreToolbarAction,
|
targets: [PluginExtensionPoints.ExploreToolbarAction],
|
||||||
icon: 'link',
|
icon: 'link',
|
||||||
configure: (context) => {
|
configure: (context) => {
|
||||||
return context?.shouldShowAddCorrelation ? {} : undefined;
|
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';
|
import { getExploreExtensionConfigs } from 'app/features/explore/extensions/getExploreExtensionConfigs';
|
||||||
|
|
||||||
export function getCoreExtensionConfigurations(): PluginExtensionLinkConfig[] {
|
export function getCoreExtensionConfigurations(): PluginExtensionAddedLinkConfig[] {
|
||||||
return [...getExploreExtensionConfigs()];
|
return [...getExploreExtensionConfigs()];
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,11 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import {
|
import { PluginExtensionAddedComponentConfig, PluginExtensionAddedLinkConfig } from '@grafana/data';
|
||||||
PluginAddedComponentConfig,
|
|
||||||
PluginExtensionComponentConfig,
|
|
||||||
PluginExtensionLinkConfig,
|
|
||||||
PluginExtensionTypes,
|
|
||||||
} from '@grafana/data';
|
|
||||||
import { reportInteraction } from '@grafana/runtime';
|
import { reportInteraction } from '@grafana/runtime';
|
||||||
|
|
||||||
import { getPluginExtensions } from './getPluginExtensions';
|
import { getPluginExtensions } from './getPluginExtensions';
|
||||||
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
|
|
||||||
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
|
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
|
||||||
|
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
|
||||||
import { isReadOnlyProxy } from './utils';
|
import { isReadOnlyProxy } from './utils';
|
||||||
import { assertPluginExtensionLink } from './validators';
|
import { assertPluginExtensionLink } from './validators';
|
||||||
|
|
||||||
@ -24,19 +19,17 @@ jest.mock('@grafana/runtime', () => {
|
|||||||
async function createRegistries(
|
async function createRegistries(
|
||||||
preloadResults: Array<{
|
preloadResults: Array<{
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
addedComponentConfigs: PluginAddedComponentConfig[];
|
addedComponentConfigs: PluginExtensionAddedComponentConfig[];
|
||||||
extensionConfigs: any[];
|
addedLinkConfigs: PluginExtensionAddedLinkConfig[];
|
||||||
}>
|
}>
|
||||||
) {
|
) {
|
||||||
const registry = new ReactivePluginExtensionsRegistry();
|
const addedLinksRegistry = new AddedLinksRegistry();
|
||||||
const addedComponentsRegistry = new AddedComponentsRegistry();
|
const addedComponentsRegistry = new AddedComponentsRegistry();
|
||||||
|
|
||||||
for (const { pluginId, extensionConfigs, addedComponentConfigs } of preloadResults) {
|
for (const { pluginId, addedLinkConfigs, addedComponentConfigs } of preloadResults) {
|
||||||
registry.register({
|
addedLinksRegistry.register({
|
||||||
pluginId,
|
pluginId,
|
||||||
exposedComponentConfigs: [],
|
configs: addedLinkConfigs,
|
||||||
extensionConfigs,
|
|
||||||
addedComponentConfigs: [],
|
|
||||||
});
|
});
|
||||||
addedComponentsRegistry.register({
|
addedComponentsRegistry.register({
|
||||||
pluginId,
|
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()', () => {
|
describe('getPluginExtensions()', () => {
|
||||||
const extensionPoint1 = 'grafana/dashboard/panel/menu';
|
const extensionPoint1 = 'grafana/dashboard/panel/menu/v1';
|
||||||
const extensionPoint2 = 'plugins/myorg-basic-app/start';
|
const extensionPoint2 = 'plugins/myorg-basic-app/start/v1';
|
||||||
const extensionPoint3 = 'grafana/datasources/config';
|
const extensionPoint3 = 'grafana/datasources/config/v1';
|
||||||
const pluginId = 'grafana-basic-app';
|
const pluginId = 'grafana-basic-app';
|
||||||
// Sample extension configs that are used in the tests below
|
// 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(() => {
|
beforeEach(() => {
|
||||||
link1 = {
|
link1 = {
|
||||||
type: PluginExtensionTypes.link,
|
|
||||||
title: 'Link 1',
|
title: 'Link 1',
|
||||||
description: 'Link 1 description',
|
description: 'Link 1 description',
|
||||||
path: `/a/${pluginId}/declare-incident`,
|
path: `/a/${pluginId}/declare-incident`,
|
||||||
extensionPointId: extensionPoint1,
|
targets: extensionPoint1,
|
||||||
configure: jest.fn().mockReturnValue({}),
|
configure: jest.fn().mockReturnValue({}),
|
||||||
};
|
};
|
||||||
link2 = {
|
link2 = {
|
||||||
type: PluginExtensionTypes.link,
|
|
||||||
title: 'Link 2',
|
title: 'Link 2',
|
||||||
description: 'Link 2 description',
|
description: 'Link 2 description',
|
||||||
path: `/a/${pluginId}/declare-incident`,
|
path: `/a/${pluginId}/declare-incident`,
|
||||||
extensionPointId: extensionPoint2,
|
targets: extensionPoint2,
|
||||||
configure: jest.fn().mockImplementation((context) => ({ title: context?.title })),
|
configure: jest.fn().mockImplementation((context) => ({ title: context?.title })),
|
||||||
};
|
};
|
||||||
component1 = {
|
component1 = {
|
||||||
type: PluginExtensionTypes.component,
|
|
||||||
title: 'Component 1',
|
title: 'Component 1',
|
||||||
description: 'Component 1 description',
|
description: 'Component 1 description',
|
||||||
extensionPointId: extensionPoint3,
|
targets: extensionPoint3,
|
||||||
component: (context) => {
|
component: (context) => {
|
||||||
return <div>Hello world!</div>;
|
return <div>Hello world!</div>;
|
||||||
},
|
},
|
||||||
@ -88,7 +83,7 @@ describe('getPluginExtensions()', () => {
|
|||||||
|
|
||||||
test('should return the extensions for the given placement', async () => {
|
test('should return the extensions for the given placement', async () => {
|
||||||
const registries = await createRegistries([
|
const registries = await createRegistries([
|
||||||
{ pluginId, extensionConfigs: [link1, link2], addedComponentConfigs: [] },
|
{ pluginId, addedLinkConfigs: [link1, link2], addedComponentConfigs: [] },
|
||||||
]);
|
]);
|
||||||
const { extensions } = getPluginExtensions({
|
const { extensions } = getPluginExtensions({
|
||||||
...registries,
|
...registries,
|
||||||
@ -99,7 +94,6 @@ describe('getPluginExtensions()', () => {
|
|||||||
expect(extensions[0]).toEqual(
|
expect(extensions[0]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
pluginId,
|
pluginId,
|
||||||
type: PluginExtensionTypes.link,
|
|
||||||
title: link1.title,
|
title: link1.title,
|
||||||
description: link1.description,
|
description: link1.description,
|
||||||
path: expect.stringContaining(link1.path!),
|
path: expect.stringContaining(link1.path!),
|
||||||
@ -110,7 +104,7 @@ describe('getPluginExtensions()', () => {
|
|||||||
test('should not limit the number of extensions per plugin by default', async () => {
|
test('should not limit the number of extensions per plugin by default', async () => {
|
||||||
// Registering 3 extensions for the same plugin for the same placement
|
// Registering 3 extensions for the same plugin for the same placement
|
||||||
const registries = await createRegistries([
|
const registries = await createRegistries([
|
||||||
{ pluginId, extensionConfigs: [link1, link1, link1, link2], addedComponentConfigs: [] },
|
{ pluginId, addedLinkConfigs: [link1, link1, link1, link2], addedComponentConfigs: [] },
|
||||||
]);
|
]);
|
||||||
const { extensions } = getPluginExtensions({
|
const { extensions } = getPluginExtensions({
|
||||||
...registries,
|
...registries,
|
||||||
@ -121,7 +115,6 @@ describe('getPluginExtensions()', () => {
|
|||||||
expect(extensions[0]).toEqual(
|
expect(extensions[0]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
pluginId,
|
pluginId,
|
||||||
type: PluginExtensionTypes.link,
|
|
||||||
title: link1.title,
|
title: link1.title,
|
||||||
description: link1.description,
|
description: link1.description,
|
||||||
path: expect.stringContaining(link1.path!),
|
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 () => {
|
test('should be possible to limit the number of extensions per plugin for a given placement', async () => {
|
||||||
const registries = await createRegistries([
|
const registries = await createRegistries([
|
||||||
{ pluginId, extensionConfigs: [link1, link1, link1, link2], addedComponentConfigs: [] },
|
{ pluginId, addedLinkConfigs: [link1, link1, link1, link2], addedComponentConfigs: [] },
|
||||||
{
|
{
|
||||||
pluginId: 'my-plugin',
|
pluginId: 'my-plugin',
|
||||||
addedComponentConfigs: [],
|
addedComponentConfigs: [],
|
||||||
extensionConfigs: [
|
addedLinkConfigs: [
|
||||||
{ ...link1, path: '/a/my-plugin/declare-incident' },
|
{ ...link1, path: '/a/my-plugin/declare-incident' },
|
||||||
{ ...link1, path: '/a/my-plugin/declare-incident' },
|
{ ...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(extensions[0]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
pluginId,
|
pluginId,
|
||||||
type: PluginExtensionTypes.link,
|
|
||||||
title: link1.title,
|
title: link1.title,
|
||||||
description: link1.description,
|
description: link1.description,
|
||||||
path: expect.stringContaining(link1.path!),
|
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 () => {
|
test('should return with an empty list if there are no extensions registered for a placement yet', async () => {
|
||||||
const registries = await createRegistries([
|
const registries = await createRegistries([
|
||||||
{ pluginId, extensionConfigs: [link1, link2], addedComponentConfigs: [] },
|
{ pluginId, addedLinkConfigs: [link1, link2], addedComponentConfigs: [] },
|
||||||
]);
|
]);
|
||||||
const { extensions } = getPluginExtensions({
|
const { extensions } = getPluginExtensions({
|
||||||
...registries,
|
...registries,
|
||||||
@ -177,7 +169,7 @@ describe('getPluginExtensions()', () => {
|
|||||||
|
|
||||||
test('should pass the context to the configure() function', async () => {
|
test('should pass the context to the configure() function', async () => {
|
||||||
const context = { title: 'New title from the context!' };
|
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 });
|
getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 });
|
||||||
|
|
||||||
@ -194,7 +186,7 @@ describe('getPluginExtensions()', () => {
|
|||||||
category: 'Machine Learning',
|
category: 'Machine Learning',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]);
|
const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
|
||||||
const { extensions } = getPluginExtensions({
|
const { extensions } = getPluginExtensions({
|
||||||
...registries,
|
...registries,
|
||||||
extensionPointId: extensionPoint2,
|
extensionPointId: extensionPoint2,
|
||||||
@ -220,7 +212,7 @@ describe('getPluginExtensions()', () => {
|
|||||||
category: 'Machine Learning',
|
category: 'Machine Learning',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]);
|
const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
|
||||||
const { extensions } = getPluginExtensions({
|
const { extensions } = getPluginExtensions({
|
||||||
...registries,
|
...registries,
|
||||||
extensionPointId: extensionPoint2,
|
extensionPointId: extensionPoint2,
|
||||||
@ -231,7 +223,7 @@ describe('getPluginExtensions()', () => {
|
|||||||
|
|
||||||
expect(link2.configure).toHaveBeenCalledTimes(1);
|
expect(link2.configure).toHaveBeenCalledTimes(1);
|
||||||
expect(extension.path).toBe(
|
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',
|
title: 'test',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]);
|
const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
|
||||||
const { extensions } = getPluginExtensions({
|
const { extensions } = getPluginExtensions({
|
||||||
...registries,
|
...registries,
|
||||||
extensionPointId: extensionPoint2,
|
extensionPointId: extensionPoint2,
|
||||||
@ -265,7 +257,7 @@ describe('getPluginExtensions()', () => {
|
|||||||
});
|
});
|
||||||
test('should pass a read only context to the configure() function', async () => {
|
test('should pass a read only context to the configure() function', async () => {
|
||||||
const context = { title: 'New title from the context!' };
|
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({
|
const { extensions } = getPluginExtensions({
|
||||||
...registries,
|
...registries,
|
||||||
context,
|
context,
|
||||||
@ -289,7 +281,7 @@ describe('getPluginExtensions()', () => {
|
|||||||
throw new Error('Something went wrong!');
|
throw new Error('Something went wrong!');
|
||||||
});
|
});
|
||||||
|
|
||||||
const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]);
|
const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
|
getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
|
||||||
@ -309,7 +301,7 @@ describe('getPluginExtensions()', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const registries = await createRegistries([
|
const registries = await createRegistries([
|
||||||
{ pluginId, extensionConfigs: [link1, link2], addedComponentConfigs: [] },
|
{ pluginId, addedLinkConfigs: [link1, link2], addedComponentConfigs: [] },
|
||||||
]);
|
]);
|
||||||
const { extensions: extensionsAtPlacement1 } = getPluginExtensions({
|
const { extensions: extensionsAtPlacement1 } = getPluginExtensions({
|
||||||
...registries,
|
...registries,
|
||||||
@ -336,7 +328,7 @@ describe('getPluginExtensions()', () => {
|
|||||||
|
|
||||||
link2.configure = jest.fn().mockImplementation(() => overrides);
|
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 });
|
const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
|
||||||
|
|
||||||
expect(extensions).toHaveLength(0);
|
expect(extensions).toHaveLength(0);
|
||||||
@ -347,7 +339,7 @@ describe('getPluginExtensions()', () => {
|
|||||||
test('should skip the extension if the configure() function returns a promise', async () => {
|
test('should skip the extension if the configure() function returns a promise', async () => {
|
||||||
link2.configure = jest.fn().mockImplementation(() => Promise.resolve({}));
|
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 });
|
const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
|
||||||
|
|
||||||
expect(extensions).toHaveLength(0);
|
expect(extensions).toHaveLength(0);
|
||||||
@ -358,7 +350,7 @@ describe('getPluginExtensions()', () => {
|
|||||||
test('should skip (hide) the extension if the configure() function returns undefined', async () => {
|
test('should skip (hide) the extension if the configure() function returns undefined', async () => {
|
||||||
link2.configure = jest.fn().mockImplementation(() => undefined);
|
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 });
|
const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
|
||||||
|
|
||||||
expect(extensions).toHaveLength(0);
|
expect(extensions).toHaveLength(0);
|
||||||
@ -372,7 +364,7 @@ describe('getPluginExtensions()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const context = {};
|
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 { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
|
||||||
const [extension] = extensions;
|
const [extension] = extensions;
|
||||||
|
|
||||||
@ -395,7 +387,7 @@ describe('getPluginExtensions()', () => {
|
|||||||
link2.path = undefined;
|
link2.path = undefined;
|
||||||
link2.onClick = jest.fn().mockRejectedValue(new Error('testing'));
|
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 { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
|
||||||
const [extension] = extensions;
|
const [extension] = extensions;
|
||||||
|
|
||||||
@ -414,7 +406,7 @@ describe('getPluginExtensions()', () => {
|
|||||||
throw new Error('Something went wrong!');
|
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 { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
|
||||||
const [extension] = extensions;
|
const [extension] = extensions;
|
||||||
|
|
||||||
@ -432,7 +424,7 @@ describe('getPluginExtensions()', () => {
|
|||||||
link2.path = undefined;
|
link2.path = undefined;
|
||||||
link2.onClick = jest.fn();
|
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 { extensions } = getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 });
|
||||||
const [extension] = extensions;
|
const [extension] = extensions;
|
||||||
|
|
||||||
@ -455,7 +447,7 @@ describe('getPluginExtensions()', () => {
|
|||||||
array: ['a'],
|
array: ['a'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]);
|
const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
|
||||||
getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 });
|
getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 });
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
@ -471,7 +463,7 @@ describe('getPluginExtensions()', () => {
|
|||||||
const registries = await createRegistries([
|
const registries = await createRegistries([
|
||||||
{
|
{
|
||||||
pluginId,
|
pluginId,
|
||||||
extensionConfigs: [
|
addedLinkConfigs: [
|
||||||
{
|
{
|
||||||
...link1,
|
...link1,
|
||||||
path: undefined,
|
path: undefined,
|
||||||
@ -501,22 +493,19 @@ describe('getPluginExtensions()', () => {
|
|||||||
const registries = await createRegistries([
|
const registries = await createRegistries([
|
||||||
{
|
{
|
||||||
pluginId,
|
pluginId,
|
||||||
extensionConfigs: [],
|
addedLinkConfigs: [],
|
||||||
addedComponentConfigs: [
|
addedComponentConfigs: [component1],
|
||||||
{
|
|
||||||
...component1,
|
|
||||||
targets: component1.extensionPointId,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
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).toHaveLength(1);
|
||||||
expect(extensions[0]).toEqual(
|
expect(extensions[0]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
pluginId,
|
pluginId,
|
||||||
type: PluginExtensionTypes.component,
|
|
||||||
title: component1.title,
|
title: component1.title,
|
||||||
description: component1.description,
|
description: component1.description,
|
||||||
})
|
})
|
||||||
@ -527,16 +516,13 @@ describe('getPluginExtensions()', () => {
|
|||||||
const registries = await createRegistries([
|
const registries = await createRegistries([
|
||||||
{
|
{
|
||||||
pluginId,
|
pluginId,
|
||||||
extensionConfigs: [],
|
addedLinkConfigs: [],
|
||||||
addedComponentConfigs: [
|
addedComponentConfigs: [
|
||||||
{
|
component1,
|
||||||
...component1,
|
|
||||||
targets: component1.extensionPointId,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: 'Component 2',
|
title: 'Component 2',
|
||||||
description: 'Component 2 description',
|
description: 'Component 2 description',
|
||||||
targets: component1.extensionPointId,
|
targets: component1.targets,
|
||||||
component: (context) => {
|
component: (context) => {
|
||||||
return <div>Hello world2!</div>;
|
return <div>Hello world2!</div>;
|
||||||
},
|
},
|
||||||
@ -547,14 +533,13 @@ describe('getPluginExtensions()', () => {
|
|||||||
const { extensions } = getPluginExtensions({
|
const { extensions } = getPluginExtensions({
|
||||||
...registries,
|
...registries,
|
||||||
limitPerPlugin: 1,
|
limitPerPlugin: 1,
|
||||||
extensionPointId: component1.extensionPointId,
|
extensionPointId: Array.isArray(component1.targets) ? component1.targets[0] : component1.targets,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(extensions).toHaveLength(1);
|
expect(extensions).toHaveLength(1);
|
||||||
expect(extensions[0]).toEqual(
|
expect(extensions[0]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
pluginId,
|
pluginId,
|
||||||
type: PluginExtensionTypes.component,
|
|
||||||
title: component1.title,
|
title: component1.title,
|
||||||
description: component1.description,
|
description: component1.description,
|
||||||
})
|
})
|
||||||
|
@ -4,58 +4,53 @@ import {
|
|||||||
type PluginExtension,
|
type PluginExtension,
|
||||||
PluginExtensionTypes,
|
PluginExtensionTypes,
|
||||||
type PluginExtensionLink,
|
type PluginExtensionLink,
|
||||||
type PluginExtensionLinkConfig,
|
|
||||||
type PluginExtensionComponent,
|
type PluginExtensionComponent,
|
||||||
urlUtil,
|
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { GetPluginExtensions, reportInteraction } from '@grafana/runtime';
|
import { GetPluginExtensions } from '@grafana/runtime';
|
||||||
|
|
||||||
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
|
import { AddedComponentRegistryItem } from './registry/AddedComponentsRegistry';
|
||||||
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
|
import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry';
|
||||||
import type { AddedComponentsRegistryState, PluginExtensionRegistry } from './types';
|
import { RegistryType } from './registry/Registry';
|
||||||
|
import type { PluginExtensionRegistries } from './registry/types';
|
||||||
import {
|
import {
|
||||||
isPluginExtensionLinkConfig,
|
|
||||||
getReadOnlyProxy,
|
getReadOnlyProxy,
|
||||||
logWarning,
|
logWarning,
|
||||||
generateExtensionId,
|
generateExtensionId,
|
||||||
getEventHelpers,
|
|
||||||
wrapWithPluginContext,
|
wrapWithPluginContext,
|
||||||
|
getLinkExtensionOnClick,
|
||||||
|
getLinkExtensionOverrides,
|
||||||
|
getLinkExtensionPathWithTracking,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import { assertIsNotPromise, assertLinkPathIsValid, assertStringProps, isPromise } from './validators';
|
|
||||||
|
|
||||||
type GetExtensions = ({
|
type GetExtensions = ({
|
||||||
context,
|
context,
|
||||||
extensionPointId,
|
extensionPointId,
|
||||||
limitPerPlugin,
|
limitPerPlugin,
|
||||||
registry,
|
addedLinksRegistry,
|
||||||
addedComponentsRegistry,
|
addedComponentsRegistry,
|
||||||
}: {
|
}: {
|
||||||
context?: object | Record<string | symbol, unknown>;
|
context?: object | Record<string | symbol, unknown>;
|
||||||
extensionPointId: string;
|
extensionPointId: string;
|
||||||
limitPerPlugin?: number;
|
limitPerPlugin?: number;
|
||||||
registry: PluginExtensionRegistry;
|
addedComponentsRegistry: RegistryType<AddedComponentRegistryItem[]> | undefined;
|
||||||
addedComponentsRegistry: AddedComponentsRegistryState;
|
addedLinksRegistry: RegistryType<AddedLinkRegistryItem[]> | undefined;
|
||||||
}) => { extensions: PluginExtension[] };
|
}) => { extensions: PluginExtension[] };
|
||||||
|
|
||||||
export function createPluginExtensionsGetter(
|
export function createPluginExtensionsGetter(registries: PluginExtensionRegistries): GetPluginExtensions {
|
||||||
extensionRegistry: ReactivePluginExtensionsRegistry,
|
let addedComponentsRegistry: RegistryType<AddedComponentRegistryItem[]>;
|
||||||
addedComponentRegistry: AddedComponentsRegistry
|
let addedLinksRegistry: RegistryType<Array<AddedLinkRegistryItem<object>>>;
|
||||||
): GetPluginExtensions {
|
|
||||||
let registry: PluginExtensionRegistry = { id: '', extensions: {} };
|
|
||||||
let addedComponentsRegistryState: AddedComponentsRegistryState = {};
|
|
||||||
|
|
||||||
// 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.
|
// plugin extensions getter.
|
||||||
extensionRegistry.asObservable().subscribe((r) => {
|
registries.addedComponentsRegistry.asObservable().subscribe((componentsRegistry) => {
|
||||||
registry = r;
|
addedComponentsRegistry = componentsRegistry;
|
||||||
});
|
});
|
||||||
|
|
||||||
addedComponentRegistry.asObservable().subscribe((r) => {
|
registries.addedLinksRegistry.asObservable().subscribe((linksRegistry) => {
|
||||||
addedComponentsRegistryState = r;
|
addedLinksRegistry = linksRegistry;
|
||||||
});
|
});
|
||||||
|
|
||||||
return (options) =>
|
return (options) => getPluginExtensions({ ...options, addedComponentsRegistry, addedLinksRegistry });
|
||||||
getPluginExtensions({ ...options, registry, addedComponentsRegistry: addedComponentsRegistryState });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns with a list of plugin extensions for the given extension point
|
// Returns with a list of plugin extensions for the given extension point
|
||||||
@ -63,20 +58,17 @@ export const getPluginExtensions: GetExtensions = ({
|
|||||||
context,
|
context,
|
||||||
extensionPointId,
|
extensionPointId,
|
||||||
limitPerPlugin,
|
limitPerPlugin,
|
||||||
registry,
|
addedLinksRegistry,
|
||||||
addedComponentsRegistry,
|
addedComponentsRegistry,
|
||||||
}) => {
|
}) => {
|
||||||
const frozenContext = context ? getReadOnlyProxy(context) : {};
|
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.
|
// 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 extensions: PluginExtension[] = [];
|
||||||
const extensionsByPlugin: Record<string, number> = {};
|
const extensionsByPlugin: Record<string, number> = {};
|
||||||
|
|
||||||
for (const registryItem of registryItems) {
|
for (const addedLink of addedLinksRegistry?.[extensionPointId] ?? []) {
|
||||||
try {
|
try {
|
||||||
const extensionConfig = registryItem.config;
|
const { pluginId } = addedLink;
|
||||||
const { pluginId } = registryItem;
|
|
||||||
|
|
||||||
// Only limit if the `limitPerPlugin` is set
|
// Only limit if the `limitPerPlugin` is set
|
||||||
if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) {
|
if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) {
|
||||||
continue;
|
continue;
|
||||||
@ -86,34 +78,35 @@ export const getPluginExtensions: GetExtensions = ({
|
|||||||
extensionsByPlugin[pluginId] = 0;
|
extensionsByPlugin[pluginId] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// LINK
|
// Run the configure() function with the current context, and apply the ovverides
|
||||||
if (isPluginExtensionLinkConfig(extensionConfig)) {
|
const overrides = getLinkExtensionOverrides(pluginId, addedLink, frozenContext);
|
||||||
// Run the configure() function with the current context, and apply the ovverides
|
|
||||||
const overrides = getLinkExtensionOverrides(pluginId, extensionConfig, frozenContext);
|
|
||||||
|
|
||||||
// configure() returned an `undefined` -> hide the extension
|
// configure() returned an `undefined` -> hide the extension
|
||||||
if (extensionConfig.configure && overrides === undefined) {
|
if (addedLink.configure && overrides === undefined) {
|
||||||
continue;
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
logWarning(error.message);
|
logWarning(error.message);
|
||||||
@ -121,139 +114,32 @@ export const getPluginExtensions: GetExtensions = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (extensionPointId in addedComponentsRegistry) {
|
const addedComponents = addedComponentsRegistry?.[extensionPointId] ?? [];
|
||||||
try {
|
for (const addedComponent of addedComponents) {
|
||||||
const addedComponents = addedComponentsRegistry[extensionPointId];
|
// Only limit if the `limitPerPlugin` is set
|
||||||
for (const addedComponent of addedComponents) {
|
if (limitPerPlugin && extensionsByPlugin[addedComponent.pluginId] >= limitPerPlugin) {
|
||||||
// Only limit if the `limitPerPlugin` is set
|
continue;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 };
|
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);
|
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();
|
const registry = new AddedComponentsRegistry();
|
||||||
registry.register({
|
registry.register({
|
||||||
pluginId: 'grafana-basic-app',
|
pluginId: 'grafana-basic-app',
|
||||||
@ -322,14 +322,14 @@ describe('AddedComponentsRegistry', () => {
|
|||||||
{
|
{
|
||||||
title: 'Component 1 title',
|
title: 'Component 1 title',
|
||||||
description: 'Component 1 description',
|
description: 'Component 1 description',
|
||||||
targets: ['grafana/alerting/home'],
|
targets: ['grafana/test/home'],
|
||||||
component: () => React.createElement('div', null, 'Hello World1'),
|
component: () => React.createElement('div', null, 'Hello World1'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(consoleWarn).toHaveBeenCalledWith(
|
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();
|
const currentState = await registry.getState();
|
||||||
expect(Object.keys(currentState)).toHaveLength(1);
|
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 { logWarning, wrapWithPluginContext } from '../utils';
|
||||||
import { extensionPointEndsWithVersion, isExtensionPointIdValid, isReactComponent } from '../validators';
|
import {
|
||||||
|
extensionPointEndsWithVersion,
|
||||||
|
isExtensionPointIdValid,
|
||||||
|
isGrafanaCoreExtensionPoint,
|
||||||
|
isReactComponent,
|
||||||
|
} from '../validators';
|
||||||
|
|
||||||
import { PluginExtensionConfigs, Registry, RegistryType } from './Registry';
|
import { PluginExtensionConfigs, Registry, RegistryType } from './Registry';
|
||||||
|
|
||||||
@ -12,7 +17,10 @@ export type AddedComponentRegistryItem<Props = {}> = {
|
|||||||
component: React.ComponentType<Props>;
|
component: React.ComponentType<Props>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class AddedComponentsRegistry extends Registry<AddedComponentRegistryItem[], PluginAddedComponentConfig> {
|
export class AddedComponentsRegistry extends Registry<
|
||||||
|
AddedComponentRegistryItem[],
|
||||||
|
PluginExtensionAddedComponentConfig
|
||||||
|
> {
|
||||||
constructor(initialState: RegistryType<AddedComponentRegistryItem[]> = {}) {
|
constructor(initialState: RegistryType<AddedComponentRegistryItem[]> = {}) {
|
||||||
super({
|
super({
|
||||||
initialState,
|
initialState,
|
||||||
@ -21,7 +29,7 @@ export class AddedComponentsRegistry extends Registry<AddedComponentRegistryItem
|
|||||||
|
|
||||||
mapToRegistry(
|
mapToRegistry(
|
||||||
registry: RegistryType<AddedComponentRegistryItem[]>,
|
registry: RegistryType<AddedComponentRegistryItem[]>,
|
||||||
item: PluginExtensionConfigs<PluginAddedComponentConfig>
|
item: PluginExtensionConfigs<PluginExtensionAddedComponentConfig>
|
||||||
): RegistryType<AddedComponentRegistryItem[]> {
|
): RegistryType<AddedComponentRegistryItem[]> {
|
||||||
const { pluginId, configs } = item;
|
const { pluginId, configs } = item;
|
||||||
|
|
||||||
@ -52,7 +60,7 @@ export class AddedComponentsRegistry extends Registry<AddedComponentRegistryItem
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!extensionPointEndsWithVersion(extensionPointId)) {
|
if (!isGrafanaCoreExtensionPoint(extensionPointId) && !extensionPointEndsWithVersion(extensionPointId)) {
|
||||||
logWarning(
|
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'.`
|
`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 { logWarning } from '../utils';
|
||||||
import { extensionPointEndsWithVersion } from '../validators';
|
import { extensionPointEndsWithVersion } from '../validators';
|
||||||
@ -12,7 +12,10 @@ export type ExposedComponentRegistryItem<Props = {}> = {
|
|||||||
component: React.ComponentType<Props>;
|
component: React.ComponentType<Props>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ExposedComponentsRegistry extends Registry<ExposedComponentRegistryItem, PluginExposedComponentConfig> {
|
export class ExposedComponentsRegistry extends Registry<
|
||||||
|
ExposedComponentRegistryItem,
|
||||||
|
PluginExtensionExposedComponentConfig
|
||||||
|
> {
|
||||||
constructor(initialState: RegistryType<ExposedComponentRegistryItem> = {}) {
|
constructor(initialState: RegistryType<ExposedComponentRegistryItem> = {}) {
|
||||||
super({
|
super({
|
||||||
initialState,
|
initialState,
|
||||||
@ -21,7 +24,7 @@ export class ExposedComponentsRegistry extends Registry<ExposedComponentRegistry
|
|||||||
|
|
||||||
mapToRegistry(
|
mapToRegistry(
|
||||||
registry: RegistryType<ExposedComponentRegistryItem>,
|
registry: RegistryType<ExposedComponentRegistryItem>,
|
||||||
{ pluginId, configs }: PluginExtensionConfigs<PluginExposedComponentConfig>
|
{ pluginId, configs }: PluginExtensionConfigs<PluginExtensionExposedComponentConfig>
|
||||||
): RegistryType<ExposedComponentRegistryItem> {
|
): RegistryType<ExposedComponentRegistryItem> {
|
||||||
if (!configs) {
|
if (!configs) {
|
||||||
return registry;
|
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);
|
const registry = useObservable(observableRegistry);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (!registry || !registry[id]) {
|
if (!registry?.[id]) {
|
||||||
return {
|
return {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
component: null,
|
component: null,
|
||||||
|
@ -19,16 +19,10 @@ export function createUsePluginComponents(registry: AddedComponentsRegistry) {
|
|||||||
const registry = useObservable(observableRegistry);
|
const registry = useObservable(observableRegistry);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (!registry || !registry[extensionPointId]) {
|
|
||||||
return {
|
|
||||||
isLoading: false,
|
|
||||||
components: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const components: Array<React.ComponentType<Props>> = [];
|
const components: Array<React.ComponentType<Props>> = [];
|
||||||
const registryItems = registry[extensionPointId];
|
|
||||||
const extensionsByPlugin: Record<string, number> = {};
|
const extensionsByPlugin: Record<string, number> = {};
|
||||||
for (const registryItem of registryItems) {
|
|
||||||
|
for (const registryItem of registry?.[extensionPointId] ?? []) {
|
||||||
const { pluginId } = registryItem;
|
const { pluginId } = registryItem;
|
||||||
|
|
||||||
// Only limit if the `limitPerPlugin` is set
|
// Only limit if the `limitPerPlugin` is set
|
||||||
|
@ -1,26 +1,28 @@
|
|||||||
import { act } from '@testing-library/react';
|
import { act } from '@testing-library/react';
|
||||||
import { renderHook } from '@testing-library/react-hooks';
|
import { renderHook } from '@testing-library/react-hooks';
|
||||||
|
|
||||||
import { PluginExtensionTypes } from '@grafana/data';
|
|
||||||
|
|
||||||
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
|
|
||||||
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
|
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
|
||||||
|
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
|
||||||
|
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
|
||||||
|
import { PluginExtensionRegistries } from './registry/types';
|
||||||
import { createUsePluginExtensions } from './usePluginExtensions';
|
import { createUsePluginExtensions } from './usePluginExtensions';
|
||||||
|
|
||||||
describe('usePluginExtensions()', () => {
|
describe('usePluginExtensions()', () => {
|
||||||
let reactiveRegistry: ReactivePluginExtensionsRegistry;
|
let registries: PluginExtensionRegistries;
|
||||||
let addedComponentsRegistry: AddedComponentsRegistry;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
reactiveRegistry = new ReactivePluginExtensionsRegistry();
|
registries = {
|
||||||
addedComponentsRegistry = new AddedComponentsRegistry();
|
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', () => {
|
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(() =>
|
const { result } = renderHook(() =>
|
||||||
usePluginExtensions({
|
usePluginExtensions({
|
||||||
extensionPointId: 'foo/bar',
|
extensionPointId: 'foo/bar/v1',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -28,32 +30,28 @@ describe('usePluginExtensions()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return the plugin link extensions from the registry', () => {
|
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';
|
const pluginId = 'my-app-plugin';
|
||||||
|
|
||||||
reactiveRegistry.register({
|
registries.addedLinksRegistry.register({
|
||||||
pluginId,
|
pluginId,
|
||||||
extensionConfigs: [
|
configs: [
|
||||||
{
|
{
|
||||||
type: PluginExtensionTypes.link,
|
targets: extensionPointId,
|
||||||
extensionPointId,
|
|
||||||
title: '1',
|
title: '1',
|
||||||
description: '1',
|
description: '1',
|
||||||
path: `/a/${pluginId}/2`,
|
path: `/a/${pluginId}/2`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: PluginExtensionTypes.link,
|
targets: extensionPointId,
|
||||||
extensionPointId,
|
|
||||||
title: '2',
|
title: '2',
|
||||||
description: '2',
|
description: '2',
|
||||||
path: `/a/${pluginId}/2`,
|
path: `/a/${pluginId}/2`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exposedComponentConfigs: [],
|
|
||||||
addedComponentConfigs: [],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry);
|
const usePluginExtensions = createUsePluginExtensions(registries);
|
||||||
const { result } = renderHook(() => usePluginExtensions({ extensionPointId }));
|
const { result } = renderHook(() => usePluginExtensions({ extensionPointId }));
|
||||||
|
|
||||||
expect(result.current.extensions.length).toBe(2);
|
expect(result.current.extensions.length).toBe(2);
|
||||||
@ -62,33 +60,29 @@ describe('usePluginExtensions()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return the plugin component extensions from the registry', () => {
|
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 componentExtensionPointId = 'plugins/component/bar/v1';
|
||||||
const pluginId = 'my-app-plugin';
|
const pluginId = 'my-app-plugin';
|
||||||
|
|
||||||
reactiveRegistry.register({
|
registries.addedLinksRegistry.register({
|
||||||
pluginId,
|
pluginId,
|
||||||
extensionConfigs: [
|
configs: [
|
||||||
{
|
{
|
||||||
type: PluginExtensionTypes.link,
|
targets: linkExtensionPointId,
|
||||||
extensionPointId: linkExtensionPointId,
|
|
||||||
title: '1',
|
title: '1',
|
||||||
description: '1',
|
description: '1',
|
||||||
path: `/a/${pluginId}/2`,
|
path: `/a/${pluginId}/2`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: PluginExtensionTypes.link,
|
targets: linkExtensionPointId,
|
||||||
extensionPointId: linkExtensionPointId,
|
|
||||||
title: '2',
|
title: '2',
|
||||||
description: '2',
|
description: '2',
|
||||||
path: `/a/${pluginId}/2`,
|
path: `/a/${pluginId}/2`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exposedComponentConfigs: [],
|
|
||||||
addedComponentConfigs: [],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
addedComponentsRegistry.register({
|
registries.addedComponentsRegistry.register({
|
||||||
pluginId,
|
pluginId,
|
||||||
configs: [
|
configs: [
|
||||||
{
|
{
|
||||||
@ -106,7 +100,7 @@ describe('usePluginExtensions()', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry);
|
const usePluginExtensions = createUsePluginExtensions(registries);
|
||||||
const { result } = renderHook(() => usePluginExtensions({ extensionPointId: componentExtensionPointId }));
|
const { result } = renderHook(() => usePluginExtensions({ extensionPointId: componentExtensionPointId }));
|
||||||
|
|
||||||
expect(result.current.extensions.length).toBe(2);
|
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', () => {
|
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 pluginId = 'my-app-plugin';
|
||||||
const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry);
|
const usePluginExtensions = createUsePluginExtensions(registries);
|
||||||
let { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId }));
|
let { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId }));
|
||||||
|
|
||||||
// No extensions yet
|
// No extensions yet
|
||||||
@ -125,26 +119,22 @@ describe('usePluginExtensions()', () => {
|
|||||||
|
|
||||||
// Add extensions to the registry
|
// Add extensions to the registry
|
||||||
act(() => {
|
act(() => {
|
||||||
reactiveRegistry.register({
|
registries.addedLinksRegistry.register({
|
||||||
pluginId,
|
pluginId,
|
||||||
extensionConfigs: [
|
configs: [
|
||||||
{
|
{
|
||||||
type: PluginExtensionTypes.link,
|
targets: extensionPointId,
|
||||||
extensionPointId,
|
|
||||||
title: '1',
|
title: '1',
|
||||||
description: '1',
|
description: '1',
|
||||||
path: `/a/${pluginId}/2`,
|
path: `/a/${pluginId}/2`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: PluginExtensionTypes.link,
|
targets: extensionPointId,
|
||||||
extensionPointId,
|
|
||||||
title: '2',
|
title: '2',
|
||||||
description: '2',
|
description: '2',
|
||||||
path: `/a/${pluginId}/2`,
|
path: `/a/${pluginId}/2`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exposedComponentConfigs: [],
|
|
||||||
addedComponentConfigs: [],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -157,78 +147,81 @@ describe('usePluginExtensions()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should only render the hook once', () => {
|
it('should only render the hook once', () => {
|
||||||
const spy = jest.spyOn(reactiveRegistry, 'asObservable');
|
const addedComponentsRegistrySpy = jest.spyOn(registries.addedComponentsRegistry, 'asObservable');
|
||||||
const extensionPointId = 'plugins/foo/bar';
|
const addedLinksRegistrySpy = jest.spyOn(registries.addedLinksRegistry, 'asObservable');
|
||||||
const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry);
|
const extensionPointId = 'plugins/foo/bar/v1';
|
||||||
|
const usePluginExtensions = createUsePluginExtensions(registries);
|
||||||
|
|
||||||
renderHook(() => usePluginExtensions({ extensionPointId }));
|
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', () => {
|
it('should return the same extensions object if the context object is the same', async () => {
|
||||||
const extensionPointId = 'plugins/foo/bar';
|
const extensionPointId = 'plugins/foo/bar/v1';
|
||||||
const pluginId = 'my-app-plugin';
|
const pluginId = 'my-app-plugin';
|
||||||
const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry);
|
const usePluginExtensions = createUsePluginExtensions(registries);
|
||||||
|
|
||||||
// Add extensions to the registry
|
// Add extensions to the registry
|
||||||
act(() => {
|
act(() => {
|
||||||
reactiveRegistry.register({
|
registries.addedLinksRegistry.register({
|
||||||
pluginId,
|
pluginId,
|
||||||
extensionConfigs: [
|
configs: [
|
||||||
{
|
{
|
||||||
type: PluginExtensionTypes.link,
|
targets: extensionPointId,
|
||||||
extensionPointId,
|
|
||||||
title: '1',
|
title: '1',
|
||||||
description: '1',
|
description: '1',
|
||||||
path: `/a/${pluginId}/2`,
|
path: `/a/${pluginId}/2`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: PluginExtensionTypes.link,
|
targets: extensionPointId,
|
||||||
extensionPointId,
|
|
||||||
title: '2',
|
title: '2',
|
||||||
description: '2',
|
description: '2',
|
||||||
path: `/a/${pluginId}/2`,
|
path: `/a/${pluginId}/2`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exposedComponentConfigs: [],
|
|
||||||
addedComponentConfigs: [],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if it returns the same extensions object in case nothing changes
|
// Check if it returns the same extensions object in case nothing changes
|
||||||
const context = {};
|
const context = {};
|
||||||
const firstResults = renderHook(() => usePluginExtensions({ extensionPointId, context }));
|
const { rerender, result } = renderHook(usePluginExtensions, {
|
||||||
const secondResults = renderHook(() => usePluginExtensions({ extensionPointId, context }));
|
initialProps: {
|
||||||
expect(firstResults.result.current.extensions === secondResults.result.current.extensions).toBe(true);
|
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', () => {
|
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 pluginId = 'my-app-plugin';
|
||||||
const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry);
|
const usePluginExtensions = createUsePluginExtensions(registries);
|
||||||
|
|
||||||
// Add extensions to the registry
|
// Add extensions to the registry
|
||||||
act(() => {
|
act(() => {
|
||||||
reactiveRegistry.register({
|
registries.addedLinksRegistry.register({
|
||||||
pluginId,
|
pluginId,
|
||||||
extensionConfigs: [
|
configs: [
|
||||||
{
|
{
|
||||||
type: PluginExtensionTypes.link,
|
targets: extensionPointId,
|
||||||
extensionPointId,
|
|
||||||
title: '1',
|
title: '1',
|
||||||
description: '1',
|
description: '1',
|
||||||
path: `/a/${pluginId}/2`,
|
path: `/a/${pluginId}/2`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: PluginExtensionTypes.link,
|
targets: extensionPointId,
|
||||||
extensionPointId,
|
|
||||||
title: '2',
|
title: '2',
|
||||||
description: '2',
|
description: '2',
|
||||||
path: `/a/${pluginId}/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', () => {
|
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 pluginId = 'my-app-plugin';
|
||||||
const context = {};
|
const context = {};
|
||||||
const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry);
|
const usePluginExtensions = createUsePluginExtensions(registries);
|
||||||
|
|
||||||
// Add the first extension
|
// Add the first extension
|
||||||
act(() => {
|
act(() => {
|
||||||
reactiveRegistry.register({
|
registries.addedLinksRegistry.register({
|
||||||
pluginId,
|
pluginId,
|
||||||
extensionConfigs: [
|
configs: [
|
||||||
{
|
{
|
||||||
type: PluginExtensionTypes.link,
|
targets: extensionPointId,
|
||||||
extensionPointId,
|
|
||||||
title: '1',
|
title: '1',
|
||||||
description: '1',
|
description: '1',
|
||||||
path: `/a/${pluginId}/2`,
|
path: `/a/${pluginId}/2`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exposedComponentConfigs: [],
|
|
||||||
addedComponentConfigs: [],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -267,20 +257,17 @@ describe('usePluginExtensions()', () => {
|
|||||||
|
|
||||||
// Add the second extension
|
// Add the second extension
|
||||||
act(() => {
|
act(() => {
|
||||||
reactiveRegistry.register({
|
registries.addedLinksRegistry.register({
|
||||||
pluginId,
|
pluginId,
|
||||||
extensionConfigs: [
|
configs: [
|
||||||
{
|
{
|
||||||
type: PluginExtensionTypes.link,
|
targets: extensionPointId,
|
||||||
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)
|
// 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',
|
title: '2',
|
||||||
description: '2',
|
description: '2',
|
||||||
path: `/a/${pluginId}/2`,
|
path: `/a/${pluginId}/2`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exposedComponentConfigs: [],
|
|
||||||
addedComponentConfigs: [],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,64 +1,40 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { useObservable } from 'react-use';
|
import { useObservable } from 'react-use';
|
||||||
|
|
||||||
import { PluginExtension } from '@grafana/data';
|
import { PluginExtension } from '@grafana/data';
|
||||||
import { GetPluginExtensionsOptions, UsePluginExtensionsResult } from '@grafana/runtime';
|
import { GetPluginExtensionsOptions, UsePluginExtensionsResult } from '@grafana/runtime';
|
||||||
|
|
||||||
import { getPluginExtensions } from './getPluginExtensions';
|
import { getPluginExtensions } from './getPluginExtensions';
|
||||||
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
|
import { PluginExtensionRegistries } from './registry/types';
|
||||||
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
|
|
||||||
|
|
||||||
export function createUsePluginExtensions(
|
export function createUsePluginExtensions(registries: PluginExtensionRegistries) {
|
||||||
extensionsRegistry: ReactivePluginExtensionsRegistry,
|
const observableAddedComponentsRegistry = registries.addedComponentsRegistry.asObservable();
|
||||||
addedComponentsRegistry: AddedComponentsRegistry
|
const observableAddedLinksRegistry = registries.addedLinksRegistry.asObservable();
|
||||||
) {
|
|
||||||
const observableRegistry = extensionsRegistry.asObservable();
|
|
||||||
const observableAddedComponentRegistry = addedComponentsRegistry.asObservable();
|
|
||||||
const cache: {
|
|
||||||
id: string;
|
|
||||||
extensions: Record<string, { context: GetPluginExtensionsOptions['context']; extensions: PluginExtension[] }>;
|
|
||||||
} = {
|
|
||||||
id: '',
|
|
||||||
extensions: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
return function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult<PluginExtension> {
|
return function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult<PluginExtension> {
|
||||||
const registry = useObservable(observableRegistry);
|
const addedComponentsRegistry = useObservable(observableAddedComponentsRegistry);
|
||||||
const addedComponentsRegistry = useObservable(observableAddedComponentRegistry);
|
const addedLinksRegistry = useObservable(observableAddedLinksRegistry);
|
||||||
|
|
||||||
if (!registry || !addedComponentsRegistry) {
|
const { extensions } = useMemo(() => {
|
||||||
return { extensions: [], isLoading: false };
|
if (!addedLinksRegistry && !addedComponentsRegistry) {
|
||||||
}
|
return { extensions: [], isLoading: false };
|
||||||
|
}
|
||||||
|
|
||||||
if (registry.id !== cache.id) {
|
return getPluginExtensions({
|
||||||
cache.id = registry.id;
|
extensionPointId: options.extensionPointId,
|
||||||
cache.extensions = {};
|
context: options.context,
|
||||||
}
|
limitPerPlugin: options.limitPerPlugin,
|
||||||
|
addedComponentsRegistry,
|
||||||
// `getPluginExtensions` will return a new array of objects even if it is called with the same options, as it always constructing a frozen objects.
|
addedLinksRegistry,
|
||||||
// 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}`;
|
addedLinksRegistry,
|
||||||
if (cache.extensions[key] && cache.extensions[key].context === options.context) {
|
|
||||||
return {
|
|
||||||
extensions: cache.extensions[key].extensions,
|
|
||||||
isLoading: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const { extensions } = getPluginExtensions({
|
|
||||||
...options,
|
|
||||||
registry,
|
|
||||||
addedComponentsRegistry,
|
addedComponentsRegistry,
|
||||||
});
|
options.extensionPointId,
|
||||||
|
options.context,
|
||||||
|
options.limitPerPlugin,
|
||||||
|
]);
|
||||||
|
|
||||||
cache.extensions[key] = {
|
return { extensions, isLoading: false };
|
||||||
context: options.context,
|
|
||||||
extensions,
|
|
||||||
};
|
|
||||||
|
|
||||||
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 { render, screen } from '@testing-library/react';
|
||||||
import { type Unsubscribable } from 'rxjs';
|
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 appEvents from 'app/core/app_events';
|
||||||
import { ShowModalReactEvent } from 'app/types/events';
|
import { ShowModalReactEvent } from 'app/types/events';
|
||||||
|
|
||||||
import {
|
import { deepFreeze, handleErrorsInFn, getReadOnlyProxy, getEventHelpers, wrapWithPluginContext } from './utils';
|
||||||
deepFreeze,
|
|
||||||
isPluginExtensionLinkConfig,
|
|
||||||
handleErrorsInFn,
|
|
||||||
getReadOnlyProxy,
|
|
||||||
getEventHelpers,
|
|
||||||
wrapWithPluginContext,
|
|
||||||
} from './utils';
|
|
||||||
|
|
||||||
jest.mock('app/features/plugins/pluginSettings', () => ({
|
jest.mock('app/features/plugins/pluginSettings', () => ({
|
||||||
...jest.requireActual('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()', () => {
|
describe('handleErrorsInFn()', () => {
|
||||||
test('should catch errors thrown by the provided function and print them as console warnings', () => {
|
test('should catch errors thrown by the provided function and print them as console warnings', () => {
|
||||||
global.console.warn = jest.fn();
|
global.console.warn = jest.fn();
|
||||||
|
@ -14,12 +14,18 @@ import {
|
|||||||
PluginContextProvider,
|
PluginContextProvider,
|
||||||
PluginExtensionLink,
|
PluginExtensionLink,
|
||||||
PanelMenuItem,
|
PanelMenuItem,
|
||||||
|
PluginExtensionAddedLinkConfig,
|
||||||
|
urlUtil,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
|
import { reportInteraction } from '@grafana/runtime';
|
||||||
import { Modal } from '@grafana/ui';
|
import { Modal } from '@grafana/ui';
|
||||||
import appEvents from 'app/core/app_events';
|
import appEvents from 'app/core/app_events';
|
||||||
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
|
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
|
||||||
import { ShowModalReactEvent } from 'app/types/events';
|
import { ShowModalReactEvent } from 'app/types/events';
|
||||||
|
|
||||||
|
import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry';
|
||||||
|
import { assertIsNotPromise, assertLinkPathIsValid, assertStringProps, isPromise } from './validators';
|
||||||
|
|
||||||
export function logWarning(message: string) {
|
export function logWarning(message: string) {
|
||||||
console.warn(`[Plugin Extensions] ${message}`);
|
console.warn(`[Plugin Extensions] ${message}`);
|
||||||
}
|
}
|
||||||
@ -218,11 +224,10 @@ export function isReadOnlyProxy(value: unknown): boolean {
|
|||||||
return isRecord(value) && value[_isProxy] === true;
|
return isRecord(value) && value[_isProxy] === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createExtensionLinkConfig<T extends object>(
|
export function createAddedLinkConfig<T extends object>(
|
||||||
config: Omit<PluginExtensionLinkConfig<T>, 'type'>
|
config: PluginExtensionAddedLinkConfig<T>
|
||||||
): PluginExtensionLinkConfig {
|
): PluginExtensionAddedLinkConfig {
|
||||||
const linkConfig: PluginExtensionLinkConfig<T> = {
|
const linkConfig: PluginExtensionAddedLinkConfig<T> = {
|
||||||
type: PluginExtensionTypes.link,
|
|
||||||
...config,
|
...config,
|
||||||
};
|
};
|
||||||
assertLinkConfig(linkConfig);
|
assertLinkConfig(linkConfig);
|
||||||
@ -230,12 +235,8 @@ export function createExtensionLinkConfig<T extends object>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function assertLinkConfig<T extends object>(
|
function assertLinkConfig<T extends object>(
|
||||||
config: PluginExtensionLinkConfig<T>
|
config: PluginExtensionAddedLinkConfig<T>
|
||||||
): asserts config is PluginExtensionLinkConfig {
|
): asserts config is PluginExtensionAddedLinkConfig {}
|
||||||
if (config.type !== PluginExtensionTypes.link) {
|
|
||||||
throw Error('config is not a extension link');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function truncateTitle(title: string, length: number): string {
|
export function truncateTitle(title: string, length: number): string {
|
||||||
if (title.length < length) {
|
if (title.length < length) {
|
||||||
@ -294,3 +295,103 @@ export function createExtensionSubMenu(extensions: PluginExtensionLink[]): Panel
|
|||||||
|
|
||||||
return subMenu;
|
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 { memo } from 'react';
|
||||||
|
|
||||||
import { PluginExtension, PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data';
|
import { PluginExtensionAddedLinkConfig, PluginExtensionLinkConfig, PluginExtensionPoints } from '@grafana/data';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
assertConfigureIsValid,
|
assertConfigureIsValid,
|
||||||
assertLinkPathIsValid,
|
assertLinkPathIsValid,
|
||||||
assertExtensionPointIdIsValid,
|
|
||||||
assertPluginExtensionLink,
|
|
||||||
assertStringProps,
|
assertStringProps,
|
||||||
isPluginExtensionConfigValid,
|
isGrafanaCoreExtensionPoint,
|
||||||
isReactComponent,
|
isReactComponent,
|
||||||
} from './validators';
|
} from './validators';
|
||||||
|
|
||||||
describe('Plugin Extension 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()', () => {
|
describe('assertLinkPathIsValid()', () => {
|
||||||
it('should not throw an error if the link path is valid', () => {
|
it('should not throw an error if the link path is valid', () => {
|
||||||
expect(() => {
|
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()', () => {
|
describe('assertConfigureIsValid()', () => {
|
||||||
it('should NOT throw an error if the configure() function is missing', () => {
|
it('should NOT throw an error if the configure() function is missing', () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
assertConfigureIsValid({
|
assertConfigureIsValid({
|
||||||
title: 'Title',
|
title: 'Title',
|
||||||
description: 'Description',
|
description: 'Description',
|
||||||
extensionPointId: 'grafana/some-page/extension-point-a',
|
targets: 'grafana/some-page/extension-point-a',
|
||||||
} as PluginExtensionLinkConfig);
|
} as PluginExtensionAddedLinkConfig);
|
||||||
}).not.toThrowError();
|
}).not.toThrowError();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -127,9 +69,9 @@ describe('Plugin Extension Validators', () => {
|
|||||||
assertConfigureIsValid({
|
assertConfigureIsValid({
|
||||||
title: 'Title',
|
title: 'Title',
|
||||||
description: 'Description',
|
description: 'Description',
|
||||||
extensionPointId: 'grafana/some-page/extension-point-a',
|
targets: 'grafana/some-page/extension-point-a',
|
||||||
configure: () => {},
|
configure: () => {},
|
||||||
} as PluginExtensionLinkConfig);
|
} as PluginExtensionAddedLinkConfig);
|
||||||
}).not.toThrowError();
|
}).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()', () => {
|
describe('isReactComponent()', () => {
|
||||||
it('should return TRUE if we pass in a valid React component', () => {
|
it('should return TRUE if we pass in a valid React component', () => {
|
||||||
expect(isReactComponent(() => <div>Some text</div>)).toBe(true);
|
expect(isReactComponent(() => <div>Some text</div>)).toBe(true);
|
||||||
@ -292,4 +170,18 @@ describe('Plugin Extension Validators', () => {
|
|||||||
expect(isReactComponent(null)).toBe(false);
|
expect(isReactComponent(null)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isGrafanaCoreExtensionPoint()', () => {
|
||||||
|
it('should return TRUE if we pass an PluginExtensionPoints value', () => {
|
||||||
|
expect(isGrafanaCoreExtensionPoint(PluginExtensionPoints.AlertingAlertingRuleAction)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return TRUE if we pass a string that is not listed under the PluginExtensionPoints enum', () => {
|
||||||
|
expect(isGrafanaCoreExtensionPoint('grafana/alerting/alertingrule/action')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return FALSE if we pass a string that is not listed under the PluginExtensionPoints enum', () => {
|
||||||
|
expect(isGrafanaCoreExtensionPoint('grafana/dashboard/alertingrule/action')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,13 +1,7 @@
|
|||||||
import type {
|
import type { PluginExtensionAddedLinkConfig, PluginExtension, PluginExtensionLink } from '@grafana/data';
|
||||||
PluginExtension,
|
import { PluginAddedLinksConfigureFunc, PluginExtensionPoints } from '@grafana/data/src/types/pluginExtensions';
|
||||||
PluginExtensionConfig,
|
|
||||||
PluginExtensionLink,
|
|
||||||
PluginExtensionLinkConfig,
|
|
||||||
} from '@grafana/data';
|
|
||||||
import { isPluginExtensionLink } from '@grafana/runtime';
|
import { isPluginExtensionLink } from '@grafana/runtime';
|
||||||
|
|
||||||
import { isPluginExtensionLinkConfig, logWarning } from './utils';
|
|
||||||
|
|
||||||
export function assertPluginExtensionLink(
|
export function assertPluginExtensionLink(
|
||||||
extension: PluginExtension | undefined,
|
extension: PluginExtension | undefined,
|
||||||
errorMessage = 'extension is not a link extension'
|
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) {
|
export function assertLinkPathIsValid(pluginId: string, path: string) {
|
||||||
if (!isLinkPathValid(pluginId, path)) {
|
if (!isLinkPathValid(pluginId, path)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@ -40,18 +25,10 @@ export function assertIsReactComponent(component: React.ComponentType) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertExtensionPointIdIsValid(pluginId: string, extension: PluginExtensionConfig) {
|
export function assertConfigureIsValid(config: PluginExtensionAddedLinkConfig) {
|
||||||
if (!isExtensionPointIdValid(pluginId, extension.extensionPointId)) {
|
if (!isConfigureFnValid(config.configure)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid extension "${extension.title}". The extensionPointId should start with either "grafana/", "plugins/" or "capabilities/${pluginId}" (currently: "${extension.extensionPointId}"). Skipping the extension.`
|
`Invalid extension "${config.title}". The "configure" property must be a function. 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.`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -88,43 +65,20 @@ export function extensionPointEndsWithVersion(extensionPointId: string) {
|
|||||||
return extensionPointId.match(/.*\/v\d+$/);
|
return extensionPointId.match(/.*\/v\d+$/);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isConfigureFnValid(extension: PluginExtensionLinkConfig) {
|
export function isGrafanaCoreExtensionPoint(extensionPointId: string) {
|
||||||
return extension.configure ? typeof extension.configure === 'function' : true;
|
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) {
|
export function isStringPropValid(prop: unknown) {
|
||||||
return typeof prop === 'string' && prop.length > 0;
|
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> {
|
export function isPromise(value: unknown): value is Promise<unknown> {
|
||||||
return (
|
return (
|
||||||
value instanceof Promise || (typeof value === 'object' && value !== null && 'then' in value && 'catch' in value)
|
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 type { PluginExtensionAddedLinkConfig, PluginExtensionExposedComponentConfig } from '@grafana/data';
|
||||||
import { PluginAddedComponentConfig } from '@grafana/data/src/types/pluginExtensions';
|
import { PluginExtensionAddedComponentConfig } from '@grafana/data/src/types/pluginExtensions';
|
||||||
import type { AppPluginConfig } from '@grafana/runtime';
|
import type { AppPluginConfig } from '@grafana/runtime';
|
||||||
import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
|
import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
|
||||||
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
|
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
|
||||||
|
|
||||||
import { ReactivePluginExtensionsRegistry } from './extensions/reactivePluginExtensionRegistry';
|
import { PluginExtensionRegistries } from './extensions/registry/types';
|
||||||
import { AddedComponentsRegistry } from './extensions/registry/AddedComponentsRegistry';
|
|
||||||
import { ExposedComponentsRegistry } from './extensions/registry/ExposedComponentsRegistry';
|
|
||||||
import * as pluginLoader from './plugin_loader';
|
import * as pluginLoader from './plugin_loader';
|
||||||
|
|
||||||
export type PluginPreloadResult = {
|
export type PluginPreloadResult = {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
error?: unknown;
|
error?: unknown;
|
||||||
extensionConfigs: PluginExtensionConfig[];
|
exposedComponentConfigs: PluginExtensionExposedComponentConfig[];
|
||||||
exposedComponentConfigs: PluginExposedComponentConfig[];
|
addedComponentConfigs?: PluginExtensionAddedComponentConfig[];
|
||||||
addedComponentConfigs: PluginAddedComponentConfig[];
|
addedLinkConfigs?: PluginExtensionAddedLinkConfig[];
|
||||||
};
|
|
||||||
|
|
||||||
type PluginExtensionRegistries = {
|
|
||||||
extensionsRegistry: ReactivePluginExtensionsRegistry;
|
|
||||||
addedComponentsRegistry: AddedComponentsRegistry;
|
|
||||||
exposedComponentsRegistry: ExposedComponentsRegistry;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function preloadPlugins(
|
export async function preloadPlugins(
|
||||||
@ -38,14 +30,17 @@ export async function preloadPlugins(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
registries.extensionsRegistry.register(preloadedPlugin);
|
|
||||||
registries.exposedComponentsRegistry.register({
|
registries.exposedComponentsRegistry.register({
|
||||||
pluginId: preloadedPlugin.pluginId,
|
pluginId: preloadedPlugin.pluginId,
|
||||||
configs: preloadedPlugin.exposedComponentConfigs,
|
configs: preloadedPlugin.exposedComponentConfigs,
|
||||||
});
|
});
|
||||||
registries.addedComponentsRegistry.register({
|
registries.addedComponentsRegistry.register({
|
||||||
pluginId: preloadedPlugin.pluginId,
|
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,
|
isAngular: config.angular.detected,
|
||||||
pluginId,
|
pluginId,
|
||||||
});
|
});
|
||||||
const { extensionConfigs = [], exposedComponentConfigs = [], addedComponentConfigs = [] } = plugin;
|
const { exposedComponentConfigs = [], addedComponentConfigs = [], addedLinkConfigs = [] } = plugin;
|
||||||
|
|
||||||
// Fetching meta-information for the preloaded app plugin and caching it for later.
|
// 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.)
|
// (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);
|
getPluginSettings(pluginId);
|
||||||
|
|
||||||
return { pluginId, extensionConfigs, exposedComponentConfigs, addedComponentConfigs };
|
return { pluginId, exposedComponentConfigs, addedComponentConfigs, addedLinkConfigs };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Plugins] Failed to preload plugin: ${path} (version: ${version})`, 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 {
|
} finally {
|
||||||
stopMeasure(`frontend_plugin_preload_${pluginId}`);
|
stopMeasure(`frontend_plugin_preload_${pluginId}`);
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user