diff --git a/devenv/plugins.yaml b/devenv/plugins.yaml index 7abef7b8789..554a4828cff 100644 --- a/devenv/plugins.yaml +++ b/devenv/plugins.yaml @@ -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 diff --git a/e2e/test-plugins/grafana-extensionstest-app/pages/AddedComponents.tsx b/e2e/test-plugins/grafana-extensionstest-app/pages/AddedComponents.tsx index b7f3242f3a6..6e8d705d0dd 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/pages/AddedComponents.tsx +++ b/e2e/test-plugins/grafana-extensionstest-app/pages/AddedComponents.tsx @@ -9,7 +9,7 @@ type ReusableComponentProps = { export function AddedComponents() { const { components } = usePluginComponents({ - extensionPointId: 'plugins/grafana-extensionexample2-app/addComponent/v1', + extensionPointId: 'plugins/grafana-extensionstest-app/addComponent/v1', }); return ( diff --git a/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyGetters.tsx b/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyGetters.tsx index 910eed92e5b..5cebc74d744 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyGetters.tsx +++ b/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyGetters.tsx @@ -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({ diff --git a/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyHooks.tsx b/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyHooks.tsx index ab963f07169..ef63caf9244 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyHooks.tsx +++ b/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyHooks.tsx @@ -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({ diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugin.json b/e2e/test-plugins/grafana-extensionstest-app/plugin.json index 0924f5aad10..39252838461 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/plugin.json +++ b/e2e/test-plugins/grafana-extensionstest-app/plugin.json @@ -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"] + } } } diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/plugin.json b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/plugin.json index 3d012351bbe..bb40c05b9f2 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/plugin.json +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/plugin.json @@ -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": [] diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/module.tsx b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/module.tsx index 071370cf592..5a75974e1ed 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/module.tsx +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/module.tsx @@ -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 }) =>
Hello {name}!
, }) .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 }) => ( diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/plugin.json b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/plugin.json index f27f9e1113b..9585ba8761e 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/plugin.json +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/plugin.json @@ -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", diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/components/App/AddedLinks.tsx b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/components/App/AddedLinks.tsx new file mode 100644 index 00000000000..64df8da1575 --- /dev/null +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/components/App/AddedLinks.tsx @@ -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 ( + +
+

Link extensions defined with addLink and retrieved using usePluginLinks

+ +
+
+

Link extensions defined with addLink and retrieved using usePluginExtensions

+ +
+
+ ); +} diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/components/App/App.tsx b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/components/App/App.tsx new file mode 100644 index 00000000000..3ee8ecc33ba --- /dev/null +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/components/App/App.tsx @@ -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 { + render() { + return ( +
+ Hello Grafana! + +
+ ); + } +} diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/img/logo.svg b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/img/logo.svg new file mode 100644 index 00000000000..3d284dea3af --- /dev/null +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/img/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/module.tsx b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/module.tsx new file mode 100644 index 00000000000..bba11717f86 --- /dev/null +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/module.tsx @@ -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: () =>
From plugin B
, + }); + }, + }) + .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 }) =>
Hello {name}!
, + }) + .addComponent<{ name: string }>({ + targets: ['plugins/grafana-extensionstest-app/addComponent/v1'], + title: 'Added component (where meta data is missing)', + description: '.', + component: ({ name }: { name: string }) => ( +
Hello {name}!
+ ), + }) + .addLink({ + title: 'Added link (where meta data is missing)', + description: '.', + targets: [LINKS_EXTENSION_POINT_ID], + onClick: (_, { openModal }) => { + openModal({ + title: 'Modal from app C', + body: () =>
From plugin B
, + }); + }, + }); diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/plugin.json b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/plugin.json new file mode 100644 index 00000000000..dc50b7cd483 --- /dev/null +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/plugin.json @@ -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": [] + } +} diff --git a/e2e/test-plugins/grafana-extensionstest-app/testIds.ts b/e2e/test-plugins/grafana-extensionstest-app/testIds.ts index debed95957b..dda567237af 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/testIds.ts +++ b/e2e/test-plugins/grafana-extensionstest-app/testIds.ts @@ -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', diff --git a/e2e/test-plugins/grafana-extensionstest-app/tests/legacy/extensionPoints.hooks.spec.ts b/e2e/test-plugins/grafana-extensionstest-app/tests/legacy/extensionPoints.hooks.spec.ts index d5c96eefa31..c4ae9d2d23a 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/tests/legacy/extensionPoints.hooks.spec.ts +++ b/e2e/test-plugins/grafana-extensionstest-app/tests/legacy/extensionPoints.hooks.spec.ts @@ -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(); + }); +}); diff --git a/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts b/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts index df5f64796d4..2d471db1898 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts +++ b/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts @@ -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(); +}); diff --git a/packages/grafana-data/src/context/plugins/usePluginContext.tsx b/packages/grafana-data/src/context/plugins/usePluginContext.tsx index 51723446b81..58190a7e4e1 100644 --- a/packages/grafana-data/src/context/plugins/usePluginContext.tsx +++ b/packages/grafana-data/src/context/plugins/usePluginContext.tsx @@ -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; } diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 890123bcd90..d6d414a81c5 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -586,6 +586,7 @@ export { type AngularMeta, type PluginMeta, type PluginDependencies, + type PluginExtensions, type PluginInclude, type PluginBuildInfo, type ScreenshotInfo, diff --git a/packages/grafana-data/src/types/plugin.ts b/packages/grafana-data/src/types/plugin.ts index 56ec6168d0b..4233771dbe6 100644 --- a/packages/grafana-data/src/types/plugin.ts +++ b/packages/grafana-data/src/types/plugin.ts @@ -98,6 +98,7 @@ export interface PluginMeta { 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 { diff --git a/packages/grafana-runtime/src/analytics/plugins/usePluginInteractionReporter.ts b/packages/grafana-runtime/src/analytics/plugins/usePluginInteractionReporter.ts index d9d89b76770..29da46165ac 100644 --- a/packages/grafana-runtime/src/analytics/plugins/usePluginInteractionReporter.ts +++ b/packages/grafana-runtime/src/analytics/plugins/usePluginInteractionReporter.ts @@ -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); diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 8c1c1c93d73..71ec381d45b 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -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 = { diff --git a/pkg/api/dtos/plugins.go b/pkg/api/dtos/plugins.go index 0d882bce28f..b14b915e5ff 100644 --- a/pkg/api/dtos/plugins.go +++ b/pkg/api/dtos/plugins.go @@ -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"` diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 6566b02acfc..511d673ca6b 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -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 { diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index f10f1a48d2b..e690dc91709 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -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() { diff --git a/pkg/plugins/manager/loader/finder/local_test.go b/pkg/plugins/manager/loader/finder/local_test.go index 4f84d8bb4f4..9664f824186 100644 --- a/pkg/plugins/manager/loader/finder/local_test.go +++ b/pkg/plugins/manager/loader/finder/local_test.go @@ -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, diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go index 7e1f2ecaafc..c4cf2d4faf7 100644 --- a/pkg/plugins/manager/loader/loader_test.go +++ b/pkg/plugins/manager/loader/loader_test.go @@ -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", diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index 2f3a857d1cd..c14cc44dd17 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -1,6 +1,7 @@ package plugins import ( + "encoding/json" "errors" "fmt" @@ -42,9 +43,100 @@ func (e DuplicateError) Is(err error) bool { } type Dependencies struct { - GrafanaDependency string `json:"grafanaDependency"` - GrafanaVersion string `json:"grafanaVersion"` - Plugins []Dependency `json:"plugins"` + 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 ( diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index 5a91ea24b69..ee69c10f32b 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -109,7 +109,8 @@ type JSONData struct { SkipDataQuery bool `json:"skipDataQuery"` // App settings - AutoEnabled bool `json:"autoEnabled"` + 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 diff --git a/pkg/plugins/plugins_test.go b/pkg/plugins/plugins_test.go index 62753a56ae8..797c39a8ef0 100644 --- a/pkg/plugins/plugins_test.go +++ b/pkg/plugins/plugins_test.go @@ -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()) }) } diff --git a/pkg/services/pluginsintegration/loader/loader_test.go b/pkg/services/pluginsintegration/loader/loader_test.go index 2fed29e25a4..4eafd7db235 100644 --- a/pkg/services/pluginsintegration/loader/loader_test.go +++ b/pkg/services/pluginsintegration/loader/loader_test.go @@ -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{ @@ -840,10 +930,18 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) { }, Version: "1.0.0", }, - State: plugins.ReleaseStateAlpha, - Dependencies: plugins.Dependencies{GrafanaVersion: "*", Plugins: []plugins.Dependency{}}, - Backend: true, - Executable: "test", + State: plugins.ReleaseStateAlpha, + 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", }, FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "valid-v2-pvt-signature-root-url-uri/plugin")), Class: plugins.ClassExternal, @@ -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", diff --git a/pkg/tests/api/plugins/data/expectedListResp.json b/pkg/tests/api/plugins/data/expectedListResp.json index f83e6110626..dcbe8301979 100644 --- a/pkg/tests/api/plugins/data/expectedListResp.json +++ b/pkg/tests/api/plugins/data/expectedListResp.json @@ -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, diff --git a/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts b/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts index 60f641dc089..f5bdac45050 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts @@ -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: [], + }, + }, }; }); diff --git a/public/app/features/alerting/unified/utils/rules.test.ts b/public/app/features/alerting/unified/utils/rules.test.ts index 59d77e3b3d3..fb1477d038c 100644 --- a/public/app/features/alerting/unified/utils/rules.test.ts +++ b/public/app/features/alerting/unified/utils/rules.test.ts @@ -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({ diff --git a/public/app/features/plugins/admin/__mocks__/remotePlugin.mock.ts b/public/app/features/plugins/admin/__mocks__/remotePlugin.mock.ts index d649c990042..f95a40df8f1 100644 --- a/public/app/features/plugins/admin/__mocks__/remotePlugin.mock.ts +++ b/public/app/features/plugins/admin/__mocks__/remotePlugin.mock.ts @@ -43,6 +43,9 @@ export default { grafanaDependency: '>=7.3.0', grafanaVersion: '7.3', plugins: [], + extensions: { + exposedComponents: [], + }, }, info: { links: [], diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts index ac0dc845a0a..8e0041d185c 100644 --- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts @@ -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 '/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(); + }); }); diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts index f95f3167fe6..1760f3d36cf 100644 --- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts +++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts @@ -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; } + if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedComponentMetaInfoMissing(pluginId, config)) { + 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 '/my-component-id/v1'.` - ); - continue; - } - if (!isGrafanaCoreExtensionPoint(extensionPointId) && !extensionPointEndsWithVersion(extensionPointId)) { logWarning( - `Added component with id '${extensionPointId}' does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'.` + `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'.` ); } diff --git a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts index a77eddb2a7d..45735806af9 100644 --- a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts @@ -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(); + }); }); diff --git a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts index d547f426258..d2f6207e496 100644 --- a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts +++ b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts @@ -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 ({ + ...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(); + }); }); diff --git a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts index 90bbca13c73..c84c89ab199 100644 --- a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts +++ b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts @@ -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 }; } diff --git a/public/app/features/plugins/extensions/usePluginComponent.test.tsx b/public/app/features/plugins/extensions/usePluginComponent.test.tsx index 8b5e644e040..7cf467b5e38 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.test.tsx @@ -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: () =>
Hello World
, + }; + 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 }) => ( {children} ); }); + 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: () =>
Hello World
}], + 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: () =>
Hello World
, - }, - ], + 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: () =>
Hello World
, - }, - ], + 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 }) => ( + + {children} + + ); + + 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 }) => ( + {children} + ); + + 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 }) => ( + + {children} + + ); + + 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 }) => ( + + {children} + + ); + + registries.exposedComponentsRegistry.register({ + pluginId, + configs: [exposedComponentConfig], + }); + + let { result } = renderHook(() => usePluginComponent(exposedComponentId), { wrapper }); + expect(result.current.component).not.toBe(null); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); }); diff --git a/public/app/features/plugins/extensions/usePluginComponent.tsx b/public/app/features/plugins/extensions/usePluginComponent.tsx index 7dfeb3e1f67..9ae43c27bbf 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.tsx @@ -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(id: string): UsePluginComponentResult { 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(id: string): UsePl isLoading: false, component: wrapWithPluginContext(registryItem.pluginId, registryItem.component), }; - }, [id, registryState]); + }, [id, pluginContext, registryState]); } diff --git a/public/app/features/plugins/extensions/usePluginComponents.test.tsx b/public/app/features/plugins/extensions/usePluginComponents.test.tsx index a9855f8006a..0c37e90e549 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.test.tsx @@ -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 }) => ( - {children} + + {children} + ); }); @@ -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) => )); }); + 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: () =>
Hello World1
, }, { - targets: extensionPointId, + targets: [extensionPointId], title: '2', description: '2', component: () =>
Hello World2
, }, { - targets: extensionPointId, + targets: [extensionPointId], title: '3', description: '3', component: () =>
Hello World3
, @@ -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 }) => ( + + {children} + + ); + + registries.addedComponentsRegistry.register({ + pluginId, + configs: [ + { + targets: extensionPointId, + title: '1', + description: '1', + component: () =>
Component
, + }, + ], + }); + + // 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 }) => ( + + {children} + + ); + + // 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 }) => ( + {children} + ); + + // 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: () =>
Component
, + }, + ], + }); + + 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 }) => ( + {children} + ); + + 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 }) => ( + + {children} + + ); + + // 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: () =>
Component
, + }, + ], + }); + + // 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 }) => ( + + {children} + + ); + + // 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: () =>
Component
, + }, + ], + }); + + // 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(); + }); }); diff --git a/public/app/features/plugins/extensions/usePluginComponents.tsx b/public/app/features/plugins/extensions/usePluginComponents.tsx index bb19a09608f..92dbc4e676c 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.tsx @@ -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({ @@ -15,10 +18,34 @@ export function usePluginComponents({ }: UsePluginComponentOptions): UsePluginComponentsResult { 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> = []; const extensionsByPlugin: Record = {}; + 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({ isLoading: false, components, }; - }, [extensionPointId, limitPerPlugin, registryState]); + }, [extensionPointId, limitPerPlugin, pluginContext, registryState]); } diff --git a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx index 17ff510f8ef..71254272588 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx @@ -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); diff --git a/public/app/features/plugins/extensions/usePluginExtensions.tsx b/public/app/features/plugins/extensions/usePluginExtensions.tsx index f67c24ba5c5..8b5c3e69f37 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.tsx @@ -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 { + 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 }; diff --git a/public/app/features/plugins/extensions/usePluginLinks.test.tsx b/public/app/features/plugins/extensions/usePluginLinks.test.tsx index c061932ac73..d4d0cc6a843 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.test.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.test.tsx @@ -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 }) => ( - {children} + + {children} + ); }); @@ -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 }) => ( + + {children} + + ); + + 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 }) => ( + + {children} + + ); + + // 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 }) => ( + {children} + ); + + // 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 }) => ( + {children} + ); + + 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 }) => ( + + {children} + + ); + + // 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 }) => ( + + {children} + + ); + + // 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(); + }); }); diff --git a/public/app/features/plugins/extensions/usePluginLinks.tsx b/public/app/features/plugins/extensions/usePluginLinks.tsx index 723ea6cd9bf..1f868b01c77 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.tsx @@ -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]); } diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx index aca0f0797cf..3697fe0b71f 100644 --- a/public/app/features/plugins/extensions/utils.test.tsx +++ b/public/app/features/plugins/extensions/utils.test.tsx @@ -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
Version: {context.meta.info.version}
; + return
Version: {context!.meta.info.version}
; }; 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 (
-

Hello {audience}!

Version: {meta.info.version} +

Hello {audience}!

Version: {pluginContext!.meta.info.version}
); }; @@ -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: () =>
Component content
, + }; + + 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: () =>
Component content
, + }; + + 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}"`); + }); + }); }); diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index ade21ad0962..0938c1df666 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -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; +}; diff --git a/public/app/features/plugins/extensions/validators.test.tsx b/public/app/features/plugins/extensions/validators.test.tsx index efb329af5cf..2ce296c7210 100644 --- a/public/app/features/plugins/extensions/validators.test.tsx +++ b/public/app/features/plugins/extensions/validators.test.tsx @@ -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); + }); + }); }); diff --git a/public/app/features/plugins/extensions/validators.ts b/public/app/features/plugins/extensions/validators.ts index 96970860973..b03df733d83 100644 --- a/public/app/features/plugins/extensions/validators.ts +++ b/public/app/features/plugins/extensions/validators.ts @@ -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) { diff --git a/scripts/grafana-server/custom.ini b/scripts/grafana-server/custom.ini index 5d09d40ac63..95499fabb85 100644 --- a/scripts/grafana-server/custom.ini +++ b/scripts/grafana-server/custom.ini @@ -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