E2E: Add support for building test plugins (#91873)

* build test apps with webpack

* add extensions test app

* update e2e tests

* remove non-build test apps using amd

* use @grafana/plugin-configs rather than create-plugin config

* Update e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/extensions/usePluginComponents.spec.ts

Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com>

* Update package.json

Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com>

* use run dir variable instead of hardcoded path

* add dummy licence file

* add separate step for building test plugins

* support nested plugins

* remove react-router-dom from the externals array

* remove add_mode dev

* lint starlark

* pass license path as env variable

* fix the path

* chore(e2e-plugins): clean up dependencies to match core versions

* refactor(e2e-plugins): prefer extending webpack plugins-config

* docs(e2e-plugins): add basic info to extensions test plugin readme

* update readme

* change dir name from custom plugins to test plugins

* change root readme

* update lockfile

---------

Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com>
This commit is contained in:
Erik Sundell 2024-08-23 09:00:03 +02:00 committed by GitHub
parent 0af4a20b58
commit d8ec95e9b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
78 changed files with 1143 additions and 722 deletions

View File

@ -10,6 +10,7 @@ const eslintPathsToIgnore = [
'public/app/angular', // will be removed in Grafana 11
'public/app/plugins/panel/graph', // will be removed alongside angular
'public/app/plugins/panel/table-old', // will be removed alongside angular
'e2e/test-plugins',
];
// Avoid using functions that report the position of the issues, as this causes a lot of merge conflicts

View File

@ -628,6 +628,14 @@ steps:
volumes:
- name: docker
path: /var/run/docker.sock
- commands:
- yarn e2e:plugin:build
depends_on:
- yarn-install
environment:
NODE_OPTIONS: --max_old_space_size=8192
image: node:20.9.0-alpine
name: build-test-plugins
- commands:
- apk add --update tar bash
- mkdir grafana
@ -646,6 +654,7 @@ steps:
- ./bin/build e2e-tests --port 3001 --suite dashboards-suite
depends_on:
- grafana-server
- build-test-plugins
environment:
HOST: grafana-server
image: cypress/included:13.10.0
@ -654,6 +663,7 @@ steps:
- ./bin/build e2e-tests --port 3001 --suite scenes/dashboards-suite
depends_on:
- grafana-server
- build-test-plugins
environment:
HOST: grafana-server
image: cypress/included:13.10.0
@ -662,6 +672,7 @@ steps:
- ./bin/build e2e-tests --port 3001 --suite smoke-tests-suite
depends_on:
- grafana-server
- build-test-plugins
environment:
HOST: grafana-server
image: cypress/included:13.10.0
@ -670,6 +681,7 @@ steps:
- ./bin/build e2e-tests --port 3001 --suite scenes/smoke-tests-suite
depends_on:
- grafana-server
- build-test-plugins
environment:
HOST: grafana-server
image: cypress/included:13.10.0
@ -678,6 +690,7 @@ steps:
- ./bin/build e2e-tests --port 3001 --suite panels-suite
depends_on:
- grafana-server
- build-test-plugins
environment:
HOST: grafana-server
image: cypress/included:13.10.0
@ -686,6 +699,7 @@ steps:
- ./bin/build e2e-tests --port 3001 --suite scenes/panels-suite
depends_on:
- grafana-server
- build-test-plugins
environment:
HOST: grafana-server
image: cypress/included:13.10.0
@ -694,6 +708,7 @@ steps:
- ./bin/build e2e-tests --port 3001 --suite various-suite
depends_on:
- grafana-server
- build-test-plugins
environment:
HOST: grafana-server
image: cypress/included:13.10.0
@ -702,6 +717,7 @@ steps:
- ./bin/build e2e-tests --port 3001 --suite scenes/various-suite
depends_on:
- grafana-server
- build-test-plugins
environment:
HOST: grafana-server
image: cypress/included:13.10.0
@ -738,6 +754,7 @@ steps:
- yarn e2e:playwright
depends_on:
- grafana-server
- build-test-plugins
environment:
HOST: grafana-server
PORT: "3001"
@ -2035,6 +2052,14 @@ steps:
volumes:
- name: docker
path: /var/run/docker.sock
- commands:
- yarn e2e:plugin:build
depends_on:
- yarn-install
environment:
NODE_OPTIONS: --max_old_space_size=8192
image: node:20.9.0-alpine
name: build-test-plugins
- commands:
- apk add --update tar bash
- mkdir grafana
@ -2053,6 +2078,7 @@ steps:
- ./bin/build e2e-tests --port 3001 --suite dashboards-suite
depends_on:
- grafana-server
- build-test-plugins
environment:
HOST: grafana-server
image: cypress/included:13.10.0
@ -2061,6 +2087,7 @@ steps:
- ./bin/build e2e-tests --port 3001 --suite scenes/dashboards-suite
depends_on:
- grafana-server
- build-test-plugins
environment:
HOST: grafana-server
image: cypress/included:13.10.0
@ -2069,6 +2096,7 @@ steps:
- ./bin/build e2e-tests --port 3001 --suite smoke-tests-suite
depends_on:
- grafana-server
- build-test-plugins
environment:
HOST: grafana-server
image: cypress/included:13.10.0
@ -2077,6 +2105,7 @@ steps:
- ./bin/build e2e-tests --port 3001 --suite scenes/smoke-tests-suite
depends_on:
- grafana-server
- build-test-plugins
environment:
HOST: grafana-server
image: cypress/included:13.10.0
@ -2085,6 +2114,7 @@ steps:
- ./bin/build e2e-tests --port 3001 --suite panels-suite
depends_on:
- grafana-server
- build-test-plugins
environment:
HOST: grafana-server
image: cypress/included:13.10.0
@ -2093,6 +2123,7 @@ steps:
- ./bin/build e2e-tests --port 3001 --suite scenes/panels-suite
depends_on:
- grafana-server
- build-test-plugins
environment:
HOST: grafana-server
image: cypress/included:13.10.0
@ -2101,6 +2132,7 @@ steps:
- ./bin/build e2e-tests --port 3001 --suite various-suite
depends_on:
- grafana-server
- build-test-plugins
environment:
HOST: grafana-server
image: cypress/included:13.10.0
@ -2109,6 +2141,7 @@ steps:
- ./bin/build e2e-tests --port 3001 --suite scenes/various-suite
depends_on:
- grafana-server
- build-test-plugins
environment:
HOST: grafana-server
image: cypress/included:13.10.0
@ -2145,6 +2178,7 @@ steps:
- yarn e2e:playwright
depends_on:
- grafana-server
- build-test-plugins
environment:
HOST: grafana-server
PORT: "3001"
@ -6074,6 +6108,6 @@ kind: secret
name: gcr_credentials
---
kind: signature
hmac: cd0bc27b34a09de191974f360d43b55324bd88d20c4fe92f7c41df56394fc25a
hmac: 7c752913b444e0efe410d5a8a0d300e1b4d48d2cac8df602c35314bc62b7ac3c
...

View File

@ -13,7 +13,7 @@ node_modules
/public/lib/monaco
/scripts/grafana-server/tmp
vendor
e2e/custom-plugins
e2e/test-plugins
playwright-report
# TS generate from cue by cuetsy

View File

@ -1,27 +1,19 @@
apiVersion: 1
apps:
- type: myorg-extensions-app
- type: grafana-extensionstest-app
org_id: 1
org_name: Main Org.
disabled: false
- type: myorg-a-app
jsonData:
apiUrl: http://default-url.com
secureJsonData:
apiKey: secret-key
- type: grafana-extensionexample1-app
org_id: 1
org_name: Main Org.
disabled: false
- type: myorg-b-app
org_id: 1
org_name: Main Org.
disabled: false
- type: myorg-extensionpoint-app
org_id: 1
org_name: Main Org.
disabled: false
- type: myorg-componentconsumer-app
org_id: 1
org_name: Main Org.
disabled: false
- type: myorg-componentexposer-app
- type: grafana-extensionexample2-app
org_id: 1
org_name: Main Org.
disabled: false

View File

@ -1,5 +0,0 @@
# Custom plugins
Plugins in this directory will be installed when the e2e [test server](https://github.com/grafana/grafana/blob/main/scripts/grafana-server/start-server) is started. Optionally, you can provision the plugin by adding configuration to the [datasources.yaml](https://github.com/grafana/grafana/blob/extensions/add-e2e-tests/devenv/datasources.yaml) or to the [plugins.yaml](https://github.com/grafana/grafana/blob/extensions/add-e2e-tests/devenv/plugins.yaml).
These plugins are not being built as part of CI. Plugins in this directory are being version controlled, so make sure the bundle size is small. Only use dependencies provided by the runtime (see list of runtime dependencies [here](https://github.com/grafana/plugin-tools/blob/08b67179bdbf8847788c54aadb22654aa1a7c060/packages/create-plugin/templates/common/.config/webpack/webpack.config.ts#L36)).

View File

@ -1,22 +0,0 @@
# App with exposed components
This directory contains two apps - `myorg-componentconsumer-app` and `myorg-componentexposer-app` which is nested inside `myorg-componentconsumer-app`.
`myorg-componentconsumer-app` exposes a simple React component using the [`exposeComponent`](https://grafana.com/developers/plugin-tools/reference/ui-extensions#exposecomponent) api. `myorg-componentconsumer-app` in turn, consumes this compoment using the [`https://grafana.com/developers/plugin-tools/reference/ui-extensions#useplugincomponent`](https://grafana.com/developers/plugin-tools/reference/ui-extensions#useplugincomponent) hook.
To test this app:
```sh
# start e2e test instance (it will install this plugin)
PORT=3000 ./scripts/grafana-server/start-server
# run Playwright tests using Playwright VSCode extension or with the following script
yarn e2e:playwright
```
or
```
PORT=3000 ./scripts/grafana-server/start-server
yarn start
yarn e2e
```

View File

@ -1,28 +0,0 @@
define(['@grafana/data', '@grafana/runtime', 'react'], function (grafanaData, grafanaRuntime, React) {
var AppPlugin = grafanaData.AppPlugin;
var usePluginComponent = grafanaRuntime.usePluginComponent;
var MyComponent = function () {
var plugin = usePluginComponent('myorg-componentexposer-app/reusable-component/v1');
var TestComponent = plugin.component;
var isLoading = plugin.isLoading;
if (!TestComponent) {
return null;
}
return React.createElement(
React.Fragment,
null,
React.createElement('div', null, 'Exposed component:'),
isLoading ? 'Loading..' : React.createElement(TestComponent, { name: 'World' })
);
};
var App = function () {
return React.createElement('div', null, 'Hello Grafana!', React.createElement(MyComponent, null));
};
var plugin = new AppPlugin().setRootPage(App);
return { plugin: plugin };
});

View File

@ -1,35 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
"type": "app",
"name": "Extensions exposed component App",
"id": "myorg-componentconsumer-app",
"preload": true,
"info": {
"keywords": ["app"],
"description": "Example on how to extend grafana ui from a plugin",
"author": {
"name": "Myorg"
},
"logos": {
"small": "img/logo.svg",
"large": "img/logo.svg"
},
"screenshots": [],
"version": "1.0.0",
"updated": "2024-08-09"
},
"includes": [
{
"type": "page",
"name": "Default",
"path": "/a/myorg-componentconsumer-app",
"role": "Admin",
"addToNav": true,
"defaultNav": true
}
],
"dependencies": {
"grafanaDependency": ">=10.3.3",
"plugins": []
}
}

View File

@ -1,14 +0,0 @@
define(['@grafana/data', 'module', 'react'], function (grafanaData, amdModule, React) {
const plugin = new grafanaData.AppPlugin().exposeComponent({
id: 'myorg-componentexposer-app/reusable-component/v1',
title: 'Reusable component',
description: 'A component that can be reused by other app plugins.',
component: function ({ name }) {
return React.createElement('div', { 'data-testid': 'exposed-component' }, 'Hello ', name, '!');
},
});
return {
plugin: plugin,
};
});

View File

@ -1,12 +0,0 @@
# App with extension point
This app was initially copied from the [app-with-extension-point](https://github.com/grafana/grafana-plugin-examples/tree/main/examples/app-with-extension-point) example plugin. The plugin bundle is using AMD, but it's not minified and the plugin feature set is small so it should be possible to make changes in this file if necessary.
To test this app:
```sh
# start e2e test instance (it will install this plugin)
PORT=3000 ./scripts/grafana-server/start-server
# run Playwright tests using Playwright VSCode extension or with the following script
yarn e2e:playwright
```

View File

@ -1,141 +0,0 @@
define(['@grafana/data', 'react', '@grafana/ui', '@grafana/runtime'], function (data, React, UI, runtime) {
'use strict';
const styles = {
container: 'main-app-body',
actions: { button: 'action-button' },
modal: { container: 'container', open: 'open-link' },
appA: { container: 'a-app-body' },
appB: { modal: 'b-app-modal' },
};
function ModalComponent({ onDismiss, title, path }) {
return React.createElement(
UI.Modal,
{ 'data-testid': styles.modal.container, title, isOpen: true, onDismiss },
React.createElement(
UI.VerticalGroup,
{ spacing: 'sm' },
React.createElement('p', null, 'Do you want to proceed in the current tab or open a new tab?')
),
React.createElement(
UI.Modal.ButtonRow,
null,
React.createElement(UI.Button, { onClick: onDismiss, fill: 'outline', variant: 'secondary' }, 'Cancel'),
React.createElement(
UI.Button,
{
type: 'submit',
variant: 'secondary',
onClick: function () {
window.open(data.locationUtil.assureBaseUrl(path), '_blank');
onDismiss();
},
icon: 'external-link-alt',
},
'Open in new tab'
),
React.createElement(
UI.Button,
{
'data-testid': styles.modal.open,
type: 'submit',
variant: 'primary',
onClick: function () {
runtime.locationService.push(path);
},
icon: 'apps',
},
'Open'
)
)
);
}
function ActionComponent({ extensions }) {
const options = React.useMemo(
function () {
return extensions.reduce(function (acc, extension) {
if (runtime.isPluginExtensionLink(extension)) {
acc.push({ label: extension.title, title: extension.title, value: extension });
}
return acc;
}, []);
},
[extensions]
);
const [selected, setSelected] = React.useState();
return options.length === 0
? React.createElement(UI.Button, null, 'Run default action')
: React.createElement(
React.Fragment,
null,
React.createElement(
UI.ButtonGroup,
null,
React.createElement(
UI.ToolbarButton,
{
key: 'default-action',
variant: 'canvas',
onClick: function () {
alert('You triggered the default action');
},
},
'Run default action'
),
React.createElement(UI.ButtonSelect, {
'data-testid': styles.actions.button,
key: 'select-extension',
variant: 'canvas',
options: options,
onChange: function (e) {
const extension = e.value;
if (runtime.isPluginExtensionLink(extension)) {
if (extension.path) setSelected(extension);
if (extension.onClick) extension.onClick();
}
},
})
),
selected &&
selected.path &&
React.createElement(ModalComponent, {
title: selected.title,
path: selected.path,
onDismiss: function () {
setSelected(undefined);
},
})
);
}
class RootComponent extends React.PureComponent {
render() {
const { extensions } = runtime.getPluginExtensions({
extensionPointId: 'plugins/myorg-extensionpoint-app/actions',
context: {},
});
return React.createElement(
'div',
{ 'data-testid': styles.container, style: { marginTop: '5%' } },
React.createElement(
UI.HorizontalGroup,
{ align: 'flex-start', justify: 'center' },
React.createElement(
UI.HorizontalGroup,
null,
React.createElement('span', null, 'Hello Grafana! These are the actions you can trigger from this plugin'),
React.createElement(ActionComponent, { extensions: extensions })
)
)
);
}
}
const plugin = new data.AppPlugin().setRootPage(RootComponent);
return { plugin: plugin };
});

View File

@ -1,36 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
"type": "app",
"name": "Extension Point App",
"id": "myorg-extensionpoint-app",
"preload": true,
"info": {
"keywords": ["app"],
"description": "Show case how to add an extension point to your plugin",
"author": {
"name": "Myorg"
},
"logos": {
"small": "img/logo.svg",
"large": "img/logo.svg"
},
"screenshots": [],
"version": "1.0.0",
"updated": "2024-06-11"
},
"includes": [
{
"type": "page",
"name": "Default",
"path": "/a/myorg-extensionpoint-app",
"role": "Admin",
"addToNav": true,
"defaultNav": true
}
],
"dependencies": {
"grafanaDependency": ">=10.3.3",
"plugins": []
},
"extensions": []
}

View File

@ -1,26 +0,0 @@
define(['@grafana/data', 'react'], function (data, React) {
'use strict';
const styles = {
container: 'a-app-body',
};
class RootComponent extends React.PureComponent {
render() {
return React.createElement(
'div',
{ 'data-testid': styles.container, className: 'page-container' },
'Hello Grafana!'
);
}
}
const plugin = new data.AppPlugin().setRootPage(RootComponent).configureExtensionLink({
title: 'Go to A',
description: 'Navigating to plugin A',
extensionPointId: 'plugins/myorg-extensionpoint-app/actions',
path: '/a/myorg-a-app/',
});
return { plugin: plugin };
});

View File

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

View File

@ -1,27 +0,0 @@
define(['react', '@grafana/data'], function (React, data) {
'use strict';
class RootComponent extends React.PureComponent {
render() {
return React.createElement('div', { className: 'page-container' }, 'Hello Grafana!');
}
}
const modalId = 'b-app-modal';
const plugin = new data.AppPlugin().setRootPage(RootComponent).configureExtensionLink({
title: 'Open from B',
description: 'Open a modal from plugin B',
extensionPointId: 'plugins/myorg-extensionpoint-app/actions',
onClick: function (e, { openModal }) {
openModal({
title: 'Modal from app B',
body: function () {
return React.createElement('div', { 'data-testid': modalId }, 'From plugin B');
},
});
},
});
return { plugin: plugin };
});

View File

@ -1,12 +0,0 @@
# App with extensions
This app was initially copied from the [app-with-extensions](https://github.com/grafana/grafana-plugin-examples/tree/main/examples/app-with-extensions) example plugin. The plugin bundle is using AMD, but it's not minified and the plugin feature set is small so it should be possible to make changes in this file if necessary.
To test this app:
```sh
# start e2e test instance (it will install this plugin)
PORT=3000 ./scripts/grafana-server/start-server
# run Playwright tests using Playwright VSCode extension or with the following script
yarn e2e:playwright
```

View File

@ -1,216 +0,0 @@
define(['react', '@grafana/data', '@grafana/ui', '@grafana/runtime', '@emotion/css', 'rxjs'], function (
React,
data,
ui,
runtime,
css,
rxjs
) {
'use strict';
const styles = {
modalBody: 'ape-modal-body',
mainPageContainer: 'ape-main-page-container',
};
class RootComponent extends React.PureComponent {
render() {
return React.createElement(
'div',
{ 'data-testid': styles.mainPageContainer, className: 'page-container' },
'Hello Grafana!'
);
}
}
const asyncWrapper = (fn) => {
return function () {
const gen = fn.apply(this, arguments);
return new Promise((resolve, reject) => {
function step(key, arg) {
let info, value;
try {
info = gen[key](arg);
value = info.value;
} catch (error) {
reject(error);
return;
}
if (info.done) {
resolve(value);
} else {
Promise.resolve(value).then(next, throw_);
}
}
function next(value) {
step('next', value);
}
function throw_(value) {
step('throw', value);
}
next();
});
};
};
const getStyles = (theme) => ({
colorWeak: css.css`color: ${theme.colors.text.secondary};`,
marginTop: css.css`margin-top: ${theme.spacing(3)};`,
});
const updatePlugin = asyncWrapper(function* (pluginId, settings) {
const response = runtime
.getBackendSrv()
.fetch({ url: `/api/plugins/${pluginId}/settings`, method: 'POST', data: settings });
return rxjs.lastValueFrom(response);
});
const handleUpdate = asyncWrapper(function* (pluginId, settings) {
try {
yield updatePlugin(pluginId, settings);
window.location.reload();
} catch (error) {
console.error('Error while updating the plugin', error);
}
});
const configPageBody = ({ plugin }) => {
const styles = getStyles(ui.useStyles2());
const { enabled, jsonData } = plugin.meta;
return React.createElement(
'div',
null,
React.createElement(ui.Legend, null, 'Enable / Disable '),
!enabled &&
React.createElement(
React.Fragment,
null,
React.createElement('div', { className: styles.colorWeak }, 'The plugin is currently not enabled.'),
React.createElement(
ui.Button,
{
className: styles.marginTop,
variant: 'primary',
onClick: () => handleUpdate(plugin.meta.id, { enabled: true, pinned: true, jsonData: jsonData }),
},
'Enable plugin'
)
),
enabled &&
React.createElement(
React.Fragment,
null,
React.createElement('div', { className: styles.colorWeak }, 'The plugin is currently enabled.'),
React.createElement(
ui.Button,
{
className: styles.marginTop,
variant: 'destructive',
onClick: () => handleUpdate(plugin.meta.id, { enabled: false, pinned: false, jsonData: jsonData }),
},
'Disable plugin'
)
)
);
};
const selectQueryModal = ({ targets = [], onDismiss }) => {
const [selectedQuery, setSelectedQuery] = React.useState(targets[0]);
return React.createElement(
'div',
{ 'data-testid': styles.modalBody },
React.createElement(
'p',
null,
'Please select the query you would like to use to create "something" in the plugin.'
),
React.createElement(
ui.HorizontalGroup,
null,
targets.map((query) =>
React.createElement(ui.FilterPill, {
key: query.refId,
label: query.refId,
selected: query.refId === (selectedQuery ? selectedQuery.refId : null),
onClick: () => setSelectedQuery(query),
})
)
),
React.createElement(
ui.Modal.ButtonRow,
null,
React.createElement(ui.Button, { variant: 'secondary', fill: 'outline', onClick: onDismiss }, 'Cancel'),
React.createElement(
ui.Button,
{
disabled: !Boolean(selectedQuery),
onClick: () => {
onDismiss && onDismiss();
alert(`You selected query "${selectedQuery.refId}"`);
},
},
'OK'
)
)
);
};
const plugin = new data.AppPlugin()
.setRootPage(RootComponent)
.addConfigPage({
title: 'Configuration',
icon: 'cog',
body: configPageBody,
id: 'configuration',
})
.configureExtensionLink({
title: 'Open from time series or pie charts (path)',
description: 'This link will only be visible on time series and pie charts',
extensionPointId: data.PluginExtensionPoints.DashboardPanelMenu,
path: `/a/myorg-extensions-app/`,
configure: (context) => {
if (context.dashboard?.title === 'Link Extensions (path)') {
switch (context.pluginId) {
case 'timeseries':
return {};
case 'piechart':
return { title: `Open from ${context.pluginId}` };
default:
return;
}
}
},
})
.configureExtensionLink({
title: 'Open from time series or pie charts (onClick)',
description: 'This link will only be visible on time series and pie charts',
extensionPointId: data.PluginExtensionPoints.DashboardPanelMenu,
onClick: (_, { context, openModal }) => {
const targets = context?.targets || [];
const title = context?.title;
if (!targets.length) return;
if (targets.length > 1) {
openModal({
title: `Select query from "${title}"`,
body: (props) => React.createElement(selectQueryModal, { ...props, targets: targets }),
});
} else {
alert(`You selected query "${targets[0].refId}"`);
}
},
configure: (context) => {
if (context.dashboard?.title === 'Link Extensions (onClick)') {
switch (context.pluginId) {
case 'timeseries':
return {};
case 'piechart':
return { title: `Open from ${context.pluginId}` };
default:
return;
}
}
},
});
return { plugin: plugin };
});

View File

@ -1,49 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
"type": "app",
"name": "Extensions App",
"id": "myorg-extensions-app",
"preload": true,
"info": {
"keywords": ["app"],
"description": "Example on how to extend grafana ui from a plugin",
"author": {
"name": "Myorg"
},
"logos": {
"small": "img/logo.svg",
"large": "img/logo.svg"
},
"screenshots": [],
"version": "1.0.0",
"updated": "2024-06-11"
},
"includes": [
{
"type": "page",
"name": "Default",
"path": "/a/myorg-extensions-app",
"role": "Admin",
"addToNav": true,
"defaultNav": true
}
],
"dependencies": {
"grafanaDependency": ">=10.3.3",
"plugins": []
},
"extensions": [
{
"extensionPointId": "grafana/dashboard/panel/menu",
"type": "link",
"title": "Open from time series or pie charts (path)",
"description": "This link will only be visible on time series and pie charts"
},
{
"extensionPointId": "grafana/dashboard/panel/menu",
"type": "link",
"title": "Open from time series or pie charts (onClick)",
"description": "This link will only be visible on time series and pie charts"
}
]
}

View File

@ -1,5 +1,7 @@
import { test, expect } from '@grafana/plugin-e2e';
import { ensureExtensionRegistryIsPopulated } from './utils';
const testIds = {
container: 'main-app-body',
actions: {
@ -14,13 +16,18 @@ const testIds = {
},
appB: {
modal: 'b-app-modal',
reusableComponent: 'b-app-configure-extension-component',
},
legacyAPIPage: {
container: 'data-testid pg-two-container',
},
};
const pluginId = 'myorg-extensionpoint-app';
const pluginId = 'grafana-extensionstest-app';
test('should extend the actions menu with a link to a-app plugin', async ({ page }) => {
await page.goto(`/a/${pluginId}/one`);
await page.goto(`/a/${pluginId}/legacy-apis`);
await ensureExtensionRegistryIsPopulated(page);
await page.getByTestId(testIds.actions.button).click();
await page.getByTestId(testIds.container).getByText('Go to A').click();
await page.getByTestId(testIds.modal.open).click();
@ -28,7 +35,16 @@ test('should extend the actions menu with a link to a-app plugin', async ({ page
});
test('should extend the actions menu with a command triggered from b-app plugin', async ({ page }) => {
await page.goto(`/a/${pluginId}/one`);
await page.goto(`/a/${pluginId}/legacy-apis`);
await ensureExtensionRegistryIsPopulated(page);
await expect(
page.getByTestId(testIds.legacyAPIPage.container).getByTestId(testIds.appB.reusableComponent)
).toHaveText('Hello World!');
});
test('should extend main app with component extension from app B', async ({ page }) => {
await page.goto(`/a/${pluginId}/legacy-apis`);
await ensureExtensionRegistryIsPopulated(page);
await page.getByTestId(testIds.actions.button).click();
await page.getByTestId(testIds.container).getByText('Open from B').click();
await expect(page.getByTestId(testIds.appB.modal)).toBeVisible();

View File

@ -1,5 +1,8 @@
import { selectors } from '@grafana/e2e-selectors';
import { expect, test } from '@grafana/plugin-e2e';
import { ensureExtensionRegistryIsPopulated } from './utils';
const panelTitle = 'Link with defaults';
const extensionTitle = 'Open from time series...';
const testIds = {
@ -7,7 +10,7 @@ const testIds = {
container: 'ape-modal-body',
},
mainPage: {
container: 'ape-main-page-container',
container: 'main-app-body',
},
};
@ -16,6 +19,7 @@ const linkPathDashboardUid = 'd1fbb077-cd44-4738-8c8a-d4e66748b719';
test('should add link extension (path) with defaults to time series panel', async ({ gotoDashboardPage, page }) => {
const dashboardPage = await gotoDashboardPage({ uid: linkPathDashboardUid });
await ensureExtensionRegistryIsPopulated(page);
const panel = await dashboardPage.getPanelByTitle(panelTitle);
await panel.clickOnMenuItem(extensionTitle, { parentItem: 'Extensions' });
await expect(page.getByTestId(testIds.mainPage.container)).toBeVisible();
@ -23,6 +27,7 @@ test('should add link extension (path) with defaults to time series panel', asyn
test('should add link extension (onclick) with defaults to time series panel', async ({ gotoDashboardPage, page }) => {
const dashboardPage = await gotoDashboardPage({ uid: linkOnClickDashboardUid });
await ensureExtensionRegistryIsPopulated(page);
const panel = await dashboardPage.getPanelByTitle(panelTitle);
await panel.clickOnMenuItem(extensionTitle, { parentItem: 'Extensions' });
await expect(page.getByRole('dialog')).toContainText('Select query from "Link with defaults"');
@ -32,6 +37,7 @@ test('should add link extension (onclick) with new title to pie chart panel', as
const panelTitle = 'Link with new name';
const extensionTitle = 'Open from piechart';
const dashboardPage = await gotoDashboardPage({ uid: linkOnClickDashboardUid });
await ensureExtensionRegistryIsPopulated(page);
const panel = await dashboardPage.getPanelByTitle(panelTitle);
await panel.clickOnMenuItem(extensionTitle, { parentItem: 'Extensions' });
await expect(page.getByRole('dialog')).toContainText('Select query from "Link with new name"');

View File

@ -1,9 +1,9 @@
import { test, expect } from '@grafana/plugin-e2e';
const pluginId = 'myorg-componentconsumer-app';
const pluginId = 'grafana-extensionstest-app';
const exposedComponentTestId = 'exposed-component';
test('should display component exposed by another app', async ({ page }) => {
await page.goto(`/a/${pluginId}`);
await page.goto(`/a/${pluginId}/exposed-components`);
await expect(await page.getByTestId(exposedComponentTestId)).toHaveText('Hello World!');
});

View File

@ -0,0 +1,11 @@
import { test, expect } from '@grafana/plugin-e2e';
const pluginId = 'grafana-extensionstest-app';
const exposedComponentTestId = 'exposed-component';
test('should render component with usePluginComponents hook', async ({ page }) => {
await page.goto(`/a/${pluginId}/added-components`);
await expect(
page.getByTestId('data-testid pg-added-components-container').getByTestId('b-app-add-component')
).toHaveText('Hello World!');
});

View File

@ -0,0 +1,10 @@
import { Page } from '@playwright/test';
import { selectors } from '@grafana/e2e-selectors';
export async function ensureExtensionRegistryIsPopulated(page: Page) {
// Due to these plugins using the old getter extensions api we need to force a refresh by navigating home then back
// to guarantee the extensions are available to the plugin before we interact with the page.
await page.getByTestId(selectors.components.Breadcrumbs.breadcrumb('Home')).click();
await page.goBack();
}

View File

@ -0,0 +1,33 @@
# Test plugins
The [e2e test server](https://github.com/grafana/grafana/blob/main/scripts/grafana-server/start-server) automatically scans and looks for plugins in this directory.
### To add a new test plugin:
1. If provisioning is required you may update the YAML config file in [`/devenv`](https://github.com/grafana/grafana/tree/main/devenv).
2. Add the plugin ID to the `allow_loading_unsigned_plugins` setting in the test server's [configuration file](https://github.com/grafana/grafana/blob/main/scripts/grafana-server/custom.ini).
### Building a test plugin with webpack
If you wish to build a test plugin with webpack, you may take a look at how the [grafana-extensionstest-app](./grafana-extensionstest-app/) is wired. A few things to keep in mind:
- the package name needs to be prefixed with `@test-plugins/`
- extend the webpack config from [`@grafana/plugin-configs`](../../packages/grafana-plugin-configs/) and use custom webpack config to only copy the necessary files (see example [here](./grafana-extensionstest-app/webpack.config.ts))
- keep dependency versions in sync with what's in core
#### Local development
1: Install frontend dependencies:
`yarn install --immutable`
2: Build and watch the core frontend
`yarn start`
3: Build and watch the test plugins
`yarn e2e:plugin:build:dev`
4: Build the backend
`make build-go`
5: Start the Grafana e2e test server with the provisioned test plugin
`PORT=3000 ./scripts/grafana-server/start-server`

View File

Before

Width:  |  Height:  |  Size: 100 B

After

Width:  |  Height:  |  Size: 100 B

View File

Before

Width:  |  Height:  |  Size: 100 B

After

Width:  |  Height:  |  Size: 100 B

View File

Before

Width:  |  Height:  |  Size: 100 B

After

Width:  |  Height:  |  Size: 100 B

View File

@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
node_modules/
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# Compiled binary addons (https://nodejs.org/api/addons.html)
dist/
artifacts/
work/
ci/
# e2e test directories
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
# Editor
.idea
.eslintcache

View File

@ -0,0 +1 @@
# Changelog

View File

@ -0,0 +1,35 @@
# Extensions test plugins
This is an app plugin containing nested app plugins that are used for testing the plugins ui extensions APIs.
Further reading:
- [Plugin Ui Extensions docs](https://grafana.com/developers/plugin-tools/how-to-guides/ui-extensions/)
- [Plugin E2e testing docs](https://grafana.com/developers/plugin-tools/e2e-test-a-plugin/introduction)
## Build
To build this plugin run `yarn e2e:plugin:build`.
## Development
1: Install frontend dependencies:
`yarn install --immutable`
2: Build and watch the core frontend
`yarn start`
3: Build and watch the test plugins
`yarn e2e:plugin:build:dev`
4: Build the backend
`make build-go`
5: Start the Grafana e2e test server with the provisioned test plugin
`PORT=3000 ./scripts/grafana-server/start-server`
Note that this plugin extends the `@grafana/plugin-configs` configs which is why it has no src directory and uses a custom webpack config to copy necessary files.
## Run Playwright tests
- `yarn e2e:playwright`

View File

@ -0,0 +1,100 @@
import { PluginExtension, PluginExtensionLink, SelectableValue, locationUtil } from '@grafana/data';
import { isPluginExtensionLink, locationService } from '@grafana/runtime';
import { Button, ButtonGroup, ButtonSelect, Modal, Stack, ToolbarButton } from '@grafana/ui';
import { testIds } from '../testIds';
import { ReactElement, useMemo, useState } from 'react';
type Props = {
extensions: PluginExtension[];
};
export function ActionButton(props: Props): ReactElement {
const options = useExtensionsAsOptions(props.extensions);
const [extension, setExtension] = useState<PluginExtensionLink | undefined>();
if (options.length === 0) {
return <Button>Run default action</Button>;
}
return (
<>
<ButtonGroup>
<ToolbarButton key="default-action" variant="canvas" onClick={() => alert('You triggered the default action')}>
Run default action
</ToolbarButton>
<ButtonSelect
data-testid={testIds.actions.button}
key="select-extension"
variant="canvas"
options={options}
onChange={(option) => {
const extension = option.value;
if (isPluginExtensionLink(extension)) {
if (extension.path) {
return setExtension(extension);
}
if (extension.onClick) {
return extension.onClick();
}
}
}}
/>
</ButtonGroup>
{extension && extension?.path && (
<LinkModal title={extension.title} path={extension.path} onDismiss={() => setExtension(undefined)} />
)}
</>
);
}
function useExtensionsAsOptions(extensions: PluginExtension[]): Array<SelectableValue<PluginExtension>> {
return useMemo(() => {
return extensions.reduce((options: Array<SelectableValue<PluginExtension>>, extension) => {
if (isPluginExtensionLink(extension)) {
options.push({
label: extension.title,
title: extension.title,
value: extension,
});
}
return options;
}, []);
}, [extensions]);
}
type LinkModelProps = {
onDismiss: () => void;
title: string;
path: string;
};
export function LinkModal(props: LinkModelProps): ReactElement {
const { onDismiss, title, path } = props;
const openInNewTab = () => {
global.open(locationUtil.assureBaseUrl(path), '_blank');
onDismiss();
};
const openInCurrentTab = () => locationService.push(path);
return (
<Modal data-testid={testIds.modal.container} title={title} isOpen onDismiss={onDismiss}>
<Stack direction={'column'}>
<p>Do you want to proceed in the current tab or open a new tab?</p>
</Stack>
<Modal.ButtonRow>
<Button onClick={onDismiss} fill="outline" variant="secondary">
Cancel
</Button>
<Button type="submit" variant="secondary" onClick={openInNewTab} icon="external-link-alt">
Open in new tab
</Button>
<Button data-testid={testIds.modal.open} type="submit" variant="primary" onClick={openInCurrentTab} icon="apps">
Open
</Button>
</Modal.ButtonRow>
</Modal>
);
}

View File

@ -0,0 +1 @@
export { ActionButton } from './ActionButton';

View File

@ -0,0 +1,19 @@
import { Route, Routes } from 'react-router-dom';
import { AppRootProps } from '@grafana/data';
import { ROUTES } from '../../constants';
import { AddedComponents, ExposedComponents, LegacyAPIs } from '../../pages';
import { testIds } from '../testIds';
export function App(props: AppRootProps) {
return (
<div data-testid={testIds.container} style={{ marginTop: '5%' }}>
<Routes>
<Route path={ROUTES.LegacyAPIs} element={<LegacyAPIs />} />
<Route path={ROUTES.ExposedComponents} element={<ExposedComponents />} />
<Route path={ROUTES.AddedComponents} element={<AddedComponents />} />
<Route path={'*'} element={<LegacyAPIs />} />
</Routes>
</div>
);
}

View File

@ -0,0 +1 @@
export * from './App';

View File

@ -0,0 +1,135 @@
import { ChangeEvent, useState } from 'react';
import { lastValueFrom } from 'rxjs';
import { css } from '@emotion/css';
import { AppPluginMeta, GrafanaTheme2, PluginConfigPageProps, PluginMeta } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { Button, Field, FieldSet, Input, SecretInput, useStyles2 } from '@grafana/ui';
import { testIds } from '../testIds';
export type AppPluginSettings = {
apiUrl?: string;
};
type State = {
// The URL to reach our custom API.
apiUrl: string;
// Tells us if the API key secret is set.
isApiKeySet: boolean;
// A secret key for our custom API.
apiKey: string;
};
export interface AppConfigProps extends PluginConfigPageProps<AppPluginMeta<AppPluginSettings>> {}
export const AppConfig = ({ plugin }: AppConfigProps) => {
const s = useStyles2(getStyles);
const { enabled, pinned, jsonData, secureJsonFields } = plugin.meta;
const [state, setState] = useState<State>({
apiUrl: jsonData?.apiUrl || '',
apiKey: '',
isApiKeySet: Boolean(secureJsonFields?.apiKey),
});
const onResetApiKey = () =>
setState({
...state,
apiKey: '',
isApiKeySet: false,
});
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
setState({
...state,
[event.target.name]: event.target.value.trim(),
});
};
return (
<div data-testid={testIds.appConfig.container}>
<FieldSet label="API Settings">
<Field label="API Key" description="A secret key for authenticating to our custom API">
<SecretInput
width={60}
id="config-api-key"
data-testid={testIds.appConfig.apiKey}
name="apiKey"
value={state.apiKey}
isConfigured={state.isApiKeySet}
placeholder={'Your secret API key'}
onChange={onChange}
onReset={onResetApiKey}
/>
</Field>
<Field label="API Url" description="" className={s.marginTop}>
<Input
width={60}
name="apiUrl"
id="config-api-url"
data-testid={testIds.appConfig.apiUrl}
value={state.apiUrl}
placeholder={`E.g.: http://mywebsite.com/api/v1`}
onChange={onChange}
/>
</Field>
<div className={s.marginTop}>
<Button
type="submit"
data-testid={testIds.appConfig.submit}
onClick={() =>
updatePluginAndReload(plugin.meta.id, {
enabled,
pinned,
jsonData: {
apiUrl: state.apiUrl,
},
// This cannot be queried later by the frontend.
// We don't want to override it in case it was set previously and left untouched now.
secureJsonData: state.isApiKeySet
? undefined
: {
apiKey: state.apiKey,
},
})
}
disabled={Boolean(!state.apiUrl || (!state.isApiKeySet && !state.apiKey))}
>
Save API settings
</Button>
</div>
</FieldSet>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
colorWeak: css`
color: ${theme.colors.text.secondary};
`,
marginTop: css`
margin-top: ${theme.spacing(3)};
`,
});
const updatePluginAndReload = async (pluginId: string, data: Partial<PluginMeta<AppPluginSettings>>) => {
try {
await updatePlugin(pluginId, data);
// Reloading the page as the changes made here wouldn't be propagated to the actual plugin otherwise.
// This is not ideal, however unfortunately currently there is no supported way for updating the plugin state.
window.location.reload();
} catch (e) {
console.error('Error while updating the plugin', e);
}
};
export const updatePlugin = async (pluginId: string, data: Partial<PluginMeta>) => {
const response = await getBackendSrv().fetch({
url: `/api/plugins/${pluginId}/settings`,
method: 'POST',
data,
});
return lastValueFrom(response);
};

View File

@ -0,0 +1 @@
export * from './AppConfig';

View File

@ -0,0 +1,45 @@
import { DataQuery } from '@grafana/data';
import { Button, FilterPill, Modal, Stack } from '@grafana/ui';
import { testIds } from '../testIds';
import { ReactElement, useState } from 'react';
import { selectQuery } from '../../utils/utils';
type Props = {
targets: DataQuery[] | undefined;
onDismiss?: () => void;
};
export function QueryModal(props: Props): ReactElement {
const { targets = [], onDismiss } = props;
const [selected, setSelected] = useState(targets[0]);
return (
<div data-testid={testIds.modal.container}>
<p>Please select the query you would like to use to create &quot;something&quot; in the plugin.</p>
<Stack>
{targets.map((query) => (
<FilterPill
key={query.refId}
label={query.refId}
selected={query.refId === selected?.refId}
onClick={() => setSelected(query)}
/>
))}
</Stack>
<Modal.ButtonRow>
<Button variant="secondary" fill="outline" onClick={onDismiss}>
Cancel
</Button>
<Button
disabled={!Boolean(selected)}
onClick={() => {
onDismiss?.();
selectQuery(selected);
}}
>
OK
</Button>
</Modal.ButtonRow>
</div>
);
}

View File

@ -0,0 +1 @@
export { QueryModal } from './QueryModal';

View File

@ -0,0 +1,36 @@
export const testIds = {
container: 'main-app-body',
actions: {
button: 'action-button',
},
modal: {
container: 'container',
open: 'open-link',
},
appA: {
container: 'a-app-body',
},
appB: {
modal: 'b-app-modal',
},
appConfig: {
container: 'data-testid ac-container',
apiKey: 'data-testid ac-api-key',
apiUrl: 'data-testid ac-api-url',
submit: 'data-testid ac-submit-form',
},
pageOne: {
container: 'data-testid pg-one-container',
navigateToFour: 'data-testid navigate-to-four',
},
pageTwo: {
container: 'data-testid pg-two-container',
},
addedComponentsPage: {
container: 'data-testid pg-added-components-container',
},
pageFour: {
container: 'data-testid pg-four-container',
navigateBack: 'data-testid navigate-back',
},
};

View File

@ -0,0 +1,9 @@
import pluginJson from './plugin.json';
export const PLUGIN_BASE_URL = `/a/${pluginJson.id}`;
export enum ROUTES {
LegacyAPIs = 'legacy-apis',
ExposedComponents = 'exposed-components',
AddedComponents = 'added-components',
}

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,86 @@
import { AppPlugin, PluginExtensionPanelContext, PluginExtensionPoints } from '@grafana/data';
import { App } from './components/App';
import { QueryModal } from './components/QueryModal';
import { selectQuery } from './utils/utils';
import pluginJson from './plugin.json';
export const plugin = new AppPlugin<{}>()
.setRootPage(App)
.configureExtensionLink<PluginExtensionPanelContext>({
title: 'Open from time series or pie charts (path)',
description: 'This link will only be visible on time series and pie charts',
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
path: `/a/${pluginJson.id}/`,
configure: (context) => {
// Will only be visible for the Link Extensions dashboard
if (context?.dashboard?.title !== 'Link Extensions (path)') {
return undefined;
}
switch (context?.pluginId) {
case 'timeseries':
return {}; // Does not apply any overrides
case 'piechart':
return {
title: `Open from ${context.pluginId}`,
};
default:
// By returning undefined the extension will be hidden
return undefined;
}
},
})
.configureExtensionLink<PluginExtensionPanelContext>({
title: 'Open from time series or pie charts (onClick)',
description: 'This link will only be visible on time series and pie charts',
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
onClick: (_, { openModal, context }) => {
const targets = context?.targets ?? [];
const title = context?.title;
if (!isSupported(context)) {
return;
}
// Show a modal to display a UI for selecting between the available queries (targets)
// in case there are more available.
if (targets.length > 1) {
return openModal({
title: `Select query from "${title}"`,
body: (props) => <QueryModal {...props} targets={targets} />,
});
}
const [target] = targets;
selectQuery(target);
},
configure: (context) => {
// Will only be visible for the Command Extensions dashboard
if (context?.dashboard?.title !== 'Link Extensions (onClick)') {
return undefined;
}
if (!isSupported(context)) {
return;
}
switch (context?.pluginId) {
case 'timeseries':
return {}; // Does not apply any overrides
case 'piechart':
return {
title: `Open from ${context.pluginId}`,
};
default:
// By returning undefined the extension will be hidden
return undefined;
}
},
});
function isSupported(context?: PluginExtensionPanelContext): boolean {
const targets = context?.targets ?? [];
return targets.length > 0;
}

View File

@ -0,0 +1,48 @@
{
"name": "@test-plugins/extensions-test-app",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "webpack -c ./webpack.config.ts --env production",
"dev": "webpack -w -c ./webpack.config.ts --env development",
"typecheck": "tsc --noEmit",
"lint": "eslint --cache --ignore-path ./.gitignore --ext .js,.jsx,.ts,.tsx ."
},
"author": "Grafana Labs",
"license": "Apache-2.0",
"devDependencies": {
"@grafana/eslint-config": "7.0.0",
"@grafana/plugin-configs": "11.3.0-pre",
"@types/lodash": "4.17.7",
"@types/node": "20.14.14",
"@types/prismjs": "1.26.4",
"@types/react": "18.3.3",
"@types/react-dom": "18.2.25",
"@types/semver": "7.5.8",
"@types/uuid": "9.0.8",
"glob": "10.4.1",
"ts-node": "10.9.2",
"typescript": "5.5.4",
"webpack": "5.91.0",
"webpack-merge": "5.10.0"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"@emotion/css": "11.11.2",
"@grafana/data": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/schema": "workspace:*",
"@grafana/ui": "workspace:*",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-router-dom": "^6.22.0",
"rxjs": "7.8.1",
"tslib": "2.6.3"
},
"peerDependencies": {
"@grafana/runtime": "*"
},
"packageManager": "yarn@4.4.0"
}

View File

@ -0,0 +1,26 @@
import { testIds } from '../components/testIds';
import { PluginPage, usePluginComponents } from '@grafana/runtime';
import { Stack } from '@grafana/ui';
type ReusableComponentProps = {
name: string;
};
export function AddedComponents() {
const { components } = usePluginComponents<ReusableComponentProps>({
extensionPointId: 'plugins/grafana-extensionexample2-app/addComponent/v1',
});
return (
<PluginPage>
<Stack direction={'column'} gap={4} data-testid={testIds.addedComponentsPage.container}>
<article>
<h3>Component extensions defined with addComponent and retrived with usePluginComponents hook</h3>
{components.map((Component, i) => {
return <Component key={i} name="World" />;
})}
</article>
</Stack>
</PluginPage>
);
}

View File

@ -0,0 +1,24 @@
import { testIds } from '../components/testIds';
import { PluginPage, usePluginComponent } from '@grafana/runtime';
type ReusableComponentProps = {
name: string;
};
export function ExposedComponents() {
var { component: ReusableComponent } = usePluginComponent<ReusableComponentProps>(
'grafana-extensionexample1-app/reusable-component/v1'
);
if (!ReusableComponent) {
return null;
}
return (
<PluginPage>
<div data-testid={testIds.pageTwo.container}>
<ReusableComponent name={'World'} />
</div>
</PluginPage>
);
}

View File

@ -0,0 +1,44 @@
import { testIds } from '../components/testIds';
import { PluginPage, getPluginComponentExtensions, getPluginExtensions } from '@grafana/runtime';
import { ActionButton } from '../components/ActionButton';
import { Stack } from '@grafana/ui';
type AppExtensionContext = {};
type ReusableComponentProps = {
name: string;
};
export function LegacyAPIs() {
const extensionPointId = 'plugins/grafana-extensionstest-app/actions';
const context: AppExtensionContext = {};
const { extensions } = getPluginExtensions({
extensionPointId,
context,
});
const { extensions: componentExtensions } = getPluginComponentExtensions<ReusableComponentProps>({
extensionPointId: 'plugins/grafana-extensionexample2-app/configure-extension-component/v1',
});
return (
<PluginPage>
<Stack direction={'column'} gap={4} data-testid={testIds.pageTwo.container}>
<article>
<h3>Link extensions defined with configureExtensionLink and retrived using getPluginExtensions</h3>
<ActionButton extensions={extensions} />
</article>
<article>
<h3>
Component extensions defined with configureExtensionComponent and retrived using
getPluginComponentExtensions
</h3>
{componentExtensions.map((extension) => {
const Component = extension.component;
return <Component key={extension.id} name="World" />;
})}
</article>
</Stack>
</PluginPage>
);
}

View File

@ -0,0 +1,3 @@
export { ExposedComponents } from './ExposedComponents';
export { LegacyAPIs } from './LegacyAPIs';
export { AddedComponents } from './AddedComponents';

View File

@ -0,0 +1,59 @@
{
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
"type": "app",
"name": "Extensions test app",
"preload": true,
"id": "grafana-extensionstest-app",
"info": {
"keywords": ["app"],
"description": "",
"author": {
"name": "Grafana"
},
"logos": {
"small": "img/logo.svg",
"large": "img/logo.svg"
},
"screenshots": [],
"version": "%VERSION%",
"updated": "%TODAY%"
},
"includes": [
{
"type": "page",
"name": "Legacy APIs",
"path": "/a/grafana-extensionstest-app/legacy-apis",
"role": "Admin",
"addToNav": true,
"defaultNav": false
},
{
"type": "page",
"name": "Exposed components",
"path": "/a/grafana-extensionstest-app/exposed-components",
"role": "Admin",
"addToNav": true,
"defaultNav": false
},
{
"type": "page",
"name": "Added components",
"path": "/a/grafana-extensionstest-app/added-components",
"role": "Admin",
"addToNav": true,
"defaultNav": false
},
{
"type": "page",
"icon": "cog",
"name": "Configuration",
"path": "/plugins/grafana-extensionstest-app",
"role": "Admin",
"addToNav": true
}
],
"dependencies": {
"grafanaDependency": ">=10.4.0",
"plugins": []
}
}

View File

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

View File

@ -0,0 +1,17 @@
import { AppPlugin } from '@grafana/data';
import { App } from './components/App';
export const plugin = new AppPlugin<{}>()
.setRootPage(App)
.configureExtensionLink({
title: 'Go to A',
description: 'Navigating to pluging A',
extensionPointId: 'plugins/grafana-extensionstest-app/actions',
path: '/a/grafana-extensionexample1-app/',
})
.exposeComponent({
id: 'grafana-extensionexample1-app/reusable-component/v1',
title: 'Reusable component',
description: 'A component that can be reused by other app plugins.',
component: ({ name }: { name: string }) => <div data-testid="exposed-component">Hello {name}!</div>,
});

View File

@ -1,8 +1,8 @@
{
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
"type": "app",
"name": "A App",
"id": "myorg-componentexposer-app",
"name": "C App",
"id": "grafana-extensionexample1-app",
"preload": true,
"info": {
"keywords": ["app"],
@ -22,7 +22,7 @@
{
"type": "page",
"name": "Default",
"path": "/a/myorg-componentexposer-app",
"path": "/a/grafana-extensionexample1-app",
"role": "Admin",
"addToNav": false,
"defaultNav": false

View File

@ -0,0 +1,16 @@
export const testIds = {
container: 'main-app-body',
actions: {
button: 'action-button',
},
modal: {
container: 'container',
open: 'open-link',
},
appA: {
container: 'a-app-body',
},
appB: {
modal: 'b-app-modal',
},
};

View File

@ -0,0 +1,8 @@
import * as React from 'react';
import { AppRootProps } from '@grafana/data';
export class App extends React.PureComponent<AppRootProps> {
render() {
return <div className="page-container">Hello Grafana!</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,32 @@
import { AppPlugin } from '@grafana/data';
import { App } from './components/App';
import { testIds } from './testIds';
console.log('Hello from app B');
export const plugin = new AppPlugin<{}>()
.setRootPage(App)
.configureExtensionLink({
title: 'Open from B',
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-extensionexample2-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',
title: 'Added component from B',
description: 'A component that can be reused by other app plugins. Shared using addComponent api',
component: ({ name }: { name: string }) => (
<div data-testid={testIds.appB.reusableAddedComponent}>Hello {name}!</div>
),
});

View File

@ -1,14 +1,14 @@
{
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
"type": "app",
"name": "B App",
"id": "myorg-b-app",
"name": "D App",
"id": "grafana-extensionexample2-app",
"preload": true,
"info": {
"keywords": ["app"],
"description": "Will extend root app with ui extensions",
"author": {
"name": "Myorg"
"name": "grafana"
},
"logos": {
"small": "img/logo.svg",
@ -22,7 +22,7 @@
{
"type": "page",
"name": "Default",
"path": "/a/myorg-b-app",
"path": "/a/grafana-extensionexample2-app",
"role": "Admin",
"addToNav": false,
"defaultNav": false
@ -31,13 +31,5 @@
"dependencies": {
"grafanaDependency": ">=10.3.3",
"plugins": []
},
"extensions": [
{
"extensionPointId": "plugins/myorg-extensionpoint-app/actions",
"title": "Open from B",
"description": "Open a modal from plugin B",
"type": "link"
}
]
}
}

View File

@ -0,0 +1,18 @@
export const testIds = {
container: 'main-app-body',
actions: {
button: 'action-button',
},
modal: {
container: 'container',
open: 'open-link',
},
appA: {
container: 'a-app-body',
},
appB: {
modal: 'b-app-modal',
reusableComponent: 'b-app-configure-extension-component',
reusableAddedComponent: 'b-app-add-component',
},
};

View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"types": ["node", "jest", "@testing-library/jest-dom"]
},
"extends": "@grafana/plugin-configs/tsconfig.json",
"include": ["."]
}

View File

@ -0,0 +1,6 @@
import { PLUGIN_BASE_URL } from '../constants';
// Prefixes the route with the base URL of the plugin
export function prefixRoute(route: string): string {
return `${PLUGIN_BASE_URL}/${route}`;
}

View File

@ -0,0 +1,5 @@
import { DataQuery } from '@grafana/data';
export function selectQuery(target: DataQuery): void {
alert(`You selected query "${target.refId}"`);
}

View File

@ -0,0 +1,44 @@
import CopyWebpackPlugin from 'copy-webpack-plugin';
import grafanaConfig from '@grafana/plugin-configs/webpack.config';
import { mergeWithCustomize, unique } from 'webpack-merge';
import { Configuration } from 'webpack';
function skipFiles(f: string): boolean {
if (f.includes('/dist/')) {
// avoid copying files already in dist
return false;
}
if (f.includes('/node_modules/')) {
// avoid copying tsconfig.json
return false;
}
if (f.includes('/package.json')) {
// avoid copying package.json
return false;
}
return true;
}
const config = async (env: Record<string, unknown>): Promise<Configuration> => {
const baseConfig = await grafanaConfig(env);
const customConfig = {
plugins: [
new CopyWebpackPlugin({
patterns: [
// To `compiler.options.output`
{ from: 'README.md', to: '.', force: true },
{ from: 'plugin.json', to: '.' },
{ from: 'CHANGELOG.md', to: '.', force: true },
{ from: '**/*.json', to: '.', filter: skipFiles },
{ from: '**/*.svg', to: '.', noErrorOnMissing: true, filter: skipFiles }, // Optional
],
}),
],
};
return mergeWithCustomize({
customizeArray: unique('plugins', ['CopyPlugin'], (plugin) => plugin.constructor && plugin.constructor.name),
})(baseConfig, customConfig);
};
export default config;

View File

@ -20,6 +20,8 @@
"e2e:playwright": "yarn playwright test",
"e2e:playwright:server": "./e2e/plugin-e2e/start-and-run-suite",
"e2e:storybook": "PORT=9001 ./e2e/run-suite storybook true",
"e2e:plugin:build": "nx run-many -t build --projects='@test-plugins/*'",
"e2e:plugin:build:dev": "nx run-many -t dev --projects='@test-plugins/*' --maxParallel=100",
"test": "jest --notify --watch",
"test:coverage": "jest --coverage",
"test:coverage:changes": "jest --coverage --changedSince=origin/main",
@ -431,7 +433,8 @@
"packages/*",
"packages/!(grafana-icons)/**",
"plugins-bundled/internal/*",
"public/app/plugins/*/*"
"public/app/plugins/*/*",
"e2e/test-plugins/*"
]
},
"engines": {

View File

@ -12,13 +12,29 @@ export function getPluginJson() {
}
export async function getEntries(): Promise<Record<string, string>> {
const pluginModules = await glob(path.resolve(process.cwd(), `module.{ts,tsx}`), { absolute: true });
if (pluginModules.length > 0) {
return {
module: pluginModules[0],
};
}
throw new Error('Could not find module.ts or module.tsx file');
const pluginsJson = await glob(path.resolve(process.cwd(), '**/plugin.json'), {
ignore: ['**/dist/**'],
absolute: true,
});
const plugins = await Promise.all(
pluginsJson.map((pluginJson) => {
const folder = path.dirname(pluginJson);
return glob(`${folder}/module.{ts,tsx,js,jsx}`, { absolute: true });
})
);
let result: Record<string, string> = {};
return plugins.reduce((result, modules) => {
return modules.reduce((result, module) => {
const pluginPath = path.dirname(module);
const pluginName = path.relative(process.cwd(), pluginPath).replace(/src\/?/i, '');
const entryName = pluginName === '' ? 'module' : `${pluginName}/module`;
result[entryName] = module;
return result;
}, result);
}, result);
}
export function hasLicense() {

View File

@ -59,7 +59,6 @@ const config = async (env: Record<string, unknown>): Promise<Configuration> => {
'redux',
'rxjs',
'react-router',
'react-router-dom',
'd3',
'angular',
'@grafana/ui',

View File

@ -4,6 +4,7 @@ load(
"scripts/drone/steps/lib.star",
"build_frontend_package_step",
"build_storybook_step",
"build_test_plugins_step",
"cloud_plugins_e2e_tests_step",
"compile_build_cmd",
"download_grabpl_step",
@ -93,6 +94,7 @@ def build_e2e(trigger, ver_mode):
build_steps.extend(
[
build_test_plugins_step(),
grafana_server_step(),
e2e_tests_step("dashboards-suite"),
e2e_tests_step("scenes/dashboards-suite"),

View File

@ -470,6 +470,26 @@ def build_frontend_step():
],
}
def build_test_plugins_step():
"""Build the test plugins used in e2e tests
Returns:
Drone step.
"""
return {
"name": "build-test-plugins",
"image": images["node"],
"environment": {
"NODE_OPTIONS": "--max_old_space_size=8192",
},
"depends_on": [
"yarn-install",
],
"commands": [
"yarn e2e:plugin:build",
],
}
def update_package_json_version():
"""Updates the packages/ to use a version that has the build ID in it: 10.0.0pre -> 10.0.0-5432pre
@ -773,6 +793,7 @@ def e2e_tests_step(suite, port = 3001, tries = None):
"image": images["cypress"],
"depends_on": [
"grafana-server",
"build-test-plugins",
],
"environment": {
"HOST": "grafana-server",
@ -872,6 +893,7 @@ def playwright_e2e_tests_step():
"image": images["node_deb"],
"depends_on": [
"grafana-server",
"build-test-plugins",
],
"commands": [
"npx wait-on@7.0.1 http://$HOST:$PORT",

View File

@ -1,3 +1,4 @@
[security]
content_security_policy = true
content_security_policy_template = """require-trusted-types-for 'script'; script-src 'self' 'unsafe-eval' 'unsafe-inline' 'strict-dynamic' $NONCE;object-src 'none';font-src 'self';style-src 'self' 'unsafe-inline' blob:;img-src * data:;base-uri 'self';connect-src 'self' grafana.com ws://$ROOT_PATH wss://$ROOT_PATH;manifest-src 'self';media-src 'none';form-action 'self';"""
@ -5,6 +6,9 @@ content_security_policy_template = """require-trusted-types-for 'script'; script
[feature_toggles]
enable = publicDashboards
[plugins]
allow_loading_unsigned_plugins=grafana-extensionstest-app,grafana-extensionexample1-app,grafana-extensionexample2-app,
[database]
type=sqlite3
wal=true

View File

@ -23,8 +23,9 @@ fi
echo starting server
cp -r ./bin $RUNDIR
cp -r ./public $RUNDIR
cp -r ./tools $RUNDIR
ln -s $(realpath ./public) $RUNDIR
mkdir $RUNDIR/conf
mkdir $PROV_DIR
@ -39,12 +40,12 @@ cp ./conf/defaults.ini $RUNDIR/conf/defaults.ini
echo -e "Copying custom plugins from e2e tests"
mkdir -p "$RUNDIR/data/plugins"
# when running in a local computer
if [ -d "./e2e/custom-plugins" ]; then
cp -r "./e2e/custom-plugins" "$RUNDIR/data/plugins"
if [ -d "./e2e/test-plugins" ]; then
ln -s $(realpath ./e2e/test-plugins/*) "$RUNDIR/data/plugins"
# when running in CI
elif [ -d "../e2e/custom-plugins" ]; then
cp -r "../e2e/custom-plugins" "$RUNDIR/data/plugins"
elif [ -d "../e2e/test-plugins" ]; then
cp -r "../e2e/test-plugins" "$RUNDIR/data/plugins"
fi
echo -e "Copy provisioning setup from devenv"

View File

@ -6678,6 +6678,13 @@ __metadata:
languageName: node
linkType: hard
"@remix-run/router@npm:1.19.1":
version: 1.19.1
resolution: "@remix-run/router@npm:1.19.1"
checksum: 10/2800c2f6567a982fe942aacc4cb5b170e7cc89bd455960e3bea2424161ff7dac32d01886322d88dd19b88d1bea711f39566d17f02b73eeb74999affb471f8f52
languageName: node
linkType: hard
"@rollup/plugin-image@npm:3.0.3":
version: 3.0.3
resolution: "@rollup/plugin-image@npm:3.0.3"
@ -8796,6 +8803,39 @@ __metadata:
languageName: node
linkType: hard
"@test-plugins/extensions-test-app@workspace:e2e/test-plugins/grafana-extensionstest-app":
version: 0.0.0-use.local
resolution: "@test-plugins/extensions-test-app@workspace:e2e/test-plugins/grafana-extensionstest-app"
dependencies:
"@emotion/css": "npm:11.11.2"
"@grafana/data": "workspace:*"
"@grafana/eslint-config": "npm:7.0.0"
"@grafana/plugin-configs": "npm:11.3.0-pre"
"@grafana/runtime": "workspace:*"
"@grafana/schema": "workspace:*"
"@grafana/ui": "workspace:*"
"@types/lodash": "npm:4.17.7"
"@types/node": "npm:20.14.14"
"@types/prismjs": "npm:1.26.4"
"@types/react": "npm:18.3.3"
"@types/react-dom": "npm:18.2.25"
"@types/semver": "npm:7.5.8"
"@types/uuid": "npm:9.0.8"
glob: "npm:10.4.1"
react: "npm:18.2.0"
react-dom: "npm:18.2.0"
react-router-dom: "npm:^6.22.0"
rxjs: "npm:7.8.1"
ts-node: "npm:10.9.2"
tslib: "npm:2.6.3"
typescript: "npm:5.5.4"
webpack: "npm:5.91.0"
webpack-merge: "npm:5.10.0"
peerDependencies:
"@grafana/runtime": "*"
languageName: unknown
linkType: soft
"@testing-library/dom@npm:10.0.0, @testing-library/dom@npm:>=7, @testing-library/dom@npm:^10.0.0":
version: 10.0.0
resolution: "@testing-library/dom@npm:10.0.0"
@ -27286,6 +27326,19 @@ __metadata:
languageName: node
linkType: hard
"react-router-dom@npm:^6.22.0":
version: 6.26.1
resolution: "react-router-dom@npm:6.26.1"
dependencies:
"@remix-run/router": "npm:1.19.1"
react-router: "npm:6.26.1"
peerDependencies:
react: ">=16.8"
react-dom: ">=16.8"
checksum: 10/1bd255d1ff88f477699c72656e7c07702a907e644388a1bea1c648f2df0c3c86db2e90bea945b1d43eaf84ebab194f3868f3788502965ad5f20c508c6874f1fe
languageName: node
linkType: hard
"react-router@npm:5.3.3":
version: 5.3.3
resolution: "react-router@npm:5.3.3"
@ -27317,6 +27370,17 @@ __metadata:
languageName: node
linkType: hard
"react-router@npm:6.26.1":
version: 6.26.1
resolution: "react-router@npm:6.26.1"
dependencies:
"@remix-run/router": "npm:1.19.1"
peerDependencies:
react: ">=16.8"
checksum: 10/b3761515c75da65a1678f005d08a6285ceccd9df7237ae6fdd9ab2ab816ef328435b75610f705ecd9ecd41c6878fd22eb9b44c5391cdef2e1ed99ddbc78de8a4
languageName: node
linkType: hard
"react-select-event@npm:5.5.1, react-select-event@npm:^5.1.0":
version: 5.5.1
resolution: "react-select-event@npm:5.5.1"