diff --git a/.eslintignore b/.eslintignore index 01e0c6ec342..95ace0ec535 100644 --- a/.eslintignore +++ b/.eslintignore @@ -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 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4310fea2fbb..e87f3dc4109 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/contribute/developer-guide.md b/contribute/developer-guide.md index 2607b91f1a9..bcdb979b790 100644 --- a/contribute/developer-guide.md +++ b/contribute/developer-guide.md @@ -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 diff --git a/contribute/style-guides/e2e-plugins.md b/contribute/style-guides/e2e-plugins.md index f675db96784..6324204c4f6 100644 --- a/contribute/style-guides/e2e-plugins.md +++ b/contribute/style-guides/e2e-plugins.md @@ -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. diff --git a/devenv/dev-dashboards/extensions/link-onclick-extensions.json b/devenv/dev-dashboards/extensions/link-onclick-extensions.json new file mode 100644 index 00000000000..5119eb4c1b9 --- /dev/null +++ b/devenv/dev-dashboards/extensions/link-onclick-extensions.json @@ -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": "" +} \ No newline at end of file diff --git a/devenv/dev-dashboards/extensions/link-path-extensions.json b/devenv/dev-dashboards/extensions/link-path-extensions.json new file mode 100644 index 00000000000..6ef0669e371 --- /dev/null +++ b/devenv/dev-dashboards/extensions/link-path-extensions.json @@ -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": "" + } \ No newline at end of file diff --git a/devenv/jsonnet/dev-dashboards.libsonnet b/devenv/jsonnet/dev-dashboards.libsonnet index 6c9eb8afb30..8eabc329c5f 100644 --- a/devenv/jsonnet/dev-dashboards.libsonnet +++ b/devenv/jsonnet/dev-dashboards.libsonnet @@ -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'), diff --git a/devenv/plugins.yaml b/devenv/plugins.yaml new file mode 100644 index 00000000000..9e488cc065f --- /dev/null +++ b/devenv/plugins.yaml @@ -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 diff --git a/e2e/custom-plugins/README.md b/e2e/custom-plugins/README.md new file mode 100644 index 00000000000..9fef8ba9aaf --- /dev/null +++ b/e2e/custom-plugins/README.md @@ -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)). diff --git a/e2e/custom-plugins/app-with-extension-point/README.md b/e2e/custom-plugins/app-with-extension-point/README.md new file mode 100644 index 00000000000..99b656fa109 --- /dev/null +++ b/e2e/custom-plugins/app-with-extension-point/README.md @@ -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 +``` diff --git a/e2e/custom-plugins/app-with-extension-point/module.js b/e2e/custom-plugins/app-with-extension-point/module.js new file mode 100644 index 00000000000..f08f08a2587 --- /dev/null +++ b/e2e/custom-plugins/app-with-extension-point/module.js @@ -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 }; +}); diff --git a/e2e/custom-plugins/app-with-extension-point/plugin.json b/e2e/custom-plugins/app-with-extension-point/plugin.json new file mode 100644 index 00000000000..b1d3c396384 --- /dev/null +++ b/e2e/custom-plugins/app-with-extension-point/plugin.json @@ -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": [] + } +} diff --git a/e2e/custom-plugins/app-with-extension-point/plugins/myorg-a-app/module.js b/e2e/custom-plugins/app-with-extension-point/plugins/myorg-a-app/module.js new file mode 100644 index 00000000000..055db7959fb --- /dev/null +++ b/e2e/custom-plugins/app-with-extension-point/plugins/myorg-a-app/module.js @@ -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 }; +}); diff --git a/e2e/custom-plugins/app-with-extension-point/plugins/myorg-a-app/plugin.json b/e2e/custom-plugins/app-with-extension-point/plugins/myorg-a-app/plugin.json new file mode 100644 index 00000000000..a37ec4e1710 --- /dev/null +++ b/e2e/custom-plugins/app-with-extension-point/plugins/myorg-a-app/plugin.json @@ -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" + } + ] + } +} diff --git a/e2e/custom-plugins/app-with-extension-point/plugins/myorg-b-app/module.js b/e2e/custom-plugins/app-with-extension-point/plugins/myorg-b-app/module.js new file mode 100644 index 00000000000..03c7468e6ae --- /dev/null +++ b/e2e/custom-plugins/app-with-extension-point/plugins/myorg-b-app/module.js @@ -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 }; +}); diff --git a/e2e/custom-plugins/app-with-extension-point/plugins/myorg-b-app/plugin.json b/e2e/custom-plugins/app-with-extension-point/plugins/myorg-b-app/plugin.json new file mode 100644 index 00000000000..ac501bc7f56 --- /dev/null +++ b/e2e/custom-plugins/app-with-extension-point/plugins/myorg-b-app/plugin.json @@ -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" + } + ] + } +} diff --git a/e2e/custom-plugins/app-with-extensions/README.md b/e2e/custom-plugins/app-with-extensions/README.md new file mode 100644 index 00000000000..62ceac9ced4 --- /dev/null +++ b/e2e/custom-plugins/app-with-extensions/README.md @@ -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 +``` diff --git a/e2e/custom-plugins/app-with-extensions/module.js b/e2e/custom-plugins/app-with-extensions/module.js new file mode 100644 index 00000000000..82469a3e211 --- /dev/null +++ b/e2e/custom-plugins/app-with-extensions/module.js @@ -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 }; +}); diff --git a/e2e/custom-plugins/app-with-extensions/plugin.json b/e2e/custom-plugins/app-with-extensions/plugin.json new file mode 100644 index 00000000000..d54f150b836 --- /dev/null +++ b/e2e/custom-plugins/app-with-extensions/plugin.json @@ -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" + } + ] +} diff --git a/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/extensionPoints.spec.ts b/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/extensionPoints.spec.ts new file mode 100644 index 00000000000..bdd51f67d7e --- /dev/null +++ b/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/extensionPoints.spec.ts @@ -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(); +}); diff --git a/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/extensions.spec.ts b/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/extensions.spec.ts new file mode 100644 index 00000000000..71ada2f4ee5 --- /dev/null +++ b/e2e/plugin-e2e/plugin-e2e-api-tests/as-admin-user/extensions.spec.ts @@ -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"'); +}); diff --git a/e2e/plugin-e2e/start-and-run-suite b/e2e/plugin-e2e/start-and-run-suite new file mode 100755 index 00000000000..3322f449345 --- /dev/null +++ b/e2e/plugin-e2e/start-and-run-suite @@ -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 diff --git a/package.json b/package.json index 78218f9eced..b999b4f1c53 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/grafana-server/start-server b/scripts/grafana-server/start-server index 75259985cc1..83e74b7508a 100755 --- a/scripts/grafana-server/start-server +++ b/scripts/grafana-server/start-server @@ -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