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>
This commit is contained in:
Erik Sundell
2024-08-23 09:00:03 +02:00
committed by GitHub
co-authored by Jack Westbrook
parent 0af4a20b58
commit d8ec95e9b1
78 changed files with 1143 additions and 722 deletions
+33
View File
@@ -0,0 +1,33 @@
# Test plugins
The [e2e test server](https://github.com/grafana/grafana/blob/main/scripts/grafana-server/start-server) automatically scans and looks for plugins in this directory.
### To add a new test plugin:
1. If provisioning is required you may update the YAML config file in [`/devenv`](https://github.com/grafana/grafana/tree/main/devenv).
2. Add the plugin ID to the `allow_loading_unsigned_plugins` setting in the test server's [configuration file](https://github.com/grafana/grafana/blob/main/scripts/grafana-server/custom.ini).
### Building a test plugin with webpack
If you wish to build a test plugin with webpack, you may take a look at how the [grafana-extensionstest-app](./grafana-extensionstest-app/) is wired. A few things to keep in mind:
- the package name needs to be prefixed with `@test-plugins/`
- extend the webpack config from [`@grafana/plugin-configs`](../../packages/grafana-plugin-configs/) and use custom webpack config to only copy the necessary files (see example [here](./grafana-extensionstest-app/webpack.config.ts))
- keep dependency versions in sync with what's in core
#### Local development
1: Install frontend dependencies:
`yarn install --immutable`
2: Build and watch the core frontend
`yarn start`
3: Build and watch the test plugins
`yarn e2e:plugin:build:dev`
4: Build the backend
`make build-go`
5: Start the Grafana e2e test server with the provisioned test plugin
`PORT=3000 ./scripts/grafana-server/start-server`
@@ -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 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 &quot;something&quot; 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',
}
@@ -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)
.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 @@
<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,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,35 @@
{
"$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
}
],
"dependencies": {
"grafanaDependency": ">=10.3.3",
"plugins": []
}
}
@@ -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 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 81.9 71.52"><defs><style>.cls-1{fill:#84aff1;}.cls-2{fill:#3865ab;}.cls-3{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" x1="42.95" y1="16.88" x2="81.9" y2="16.88" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M55.46,62.43A2,2,0,0,1,54.07,59l4.72-4.54a2,2,0,0,1,2.2-.39l3.65,1.63,3.68-3.64a2,2,0,1,1,2.81,2.84l-4.64,4.6a2,2,0,0,1-2.22.41L60.6,58.26l-3.76,3.61A2,2,0,0,1,55.46,62.43Z"/><path class="cls-2" d="M37,0H2A2,2,0,0,0,0,2V31.76a2,2,0,0,0,2,2H37a2,2,0,0,0,2-2V2A2,2,0,0,0,37,0ZM4,29.76V8.84H35V29.76Z"/><path class="cls-3" d="M79.9,0H45a2,2,0,0,0-2,2V31.76a2,2,0,0,0,2,2h35a2,2,0,0,0,2-2V2A2,2,0,0,0,79.9,0ZM47,29.76V8.84h31V29.76Z"/><path class="cls-2" d="M37,37.76H2a2,2,0,0,0-2,2V69.52a2,2,0,0,0,2,2H37a2,2,0,0,0,2-2V39.76A2,2,0,0,0,37,37.76ZM4,67.52V46.6H35V67.52Z"/><path class="cls-2" d="M79.9,37.76H45a2,2,0,0,0-2,2V69.52a2,2,0,0,0,2,2h35a2,2,0,0,0,2-2V39.76A2,2,0,0,0,79.9,37.76ZM47,67.52V46.6h31V67.52Z"/><rect class="cls-1" x="10.48" y="56.95" width="4" height="5.79"/><rect class="cls-1" x="17.43" y="53.95" width="4" height="8.79"/><rect class="cls-1" x="24.47" y="50.95" width="4" height="11.79"/><path class="cls-1" d="M19.47,25.8a6.93,6.93,0,1,1,6.93-6.92A6.93,6.93,0,0,1,19.47,25.8Zm0-9.85a2.93,2.93,0,1,0,2.93,2.93A2.93,2.93,0,0,0,19.47,16Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,32 @@
import { AppPlugin } from '@grafana/data';
import { App } from './components/App';
import { testIds } from './testIds';
console.log('Hello from app B');
export const plugin = new AppPlugin<{}>()
.setRootPage(App)
.configureExtensionLink({
title: 'Open from B',
description: 'Open a modal from plugin B',
extensionPointId: 'plugins/grafana-extensionstest-app/actions',
onClick: (_, { openModal }) => {
openModal({
title: 'Modal from app B',
body: () => <div data-testid={testIds.appB.modal}>From plugin B</div>,
});
},
})
.configureExtensionComponent({
extensionPointId: 'plugins/grafana-extensionexample2-app/configure-extension-component/v1',
title: 'Configure extension component from B',
description: 'A component that can be reused by other app plugins. Shared using configureExtensionComponent api',
component: ({ name }: { name: string }) => <div data-testid={testIds.appB.reusableComponent}>Hello {name}!</div>,
})
.addComponent<{ name: string }>({
targets: 'plugins/grafana-extensionexample2-app/addComponent/v1',
title: 'Added component from B',
description: 'A component that can be reused by other app plugins. Shared using addComponent api',
component: ({ name }: { name: string }) => (
<div data-testid={testIds.appB.reusableAddedComponent}>Hello {name}!</div>
),
});
@@ -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-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%"
},
"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 @@
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;