E2E: migrate to playwright (#107241)
* separate playwright into its own folder * better separation * add login scenario, add tags * remove ui option * update CODEOWNERS * add a panels suite test * migrate queries test to playwright * rename + add dashlist test * add panelEdit_base * add geomap map controls test * add geomap-layer-types * add geomap-spatial-operations tests * add frontend-sandbox-panel tests * add smoke-tests-suite * add comment about adding datasource * add dashboard-browse-nested * add dashboard-browse * add dashboard-export-json * add dashboard-keybindings test * remove @wip tag * turn on screenshots and add comment for why this test fails * add dashboard-links-without-slug test * try adding permissions in the test as well * add dashboard-live-streaming * context in the test doesn't work - sad * create dashboard-public-templating * add dashboard-public-create and make live streaming more resilient * add share externally test * add dashboard-share-internally * add share-snapshot-create test * add dashboard-templating * add timepicker tests * add embedded-dashboard test * add general_dashboards test * add import-dashboard test * add load-options-from-url test * add new-constant-variable test * add custom-variable test * add new-datasource-variable test * add new-interval-variable test * add text-box-variable test * add new-query-variable test * add horizontal repeat test * add panel-vertical-repeat test * add empty-row-repeat test * add set-options-from-ui test * add snapshot-create test * add templating test * add textbox-variables test * add cloud-plugins-suite * add storybook verification tests * add playwright storybook verification workflow * add playwright browsers * update CODEOWNERS * test change to trigger storybook verification workflows * try container instead * get the version right... * go back to installing - less chance of forgetting to update * Basic Github Actions Squashed commit of the following: commitf84c650a71Author: joshhunt <josh.hunt@grafana.com> Date: Tue Jul 1 13:23:46 2025 +0100 add arg for sharding, but not using it yet commit7bcf0512c6Author: joshhunt <josh.hunt@grafana.com> Date: Tue Jul 1 12:30:30 2025 +0100 less newline commitb643911882Author: joshhunt <josh.hunt@grafana.com> Date: Tue Jul 1 12:24:31 2025 +0100 less logs commit38f871e9c2Author: joshhunt <josh.hunt@grafana.com> Date: Tue Jul 1 10:00:26 2025 +0100 fix yaml commitdb9a773136Author: joshhunt <josh.hunt@grafana.com> Date: Tue Jul 1 09:57:47 2025 +0100 clean up files commitc0525f41faAuthor: joshhunt <josh.hunt@grafana.com> Date: Tue Jul 1 09:44:56 2025 +0100 gha workflow commit895bea7c52Author: joshhunt <josh.hunt@grafana.com> Date: Mon Jun 30 19:33:08 2025 +0100 working dagger commitcea1f84437Author: joshhunt <josh.hunt@grafana.com> Date: Mon Jun 30 16:17:46 2025 +0100 wip * shard gha * some tidy up * add flags for exporting results, and a gha step to merge runs * fix shard gha * add dashboard-duplicate-panel test * add dashboard-outline test * add dashboards-add-panel * remove some commented out code * add dashboards-title-description test * add dashboards-remove-panel * don't install cypress * gha: check playwright results * add dashboards-edit-adhoc-variables test * fix check-jobs * add dagger cloud token * add dagger cloud token * add edit-datasource-variable test * update CODEOWNERS * add dashboards-edit-group-by-variables (skipped for now) * add dashboards-edit-panel-title-description test * add dashboards-edit-transparent-bg test * add dashboards-edit-query-variables test * run with 8 shards * add dashboards-edit-variables * tidy up gha * add dashboard-group-panels * fix action * try to cache the grafana build * fix missing action becuase no checkout, use builtin continue-on-error instead * fix missing id * cat out.txt * debug build cache * fix debug build cache * add dashboards-panel-layouts test * tidy up * no more debug * fix grafana dir * add dashboards-move-panel test * skip some failing tests * mark up plugins tests with @plugins tag, only run @plugins tests in drone * Hackathon/Playwright Conversion - Various Suite (#107516) * Playwright Migration: Various Suite tests * skipping bad tests * fix some tests that can fail * fix uid * separate user for the verify-i18n test * build test plugins for grafana server * properly blur input fields * login manually * get dashboardPage from goto * ignore a couple of type assertions * remove a couple of timeouts * remove timeouts on dashboard-share-internally * use toBeHidden * make dashboard-share-internally more stable * remove TEMP_DAGGER_TOKEN * clean up visaulization-suggestions * unskip gauge test * unskip trace-view-scrolling * attempt to make create variable utils stable * unskip loki tests * make go linter happy * unskip edit-group-by-variables test * unskip move panel tests * isolate dashboard-timepicker tests with separate user * create data source as part of smoke test * make sure we're awaiting in dashboard-edit-adhoc-variables * make dashboards-edit-variables test more robust * Hackathon Playwright: Dashboards Search (#107580) * Hackathon Playwright: Dashboards Search * Feedback changes * make trace-view-scrolling more stable * add json report and bench step * fix bench version * move fail step to after the playwright report so we can report test failures * fix output file name * fix typo * try wrap in expect.poll * stability * bit more tidy up * fix dashboard-new-layouts tests * move test-plugins to e2e-playwright * fix go code for drone e2e run * move loki plugin-e2e test * make v2 dashboards work again --------- Co-authored-by: joshhunt <josh.hunt@grafana.com> Co-authored-by: Josh Hunt <joshhunt@users.noreply.github.com> Co-authored-by: Collin Fingar <collin.fingar@grafana.com> Co-authored-by: Jeff Levin <jeff@levinology.com>
@@ -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`
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>
|
||||
|
After Width: | Height: | Size: 100 B |
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* This is a dummy plugin to test the frontend sandbox
|
||||
* It is not meant to be used in any other way
|
||||
* This file doesn't require any compilation
|
||||
*/
|
||||
define(['react', '@grafana/data', 'react-router-dom'], function (React, grafanaData, ReactRouterDom) {
|
||||
const { AppPlugin } = grafanaData;
|
||||
const { Switch, Route } = ReactRouterDom;
|
||||
|
||||
function PageOne() {
|
||||
return React.createElement(
|
||||
'div',
|
||||
{
|
||||
'data-testid': 'sandbox-app-test-page-one',
|
||||
},
|
||||
'This is a page one'
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return React.createElement(Switch, null, React.createElement(Route, { component: PageOne }));
|
||||
}
|
||||
function AppConfig() {
|
||||
return React.createElement(
|
||||
'div',
|
||||
{
|
||||
'data-testid': 'sandbox-app-test-config-page',
|
||||
},
|
||||
|
||||
'This is a config page'
|
||||
);
|
||||
}
|
||||
|
||||
const plugin = new AppPlugin().setRootPage(App).addConfigPage({
|
||||
title: 'Configuration',
|
||||
icon: 'cog',
|
||||
body: AppConfig,
|
||||
id: 'configuration',
|
||||
});
|
||||
return { plugin };
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/grafana/grafana/master/docs/sources/developers/plugins/plugin.schema.json",
|
||||
"type": "app",
|
||||
"name": "Sandbox app test plugin",
|
||||
"id": "sandbox-app-test",
|
||||
"info": {
|
||||
"keywords": ["app", "sandbox"],
|
||||
"description": "",
|
||||
"author": {
|
||||
"name": "Grafana"
|
||||
},
|
||||
"logos": {
|
||||
"small": "img/logo.svg",
|
||||
"large": "img/logo.svg"
|
||||
},
|
||||
"links": [],
|
||||
"screenshots": [],
|
||||
"version": "1.0.0",
|
||||
"updated": "2023-06-27"
|
||||
},
|
||||
"includes": [
|
||||
{
|
||||
"type": "page",
|
||||
"icon": "cog",
|
||||
"name": "Sandbox App Page",
|
||||
"path": "/plugins/sandbox-app-test",
|
||||
"role": "Admin",
|
||||
"addToNav": true
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=10.0",
|
||||
"plugins": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>
|
||||
|
After Width: | Height: | Size: 100 B |
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* This is a dummy plugin to test the frontend sandbox
|
||||
* It is not meant to be used in any other way
|
||||
* This file doesn't require any compilation
|
||||
*/
|
||||
define(['react', '@grafana/data'], function (React, grafanaData) {
|
||||
const { DataSourcePlugin, DataSourceApi, MutableDataFrame, FieldType } = grafanaData;
|
||||
const { useState } = React;
|
||||
|
||||
const QueryEditor = (props) => {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const handleChange = (event) => {
|
||||
setValue(event.target.value);
|
||||
props.onChange({
|
||||
...props.query,
|
||||
testValue: event.target.value,
|
||||
});
|
||||
props.onRunQuery();
|
||||
};
|
||||
|
||||
return React.createElement(
|
||||
'div',
|
||||
null,
|
||||
React.createElement('label', { htmlFor: 'inputField' }, 'Dummy input field'),
|
||||
React.createElement('input', {
|
||||
type: 'text',
|
||||
id: 'inputField',
|
||||
'data-testid': 'sandbox-query-editor-query-input',
|
||||
value: value,
|
||||
onChange: handleChange,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const ConfigEditor = (props) => {
|
||||
const { onOptionsChange, options } = props;
|
||||
const value = options.jsonData.input || '';
|
||||
|
||||
const handleChange = (event) => {
|
||||
onOptionsChange({
|
||||
...options,
|
||||
jsonData: {
|
||||
input: event.target.value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return React.createElement(
|
||||
'div',
|
||||
null,
|
||||
React.createElement('label', { htmlFor: 'inputField' }, 'Test Config field'),
|
||||
React.createElement('input', {
|
||||
type: 'text',
|
||||
id: 'inputField',
|
||||
'data-testid': 'sandbox-config-editor-query-input',
|
||||
value: value,
|
||||
onChange: handleChange,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
class BasicDataSource extends DataSourceApi {
|
||||
constructor(instanceSettings) {
|
||||
super(instanceSettings);
|
||||
}
|
||||
|
||||
// this is a test query it'll generate a logarithmic series starting now-1h
|
||||
async query(options) {
|
||||
const promises = options.targets.map(async (target) => {
|
||||
const query = target;
|
||||
|
||||
const timestamps = [];
|
||||
const values = [];
|
||||
|
||||
const endTime = Date.now();
|
||||
const startTime = endTime - 3600000; // 1 hour in milliseconds
|
||||
|
||||
// Define the logarithmic base and increment factor
|
||||
const base = 2;
|
||||
const increment = 0.1;
|
||||
|
||||
// Generate the data points
|
||||
for (let i = 0; i < 50; i++) {
|
||||
// Calculate the timestamp
|
||||
const timestamp = startTime + ((endTime - startTime) / 50) * i;
|
||||
timestamps.push(timestamp);
|
||||
|
||||
// Calculate the value using logarithmic increase
|
||||
const value = Math.pow(base, increment * i);
|
||||
values.push(value);
|
||||
}
|
||||
|
||||
return new MutableDataFrame({
|
||||
refId: query.refId,
|
||||
fields: [
|
||||
{ name: 'Time', type: FieldType.time, values: timestamps },
|
||||
{ name: 'Value', type: FieldType.number, values: values },
|
||||
],
|
||||
});
|
||||
});
|
||||
return Promise.all(promises).then((data) => ({ data }));
|
||||
}
|
||||
async testDatasource() {
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'Sandbox Success',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const plugin = new DataSourcePlugin(BasicDataSource).setConfigEditor(ConfigEditor).setQueryEditor(QueryEditor);
|
||||
|
||||
return { plugin };
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/grafana/grafana/master/docs/sources/developers/plugins/plugin.schema.json",
|
||||
"type": "datasource",
|
||||
"name": "Sandbox datasource test plugin",
|
||||
"id": "sandbox-test-datasource",
|
||||
"metrics": true,
|
||||
"info": {
|
||||
"keywords": ["explore", "datasource"],
|
||||
"description": "",
|
||||
"author": {
|
||||
"name": "Grafana"
|
||||
},
|
||||
"logos": {
|
||||
"small": "img/logo.svg",
|
||||
"large": "img/logo.svg"
|
||||
},
|
||||
"links": [],
|
||||
"screenshots": [],
|
||||
"version": "1.0.0",
|
||||
"updated": "2023-06-27"
|
||||
},
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=10.0",
|
||||
"plugins": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>
|
||||
|
After Width: | Height: | Size: 100 B |
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* This is a dummy plugin to test the frontend sandbox
|
||||
* It is not meant to be used in any other way
|
||||
* This file doesn't require any compilation
|
||||
*/
|
||||
define(['react', '@grafana/data'], function (React, grafanaData) {
|
||||
// This would be a custom editor component
|
||||
function Editor() {
|
||||
const onChangeInternal = (event) => {
|
||||
const outsideEl =
|
||||
event.target.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement
|
||||
.parentElement.parentElement;
|
||||
outsideEl.dataset.sandboxTest = 'panel-editor';
|
||||
};
|
||||
return React.createElement('input', {
|
||||
type: 'text',
|
||||
onChange: onChangeInternal,
|
||||
'data-testid': 'panel-editor-custom-editor-input',
|
||||
});
|
||||
}
|
||||
|
||||
const PanelComponent = () => {
|
||||
const [testedGlobals, setTestedGlobals] = React.useState([]);
|
||||
const createIframe = () => {
|
||||
// direct iframe creation
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = 'about:blank';
|
||||
iframe.id = 'createElementIframe';
|
||||
iframe.style.width = '10%';
|
||||
iframe.style.height = '10%';
|
||||
iframe.style.border = 'none';
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
// via innerHTML
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
div.innerHTML =
|
||||
'<iframe src="about:blank" id="innerHTMLIframe" style="width: 10%; height: 10%; border: none;"></iframe>';
|
||||
|
||||
// via append
|
||||
const appendIframe = document.createElement('iframe');
|
||||
appendIframe.src = 'about:blank';
|
||||
appendIframe.id = 'appendIframe';
|
||||
appendIframe.style.width = '10%';
|
||||
appendIframe.style.height = '10%';
|
||||
appendIframe.style.border = 'none';
|
||||
document.body.append(appendIframe);
|
||||
|
||||
// via prepend
|
||||
const prependIframe = document.createElement('iframe');
|
||||
prependIframe.src = 'about:blank';
|
||||
prependIframe.id = 'prependIframe';
|
||||
prependIframe.style.width = '10%';
|
||||
prependIframe.style.height = '10%';
|
||||
prependIframe.style.border = 'none';
|
||||
document.body.prepend(prependIframe);
|
||||
|
||||
// via after
|
||||
const referenceElementAfter = document.createElement('div');
|
||||
document.body.appendChild(referenceElementAfter);
|
||||
const afterIframe = document.createElement('iframe');
|
||||
afterIframe.src = 'about:blank';
|
||||
afterIframe.id = 'afterIframe';
|
||||
afterIframe.style.width = '10%';
|
||||
afterIframe.style.height = '10%';
|
||||
afterIframe.style.border = 'none';
|
||||
referenceElementAfter.after(afterIframe);
|
||||
|
||||
// via before
|
||||
const referenceElementBefore = document.createElement('div');
|
||||
document.body.appendChild(referenceElementBefore);
|
||||
const beforeIframe = document.createElement('iframe');
|
||||
beforeIframe.src = 'about:blank';
|
||||
beforeIframe.id = 'beforeIframe';
|
||||
beforeIframe.style.width = '10%';
|
||||
beforeIframe.style.height = '10%';
|
||||
beforeIframe.style.border = 'none';
|
||||
referenceElementBefore.before(beforeIframe);
|
||||
|
||||
// via outerHTML
|
||||
const outerHTMLIframe = document.createElement('iframe');
|
||||
outerHTMLIframe.src = 'about:blank';
|
||||
outerHTMLIframe.id = 'outerHTMLIframeTemp';
|
||||
outerHTMLIframe.style.width = '10%';
|
||||
outerHTMLIframe.style.height = '10%';
|
||||
outerHTMLIframe.style.border = 'none';
|
||||
document.body.appendChild(outerHTMLIframe);
|
||||
outerHTMLIframe.outerHTML =
|
||||
'<iframe src="about:blank" id="outerHTMLIframe" style="width: 10%; height: 10%; border: none;"></iframe>';
|
||||
|
||||
// via parseFromString
|
||||
const iframeString =
|
||||
'<iframe src="about:blank" id="parseFromStringIframe" style="width: 10%; height: 10%; border: none;"></iframe>';
|
||||
const parser = new DOMParser();
|
||||
const parsedDoc = parser.parseFromString(iframeString, 'text/html');
|
||||
document.body.appendChild(parsedDoc.body.firstChild);
|
||||
|
||||
// via insertBefore
|
||||
const referenceForInsertBefore = document.createElement('div');
|
||||
document.body.appendChild(referenceForInsertBefore);
|
||||
const insertBeforeIframe = document.createElement('iframe');
|
||||
insertBeforeIframe.src = 'about:blank';
|
||||
insertBeforeIframe.id = 'insertBeforeIframe';
|
||||
insertBeforeIframe.style.width = '10%';
|
||||
insertBeforeIframe.style.height = '10%';
|
||||
insertBeforeIframe.style.border = 'none';
|
||||
document.body.insertBefore(insertBeforeIframe, referenceForInsertBefore);
|
||||
|
||||
// via replaceChild
|
||||
const replaceChildDiv = document.createElement('div');
|
||||
document.body.appendChild(replaceChildDiv);
|
||||
const replaceChildIframe = document.createElement('iframe');
|
||||
replaceChildIframe.src = 'about:blank';
|
||||
replaceChildIframe.id = 'replaceChildIframe';
|
||||
replaceChildIframe.style.width = '10%';
|
||||
replaceChildIframe.style.height = '10%';
|
||||
replaceChildIframe.style.border = 'none';
|
||||
document.body.replaceChild(replaceChildIframe, replaceChildDiv);
|
||||
};
|
||||
|
||||
const reachOut = (e) => {
|
||||
const outsideEl = e.target.parentElement.parentElement.parentElement.parentElement.parentElement;
|
||||
outsideEl.dataset.sandboxTest = 'true';
|
||||
};
|
||||
|
||||
// a function for each global we want to test
|
||||
const globalTests = [
|
||||
function () {
|
||||
try {
|
||||
console.log(window.Prism.languages);
|
||||
return 'Prism';
|
||||
} catch (e) {}
|
||||
},
|
||||
function () {
|
||||
try {
|
||||
console.log(window.jQuery.fn.jquery);
|
||||
console.log(window.$.fn.jquery);
|
||||
return 'jQuery';
|
||||
} catch (e) {}
|
||||
},
|
||||
function () {
|
||||
try {
|
||||
console.log(window.locationSandbox);
|
||||
return 'location';
|
||||
} catch (e) {}
|
||||
},
|
||||
];
|
||||
|
||||
// it'll assign a variable with one attribute of the Globals
|
||||
// and create a new element with the name of the global.
|
||||
const testGlobals = () => {
|
||||
const results = globalTests.map((test) => test());
|
||||
setTestedGlobals(results);
|
||||
};
|
||||
|
||||
const elements = testedGlobals.map((item) => {
|
||||
const attributes = { 'data-sandbox-global': `${item}` };
|
||||
return React.createElement('div', attributes, item);
|
||||
});
|
||||
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ className: 'frontend-sandbox-test' },
|
||||
React.createElement(
|
||||
'button',
|
||||
{ onClick: createIframe, 'data-testid': 'button-create-iframes' },
|
||||
'Create iframes'
|
||||
),
|
||||
React.createElement('button', { onClick: reachOut, 'data-testid': 'button-reach-out' }, 'Reach out'),
|
||||
React.createElement('button', { onClick: testGlobals, 'data-testid': 'button-test-globals' }, 'Test Globals'),
|
||||
React.createElement('div', null, elements)
|
||||
);
|
||||
};
|
||||
|
||||
const plugin = new grafanaData.PanelPlugin(PanelComponent);
|
||||
|
||||
plugin.setPanelOptions((builder) => {
|
||||
builder.addCustomEditor({
|
||||
id: 'buttons',
|
||||
path: 'buttons',
|
||||
name: 'Button Configuration',
|
||||
defaultValue: [{ text: '', datasource: '', query: '' }],
|
||||
editor: (props) => React.createElement(Editor, { onChange: props.onChange }),
|
||||
});
|
||||
});
|
||||
|
||||
return { plugin };
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/grafana/grafana/master/docs/sources/developers/plugins/plugin.schema.json",
|
||||
"type": "panel",
|
||||
"name": "Sandbox test plugin",
|
||||
"id": "sandbox-test-panel",
|
||||
"info": {
|
||||
"keywords": ["panel"],
|
||||
"description": "",
|
||||
"author": {
|
||||
"name": "Grafana"
|
||||
},
|
||||
"logos": {
|
||||
"small": "img/logo.svg",
|
||||
"large": "img/logo.svg"
|
||||
},
|
||||
"links": [],
|
||||
"screenshots": [],
|
||||
"version": "1.0.0",
|
||||
"updated": "2023-06-27"
|
||||
},
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=10.0",
|
||||
"plugins": []
|
||||
}
|
||||
}
|
||||
@@ -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 playwright test --project extensions-test-app`
|
||||
@@ -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,21 @@
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { AppRootProps } from '@grafana/data';
|
||||
|
||||
import { ROUTES } from '../../constants';
|
||||
import { AddedComponents, AddedLinks, ExposedComponents } from '../../pages';
|
||||
import { testIds } from '../../testIds';
|
||||
|
||||
export function App(props: AppRootProps) {
|
||||
return (
|
||||
<div data-testid={testIds.container} style={{ marginTop: '5%' }}>
|
||||
<Routes>
|
||||
<Route path={ROUTES.ExposedComponents} element={<ExposedComponents />} />
|
||||
<Route path={ROUTES.AddedComponents} element={<AddedComponents />} />
|
||||
<Route path={ROUTES.AddedLinks} element={<AddedLinks />} />
|
||||
|
||||
<Route path={'*'} element={<ExposedComponents />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './App';
|
||||
@@ -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,11 @@
|
||||
import pluginJson from './plugin.json';
|
||||
|
||||
export const PLUGIN_BASE_URL = `/a/${pluginJson.id}`;
|
||||
|
||||
export enum ROUTES {
|
||||
LegacyGetters = 'legacy-getters',
|
||||
LegacyHooks = 'legacy-hooks',
|
||||
ExposedComponents = 'exposed-components',
|
||||
AddedComponents = 'added-components',
|
||||
AddedLinks = 'added-links',
|
||||
}
|
||||
@@ -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,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)
|
||||
.addLink<PluginExtensionPanelContext>({
|
||||
title: 'Open from time series or pie charts (path)',
|
||||
description: 'This link will only be visible on time series and pie charts',
|
||||
targets: 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;
|
||||
}
|
||||
},
|
||||
})
|
||||
.addLink<PluginExtensionPanelContext>({
|
||||
title: 'Open from time series or pie charts (onClick)',
|
||||
description: 'This link will only be visible on time series and pie charts',
|
||||
targets: 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,46 @@
|
||||
{
|
||||
"name": "@test-plugins/extensions-test-app",
|
||||
"version": "12.1.0-pre",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production",
|
||||
"dev": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' 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/plugin-configs": "workspace:*",
|
||||
"@types/lodash": "4.17.7",
|
||||
"@types/node": "22.15.0",
|
||||
"@types/prismjs": "1.26.4",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@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.95.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.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"rxjs": "7.8.1",
|
||||
"tslib": "2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@grafana/runtime": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { PluginPage, usePluginComponents } from '@grafana/runtime';
|
||||
import { Stack } from '@grafana/ui';
|
||||
|
||||
import { testIds } from '../testIds';
|
||||
|
||||
type ReusableComponentProps = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function AddedComponents() {
|
||||
const { components } = usePluginComponents<ReusableComponentProps>({
|
||||
extensionPointId: 'plugins/grafana-extensionstest-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,22 @@
|
||||
import { PluginPage, usePluginLinks } from '@grafana/runtime';
|
||||
import { Stack } from '@grafana/ui';
|
||||
|
||||
import { ActionButton } from '../components/ActionButton';
|
||||
import { testIds } from '../testIds';
|
||||
|
||||
export const LINKS_EXTENSION_POINT_ID = 'plugins/grafana-extensionstest-app/use-plugin-links/v1';
|
||||
|
||||
export function AddedLinks() {
|
||||
const { links } = usePluginLinks({ extensionPointId: LINKS_EXTENSION_POINT_ID });
|
||||
|
||||
return (
|
||||
<PluginPage>
|
||||
<Stack direction={'column'} gap={4} data-testid={testIds.addedLinksPage.container}>
|
||||
<section data-testid={testIds.addedLinksPage.section1}>
|
||||
<h3>Link extensions defined with addLink and retrieved using usePluginLinks</h3>
|
||||
<ActionButton extensions={links} />
|
||||
</section>
|
||||
</Stack>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { PluginPage, usePluginComponent } from '@grafana/runtime';
|
||||
|
||||
import { testIds } from '../testIds';
|
||||
|
||||
type ReusableComponentProps = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function ExposedComponents() {
|
||||
const { component: ReusableComponent } = usePluginComponent<ReusableComponentProps>(
|
||||
'grafana-extensionexample1-app/reusable-component/v1'
|
||||
);
|
||||
|
||||
if (!ReusableComponent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PluginPage>
|
||||
<div data-testid={testIds.exposedComponentsPage.container}>
|
||||
<ReusableComponent name={'World'} />
|
||||
</div>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ExposedComponents } from './ExposedComponents';
|
||||
export { AddedComponents } from './AddedComponents';
|
||||
export { AddedLinks } from './AddedLinks';
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$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": "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",
|
||||
"name": "Added links",
|
||||
"path": "/a/grafana-extensionstest-app/added-links",
|
||||
"role": "Admin",
|
||||
"addToNav": true,
|
||||
"defaultNav": false
|
||||
}
|
||||
],
|
||||
"extensions": {
|
||||
"addedLinks": [
|
||||
{
|
||||
"targets": ["grafana/dashboard/panel/menu"],
|
||||
"title": "Open from time series or pie charts (path)",
|
||||
"description": "This link will only be visible on time series and pie charts"
|
||||
},
|
||||
{
|
||||
"targets": ["grafana/dashboard/panel/menu"],
|
||||
"title": "Open from time series or pie charts (onClick)",
|
||||
"description": "This link will only be visible on time series and pie charts"
|
||||
}
|
||||
],
|
||||
"extensionPoints": [
|
||||
{
|
||||
"id": "plugins/grafana-extensionstest-app/use-plugin-links/v1",
|
||||
"title": "Extension point - links"
|
||||
},
|
||||
{
|
||||
"id": "plugins/grafana-extensionstest-app/addComponent/v1",
|
||||
"title": "Extension point - components"
|
||||
},
|
||||
{
|
||||
"id": "plugins/grafana-extensionstest-app/actions",
|
||||
"title": "Legacy extension point - usePluginExtensions() and usePluginLinkExtensions()"
|
||||
},
|
||||
{
|
||||
"id": "plugins/grafana-extensionstest-app/configure-extension-component/v1",
|
||||
"title": "Legacy extension point - usePluginComponentExtensions()"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=10.4.0",
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
"exposedComponents": ["grafana-extensionexample1-app/reusable-component/v1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,27 @@
|
||||
import { AppPlugin } from '@grafana/data';
|
||||
|
||||
import { LINKS_EXTENSION_POINT_ID } from '../../pages/AddedLinks';
|
||||
import { testIds } from '../../testIds';
|
||||
|
||||
import { App } from './components/App';
|
||||
|
||||
export const plugin = new AppPlugin<{}>()
|
||||
.setRootPage(App)
|
||||
.exposeComponent({
|
||||
id: 'grafana-extensionexample1-app/reusable-component/v1',
|
||||
title: 'Exposed component',
|
||||
description: 'A component that can be reused by other app plugins.',
|
||||
component: ({ name }: { name: string }) => <div data-testid={testIds.appB.exposedComponent}>Hello {name}!</div>,
|
||||
})
|
||||
.addLink({
|
||||
title: 'Basic link',
|
||||
description: '...',
|
||||
targets: [LINKS_EXTENSION_POINT_ID],
|
||||
path: '/a/grafana-extensionexample1-app/',
|
||||
})
|
||||
.addLink({
|
||||
title: 'Go to A',
|
||||
description: 'Navigating to pluging A',
|
||||
targets: [LINKS_EXTENSION_POINT_ID],
|
||||
path: '/a/grafana-extensionexample1-app/',
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
|
||||
"type": "app",
|
||||
"name": "C App",
|
||||
"id": "grafana-extensionexample1-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/grafana-extensionexample1-app",
|
||||
"role": "Admin",
|
||||
"addToNav": false,
|
||||
"defaultNav": false
|
||||
}
|
||||
],
|
||||
"extensions": {
|
||||
"exposedComponents": [
|
||||
{
|
||||
"id": "grafana-extensionexample1-app/reusable-component/v1",
|
||||
"title": "Exposed component",
|
||||
"description": "A component that can be reused by other app plugins."
|
||||
}
|
||||
],
|
||||
"addedLinks": [
|
||||
{
|
||||
"targets": [
|
||||
"plugins/grafana-extensionstest-app/actions",
|
||||
"plugins/grafana-extensionstest-app/use-plugin-links/v1"
|
||||
],
|
||||
"title": "Go to A",
|
||||
"description": "Navigating to pluging A"
|
||||
},
|
||||
{
|
||||
"targets": ["plugins/grafana-extensionstest-app/use-plugin-links/v1"],
|
||||
"title": "Basic link",
|
||||
"description": "..."
|
||||
}
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=10.3.3",
|
||||
"plugins": []
|
||||
}
|
||||
}
|
||||
@@ -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,28 @@
|
||||
import { AppPlugin } from '@grafana/data';
|
||||
|
||||
import { LINKS_EXTENSION_POINT_ID } from '../../pages/AddedLinks';
|
||||
import { testIds } from '../../testIds';
|
||||
|
||||
import { App } from './components/App';
|
||||
|
||||
export const plugin = new AppPlugin<{}>()
|
||||
.setRootPage(App)
|
||||
.addComponent<{ name: string }>({
|
||||
targets: 'plugins/grafana-extensionstest-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>
|
||||
),
|
||||
})
|
||||
.addLink({
|
||||
title: 'Open from B',
|
||||
description: 'Open a modal from plugin B',
|
||||
targets: [LINKS_EXTENSION_POINT_ID],
|
||||
onClick: (_, { openModal }) => {
|
||||
openModal({
|
||||
title: 'Modal from app B',
|
||||
body: () => <div data-testid={testIds.appB.modal}>From plugin B</div>,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
|
||||
"type": "app",
|
||||
"name": "D App",
|
||||
"id": "grafana-extensionexample2-app",
|
||||
"preload": true,
|
||||
"info": {
|
||||
"keywords": ["app"],
|
||||
"description": "Will extend root app with ui extensions",
|
||||
"author": {
|
||||
"name": "grafana"
|
||||
},
|
||||
"logos": {
|
||||
"small": "img/logo.svg",
|
||||
"large": "img/logo.svg"
|
||||
},
|
||||
"screenshots": [],
|
||||
"version": "%VERSION%",
|
||||
"updated": "%TODAY%"
|
||||
},
|
||||
"extensions": {
|
||||
"addedLinks": [
|
||||
{
|
||||
"targets": [
|
||||
"plugins/grafana-extensionstest-app/actions",
|
||||
"plugins/grafana-extensionstest-app/use-plugin-links/v1"
|
||||
],
|
||||
"title": "Open from B",
|
||||
"description": "Open a modal from plugin B"
|
||||
}
|
||||
],
|
||||
"addedComponents": [
|
||||
{
|
||||
"targets": ["plugins/grafana-extensionstest-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"
|
||||
},
|
||||
{
|
||||
"targets": ["plugins/grafana-extensionstest-app/addComponent/v1"],
|
||||
"title": "Added component from B",
|
||||
"description": "A component that can be reused by other app plugins. Shared using addComponent api"
|
||||
}
|
||||
]
|
||||
},
|
||||
"includes": [
|
||||
{
|
||||
"type": "page",
|
||||
"name": "Default",
|
||||
"path": "/a/grafana-extensionexample2-app",
|
||||
"role": "Admin",
|
||||
"addToNav": false,
|
||||
"defaultNav": false
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=10.3.3",
|
||||
"plugins": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { usePluginLinks } from '@grafana/runtime';
|
||||
import { Stack } from '@grafana/ui';
|
||||
import { testIds } from '../../../../testIds';
|
||||
import { ActionButton } from '../../../../components/ActionButton';
|
||||
|
||||
export const LINKS_EXTENSION_POINT_ID = 'plugins/grafana-extensionstest-app/use-plugin-links/v1';
|
||||
|
||||
export function AddedLinks() {
|
||||
const { links } = usePluginLinks({ extensionPointId: LINKS_EXTENSION_POINT_ID });
|
||||
return (
|
||||
<Stack direction={'column'} gap={4}>
|
||||
<section data-testid={testIds.appC.section1}>
|
||||
<h3>Link extensions defined with addLink and retrieved using usePluginLinks</h3>
|
||||
<ActionButton extensions={links} />
|
||||
</section>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { AppRootProps } from '@grafana/data';
|
||||
import { AddedLinks } from './AddedLinks';
|
||||
import { testIds } from '../../../../testIds';
|
||||
|
||||
export class App extends React.PureComponent<AppRootProps> {
|
||||
render() {
|
||||
return (
|
||||
<div data-testid={testIds.appC.container} className="page-container">
|
||||
Hello Grafana!
|
||||
<AddedLinks></AddedLinks>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,27 @@
|
||||
import { AppPlugin } from '@grafana/data';
|
||||
|
||||
import { LINKS_EXTENSION_POINT_ID } from '../../pages/AddedLinks';
|
||||
import { testIds } from '../../testIds';
|
||||
import { App } from '../../components/App';
|
||||
|
||||
export const plugin = new AppPlugin<{}>()
|
||||
.setRootPage(App)
|
||||
.addComponent<{ name: string }>({
|
||||
targets: ['plugins/grafana-extensionstest-app/addComponent/v1'],
|
||||
title: 'Added component (where meta data is missing)',
|
||||
description: '.',
|
||||
component: ({ name }: { name: string }) => (
|
||||
<div data-testid={testIds.appB.reusableAddedComponent}>Hello {name}!</div>
|
||||
),
|
||||
})
|
||||
.addLink({
|
||||
title: 'Added link (where meta data is missing)',
|
||||
description: '.',
|
||||
targets: [LINKS_EXTENSION_POINT_ID],
|
||||
onClick: (_, { openModal }) => {
|
||||
openModal({
|
||||
title: 'Modal from app C',
|
||||
body: () => <div data-testid={testIds.appB.modal}>From plugin B</div>,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
|
||||
"type": "app",
|
||||
"name": "D App",
|
||||
"id": "grafana-extensionexample3-app",
|
||||
"preload": true,
|
||||
"info": {
|
||||
"keywords": ["app"],
|
||||
"description": "Will extend root app with ui extensions",
|
||||
"author": {
|
||||
"name": "grafana"
|
||||
},
|
||||
"logos": {
|
||||
"small": "img/logo.svg",
|
||||
"large": "img/logo.svg"
|
||||
},
|
||||
"screenshots": [],
|
||||
"version": "%VERSION%",
|
||||
"updated": "%TODAY%"
|
||||
},
|
||||
"includes": [
|
||||
{
|
||||
"type": "page",
|
||||
"name": "Default",
|
||||
"path": "/a/grafana-extensionexample3-app",
|
||||
"role": "Admin",
|
||||
"addToNav": false,
|
||||
"defaultNav": false
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=10.3.3",
|
||||
"plugins": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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',
|
||||
exposedComponent: 'b-app-exposed-component',
|
||||
},
|
||||
appC: {
|
||||
container: 'c-app-body',
|
||||
section1: 'use-plugin-links',
|
||||
section2: 'use-plugin-extensions',
|
||||
},
|
||||
legacyGettersPage: {
|
||||
container: 'data-testid pg-legacy-getters-container',
|
||||
section1: 'get-plugin-extensions',
|
||||
section2: 'configure-extension-link-get-plugin-link-extensions',
|
||||
section3: 'configure-extension-component-get-plugin-component-extensions',
|
||||
},
|
||||
legacyHooksPage: {
|
||||
container: 'data-testid pg-legacy-hooks-container',
|
||||
section1: 'use-plugin-extensions',
|
||||
section2: 'configure-extension-link-use-plugin-link-extensions',
|
||||
section3: 'configure-extension-component-use-plugin-component-extensions',
|
||||
},
|
||||
exposedComponentsPage: {
|
||||
container: 'data-testid pg-exposed-components-container',
|
||||
},
|
||||
addedComponentsPage: {
|
||||
container: 'data-testid pg-added-components-container',
|
||||
},
|
||||
addedLinksPage: {
|
||||
container: 'data-testid pg-added-links-container',
|
||||
section1: 'use-plugin-links',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
import { testIds } from '../testIds';
|
||||
import pluginJson from '../plugin.json';
|
||||
|
||||
test.describe(
|
||||
'grafana-extensionstest-app',
|
||||
{
|
||||
tag: ['@plugins'],
|
||||
},
|
||||
() => {
|
||||
test('should display component exposed by another app', async ({ page }) => {
|
||||
await page.goto(`/a/${pluginJson.id}/exposed-components`);
|
||||
await expect(page.getByTestId(testIds.appB.exposedComponent)).toHaveText('Hello World!');
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
import pluginJson from '../plugin.json';
|
||||
import { testIds } from '../testIds';
|
||||
|
||||
test.describe(
|
||||
'grafana-extensionstest-app',
|
||||
{
|
||||
tag: ['@plugins'],
|
||||
},
|
||||
() => {
|
||||
test('should render component with usePluginComponents hook', async ({ page }) => {
|
||||
await page.goto(`/a/${pluginJson.id}/added-components`);
|
||||
await expect(
|
||||
page.getByTestId(testIds.addedComponentsPage.container).getByTestId(testIds.appB.reusableAddedComponent)
|
||||
).toHaveText('Hello World!');
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,76 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
import pluginJson from '../plugin.json';
|
||||
import testApp3pluginJson from '../plugins/grafana-extensionexample3-app/plugin.json';
|
||||
import { testIds } from '../testIds';
|
||||
|
||||
test.describe('grafana-extensionstest-app', { tag: ['@plugins'] }, () => {
|
||||
test('should extend the actions menu with a link to a-app plugin', async ({ page }) => {
|
||||
await page.goto(`/a/${pluginJson.id}/added-links`);
|
||||
const section = await page.getByTestId(testIds.addedLinksPage.section1);
|
||||
await section.getByTestId(testIds.actions.button).click();
|
||||
await page.getByTestId(testIds.container).getByText('Go to A').click();
|
||||
await page.getByTestId(testIds.modal.open).click();
|
||||
await expect(page.getByTestId(testIds.appA.container)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should extend main app with link extension from app B', async ({ page }) => {
|
||||
await page.goto(`/a/${pluginJson.id}/added-links`);
|
||||
const section = await page.getByTestId(testIds.addedLinksPage.section1);
|
||||
await section.getByTestId(testIds.actions.button).click();
|
||||
await page.getByTestId(testIds.container).getByText('Open from B').click();
|
||||
await expect(page.getByTestId(testIds.appB.modal)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should extend main app with basic link extension from app A', async ({ page }) => {
|
||||
await page.goto(`/a/${pluginJson.id}/added-links`);
|
||||
const section = await page.getByTestId(testIds.addedLinksPage.section1);
|
||||
await section.getByTestId(testIds.actions.button).click();
|
||||
await page.getByTestId(testIds.container).getByText('Basic link').click();
|
||||
await page.getByTestId(testIds.modal.open).click();
|
||||
await expect(page.getByTestId(testIds.appA.container)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not display any extensions when extension point is not declared in plugin json when in development mode', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/a/${testApp3pluginJson.id}`);
|
||||
const container = await page.getByTestId(testIds.appC.section1);
|
||||
await expect(container.getByTestId(testIds.actions.button)).not.toBeVisible();
|
||||
});
|
||||
|
||||
const panelTitle = 'Link with defaults';
|
||||
const extensionTitle = 'Open from time series...';
|
||||
|
||||
const linkOnClickDashboardUid = 'dbfb47c5-e5e5-4d28-8ac7-35f349b95946';
|
||||
const linkPathDashboardUid = 'd1fbb077-cd44-4738-8c8a-d4e66748b719';
|
||||
|
||||
test('configureExtensionLink - should add link extension (path) with defaults to time series panel.', async ({
|
||||
gotoDashboardPage,
|
||||
page,
|
||||
}) => {
|
||||
const dashboardPage = await gotoDashboardPage({ uid: linkPathDashboardUid });
|
||||
const panel = await dashboardPage.getPanelByTitle(panelTitle);
|
||||
await panel.clickOnMenuItem(extensionTitle, { parentItem: 'Extensions' });
|
||||
await expect(page.getByRole('heading', { name: 'Extensions test app' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should add link extension (onclick) with defaults to time series panel', async ({
|
||||
gotoDashboardPage,
|
||||
page,
|
||||
}) => {
|
||||
const dashboardPage = await gotoDashboardPage({ uid: linkOnClickDashboardUid });
|
||||
const panel = await dashboardPage.getPanelByTitle(panelTitle);
|
||||
await panel.clickOnMenuItem(extensionTitle, { parentItem: 'Extensions' });
|
||||
await expect(page.getByRole('dialog')).toContainText('Select query from "Link with defaults"');
|
||||
});
|
||||
|
||||
test('should add link extension (onclick) with new title to pie chart panel', async ({ gotoDashboardPage, page }) => {
|
||||
const panelTitle = 'Link with new name';
|
||||
const extensionTitle = 'Open from piechart';
|
||||
const dashboardPage = await gotoDashboardPage({ uid: linkOnClickDashboardUid });
|
||||
const panel = await dashboardPage.getPanelByTitle(panelTitle);
|
||||
await panel.clickOnMenuItem(extensionTitle, { parentItem: 'Extensions' });
|
||||
await expect(page.getByRole('dialog')).toContainText('Select query from "Link with new name"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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,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, { type Env } from '@grafana/plugin-configs/webpack.config.ts';
|
||||
import { mergeWithCustomize, unique } from 'webpack-merge';
|
||||
import { type 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: Env): 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;
|
||||
@@ -0,0 +1 @@
|
||||
# Changelog
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ChangeEvent } from 'react';
|
||||
import { Checkbox, InlineField, InlineSwitch, Input, SecretInput, Select } from '@grafana/ui';
|
||||
import { DataSourcePluginOptionsEditorProps, SelectableValue, toOption } from '@grafana/data';
|
||||
import { MyDataSourceOptions, MySecureJsonData } from '../types';
|
||||
|
||||
interface Props extends DataSourcePluginOptionsEditorProps<MyDataSourceOptions, MySecureJsonData> {}
|
||||
|
||||
export function ConfigEditor(props: Props) {
|
||||
const { onOptionsChange, options } = props;
|
||||
const { jsonData, secureJsonFields, secureJsonData } = options;
|
||||
|
||||
const onJsonDataChange = (key: string, value: string | number | boolean) => {
|
||||
onOptionsChange({
|
||||
...options,
|
||||
jsonData: {
|
||||
...jsonData,
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Secure field (only sent to the backend)
|
||||
const onSecureJsonDataChange = (key: string, value: string | number) => {
|
||||
onOptionsChange({
|
||||
...options,
|
||||
secureJsonData: {
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onResetAPIKey = () => {
|
||||
onOptionsChange({
|
||||
...options,
|
||||
secureJsonFields: {
|
||||
...options.secureJsonFields,
|
||||
apiKey: false,
|
||||
},
|
||||
secureJsonData: {
|
||||
...options.secureJsonData,
|
||||
apiKey: '',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<InlineField label="Path" labelWidth={14} interactive tooltip={'Json field returned to frontend'}>
|
||||
<Input
|
||||
id="config-editor-path"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onJsonDataChange('path', e.target.value)}
|
||||
value={jsonData.path}
|
||||
placeholder="Enter the path, e.g. /api/v1"
|
||||
width={40}
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField label="API Key" labelWidth={14} interactive tooltip={'Secure json field (backend only)'}>
|
||||
<SecretInput
|
||||
required
|
||||
id="config-editor-api-key"
|
||||
isConfigured={secureJsonFields.apiKey}
|
||||
value={secureJsonData?.apiKey}
|
||||
placeholder="Enter your API key"
|
||||
width={40}
|
||||
onReset={onResetAPIKey}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onSecureJsonDataChange('path', e.target.value)}
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField label="Switch Enabled">
|
||||
<InlineSwitch
|
||||
width={40}
|
||||
label="Switch Enabled"
|
||||
value={jsonData.switchEnabled ?? false}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onJsonDataChange('switchEnabled', e.target.checked)}
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField label="Checkbox Enabled">
|
||||
<Checkbox
|
||||
width={40}
|
||||
id="config-checkbox-enabled"
|
||||
value={jsonData.checkboxEnabled}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onJsonDataChange('checkboxEnabled', e.target.checked)}
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField label="Auth type">
|
||||
<Select
|
||||
width={40}
|
||||
inputId="config-auth-type"
|
||||
value={jsonData.authType ?? 'keys'}
|
||||
options={['keys', 'credentials'].map(toOption)}
|
||||
onChange={(e: SelectableValue<string>) => onJsonDataChange('authType', e.value!)}
|
||||
/>
|
||||
</InlineField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ChangeEvent } from 'react';
|
||||
import { InlineField, Input, Stack } from '@grafana/ui';
|
||||
import { QueryEditorProps } from '@grafana/data';
|
||||
import { DataSource } from '../datasource';
|
||||
import { MyDataSourceOptions, MyQuery } from '../types';
|
||||
|
||||
type Props = QueryEditorProps<DataSource, MyQuery, MyDataSourceOptions>;
|
||||
|
||||
export function QueryEditor({ query, onChange, onRunQuery }: Props) {
|
||||
const onQueryTextChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange({ ...query, queryText: event.target.value });
|
||||
};
|
||||
|
||||
const onConstantChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange({ ...query, constant: parseFloat(event.target.value) });
|
||||
// executes the query
|
||||
onRunQuery();
|
||||
};
|
||||
|
||||
const { queryText, constant } = query;
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<InlineField label="Constant">
|
||||
<Input
|
||||
id="query-editor-constant"
|
||||
onChange={onConstantChange}
|
||||
value={constant}
|
||||
width={8}
|
||||
type="number"
|
||||
step="0.1"
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField label="Query Text" labelWidth={16} tooltip="Not used yet">
|
||||
<Input
|
||||
id="query-editor-query-text"
|
||||
onChange={onQueryTextChange}
|
||||
value={queryText || ''}
|
||||
required
|
||||
placeholder="Enter a query"
|
||||
/>
|
||||
</InlineField>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { getBackendSrv, isFetchError } from '@grafana/runtime';
|
||||
import {
|
||||
CoreApp,
|
||||
DataQueryRequest,
|
||||
DataQueryResponse,
|
||||
DataSourceApi,
|
||||
DataSourceInstanceSettings,
|
||||
createDataFrame,
|
||||
FieldType,
|
||||
} from '@grafana/data';
|
||||
|
||||
import { MyQuery, MyDataSourceOptions, DEFAULT_QUERY, DataSourceResponse } from './types';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { VariableSupport } from './variables';
|
||||
|
||||
export class DataSource extends DataSourceApi<MyQuery, MyDataSourceOptions> {
|
||||
baseUrl: string;
|
||||
|
||||
constructor(instanceSettings: DataSourceInstanceSettings<MyDataSourceOptions>) {
|
||||
super(instanceSettings);
|
||||
this.baseUrl = instanceSettings.url!;
|
||||
this.variables = new VariableSupport();
|
||||
}
|
||||
|
||||
getDefaultQuery(_: CoreApp): Partial<MyQuery> {
|
||||
return DEFAULT_QUERY;
|
||||
}
|
||||
|
||||
filterQuery(query: MyQuery): boolean {
|
||||
// if no query has been provided, prevent the query from being executed
|
||||
return !!query.queryText;
|
||||
}
|
||||
|
||||
async query(options: DataQueryRequest<MyQuery>): Promise<DataQueryResponse> {
|
||||
const { range } = options;
|
||||
const from = range!.from.valueOf();
|
||||
const to = range!.to.valueOf();
|
||||
|
||||
return {
|
||||
data: [
|
||||
createDataFrame({
|
||||
refId: 'A',
|
||||
fields: [
|
||||
{ name: 'Time', values: [from, to], type: FieldType.time },
|
||||
{ name: 'Value', values: ['A', 'B'], type: FieldType.string },
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async request(url: string, params?: string) {
|
||||
const response = getBackendSrv().fetch<DataSourceResponse>({
|
||||
url: `${this.baseUrl}${url}${params?.length ? `?${params}` : ''}`,
|
||||
});
|
||||
return lastValueFrom(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether we can connect to the API.
|
||||
*/
|
||||
async testDatasource() {
|
||||
const defaultErrorMessage = 'Cannot connect to API';
|
||||
|
||||
try {
|
||||
const response = await this.request('/health');
|
||||
if (response.status === 200) {
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'Success',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: 'error',
|
||||
message: response.statusText ? response.statusText : defaultErrorMessage,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
let message = '';
|
||||
if (typeof err === 'string') {
|
||||
message = err;
|
||||
} else if (isFetchError(err)) {
|
||||
message = 'Fetch error: ' + (err.statusText ? err.statusText : defaultErrorMessage);
|
||||
if (err.data && err.data.error && err.data.error.code) {
|
||||
message += ': ' + err.data.error.code + '. ' + err.data.error.message;
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'error',
|
||||
message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,9 @@
|
||||
import { DataSourcePlugin } from '@grafana/data';
|
||||
import { DataSource } from './datasource';
|
||||
import { ConfigEditor } from './components/ConfigEditor';
|
||||
import { QueryEditor } from './components/QueryEditor';
|
||||
import { MyQuery, MyDataSourceOptions } from './types';
|
||||
|
||||
export const plugin = new DataSourcePlugin<DataSource, MyQuery, MyDataSourceOptions>(DataSource)
|
||||
.setConfigEditor(ConfigEditor)
|
||||
.setQueryEditor(QueryEditor);
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@test-plugins/grafana-e2etest-datasource",
|
||||
"version": "12.1.0-pre",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production",
|
||||
"dev": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -w -c ./webpack.config.ts --env development",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint --cache --ignore-path ./.gitignore --ext .js,.jsx,.ts,.tsx ."
|
||||
},
|
||||
"author": "Grafana",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@grafana/plugin-configs": "workspace:*",
|
||||
"@types/lodash": "4.17.7",
|
||||
"@types/node": "22.15.0",
|
||||
"@types/prismjs": "1.26.4",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@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.95.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.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"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 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
|
||||
"type": "datasource",
|
||||
"name": "Test",
|
||||
"id": "grafana-e2etest-datasource",
|
||||
"metrics": true,
|
||||
"info": {
|
||||
"description": "",
|
||||
"author": {
|
||||
"name": "Grafana"
|
||||
},
|
||||
"keywords": ["datasource"],
|
||||
"logos": {
|
||||
"small": "img/logo.svg",
|
||||
"large": "img/logo.svg"
|
||||
},
|
||||
"links": [],
|
||||
"screenshots": [],
|
||||
"version": "%VERSION%",
|
||||
"updated": "%TODAY%"
|
||||
},
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=10.4.0",
|
||||
"plugins": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { test, expect, DataSourceConfigPage } from '@grafana/plugin-e2e';
|
||||
|
||||
// The following tests verify that label and input field association is working correctly.
|
||||
// If these tests break, e2e tests in external plugins will break too.
|
||||
|
||||
test.describe(
|
||||
'grafana-test-datasource',
|
||||
{
|
||||
tag: ['@plugins'],
|
||||
},
|
||||
() => {
|
||||
test.describe('config editor ', () => {
|
||||
let configPage: DataSourceConfigPage;
|
||||
test.beforeEach(async ({ createDataSourceConfigPage }) => {
|
||||
configPage = await createDataSourceConfigPage({ type: 'grafana-e2etest-datasource' });
|
||||
});
|
||||
|
||||
test('text input field', async ({ page }) => {
|
||||
const field = page.getByRole('textbox', { name: 'API key' });
|
||||
await expect(field).toBeEmpty();
|
||||
await field.fill('test text');
|
||||
await expect(field).toHaveValue('test text');
|
||||
});
|
||||
|
||||
test('switch field', async ({ page }) => {
|
||||
const field = page.getByLabel('Switch Enabled');
|
||||
await expect(field).not.toBeChecked();
|
||||
await field.check();
|
||||
await expect(field).toBeChecked();
|
||||
});
|
||||
|
||||
test('checkbox field', async ({ page }) => {
|
||||
const field = page.getByRole('checkbox', { name: 'Checkbox Enabled' });
|
||||
await expect(field).not.toBeChecked();
|
||||
await field.check({ force: true });
|
||||
await expect(field).toBeChecked();
|
||||
});
|
||||
|
||||
test('select field', async ({ page, selectors }) => {
|
||||
const field = page.getByRole('combobox', { name: 'Auth type' });
|
||||
await field.click();
|
||||
const option = selectors.components.Select.option;
|
||||
await expect(configPage.getByGrafanaSelector(option)).toHaveText(['keys', 'credentials']);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
test.describe(
|
||||
'grafana-test-datasource',
|
||||
{
|
||||
tag: ['@plugins'],
|
||||
},
|
||||
() => {
|
||||
test('should render variable editor', async ({ variableEditPage, page }) => {
|
||||
await variableEditPage.datasource.set('gdev-e2etestdatasource');
|
||||
await expect(page.getByRole('textbox', { name: 'Query Text' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('create new, successful variable query', async ({ variableEditPage, page }) => {
|
||||
await variableEditPage.datasource.set('gdev-e2etestdatasource');
|
||||
await page.getByRole('textbox', { name: 'Query Text' }).fill('variableQuery');
|
||||
await variableEditPage.runQuery();
|
||||
await expect(variableEditPage).toDisplayPreviews(['A', 'B']);
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -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,37 @@
|
||||
import { DataSourceJsonData } from '@grafana/data';
|
||||
import { DataQuery } from '@grafana/schema';
|
||||
|
||||
export interface MyQuery extends DataQuery {
|
||||
queryText?: string;
|
||||
constant: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_QUERY: Partial<MyQuery> = {
|
||||
constant: 6.5,
|
||||
};
|
||||
|
||||
export interface DataPoint {
|
||||
Time: number;
|
||||
Value: number;
|
||||
}
|
||||
|
||||
export interface DataSourceResponse {
|
||||
datapoints: DataPoint[];
|
||||
}
|
||||
|
||||
/**
|
||||
* These are options configured for each DataSource instance
|
||||
*/
|
||||
export interface MyDataSourceOptions extends DataSourceJsonData {
|
||||
switchEnabled: boolean;
|
||||
checkboxEnabled: boolean;
|
||||
authType: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value that is used in the backend, but never sent over HTTP to the frontend
|
||||
*/
|
||||
export interface MySecureJsonData {
|
||||
apiKey?: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VariableSupportBase, VariableSupportType } from '@grafana/data';
|
||||
import { DEFAULT_QUERY, MyQuery } from './types';
|
||||
import { DataSource } from './datasource';
|
||||
|
||||
export class VariableSupport extends VariableSupportBase<DataSource> {
|
||||
getType(): VariableSupportType {
|
||||
return VariableSupportType.Datasource;
|
||||
}
|
||||
|
||||
getDefaultQuery(): Partial<MyQuery> {
|
||||
return DEFAULT_QUERY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import CopyWebpackPlugin from 'copy-webpack-plugin';
|
||||
import grafanaConfig, { type Env } from '@grafana/plugin-configs/webpack.config.ts';
|
||||
import { mergeWithCustomize, unique } from 'webpack-merge';
|
||||
import { type 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: Env): 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;
|
||||