mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Plugin Extensions: Require meta-data to be defined in plugin.json
during development mode (#93429)
* feat: add extensions to the backend plugin model * feat: update the frontend plugin types * feat(pluginContext): return a `null` if there is no context found This will be necessary to understand if a certain hook is running inside a plugin context or not. * feat: add utility functions for checking extension configs * tests: fix failing tests due to the type updates * feat(AddedComponentsRegistry): validate plugin meta-info * feat(AddedLinksRegistry): validate meta-info * feat(ExposedComponentsRegistry): validate meta-info * feat(usePluginComponent): add meta-info validation * feat(usePluginComponents): add meta-info validation * feat(usePluginLinks): add meta-info validation * fix: only validate meta-info in registries if dev mode is enabled * tests: add unit tests for the restrictions functionality * tests: fix Go tests * fix(tests): revert accidental changes * fix: run goimports * fix: api tests * add nested app so that meta data can bested e2e tested * refactor(types): extract the ExtensionInfo into a separate type * refactor(extensions/utils): use Array.prototype.some() instead of .find() * refactor(usePluginLinks): update warning message * feat(usePluginExtensions()): validate plugin meta-info * Wip * fix(e2e): E2E tests for extensions * fix(extensions): allow multiple "/" slashes in the extension point id * fix(extensions/validators): stop validating the plugin id pattern --------- Co-authored-by: Erik Sundell <erik.sundell87@gmail.com>
This commit is contained in:
parent
7188c13d22
commit
6096f46774
@ -17,3 +17,7 @@ apps:
|
||||
org_id: 1
|
||||
org_name: Main Org.
|
||||
disabled: false
|
||||
- type: grafana-extensionexample3-app
|
||||
org_id: 1
|
||||
org_name: Main Org.
|
||||
disabled: false
|
||||
|
@ -9,7 +9,7 @@ type ReusableComponentProps = {
|
||||
|
||||
export function AddedComponents() {
|
||||
const { components } = usePluginComponents<ReusableComponentProps>({
|
||||
extensionPointId: 'plugins/grafana-extensionexample2-app/addComponent/v1',
|
||||
extensionPointId: 'plugins/grafana-extensionstest-app/addComponent/v1',
|
||||
});
|
||||
|
||||
return (
|
||||
|
@ -16,7 +16,7 @@ type ReusableComponentProps = {
|
||||
|
||||
export function LegacyGetters() {
|
||||
const extensionPointId1 = 'plugins/grafana-extensionstest-app/actions';
|
||||
const extensionPointId2 = 'plugins/grafana-extensionexample2-app/configure-extension-component/v1';
|
||||
const extensionPointId2 = 'plugins/grafana-extensionstest-app/configure-extension-component/v1';
|
||||
const context: AppExtensionContext = {};
|
||||
|
||||
const { extensions } = getPluginExtensions({
|
||||
|
@ -16,7 +16,7 @@ type ReusableComponentProps = {
|
||||
|
||||
export function LegacyHooks() {
|
||||
const extensionPointId1 = 'plugins/grafana-extensionstest-app/actions';
|
||||
const extensionPointId2 = 'plugins/grafana-extensionexample2-app/configure-extension-component/v1';
|
||||
const extensionPointId2 = 'plugins/grafana-extensionstest-app/configure-extension-component/v1';
|
||||
const context: AppExtensionContext = {};
|
||||
|
||||
const { extensions } = usePluginExtensions({
|
||||
|
@ -60,8 +60,43 @@
|
||||
"defaultNav": false
|
||||
}
|
||||
],
|
||||
"extensions": {
|
||||
"addedLinks": [
|
||||
{
|
||||
"targets": ["grafana/dashboard/panel/menu"],
|
||||
"title": "Open from time series or pie charts (path)",
|
||||
"description": "This link will only be visible on time series and pie charts"
|
||||
},
|
||||
{
|
||||
"targets": ["grafana/dashboard/panel/menu"],
|
||||
"title": "Open from time series or pie charts (onClick)",
|
||||
"description": "This link will only be visible on time series and pie charts"
|
||||
}
|
||||
],
|
||||
"extensionPoints": [
|
||||
{
|
||||
"id": "plugins/grafana-extensionstest-app/use-plugin-links/v1",
|
||||
"title": "Extension point - links"
|
||||
},
|
||||
{
|
||||
"id": "plugins/grafana-extensionstest-app/addComponent/v1",
|
||||
"title": "Extension point - components"
|
||||
},
|
||||
{
|
||||
"id": "plugins/grafana-extensionstest-app/actions",
|
||||
"title": "Legacy extension point - usePluginExtensions() and usePluginLinkExtensions()"
|
||||
},
|
||||
{
|
||||
"id": "plugins/grafana-extensionstest-app/configure-extension-component/v1",
|
||||
"title": "Legacy extension point - usePluginComponentExtensions()"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=10.4.0",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": ["grafana-extensionexample1-app/reusable-component/v1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -28,6 +28,30 @@
|
||||
"defaultNav": false
|
||||
}
|
||||
],
|
||||
"extensions": {
|
||||
"exposedComponents": [
|
||||
{
|
||||
"id": "grafana-extensionexample1-app/reusable-component/v1",
|
||||
"title": "Exposed component",
|
||||
"description": "A component that can be reused by other app plugins."
|
||||
}
|
||||
],
|
||||
"addedLinks": [
|
||||
{
|
||||
"targets": [
|
||||
"plugins/grafana-extensionstest-app/actions",
|
||||
"plugins/grafana-extensionstest-app/use-plugin-links/v1"
|
||||
],
|
||||
"title": "Go to A",
|
||||
"description": "Navigating to pluging A"
|
||||
},
|
||||
{
|
||||
"targets": ["plugins/grafana-extensionstest-app/use-plugin-links/v1"],
|
||||
"title": "Basic link",
|
||||
"description": "..."
|
||||
}
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=10.3.3",
|
||||
"plugins": []
|
||||
|
@ -19,13 +19,13 @@ export const plugin = new AppPlugin<{}>()
|
||||
},
|
||||
})
|
||||
.configureExtensionComponent({
|
||||
extensionPointId: 'plugins/grafana-extensionexample2-app/configure-extension-component/v1',
|
||||
extensionPointId: 'plugins/grafana-extensionstest-app/configure-extension-component/v1',
|
||||
title: 'Configure extension component from B',
|
||||
description: 'A component that can be reused by other app plugins. Shared using configureExtensionComponent api',
|
||||
component: ({ name }: { name: string }) => <div data-testid={testIds.appB.reusableComponent}>Hello {name}!</div>,
|
||||
})
|
||||
.addComponent<{ name: string }>({
|
||||
targets: 'plugins/grafana-extensionexample2-app/addComponent/v1',
|
||||
targets: 'plugins/grafana-extensionstest-app/addComponent/v1',
|
||||
title: 'Added component from B',
|
||||
description: 'A component that can be reused by other app plugins. Shared using addComponent api',
|
||||
component: ({ name }: { name: string }) => (
|
||||
|
@ -18,6 +18,30 @@
|
||||
"version": "%VERSION%",
|
||||
"updated": "%TODAY%"
|
||||
},
|
||||
"extensions": {
|
||||
"addedLinks": [
|
||||
{
|
||||
"targets": [
|
||||
"plugins/grafana-extensionstest-app/actions",
|
||||
"plugins/grafana-extensionstest-app/use-plugin-links/v1"
|
||||
],
|
||||
"title": "Open from B",
|
||||
"description": "Open a modal from plugin B"
|
||||
}
|
||||
],
|
||||
"addedComponents": [
|
||||
{
|
||||
"targets": ["plugins/grafana-extensionstest-app/configure-extension-component/v1"],
|
||||
"title": "Configure extension component from B",
|
||||
"description": "A component that can be reused by other app plugins. Shared using configureExtensionComponent api"
|
||||
},
|
||||
{
|
||||
"targets": ["plugins/grafana-extensionstest-app/addComponent/v1"],
|
||||
"title": "Added component from B",
|
||||
"description": "A component that can be reused by other app plugins. Shared using addComponent api"
|
||||
}
|
||||
]
|
||||
},
|
||||
"includes": [
|
||||
{
|
||||
"type": "page",
|
||||
|
@ -0,0 +1,24 @@
|
||||
import { usePluginExtensions, usePluginLinks } from '@grafana/runtime';
|
||||
import { Stack } from '@grafana/ui';
|
||||
import { testIds } from '../../../../testIds';
|
||||
import { ActionButton } from '../../../../components/ActionButton';
|
||||
|
||||
export const LINKS_EXTENSION_POINT_ID = 'plugins/grafana-extensionstest-app/use-plugin-links/v1';
|
||||
|
||||
export function AddedLinks() {
|
||||
const { links } = usePluginLinks({ extensionPointId: LINKS_EXTENSION_POINT_ID });
|
||||
const { extensions } = usePluginExtensions({ extensionPointId: LINKS_EXTENSION_POINT_ID });
|
||||
|
||||
return (
|
||||
<Stack direction={'column'} gap={4}>
|
||||
<section data-testid={testIds.appC.section1}>
|
||||
<h3>Link extensions defined with addLink and retrieved using usePluginLinks</h3>
|
||||
<ActionButton extensions={links} />
|
||||
</section>
|
||||
<section data-testid={testIds.appC.section2}>
|
||||
<h3>Link extensions defined with addLink and retrieved using usePluginExtensions</h3>
|
||||
<ActionButton extensions={extensions} />
|
||||
</section>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { AppRootProps } from '@grafana/data';
|
||||
import { AddedLinks } from './AddedLinks';
|
||||
import { testIds } from '../../../../testIds';
|
||||
|
||||
export class App extends React.PureComponent<AppRootProps> {
|
||||
render() {
|
||||
return (
|
||||
<div data-testid={testIds.appC.container} className="page-container">
|
||||
Hello Grafana!
|
||||
<AddedLinks></AddedLinks>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 81.9 71.52"><defs><style>.cls-1{fill:#84aff1;}.cls-2{fill:#3865ab;}.cls-3{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" x1="42.95" y1="16.88" x2="81.9" y2="16.88" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M55.46,62.43A2,2,0,0,1,54.07,59l4.72-4.54a2,2,0,0,1,2.2-.39l3.65,1.63,3.68-3.64a2,2,0,1,1,2.81,2.84l-4.64,4.6a2,2,0,0,1-2.22.41L60.6,58.26l-3.76,3.61A2,2,0,0,1,55.46,62.43Z"/><path class="cls-2" d="M37,0H2A2,2,0,0,0,0,2V31.76a2,2,0,0,0,2,2H37a2,2,0,0,0,2-2V2A2,2,0,0,0,37,0ZM4,29.76V8.84H35V29.76Z"/><path class="cls-3" d="M79.9,0H45a2,2,0,0,0-2,2V31.76a2,2,0,0,0,2,2h35a2,2,0,0,0,2-2V2A2,2,0,0,0,79.9,0ZM47,29.76V8.84h31V29.76Z"/><path class="cls-2" d="M37,37.76H2a2,2,0,0,0-2,2V69.52a2,2,0,0,0,2,2H37a2,2,0,0,0,2-2V39.76A2,2,0,0,0,37,37.76ZM4,67.52V46.6H35V67.52Z"/><path class="cls-2" d="M79.9,37.76H45a2,2,0,0,0-2,2V69.52a2,2,0,0,0,2,2h35a2,2,0,0,0,2-2V39.76A2,2,0,0,0,79.9,37.76ZM47,67.52V46.6h31V67.52Z"/><rect class="cls-1" x="10.48" y="56.95" width="4" height="5.79"/><rect class="cls-1" x="17.43" y="53.95" width="4" height="8.79"/><rect class="cls-1" x="24.47" y="50.95" width="4" height="11.79"/><path class="cls-1" d="M19.47,25.8a6.93,6.93,0,1,1,6.93-6.92A6.93,6.93,0,0,1,19.47,25.8Zm0-9.85a2.93,2.93,0,1,0,2.93,2.93A2.93,2.93,0,0,0,19.47,16Z"/></g></g></svg>
|
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,44 @@
|
||||
import { AppPlugin } from '@grafana/data';
|
||||
|
||||
import { LINKS_EXTENSION_POINT_ID } from '../../pages/AddedLinks';
|
||||
import { testIds } from '../../testIds';
|
||||
import { App } from '../../components/App';
|
||||
|
||||
export const plugin = new AppPlugin<{}>()
|
||||
.setRootPage(App)
|
||||
.configureExtensionLink({
|
||||
title: 'configureExtensionLink (where meta data is missing)',
|
||||
description: 'Open a modal from plugin B',
|
||||
extensionPointId: 'plugins/grafana-extensionstest-app/actions',
|
||||
onClick: (_, { openModal }) => {
|
||||
openModal({
|
||||
title: 'Modal from app B',
|
||||
body: () => <div data-testid={testIds.appB.modal}>From plugin B</div>,
|
||||
});
|
||||
},
|
||||
})
|
||||
.configureExtensionComponent({
|
||||
extensionPointId: 'plugins/grafana-extensionstest-app/configure-extension-component/v1',
|
||||
title: 'configureExtensionComponent (where meta data is missing)',
|
||||
description: 'A component that can be reused by other app plugins. Shared using configureExtensionComponent api',
|
||||
component: ({ name }: { name: string }) => <div data-testid={testIds.appB.reusableComponent}>Hello {name}!</div>,
|
||||
})
|
||||
.addComponent<{ name: string }>({
|
||||
targets: ['plugins/grafana-extensionstest-app/addComponent/v1'],
|
||||
title: 'Added component (where meta data is missing)',
|
||||
description: '.',
|
||||
component: ({ name }: { name: string }) => (
|
||||
<div data-testid={testIds.appB.reusableAddedComponent}>Hello {name}!</div>
|
||||
),
|
||||
})
|
||||
.addLink({
|
||||
title: 'Added link (where meta data is missing)',
|
||||
description: '.',
|
||||
targets: [LINKS_EXTENSION_POINT_ID],
|
||||
onClick: (_, { openModal }) => {
|
||||
openModal({
|
||||
title: 'Modal from app C',
|
||||
body: () => <div data-testid={testIds.appB.modal}>From plugin B</div>,
|
||||
});
|
||||
},
|
||||
});
|
@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
|
||||
"type": "app",
|
||||
"name": "D App",
|
||||
"id": "grafana-extensionexample3-app",
|
||||
"preload": true,
|
||||
"info": {
|
||||
"keywords": ["app"],
|
||||
"description": "Will extend root app with ui extensions",
|
||||
"author": {
|
||||
"name": "grafana"
|
||||
},
|
||||
"logos": {
|
||||
"small": "img/logo.svg",
|
||||
"large": "img/logo.svg"
|
||||
},
|
||||
"screenshots": [],
|
||||
"version": "%VERSION%",
|
||||
"updated": "%TODAY%"
|
||||
},
|
||||
"includes": [
|
||||
{
|
||||
"type": "page",
|
||||
"name": "Default",
|
||||
"path": "/a/grafana-extensionexample3-app",
|
||||
"role": "Admin",
|
||||
"addToNav": false,
|
||||
"defaultNav": false
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=10.3.3",
|
||||
"plugins": []
|
||||
}
|
||||
}
|
@ -16,6 +16,11 @@ export const testIds = {
|
||||
reusableAddedComponent: 'b-app-add-component',
|
||||
exposedComponent: 'b-app-exposed-component',
|
||||
},
|
||||
appC: {
|
||||
container: 'c-app-body',
|
||||
section1: 'use-plugin-links',
|
||||
section2: 'use-plugin-extensions',
|
||||
},
|
||||
legacyGettersPage: {
|
||||
container: 'data-testid pg-legacy-getters-container',
|
||||
section1: 'get-plugin-extensions',
|
||||
|
@ -2,6 +2,7 @@ import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
import { testIds } from '../../testIds';
|
||||
import pluginJson from '../../plugin.json';
|
||||
import testApp3pluginJson from '../../plugins/grafana-extensionexample3-app/plugin.json';
|
||||
|
||||
test.describe('usePluginExtensions + configureExtensionLink', () => {
|
||||
test('should extend the actions menu with a link to a-app plugin', async ({ page }) => {
|
||||
@ -12,6 +13,17 @@ test.describe('usePluginExtensions + configureExtensionLink', () => {
|
||||
await page.getByTestId(testIds.modal.open).click();
|
||||
await expect(page.getByTestId(testIds.appA.container)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not display extensions that have not been declared in plugin.json when in development mode', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/a/${pluginJson.id}/legacy-hooks`);
|
||||
const section = await page.getByTestId(testIds.legacyHooksPage.section1);
|
||||
await section.getByTestId(testIds.actions.button).click();
|
||||
await expect(
|
||||
page.getByTestId(testIds.container).getByText('configureExtensionLink (where meta data is missing)')
|
||||
).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('usePluginExtensions + configureExtensionComponent', () => {
|
||||
@ -43,3 +55,13 @@ test.describe('usePluginComponentExtensions + configureExtensionComponent', () =
|
||||
).toHaveText('Hello World!');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('usePluginExtensions + addLink', () => {
|
||||
test('should not display extensions in case extension point has not been declared in plugin json (dev mode only)', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/a/${testApp3pluginJson.id}/legacy-hooks`);
|
||||
const section = await page.getByTestId(testIds.appC.section2);
|
||||
await expect(section.getByTestId(testIds.actions.button)).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
import pluginJson from '../plugin.json';
|
||||
import testApp3pluginJson from '../plugins/grafana-extensionexample3-app/plugin.json';
|
||||
import { testIds } from '../testIds';
|
||||
|
||||
test('should extend the actions menu with a link to a-app plugin', async ({ page }) => {
|
||||
@ -28,3 +29,11 @@ test('should extend main app with basic link extension from app A', async ({ pag
|
||||
await page.getByTestId(testIds.modal.open).click();
|
||||
await expect(page.getByTestId(testIds.appA.container)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not display any extensions when extension point is not declared in plugin json when in development mode', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/a/${testApp3pluginJson.id}`);
|
||||
const container = await page.getByTestId(testIds.appC.section1);
|
||||
await expect(container.getByTestId(testIds.actions.button)).not.toBeVisible();
|
||||
});
|
||||
|
@ -2,10 +2,14 @@ import { useContext } from 'react';
|
||||
|
||||
import { Context, PluginContextType } from './PluginContext';
|
||||
|
||||
export function usePluginContext(): PluginContextType {
|
||||
export function usePluginContext(): PluginContextType | null {
|
||||
const context = useContext(Context);
|
||||
|
||||
// The extensions hooks (e.g. `usePluginLinks()`) are using this hook to check
|
||||
// if they are inside a plugin or not (core Grafana), so we should be able to return an empty state as well (`null`).
|
||||
if (!context) {
|
||||
throw new Error('usePluginContext must be used within a PluginContextProvider');
|
||||
return null;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
@ -586,6 +586,7 @@ export {
|
||||
type AngularMeta,
|
||||
type PluginMeta,
|
||||
type PluginDependencies,
|
||||
type PluginExtensions,
|
||||
type PluginInclude,
|
||||
type PluginBuildInfo,
|
||||
type ScreenshotInfo,
|
||||
|
@ -98,6 +98,7 @@ export interface PluginMeta<T extends KeyValue = {}> {
|
||||
angular?: AngularMeta;
|
||||
angularDetected?: boolean;
|
||||
loadingStrategy?: PluginLoadingStrategy;
|
||||
extensions?: PluginExtensions;
|
||||
}
|
||||
|
||||
interface PluginDependencyInfo {
|
||||
@ -111,6 +112,38 @@ export interface PluginDependencies {
|
||||
grafanaDependency?: string;
|
||||
grafanaVersion: string;
|
||||
plugins: PluginDependencyInfo[];
|
||||
extensions: {
|
||||
// A list of exposed component IDs
|
||||
exposedComponents: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export type ExtensionInfo = {
|
||||
targets: string | string[];
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export interface PluginExtensions {
|
||||
// The component extensions that the plugin registers
|
||||
addedComponents: ExtensionInfo[];
|
||||
|
||||
// The link extensions that the plugin registers
|
||||
addedLinks: ExtensionInfo[];
|
||||
|
||||
// The React components that the plugin exposes
|
||||
exposedComponents: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}>;
|
||||
|
||||
// The extension points that the plugin provides
|
||||
extensionPoints: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export enum PluginIncludeType {
|
||||
|
@ -12,6 +12,13 @@ export function usePluginInteractionReporter(): typeof reportInteraction {
|
||||
const context = usePluginContext();
|
||||
|
||||
return useMemo(() => {
|
||||
// Happens when the hook is not used inside a plugin (e.g. in core Grafana)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
`No PluginContext found. The usePluginInteractionReporter() hook can only be used from a plugin.`
|
||||
);
|
||||
}
|
||||
|
||||
const info = isDataSourcePluginContext(context)
|
||||
? createDataSourcePluginEventProperties(context.instanceSettings)
|
||||
: createPluginEventProperties(context.meta);
|
||||
|
@ -18,6 +18,8 @@ import {
|
||||
getThemeById,
|
||||
AngularMeta,
|
||||
PluginLoadingStrategy,
|
||||
PluginDependencies,
|
||||
PluginExtensions,
|
||||
} from '@grafana/data';
|
||||
|
||||
export interface AzureSettings {
|
||||
@ -42,6 +44,8 @@ export type AppPluginConfig = {
|
||||
preload: boolean;
|
||||
angular: AngularMeta;
|
||||
loadingStrategy: PluginLoadingStrategy;
|
||||
dependencies: PluginDependencies;
|
||||
extensions: PluginExtensions;
|
||||
};
|
||||
|
||||
export type PreinstalledPlugin = {
|
||||
|
@ -17,6 +17,7 @@ type PluginSetting struct {
|
||||
Info plugins.Info `json:"info"`
|
||||
Includes []*plugins.Includes `json:"includes"`
|
||||
Dependencies plugins.Dependencies `json:"dependencies"`
|
||||
Extensions plugins.Extensions `json:"extensions"`
|
||||
JsonData map[string]any `json:"jsonData"`
|
||||
SecureJsonFields map[string]bool `json:"secureJsonFields"`
|
||||
DefaultNavUrl string `json:"defaultNavUrl"`
|
||||
|
@ -561,6 +561,8 @@ func (hs *HTTPServer) newAppDTO(ctx context.Context, plugin pluginstore.Plugin,
|
||||
Preload: false,
|
||||
Angular: plugin.Angular,
|
||||
LoadingStrategy: hs.pluginAssets.LoadingStrategy(ctx, plugin),
|
||||
Extensions: plugin.Extensions,
|
||||
Dependencies: plugin.Dependencies,
|
||||
}
|
||||
|
||||
if settings.Enabled {
|
||||
|
@ -209,6 +209,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response.
|
||||
SecureJsonFields: map[string]bool{},
|
||||
AngularDetected: plugin.Angular.Detected,
|
||||
LoadingStrategy: hs.pluginAssets.LoadingStrategy(c.Req.Context(), plugin),
|
||||
Extensions: plugin.Extensions,
|
||||
}
|
||||
|
||||
if plugin.IsApp() {
|
||||
|
@ -50,6 +50,15 @@ func TestFinder_Find(t *testing.T) {
|
||||
Dependencies: plugins.Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
State: plugins.ReleaseStateAlpha,
|
||||
Backend: true,
|
||||
@ -82,6 +91,15 @@ func TestFinder_Find(t *testing.T) {
|
||||
Dependencies: plugins.Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
},
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testData, "duplicate-plugins/nested")),
|
||||
@ -104,6 +122,15 @@ func TestFinder_Find(t *testing.T) {
|
||||
Dependencies: plugins.Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
},
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testData, "duplicate-plugins/nested/nested")),
|
||||
@ -150,6 +177,9 @@ func TestFinder_Find(t *testing.T) {
|
||||
{ID: "graphite", Type: "datasource", Name: "Graphite", Version: "1.0.0"},
|
||||
{ID: "graph", Type: "panel", Name: "Graph", Version: "1.0.0"},
|
||||
},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Includes: []*plugins.Includes{
|
||||
{
|
||||
@ -169,6 +199,12 @@ func TestFinder_Find(t *testing.T) {
|
||||
{Name: "Nginx Panel", Type: "panel", Role: "Viewer", Action: "plugins.app:access"},
|
||||
{Name: "Nginx Datasource", Type: "datasource", Role: "Viewer", Action: "plugins.app:access"},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
},
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testData, "includes-symlinks")),
|
||||
},
|
||||
@ -197,6 +233,15 @@ func TestFinder_Find(t *testing.T) {
|
||||
Dependencies: plugins.Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
},
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testData, "duplicate-plugins/nested")),
|
||||
@ -219,6 +264,15 @@ func TestFinder_Find(t *testing.T) {
|
||||
Dependencies: plugins.Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
},
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testData, "duplicate-plugins/nested/nested")),
|
||||
@ -241,6 +295,15 @@ func TestFinder_Find(t *testing.T) {
|
||||
Dependencies: plugins.Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
State: plugins.ReleaseStateAlpha,
|
||||
Backend: true,
|
||||
@ -272,6 +335,15 @@ func TestFinder_Find(t *testing.T) {
|
||||
Dependencies: plugins.Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
State: plugins.ReleaseStateAlpha,
|
||||
Backend: true,
|
||||
|
@ -98,6 +98,15 @@ func TestLoader_Load(t *testing.T) {
|
||||
Dependencies: plugins.Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
Category: "cloud",
|
||||
Annotations: true,
|
||||
@ -141,6 +150,15 @@ func TestLoader_Load(t *testing.T) {
|
||||
Dependencies: plugins.Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
Executable: "test",
|
||||
Backend: true,
|
||||
@ -195,6 +213,9 @@ func TestLoader_Load(t *testing.T) {
|
||||
{Type: "datasource", ID: "graphite", Name: "Graphite", Version: "1.0.0"},
|
||||
{Type: "panel", ID: "graph", Name: "Graph", Version: "1.0.0"},
|
||||
},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Includes: []*plugins.Includes{
|
||||
{
|
||||
@ -228,6 +249,12 @@ func TestLoader_Load(t *testing.T) {
|
||||
Slug: "nginx-datasource",
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
},
|
||||
Class: plugins.ClassExternal,
|
||||
Module: "public/plugins/test-app/module.js",
|
||||
@ -266,6 +293,15 @@ func TestLoader_Load(t *testing.T) {
|
||||
Dependencies: plugins.Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
Backend: true,
|
||||
State: plugins.ReleaseStateAlpha,
|
||||
@ -312,6 +348,15 @@ func TestLoader_Load(t *testing.T) {
|
||||
Dependencies: plugins.Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
Backend: true,
|
||||
State: plugins.ReleaseStateAlpha,
|
||||
@ -393,11 +438,20 @@ func TestLoader_Load(t *testing.T) {
|
||||
GrafanaDependency: ">=8.0.0",
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Includes: []*plugins.Includes{
|
||||
{Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-memory"},
|
||||
{Name: "Root Page (react)", Type: "page", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Path: "/a/my-simple-app", DefaultNav: true, AddToNav: true, Slug: "root-page-react"},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
Backend: false,
|
||||
},
|
||||
DefaultNavURL: "/plugins/test-app/page/root-page-react",
|
||||
|
@ -1,6 +1,7 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
@ -45,6 +46,97 @@ type Dependencies struct {
|
||||
GrafanaDependency string `json:"grafanaDependency"`
|
||||
GrafanaVersion string `json:"grafanaVersion"`
|
||||
Plugins []Dependency `json:"plugins"`
|
||||
Extensions ExtensionsDependencies `json:"extensions"`
|
||||
}
|
||||
|
||||
// We need different versions for the Extensions struct because there is a now deprecated plugin.json schema out there, where the "extensions" prop
|
||||
// is in a different format (Extensions V1). In order to support those as well while reading the plugin.json, we need to add a custom unmarshaling logic for extensions.
|
||||
type ExtensionV1 struct {
|
||||
ExtensionPointID string `json:"extensionPointId"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type ExtensionsV2 struct {
|
||||
AddedLinks []AddedLink `json:"addedLinks"`
|
||||
AddedComponents []AddedComponent `json:"addedComponents"`
|
||||
ExposedComponents []ExposedComponent `json:"exposedComponents"`
|
||||
ExtensionPoints []ExtensionPoint `json:"extensionPoints"`
|
||||
}
|
||||
|
||||
type Extensions ExtensionsV2
|
||||
|
||||
func (e *Extensions) UnmarshalJSON(data []byte) error {
|
||||
var err error
|
||||
var extensionsV2 ExtensionsV2
|
||||
|
||||
if err = json.Unmarshal(data, &extensionsV2); err == nil {
|
||||
e.AddedComponents = extensionsV2.AddedComponents
|
||||
e.AddedLinks = extensionsV2.AddedLinks
|
||||
e.ExposedComponents = extensionsV2.ExposedComponents
|
||||
e.ExtensionPoints = extensionsV2.ExtensionPoints
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fallback (V1)
|
||||
var extensionsV1 []ExtensionV1
|
||||
if err = json.Unmarshal(data, &extensionsV1); err == nil {
|
||||
// Trying to process old format and add them to `AddedLinks` and `AddedComponents`
|
||||
for _, extensionV1 := range extensionsV1 {
|
||||
if extensionV1.Type == "link" {
|
||||
extensionV2 := AddedLink{
|
||||
Targets: []string{extensionV1.ExtensionPointID},
|
||||
Title: extensionV1.Title,
|
||||
Description: extensionV1.Description,
|
||||
}
|
||||
e.AddedLinks = append(e.AddedLinks, extensionV2)
|
||||
}
|
||||
|
||||
if extensionV1.Type == "component" {
|
||||
extensionV2 := AddedComponent{
|
||||
Targets: []string{extensionV1.ExtensionPointID},
|
||||
Title: extensionV1.Title,
|
||||
Description: extensionV1.Description,
|
||||
}
|
||||
|
||||
e.AddedComponents = append(e.AddedComponents, extensionV2)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type AddedLink struct {
|
||||
Targets []string `json:"targets"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type AddedComponent struct {
|
||||
Targets []string `json:"targets"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type ExposedComponent struct {
|
||||
Id string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type ExtensionPoint struct {
|
||||
Id string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type ExtensionsDependencies struct {
|
||||
ExposedComponents []string `json:"exposedComponents"`
|
||||
}
|
||||
|
||||
type Includes struct {
|
||||
@ -231,6 +323,8 @@ type AppDTO struct {
|
||||
Preload bool `json:"preload"`
|
||||
Angular AngularMeta `json:"angular"`
|
||||
LoadingStrategy LoadingStrategy `json:"loadingStrategy"`
|
||||
Extensions Extensions `json:"extensions"`
|
||||
Dependencies Dependencies `json:"dependencies"`
|
||||
}
|
||||
|
||||
const (
|
||||
|
@ -110,6 +110,7 @@ type JSONData struct {
|
||||
|
||||
// App settings
|
||||
AutoEnabled bool `json:"autoEnabled"`
|
||||
Extensions Extensions `json:"extensions"`
|
||||
|
||||
// Datasource settings
|
||||
Annotations bool `json:"annotations"`
|
||||
@ -173,6 +174,26 @@ func ReadPluginJSON(reader io.Reader) (JSONData, error) {
|
||||
plugin.Dependencies.GrafanaVersion = "*"
|
||||
}
|
||||
|
||||
if len(plugin.Dependencies.Extensions.ExposedComponents) == 0 {
|
||||
plugin.Dependencies.Extensions.ExposedComponents = make([]string, 0)
|
||||
}
|
||||
|
||||
if plugin.Extensions.AddedLinks == nil {
|
||||
plugin.Extensions.AddedLinks = []AddedLink{}
|
||||
}
|
||||
|
||||
if plugin.Extensions.AddedComponents == nil {
|
||||
plugin.Extensions.AddedComponents = []AddedComponent{}
|
||||
}
|
||||
|
||||
if plugin.Extensions.ExposedComponents == nil {
|
||||
plugin.Extensions.ExposedComponents = []ExposedComponent{}
|
||||
}
|
||||
|
||||
if plugin.Extensions.ExtensionPoints == nil {
|
||||
plugin.Extensions.ExtensionPoints = []ExtensionPoint{}
|
||||
}
|
||||
|
||||
for _, include := range plugin.Includes {
|
||||
if include.Role == "" {
|
||||
include.Role = org.RoleViewer
|
||||
|
@ -52,13 +52,25 @@ func Test_ReadPluginJSON(t *testing.T) {
|
||||
Updated: "2015-02-10",
|
||||
Keywords: []string{"test"},
|
||||
},
|
||||
|
||||
Extensions: Extensions{
|
||||
AddedLinks: []AddedLink{},
|
||||
AddedComponents: []AddedComponent{},
|
||||
ExposedComponents: []ExposedComponent{},
|
||||
ExtensionPoints: []ExtensionPoint{},
|
||||
},
|
||||
|
||||
Dependencies: Dependencies{
|
||||
GrafanaVersion: "3.x.x",
|
||||
Plugins: []Dependency{
|
||||
{Type: "datasource", ID: "graphite", Name: "Graphite", Version: "1.0.0"},
|
||||
{Type: "panel", ID: "graph", Name: "Graph", Version: "1.0.0"},
|
||||
},
|
||||
Extensions: ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
|
||||
Includes: []*Includes{
|
||||
{Name: "Nginx Connections", Path: "dashboards/connections.json", Type: "dashboard", Role: org.RoleViewer, Action: ActionAppAccess},
|
||||
{Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: org.RoleViewer, Action: ActionAppAccess},
|
||||
@ -94,10 +106,22 @@ func Test_ReadPluginJSON(t *testing.T) {
|
||||
ID: "grafana-piechart-panel",
|
||||
Type: TypePanel,
|
||||
Name: "Pie Chart (old)",
|
||||
|
||||
Extensions: Extensions{
|
||||
AddedLinks: []AddedLink{},
|
||||
AddedComponents: []AddedComponent{},
|
||||
ExposedComponents: []ExposedComponent{},
|
||||
ExtensionPoints: []ExtensionPoint{},
|
||||
},
|
||||
|
||||
Dependencies: Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []Dependency{},
|
||||
Extensions: ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
|
||||
Includes: []*Includes{
|
||||
{Name: "Pie Charts", Path: "dashboards/demo.json", Type: "dashboard", Role: org.RoleViewer},
|
||||
},
|
||||
@ -117,10 +141,21 @@ func Test_ReadPluginJSON(t *testing.T) {
|
||||
ID: "grafana-pyroscope-datasource",
|
||||
AliasIDs: []string{"phlare"}, // Hardcoded from the parser
|
||||
Type: TypeDataSource,
|
||||
|
||||
Extensions: Extensions{
|
||||
AddedLinks: []AddedLink{},
|
||||
AddedComponents: []AddedComponent{},
|
||||
ExposedComponents: []ExposedComponent{},
|
||||
ExtensionPoints: []ExtensionPoint{},
|
||||
},
|
||||
|
||||
Dependencies: Dependencies{
|
||||
GrafanaDependency: "",
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []Dependency{},
|
||||
Extensions: ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -142,18 +177,257 @@ func Test_ReadPluginJSON(t *testing.T) {
|
||||
Dependencies: Dependencies{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "can read the latest versions of extensions information (v2)",
|
||||
pluginJSON: func(t *testing.T) io.ReadCloser {
|
||||
pJSON := `{
|
||||
"id": "myorg-extensions-app",
|
||||
"name": "Extensions App",
|
||||
"type": "app",
|
||||
"extensions": {
|
||||
"addedLinks": [
|
||||
{
|
||||
"title": "Added link 1",
|
||||
"description": "Added link 1 description",
|
||||
"targets": ["grafana/dashboard/panel/menu"]
|
||||
}
|
||||
],
|
||||
"addedComponents": [
|
||||
{
|
||||
"title": "Added component 1",
|
||||
"description": "Added component 1 description",
|
||||
"targets": ["grafana/user/profile/tab"]
|
||||
}
|
||||
],
|
||||
"exposedComponents": [
|
||||
{
|
||||
"title": "Exposed component 1",
|
||||
"description": "Exposed component 1 description",
|
||||
"id": "myorg-extensions-app/component-1/v1"
|
||||
}
|
||||
],
|
||||
"extensionPoints": [
|
||||
{
|
||||
"title": "Extension point 1",
|
||||
"description": "Extension points 1 description",
|
||||
"id": "myorg-extensions-app/extensions-point-1/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}`
|
||||
return io.NopCloser(strings.NewReader(pJSON))
|
||||
},
|
||||
expected: JSONData{
|
||||
ID: "myorg-extensions-app",
|
||||
Name: "Extensions App",
|
||||
Type: TypeApp,
|
||||
|
||||
Extensions: Extensions{
|
||||
AddedLinks: []AddedLink{
|
||||
{Title: "Added link 1", Description: "Added link 1 description", Targets: []string{"grafana/dashboard/panel/menu"}},
|
||||
},
|
||||
AddedComponents: []AddedComponent{
|
||||
{Title: "Added component 1", Description: "Added component 1 description", Targets: []string{"grafana/user/profile/tab"}},
|
||||
},
|
||||
ExposedComponents: []ExposedComponent{
|
||||
{Id: "myorg-extensions-app/component-1/v1", Title: "Exposed component 1", Description: "Exposed component 1 description"},
|
||||
},
|
||||
ExtensionPoints: []ExtensionPoint{
|
||||
{Id: "myorg-extensions-app/extensions-point-1/v1", Title: "Extension point 1", Description: "Extension points 1 description"},
|
||||
},
|
||||
},
|
||||
|
||||
Dependencies: Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []Dependency{},
|
||||
Extensions: ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "can read deprecated extensions info (v1) and parse it as v2",
|
||||
pluginJSON: func(t *testing.T) io.ReadCloser {
|
||||
pJSON := `{
|
||||
"id": "myorg-extensions-app",
|
||||
"name": "Extensions App",
|
||||
"type": "app",
|
||||
"extensions": [
|
||||
{
|
||||
"extensionPointId": "grafana/dashboard/panel/menu",
|
||||
"title": "Added link 1",
|
||||
"description": "Added link 1 description",
|
||||
"type": "link"
|
||||
},
|
||||
{
|
||||
"extensionPointId": "grafana/dashboard/panel/menu",
|
||||
"title": "Added link 2",
|
||||
"description": "Added link 2 description",
|
||||
"type": "link"
|
||||
},
|
||||
{
|
||||
"extensionPointId": "grafana/user/profile/tab",
|
||||
"title": "Added component 1",
|
||||
"description": "Added component 1 description",
|
||||
"type": "component"
|
||||
}
|
||||
]
|
||||
}`
|
||||
return io.NopCloser(strings.NewReader(pJSON))
|
||||
},
|
||||
expected: JSONData{
|
||||
ID: "myorg-extensions-app",
|
||||
Name: "Extensions App",
|
||||
Type: TypeApp,
|
||||
|
||||
Extensions: Extensions{
|
||||
AddedLinks: []AddedLink{
|
||||
{Title: "Added link 1", Description: "Added link 1 description", Targets: []string{"grafana/dashboard/panel/menu"}},
|
||||
{Title: "Added link 2", Description: "Added link 2 description", Targets: []string{"grafana/dashboard/panel/menu"}},
|
||||
},
|
||||
AddedComponents: []AddedComponent{
|
||||
{Title: "Added component 1", Description: "Added component 1 description", Targets: []string{"grafana/user/profile/tab"}},
|
||||
},
|
||||
ExposedComponents: []ExposedComponent{},
|
||||
ExtensionPoints: []ExtensionPoint{},
|
||||
},
|
||||
|
||||
Dependencies: Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []Dependency{},
|
||||
Extensions: ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "works if extensions info is empty",
|
||||
pluginJSON: func(t *testing.T) io.ReadCloser {
|
||||
pJSON := `{
|
||||
"id": "myorg-extensions-app",
|
||||
"name": "Extensions App",
|
||||
"type": "app",
|
||||
"extensions": []
|
||||
}`
|
||||
return io.NopCloser(strings.NewReader(pJSON))
|
||||
},
|
||||
expected: JSONData{
|
||||
ID: "myorg-extensions-app",
|
||||
Name: "Extensions App",
|
||||
Type: TypeApp,
|
||||
|
||||
Extensions: Extensions{
|
||||
AddedLinks: []AddedLink{},
|
||||
AddedComponents: []AddedComponent{},
|
||||
ExposedComponents: []ExposedComponent{},
|
||||
ExtensionPoints: []ExtensionPoint{},
|
||||
},
|
||||
|
||||
Dependencies: Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []Dependency{},
|
||||
Extensions: ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "works if extensions info is completely missing",
|
||||
pluginJSON: func(t *testing.T) io.ReadCloser {
|
||||
pJSON := `{
|
||||
"id": "myorg-extensions-app",
|
||||
"name": "Extensions App",
|
||||
"type": "app"
|
||||
}`
|
||||
return io.NopCloser(strings.NewReader(pJSON))
|
||||
},
|
||||
expected: JSONData{
|
||||
ID: "myorg-extensions-app",
|
||||
Name: "Extensions App",
|
||||
Type: TypeApp,
|
||||
|
||||
Extensions: Extensions{
|
||||
AddedLinks: []AddedLink{},
|
||||
AddedComponents: []AddedComponent{},
|
||||
ExposedComponents: []ExposedComponent{},
|
||||
ExtensionPoints: []ExtensionPoint{},
|
||||
},
|
||||
|
||||
Dependencies: Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []Dependency{},
|
||||
Extensions: ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "can read extensions related dependencies",
|
||||
pluginJSON: func(t *testing.T) io.ReadCloser {
|
||||
pJSON := `{
|
||||
"id": "myorg-extensions-app",
|
||||
"name": "Extensions App",
|
||||
"type": "app",
|
||||
"dependencies": {
|
||||
"grafanaDependency": "10.0.0",
|
||||
"extensions": {
|
||||
"exposedComponents": ["myorg-extensions-app/component-1/v1"]
|
||||
}
|
||||
}
|
||||
}`
|
||||
return io.NopCloser(strings.NewReader(pJSON))
|
||||
},
|
||||
expected: JSONData{
|
||||
ID: "myorg-extensions-app",
|
||||
Name: "Extensions App",
|
||||
Type: TypeApp,
|
||||
|
||||
Extensions: Extensions{
|
||||
AddedLinks: []AddedLink{},
|
||||
AddedComponents: []AddedComponent{},
|
||||
ExposedComponents: []ExposedComponent{},
|
||||
ExtensionPoints: []ExtensionPoint{},
|
||||
},
|
||||
|
||||
Dependencies: Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
GrafanaDependency: "10.0.0",
|
||||
Plugins: []Dependency{},
|
||||
Extensions: ExtensionsDependencies{
|
||||
ExposedComponents: []string{"myorg-extensions-app/component-1/v1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := tt.pluginJSON(t)
|
||||
got, err := ReadPluginJSON(p)
|
||||
|
||||
// Check if the test returns the same error as expected
|
||||
// (unneccary to check further if there is an error at this point)
|
||||
if tt.err == nil && err != nil {
|
||||
t.Errorf("Error while reading pluginJSON: %+v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the test returns the same error as expected
|
||||
if tt.err != nil {
|
||||
require.ErrorIs(t, err, tt.err)
|
||||
}
|
||||
|
||||
// Check if the test returns the expected pluginJSONData
|
||||
if !cmp.Equal(got, tt.expected) {
|
||||
t.Errorf("Unexpected pluginJSONData: %v", cmp.Diff(got, tt.expected))
|
||||
}
|
||||
|
||||
// Should be able to close the reader
|
||||
require.NoError(t, p.Close())
|
||||
})
|
||||
}
|
||||
|
@ -98,6 +98,15 @@ func TestLoader_Load(t *testing.T) {
|
||||
Dependencies: plugins.Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
Category: "cloud",
|
||||
Annotations: true,
|
||||
@ -141,6 +150,15 @@ func TestLoader_Load(t *testing.T) {
|
||||
Dependencies: plugins.Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
Executable: "test",
|
||||
Backend: true,
|
||||
@ -195,6 +213,9 @@ func TestLoader_Load(t *testing.T) {
|
||||
{Type: "datasource", ID: "graphite", Name: "Graphite", Version: "1.0.0"},
|
||||
{Type: "panel", ID: "graph", Name: "Graph", Version: "1.0.0"},
|
||||
},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Includes: []*plugins.Includes{
|
||||
{
|
||||
@ -228,6 +249,12 @@ func TestLoader_Load(t *testing.T) {
|
||||
Slug: "nginx-datasource",
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
},
|
||||
Class: plugins.ClassExternal,
|
||||
Module: "public/plugins/test-app/module.js",
|
||||
@ -266,6 +293,15 @@ func TestLoader_Load(t *testing.T) {
|
||||
Dependencies: plugins.Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
Backend: true,
|
||||
State: plugins.ReleaseStateAlpha,
|
||||
@ -318,6 +354,15 @@ func TestLoader_Load(t *testing.T) {
|
||||
Dependencies: plugins.Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
Backend: true,
|
||||
State: plugins.ReleaseStateAlpha,
|
||||
@ -423,6 +468,15 @@ func TestLoader_Load(t *testing.T) {
|
||||
GrafanaDependency: ">=8.0.0",
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
Includes: []*plugins.Includes{
|
||||
{Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-memory"},
|
||||
@ -497,6 +551,15 @@ func TestLoader_Load_ExternalRegistration(t *testing.T) {
|
||||
Dependencies: plugins.Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
IAM: &pfs.IAM{
|
||||
Permissions: []pfs.Permission{
|
||||
@ -599,6 +662,15 @@ func TestLoader_Load_CustomSource(t *testing.T) {
|
||||
Dependencies: plugins.Dependencies{
|
||||
GrafanaVersion: "3.x.x",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
},
|
||||
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "cdn/plugin")),
|
||||
@ -671,6 +743,15 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) {
|
||||
Dependencies: plugins.Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
Backend: true,
|
||||
Executable: "test",
|
||||
@ -767,6 +848,15 @@ func TestLoader_Load_RBACReady(t *testing.T) {
|
||||
GrafanaVersion: "*",
|
||||
GrafanaDependency: ">=8.0.0",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
Includes: []*plugins.Includes{},
|
||||
Roles: []plugins.RoleRegistration{
|
||||
@ -841,7 +931,15 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) {
|
||||
Version: "1.0.0",
|
||||
},
|
||||
State: plugins.ReleaseStateAlpha,
|
||||
Dependencies: plugins.Dependencies{GrafanaVersion: "*", Plugins: []plugins.Dependency{}},
|
||||
Dependencies: plugins.Dependencies{GrafanaVersion: "*", Plugins: []plugins.Dependency{}, Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
}},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
Backend: true,
|
||||
Executable: "test",
|
||||
},
|
||||
@ -913,6 +1011,15 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) {
|
||||
{Type: "datasource", ID: "graphite", Name: "Graphite", Version: "1.0.0"},
|
||||
{Type: "panel", ID: "graph", Name: "Graph", Version: "1.0.0"},
|
||||
},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
Includes: []*plugins.Includes{
|
||||
{Name: "Nginx Connections", Path: "dashboards/connections.json", Type: "dashboard", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-connections"},
|
||||
@ -994,6 +1101,9 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) {
|
||||
{Type: "datasource", ID: "graphite", Name: "Graphite", Version: "1.0.0"},
|
||||
{Type: "panel", ID: "graph", Name: "Graph", Version: "1.0.0"},
|
||||
},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Includes: []*plugins.Includes{
|
||||
{Name: "Nginx Connections", Path: "dashboards/connections.json", Type: "dashboard", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-connections"},
|
||||
@ -1001,6 +1111,12 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) {
|
||||
{Name: "Nginx Panel", Type: "panel", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-panel"},
|
||||
{Name: "Nginx Datasource", Type: "datasource", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-datasource"},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
Backend: false,
|
||||
},
|
||||
FS: mustNewStaticFSForTests(t, pluginDir1),
|
||||
@ -1208,6 +1324,15 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
|
||||
Dependencies: plugins.Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
Backend: true,
|
||||
},
|
||||
@ -1241,6 +1366,15 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
|
||||
Dependencies: plugins.Dependencies{
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
},
|
||||
Module: "public/plugins/test-datasource/nested/module.js",
|
||||
@ -1336,6 +1470,9 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
|
||||
GrafanaVersion: "7.0.0",
|
||||
GrafanaDependency: ">=7.0.0",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Includes: []*plugins.Includes{
|
||||
{
|
||||
@ -1382,6 +1519,12 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
|
||||
Slug: "lots-of-stats",
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
Backend: false,
|
||||
},
|
||||
Module: "public/plugins/myorgid-simple-app/module.js",
|
||||
@ -1421,6 +1564,15 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
|
||||
GrafanaDependency: ">=7.0.0",
|
||||
GrafanaVersion: "*",
|
||||
Plugins: []plugins.Dependency{},
|
||||
Extensions: plugins.ExtensionsDependencies{
|
||||
ExposedComponents: []string{},
|
||||
},
|
||||
},
|
||||
Extensions: plugins.Extensions{
|
||||
AddedLinks: []plugins.AddedLink{},
|
||||
AddedComponents: []plugins.AddedComponent{},
|
||||
ExposedComponents: []plugins.ExposedComponent{},
|
||||
ExtensionPoints: []plugins.ExtensionPoint{},
|
||||
},
|
||||
},
|
||||
Module: "public/plugins/myorgid-simple-app/child/module.js",
|
||||
|
@ -25,7 +25,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -75,7 +78,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -113,7 +119,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -179,7 +188,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "\u003e=10.3.0",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -217,7 +229,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -255,7 +270,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -298,7 +316,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -336,7 +357,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -377,7 +401,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -415,7 +442,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -453,7 +483,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -503,7 +536,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -541,7 +577,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -579,7 +618,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -617,7 +659,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -655,7 +700,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -693,7 +741,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -744,7 +795,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "\u003e=10.3.0-0",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -782,7 +836,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -829,7 +886,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -867,7 +927,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -910,7 +973,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -948,7 +1014,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -995,7 +1064,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "\u003e=10.3.0-0",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1033,7 +1105,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1080,7 +1155,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1118,7 +1196,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "\u003e=10.4.0",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1156,7 +1237,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "\u003e=10.4.0",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1194,7 +1278,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1232,7 +1319,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1270,7 +1360,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1318,7 +1411,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "\u003e=10.3.0-0",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1356,7 +1452,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1394,7 +1493,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1437,7 +1539,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1475,7 +1580,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1513,7 +1621,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1551,7 +1662,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1589,7 +1703,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1627,7 +1744,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1670,7 +1790,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "\u003e=10.3.0-0",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1708,7 +1831,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "\u003e=10.3.0-0",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1746,7 +1872,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1784,7 +1913,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1822,7 +1954,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1860,7 +1995,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1898,7 +2036,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1939,7 +2080,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
@ -1982,7 +2126,10 @@
|
||||
"dependencies": {
|
||||
"grafanaDependency": "\u003e=10.3.0-0",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": []
|
||||
}
|
||||
},
|
||||
"latestVersion": "",
|
||||
"hasUpdate": false,
|
||||
|
@ -19,6 +19,19 @@ export const getPluginsHandler = (pluginsArray: PluginMeta[] = plugins) => {
|
||||
version: info.version,
|
||||
angular: angular ?? { detected: false, hideDeprecation: false },
|
||||
loadingStrategy: PluginLoadingStrategy.script,
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
extensionPoints: [],
|
||||
exposedComponents: [],
|
||||
},
|
||||
dependencies: {
|
||||
grafanaVersion: '',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -49,6 +49,19 @@ describe('getRuleOrigin', () => {
|
||||
preload: true,
|
||||
angular: { detected: false, hideDeprecation: false },
|
||||
loadingStrategy: PluginLoadingStrategy.script,
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
extensionPoints: [],
|
||||
exposedComponents: [],
|
||||
},
|
||||
dependencies: {
|
||||
grafanaVersion: '',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const rule = mockCombinedRule({
|
||||
|
@ -43,6 +43,9 @@ export default {
|
||||
grafanaDependency: '>=7.3.0',
|
||||
grafanaVersion: '7.3',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [],
|
||||
},
|
||||
},
|
||||
info: {
|
||||
links: [],
|
||||
|
@ -1,15 +1,62 @@
|
||||
import React from 'react';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { PluginLoadingStrategy } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { isGrafanaDevMode } from '../utils';
|
||||
|
||||
import { AddedComponentsRegistry } from './AddedComponentsRegistry';
|
||||
import { MSG_CANNOT_REGISTER_READ_ONLY } from './Registry';
|
||||
|
||||
jest.mock('../utils', () => ({
|
||||
...jest.requireActual('../utils'),
|
||||
|
||||
// Manually set the dev mode to false
|
||||
// (to make sure that by default we are testing a production scneario)
|
||||
isGrafanaDevMode: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
describe('AddedComponentsRegistry', () => {
|
||||
const consoleWarn = jest.fn();
|
||||
const originalApps = config.apps;
|
||||
const pluginId = 'grafana-basic-app';
|
||||
const appPluginConfig = {
|
||||
id: pluginId,
|
||||
path: '',
|
||||
version: '',
|
||||
preload: false,
|
||||
angular: {
|
||||
detected: false,
|
||||
hideDeprecation: false,
|
||||
},
|
||||
loadingStrategy: PluginLoadingStrategy.fetch,
|
||||
dependencies: {
|
||||
grafanaVersion: '8.0.0',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [],
|
||||
},
|
||||
},
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
exposedComponents: [],
|
||||
extensionPoints: [],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
global.console.warn = consoleWarn;
|
||||
consoleWarn.mockReset();
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
|
||||
config.apps = {
|
||||
[pluginId]: appPluginConfig,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.apps = originalApps;
|
||||
});
|
||||
|
||||
it('should return empty registry when no extensions registered', async () => {
|
||||
@ -20,15 +67,14 @@ describe('AddedComponentsRegistry', () => {
|
||||
});
|
||||
|
||||
it('should be possible to register added components in the registry', async () => {
|
||||
const pluginId = 'grafana-basic-app';
|
||||
const id = `${pluginId}/hello-world/v1`;
|
||||
const extensionPointId = `${pluginId}/hello-world/v1`;
|
||||
const reactiveRegistry = new AddedComponentsRegistry();
|
||||
|
||||
reactiveRegistry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
targets: [id],
|
||||
targets: [extensionPointId],
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => React.createElement('div', null, 'Hello World'),
|
||||
@ -39,15 +85,17 @@ describe('AddedComponentsRegistry', () => {
|
||||
const registry = await reactiveRegistry.getState();
|
||||
|
||||
expect(Object.keys(registry)).toHaveLength(1);
|
||||
expect(registry[id][0]).toMatchObject({
|
||||
expect(registry[extensionPointId][0]).toMatchObject({
|
||||
pluginId,
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
});
|
||||
});
|
||||
|
||||
it('should be possible to asynchronously register component extensions for the same extension point (different plugins)', async () => {
|
||||
const pluginId1 = 'grafana-basic-app';
|
||||
const pluginId2 = 'grafana-basic-app2';
|
||||
const extensionPointId = 'grafana/alerting/home';
|
||||
const reactiveRegistry = new AddedComponentsRegistry();
|
||||
|
||||
// Register extensions for the first plugin
|
||||
@ -65,7 +113,7 @@ describe('AddedComponentsRegistry', () => {
|
||||
|
||||
const registry1 = await reactiveRegistry.getState();
|
||||
expect(Object.keys(registry1)).toHaveLength(1);
|
||||
expect(registry1['grafana/alerting/home'][0]).toMatchObject({
|
||||
expect(registry1[extensionPointId][0]).toMatchObject({
|
||||
pluginId: pluginId1,
|
||||
title: 'Component 1 title',
|
||||
description: 'Component 1 description',
|
||||
@ -78,7 +126,7 @@ describe('AddedComponentsRegistry', () => {
|
||||
{
|
||||
title: 'Component 2 title',
|
||||
description: 'Component 2 description',
|
||||
targets: ['grafana/alerting/home'],
|
||||
targets: [extensionPointId],
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
],
|
||||
@ -86,7 +134,7 @@ describe('AddedComponentsRegistry', () => {
|
||||
|
||||
const registry2 = await reactiveRegistry.getState();
|
||||
expect(Object.keys(registry2)).toHaveLength(1);
|
||||
expect(registry2['grafana/alerting/home']).toEqual(
|
||||
expect(registry2[extensionPointId]).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pluginId: pluginId1,
|
||||
@ -105,6 +153,8 @@ describe('AddedComponentsRegistry', () => {
|
||||
it('should be possible to asynchronously register component extensions for a different extension points (different plugin)', async () => {
|
||||
const pluginId1 = 'grafana-basic-app';
|
||||
const pluginId2 = 'grafana-basic-app2';
|
||||
const extensionPointId1 = 'grafana/alerting/home';
|
||||
const extensionPointId2 = 'grafana/user/profile/tab';
|
||||
const reactiveRegistry = new AddedComponentsRegistry();
|
||||
|
||||
// Register extensions for the first plugin
|
||||
@ -114,7 +164,7 @@ describe('AddedComponentsRegistry', () => {
|
||||
{
|
||||
title: 'Component 1 title',
|
||||
description: 'Component 1 description',
|
||||
targets: ['grafana/alerting/home'],
|
||||
targets: [extensionPointId1],
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
],
|
||||
@ -122,7 +172,7 @@ describe('AddedComponentsRegistry', () => {
|
||||
|
||||
const registry1 = await reactiveRegistry.getState();
|
||||
expect(registry1).toEqual({
|
||||
'grafana/alerting/home': expect.arrayContaining([
|
||||
[extensionPointId1]: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pluginId: pluginId1,
|
||||
title: 'Component 1 title',
|
||||
@ -138,7 +188,7 @@ describe('AddedComponentsRegistry', () => {
|
||||
{
|
||||
title: 'Component 2 title',
|
||||
description: 'Component 2 description',
|
||||
targets: ['grafana/user/profile/tab'],
|
||||
targets: [extensionPointId2],
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
],
|
||||
@ -147,14 +197,14 @@ describe('AddedComponentsRegistry', () => {
|
||||
const registry2 = await reactiveRegistry.getState();
|
||||
|
||||
expect(registry2).toEqual({
|
||||
'grafana/alerting/home': expect.arrayContaining([
|
||||
[extensionPointId1]: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pluginId: pluginId1,
|
||||
title: 'Component 1 title',
|
||||
description: 'Component 1 description',
|
||||
}),
|
||||
]),
|
||||
'grafana/user/profile/tab': expect.arrayContaining([
|
||||
[extensionPointId2]: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pluginId: pluginId2,
|
||||
title: 'Component 2 title',
|
||||
@ -165,8 +215,8 @@ describe('AddedComponentsRegistry', () => {
|
||||
});
|
||||
|
||||
it('should be possible to asynchronously register component extensions for the same extension point (same plugin)', async () => {
|
||||
const pluginId = 'grafana-basic-app';
|
||||
const reactiveRegistry = new AddedComponentsRegistry();
|
||||
const extensionPointId = 'grafana/alerting/home';
|
||||
|
||||
// Register extensions for the first extension point
|
||||
reactiveRegistry.register({
|
||||
@ -175,20 +225,20 @@ describe('AddedComponentsRegistry', () => {
|
||||
{
|
||||
title: 'Component 1 title',
|
||||
description: 'Component 1 description',
|
||||
targets: ['grafana/alerting/home'],
|
||||
targets: [extensionPointId],
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
{
|
||||
title: 'Component 2 title',
|
||||
description: 'Component 2 description',
|
||||
targets: ['grafana/alerting/home'],
|
||||
targets: [extensionPointId],
|
||||
component: () => React.createElement('div', null, 'Hello World2'),
|
||||
},
|
||||
],
|
||||
});
|
||||
const registry1 = await reactiveRegistry.getState();
|
||||
expect(registry1).toEqual({
|
||||
'grafana/alerting/home': expect.arrayContaining([
|
||||
[extensionPointId]: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pluginId: pluginId,
|
||||
title: 'Component 1 title',
|
||||
@ -204,8 +254,9 @@ describe('AddedComponentsRegistry', () => {
|
||||
});
|
||||
|
||||
it('should be possible to register one extension component targeting multiple extension points', async () => {
|
||||
const pluginId = 'grafana-basic-app';
|
||||
const reactiveRegistry = new AddedComponentsRegistry();
|
||||
const extensionPointId1 = 'grafana/alerting/home';
|
||||
const extensionPointId2 = 'grafana/user/profile/tab';
|
||||
|
||||
reactiveRegistry.register({
|
||||
pluginId: pluginId,
|
||||
@ -213,21 +264,21 @@ describe('AddedComponentsRegistry', () => {
|
||||
{
|
||||
title: 'Component 1 title',
|
||||
description: 'Component 1 description',
|
||||
targets: ['grafana/alerting/home', 'grafana/user/profile/tab'],
|
||||
targets: [extensionPointId1, extensionPointId2],
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
],
|
||||
});
|
||||
const registry1 = await reactiveRegistry.getState();
|
||||
expect(registry1).toEqual({
|
||||
'grafana/alerting/home': expect.arrayContaining([
|
||||
[extensionPointId1]: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pluginId: pluginId,
|
||||
title: 'Component 1 title',
|
||||
description: 'Component 1 description',
|
||||
}),
|
||||
]),
|
||||
'grafana/user/profile/tab': expect.arrayContaining([
|
||||
[extensionPointId2]: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pluginId: pluginId,
|
||||
title: 'Component 1 title',
|
||||
@ -239,7 +290,9 @@ describe('AddedComponentsRegistry', () => {
|
||||
|
||||
it('should notify subscribers when the registry changes', async () => {
|
||||
const pluginId1 = 'grafana-basic-app';
|
||||
const pluginId2 = 'another-plugin';
|
||||
const pluginId2 = 'myorg-extensions-app';
|
||||
const extensionPointId1 = 'grafana/alerting/home';
|
||||
const extensionPointId2 = 'grafana/user/profile/tab';
|
||||
const reactiveRegistry = new AddedComponentsRegistry();
|
||||
const observable = reactiveRegistry.asObservable();
|
||||
const subscribeCallback = jest.fn();
|
||||
@ -252,7 +305,7 @@ describe('AddedComponentsRegistry', () => {
|
||||
{
|
||||
title: 'Component 1 title',
|
||||
description: 'Component 1 description',
|
||||
targets: ['grafana/alerting/home'],
|
||||
targets: [extensionPointId1],
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
],
|
||||
@ -266,7 +319,7 @@ describe('AddedComponentsRegistry', () => {
|
||||
{
|
||||
title: 'Component 2 title',
|
||||
description: 'Component 2 description',
|
||||
targets: ['grafana/user/profile/tab'],
|
||||
targets: [extensionPointId2],
|
||||
component: () => React.createElement('div', null, 'Hello World2'),
|
||||
},
|
||||
],
|
||||
@ -277,14 +330,14 @@ describe('AddedComponentsRegistry', () => {
|
||||
const registry = subscribeCallback.mock.calls[2][0];
|
||||
|
||||
expect(registry).toEqual({
|
||||
'grafana/alerting/home': expect.arrayContaining([
|
||||
[extensionPointId1]: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pluginId: pluginId1,
|
||||
title: 'Component 1 title',
|
||||
description: 'Component 1 description',
|
||||
}),
|
||||
]),
|
||||
'grafana/user/profile/tab': expect.arrayContaining([
|
||||
[extensionPointId2]: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pluginId: pluginId2,
|
||||
title: 'Component 2 title',
|
||||
@ -294,43 +347,24 @@ describe('AddedComponentsRegistry', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip registering component and log a warning when id is not prefixed with plugin id or grafana', async () => {
|
||||
const registry = new AddedComponentsRegistry();
|
||||
registry.register({
|
||||
pluginId: 'grafana-basic-app',
|
||||
configs: [
|
||||
{
|
||||
title: 'Component 1 title',
|
||||
description: 'Component 1 description',
|
||||
targets: ['alerting/home'],
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(consoleWarn).toHaveBeenCalledWith(
|
||||
"[Plugin Extensions] Could not register added component with id 'alerting/home'. Reason: The component id does not match the id naming convention. Id should be prefixed with plugin id or grafana. e.g '<grafana|myorg-basic-app>/my-component-id/v1'."
|
||||
);
|
||||
const currentState = await registry.getState();
|
||||
expect(Object.keys(currentState)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should log a warning when added component id is not suffixed with component version', async () => {
|
||||
const registry = new AddedComponentsRegistry();
|
||||
const extensionPointId = 'grafana/test/home';
|
||||
|
||||
registry.register({
|
||||
pluginId: 'grafana-basic-app',
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
title: 'Component 1 title',
|
||||
description: 'Component 1 description',
|
||||
targets: ['grafana/test/home'],
|
||||
targets: [extensionPointId],
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(consoleWarn).toHaveBeenCalledWith(
|
||||
"[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'."
|
||||
`[Plugin Extensions] Added component "Component 1 title": it's recommended to suffix the extension point id ("${extensionPointId}") with a version, e.g 'myorg-basic-app/extension-point/v1'.`
|
||||
);
|
||||
const currentState = await registry.getState();
|
||||
expect(Object.keys(currentState)).toHaveLength(1);
|
||||
@ -338,13 +372,15 @@ describe('AddedComponentsRegistry', () => {
|
||||
|
||||
it('should not register component when description is missing', async () => {
|
||||
const registry = new AddedComponentsRegistry();
|
||||
const extensionPointId = 'grafana/alerting/home';
|
||||
|
||||
registry.register({
|
||||
pluginId: 'grafana-basic-app',
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
title: 'Component 1 title',
|
||||
description: '',
|
||||
targets: ['grafana/alerting/home'],
|
||||
targets: [extensionPointId],
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
],
|
||||
@ -359,13 +395,15 @@ describe('AddedComponentsRegistry', () => {
|
||||
|
||||
it('should not register component when title is missing', async () => {
|
||||
const registry = new AddedComponentsRegistry();
|
||||
const extensionPointId = 'grafana/alerting/home';
|
||||
|
||||
registry.register({
|
||||
pluginId: 'grafana-basic-app',
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
title: 'Component 1 title',
|
||||
description: '',
|
||||
targets: ['grafana/alerting/home'],
|
||||
targets: [extensionPointId],
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
],
|
||||
@ -382,15 +420,16 @@ describe('AddedComponentsRegistry', () => {
|
||||
it('should not be possible to register a component on a read-only registry', async () => {
|
||||
const registry = new AddedComponentsRegistry();
|
||||
const readOnlyRegistry = registry.readOnly();
|
||||
const extensionPointId = 'grafana/alerting/home';
|
||||
|
||||
expect(() => {
|
||||
readOnlyRegistry.register({
|
||||
pluginId: 'grafana-basic-app',
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
title: 'Component 1 title',
|
||||
description: '',
|
||||
targets: ['grafana/alerting/home'],
|
||||
targets: [extensionPointId],
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
},
|
||||
],
|
||||
@ -434,4 +473,105 @@ describe('AddedComponentsRegistry', () => {
|
||||
expect(subscribeCallback).toHaveBeenCalledTimes(2);
|
||||
expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual(['grafana/alerting/home']);
|
||||
});
|
||||
|
||||
it('should not register a component added by a plugin in dev-mode if the meta-info is missing from the plugin.json', async () => {
|
||||
// Enabling dev mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
|
||||
|
||||
const registry = new AddedComponentsRegistry();
|
||||
const componentConfig = {
|
||||
title: 'Component title',
|
||||
description: 'Component description',
|
||||
targets: ['grafana/alerting/home'],
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
};
|
||||
|
||||
// Make sure that the meta-info is empty
|
||||
config.apps[pluginId].extensions.addedComponents = [];
|
||||
|
||||
registry.register({
|
||||
pluginId,
|
||||
configs: [componentConfig],
|
||||
});
|
||||
|
||||
const currentState = await registry.getState();
|
||||
|
||||
expect(Object.keys(currentState)).toHaveLength(0);
|
||||
expect(consoleWarn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should register a component added by a core Grafana in dev-mode even if the meta-info is missing', async () => {
|
||||
// Enabling dev mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
|
||||
|
||||
const registry = new AddedComponentsRegistry();
|
||||
const componentConfig = {
|
||||
title: 'Component title',
|
||||
description: 'Component description',
|
||||
targets: ['grafana/alerting/home'],
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
};
|
||||
|
||||
registry.register({
|
||||
pluginId: 'grafana',
|
||||
configs: [componentConfig],
|
||||
});
|
||||
|
||||
const currentState = await registry.getState();
|
||||
|
||||
expect(Object.keys(currentState)).toHaveLength(1);
|
||||
expect(consoleWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should register a component added by a plugin in production mode even if the meta-info is missing', async () => {
|
||||
// Production mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
|
||||
|
||||
const registry = new AddedComponentsRegistry();
|
||||
const componentConfig = {
|
||||
title: 'Component title',
|
||||
description: 'Component description',
|
||||
targets: ['grafana/alerting/home'],
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
};
|
||||
|
||||
// Make sure that the meta-info is empty
|
||||
config.apps[pluginId].extensions.addedComponents = [];
|
||||
|
||||
registry.register({
|
||||
pluginId,
|
||||
configs: [componentConfig],
|
||||
});
|
||||
|
||||
const currentState = await registry.getState();
|
||||
|
||||
expect(Object.keys(currentState)).toHaveLength(1);
|
||||
expect(consoleWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should register a component added by a plugin in dev-mode if the meta-info is present', async () => {
|
||||
// Enabling dev mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
|
||||
|
||||
const registry = new AddedComponentsRegistry();
|
||||
const componentConfig = {
|
||||
title: 'Component title',
|
||||
description: 'Component description',
|
||||
targets: ['grafana/alerting/home'],
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
};
|
||||
|
||||
// Make sure that the meta-info is empty
|
||||
config.apps[pluginId].extensions.addedComponents = [componentConfig];
|
||||
|
||||
registry.register({
|
||||
pluginId,
|
||||
configs: [componentConfig],
|
||||
});
|
||||
|
||||
const currentState = await registry.getState();
|
||||
|
||||
expect(Object.keys(currentState)).toHaveLength(1);
|
||||
expect(consoleWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
@ -2,13 +2,8 @@ import { ReplaySubject } from 'rxjs';
|
||||
|
||||
import { PluginExtensionAddedComponentConfig } from '@grafana/data';
|
||||
|
||||
import { logWarning, wrapWithPluginContext } from '../utils';
|
||||
import {
|
||||
extensionPointEndsWithVersion,
|
||||
isExtensionPointIdValid,
|
||||
isGrafanaCoreExtensionPoint,
|
||||
isReactComponent,
|
||||
} from '../validators';
|
||||
import { isAddedComponentMetaInfoMissing, isGrafanaDevMode, logWarning, wrapWithPluginContext } from '../utils';
|
||||
import { extensionPointEndsWithVersion, isGrafanaCoreExtensionPoint, isReactComponent } from '../validators';
|
||||
|
||||
import { PluginExtensionConfigs, Registry, RegistryType } from './Registry';
|
||||
|
||||
@ -56,18 +51,15 @@ export class AddedComponentsRegistry extends Registry<
|
||||
continue;
|
||||
}
|
||||
|
||||
const extensionPointIds = Array.isArray(config.targets) ? config.targets : [config.targets];
|
||||
for (const extensionPointId of extensionPointIds) {
|
||||
if (!isExtensionPointIdValid(pluginId, extensionPointId)) {
|
||||
logWarning(
|
||||
`Could not register added component with id '${extensionPointId}'. Reason: The component id does not match the id naming convention. Id should be prefixed with plugin id or grafana. e.g '<grafana|myorg-basic-app>/my-component-id/v1'.`
|
||||
);
|
||||
if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedComponentMetaInfoMissing(pluginId, config)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const extensionPointIds = Array.isArray(config.targets) ? config.targets : [config.targets];
|
||||
for (const extensionPointId of extensionPointIds) {
|
||||
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'.`
|
||||
`Added component "${config.title}": it's recommended to suffix the extension point id ("${extensionPointId}") with a version, e.g 'myorg-basic-app/extension-point/v1'.`
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,14 +1,61 @@
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { PluginLoadingStrategy } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { isGrafanaDevMode } from '../utils';
|
||||
|
||||
import { AddedLinksRegistry } from './AddedLinksRegistry';
|
||||
import { MSG_CANNOT_REGISTER_READ_ONLY } from './Registry';
|
||||
|
||||
jest.mock('../utils', () => ({
|
||||
...jest.requireActual('../utils'),
|
||||
|
||||
// Manually set the dev mode to false
|
||||
// (to make sure that by default we are testing a production scneario)
|
||||
isGrafanaDevMode: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
describe('AddedLinksRegistry', () => {
|
||||
const originalApps = config.apps;
|
||||
const consoleWarn = jest.fn();
|
||||
const pluginId = 'grafana-basic-app';
|
||||
const appPluginConfig = {
|
||||
id: pluginId,
|
||||
path: '',
|
||||
version: '',
|
||||
preload: false,
|
||||
angular: {
|
||||
detected: false,
|
||||
hideDeprecation: false,
|
||||
},
|
||||
loadingStrategy: PluginLoadingStrategy.fetch,
|
||||
dependencies: {
|
||||
grafanaVersion: '8.0.0',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [],
|
||||
},
|
||||
},
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
exposedComponents: [],
|
||||
extensionPoints: [],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
global.console.warn = consoleWarn;
|
||||
consoleWarn.mockReset();
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
|
||||
config.apps = {
|
||||
[pluginId]: appPluginConfig,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.apps = originalApps;
|
||||
});
|
||||
|
||||
it('should return empty registry when no extensions registered', async () => {
|
||||
@ -19,7 +66,6 @@ describe('AddedLinksRegistry', () => {
|
||||
});
|
||||
|
||||
it('should be possible to register link extensions in the registry', async () => {
|
||||
const pluginId = 'grafana-basic-app';
|
||||
const addedLinksRegistry = new AddedLinksRegistry();
|
||||
|
||||
addedLinksRegistry.register({
|
||||
@ -580,4 +626,109 @@ describe('AddedLinksRegistry', () => {
|
||||
expect(subscribeCallback).toHaveBeenCalledTimes(2);
|
||||
expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual(['plugins/myorg-basic-app/start']);
|
||||
});
|
||||
|
||||
it('should not register a link added by a plugin in dev-mode if the meta-info is missing from the plugin.json', async () => {
|
||||
// Enabling dev mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
|
||||
|
||||
const registry = new AddedLinksRegistry();
|
||||
const linkConfig = {
|
||||
title: 'Link 1',
|
||||
description: 'Link 1 description',
|
||||
path: `/a/${pluginId}/declare-incident`,
|
||||
targets: 'grafana/dashboard/panel/menu',
|
||||
configure: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
|
||||
// Make sure that the meta-info is empty
|
||||
config.apps[pluginId].extensions.addedLinks = [];
|
||||
|
||||
registry.register({
|
||||
pluginId,
|
||||
configs: [linkConfig],
|
||||
});
|
||||
|
||||
const currentState = await registry.getState();
|
||||
|
||||
expect(Object.keys(currentState)).toHaveLength(0);
|
||||
expect(consoleWarn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should register a link added by core Grafana in dev-mode even if the meta-info is missing', async () => {
|
||||
// Enabling dev mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
|
||||
|
||||
const registry = new AddedLinksRegistry();
|
||||
const linkConfig = {
|
||||
title: 'Link 1',
|
||||
description: 'Link 1 description',
|
||||
path: `/a/grafana/declare-incident`,
|
||||
targets: 'grafana/dashboard/panel/menu',
|
||||
configure: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
|
||||
registry.register({
|
||||
pluginId: 'grafana',
|
||||
configs: [linkConfig],
|
||||
});
|
||||
|
||||
const currentState = await registry.getState();
|
||||
|
||||
expect(Object.keys(currentState)).toHaveLength(1);
|
||||
expect(consoleWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should register a link added by a plugin in production mode even if the meta-info is missing', async () => {
|
||||
// Production mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
|
||||
|
||||
const registry = new AddedLinksRegistry();
|
||||
const linkConfig = {
|
||||
title: 'Link 1',
|
||||
description: 'Link 1 description',
|
||||
path: `/a/${pluginId}/declare-incident`,
|
||||
targets: 'grafana/dashboard/panel/menu',
|
||||
configure: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
|
||||
// Make sure that the meta-info is empty
|
||||
config.apps[pluginId].extensions.addedLinks = [];
|
||||
|
||||
registry.register({
|
||||
pluginId,
|
||||
configs: [linkConfig],
|
||||
});
|
||||
|
||||
const currentState = await registry.getState();
|
||||
|
||||
expect(Object.keys(currentState)).toHaveLength(1);
|
||||
expect(consoleWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should register a link added by a plugin in dev-mode if the meta-info is present', async () => {
|
||||
// Enabling dev mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
|
||||
|
||||
const registry = new AddedLinksRegistry();
|
||||
const linkConfig = {
|
||||
title: 'Link 1',
|
||||
description: 'Link 1 description',
|
||||
path: `/a/${pluginId}/declare-incident`,
|
||||
targets: ['grafana/dashboard/panel/menu'],
|
||||
configure: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
|
||||
// Make sure that the meta-info is empty
|
||||
config.apps[pluginId].extensions.addedLinks = [linkConfig];
|
||||
|
||||
registry.register({
|
||||
pluginId,
|
||||
configs: [linkConfig],
|
||||
});
|
||||
|
||||
const currentState = await registry.getState();
|
||||
|
||||
expect(Object.keys(currentState)).toHaveLength(1);
|
||||
expect(consoleWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
@ -3,11 +3,10 @@ import { ReplaySubject } from 'rxjs';
|
||||
import { IconName, PluginExtensionAddedLinkConfig } from '@grafana/data';
|
||||
import { PluginAddedLinksConfigureFunc, PluginExtensionEventHelpers } from '@grafana/data/src/types/pluginExtensions';
|
||||
|
||||
import { logWarning } from '../utils';
|
||||
import { isAddedLinkMetaInfoMissing, isGrafanaDevMode, logWarning } from '../utils';
|
||||
import {
|
||||
extensionPointEndsWithVersion,
|
||||
isConfigureFnValid,
|
||||
isExtensionPointIdValid,
|
||||
isGrafanaCoreExtensionPoint,
|
||||
isLinkPathValid,
|
||||
} from '../validators';
|
||||
@ -73,18 +72,15 @@ export class AddedLinksRegistry extends Registry<AddedLinkRegistryItem[], Plugin
|
||||
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.`
|
||||
);
|
||||
if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedLinkMetaInfoMissing(pluginId, config)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const extensionPointIds = Array.isArray(targets) ? targets : [targets];
|
||||
for (const extensionPointId of extensionPointIds) {
|
||||
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'.`
|
||||
`Added link "${config.title}: it's recommended to suffix the extension point id ("${extensionPointId}") with a version, e.g 'myorg-basic-app/extension-point/v1'.`
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,15 +1,62 @@
|
||||
import React from 'react';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { PluginLoadingStrategy } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { isGrafanaDevMode } from '../utils';
|
||||
|
||||
import { ExposedComponentsRegistry } from './ExposedComponentsRegistry';
|
||||
import { MSG_CANNOT_REGISTER_READ_ONLY } from './Registry';
|
||||
|
||||
jest.mock('../utils', () => ({
|
||||
...jest.requireActual('../utils'),
|
||||
|
||||
// Manually set the dev mode to false
|
||||
// (to make sure that by default we are testing a production scneario)
|
||||
isGrafanaDevMode: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
describe('ExposedComponentsRegistry', () => {
|
||||
const consoleWarn = jest.fn();
|
||||
const originalApps = config.apps;
|
||||
const pluginId = 'grafana-basic-app';
|
||||
const appPluginConfig = {
|
||||
id: pluginId,
|
||||
path: '',
|
||||
version: '',
|
||||
preload: false,
|
||||
angular: {
|
||||
detected: false,
|
||||
hideDeprecation: false,
|
||||
},
|
||||
loadingStrategy: PluginLoadingStrategy.fetch,
|
||||
dependencies: {
|
||||
grafanaVersion: '8.0.0',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [],
|
||||
},
|
||||
},
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
exposedComponents: [],
|
||||
extensionPoints: [],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
global.console.warn = consoleWarn;
|
||||
consoleWarn.mockReset();
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
|
||||
config.apps = {
|
||||
[pluginId]: appPluginConfig,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.apps = originalApps;
|
||||
});
|
||||
|
||||
it('should return empty registry when no exposed components have been registered', async () => {
|
||||
@ -397,4 +444,105 @@ describe('ExposedComponentsRegistry', () => {
|
||||
expect(subscribeCallback).toHaveBeenCalledTimes(2);
|
||||
expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual([`${pluginId}/hello-world/v1`]);
|
||||
});
|
||||
|
||||
it('should not register an exposed component added by a plugin in dev-mode if the meta-info is missing from the plugin.json', async () => {
|
||||
// Enabling dev mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
|
||||
|
||||
const registry = new ExposedComponentsRegistry();
|
||||
const componentConfig = {
|
||||
id: `${pluginId}/exposed-component/v1`,
|
||||
title: 'Component title',
|
||||
description: 'Component description',
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
};
|
||||
|
||||
// Make sure that the meta-info is empty
|
||||
config.apps[pluginId].extensions.exposedComponents = [];
|
||||
|
||||
registry.register({
|
||||
pluginId,
|
||||
configs: [componentConfig],
|
||||
});
|
||||
|
||||
const currentState = await registry.getState();
|
||||
|
||||
expect(Object.keys(currentState)).toHaveLength(0);
|
||||
expect(consoleWarn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should register an exposed component added by a core Grafana in dev-mode even if the meta-info is missing', async () => {
|
||||
// Enabling dev mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
|
||||
|
||||
const registry = new ExposedComponentsRegistry();
|
||||
const componentConfig = {
|
||||
id: `${pluginId}/exposed-component/v1`,
|
||||
title: 'Component title',
|
||||
description: 'Component description',
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
};
|
||||
|
||||
registry.register({
|
||||
pluginId: 'grafana',
|
||||
configs: [componentConfig],
|
||||
});
|
||||
|
||||
const currentState = await registry.getState();
|
||||
|
||||
expect(Object.keys(currentState)).toHaveLength(1);
|
||||
expect(consoleWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should register an exposed component added by a plugin in production mode even if the meta-info is missing', async () => {
|
||||
// Production mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
|
||||
|
||||
const registry = new ExposedComponentsRegistry();
|
||||
const componentConfig = {
|
||||
id: `${pluginId}/exposed-component/v1`,
|
||||
title: 'Component title',
|
||||
description: 'Component description',
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
};
|
||||
|
||||
// Make sure that the meta-info is empty
|
||||
config.apps[pluginId].extensions.exposedComponents = [];
|
||||
|
||||
registry.register({
|
||||
pluginId,
|
||||
configs: [componentConfig],
|
||||
});
|
||||
|
||||
const currentState = await registry.getState();
|
||||
|
||||
expect(Object.keys(currentState)).toHaveLength(1);
|
||||
expect(consoleWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should register an exposed component added by a plugin in dev-mode if the meta-info is present', async () => {
|
||||
// Enabling dev mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
|
||||
|
||||
const registry = new ExposedComponentsRegistry();
|
||||
const componentConfig = {
|
||||
id: `${pluginId}/exposed-component/v1`,
|
||||
title: 'Component title',
|
||||
description: 'Component description',
|
||||
component: () => React.createElement('div', null, 'Hello World1'),
|
||||
};
|
||||
|
||||
// Make sure that the meta-info is empty
|
||||
config.apps[pluginId].extensions.exposedComponents = [componentConfig];
|
||||
|
||||
registry.register({
|
||||
pluginId,
|
||||
configs: [componentConfig],
|
||||
});
|
||||
|
||||
const currentState = await registry.getState();
|
||||
|
||||
expect(Object.keys(currentState)).toHaveLength(1);
|
||||
expect(consoleWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
@ -2,7 +2,7 @@ import { ReplaySubject } from 'rxjs';
|
||||
|
||||
import { PluginExtensionExposedComponentConfig } from '@grafana/data';
|
||||
|
||||
import { logWarning } from '../utils';
|
||||
import { isExposedComponentMetaInfoMissing, isGrafanaDevMode, logWarning } from '../utils';
|
||||
import { extensionPointEndsWithVersion } from '../validators';
|
||||
|
||||
import { Registry, RegistryType, PluginExtensionConfigs } from './Registry';
|
||||
@ -68,6 +68,10 @@ export class ExposedComponentsRegistry extends Registry<
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pluginId !== 'grafana' && isGrafanaDevMode() && isExposedComponentMetaInfoMissing(pluginId, config)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
registry[id] = { ...config, pluginId };
|
||||
}
|
||||
|
||||
|
@ -1,13 +1,14 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
|
||||
import { PluginContextProvider, PluginLoadingStrategy, PluginMeta, PluginType } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
|
||||
import { setupPluginExtensionRegistries } from './registry/setup';
|
||||
import { PluginExtensionRegistries } from './registry/types';
|
||||
import { usePluginComponent } from './usePluginComponent';
|
||||
import * as utils from './utils';
|
||||
|
||||
const wrapWithPluginContext = jest.spyOn(utils, 'wrapWithPluginContext');
|
||||
import { isGrafanaDevMode, wrapWithPluginContext } from './utils';
|
||||
|
||||
jest.mock('app/features/plugins/pluginSettings', () => ({
|
||||
getPluginSettings: jest.fn().mockResolvedValue({
|
||||
@ -20,20 +21,111 @@ jest.mock('app/features/plugins/pluginSettings', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('./utils', () => ({
|
||||
...jest.requireActual('./utils'),
|
||||
|
||||
// Manually set the dev mode to false
|
||||
// (to make sure that by default we are testing a production scneario)
|
||||
isGrafanaDevMode: jest.fn().mockReturnValue(false),
|
||||
wrapWithPluginContext: jest.fn().mockImplementation((_, component: React.ReactNode) => component),
|
||||
}));
|
||||
|
||||
describe('usePluginComponent()', () => {
|
||||
let registries: PluginExtensionRegistries;
|
||||
let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element;
|
||||
let pluginMeta: PluginMeta;
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
const originalApps = config.apps;
|
||||
const pluginId = 'myorg-extensions-app';
|
||||
const exposedComponentId = `${pluginId}/exposed-component/v1`;
|
||||
const exposedComponentConfig = {
|
||||
id: exposedComponentId,
|
||||
title: 'Exposed component',
|
||||
description: 'Exposed component description',
|
||||
component: () => <div>Hello World</div>,
|
||||
};
|
||||
const appPluginConfig = {
|
||||
id: pluginId,
|
||||
path: '',
|
||||
version: '',
|
||||
preload: false,
|
||||
angular: {
|
||||
detected: false,
|
||||
hideDeprecation: false,
|
||||
},
|
||||
loadingStrategy: PluginLoadingStrategy.fetch,
|
||||
dependencies: {
|
||||
grafanaVersion: '8.0.0',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [],
|
||||
},
|
||||
},
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
// This is necessary, so we can register exposed components to the registry during the tests
|
||||
// (Otherwise the registry would reject it in the imitated production mode)
|
||||
exposedComponents: [exposedComponentConfig],
|
||||
extensionPoints: [],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
registries = setupPluginExtensionRegistries();
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
|
||||
wrapWithPluginContext.mockClear();
|
||||
jest.mocked(wrapWithPluginContext).mockClear();
|
||||
|
||||
pluginMeta = {
|
||||
id: pluginId,
|
||||
name: 'Extensions App',
|
||||
type: PluginType.app,
|
||||
module: '',
|
||||
baseUrl: '',
|
||||
info: {
|
||||
author: {
|
||||
name: 'MyOrg',
|
||||
},
|
||||
description: 'App for testing extensions',
|
||||
links: [],
|
||||
logos: {
|
||||
large: '',
|
||||
small: '',
|
||||
},
|
||||
screenshots: [],
|
||||
updated: '2023-10-26T18:25:01Z',
|
||||
version: '1.0.0',
|
||||
},
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
exposedComponents: [],
|
||||
extensionPoints: [],
|
||||
},
|
||||
dependencies: {
|
||||
grafanaVersion: '8.0.0',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
config.apps = {
|
||||
[pluginId]: appPluginConfig,
|
||||
};
|
||||
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.apps = originalApps;
|
||||
});
|
||||
|
||||
it('should return null if there are no component exposed for the id', () => {
|
||||
const { result } = renderHook(() => usePluginComponent('foo/bar'), { wrapper });
|
||||
|
||||
@ -42,15 +134,12 @@ describe('usePluginComponent()', () => {
|
||||
});
|
||||
|
||||
it('should return component, that can be rendered, from the registry', async () => {
|
||||
const id = 'my-app-plugin/foo/bar/v1';
|
||||
const pluginId = 'my-app-plugin';
|
||||
|
||||
registries.exposedComponentsRegistry.register({
|
||||
pluginId,
|
||||
configs: [{ id, title: 'not important', description: 'not important', component: () => <div>Hello World</div> }],
|
||||
configs: [exposedComponentConfig],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => usePluginComponent(id), { wrapper });
|
||||
const { result } = renderHook(() => usePluginComponent(exposedComponentId), { wrapper });
|
||||
const Component = result.current.component;
|
||||
|
||||
act(() => {
|
||||
@ -63,9 +152,7 @@ describe('usePluginComponent()', () => {
|
||||
});
|
||||
|
||||
it('should dynamically update when component is registered to the registry', async () => {
|
||||
const id = 'my-app-plugin/foo/bar/v1';
|
||||
const pluginId = 'my-app-plugin';
|
||||
const { result, rerender } = renderHook(() => usePluginComponent(id), { wrapper });
|
||||
const { result, rerender } = renderHook(() => usePluginComponent(exposedComponentId), { wrapper });
|
||||
|
||||
// No extensions yet
|
||||
expect(result.current.component).toBeNull();
|
||||
@ -75,14 +162,7 @@ describe('usePluginComponent()', () => {
|
||||
act(() => {
|
||||
registries.exposedComponentsRegistry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
id,
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => <div>Hello World</div>,
|
||||
},
|
||||
],
|
||||
configs: [exposedComponentConfig],
|
||||
});
|
||||
});
|
||||
|
||||
@ -101,26 +181,132 @@ describe('usePluginComponent()', () => {
|
||||
});
|
||||
|
||||
it('should only render the hook once', async () => {
|
||||
const pluginId = 'my-app-plugin';
|
||||
const id = `${pluginId}/foo/v1`;
|
||||
|
||||
// Add extensions to the registry
|
||||
act(() => {
|
||||
registries.exposedComponentsRegistry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
id,
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => <div>Hello World</div>,
|
||||
},
|
||||
],
|
||||
configs: [exposedComponentConfig],
|
||||
});
|
||||
});
|
||||
|
||||
expect(wrapWithPluginContext).toHaveBeenCalledTimes(0);
|
||||
renderHook(() => usePluginComponent(id), { wrapper });
|
||||
renderHook(() => usePluginComponent(exposedComponentId), { wrapper });
|
||||
await waitFor(() => expect(wrapWithPluginContext).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('should not validate the meta-info in production mode', () => {
|
||||
// Empty list of exposed component ids in the plugin meta (from plugin.json)
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<PluginContextProvider
|
||||
meta={{
|
||||
...pluginMeta,
|
||||
dependencies: {
|
||||
...pluginMeta.dependencies!,
|
||||
extensions: {
|
||||
exposedComponents: [],
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
|
||||
</PluginContextProvider>
|
||||
);
|
||||
|
||||
registries.exposedComponentsRegistry.register({
|
||||
pluginId,
|
||||
configs: [exposedComponentConfig],
|
||||
});
|
||||
|
||||
// Trying to render an exposed component that is not defined in the plugin meta
|
||||
// (No restrictions due to isGrafanaDevMode() = false)
|
||||
let { result } = renderHook(() => usePluginComponent(exposedComponentId), { wrapper });
|
||||
expect(result.current.component).not.toBe(null);
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not validate the meta-info in core Grafana', () => {
|
||||
// Imitate running in dev mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
|
||||
|
||||
// No plugin context -> used in Grafana core
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
|
||||
);
|
||||
|
||||
registries.exposedComponentsRegistry.register({
|
||||
pluginId,
|
||||
configs: [exposedComponentConfig],
|
||||
});
|
||||
|
||||
// Trying to render an extension point that is not defined in the plugin meta
|
||||
// (No restrictions due to isGrafanaDevMode() = false)
|
||||
let { result } = renderHook(() => usePluginComponent(exposedComponentId), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current.component).not.toBe(null);
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should validate the meta-info in dev mode and if inside a plugin', () => {
|
||||
// Imitate running in dev mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
|
||||
|
||||
// Empty list of exposed component ids in the plugin meta (from plugin.json)
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<PluginContextProvider
|
||||
meta={{
|
||||
...pluginMeta,
|
||||
dependencies: {
|
||||
...pluginMeta.dependencies!,
|
||||
extensions: {
|
||||
exposedComponents: [],
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
|
||||
</PluginContextProvider>
|
||||
);
|
||||
|
||||
registries.exposedComponentsRegistry.register({
|
||||
pluginId,
|
||||
configs: [exposedComponentConfig],
|
||||
});
|
||||
|
||||
// Shouldn't return the component, as it's not present in the plugin.json dependencies
|
||||
let { result } = renderHook(() => usePluginComponent(exposedComponentId), { wrapper });
|
||||
expect(result.current.component).toBe(null);
|
||||
expect(consoleWarnSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return the exposed component if the meta-info is correct and in dev mode', () => {
|
||||
// Imitate running in dev mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
|
||||
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<PluginContextProvider
|
||||
meta={{
|
||||
...pluginMeta,
|
||||
dependencies: {
|
||||
...pluginMeta.dependencies!,
|
||||
extensions: {
|
||||
exposedComponents: [exposedComponentId],
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
|
||||
</PluginContextProvider>
|
||||
);
|
||||
|
||||
registries.exposedComponentsRegistry.register({
|
||||
pluginId,
|
||||
configs: [exposedComponentConfig],
|
||||
});
|
||||
|
||||
let { result } = renderHook(() => usePluginComponent(exposedComponentId), { wrapper });
|
||||
expect(result.current.component).not.toBe(null);
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
@ -1,18 +1,33 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useObservable } from 'react-use';
|
||||
|
||||
import { UsePluginComponentResult } from '@grafana/runtime';
|
||||
import { usePluginContext } from '@grafana/data';
|
||||
import { logWarning, UsePluginComponentResult } from '@grafana/runtime';
|
||||
|
||||
import { useExposedComponentsRegistry } from './ExtensionRegistriesContext';
|
||||
import { wrapWithPluginContext } from './utils';
|
||||
import { isExposedComponentDependencyMissing, isGrafanaDevMode, wrapWithPluginContext } from './utils';
|
||||
|
||||
// Returns a component exposed by a plugin.
|
||||
// (Exposed components can be defined in plugins by calling .exposeComponent() on the AppPlugin instance.)
|
||||
export function usePluginComponent<Props extends object = {}>(id: string): UsePluginComponentResult<Props> {
|
||||
const registry = useExposedComponentsRegistry();
|
||||
const registryState = useObservable(registry.asObservable());
|
||||
const pluginContext = usePluginContext();
|
||||
|
||||
return useMemo(() => {
|
||||
// For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
|
||||
const enableRestrictions = isGrafanaDevMode() && pluginContext;
|
||||
|
||||
if (enableRestrictions && isExposedComponentDependencyMissing(id, pluginContext)) {
|
||||
logWarning(
|
||||
`usePluginComponent("${id}") - The exposed component ("${id}") is missing from the dependencies[] in the "plugin.json" file.`
|
||||
);
|
||||
return {
|
||||
isLoading: false,
|
||||
component: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!registryState?.[id]) {
|
||||
return {
|
||||
isLoading: false,
|
||||
@ -26,5 +41,5 @@ export function usePluginComponent<Props extends object = {}>(id: string): UsePl
|
||||
isLoading: false,
|
||||
component: wrapWithPluginContext(registryItem.pluginId, registryItem.component),
|
||||
};
|
||||
}, [id, registryState]);
|
||||
}, [id, pluginContext, registryState]);
|
||||
}
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
|
||||
import { PluginContextProvider, PluginMeta, PluginType } from '@grafana/data';
|
||||
|
||||
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
|
||||
import { setupPluginExtensionRegistries } from './registry/setup';
|
||||
import { PluginExtensionRegistries } from './registry/types';
|
||||
import { usePluginComponents } from './usePluginComponents';
|
||||
import * as utils from './utils';
|
||||
|
||||
const wrapWithPluginContext = jest.spyOn(utils, 'wrapWithPluginContext');
|
||||
import { isGrafanaDevMode, wrapWithPluginContext } from './utils';
|
||||
|
||||
jest.mock('app/features/plugins/pluginSettings', () => ({
|
||||
getPluginSettings: jest.fn().mockResolvedValue({
|
||||
@ -20,17 +20,69 @@ jest.mock('app/features/plugins/pluginSettings', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('./utils', () => ({
|
||||
...jest.requireActual('./utils'),
|
||||
|
||||
// Manually set the dev mode to false
|
||||
// (to make sure that by default we are testing a production scneario)
|
||||
isGrafanaDevMode: jest.fn().mockReturnValue(false),
|
||||
wrapWithPluginContext: jest.fn().mockImplementation((_, component: React.ReactNode) => component),
|
||||
}));
|
||||
|
||||
describe('usePluginComponents()', () => {
|
||||
let registries: PluginExtensionRegistries;
|
||||
let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element;
|
||||
let pluginMeta: PluginMeta;
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
const pluginId = 'myorg-extensions-app';
|
||||
const extensionPointId = `${pluginId}/extension-point/v1`;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
|
||||
registries = setupPluginExtensionRegistries();
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
|
||||
wrapWithPluginContext.mockClear();
|
||||
jest.mocked(wrapWithPluginContext).mockClear();
|
||||
|
||||
pluginMeta = {
|
||||
id: pluginId,
|
||||
name: 'Extensions App',
|
||||
type: PluginType.app,
|
||||
module: '',
|
||||
baseUrl: '',
|
||||
info: {
|
||||
author: {
|
||||
name: 'MyOrg',
|
||||
},
|
||||
description: 'App for testing extensions',
|
||||
links: [],
|
||||
logos: {
|
||||
large: '',
|
||||
small: '',
|
||||
},
|
||||
screenshots: [],
|
||||
updated: '2023-10-26T18:25:01Z',
|
||||
version: '1.0.0',
|
||||
},
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
exposedComponents: [],
|
||||
extensionPoints: [],
|
||||
},
|
||||
dependencies: {
|
||||
grafanaVersion: '8.0.0',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<PluginContextProvider meta={pluginMeta}>
|
||||
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
|
||||
</PluginContextProvider>
|
||||
);
|
||||
});
|
||||
|
||||
@ -47,9 +99,6 @@ describe('usePluginComponents()', () => {
|
||||
});
|
||||
|
||||
it('should only return the plugin extension components for the given extension point ids', async () => {
|
||||
const extensionPointId = 'plugins/foo/bar/v1';
|
||||
const pluginId = 'my-app-plugin';
|
||||
|
||||
registries.addedComponentsRegistry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
@ -81,14 +130,13 @@ describe('usePluginComponents()', () => {
|
||||
act(() => {
|
||||
render(result.current.components.map((Component, index) => <Component key={index} />));
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Hello World1')).toBeVisible();
|
||||
expect(await screen.findByText('Hello World2')).toBeVisible();
|
||||
expect(await screen.queryByText('Hello World3')).toBeNull();
|
||||
expect(screen.queryByText('Hello World3')).toBeNull();
|
||||
});
|
||||
|
||||
it('should dynamically update the extensions registered for a certain extension point', () => {
|
||||
const extensionPointId = 'plugins/foo/bar/v1';
|
||||
const pluginId = 'my-app-plugin';
|
||||
let { result, rerender } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper });
|
||||
|
||||
// No extensions yet
|
||||
@ -128,8 +176,7 @@ describe('usePluginComponents()', () => {
|
||||
});
|
||||
|
||||
it('should honour the limitPerPlugin arg if its set', () => {
|
||||
const extensionPointId = 'plugins/foo/bar/v1';
|
||||
const plugins = ['my-app-plugin1', 'my-app-plugin2', 'my-app-plugin3'];
|
||||
const plugins = ['my-awesome1-app', 'my-awesome2-app', 'my-awesome3-app'];
|
||||
let { result, rerender } = renderHook(() => usePluginComponents({ extensionPointId, limitPerPlugin: 2 }), {
|
||||
wrapper,
|
||||
});
|
||||
@ -144,19 +191,19 @@ describe('usePluginComponents()', () => {
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
targets: extensionPointId,
|
||||
targets: [extensionPointId],
|
||||
title: '1',
|
||||
description: '1',
|
||||
component: () => <div>Hello World1</div>,
|
||||
},
|
||||
{
|
||||
targets: extensionPointId,
|
||||
targets: [extensionPointId],
|
||||
title: '2',
|
||||
description: '2',
|
||||
component: () => <div>Hello World2</div>,
|
||||
},
|
||||
{
|
||||
targets: extensionPointId,
|
||||
targets: [extensionPointId],
|
||||
title: '3',
|
||||
description: '3',
|
||||
component: () => <div>Hello World3</div>,
|
||||
@ -171,4 +218,191 @@ describe('usePluginComponents()', () => {
|
||||
|
||||
expect(result.current.components.length).toBe(6);
|
||||
});
|
||||
|
||||
it('should not validate the extension point meta-info in production mode', () => {
|
||||
// Empty list of extension points in the plugin meta (from plugin.json)
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<PluginContextProvider
|
||||
meta={{
|
||||
...pluginMeta,
|
||||
extensions: {
|
||||
...pluginMeta.extensions!,
|
||||
extensionPoints: [],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
|
||||
</PluginContextProvider>
|
||||
);
|
||||
|
||||
registries.addedComponentsRegistry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
targets: extensionPointId,
|
||||
title: '1',
|
||||
description: '1',
|
||||
component: () => <div>Component</div>,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Trying to render an extension point that is not defined in the plugin meta
|
||||
// (No restrictions due to isGrafanaDevMode() = false)
|
||||
let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper });
|
||||
expect(result.current.components.length).toBe(1);
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not validate the extension point id in production mode', () => {
|
||||
// Empty list of extension points in the plugin meta (from plugin.json)
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<PluginContextProvider
|
||||
meta={{
|
||||
...pluginMeta,
|
||||
extensions: {
|
||||
...pluginMeta.extensions!,
|
||||
extensionPoints: [],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
|
||||
</PluginContextProvider>
|
||||
);
|
||||
|
||||
// Trying to render an extension point that is not defined in the plugin meta
|
||||
// (No restrictions due to isGrafanaDevMode() = false)
|
||||
let { result } = renderHook(() => usePluginComponents({ extensionPointId: 'invalid-extension-point-id' }), {
|
||||
wrapper,
|
||||
});
|
||||
expect(result.current.components.length).toBe(0);
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not validate the extension point meta-info if used in Grafana core (no plugin context)', () => {
|
||||
// Imitate running in dev mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
|
||||
|
||||
// No plugin context -> used in Grafana core
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
|
||||
);
|
||||
|
||||
// Adding an extension to the extension point
|
||||
registries.addedComponentsRegistry.register({
|
||||
pluginId: 'grafana', // Only core Grafana can register extensions without a plugin context
|
||||
configs: [
|
||||
{
|
||||
targets: 'grafana/extension-point/v1',
|
||||
title: '1',
|
||||
description: '1',
|
||||
component: () => <div>Component</div>,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let { result } = renderHook(() => usePluginComponents({ extensionPointId: 'grafana/extension-point/v1' }), {
|
||||
wrapper,
|
||||
});
|
||||
expect(result.current.components.length).toBe(1);
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not validate the extension point id if used in Grafana core (no plugin context)', () => {
|
||||
// Imitate running in dev mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
|
||||
|
||||
// No plugin context -> used in Grafana core
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
|
||||
);
|
||||
|
||||
let { result } = renderHook(() => usePluginComponents({ extensionPointId: 'invalid-extension-point-id' }), {
|
||||
wrapper,
|
||||
});
|
||||
expect(result.current.components.length).toBe(0);
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should validate if the extension point meta-info is correct if in dev-mode and used by a plugin', () => {
|
||||
// Imitate running in dev mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
|
||||
|
||||
// Empty list of extension points in the plugin meta (from plugin.json)
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<PluginContextProvider
|
||||
meta={{
|
||||
...pluginMeta,
|
||||
extensions: {
|
||||
...pluginMeta.extensions!,
|
||||
extensionPoints: [],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
|
||||
</PluginContextProvider>
|
||||
);
|
||||
|
||||
// Adding an extension to the extension point - it should not be returned later
|
||||
registries.addedComponentsRegistry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
targets: extensionPointId,
|
||||
title: '1',
|
||||
description: '1',
|
||||
component: () => <div>Component</div>,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Trying to render an extension point that is not defined in the plugin meta
|
||||
let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper });
|
||||
expect(result.current.components.length).toBe(0);
|
||||
expect(consoleWarnSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not log a warning if the extension point meta-info is correct if in dev-mode and used by a plugin', () => {
|
||||
// Imitate running in dev mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
|
||||
|
||||
// The extension point is listed in the plugin meta (from plugin.json)
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<PluginContextProvider
|
||||
meta={{
|
||||
...pluginMeta,
|
||||
extensions: {
|
||||
...pluginMeta.extensions!,
|
||||
extensionPoints: [
|
||||
{
|
||||
id: extensionPointId,
|
||||
title: 'Extension point',
|
||||
description: 'Extension point description',
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
|
||||
</PluginContextProvider>
|
||||
);
|
||||
|
||||
// Adding an extension to the extension point - it should not be returned later
|
||||
registries.addedComponentsRegistry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
targets: extensionPointId,
|
||||
title: '1',
|
||||
description: '1',
|
||||
component: () => <div>Component</div>,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Trying to render an extension point that is not defined in the plugin meta
|
||||
let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper });
|
||||
expect(result.current.components.length).toBe(0);
|
||||
expect(consoleWarnSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
@ -1,12 +1,15 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useObservable } from 'react-use';
|
||||
|
||||
import { usePluginContext } from '@grafana/data';
|
||||
import {
|
||||
UsePluginComponentOptions,
|
||||
UsePluginComponentsResult,
|
||||
} from '@grafana/runtime/src/services/pluginExtensions/getPluginExtensions';
|
||||
|
||||
import { useAddedComponentsRegistry } from './ExtensionRegistriesContext';
|
||||
import { isExtensionPointMetaInfoMissing, isGrafanaDevMode, logWarning } from './utils';
|
||||
import { isExtensionPointIdValid } from './validators';
|
||||
|
||||
// Returns an array of component extensions for the given extension point
|
||||
export function usePluginComponents<Props extends object = {}>({
|
||||
@ -15,10 +18,34 @@ export function usePluginComponents<Props extends object = {}>({
|
||||
}: UsePluginComponentOptions): UsePluginComponentsResult<Props> {
|
||||
const registry = useAddedComponentsRegistry();
|
||||
const registryState = useObservable(registry.asObservable());
|
||||
const pluginContext = usePluginContext();
|
||||
|
||||
return useMemo(() => {
|
||||
// For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
|
||||
const enableRestrictions = isGrafanaDevMode() && pluginContext;
|
||||
const components: Array<React.ComponentType<Props>> = [];
|
||||
const extensionsByPlugin: Record<string, number> = {};
|
||||
const pluginId = pluginContext?.meta.id ?? '';
|
||||
|
||||
if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) {
|
||||
logWarning(
|
||||
`Extension point usePluginComponents("${extensionPointId}") - the id should be prefixed with your plugin id ("${pluginId}/").`
|
||||
);
|
||||
return {
|
||||
isLoading: false,
|
||||
components: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) {
|
||||
logWarning(
|
||||
`usePluginComponents("${extensionPointId}") - The extension point is missing from the "plugin.json" file.`
|
||||
);
|
||||
return {
|
||||
isLoading: false,
|
||||
components: [],
|
||||
};
|
||||
}
|
||||
|
||||
for (const registryItem of registryState?.[extensionPointId] ?? []) {
|
||||
const { pluginId } = registryItem;
|
||||
@ -40,5 +67,5 @@ export function usePluginComponents<Props extends object = {}>({
|
||||
isLoading: false,
|
||||
components,
|
||||
};
|
||||
}, [extensionPointId, limitPerPlugin, registryState]);
|
||||
}, [extensionPointId, limitPerPlugin, pluginContext, registryState]);
|
||||
}
|
||||
|
@ -9,6 +9,8 @@ import { createUsePluginExtensions } from './usePluginExtensions';
|
||||
|
||||
describe('usePluginExtensions()', () => {
|
||||
let registries: PluginExtensionRegistries;
|
||||
const pluginId = 'myorg-extensions-app';
|
||||
const extensionPointId = `${pluginId}/extension-point/v1`;
|
||||
|
||||
beforeEach(() => {
|
||||
registries = {
|
||||
@ -30,9 +32,6 @@ describe('usePluginExtensions()', () => {
|
||||
});
|
||||
|
||||
it('should return the plugin link extensions from the registry', () => {
|
||||
const extensionPointId = 'plugins/foo/bar/v1';
|
||||
const pluginId = 'my-app-plugin';
|
||||
|
||||
registries.addedLinksRegistry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
@ -60,21 +59,19 @@ describe('usePluginExtensions()', () => {
|
||||
});
|
||||
|
||||
it('should return the plugin component extensions from the registry', () => {
|
||||
const linkExtensionPointId = 'plugins/foo/bar/v1';
|
||||
const componentExtensionPointId = 'plugins/component/bar/v1';
|
||||
const pluginId = 'my-app-plugin';
|
||||
const componentExtensionPointId = `${pluginId}/component/v1`;
|
||||
|
||||
registries.addedLinksRegistry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
targets: linkExtensionPointId,
|
||||
targets: extensionPointId,
|
||||
title: '1',
|
||||
description: '1',
|
||||
path: `/a/${pluginId}/2`,
|
||||
},
|
||||
{
|
||||
targets: linkExtensionPointId,
|
||||
targets: extensionPointId,
|
||||
title: '2',
|
||||
description: '2',
|
||||
path: `/a/${pluginId}/2`,
|
||||
@ -109,8 +106,6 @@ describe('usePluginExtensions()', () => {
|
||||
});
|
||||
|
||||
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 = createUsePluginExtensions(registries);
|
||||
let { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId }));
|
||||
|
||||
@ -149,7 +144,6 @@ describe('usePluginExtensions()', () => {
|
||||
it('should only render the hook once', () => {
|
||||
const addedComponentsRegistrySpy = jest.spyOn(registries.addedComponentsRegistry, 'asObservable');
|
||||
const addedLinksRegistrySpy = jest.spyOn(registries.addedLinksRegistry, 'asObservable');
|
||||
const extensionPointId = 'plugins/foo/bar/v1';
|
||||
const usePluginExtensions = createUsePluginExtensions(registries);
|
||||
|
||||
renderHook(() => usePluginExtensions({ extensionPointId }));
|
||||
@ -158,8 +152,6 @@ describe('usePluginExtensions()', () => {
|
||||
});
|
||||
|
||||
it('should return the same extensions object if the context object is the same', async () => {
|
||||
const extensionPointId = 'plugins/foo/bar/v1';
|
||||
const pluginId = 'my-app-plugin';
|
||||
const usePluginExtensions = createUsePluginExtensions(registries);
|
||||
|
||||
// Add extensions to the registry
|
||||
@ -200,8 +192,6 @@ describe('usePluginExtensions()', () => {
|
||||
});
|
||||
|
||||
it('should return a new extensions object if the context object is different', () => {
|
||||
const extensionPointId = 'plugins/foo/bar/v1';
|
||||
const pluginId = 'my-app-plugin';
|
||||
const usePluginExtensions = createUsePluginExtensions(registries);
|
||||
|
||||
// Add extensions to the registry
|
||||
@ -232,8 +222,6 @@ describe('usePluginExtensions()', () => {
|
||||
});
|
||||
|
||||
it('should return a new extensions object if the registry changes but the context object is the same', () => {
|
||||
const extensionPointId = 'plugins/foo/bar/v1';
|
||||
const pluginId = 'my-app-plugin';
|
||||
const context = {};
|
||||
const usePluginExtensions = createUsePluginExtensions(registries);
|
||||
|
||||
|
@ -1,31 +1,59 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useObservable } from 'react-use';
|
||||
|
||||
import { PluginExtension } from '@grafana/data';
|
||||
import { PluginExtension, usePluginContext } from '@grafana/data';
|
||||
import { GetPluginExtensionsOptions, UsePluginExtensionsResult } from '@grafana/runtime';
|
||||
import { useSidecar } from 'app/core/context/SidecarContext';
|
||||
|
||||
import { getPluginExtensions } from './getPluginExtensions';
|
||||
import { PluginExtensionRegistries } from './registry/types';
|
||||
import { isExtensionPointMetaInfoMissing, isGrafanaDevMode, logWarning } from './utils';
|
||||
import { isExtensionPointIdValid } from './validators';
|
||||
|
||||
export function createUsePluginExtensions(registries: PluginExtensionRegistries) {
|
||||
const observableAddedComponentsRegistry = registries.addedComponentsRegistry.asObservable();
|
||||
const observableAddedLinksRegistry = registries.addedLinksRegistry.asObservable();
|
||||
|
||||
return function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult<PluginExtension> {
|
||||
const pluginContext = usePluginContext();
|
||||
const addedComponentsRegistry = useObservable(observableAddedComponentsRegistry);
|
||||
const addedLinksRegistry = useObservable(observableAddedLinksRegistry);
|
||||
const { activePluginId } = useSidecar();
|
||||
const { extensionPointId, context, limitPerPlugin } = options;
|
||||
|
||||
const { extensions } = useMemo(() => {
|
||||
// For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
|
||||
const enableRestrictions = isGrafanaDevMode() && pluginContext !== null;
|
||||
const pluginId = pluginContext?.meta.id ?? '';
|
||||
|
||||
if (!addedLinksRegistry && !addedComponentsRegistry) {
|
||||
return { extensions: [], isLoading: false };
|
||||
}
|
||||
|
||||
if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) {
|
||||
logWarning(
|
||||
`Extension point usePluginExtensions("${extensionPointId}") - the id should be prefixed with your plugin id ("${pluginId}/").`
|
||||
);
|
||||
return {
|
||||
isLoading: false,
|
||||
extensions: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) {
|
||||
logWarning(
|
||||
`Invalid extension point. Reason: The extension point is not declared in the "plugin.json" file. ExtensionPointId: "${extensionPointId}"`
|
||||
);
|
||||
return {
|
||||
isLoading: false,
|
||||
extensions: [],
|
||||
};
|
||||
}
|
||||
|
||||
return getPluginExtensions({
|
||||
extensionPointId: options.extensionPointId,
|
||||
context: options.context,
|
||||
limitPerPlugin: options.limitPerPlugin,
|
||||
extensionPointId,
|
||||
context,
|
||||
limitPerPlugin,
|
||||
addedComponentsRegistry,
|
||||
addedLinksRegistry,
|
||||
});
|
||||
@ -36,10 +64,11 @@ export function createUsePluginExtensions(registries: PluginExtensionRegistries)
|
||||
}, [
|
||||
addedLinksRegistry,
|
||||
addedComponentsRegistry,
|
||||
options.extensionPointId,
|
||||
options.context,
|
||||
options.limitPerPlugin,
|
||||
extensionPointId,
|
||||
context,
|
||||
limitPerPlugin,
|
||||
activePluginId,
|
||||
pluginContext,
|
||||
]);
|
||||
|
||||
return { extensions, isLoading: false };
|
||||
|
@ -1,10 +1,13 @@
|
||||
import { act } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
|
||||
import { PluginContextProvider, PluginMeta, PluginType } from '@grafana/data';
|
||||
|
||||
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
|
||||
import { setupPluginExtensionRegistries } from './registry/setup';
|
||||
import { PluginExtensionRegistries } from './registry/types';
|
||||
import { usePluginLinks } from './usePluginLinks';
|
||||
import { isGrafanaDevMode } from './utils';
|
||||
|
||||
jest.mock('app/features/plugins/pluginSettings', () => ({
|
||||
getPluginSettings: jest.fn().mockResolvedValue({
|
||||
@ -17,15 +20,66 @@ jest.mock('app/features/plugins/pluginSettings', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('./utils', () => ({
|
||||
...jest.requireActual('./utils'),
|
||||
|
||||
// Manually set the dev mode to false
|
||||
// (to make sure that by default we are testing a production scneario)
|
||||
isGrafanaDevMode: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
describe('usePluginLinks()', () => {
|
||||
let registries: PluginExtensionRegistries;
|
||||
let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element;
|
||||
let pluginMeta: PluginMeta;
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
const pluginId = 'myorg-extensions-app';
|
||||
const extensionPointId = `${pluginId}/extension-point/v1`;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
|
||||
registries = setupPluginExtensionRegistries();
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
|
||||
pluginMeta = {
|
||||
id: pluginId,
|
||||
name: 'Extensions App',
|
||||
type: PluginType.app,
|
||||
module: '',
|
||||
baseUrl: '',
|
||||
info: {
|
||||
author: {
|
||||
name: 'MyOrg',
|
||||
},
|
||||
description: 'App for testing extensions',
|
||||
links: [],
|
||||
logos: {
|
||||
large: '',
|
||||
small: '',
|
||||
},
|
||||
screenshots: [],
|
||||
updated: '2023-10-26T18:25:01Z',
|
||||
version: '1.0.0',
|
||||
},
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
exposedComponents: [],
|
||||
extensionPoints: [],
|
||||
},
|
||||
dependencies: {
|
||||
grafanaVersion: '8.0.0',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<PluginContextProvider meta={pluginMeta}>
|
||||
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
|
||||
</PluginContextProvider>
|
||||
);
|
||||
});
|
||||
|
||||
@ -42,9 +96,6 @@ describe('usePluginLinks()', () => {
|
||||
});
|
||||
|
||||
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';
|
||||
|
||||
registries.addedLinksRegistry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
@ -77,8 +128,6 @@ describe('usePluginLinks()', () => {
|
||||
});
|
||||
|
||||
it('should dynamically update the extensions registered for a certain extension point', () => {
|
||||
const extensionPointId = 'plugins/foo/bar/v1';
|
||||
const pluginId = 'my-app-plugin';
|
||||
let { result, rerender } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
|
||||
|
||||
// No extensions yet
|
||||
@ -112,4 +161,179 @@ describe('usePluginLinks()', () => {
|
||||
expect(result.current.links[0].title).toBe('1');
|
||||
expect(result.current.links[1].title).toBe('2');
|
||||
});
|
||||
|
||||
it('should not validate the extension point meta-info in production mode', () => {
|
||||
// Empty list of extension points in the plugin meta (from plugin.json)
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<PluginContextProvider
|
||||
meta={{
|
||||
...pluginMeta,
|
||||
extensions: {
|
||||
...pluginMeta.extensions!,
|
||||
extensionPoints: [],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
|
||||
</PluginContextProvider>
|
||||
);
|
||||
|
||||
registries.addedLinksRegistry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
targets: extensionPointId,
|
||||
title: '1',
|
||||
description: '1',
|
||||
path: `/a/${pluginId}/2`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Trying to render an extension point that is not defined in the plugin meta
|
||||
// (No restrictions due to isGrafanaDevMode() = false)
|
||||
let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
|
||||
expect(result.current.links.length).toBe(1);
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not validate the extension point id in production mode', () => {
|
||||
// Empty list of extension points in the plugin meta (from plugin.json)
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<PluginContextProvider
|
||||
meta={{
|
||||
...pluginMeta,
|
||||
extensions: {
|
||||
...pluginMeta.extensions!,
|
||||
extensionPoints: [],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
|
||||
</PluginContextProvider>
|
||||
);
|
||||
|
||||
// Trying to render an extension point that is not defined in the plugin meta
|
||||
// (No restrictions due to isGrafanaDevMode() = false)
|
||||
let { result } = renderHook(() => usePluginLinks({ extensionPointId: 'invalid-extension-point-id' }), { wrapper });
|
||||
expect(result.current.links.length).toBe(0);
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not validate the extension point meta-info if used in Grafana core (no plugin context)', () => {
|
||||
// Imitate running in dev mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
|
||||
|
||||
// No plugin context -> used in Grafana core
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
|
||||
);
|
||||
|
||||
// Adding an extension to the extension point
|
||||
registries.addedLinksRegistry.register({
|
||||
pluginId: 'grafana', // Only core Grafana can register extensions without a plugin context
|
||||
configs: [
|
||||
{
|
||||
targets: 'grafana/extension-point/v1',
|
||||
title: '1',
|
||||
description: '1',
|
||||
path: `/a/grafana/${pluginId}/2`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let { result } = renderHook(() => usePluginLinks({ extensionPointId: 'grafana/extension-point/v1' }), { wrapper });
|
||||
expect(result.current.links.length).toBe(1);
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not validate the extension point id if used in Grafana core (no plugin context)', () => {
|
||||
// Imitate running in dev mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
|
||||
|
||||
// No plugin context -> used in Grafana core
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
|
||||
);
|
||||
|
||||
let { result } = renderHook(() => usePluginLinks({ extensionPointId: 'invalid-extension-point-id' }), { wrapper });
|
||||
expect(result.current.links.length).toBe(0);
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should validate if the extension point meta-info is correct if in dev-mode and used by a plugin', () => {
|
||||
// Imitate running in dev mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
|
||||
|
||||
// Empty list of extension points in the plugin meta (from plugin.json)
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<PluginContextProvider
|
||||
meta={{
|
||||
...pluginMeta,
|
||||
extensions: {
|
||||
...pluginMeta.extensions!,
|
||||
extensionPoints: [],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
|
||||
</PluginContextProvider>
|
||||
);
|
||||
|
||||
// Adding an extension to the extension point - it should not be returned later
|
||||
registries.addedLinksRegistry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
targets: extensionPointId,
|
||||
title: '1',
|
||||
description: '1',
|
||||
path: `/a/${pluginId}/2`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Trying to render an extension point that is not defined in the plugin meta
|
||||
let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
|
||||
expect(result.current.links.length).toBe(0);
|
||||
expect(consoleWarnSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not log a warning if the extension point meta-info is correct if in dev-mode and used by a plugin', () => {
|
||||
// Imitate running in dev mode
|
||||
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
|
||||
|
||||
// Empty list of extension points in the plugin meta (from plugin.json)
|
||||
wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<PluginContextProvider
|
||||
meta={{
|
||||
...pluginMeta,
|
||||
extensions: {
|
||||
...pluginMeta.extensions!,
|
||||
extensionPoints: [],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
|
||||
</PluginContextProvider>
|
||||
);
|
||||
|
||||
// Adding an extension to the extension point - it should not be returned later
|
||||
registries.addedLinksRegistry.register({
|
||||
pluginId,
|
||||
configs: [
|
||||
{
|
||||
targets: extensionPointId,
|
||||
title: '1',
|
||||
description: '1',
|
||||
path: `/a/${pluginId}/2`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Trying to render an extension point that is not defined in the plugin meta
|
||||
let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
|
||||
expect(result.current.links.length).toBe(0);
|
||||
expect(consoleWarnSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
@ -2,7 +2,7 @@ import { isString } from 'lodash';
|
||||
import { useMemo } from 'react';
|
||||
import { useObservable } from 'react-use';
|
||||
|
||||
import { PluginExtensionLink, PluginExtensionTypes } from '@grafana/data';
|
||||
import { PluginExtensionLink, PluginExtensionTypes, usePluginContext } from '@grafana/data';
|
||||
import {
|
||||
UsePluginLinksOptions,
|
||||
UsePluginLinksResult,
|
||||
@ -15,7 +15,11 @@ import {
|
||||
getLinkExtensionOverrides,
|
||||
getLinkExtensionPathWithTracking,
|
||||
getReadOnlyProxy,
|
||||
isExtensionPointMetaInfoMissing,
|
||||
isGrafanaDevMode,
|
||||
logWarning,
|
||||
} from './utils';
|
||||
import { isExtensionPointIdValid } from './validators';
|
||||
|
||||
// Returns an array of component extensions for the given extension point
|
||||
export function usePluginLinks({
|
||||
@ -24,9 +28,34 @@ export function usePluginLinks({
|
||||
context,
|
||||
}: UsePluginLinksOptions): UsePluginLinksResult {
|
||||
const registry = useAddedLinksRegistry();
|
||||
const pluginContext = usePluginContext();
|
||||
const registryState = useObservable(registry.asObservable());
|
||||
|
||||
return useMemo(() => {
|
||||
// For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
|
||||
const enableRestrictions = isGrafanaDevMode() && pluginContext !== null;
|
||||
const pluginId = pluginContext?.meta.id ?? '';
|
||||
|
||||
if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) {
|
||||
logWarning(
|
||||
`Extension point usePluginLinks("${extensionPointId}") - the id should be prefixed with your plugin id ("${pluginId}/").`
|
||||
);
|
||||
return {
|
||||
isLoading: false,
|
||||
links: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) {
|
||||
logWarning(
|
||||
`Invalid extension point. Reason: The extension point is not declared in the "plugin.json" file. ExtensionPointId: "${extensionPointId}"`
|
||||
);
|
||||
return {
|
||||
isLoading: false,
|
||||
links: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (!registryState || !registryState[extensionPointId]) {
|
||||
return {
|
||||
isLoading: false,
|
||||
@ -80,5 +109,5 @@ export function usePluginLinks({
|
||||
isLoading: false,
|
||||
links: extensions,
|
||||
};
|
||||
}, [context, extensionPointId, limitPerPlugin, registryState]);
|
||||
}, [context, extensionPointId, limitPerPlugin, registryState, pluginContext]);
|
||||
}
|
||||
|
@ -1,7 +1,15 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { type Unsubscribable } from 'rxjs';
|
||||
|
||||
import { dateTime, usePluginContext } from '@grafana/data';
|
||||
import {
|
||||
dateTime,
|
||||
PluginContextType,
|
||||
PluginExtensionPoints,
|
||||
PluginLoadingStrategy,
|
||||
PluginType,
|
||||
usePluginContext,
|
||||
} from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { ShowModalReactEvent } from 'app/types/events';
|
||||
|
||||
@ -11,6 +19,11 @@ import {
|
||||
getReadOnlyProxy,
|
||||
createOpenModalFunction,
|
||||
wrapWithPluginContext,
|
||||
isAddedLinkMetaInfoMissing,
|
||||
isAddedComponentMetaInfoMissing,
|
||||
isExposedComponentMetaInfoMissing,
|
||||
isExposedComponentDependencyMissing,
|
||||
isExtensionPointMetaInfoMissing,
|
||||
} from './utils';
|
||||
|
||||
jest.mock('app/features/plugins/pluginSettings', () => ({
|
||||
@ -396,7 +409,7 @@ describe('Plugin Extensions / Utils', () => {
|
||||
const ModalContent = () => {
|
||||
const context = usePluginContext();
|
||||
|
||||
return <div>Version: {context.meta.info.version}</div>;
|
||||
return <div>Version: {context!.meta.info.version}</div>;
|
||||
};
|
||||
|
||||
openModal({
|
||||
@ -415,13 +428,13 @@ describe('Plugin Extensions / Utils', () => {
|
||||
};
|
||||
|
||||
const ExampleComponent = (props: ExampleComponentProps) => {
|
||||
const { meta } = usePluginContext();
|
||||
const pluginContext = usePluginContext();
|
||||
|
||||
const audience = props.audience || 'Grafana';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Hello {audience}!</h1> Version: {meta.info.version}
|
||||
<h1>Hello {audience}!</h1> Version: {pluginContext!.meta.info.version}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -446,4 +459,446 @@ describe('Plugin Extensions / Utils', () => {
|
||||
expect(screen.getByText('Version: 1.0.0')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAddedLinkMetaInfoMissing()', () => {
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
const originalApps = config.apps;
|
||||
const pluginId = 'myorg-extensions-app';
|
||||
const appPluginConfig = {
|
||||
id: pluginId,
|
||||
path: '',
|
||||
version: '',
|
||||
preload: false,
|
||||
angular: {
|
||||
detected: false,
|
||||
hideDeprecation: false,
|
||||
},
|
||||
loadingStrategy: PluginLoadingStrategy.fetch,
|
||||
dependencies: {
|
||||
grafanaVersion: '8.0.0',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [],
|
||||
},
|
||||
},
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
exposedComponents: [],
|
||||
extensionPoints: [],
|
||||
},
|
||||
};
|
||||
const extensionConfig = {
|
||||
targets: [PluginExtensionPoints.DashboardPanelMenu],
|
||||
title: 'Link title',
|
||||
description: 'Link description',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
config.apps = {
|
||||
[pluginId]: appPluginConfig,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.apps = originalApps;
|
||||
});
|
||||
|
||||
it('should return FALSE if the meta-info in the plugin.json is correct', () => {
|
||||
config.apps[pluginId].extensions.addedLinks.push(extensionConfig);
|
||||
|
||||
const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig);
|
||||
|
||||
expect(returnValue).toBe(false);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should return TRUE and log a warning if the app config is not found', () => {
|
||||
delete config.apps[pluginId];
|
||||
|
||||
const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig);
|
||||
|
||||
expect(returnValue).toBe(true);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleWarnSpy.mock.calls[0][0]).toMatch("couldn't find app plugin");
|
||||
});
|
||||
|
||||
it('should return TRUE and log a warning if the link has no meta-info in the plugin.json', () => {
|
||||
config.apps[pluginId].extensions.addedLinks = [];
|
||||
|
||||
const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig);
|
||||
|
||||
expect(returnValue).toBe(true);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleWarnSpy.mock.calls[0][0]).toMatch('not registered in the plugin.json');
|
||||
});
|
||||
|
||||
it('should return TRUE and log a warning if the "targets" do not match', () => {
|
||||
config.apps[pluginId].extensions.addedLinks.push(extensionConfig);
|
||||
|
||||
const returnValue = isAddedLinkMetaInfoMissing(pluginId, {
|
||||
...extensionConfig,
|
||||
targets: [PluginExtensionPoints.DashboardPanelMenu, PluginExtensionPoints.ExploreToolbarAction],
|
||||
});
|
||||
|
||||
expect(returnValue).toBe(true);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"targets" don\'t match');
|
||||
});
|
||||
|
||||
it('should return TRUE and log a warning if the "description" does not match', () => {
|
||||
config.apps[pluginId].extensions.addedLinks.push(extensionConfig);
|
||||
|
||||
const returnValue = isAddedLinkMetaInfoMissing(pluginId, {
|
||||
...extensionConfig,
|
||||
description: 'Link description UPDATED',
|
||||
});
|
||||
|
||||
expect(returnValue).toBe(true);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"description" doesn\'t match');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAddedComponentMetaInfoMissing()', () => {
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
const originalApps = config.apps;
|
||||
const pluginId = 'myorg-extensions-app';
|
||||
const appPluginConfig = {
|
||||
id: pluginId,
|
||||
path: '',
|
||||
version: '',
|
||||
preload: false,
|
||||
angular: {
|
||||
detected: false,
|
||||
hideDeprecation: false,
|
||||
},
|
||||
loadingStrategy: PluginLoadingStrategy.fetch,
|
||||
dependencies: {
|
||||
grafanaVersion: '8.0.0',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [],
|
||||
},
|
||||
},
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
exposedComponents: [],
|
||||
extensionPoints: [],
|
||||
},
|
||||
};
|
||||
const extensionConfig = {
|
||||
targets: [PluginExtensionPoints.DashboardPanelMenu],
|
||||
title: 'Component title',
|
||||
description: 'Component description',
|
||||
component: () => <div>Component content</div>,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
config.apps = {
|
||||
[pluginId]: appPluginConfig,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.apps = originalApps;
|
||||
});
|
||||
|
||||
it('should return FALSE if the meta-info in the plugin.json is correct', () => {
|
||||
config.apps[pluginId].extensions.addedComponents.push(extensionConfig);
|
||||
|
||||
const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig);
|
||||
|
||||
expect(returnValue).toBe(false);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should return TRUE and log a warning if the app config is not found', () => {
|
||||
delete config.apps[pluginId];
|
||||
|
||||
const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig);
|
||||
|
||||
expect(returnValue).toBe(true);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleWarnSpy.mock.calls[0][0]).toMatch("couldn't find app plugin");
|
||||
});
|
||||
|
||||
it('should return TRUE and log a warning if the Component has no meta-info in the plugin.json', () => {
|
||||
config.apps[pluginId].extensions.addedComponents = [];
|
||||
|
||||
const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig);
|
||||
|
||||
expect(returnValue).toBe(true);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleWarnSpy.mock.calls[0][0]).toMatch('not registered in the plugin.json');
|
||||
});
|
||||
|
||||
it('should return TRUE and log a warning if the "targets" do not match', () => {
|
||||
config.apps[pluginId].extensions.addedComponents.push(extensionConfig);
|
||||
|
||||
const returnValue = isAddedComponentMetaInfoMissing(pluginId, {
|
||||
...extensionConfig,
|
||||
targets: [PluginExtensionPoints.ExploreToolbarAction],
|
||||
});
|
||||
|
||||
expect(returnValue).toBe(true);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"targets" don\'t match');
|
||||
});
|
||||
|
||||
it('should return TRUE and log a warning if the "description" does not match', () => {
|
||||
config.apps[pluginId].extensions.addedComponents.push(extensionConfig);
|
||||
|
||||
const returnValue = isAddedComponentMetaInfoMissing(pluginId, {
|
||||
...extensionConfig,
|
||||
description: 'UPDATED',
|
||||
});
|
||||
|
||||
expect(returnValue).toBe(true);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"description" doesn\'t match');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExposedComponentMetaInfoMissing()', () => {
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
const originalApps = config.apps;
|
||||
const pluginId = 'myorg-extensions-app';
|
||||
const appPluginConfig = {
|
||||
id: pluginId,
|
||||
path: '',
|
||||
version: '',
|
||||
preload: false,
|
||||
angular: {
|
||||
detected: false,
|
||||
hideDeprecation: false,
|
||||
},
|
||||
loadingStrategy: PluginLoadingStrategy.fetch,
|
||||
dependencies: {
|
||||
grafanaVersion: '8.0.0',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [],
|
||||
},
|
||||
},
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
exposedComponents: [],
|
||||
extensionPoints: [],
|
||||
},
|
||||
};
|
||||
const exposedComponentConfig = {
|
||||
id: `${pluginId}/component/v1`,
|
||||
title: 'Exposed component',
|
||||
description: 'Exposed component description',
|
||||
component: () => <div>Component content</div>,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
config.apps = {
|
||||
[pluginId]: appPluginConfig,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.apps = originalApps;
|
||||
});
|
||||
|
||||
it('should return FALSE if the meta-info in the plugin.json is correct', () => {
|
||||
config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig);
|
||||
|
||||
const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig);
|
||||
|
||||
expect(returnValue).toBe(false);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should return TRUE and log a warning if the app config is not found', () => {
|
||||
delete config.apps[pluginId];
|
||||
|
||||
const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig);
|
||||
|
||||
expect(returnValue).toBe(true);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleWarnSpy.mock.calls[0][0]).toMatch("couldn't find app plugin");
|
||||
});
|
||||
|
||||
it('should return TRUE and log a warning if the exposed component has no meta-info in the plugin.json', () => {
|
||||
config.apps[pluginId].extensions.exposedComponents = [];
|
||||
|
||||
const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig);
|
||||
|
||||
expect(returnValue).toBe(true);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleWarnSpy.mock.calls[0][0]).toMatch('not registered in the plugin.json');
|
||||
});
|
||||
|
||||
it('should return TRUE and log a warning if the title does not match', () => {
|
||||
config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig);
|
||||
|
||||
const returnValue = isExposedComponentMetaInfoMissing(pluginId, {
|
||||
...exposedComponentConfig,
|
||||
title: 'UPDATED',
|
||||
});
|
||||
|
||||
expect(returnValue).toBe(true);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"title" doesn\'t match');
|
||||
});
|
||||
|
||||
it('should return TRUE and log a warning if the "description" does not match', () => {
|
||||
config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig);
|
||||
|
||||
const returnValue = isExposedComponentMetaInfoMissing(pluginId, {
|
||||
...exposedComponentConfig,
|
||||
description: 'UPDATED',
|
||||
});
|
||||
|
||||
expect(returnValue).toBe(true);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleWarnSpy.mock.calls[0][0]).toMatch('"description" doesn\'t match');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExposedComponentDependencyMissing()', () => {
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
let pluginContext: PluginContextType;
|
||||
const pluginId = 'myorg-extensions-app';
|
||||
const exposedComponentId = `${pluginId}/component/v1`;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
pluginContext = {
|
||||
meta: {
|
||||
id: pluginId,
|
||||
name: 'Extensions App',
|
||||
type: PluginType.app,
|
||||
module: '',
|
||||
baseUrl: '',
|
||||
info: {
|
||||
author: {
|
||||
name: 'MyOrg',
|
||||
},
|
||||
description: 'App for testing extensions',
|
||||
links: [],
|
||||
logos: {
|
||||
large: '',
|
||||
small: '',
|
||||
},
|
||||
screenshots: [],
|
||||
updated: '2023-10-26T18:25:01Z',
|
||||
version: '1.0.0',
|
||||
},
|
||||
dependencies: {
|
||||
grafanaVersion: '8.0.0',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('should return FALSE if the meta-info in the plugin.json is correct', () => {
|
||||
pluginContext.meta.dependencies?.extensions.exposedComponents.push(exposedComponentId);
|
||||
|
||||
const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext);
|
||||
|
||||
expect(returnValue).toBe(false);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should return TRUE and log a warning if the dependencies are missing', () => {
|
||||
delete pluginContext.meta.dependencies;
|
||||
|
||||
const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext);
|
||||
|
||||
expect(returnValue).toBe(true);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleWarnSpy.mock.calls[0][0]).toMatch(`Using exposed component "${exposedComponentId}"`);
|
||||
});
|
||||
|
||||
it('should return TRUE and log a warning if the exposed component id is not specified in the list of dependencies', () => {
|
||||
const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext);
|
||||
|
||||
expect(returnValue).toBe(true);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleWarnSpy.mock.calls[0][0]).toMatch(`Using exposed component "${exposedComponentId}"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExtensionPointMetaInfoMissing()', () => {
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
let pluginContext: PluginContextType;
|
||||
const pluginId = 'myorg-extensions-app';
|
||||
const extensionPointId = `${pluginId}/extension-point/v1`;
|
||||
const extensionPointConfig = {
|
||||
id: extensionPointId,
|
||||
title: 'Extension point title',
|
||||
description: 'Extension point description',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
pluginContext = {
|
||||
meta: {
|
||||
id: pluginId,
|
||||
name: 'Extensions App',
|
||||
type: PluginType.app,
|
||||
module: '',
|
||||
baseUrl: '',
|
||||
info: {
|
||||
author: {
|
||||
name: 'MyOrg',
|
||||
},
|
||||
description: 'App for testing extensions',
|
||||
links: [],
|
||||
logos: {
|
||||
large: '',
|
||||
small: '',
|
||||
},
|
||||
screenshots: [],
|
||||
updated: '2023-10-26T18:25:01Z',
|
||||
version: '1.0.0',
|
||||
},
|
||||
extensions: {
|
||||
addedLinks: [],
|
||||
addedComponents: [],
|
||||
exposedComponents: [],
|
||||
extensionPoints: [],
|
||||
},
|
||||
dependencies: {
|
||||
grafanaVersion: '8.0.0',
|
||||
plugins: [],
|
||||
extensions: {
|
||||
exposedComponents: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('should return FALSE if the meta-info in the plugin.json is correct', () => {
|
||||
pluginContext.meta.extensions?.extensionPoints.push(extensionPointConfig);
|
||||
|
||||
const returnValue = isExtensionPointMetaInfoMissing(extensionPointId, pluginContext);
|
||||
|
||||
expect(returnValue).toBe(false);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should return TRUE and log a warning if the extension point id is not recorded in the plugin.json', () => {
|
||||
const returnValue = isExtensionPointMetaInfoMissing(extensionPointId, pluginContext);
|
||||
|
||||
expect(returnValue).toBe(true);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(consoleWarnSpy.mock.calls[0][0]).toMatch(`Extension point "${extensionPointId}"`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -16,8 +16,11 @@ import {
|
||||
PanelMenuItem,
|
||||
PluginExtensionAddedLinkConfig,
|
||||
urlUtil,
|
||||
PluginContextType,
|
||||
PluginExtensionExposedComponentConfig,
|
||||
PluginExtensionAddedComponentConfig,
|
||||
} from '@grafana/data';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { reportInteraction, config } from '@grafana/runtime';
|
||||
import { Modal } from '@grafana/ui';
|
||||
import appEvents from 'app/core/app_events';
|
||||
// TODO: instead of depending on the service as a singleton, inject it as an argument from the React context
|
||||
@ -408,3 +411,145 @@ export const openAppInSideview = (pluginId: string) => sidecarService.openApp(pl
|
||||
export const closeAppInSideview = (pluginId: string) => sidecarService.closeApp(pluginId);
|
||||
|
||||
export const isAppOpened = (pluginId: string) => sidecarService.isAppOpened(pluginId);
|
||||
|
||||
// Comes from the `app_mode` setting in the Grafana config (defaults to "development")
|
||||
// Can be set with the `GF_DEFAULT_APP_MODE` environment variable
|
||||
export const isGrafanaDevMode = () => config.buildInfo.env === 'development';
|
||||
|
||||
// Checks if the meta information is missing from the plugin's plugin.json file
|
||||
export const isExtensionPointMetaInfoMissing = (extensionPointId: string, pluginContext: PluginContextType) => {
|
||||
const pluginId = pluginContext.meta?.id;
|
||||
const extensionPoints = pluginContext.meta?.extensions?.extensionPoints;
|
||||
|
||||
if (!extensionPoints || !extensionPoints.some((ep) => ep.id === extensionPointId)) {
|
||||
logWarning(
|
||||
`Extension point "${extensionPointId}" - it's not recorded in the "plugin.json" for "${pluginId}". Please add it under "extensions.extensionPoints[]".`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Checks if an exposed component that the plugin is depending on is missing from the `dependencies` in the plugin.json file
|
||||
export const isExposedComponentDependencyMissing = (id: string, pluginContext: PluginContextType) => {
|
||||
const pluginId = pluginContext.meta?.id;
|
||||
const exposedComponentsDependencies = pluginContext.meta?.dependencies?.extensions?.exposedComponents;
|
||||
|
||||
if (!exposedComponentsDependencies || !exposedComponentsDependencies.includes(id)) {
|
||||
logWarning(
|
||||
`Using exposed component "${id}" - it's not recorded in the "plugin.json" for "${pluginId}". Please add it under "dependencies.extensions.exposedComponents[]".`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isAddedLinkMetaInfoMissing = (pluginId: string, metaInfo: PluginExtensionAddedLinkConfig) => {
|
||||
const app = config.apps[pluginId];
|
||||
const logPrefix = `Added-link "${metaInfo.title}" from "${pluginId}" -`;
|
||||
const pluginJsonMetaInfo = app ? app.extensions.addedLinks.find(({ title }) => title === metaInfo.title) : null;
|
||||
|
||||
if (!app) {
|
||||
logWarning(`${logPrefix} couldn't find app plugin "${pluginId}"`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!pluginJsonMetaInfo) {
|
||||
logWarning(`${logPrefix} not registered in the plugin.json under "extensions.addedLinks[]".`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets];
|
||||
if (!targets.every((target) => pluginJsonMetaInfo.targets.includes(target))) {
|
||||
logWarning(`${logPrefix} the "targets" don't match with ones in the plugin.json under "extensions.addedLinks[]".`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pluginJsonMetaInfo.description !== metaInfo.description) {
|
||||
logWarning(
|
||||
`${logPrefix} the "description" doesn't match with one in the plugin.json under "extensions.addedLinks[]".`
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isAddedComponentMetaInfoMissing = (pluginId: string, metaInfo: PluginExtensionAddedComponentConfig) => {
|
||||
const app = config.apps[pluginId];
|
||||
const logPrefix = `Added component "${metaInfo.title}" -`;
|
||||
const pluginJsonMetaInfo = app ? app.extensions.addedComponents.find(({ title }) => title === metaInfo.title) : null;
|
||||
|
||||
if (!app) {
|
||||
logWarning(`${logPrefix} couldn't find app plugin "${pluginId}"`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!pluginJsonMetaInfo) {
|
||||
logWarning(`${logPrefix} not registered in the plugin.json under "extensions.addedComponents[]".`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets];
|
||||
if (!targets.every((target) => pluginJsonMetaInfo.targets.includes(target))) {
|
||||
logWarning(
|
||||
`${logPrefix} the "targets" don't match with ones in the plugin.json under "extensions.addedComponents[]".`
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pluginJsonMetaInfo.description !== metaInfo.description) {
|
||||
logWarning(
|
||||
`${logPrefix} the "description" doesn't match with one in the plugin.json under "extensions.addedComponents[]".`
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isExposedComponentMetaInfoMissing = (
|
||||
pluginId: string,
|
||||
metaInfo: PluginExtensionExposedComponentConfig
|
||||
) => {
|
||||
const app = config.apps[pluginId];
|
||||
const logPrefix = `Exposed component "${metaInfo.id}" -`;
|
||||
const pluginJsonMetaInfo = app ? app.extensions.exposedComponents.find(({ id }) => id === metaInfo.id) : null;
|
||||
|
||||
if (!app) {
|
||||
logWarning(`${logPrefix} couldn't find app plugin: "${pluginId}"`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!pluginJsonMetaInfo) {
|
||||
logWarning(`${logPrefix} not registered in the plugin.json under "extensions.exposedComponents[]".`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pluginJsonMetaInfo.title !== metaInfo.title) {
|
||||
logWarning(
|
||||
`${logPrefix} the "title" doesn't match with one in the plugin.json under "extensions.exposedComponents[]".`
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pluginJsonMetaInfo.description !== metaInfo.description) {
|
||||
logWarning(
|
||||
`${logPrefix} the "description" doesn't match with one in the plugin.json under "extensions.exposedComponents[]".`
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
assertConfigureIsValid,
|
||||
assertLinkPathIsValid,
|
||||
assertStringProps,
|
||||
isExtensionPointIdValid,
|
||||
isGrafanaCoreExtensionPoint,
|
||||
isReactComponent,
|
||||
} from './validators';
|
||||
@ -184,4 +185,50 @@ describe('Plugin Extension Validators', () => {
|
||||
expect(isGrafanaCoreExtensionPoint('grafana/dashboard/alertingrule/action')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExtensionPointIdValid()', () => {
|
||||
test.each([
|
||||
// We (for now allow core Grafana extension points to run without a version)
|
||||
['grafana/extension-point', ''],
|
||||
['grafana/extension-point', 'grafana'],
|
||||
['myorg-extensions-app/extension-point', 'myorg-extensions-app'],
|
||||
['myorg-extensions-app/extension-point/v1', 'myorg-extensions-app'],
|
||||
['plugins/myorg-extensions-app/extension-point/v1', 'myorg-extensions-app'],
|
||||
['plugins/myorg-basic-app/start', 'myorg-basic-app'],
|
||||
['myorg-extensions-app/extension-point/v1', 'myorg-extensions-app'],
|
||||
['plugins/myorg-extensions-app/extension-point/v1', 'myorg-extensions-app'],
|
||||
['plugins/grafana-app-observability-app/service/action', 'grafana-app-observability-app'],
|
||||
['plugins/grafana-k8s-app/cluster/action', 'grafana-k8s-app'],
|
||||
['plugins/grafana-oncall-app/alert-group/action', 'grafana-oncall-app'],
|
||||
['plugins/grafana-oncall-app/alert-group/action/v1', 'grafana-oncall-app'],
|
||||
['plugins/grafana-oncall-app/alert-group/action/v1.0.0', 'grafana-oncall-app'],
|
||||
])('should return TRUE if the extension point id is valid ("%s", "%s")', (extensionPointId, pluginId) => {
|
||||
expect(
|
||||
isExtensionPointIdValid({
|
||||
extensionPointId,
|
||||
pluginId,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
// Plugin id mismatch
|
||||
'myorg-extensions-app/extension-point/v1',
|
||||
'myorgs-other-app',
|
||||
],
|
||||
[
|
||||
// Missing plugin id prefix
|
||||
'extension-point/v1',
|
||||
'myorgs-extensions-app',
|
||||
],
|
||||
])('should return FALSE if the extension point id is invalid ("%s", "%s")', (extensionPointId, pluginId) => {
|
||||
expect(
|
||||
isExtensionPointIdValid({
|
||||
extensionPointId,
|
||||
pluginId,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -53,12 +53,18 @@ export function isLinkPathValid(pluginId: string, path: string) {
|
||||
return Boolean(typeof path === 'string' && path.length > 0 && path.startsWith(`/a/${pluginId}/`));
|
||||
}
|
||||
|
||||
export function isExtensionPointIdValid(pluginId: string, extensionPointId: string) {
|
||||
return Boolean(
|
||||
extensionPointId.startsWith('grafana/') ||
|
||||
extensionPointId?.startsWith('plugins/') ||
|
||||
extensionPointId?.startsWith(pluginId)
|
||||
);
|
||||
export function isExtensionPointIdValid({
|
||||
extensionPointId,
|
||||
pluginId,
|
||||
}: {
|
||||
extensionPointId: string;
|
||||
pluginId: string;
|
||||
}) {
|
||||
if (extensionPointId.startsWith('grafana/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(extensionPointId.startsWith(`plugins/${pluginId}/`) || extensionPointId.startsWith(`${pluginId}/`));
|
||||
}
|
||||
|
||||
export function extensionPointEndsWithVersion(extensionPointId: string) {
|
||||
|
@ -7,7 +7,7 @@ content_security_policy_template = """require-trusted-types-for 'script'; script
|
||||
enable = publicDashboards
|
||||
|
||||
[plugins]
|
||||
allow_loading_unsigned_plugins=grafana-extensionstest-app,grafana-extensionexample1-app,grafana-extensionexample2-app,
|
||||
allow_loading_unsigned_plugins=grafana-extensionstest-app,grafana-extensionexample1-app,grafana-extensionexample2-app,grafana-extensionexample3-app
|
||||
|
||||
[database]
|
||||
type=sqlite3
|
||||
|
Loading…
Reference in New Issue
Block a user