mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
39
e2e/test-plugins/grafana-extensionstest-app/.gitignore
vendored
Normal file
39
e2e/test-plugins/grafana-extensionstest-app/.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
node_modules/
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
dist/
|
||||
artifacts/
|
||||
work/
|
||||
ci/
|
||||
|
||||
# e2e test directories
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
|
||||
# Editor
|
||||
.idea
|
||||
|
||||
.eslintcache
|
||||
1
e2e/test-plugins/grafana-extensionstest-app/CHANGELOG.md
Normal file
1
e2e/test-plugins/grafana-extensionstest-app/CHANGELOG.md
Normal file
@@ -0,0 +1 @@
|
||||
# Changelog
|
||||
35
e2e/test-plugins/grafana-extensionstest-app/README.md
Normal file
35
e2e/test-plugins/grafana-extensionstest-app/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Extensions test plugins
|
||||
|
||||
This is an app plugin containing nested app plugins that are used for testing the plugins ui extensions APIs.
|
||||
|
||||
Further reading:
|
||||
|
||||
- [Plugin Ui Extensions docs](https://grafana.com/developers/plugin-tools/how-to-guides/ui-extensions/)
|
||||
- [Plugin E2e testing docs](https://grafana.com/developers/plugin-tools/e2e-test-a-plugin/introduction)
|
||||
|
||||
## Build
|
||||
|
||||
To build this plugin run `yarn e2e:plugin:build`.
|
||||
|
||||
## Development
|
||||
|
||||
1: Install frontend dependencies:
|
||||
`yarn install --immutable`
|
||||
|
||||
2: Build and watch the core frontend
|
||||
`yarn start`
|
||||
|
||||
3: Build and watch the test plugins
|
||||
`yarn e2e:plugin:build:dev`
|
||||
|
||||
4: Build the backend
|
||||
`make build-go`
|
||||
|
||||
5: Start the Grafana e2e test server with the provisioned test plugin
|
||||
`PORT=3000 ./scripts/grafana-server/start-server`
|
||||
|
||||
Note that this plugin extends the `@grafana/plugin-configs` configs which is why it has no src directory and uses a custom webpack config to copy necessary files.
|
||||
|
||||
## Run Playwright tests
|
||||
|
||||
- `yarn e2e:playwright`
|
||||
@@ -0,0 +1,100 @@
|
||||
import { PluginExtension, PluginExtensionLink, SelectableValue, locationUtil } from '@grafana/data';
|
||||
import { isPluginExtensionLink, locationService } from '@grafana/runtime';
|
||||
import { Button, ButtonGroup, ButtonSelect, Modal, Stack, ToolbarButton } from '@grafana/ui';
|
||||
import { testIds } from '../testIds';
|
||||
|
||||
import { ReactElement, useMemo, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
extensions: PluginExtension[];
|
||||
};
|
||||
|
||||
export function ActionButton(props: Props): ReactElement {
|
||||
const options = useExtensionsAsOptions(props.extensions);
|
||||
const [extension, setExtension] = useState<PluginExtensionLink | undefined>();
|
||||
|
||||
if (options.length === 0) {
|
||||
return <Button>Run default action</Button>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ButtonGroup>
|
||||
<ToolbarButton key="default-action" variant="canvas" onClick={() => alert('You triggered the default action')}>
|
||||
Run default action
|
||||
</ToolbarButton>
|
||||
<ButtonSelect
|
||||
data-testid={testIds.actions.button}
|
||||
key="select-extension"
|
||||
variant="canvas"
|
||||
options={options}
|
||||
onChange={(option) => {
|
||||
const extension = option.value;
|
||||
|
||||
if (isPluginExtensionLink(extension)) {
|
||||
if (extension.path) {
|
||||
return setExtension(extension);
|
||||
}
|
||||
if (extension.onClick) {
|
||||
return extension.onClick();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
{extension && extension?.path && (
|
||||
<LinkModal title={extension.title} path={extension.path} onDismiss={() => setExtension(undefined)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function useExtensionsAsOptions(extensions: PluginExtension[]): Array<SelectableValue<PluginExtension>> {
|
||||
return useMemo(() => {
|
||||
return extensions.reduce((options: Array<SelectableValue<PluginExtension>>, extension) => {
|
||||
if (isPluginExtensionLink(extension)) {
|
||||
options.push({
|
||||
label: extension.title,
|
||||
title: extension.title,
|
||||
value: extension,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, []);
|
||||
}, [extensions]);
|
||||
}
|
||||
|
||||
type LinkModelProps = {
|
||||
onDismiss: () => void;
|
||||
title: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export function LinkModal(props: LinkModelProps): ReactElement {
|
||||
const { onDismiss, title, path } = props;
|
||||
const openInNewTab = () => {
|
||||
global.open(locationUtil.assureBaseUrl(path), '_blank');
|
||||
onDismiss();
|
||||
};
|
||||
|
||||
const openInCurrentTab = () => locationService.push(path);
|
||||
|
||||
return (
|
||||
<Modal data-testid={testIds.modal.container} title={title} isOpen onDismiss={onDismiss}>
|
||||
<Stack direction={'column'}>
|
||||
<p>Do you want to proceed in the current tab or open a new tab?</p>
|
||||
</Stack>
|
||||
<Modal.ButtonRow>
|
||||
<Button onClick={onDismiss} fill="outline" variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="secondary" onClick={openInNewTab} icon="external-link-alt">
|
||||
Open in new tab
|
||||
</Button>
|
||||
<Button data-testid={testIds.modal.open} type="submit" variant="primary" onClick={openInCurrentTab} icon="apps">
|
||||
Open
|
||||
</Button>
|
||||
</Modal.ButtonRow>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ActionButton } from './ActionButton';
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { AppRootProps } from '@grafana/data';
|
||||
import { ROUTES } from '../../constants';
|
||||
import { AddedComponents, ExposedComponents, LegacyAPIs } from '../../pages';
|
||||
import { testIds } from '../testIds';
|
||||
|
||||
export function App(props: AppRootProps) {
|
||||
return (
|
||||
<div data-testid={testIds.container} style={{ marginTop: '5%' }}>
|
||||
<Routes>
|
||||
<Route path={ROUTES.LegacyAPIs} element={<LegacyAPIs />} />
|
||||
<Route path={ROUTES.ExposedComponents} element={<ExposedComponents />} />
|
||||
<Route path={ROUTES.AddedComponents} element={<AddedComponents />} />
|
||||
|
||||
<Route path={'*'} element={<LegacyAPIs />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './App';
|
||||
@@ -0,0 +1,135 @@
|
||||
import { ChangeEvent, useState } from 'react';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { css } from '@emotion/css';
|
||||
import { AppPluginMeta, GrafanaTheme2, PluginConfigPageProps, PluginMeta } from '@grafana/data';
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { Button, Field, FieldSet, Input, SecretInput, useStyles2 } from '@grafana/ui';
|
||||
import { testIds } from '../testIds';
|
||||
|
||||
export type AppPluginSettings = {
|
||||
apiUrl?: string;
|
||||
};
|
||||
|
||||
type State = {
|
||||
// The URL to reach our custom API.
|
||||
apiUrl: string;
|
||||
// Tells us if the API key secret is set.
|
||||
isApiKeySet: boolean;
|
||||
// A secret key for our custom API.
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
export interface AppConfigProps extends PluginConfigPageProps<AppPluginMeta<AppPluginSettings>> {}
|
||||
|
||||
export const AppConfig = ({ plugin }: AppConfigProps) => {
|
||||
const s = useStyles2(getStyles);
|
||||
const { enabled, pinned, jsonData, secureJsonFields } = plugin.meta;
|
||||
const [state, setState] = useState<State>({
|
||||
apiUrl: jsonData?.apiUrl || '',
|
||||
apiKey: '',
|
||||
isApiKeySet: Boolean(secureJsonFields?.apiKey),
|
||||
});
|
||||
|
||||
const onResetApiKey = () =>
|
||||
setState({
|
||||
...state,
|
||||
apiKey: '',
|
||||
isApiKeySet: false,
|
||||
});
|
||||
|
||||
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setState({
|
||||
...state,
|
||||
[event.target.name]: event.target.value.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid={testIds.appConfig.container}>
|
||||
<FieldSet label="API Settings">
|
||||
<Field label="API Key" description="A secret key for authenticating to our custom API">
|
||||
<SecretInput
|
||||
width={60}
|
||||
id="config-api-key"
|
||||
data-testid={testIds.appConfig.apiKey}
|
||||
name="apiKey"
|
||||
value={state.apiKey}
|
||||
isConfigured={state.isApiKeySet}
|
||||
placeholder={'Your secret API key'}
|
||||
onChange={onChange}
|
||||
onReset={onResetApiKey}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="API Url" description="" className={s.marginTop}>
|
||||
<Input
|
||||
width={60}
|
||||
name="apiUrl"
|
||||
id="config-api-url"
|
||||
data-testid={testIds.appConfig.apiUrl}
|
||||
value={state.apiUrl}
|
||||
placeholder={`E.g.: http://mywebsite.com/api/v1`}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className={s.marginTop}>
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid={testIds.appConfig.submit}
|
||||
onClick={() =>
|
||||
updatePluginAndReload(plugin.meta.id, {
|
||||
enabled,
|
||||
pinned,
|
||||
jsonData: {
|
||||
apiUrl: state.apiUrl,
|
||||
},
|
||||
// This cannot be queried later by the frontend.
|
||||
// We don't want to override it in case it was set previously and left untouched now.
|
||||
secureJsonData: state.isApiKeySet
|
||||
? undefined
|
||||
: {
|
||||
apiKey: state.apiKey,
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={Boolean(!state.apiUrl || (!state.isApiKeySet && !state.apiKey))}
|
||||
>
|
||||
Save API settings
|
||||
</Button>
|
||||
</div>
|
||||
</FieldSet>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
colorWeak: css`
|
||||
color: ${theme.colors.text.secondary};
|
||||
`,
|
||||
marginTop: css`
|
||||
margin-top: ${theme.spacing(3)};
|
||||
`,
|
||||
});
|
||||
|
||||
const updatePluginAndReload = async (pluginId: string, data: Partial<PluginMeta<AppPluginSettings>>) => {
|
||||
try {
|
||||
await updatePlugin(pluginId, data);
|
||||
|
||||
// Reloading the page as the changes made here wouldn't be propagated to the actual plugin otherwise.
|
||||
// This is not ideal, however unfortunately currently there is no supported way for updating the plugin state.
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
console.error('Error while updating the plugin', e);
|
||||
}
|
||||
};
|
||||
|
||||
export const updatePlugin = async (pluginId: string, data: Partial<PluginMeta>) => {
|
||||
const response = await getBackendSrv().fetch({
|
||||
url: `/api/plugins/${pluginId}/settings`,
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
|
||||
return lastValueFrom(response);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './AppConfig';
|
||||
@@ -0,0 +1,45 @@
|
||||
import { DataQuery } from '@grafana/data';
|
||||
import { Button, FilterPill, Modal, Stack } from '@grafana/ui';
|
||||
import { testIds } from '../testIds';
|
||||
import { ReactElement, useState } from 'react';
|
||||
import { selectQuery } from '../../utils/utils';
|
||||
|
||||
type Props = {
|
||||
targets: DataQuery[] | undefined;
|
||||
onDismiss?: () => void;
|
||||
};
|
||||
|
||||
export function QueryModal(props: Props): ReactElement {
|
||||
const { targets = [], onDismiss } = props;
|
||||
const [selected, setSelected] = useState(targets[0]);
|
||||
|
||||
return (
|
||||
<div data-testid={testIds.modal.container}>
|
||||
<p>Please select the query you would like to use to create "something" in the plugin.</p>
|
||||
<Stack>
|
||||
{targets.map((query) => (
|
||||
<FilterPill
|
||||
key={query.refId}
|
||||
label={query.refId}
|
||||
selected={query.refId === selected?.refId}
|
||||
onClick={() => setSelected(query)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Modal.ButtonRow>
|
||||
<Button variant="secondary" fill="outline" onClick={onDismiss}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!Boolean(selected)}
|
||||
onClick={() => {
|
||||
onDismiss?.();
|
||||
selectQuery(selected);
|
||||
}}
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
</Modal.ButtonRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { QueryModal } from './QueryModal';
|
||||
@@ -0,0 +1,36 @@
|
||||
export const testIds = {
|
||||
container: 'main-app-body',
|
||||
actions: {
|
||||
button: 'action-button',
|
||||
},
|
||||
modal: {
|
||||
container: 'container',
|
||||
open: 'open-link',
|
||||
},
|
||||
appA: {
|
||||
container: 'a-app-body',
|
||||
},
|
||||
appB: {
|
||||
modal: 'b-app-modal',
|
||||
},
|
||||
appConfig: {
|
||||
container: 'data-testid ac-container',
|
||||
apiKey: 'data-testid ac-api-key',
|
||||
apiUrl: 'data-testid ac-api-url',
|
||||
submit: 'data-testid ac-submit-form',
|
||||
},
|
||||
pageOne: {
|
||||
container: 'data-testid pg-one-container',
|
||||
navigateToFour: 'data-testid navigate-to-four',
|
||||
},
|
||||
pageTwo: {
|
||||
container: 'data-testid pg-two-container',
|
||||
},
|
||||
addedComponentsPage: {
|
||||
container: 'data-testid pg-added-components-container',
|
||||
},
|
||||
pageFour: {
|
||||
container: 'data-testid pg-four-container',
|
||||
navigateBack: 'data-testid navigate-back',
|
||||
},
|
||||
};
|
||||
9
e2e/test-plugins/grafana-extensionstest-app/constants.ts
Normal file
9
e2e/test-plugins/grafana-extensionstest-app/constants.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import pluginJson from './plugin.json';
|
||||
|
||||
export const PLUGIN_BASE_URL = `/a/${pluginJson.id}`;
|
||||
|
||||
export enum ROUTES {
|
||||
LegacyAPIs = 'legacy-apis',
|
||||
ExposedComponents = 'exposed-components',
|
||||
AddedComponents = 'added-components',
|
||||
}
|
||||
1
e2e/test-plugins/grafana-extensionstest-app/img/logo.svg
Normal file
1
e2e/test-plugins/grafana-extensionstest-app/img/logo.svg
Normal file
@@ -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 |
86
e2e/test-plugins/grafana-extensionstest-app/module.tsx
Normal file
86
e2e/test-plugins/grafana-extensionstest-app/module.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { AppPlugin, PluginExtensionPanelContext, PluginExtensionPoints } from '@grafana/data';
|
||||
import { App } from './components/App';
|
||||
import { QueryModal } from './components/QueryModal';
|
||||
import { selectQuery } from './utils/utils';
|
||||
import pluginJson from './plugin.json';
|
||||
|
||||
export const plugin = new AppPlugin<{}>()
|
||||
.setRootPage(App)
|
||||
.configureExtensionLink<PluginExtensionPanelContext>({
|
||||
title: 'Open from time series or pie charts (path)',
|
||||
description: 'This link will only be visible on time series and pie charts',
|
||||
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
|
||||
path: `/a/${pluginJson.id}/`,
|
||||
configure: (context) => {
|
||||
// Will only be visible for the Link Extensions dashboard
|
||||
if (context?.dashboard?.title !== 'Link Extensions (path)') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (context?.pluginId) {
|
||||
case 'timeseries':
|
||||
return {}; // Does not apply any overrides
|
||||
case 'piechart':
|
||||
return {
|
||||
title: `Open from ${context.pluginId}`,
|
||||
};
|
||||
|
||||
default:
|
||||
// By returning undefined the extension will be hidden
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
})
|
||||
.configureExtensionLink<PluginExtensionPanelContext>({
|
||||
title: 'Open from time series or pie charts (onClick)',
|
||||
description: 'This link will only be visible on time series and pie charts',
|
||||
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
|
||||
onClick: (_, { openModal, context }) => {
|
||||
const targets = context?.targets ?? [];
|
||||
const title = context?.title;
|
||||
|
||||
if (!isSupported(context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show a modal to display a UI for selecting between the available queries (targets)
|
||||
// in case there are more available.
|
||||
if (targets.length > 1) {
|
||||
return openModal({
|
||||
title: `Select query from "${title}"`,
|
||||
body: (props) => <QueryModal {...props} targets={targets} />,
|
||||
});
|
||||
}
|
||||
|
||||
const [target] = targets;
|
||||
selectQuery(target);
|
||||
},
|
||||
configure: (context) => {
|
||||
// Will only be visible for the Command Extensions dashboard
|
||||
if (context?.dashboard?.title !== 'Link Extensions (onClick)') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!isSupported(context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (context?.pluginId) {
|
||||
case 'timeseries':
|
||||
return {}; // Does not apply any overrides
|
||||
case 'piechart':
|
||||
return {
|
||||
title: `Open from ${context.pluginId}`,
|
||||
};
|
||||
|
||||
default:
|
||||
// By returning undefined the extension will be hidden
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function isSupported(context?: PluginExtensionPanelContext): boolean {
|
||||
const targets = context?.targets ?? [];
|
||||
return targets.length > 0;
|
||||
}
|
||||
48
e2e/test-plugins/grafana-extensionstest-app/package.json
Normal file
48
e2e/test-plugins/grafana-extensionstest-app/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@test-plugins/extensions-test-app",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "webpack -c ./webpack.config.ts --env production",
|
||||
"dev": "webpack -w -c ./webpack.config.ts --env development",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint --cache --ignore-path ./.gitignore --ext .js,.jsx,.ts,.tsx ."
|
||||
},
|
||||
"author": "Grafana Labs",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@grafana/eslint-config": "7.0.0",
|
||||
"@grafana/plugin-configs": "11.3.0-pre",
|
||||
"@types/lodash": "4.17.7",
|
||||
"@types/node": "20.14.14",
|
||||
"@types/prismjs": "1.26.4",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.2.25",
|
||||
"@types/semver": "7.5.8",
|
||||
"@types/uuid": "9.0.8",
|
||||
"glob": "10.4.1",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.5.4",
|
||||
"webpack": "5.91.0",
|
||||
"webpack-merge": "5.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/css": "11.11.2",
|
||||
"@grafana/data": "workspace:*",
|
||||
"@grafana/runtime": "workspace:*",
|
||||
"@grafana/schema": "workspace:*",
|
||||
"@grafana/ui": "workspace:*",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"rxjs": "7.8.1",
|
||||
"tslib": "2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@grafana/runtime": "*"
|
||||
},
|
||||
"packageManager": "yarn@4.4.0"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { testIds } from '../components/testIds';
|
||||
import { PluginPage, usePluginComponents } from '@grafana/runtime';
|
||||
import { Stack } from '@grafana/ui';
|
||||
|
||||
type ReusableComponentProps = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function AddedComponents() {
|
||||
const { components } = usePluginComponents<ReusableComponentProps>({
|
||||
extensionPointId: 'plugins/grafana-extensionexample2-app/addComponent/v1',
|
||||
});
|
||||
|
||||
return (
|
||||
<PluginPage>
|
||||
<Stack direction={'column'} gap={4} data-testid={testIds.addedComponentsPage.container}>
|
||||
<article>
|
||||
<h3>Component extensions defined with addComponent and retrived with usePluginComponents hook</h3>
|
||||
{components.map((Component, i) => {
|
||||
return <Component key={i} name="World" />;
|
||||
})}
|
||||
</article>
|
||||
</Stack>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { testIds } from '../components/testIds';
|
||||
import { PluginPage, usePluginComponent } from '@grafana/runtime';
|
||||
|
||||
type ReusableComponentProps = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function ExposedComponents() {
|
||||
var { component: ReusableComponent } = usePluginComponent<ReusableComponentProps>(
|
||||
'grafana-extensionexample1-app/reusable-component/v1'
|
||||
);
|
||||
|
||||
if (!ReusableComponent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PluginPage>
|
||||
<div data-testid={testIds.pageTwo.container}>
|
||||
<ReusableComponent name={'World'} />
|
||||
</div>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { testIds } from '../components/testIds';
|
||||
import { PluginPage, getPluginComponentExtensions, getPluginExtensions } from '@grafana/runtime';
|
||||
import { ActionButton } from '../components/ActionButton';
|
||||
import { Stack } from '@grafana/ui';
|
||||
|
||||
type AppExtensionContext = {};
|
||||
type ReusableComponentProps = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function LegacyAPIs() {
|
||||
const extensionPointId = 'plugins/grafana-extensionstest-app/actions';
|
||||
const context: AppExtensionContext = {};
|
||||
|
||||
const { extensions } = getPluginExtensions({
|
||||
extensionPointId,
|
||||
context,
|
||||
});
|
||||
|
||||
const { extensions: componentExtensions } = getPluginComponentExtensions<ReusableComponentProps>({
|
||||
extensionPointId: 'plugins/grafana-extensionexample2-app/configure-extension-component/v1',
|
||||
});
|
||||
|
||||
return (
|
||||
<PluginPage>
|
||||
<Stack direction={'column'} gap={4} data-testid={testIds.pageTwo.container}>
|
||||
<article>
|
||||
<h3>Link extensions defined with configureExtensionLink and retrived using getPluginExtensions</h3>
|
||||
<ActionButton extensions={extensions} />
|
||||
</article>
|
||||
<article>
|
||||
<h3>
|
||||
Component extensions defined with configureExtensionComponent and retrived using
|
||||
getPluginComponentExtensions
|
||||
</h3>
|
||||
{componentExtensions.map((extension) => {
|
||||
const Component = extension.component;
|
||||
return <Component key={extension.id} name="World" />;
|
||||
})}
|
||||
</article>
|
||||
</Stack>
|
||||
</PluginPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ExposedComponents } from './ExposedComponents';
|
||||
export { LegacyAPIs } from './LegacyAPIs';
|
||||
export { AddedComponents } from './AddedComponents';
|
||||
59
e2e/test-plugins/grafana-extensionstest-app/plugin.json
Normal file
59
e2e/test-plugins/grafana-extensionstest-app/plugin.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
|
||||
"type": "app",
|
||||
"name": "Extensions test app",
|
||||
"preload": true,
|
||||
"id": "grafana-extensionstest-app",
|
||||
"info": {
|
||||
"keywords": ["app"],
|
||||
"description": "",
|
||||
"author": {
|
||||
"name": "Grafana"
|
||||
},
|
||||
"logos": {
|
||||
"small": "img/logo.svg",
|
||||
"large": "img/logo.svg"
|
||||
},
|
||||
"screenshots": [],
|
||||
"version": "%VERSION%",
|
||||
"updated": "%TODAY%"
|
||||
},
|
||||
"includes": [
|
||||
{
|
||||
"type": "page",
|
||||
"name": "Legacy APIs",
|
||||
"path": "/a/grafana-extensionstest-app/legacy-apis",
|
||||
"role": "Admin",
|
||||
"addToNav": true,
|
||||
"defaultNav": false
|
||||
},
|
||||
{
|
||||
"type": "page",
|
||||
"name": "Exposed components",
|
||||
"path": "/a/grafana-extensionstest-app/exposed-components",
|
||||
"role": "Admin",
|
||||
"addToNav": true,
|
||||
"defaultNav": false
|
||||
},
|
||||
{
|
||||
"type": "page",
|
||||
"name": "Added components",
|
||||
"path": "/a/grafana-extensionstest-app/added-components",
|
||||
"role": "Admin",
|
||||
"addToNav": true,
|
||||
"defaultNav": false
|
||||
},
|
||||
{
|
||||
"type": "page",
|
||||
"icon": "cog",
|
||||
"name": "Configuration",
|
||||
"path": "/plugins/grafana-extensionstest-app",
|
||||
"role": "Admin",
|
||||
"addToNav": true
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=10.4.0",
|
||||
"plugins": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import { AppRootProps } from '@grafana/data';
|
||||
import { testIds } from '../../testIds';
|
||||
|
||||
export class App extends React.PureComponent<AppRootProps> {
|
||||
render() {
|
||||
return (
|
||||
<div data-testid={testIds.appA.container} className="page-container">
|
||||
Hello Grafana!
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './App';
|
||||
@@ -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 @@
|
||||
export * from './App';
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 81.9 71.52"><defs><style>.cls-1{fill:#84aff1;}.cls-2{fill:#3865ab;}.cls-3{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" x1="42.95" y1="16.88" x2="81.9" y2="16.88" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M55.46,62.43A2,2,0,0,1,54.07,59l4.72-4.54a2,2,0,0,1,2.2-.39l3.65,1.63,3.68-3.64a2,2,0,1,1,2.81,2.84l-4.64,4.6a2,2,0,0,1-2.22.41L60.6,58.26l-3.76,3.61A2,2,0,0,1,55.46,62.43Z"/><path class="cls-2" d="M37,0H2A2,2,0,0,0,0,2V31.76a2,2,0,0,0,2,2H37a2,2,0,0,0,2-2V2A2,2,0,0,0,37,0ZM4,29.76V8.84H35V29.76Z"/><path class="cls-3" d="M79.9,0H45a2,2,0,0,0-2,2V31.76a2,2,0,0,0,2,2h35a2,2,0,0,0,2-2V2A2,2,0,0,0,79.9,0ZM47,29.76V8.84h31V29.76Z"/><path class="cls-2" d="M37,37.76H2a2,2,0,0,0-2,2V69.52a2,2,0,0,0,2,2H37a2,2,0,0,0,2-2V39.76A2,2,0,0,0,37,37.76ZM4,67.52V46.6H35V67.52Z"/><path class="cls-2" d="M79.9,37.76H45a2,2,0,0,0-2,2V69.52a2,2,0,0,0,2,2h35a2,2,0,0,0,2-2V39.76A2,2,0,0,0,79.9,37.76ZM47,67.52V46.6h31V67.52Z"/><rect class="cls-1" x="10.48" y="56.95" width="4" height="5.79"/><rect class="cls-1" x="17.43" y="53.95" width="4" height="8.79"/><rect class="cls-1" x="24.47" y="50.95" width="4" height="11.79"/><path class="cls-1" d="M19.47,25.8a6.93,6.93,0,1,1,6.93-6.92A6.93,6.93,0,0,1,19.47,25.8Zm0-9.85a2.93,2.93,0,1,0,2.93,2.93A2.93,2.93,0,0,0,19.47,16Z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,32 @@
|
||||
import { AppPlugin } from '@grafana/data';
|
||||
import { App } from './components/App';
|
||||
import { testIds } from './testIds';
|
||||
|
||||
console.log('Hello from app B');
|
||||
export const plugin = new AppPlugin<{}>()
|
||||
.setRootPage(App)
|
||||
.configureExtensionLink({
|
||||
title: 'Open from B',
|
||||
description: 'Open a modal from plugin B',
|
||||
extensionPointId: 'plugins/grafana-extensionstest-app/actions',
|
||||
onClick: (_, { openModal }) => {
|
||||
openModal({
|
||||
title: 'Modal from app B',
|
||||
body: () => <div data-testid={testIds.appB.modal}>From plugin B</div>,
|
||||
});
|
||||
},
|
||||
})
|
||||
.configureExtensionComponent({
|
||||
extensionPointId: 'plugins/grafana-extensionexample2-app/configure-extension-component/v1',
|
||||
title: 'Configure extension component from B',
|
||||
description: 'A component that can be reused by other app plugins. Shared using configureExtensionComponent api',
|
||||
component: ({ name }: { name: string }) => <div data-testid={testIds.appB.reusableComponent}>Hello {name}!</div>,
|
||||
})
|
||||
.addComponent<{ name: string }>({
|
||||
targets: 'plugins/grafana-extensionexample2-app/addComponent/v1',
|
||||
title: 'Added component from B',
|
||||
description: 'A component that can be reused by other app plugins. Shared using addComponent api',
|
||||
component: ({ name }: { name: string }) => (
|
||||
<div data-testid={testIds.appB.reusableAddedComponent}>Hello {name}!</div>
|
||||
),
|
||||
});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user