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:
Levente Balogh 2024-10-04 08:41:26 +02:00 committed by GitHub
parent 7188c13d22
commit 6096f46774
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 3197 additions and 243 deletions

View File

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

View File

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

View File

@ -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({

View File

@ -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({

View File

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

View File

@ -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": []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": []
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -586,6 +586,7 @@ export {
type AngularMeta,
type PluginMeta,
type PluginDependencies,
type PluginExtensions,
type PluginInclude,
type PluginBuildInfo,
type ScreenshotInfo,

View File

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

View File

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

View File

@ -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 = {

View File

@ -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"`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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({

View File

@ -43,6 +43,9 @@ export default {
grafanaDependency: '>=7.3.0',
grafanaVersion: '7.3',
plugins: [],
extensions: {
exposedComponents: [],
},
},
info: {
links: [],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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