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>pull/92347/head
@ -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,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(); |
||||
} |
@ -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 |
@ -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 |
@ -0,0 +1 @@ |
||||
# Changelog |
@ -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', |
||||
}, |
||||
}; |
@ -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 |
@ -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; |
||||
} |
@ -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'; |
@ -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>, |
||||
}); |
@ -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'; |
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> |
||||
), |
||||
}); |
@ -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; |