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>
@ -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
|
||||
|
36
.drone.yml
@ -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
|
||||
|
||||
...
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)).
|
@ -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
|
||||
```
|
@ -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 };
|
||||
});
|
@ -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": []
|
||||
}
|
||||
}
|
@ -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,
|
||||
};
|
||||
});
|
@ -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
|
||||
```
|
@ -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 };
|
||||
});
|
@ -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": []
|
||||
}
|
@ -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 };
|
||||
});
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
@ -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 };
|
||||
});
|
@ -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
|
||||
```
|
@ -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 };
|
||||
});
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
@ -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();
|
||||
|
@ -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"');
|
||||
|
@ -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!');
|
||||
});
|
||||
|
@ -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!');
|
||||
});
|
@ -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();
|
||||
}
|
33
e2e/test-plugins/README.md
Normal 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`
|
Before Width: | Height: | Size: 100 B After Width: | Height: | Size: 100 B |
Before Width: | Height: | Size: 100 B After Width: | Height: | Size: 100 B |
Before Width: | Height: | Size: 100 B After Width: | Height: | Size: 100 B |
39
e2e/test-plugins/grafana-extensionstest-app/.gitignore
vendored
Normal 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
|
1
e2e/test-plugins/grafana-extensionstest-app/CHANGELOG.md
Normal file
@ -0,0 +1 @@
|
||||
# Changelog
|
35
e2e/test-plugins/grafana-extensionstest-app/README.md
Normal 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`
|
@ -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>
|
||||
);
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { ActionButton } from './ActionButton';
|
@ -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>
|
||||
);
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './App';
|
@ -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);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export * from './AppConfig';
|
@ -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 "something" 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>
|
||||
);
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { QueryModal } from './QueryModal';
|
@ -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',
|
||||
},
|
||||
};
|
9
e2e/test-plugins/grafana-extensionstest-app/constants.ts
Normal 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',
|
||||
}
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
86
e2e/test-plugins/grafana-extensionstest-app/module.tsx
Normal 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;
|
||||
}
|
48
e2e/test-plugins/grafana-extensionstest-app/package.json
Normal 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"
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
export { ExposedComponents } from './ExposedComponents';
|
||||
export { LegacyAPIs } from './LegacyAPIs';
|
||||
export { AddedComponents } from './AddedComponents';
|
59
e2e/test-plugins/grafana-extensionstest-app/plugin.json
Normal 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": []
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './App';
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@ -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>,
|
||||
});
|
@ -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
|
@ -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',
|
||||
},
|
||||
};
|
@ -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>;
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './App';
|
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 81.9 71.52"><defs><style>.cls-1{fill:#84aff1;}.cls-2{fill:#3865ab;}.cls-3{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" x1="42.95" y1="16.88" x2="81.9" y2="16.88" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M55.46,62.43A2,2,0,0,1,54.07,59l4.72-4.54a2,2,0,0,1,2.2-.39l3.65,1.63,3.68-3.64a2,2,0,1,1,2.81,2.84l-4.64,4.6a2,2,0,0,1-2.22.41L60.6,58.26l-3.76,3.61A2,2,0,0,1,55.46,62.43Z"/><path class="cls-2" d="M37,0H2A2,2,0,0,0,0,2V31.76a2,2,0,0,0,2,2H37a2,2,0,0,0,2-2V2A2,2,0,0,0,37,0ZM4,29.76V8.84H35V29.76Z"/><path class="cls-3" d="M79.9,0H45a2,2,0,0,0-2,2V31.76a2,2,0,0,0,2,2h35a2,2,0,0,0,2-2V2A2,2,0,0,0,79.9,0ZM47,29.76V8.84h31V29.76Z"/><path class="cls-2" d="M37,37.76H2a2,2,0,0,0-2,2V69.52a2,2,0,0,0,2,2H37a2,2,0,0,0,2-2V39.76A2,2,0,0,0,37,37.76ZM4,67.52V46.6H35V67.52Z"/><path class="cls-2" d="M79.9,37.76H45a2,2,0,0,0-2,2V69.52a2,2,0,0,0,2,2h35a2,2,0,0,0,2-2V39.76A2,2,0,0,0,79.9,37.76ZM47,67.52V46.6h31V67.52Z"/><rect class="cls-1" x="10.48" y="56.95" width="4" height="5.79"/><rect class="cls-1" x="17.43" y="53.95" width="4" height="8.79"/><rect class="cls-1" x="24.47" y="50.95" width="4" height="11.79"/><path class="cls-1" d="M19.47,25.8a6.93,6.93,0,1,1,6.93-6.92A6.93,6.93,0,0,1,19.47,25.8Zm0-9.85a2.93,2.93,0,1,0,2.93,2.93A2.93,2.93,0,0,0,19.47,16Z"/></g></g></svg>
|
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,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>
|
||||
),
|
||||
});
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
@ -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',
|
||||
},
|
||||
};
|
@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"types": ["node", "jest", "@testing-library/jest-dom"]
|
||||
},
|
||||
"extends": "@grafana/plugin-configs/tsconfig.json",
|
||||
"include": ["."]
|
||||
}
|
@ -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}`;
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import { DataQuery } from '@grafana/data';
|
||||
|
||||
export function selectQuery(target: DataQuery): void {
|
||||
alert(`You selected query "${target.refId}"`);
|
||||
}
|
@ -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;
|
@ -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": {
|
||||
|
@ -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() {
|
||||
|
@ -59,7 +59,6 @@ const config = async (env: Record<string, unknown>): Promise<Configuration> => {
|
||||
'redux',
|
||||
'rxjs',
|
||||
'react-router',
|
||||
'react-router-dom',
|
||||
'd3',
|
||||
'angular',
|
||||
'@grafana/ui',
|
||||
|
@ -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"),
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
64
yarn.lock
@ -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"
|
||||
|