mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Plugin Extensions: Require meta-data to be defined in plugin.json during development mode (#93429)
* feat: add extensions to the backend plugin model * feat: update the frontend plugin types * feat(pluginContext): return a `null` if there is no context found This will be necessary to understand if a certain hook is running inside a plugin context or not. * feat: add utility functions for checking extension configs * tests: fix failing tests due to the type updates * feat(AddedComponentsRegistry): validate plugin meta-info * feat(AddedLinksRegistry): validate meta-info * feat(ExposedComponentsRegistry): validate meta-info * feat(usePluginComponent): add meta-info validation * feat(usePluginComponents): add meta-info validation * feat(usePluginLinks): add meta-info validation * fix: only validate meta-info in registries if dev mode is enabled * tests: add unit tests for the restrictions functionality * tests: fix Go tests * fix(tests): revert accidental changes * fix: run goimports * fix: api tests * add nested app so that meta data can bested e2e tested * refactor(types): extract the ExtensionInfo into a separate type * refactor(extensions/utils): use Array.prototype.some() instead of .find() * refactor(usePluginLinks): update warning message * feat(usePluginExtensions()): validate plugin meta-info * Wip * fix(e2e): E2E tests for extensions * fix(extensions): allow multiple "/" slashes in the extension point id * fix(extensions/validators): stop validating the plugin id pattern --------- Co-authored-by: Erik Sundell <erik.sundell87@gmail.com>
This commit is contained in:
@@ -9,7 +9,7 @@ type ReusableComponentProps = {
|
||||
|
||||
export function AddedComponents() {
|
||||
const { components } = usePluginComponents<ReusableComponentProps>({
|
||||
extensionPointId: 'plugins/grafana-extensionexample2-app/addComponent/v1',
|
||||
extensionPointId: 'plugins/grafana-extensionstest-app/addComponent/v1',
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -16,7 +16,7 @@ type ReusableComponentProps = {
|
||||
|
||||
export function LegacyGetters() {
|
||||
const extensionPointId1 = 'plugins/grafana-extensionstest-app/actions';
|
||||
const extensionPointId2 = 'plugins/grafana-extensionexample2-app/configure-extension-component/v1';
|
||||
const extensionPointId2 = 'plugins/grafana-extensionstest-app/configure-extension-component/v1';
|
||||
const context: AppExtensionContext = {};
|
||||
|
||||
const { extensions } = getPluginExtensions({
|
||||
|
||||
@@ -16,7 +16,7 @@ type ReusableComponentProps = {
|
||||
|
||||
export function LegacyHooks() {
|
||||
const extensionPointId1 = 'plugins/grafana-extensionstest-app/actions';
|
||||
const extensionPointId2 = 'plugins/grafana-extensionexample2-app/configure-extension-component/v1';
|
||||
const extensionPointId2 = 'plugins/grafana-extensionstest-app/configure-extension-component/v1';
|
||||
const context: AppExtensionContext = {};
|
||||
|
||||
const { extensions } = usePluginExtensions({
|
||||
|
||||
@@ -60,8 +60,43 @@
|
||||
"defaultNav": false
|
||||
}
|
||||
],
|
||||
"extensions": {
|
||||
"addedLinks": [
|
||||
{
|
||||
"targets": ["grafana/dashboard/panel/menu"],
|
||||
"title": "Open from time series or pie charts (path)",
|
||||
"description": "This link will only be visible on time series and pie charts"
|
||||
},
|
||||
{
|
||||
"targets": ["grafana/dashboard/panel/menu"],
|
||||
"title": "Open from time series or pie charts (onClick)",
|
||||
"description": "This link will only be visible on time series and pie charts"
|
||||
}
|
||||
],
|
||||
"extensionPoints": [
|
||||
{
|
||||
"id": "plugins/grafana-extensionstest-app/use-plugin-links/v1",
|
||||
"title": "Extension point - links"
|
||||
},
|
||||
{
|
||||
"id": "plugins/grafana-extensionstest-app/addComponent/v1",
|
||||
"title": "Extension point - components"
|
||||
},
|
||||
{
|
||||
"id": "plugins/grafana-extensionstest-app/actions",
|
||||
"title": "Legacy extension point - usePluginExtensions() and usePluginLinkExtensions()"
|
||||
},
|
||||
{
|
||||
"id": "plugins/grafana-extensionstest-app/configure-extension-component/v1",
|
||||
"title": "Legacy extension point - usePluginComponentExtensions()"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=10.4.0",
|
||||
"plugins": []
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": ["grafana-extensionexample1-app/reusable-component/v1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,30 @@
|
||||
"defaultNav": false
|
||||
}
|
||||
],
|
||||
"extensions": {
|
||||
"exposedComponents": [
|
||||
{
|
||||
"id": "grafana-extensionexample1-app/reusable-component/v1",
|
||||
"title": "Exposed component",
|
||||
"description": "A component that can be reused by other app plugins."
|
||||
}
|
||||
],
|
||||
"addedLinks": [
|
||||
{
|
||||
"targets": [
|
||||
"plugins/grafana-extensionstest-app/actions",
|
||||
"plugins/grafana-extensionstest-app/use-plugin-links/v1"
|
||||
],
|
||||
"title": "Go to A",
|
||||
"description": "Navigating to pluging A"
|
||||
},
|
||||
{
|
||||
"targets": ["plugins/grafana-extensionstest-app/use-plugin-links/v1"],
|
||||
"title": "Basic link",
|
||||
"description": "..."
|
||||
}
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=10.3.3",
|
||||
"plugins": []
|
||||
|
||||
@@ -19,13 +19,13 @@ export const plugin = new AppPlugin<{}>()
|
||||
},
|
||||
})
|
||||
.configureExtensionComponent({
|
||||
extensionPointId: 'plugins/grafana-extensionexample2-app/configure-extension-component/v1',
|
||||
extensionPointId: 'plugins/grafana-extensionstest-app/configure-extension-component/v1',
|
||||
title: 'Configure extension component from B',
|
||||
description: 'A component that can be reused by other app plugins. Shared using configureExtensionComponent api',
|
||||
component: ({ name }: { name: string }) => <div data-testid={testIds.appB.reusableComponent}>Hello {name}!</div>,
|
||||
})
|
||||
.addComponent<{ name: string }>({
|
||||
targets: 'plugins/grafana-extensionexample2-app/addComponent/v1',
|
||||
targets: 'plugins/grafana-extensionstest-app/addComponent/v1',
|
||||
title: 'Added component from B',
|
||||
description: 'A component that can be reused by other app plugins. Shared using addComponent api',
|
||||
component: ({ name }: { name: string }) => (
|
||||
|
||||
@@ -18,6 +18,30 @@
|
||||
"version": "%VERSION%",
|
||||
"updated": "%TODAY%"
|
||||
},
|
||||
"extensions": {
|
||||
"addedLinks": [
|
||||
{
|
||||
"targets": [
|
||||
"plugins/grafana-extensionstest-app/actions",
|
||||
"plugins/grafana-extensionstest-app/use-plugin-links/v1"
|
||||
],
|
||||
"title": "Open from B",
|
||||
"description": "Open a modal from plugin B"
|
||||
}
|
||||
],
|
||||
"addedComponents": [
|
||||
{
|
||||
"targets": ["plugins/grafana-extensionstest-app/configure-extension-component/v1"],
|
||||
"title": "Configure extension component from B",
|
||||
"description": "A component that can be reused by other app plugins. Shared using configureExtensionComponent api"
|
||||
},
|
||||
{
|
||||
"targets": ["plugins/grafana-extensionstest-app/addComponent/v1"],
|
||||
"title": "Added component from B",
|
||||
"description": "A component that can be reused by other app plugins. Shared using addComponent api"
|
||||
}
|
||||
]
|
||||
},
|
||||
"includes": [
|
||||
{
|
||||
"type": "page",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { usePluginExtensions, usePluginLinks } from '@grafana/runtime';
|
||||
import { Stack } from '@grafana/ui';
|
||||
import { testIds } from '../../../../testIds';
|
||||
import { ActionButton } from '../../../../components/ActionButton';
|
||||
|
||||
export const LINKS_EXTENSION_POINT_ID = 'plugins/grafana-extensionstest-app/use-plugin-links/v1';
|
||||
|
||||
export function AddedLinks() {
|
||||
const { links } = usePluginLinks({ extensionPointId: LINKS_EXTENSION_POINT_ID });
|
||||
const { extensions } = usePluginExtensions({ extensionPointId: LINKS_EXTENSION_POINT_ID });
|
||||
|
||||
return (
|
||||
<Stack direction={'column'} gap={4}>
|
||||
<section data-testid={testIds.appC.section1}>
|
||||
<h3>Link extensions defined with addLink and retrieved using usePluginLinks</h3>
|
||||
<ActionButton extensions={links} />
|
||||
</section>
|
||||
<section data-testid={testIds.appC.section2}>
|
||||
<h3>Link extensions defined with addLink and retrieved using usePluginExtensions</h3>
|
||||
<ActionButton extensions={extensions} />
|
||||
</section>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { AppRootProps } from '@grafana/data';
|
||||
import { AddedLinks } from './AddedLinks';
|
||||
import { testIds } from '../../../../testIds';
|
||||
|
||||
export class App extends React.PureComponent<AppRootProps> {
|
||||
render() {
|
||||
return (
|
||||
<div data-testid={testIds.appC.container} className="page-container">
|
||||
Hello Grafana!
|
||||
<AddedLinks></AddedLinks>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 81.9 71.52"><defs><style>.cls-1{fill:#84aff1;}.cls-2{fill:#3865ab;}.cls-3{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" x1="42.95" y1="16.88" x2="81.9" y2="16.88" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M55.46,62.43A2,2,0,0,1,54.07,59l4.72-4.54a2,2,0,0,1,2.2-.39l3.65,1.63,3.68-3.64a2,2,0,1,1,2.81,2.84l-4.64,4.6a2,2,0,0,1-2.22.41L60.6,58.26l-3.76,3.61A2,2,0,0,1,55.46,62.43Z"/><path class="cls-2" d="M37,0H2A2,2,0,0,0,0,2V31.76a2,2,0,0,0,2,2H37a2,2,0,0,0,2-2V2A2,2,0,0,0,37,0ZM4,29.76V8.84H35V29.76Z"/><path class="cls-3" d="M79.9,0H45a2,2,0,0,0-2,2V31.76a2,2,0,0,0,2,2h35a2,2,0,0,0,2-2V2A2,2,0,0,0,79.9,0ZM47,29.76V8.84h31V29.76Z"/><path class="cls-2" d="M37,37.76H2a2,2,0,0,0-2,2V69.52a2,2,0,0,0,2,2H37a2,2,0,0,0,2-2V39.76A2,2,0,0,0,37,37.76ZM4,67.52V46.6H35V67.52Z"/><path class="cls-2" d="M79.9,37.76H45a2,2,0,0,0-2,2V69.52a2,2,0,0,0,2,2h35a2,2,0,0,0,2-2V39.76A2,2,0,0,0,79.9,37.76ZM47,67.52V46.6h31V67.52Z"/><rect class="cls-1" x="10.48" y="56.95" width="4" height="5.79"/><rect class="cls-1" x="17.43" y="53.95" width="4" height="8.79"/><rect class="cls-1" x="24.47" y="50.95" width="4" height="11.79"/><path class="cls-1" d="M19.47,25.8a6.93,6.93,0,1,1,6.93-6.92A6.93,6.93,0,0,1,19.47,25.8Zm0-9.85a2.93,2.93,0,1,0,2.93,2.93A2.93,2.93,0,0,0,19.47,16Z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,44 @@
|
||||
import { AppPlugin } from '@grafana/data';
|
||||
|
||||
import { LINKS_EXTENSION_POINT_ID } from '../../pages/AddedLinks';
|
||||
import { testIds } from '../../testIds';
|
||||
import { App } from '../../components/App';
|
||||
|
||||
export const plugin = new AppPlugin<{}>()
|
||||
.setRootPage(App)
|
||||
.configureExtensionLink({
|
||||
title: 'configureExtensionLink (where meta data is missing)',
|
||||
description: 'Open a modal from plugin B',
|
||||
extensionPointId: 'plugins/grafana-extensionstest-app/actions',
|
||||
onClick: (_, { openModal }) => {
|
||||
openModal({
|
||||
title: 'Modal from app B',
|
||||
body: () => <div data-testid={testIds.appB.modal}>From plugin B</div>,
|
||||
});
|
||||
},
|
||||
})
|
||||
.configureExtensionComponent({
|
||||
extensionPointId: 'plugins/grafana-extensionstest-app/configure-extension-component/v1',
|
||||
title: 'configureExtensionComponent (where meta data is missing)',
|
||||
description: 'A component that can be reused by other app plugins. Shared using configureExtensionComponent api',
|
||||
component: ({ name }: { name: string }) => <div data-testid={testIds.appB.reusableComponent}>Hello {name}!</div>,
|
||||
})
|
||||
.addComponent<{ name: string }>({
|
||||
targets: ['plugins/grafana-extensionstest-app/addComponent/v1'],
|
||||
title: 'Added component (where meta data is missing)',
|
||||
description: '.',
|
||||
component: ({ name }: { name: string }) => (
|
||||
<div data-testid={testIds.appB.reusableAddedComponent}>Hello {name}!</div>
|
||||
),
|
||||
})
|
||||
.addLink({
|
||||
title: 'Added link (where meta data is missing)',
|
||||
description: '.',
|
||||
targets: [LINKS_EXTENSION_POINT_ID],
|
||||
onClick: (_, { openModal }) => {
|
||||
openModal({
|
||||
title: 'Modal from app C',
|
||||
body: () => <div data-testid={testIds.appB.modal}>From plugin B</div>,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
|
||||
"type": "app",
|
||||
"name": "D App",
|
||||
"id": "grafana-extensionexample3-app",
|
||||
"preload": true,
|
||||
"info": {
|
||||
"keywords": ["app"],
|
||||
"description": "Will extend root app with ui extensions",
|
||||
"author": {
|
||||
"name": "grafana"
|
||||
},
|
||||
"logos": {
|
||||
"small": "img/logo.svg",
|
||||
"large": "img/logo.svg"
|
||||
},
|
||||
"screenshots": [],
|
||||
"version": "%VERSION%",
|
||||
"updated": "%TODAY%"
|
||||
},
|
||||
"includes": [
|
||||
{
|
||||
"type": "page",
|
||||
"name": "Default",
|
||||
"path": "/a/grafana-extensionexample3-app",
|
||||
"role": "Admin",
|
||||
"addToNav": false,
|
||||
"defaultNav": false
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=10.3.3",
|
||||
"plugins": []
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,11 @@ export const testIds = {
|
||||
reusableAddedComponent: 'b-app-add-component',
|
||||
exposedComponent: 'b-app-exposed-component',
|
||||
},
|
||||
appC: {
|
||||
container: 'c-app-body',
|
||||
section1: 'use-plugin-links',
|
||||
section2: 'use-plugin-extensions',
|
||||
},
|
||||
legacyGettersPage: {
|
||||
container: 'data-testid pg-legacy-getters-container',
|
||||
section1: 'get-plugin-extensions',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
import { testIds } from '../../testIds';
|
||||
import pluginJson from '../../plugin.json';
|
||||
import testApp3pluginJson from '../../plugins/grafana-extensionexample3-app/plugin.json';
|
||||
|
||||
test.describe('usePluginExtensions + configureExtensionLink', () => {
|
||||
test('should extend the actions menu with a link to a-app plugin', async ({ page }) => {
|
||||
@@ -12,6 +13,17 @@ test.describe('usePluginExtensions + configureExtensionLink', () => {
|
||||
await page.getByTestId(testIds.modal.open).click();
|
||||
await expect(page.getByTestId(testIds.appA.container)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not display extensions that have not been declared in plugin.json when in development mode', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/a/${pluginJson.id}/legacy-hooks`);
|
||||
const section = await page.getByTestId(testIds.legacyHooksPage.section1);
|
||||
await section.getByTestId(testIds.actions.button).click();
|
||||
await expect(
|
||||
page.getByTestId(testIds.container).getByText('configureExtensionLink (where meta data is missing)')
|
||||
).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('usePluginExtensions + configureExtensionComponent', () => {
|
||||
@@ -43,3 +55,13 @@ test.describe('usePluginComponentExtensions + configureExtensionComponent', () =
|
||||
).toHaveText('Hello World!');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('usePluginExtensions + addLink', () => {
|
||||
test('should not display extensions in case extension point has not been declared in plugin json (dev mode only)', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/a/${testApp3pluginJson.id}/legacy-hooks`);
|
||||
const section = await page.getByTestId(testIds.appC.section2);
|
||||
await expect(section.getByTestId(testIds.actions.button)).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
import pluginJson from '../plugin.json';
|
||||
import testApp3pluginJson from '../plugins/grafana-extensionexample3-app/plugin.json';
|
||||
import { testIds } from '../testIds';
|
||||
|
||||
test('should extend the actions menu with a link to a-app plugin', async ({ page }) => {
|
||||
@@ -28,3 +29,11 @@ test('should extend main app with basic link extension from app A', async ({ pag
|
||||
await page.getByTestId(testIds.modal.open).click();
|
||||
await expect(page.getByTestId(testIds.appA.container)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not display any extensions when extension point is not declared in plugin json when in development mode', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/a/${testApp3pluginJson.id}`);
|
||||
const container = await page.getByTestId(testIds.appC.section1);
|
||||
await expect(container.getByTestId(testIds.actions.button)).not.toBeVisible();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user