Plugin extensions: Add e2e tests (#89048)

* add custom plugins

* update bundles

* provision app plugins and their dashboards

* add one more script that run e2e tests using e2e test server

* add e2e tests

* regenerate jsonnet dashboards

* ignore custom plugins and playwright report

* use minified

* cleanup tests

* update codeowners

* add leading slash

* document new script

* document custom-plugins

* cleanup

* twist modules

* add readme
pull/89220/head
Erik Sundell 11 months ago committed by GitHub
parent a9171aa9fe
commit 72241dbf5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      .eslintignore
  2. 1
      .github/CODEOWNERS
  3. 14
      contribute/developer-guide.md
  4. 2
      contribute/style-guides/e2e-plugins.md
  5. 342
      devenv/dev-dashboards/extensions/link-onclick-extensions.json
  6. 237
      devenv/dev-dashboards/extensions/link-path-extensions.json
  7. 2
      devenv/jsonnet/dev-dashboards.libsonnet
  8. 19
      devenv/plugins.yaml
  9. 5
      e2e/custom-plugins/README.md
  10. 12
      e2e/custom-plugins/app-with-extension-point/README.md
  11. 141
      e2e/custom-plugins/app-with-extension-point/module.js
  12. 38
      e2e/custom-plugins/app-with-extension-point/plugin.json
  13. 26
      e2e/custom-plugins/app-with-extension-point/plugins/myorg-a-app/module.js
  14. 45
      e2e/custom-plugins/app-with-extension-point/plugins/myorg-a-app/plugin.json
  15. 27
      e2e/custom-plugins/app-with-extension-point/plugins/myorg-b-app/module.js
  16. 45
      e2e/custom-plugins/app-with-extension-point/plugins/myorg-b-app/plugin.json
  17. 12
      e2e/custom-plugins/app-with-extensions/README.md
  18. 216
      e2e/custom-plugins/app-with-extensions/module.js
  19. 49
      e2e/custom-plugins/app-with-extensions/plugin.json
  20. 35
      e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/extensionPoints.spec.ts
  21. 38
      e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/extensions.spec.ts
  22. 23
      e2e/plugin-e2e/start-and-run-suite
  23. 3
      package.json
  24. 2
      scripts/grafana-server/start-server

@ -13,6 +13,8 @@ node_modules
/public/lib/monaco
/scripts/grafana-server/tmp
vendor
e2e/custom-plugins
playwright-report
# TS generate from cue by cuetsy
**/*.gen.ts

@ -223,6 +223,7 @@
/devenv/local-npm/ @grafana/frontend-ops
/devenv/vscode/ @grafana/frontend-ops
/devenv/setup.sh @grafana/grafana-backend-services-squad
/devenv/plugins.yaml @grafana/plugins-platform-frontend
# Emails
/emails/ @grafana/alerting-frontend

@ -235,7 +235,7 @@ yarn e2e:dev
#### To run the Playwright tests:
**Note:** If you're using VS Code as your development editor, it's recommended to install the [Playwright test extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright). It allows you to run, debug and generate Playwright tests from within the editor. For more information about the extension and how to install it, refer to the [Playwright documentation](https://playwright.dev/docs/getting-started-vscode).
**Note:** If you're using VS Code as your development editor, it's recommended to install the [Playwright test extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright). It allows you to run, debug and generate Playwright tests from within the editor. For more information about the extension and how to use reports to analyze failing tests, refer to the [Playwright documentation](https://playwright.dev/docs/getting-started-vscode).
Each version of Playwright needs specific versions of browser binaries to operate. You need to use the Playwright CLI to install these browsers.
@ -243,22 +243,16 @@ Each version of Playwright needs specific versions of browser binaries to operat
yarn playwright install chromium
```
To run all tests in a headless Chromium browser and display results in the terminal:
To run all tests in a headless Chromium browser and display results in the terminal. This assumes you have Grafana running on port 3000.
```
yarn e2e:playwright
```
For a better developer experience, open the Playwright UI where you can visually walk through each step of the test and see what was happening before, during, and after each step.
The following script starts a Grafana [development server](https://github.com/grafana/grafana/blob/main/scripts/grafana-server/start-server) (same server that is being used when running e2e tests in Drone CI) on port 3001 and runs the Playwright tests. The development server is provisioned with the [devenv](https://github.com/grafana/grafana/blob/main/contribute/developer-guide.md#add-data-sources) dashboards, data sources and apps.
```
yarn e2e:playwright:ui
```
To open the HTML reporter for the last test run session:
```
yarn e2e:playwright:report
yarn e2e:playwright:server
```
## Configure Grafana for development

@ -33,3 +33,5 @@ Playwright end-to-end tests for plugins should be added to the [`e2e/plugin-e2e`
The script above assumes you have Grafana running on `localhost:3000`. You may change this by providing environment variables.
`HOST=127.0.0.1 PORT=3001 yarn e2e:playwright`
- `yarn e2e:playwright:server` will start a Grafana [development server](https://github.com/grafana/grafana/blob/main/scripts/grafana-server/start-server) on port 3001 and run the Playwright tests. The development server is provisioned with the [devenv](https://github.com/grafana/grafana/blob/main/contribute/developer-guide.md#add-data-sources) dashboards, data sources and apps.

@ -0,0 +1,342 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 7,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"title": "Link with one query",
"type": "timeseries"
},
{
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 0,
"y": 8
},
"id": 6,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"showRowNums": false
},
"pluginVersion": "10.1.0-55406pre",
"title": "No extensions",
"type": "table"
},
{
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
}
},
"mappings": []
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 6,
"y": 8
},
"id": 4,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"pieType": "pie",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "testdata"
},
"refId": "A",
"scenarioId": "random_walk",
"seriesCount": 4
},
{
"datasource": {
"type": "testdata"
},
"hide": false,
"refId": "B",
"scenarioId": "random_walk",
"seriesCount": 1
}
],
"title": "Link with new name",
"type": "piechart"
},
{
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 0,
"y": 16
},
"id": 2,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "testdata"
},
"refId": "A",
"scenarioId": "random_walk",
"seriesCount": 1
},
{
"datasource": {
"type": "testdata"
},
"hide": false,
"refId": "B",
"scenarioId": "random_walk",
"seriesCount": 1
}
],
"title": "Link with defaults",
"type": "timeseries"
}
],
"refresh": "",
"schemaVersion": 38,
"style": "dark",
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Link Extensions (onClick)",
"uid": "dbfb47c5-e5e5-4d28-8ac7-35f349b95946",
"version": 1,
"weekStart": ""
}

@ -0,0 +1,237 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 1,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 0,
"y": 0
},
"id": 6,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"showRowNums": false
},
"pluginVersion": "9.5.0-53420pre",
"title": "No extensions",
"type": "table"
},
{
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
}
},
"mappings": []
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 6,
"y": 0
},
"id": 4,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"pieType": "pie",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "testdata"
},
"refId": "A",
"scenarioId": "random_walk",
"seriesCount": 4
}
],
"title": "Link with new name",
"type": "piechart"
},
{
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 0,
"y": 8
},
"id": 2,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"title": "Link with defaults",
"type": "timeseries"
}
],
"refresh": "",
"schemaVersion": 38,
"style": "dark",
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Link Extensions (path)",
"uid": "d1fbb077-cd44-4738-8c8a-d4e66748b719",
"version": 3,
"weekStart": ""
}

@ -59,6 +59,8 @@
"join-by-field": (import '../dev-dashboards/transforms/join-by-field.json'),
"join-by-labels": (import '../dev-dashboards/transforms/join-by-labels.json'),
"lazy_loading": (import '../dev-dashboards/panel-common/lazy_loading.json'),
"link-onclick-extensions": (import '../dev-dashboards/extensions/link-onclick-extensions.json'),
"link-path-extensions": (import '../dev-dashboards/extensions/link-path-extensions.json'),
"linked-viz": (import '../dev-dashboards/panel-common/linked-viz.json'),
"live-flakey": (import '../dev-dashboards/live/live-flakey.json'),
"live-flakey-refresh": (import '../dev-dashboards/live/live-flakey-refresh.json'),

@ -0,0 +1,19 @@
apiVersion: 1
apps:
- type: myorg-extensions-app
org_id: 1
org_name: Main Org.
disabled: false
- type: myorg-a-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

@ -0,0 +1,5 @@
# 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)).

@ -0,0 +1,12 @@
# 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
```

@ -0,0 +1,141 @@
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 };
});

@ -0,0 +1,38 @@
{
"$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": []
},
"generated": {
"extensions": []
}
}

@ -0,0 +1,26 @@
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 };
});

@ -0,0 +1,45 @@
{
"$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"
}
]
}
}

@ -0,0 +1,27 @@
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 };
});

@ -0,0 +1,45 @@
{
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
"type": "app",
"name": "B App",
"id": "myorg-b-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-b-app",
"role": "Admin",
"addToNav": false,
"defaultNav": false
}
],
"dependencies": {
"grafanaDependency": ">=10.3.3",
"plugins": []
},
"generated": {
"extensions": [
{
"extensionPointId": "plugins/myorg-extensionpoint-app/actions",
"title": "Open from B",
"description": "Open a modal from plugin B",
"type": "link"
}
]
}
}

@ -0,0 +1,12 @@
# 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
```

@ -0,0 +1,216 @@
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 };
});

@ -0,0 +1,49 @@
{
"$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"
}
]
}

@ -0,0 +1,35 @@
import { test, expect } from '@grafana/plugin-e2e';
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',
},
};
const pluginId = 'myorg-extensionpoint-app';
test('should extend the actions menu with a link to a-app plugin', async ({ page }) => {
await page.goto(`/a/${pluginId}/one`);
await page.getByTestId(testIds.actions.button).click();
await page.getByTestId(testIds.container).getByText('Go to A').click();
await page.getByTestId(testIds.modal.open).click();
await expect(page.getByTestId(testIds.appA.container)).toBeVisible();
});
test('should extend the actions menu with a command triggered from b-app plugin', async ({ page }) => {
await page.goto(`/a/${pluginId}/one`);
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();
});

@ -0,0 +1,38 @@
import { expect, test } from '@grafana/plugin-e2e';
const panelTitle = 'Link with defaults';
const extensionTitle = 'Open from time series...';
const testIds = {
modal: {
container: 'ape-modal-body',
},
mainPage: {
container: 'ape-main-page-container',
},
};
const linkOnClickDashboardUid = 'dbfb47c5-e5e5-4d28-8ac7-35f349b95946';
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 });
const panel = await dashboardPage.getPanelByTitle(panelTitle);
await panel.clickOnMenuItem(extensionTitle, { parentItem: 'Extensions' });
await expect(page.getByTestId(testIds.mainPage.container)).toBeVisible();
});
test('should add link extension (onclick) with defaults to time series panel', async ({ gotoDashboardPage, page }) => {
const dashboardPage = await gotoDashboardPage({ uid: linkOnClickDashboardUid });
const panel = await dashboardPage.getPanelByTitle(panelTitle);
await panel.clickOnMenuItem(extensionTitle, { parentItem: 'Extensions' });
await expect(page.getByRole('dialog')).toContainText('Select query from "Link with defaults"');
});
test('should add link extension (onclick) with new title to pie chart panel', async ({ gotoDashboardPage, page }) => {
const panelTitle = 'Link with new name';
const extensionTitle = 'Open from piechart';
const dashboardPage = await gotoDashboardPage({ uid: linkOnClickDashboardUid });
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"');
});

@ -0,0 +1,23 @@
#!/bin/bash
. scripts/grafana-server/variables
LICENSE_PATH=""
if [ "$1" = "enterprise" ]; then
if [ "$2" != "dev" ] && [ "$2" != "debug" ]; then
LICENSE_PATH=$2/license.jwt
else
LICENSE_PATH=$3/license.jwt
fi
fi
if [ "$BASE_URL" != "" ]; then
echo -e "BASE_URL set, skipping starting server"
else
# Start it in the background
./scripts/grafana-server/start-server $LICENSE_PATH 2>&1 > scripts/grafana-server/server.log &
./scripts/grafana-server/wait-for-grafana
fi
PORT=3001 HOST=localhost yarn playwright test

@ -18,8 +18,7 @@
"e2e:enterprise:dev": "./e2e/start-and-run-suite enterprise dev",
"e2e:enterprise:debug": "./e2e/start-and-run-suite enterprise debug",
"e2e:playwright": "yarn playwright test",
"e2e:playwright:ui": "yarn playwright test --ui",
"e2e:playwright:report": "yarn playwright show-report",
"e2e:playwright:server": "./e2e/plugin-e2e/start-and-run-suite",
"test": "jest --notify --watch",
"test:coverage": "jest --coverage",
"test:coverage:changes": "jest --coverage --changedSince=origin/main",

@ -31,6 +31,7 @@ mkdir $PROV_DIR
mkdir $PROV_DIR/datasources
mkdir $PROV_DIR/dashboards
mkdir $PROV_DIR/alerting
mkdir $PROV_DIR/plugins
cp ./scripts/grafana-server/custom.ini $RUNDIR/conf/custom.ini
cp ./conf/defaults.ini $RUNDIR/conf/defaults.ini
@ -51,6 +52,7 @@ echo -e "Copy provisioning setup from devenv"
cp devenv/datasources.yaml $PROV_DIR/datasources
cp devenv/dashboards.yaml $PROV_DIR/dashboards
cp devenv/alert_rules.yaml $PROV_DIR/alerting
cp devenv/plugins.yaml $PROV_DIR/plugins
cp -r devenv $RUNDIR

Loading…
Cancel
Save