Plugin Extensions: Require meta-data to be defined in plugin.json during development mode (#93429)

* feat: add extensions to the backend plugin model

* feat: update the frontend plugin types

* feat(pluginContext): return a `null` if there is no context found

This will be necessary to understand if a certain hook is running inside a plugin context or not.

* feat: add utility functions for checking extension configs

* tests: fix failing tests due to the type updates

* feat(AddedComponentsRegistry): validate plugin meta-info

* feat(AddedLinksRegistry): validate  meta-info

* feat(ExposedComponentsRegistry): validate meta-info

* feat(usePluginComponent): add meta-info validation

* feat(usePluginComponents): add meta-info validation

* feat(usePluginLinks): add meta-info validation

* fix: only validate meta-info in registries if dev mode is enabled

* tests: add unit tests for the restrictions functionality

* tests: fix Go tests

* fix(tests): revert accidental changes

* fix: run goimports

* fix: api tests

* add nested app so that meta data can bested e2e tested

* refactor(types): extract the ExtensionInfo into a separate type

* refactor(extensions/utils): use Array.prototype.some() instead of .find()

* refactor(usePluginLinks): update warning message

* feat(usePluginExtensions()): validate plugin meta-info

* Wip

* fix(e2e): E2E tests for extensions

* fix(extensions): allow multiple "/" slashes in the extension point id

* fix(extensions/validators): stop validating the plugin id pattern

---------

Co-authored-by: Erik Sundell <erik.sundell87@gmail.com>
This commit is contained in:
Levente Balogh
2024-10-04 08:41:26 +02:00
committed by GitHub
parent 7188c13d22
commit 6096f46774
53 changed files with 3197 additions and 243 deletions

View File

@@ -9,7 +9,7 @@ type ReusableComponentProps = {
export function AddedComponents() {
const { components } = usePluginComponents<ReusableComponentProps>({
extensionPointId: 'plugins/grafana-extensionexample2-app/addComponent/v1',
extensionPointId: 'plugins/grafana-extensionstest-app/addComponent/v1',
});
return (

View File

@@ -16,7 +16,7 @@ type ReusableComponentProps = {
export function LegacyGetters() {
const extensionPointId1 = 'plugins/grafana-extensionstest-app/actions';
const extensionPointId2 = 'plugins/grafana-extensionexample2-app/configure-extension-component/v1';
const extensionPointId2 = 'plugins/grafana-extensionstest-app/configure-extension-component/v1';
const context: AppExtensionContext = {};
const { extensions } = getPluginExtensions({

View File

@@ -16,7 +16,7 @@ type ReusableComponentProps = {
export function LegacyHooks() {
const extensionPointId1 = 'plugins/grafana-extensionstest-app/actions';
const extensionPointId2 = 'plugins/grafana-extensionexample2-app/configure-extension-component/v1';
const extensionPointId2 = 'plugins/grafana-extensionstest-app/configure-extension-component/v1';
const context: AppExtensionContext = {};
const { extensions } = usePluginExtensions({

View File

@@ -60,8 +60,43 @@
"defaultNav": false
}
],
"extensions": {
"addedLinks": [
{
"targets": ["grafana/dashboard/panel/menu"],
"title": "Open from time series or pie charts (path)",
"description": "This link will only be visible on time series and pie charts"
},
{
"targets": ["grafana/dashboard/panel/menu"],
"title": "Open from time series or pie charts (onClick)",
"description": "This link will only be visible on time series and pie charts"
}
],
"extensionPoints": [
{
"id": "plugins/grafana-extensionstest-app/use-plugin-links/v1",
"title": "Extension point - links"
},
{
"id": "plugins/grafana-extensionstest-app/addComponent/v1",
"title": "Extension point - components"
},
{
"id": "plugins/grafana-extensionstest-app/actions",
"title": "Legacy extension point - usePluginExtensions() and usePluginLinkExtensions()"
},
{
"id": "plugins/grafana-extensionstest-app/configure-extension-component/v1",
"title": "Legacy extension point - usePluginComponentExtensions()"
}
]
},
"dependencies": {
"grafanaDependency": ">=10.4.0",
"plugins": []
"plugins": [],
"extensions": {
"exposedComponents": ["grafana-extensionexample1-app/reusable-component/v1"]
}
}
}

View File

@@ -28,6 +28,30 @@
"defaultNav": false
}
],
"extensions": {
"exposedComponents": [
{
"id": "grafana-extensionexample1-app/reusable-component/v1",
"title": "Exposed component",
"description": "A component that can be reused by other app plugins."
}
],
"addedLinks": [
{
"targets": [
"plugins/grafana-extensionstest-app/actions",
"plugins/grafana-extensionstest-app/use-plugin-links/v1"
],
"title": "Go to A",
"description": "Navigating to pluging A"
},
{
"targets": ["plugins/grafana-extensionstest-app/use-plugin-links/v1"],
"title": "Basic link",
"description": "..."
}
]
},
"dependencies": {
"grafanaDependency": ">=10.3.3",
"plugins": []

View File

@@ -19,13 +19,13 @@ export const plugin = new AppPlugin<{}>()
},
})
.configureExtensionComponent({
extensionPointId: 'plugins/grafana-extensionexample2-app/configure-extension-component/v1',
extensionPointId: 'plugins/grafana-extensionstest-app/configure-extension-component/v1',
title: 'Configure extension component from B',
description: 'A component that can be reused by other app plugins. Shared using configureExtensionComponent api',
component: ({ name }: { name: string }) => <div data-testid={testIds.appB.reusableComponent}>Hello {name}!</div>,
})
.addComponent<{ name: string }>({
targets: 'plugins/grafana-extensionexample2-app/addComponent/v1',
targets: 'plugins/grafana-extensionstest-app/addComponent/v1',
title: 'Added component from B',
description: 'A component that can be reused by other app plugins. Shared using addComponent api',
component: ({ name }: { name: string }) => (

View File

@@ -18,6 +18,30 @@
"version": "%VERSION%",
"updated": "%TODAY%"
},
"extensions": {
"addedLinks": [
{
"targets": [
"plugins/grafana-extensionstest-app/actions",
"plugins/grafana-extensionstest-app/use-plugin-links/v1"
],
"title": "Open from B",
"description": "Open a modal from plugin B"
}
],
"addedComponents": [
{
"targets": ["plugins/grafana-extensionstest-app/configure-extension-component/v1"],
"title": "Configure extension component from B",
"description": "A component that can be reused by other app plugins. Shared using configureExtensionComponent api"
},
{
"targets": ["plugins/grafana-extensionstest-app/addComponent/v1"],
"title": "Added component from B",
"description": "A component that can be reused by other app plugins. Shared using addComponent api"
}
]
},
"includes": [
{
"type": "page",

View File

@@ -0,0 +1,24 @@
import { usePluginExtensions, usePluginLinks } from '@grafana/runtime';
import { Stack } from '@grafana/ui';
import { testIds } from '../../../../testIds';
import { ActionButton } from '../../../../components/ActionButton';
export const LINKS_EXTENSION_POINT_ID = 'plugins/grafana-extensionstest-app/use-plugin-links/v1';
export function AddedLinks() {
const { links } = usePluginLinks({ extensionPointId: LINKS_EXTENSION_POINT_ID });
const { extensions } = usePluginExtensions({ extensionPointId: LINKS_EXTENSION_POINT_ID });
return (
<Stack direction={'column'} gap={4}>
<section data-testid={testIds.appC.section1}>
<h3>Link extensions defined with addLink and retrieved using usePluginLinks</h3>
<ActionButton extensions={links} />
</section>
<section data-testid={testIds.appC.section2}>
<h3>Link extensions defined with addLink and retrieved using usePluginExtensions</h3>
<ActionButton extensions={extensions} />
</section>
</Stack>
);
}

View File

@@ -0,0 +1,15 @@
import * as React from 'react';
import { AppRootProps } from '@grafana/data';
import { AddedLinks } from './AddedLinks';
import { testIds } from '../../../../testIds';
export class App extends React.PureComponent<AppRootProps> {
render() {
return (
<div data-testid={testIds.appC.container} className="page-container">
Hello Grafana!
<AddedLinks></AddedLinks>
</div>
);
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 81.9 71.52"><defs><style>.cls-1{fill:#84aff1;}.cls-2{fill:#3865ab;}.cls-3{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" x1="42.95" y1="16.88" x2="81.9" y2="16.88" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M55.46,62.43A2,2,0,0,1,54.07,59l4.72-4.54a2,2,0,0,1,2.2-.39l3.65,1.63,3.68-3.64a2,2,0,1,1,2.81,2.84l-4.64,4.6a2,2,0,0,1-2.22.41L60.6,58.26l-3.76,3.61A2,2,0,0,1,55.46,62.43Z"/><path class="cls-2" d="M37,0H2A2,2,0,0,0,0,2V31.76a2,2,0,0,0,2,2H37a2,2,0,0,0,2-2V2A2,2,0,0,0,37,0ZM4,29.76V8.84H35V29.76Z"/><path class="cls-3" d="M79.9,0H45a2,2,0,0,0-2,2V31.76a2,2,0,0,0,2,2h35a2,2,0,0,0,2-2V2A2,2,0,0,0,79.9,0ZM47,29.76V8.84h31V29.76Z"/><path class="cls-2" d="M37,37.76H2a2,2,0,0,0-2,2V69.52a2,2,0,0,0,2,2H37a2,2,0,0,0,2-2V39.76A2,2,0,0,0,37,37.76ZM4,67.52V46.6H35V67.52Z"/><path class="cls-2" d="M79.9,37.76H45a2,2,0,0,0-2,2V69.52a2,2,0,0,0,2,2h35a2,2,0,0,0,2-2V39.76A2,2,0,0,0,79.9,37.76ZM47,67.52V46.6h31V67.52Z"/><rect class="cls-1" x="10.48" y="56.95" width="4" height="5.79"/><rect class="cls-1" x="17.43" y="53.95" width="4" height="8.79"/><rect class="cls-1" x="24.47" y="50.95" width="4" height="11.79"/><path class="cls-1" d="M19.47,25.8a6.93,6.93,0,1,1,6.93-6.92A6.93,6.93,0,0,1,19.47,25.8Zm0-9.85a2.93,2.93,0,1,0,2.93,2.93A2.93,2.93,0,0,0,19.47,16Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,44 @@
import { AppPlugin } from '@grafana/data';
import { LINKS_EXTENSION_POINT_ID } from '../../pages/AddedLinks';
import { testIds } from '../../testIds';
import { App } from '../../components/App';
export const plugin = new AppPlugin<{}>()
.setRootPage(App)
.configureExtensionLink({
title: 'configureExtensionLink (where meta data is missing)',
description: 'Open a modal from plugin B',
extensionPointId: 'plugins/grafana-extensionstest-app/actions',
onClick: (_, { openModal }) => {
openModal({
title: 'Modal from app B',
body: () => <div data-testid={testIds.appB.modal}>From plugin B</div>,
});
},
})
.configureExtensionComponent({
extensionPointId: 'plugins/grafana-extensionstest-app/configure-extension-component/v1',
title: 'configureExtensionComponent (where meta data is missing)',
description: 'A component that can be reused by other app plugins. Shared using configureExtensionComponent api',
component: ({ name }: { name: string }) => <div data-testid={testIds.appB.reusableComponent}>Hello {name}!</div>,
})
.addComponent<{ name: string }>({
targets: ['plugins/grafana-extensionstest-app/addComponent/v1'],
title: 'Added component (where meta data is missing)',
description: '.',
component: ({ name }: { name: string }) => (
<div data-testid={testIds.appB.reusableAddedComponent}>Hello {name}!</div>
),
})
.addLink({
title: 'Added link (where meta data is missing)',
description: '.',
targets: [LINKS_EXTENSION_POINT_ID],
onClick: (_, { openModal }) => {
openModal({
title: 'Modal from app C',
body: () => <div data-testid={testIds.appB.modal}>From plugin B</div>,
});
},
});

View File

@@ -0,0 +1,35 @@
{
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
"type": "app",
"name": "D App",
"id": "grafana-extensionexample3-app",
"preload": true,
"info": {
"keywords": ["app"],
"description": "Will extend root app with ui extensions",
"author": {
"name": "grafana"
},
"logos": {
"small": "img/logo.svg",
"large": "img/logo.svg"
},
"screenshots": [],
"version": "%VERSION%",
"updated": "%TODAY%"
},
"includes": [
{
"type": "page",
"name": "Default",
"path": "/a/grafana-extensionexample3-app",
"role": "Admin",
"addToNav": false,
"defaultNav": false
}
],
"dependencies": {
"grafanaDependency": ">=10.3.3",
"plugins": []
}
}

View File

@@ -16,6 +16,11 @@ export const testIds = {
reusableAddedComponent: 'b-app-add-component',
exposedComponent: 'b-app-exposed-component',
},
appC: {
container: 'c-app-body',
section1: 'use-plugin-links',
section2: 'use-plugin-extensions',
},
legacyGettersPage: {
container: 'data-testid pg-legacy-getters-container',
section1: 'get-plugin-extensions',

View File

@@ -2,6 +2,7 @@ import { test, expect } from '@grafana/plugin-e2e';
import { testIds } from '../../testIds';
import pluginJson from '../../plugin.json';
import testApp3pluginJson from '../../plugins/grafana-extensionexample3-app/plugin.json';
test.describe('usePluginExtensions + configureExtensionLink', () => {
test('should extend the actions menu with a link to a-app plugin', async ({ page }) => {
@@ -12,6 +13,17 @@ test.describe('usePluginExtensions + configureExtensionLink', () => {
await page.getByTestId(testIds.modal.open).click();
await expect(page.getByTestId(testIds.appA.container)).toBeVisible();
});
test('should not display extensions that have not been declared in plugin.json when in development mode', async ({
page,
}) => {
await page.goto(`/a/${pluginJson.id}/legacy-hooks`);
const section = await page.getByTestId(testIds.legacyHooksPage.section1);
await section.getByTestId(testIds.actions.button).click();
await expect(
page.getByTestId(testIds.container).getByText('configureExtensionLink (where meta data is missing)')
).not.toBeVisible();
});
});
test.describe('usePluginExtensions + configureExtensionComponent', () => {
@@ -43,3 +55,13 @@ test.describe('usePluginComponentExtensions + configureExtensionComponent', () =
).toHaveText('Hello World!');
});
});
test.describe('usePluginExtensions + addLink', () => {
test('should not display extensions in case extension point has not been declared in plugin json (dev mode only)', async ({
page,
}) => {
await page.goto(`/a/${testApp3pluginJson.id}/legacy-hooks`);
const section = await page.getByTestId(testIds.appC.section2);
await expect(section.getByTestId(testIds.actions.button)).not.toBeVisible();
});
});

View File

@@ -1,6 +1,7 @@
import { test, expect } from '@grafana/plugin-e2e';
import pluginJson from '../plugin.json';
import testApp3pluginJson from '../plugins/grafana-extensionexample3-app/plugin.json';
import { testIds } from '../testIds';
test('should extend the actions menu with a link to a-app plugin', async ({ page }) => {
@@ -28,3 +29,11 @@ test('should extend main app with basic link extension from app A', async ({ pag
await page.getByTestId(testIds.modal.open).click();
await expect(page.getByTestId(testIds.appA.container)).toBeVisible();
});
test('should not display any extensions when extension point is not declared in plugin json when in development mode', async ({
page,
}) => {
await page.goto(`/a/${testApp3pluginJson.id}`);
const container = await page.getByTestId(testIds.appC.section1);
await expect(container.getByTestId(testIds.actions.button)).not.toBeVisible();
});