mirror of
https://github.com/grafana/grafana.git
synced 2025-02-16 18:34:52 -06:00
Plugins: Add a new UI Extension type (#68600)
* feat: add a new UI extension type: component * tests: add tests for checking if it is a react component * fix: remove reference to not existing type * chore: update betterer results * review: remove unnecessary override function for components * review: use a single type notation in import * review: stop exporting `PluginExtensionBase` * refactor: make extension config types more explicit By using some repetition now these types are much easier to oversee.
This commit is contained in:
parent
82c770115e
commit
a91033c025
@ -322,7 +322,8 @@ exports[`better eslint`] = {
|
||||
],
|
||||
"packages/grafana-data/src/types/app.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"]
|
||||
],
|
||||
"packages/grafana-data/src/types/config.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
|
@ -3,7 +3,12 @@ import { ComponentType } from 'react';
|
||||
import { KeyValue } from './data';
|
||||
import { NavModel } from './navModel';
|
||||
import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin';
|
||||
import { type PluginExtensionLinkConfig, PluginExtensionTypes } from './pluginExtensions';
|
||||
import {
|
||||
type PluginExtensionLinkConfig,
|
||||
PluginExtensionTypes,
|
||||
PluginExtensionComponentConfig,
|
||||
PluginExtensionConfig,
|
||||
} from './pluginExtensions';
|
||||
|
||||
/**
|
||||
* @public
|
||||
@ -51,7 +56,7 @@ export interface AppPluginMeta<T extends KeyValue = KeyValue> extends PluginMeta
|
||||
}
|
||||
|
||||
export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppPluginMeta<T>> {
|
||||
private _extensionConfigs: PluginExtensionLinkConfig[] = [];
|
||||
private _extensionConfigs: PluginExtensionConfig[] = [];
|
||||
|
||||
// Content under: /a/${plugin-id}/*
|
||||
root?: ComponentType<AppRootProps<T>>;
|
||||
@ -105,6 +110,17 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
configureExtensionComponent<Context extends object>(
|
||||
extension: Omit<PluginExtensionComponentConfig<Context>, 'type'>
|
||||
) {
|
||||
this._extensionConfigs.push({
|
||||
...extension,
|
||||
type: PluginExtensionTypes.component,
|
||||
} as PluginExtensionComponentConfig);
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -56,8 +56,10 @@ export {
|
||||
PluginExtensionPoints,
|
||||
type PluginExtension,
|
||||
type PluginExtensionLink,
|
||||
type PluginExtensionComponent,
|
||||
type PluginExtensionConfig,
|
||||
type PluginExtensionLinkConfig,
|
||||
type PluginExtensionComponentConfig,
|
||||
type PluginExtensionEventHelpers,
|
||||
type PluginExtensionPanelContext,
|
||||
} from './pluginExtensions';
|
||||
|
@ -1,3 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
import { DataQuery } from '@grafana/schema';
|
||||
|
||||
import { ScopedVars } from './ScopedVars';
|
||||
@ -9,9 +11,10 @@ import { RawTimeRange, TimeZone } from './time';
|
||||
|
||||
export enum PluginExtensionTypes {
|
||||
link = 'link',
|
||||
component = 'component',
|
||||
}
|
||||
|
||||
export type PluginExtension = {
|
||||
type PluginExtensionBase = {
|
||||
id: string;
|
||||
type: PluginExtensionTypes;
|
||||
title: string;
|
||||
@ -19,37 +22,64 @@ export type PluginExtension = {
|
||||
pluginId: string;
|
||||
};
|
||||
|
||||
export type PluginExtensionLink = PluginExtension & {
|
||||
export type PluginExtensionLink = PluginExtensionBase & {
|
||||
type: PluginExtensionTypes.link;
|
||||
path?: string;
|
||||
onClick?: (event?: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
export type PluginExtensionComponent = PluginExtensionBase & {
|
||||
type: PluginExtensionTypes.component;
|
||||
component: React.ComponentType;
|
||||
};
|
||||
|
||||
export type PluginExtension = PluginExtensionLink | PluginExtensionComponent;
|
||||
|
||||
// Objects used for registering extensions (in app plugins)
|
||||
// --------------------------------------------------------
|
||||
export type PluginExtensionLinkConfig<Context extends object = object> = {
|
||||
type: PluginExtensionTypes.link;
|
||||
title: string;
|
||||
description: string;
|
||||
|
||||
export type PluginExtensionConfig<Context extends object = object, ExtraProps extends object = object> = Pick<
|
||||
PluginExtension,
|
||||
'title' | 'description'
|
||||
> &
|
||||
ExtraProps & {
|
||||
// The unique identifier of the Extension Point
|
||||
// (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum)
|
||||
extensionPointId: string;
|
||||
// A URL path that will be used as the href for the rendered link extension
|
||||
// (It is optional, because in some cases the action will be handled by the `onClick` handler instead of navigating to a new page)
|
||||
path?: string;
|
||||
|
||||
// (Optional) A function that can be used to configure the extension dynamically based on the extension point's context
|
||||
configure?: (
|
||||
context?: Readonly<Context>
|
||||
) => Partial<{ title: string; description: string } & ExtraProps> | undefined;
|
||||
};
|
||||
// A function that will be called when the link is clicked
|
||||
// (It is called with the original event object)
|
||||
onClick?: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => void;
|
||||
|
||||
export type PluginExtensionLinkConfig<Context extends object = object> = PluginExtensionConfig<
|
||||
Context,
|
||||
Pick<PluginExtensionLink, 'path'> & {
|
||||
type: PluginExtensionTypes.link;
|
||||
onClick?: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => void;
|
||||
}
|
||||
>;
|
||||
// The unique identifier of the Extension Point
|
||||
// (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum)
|
||||
extensionPointId: string;
|
||||
|
||||
// (Optional) A function that can be used to configure the extension dynamically based on the extension point's context
|
||||
configure?: (context?: Readonly<Context>) =>
|
||||
| Partial<{
|
||||
title: string;
|
||||
description: string;
|
||||
path: string;
|
||||
onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => void;
|
||||
}>
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export type PluginExtensionComponentConfig<Context extends object = object> = {
|
||||
type: PluginExtensionTypes.component;
|
||||
title: string;
|
||||
description: string;
|
||||
|
||||
// The React component that will be rendered as the extension
|
||||
// (This component receives the context as a prop when it is rendered. You can just return `null` from the component to hide for certain contexts)
|
||||
component: React.ComponentType;
|
||||
|
||||
// The unique identifier of the Extension Point
|
||||
// (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum)
|
||||
extensionPointId: string;
|
||||
};
|
||||
|
||||
export type PluginExtensionConfig = PluginExtensionLinkConfig | PluginExtensionComponentConfig;
|
||||
|
||||
export type PluginExtensionEventHelpers<Context extends object = object> = {
|
||||
context?: Readonly<Context>;
|
||||
@ -68,6 +98,7 @@ export type PluginExtensionEventHelpers<Context extends object = object> = {
|
||||
// Extension Points available in core Grafana
|
||||
export enum PluginExtensionPoints {
|
||||
DashboardPanelMenu = 'grafana/dashboard/panel/menu',
|
||||
DataSourceConfig = 'grafana/datasources/config',
|
||||
}
|
||||
|
||||
export type PluginExtensionPanelContext = {
|
||||
|
@ -12,6 +12,8 @@ export * from './appEvents';
|
||||
export {
|
||||
setPluginExtensionGetter,
|
||||
getPluginExtensions,
|
||||
getPluginLinkExtensions,
|
||||
getPluginComponentExtensions,
|
||||
type GetPluginExtensions,
|
||||
} from './pluginExtensions/getPluginExtensions';
|
||||
export { isPluginExtensionLink } from './pluginExtensions/utils';
|
||||
export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils';
|
||||
|
@ -1,13 +1,15 @@
|
||||
import { PluginExtension } from '@grafana/data';
|
||||
import type { PluginExtension, PluginExtensionLink, PluginExtensionComponent } from '@grafana/data';
|
||||
|
||||
export type GetPluginExtensions = ({
|
||||
import { isPluginExtensionComponent, isPluginExtensionLink } from './utils';
|
||||
|
||||
export type GetPluginExtensions<T = PluginExtension> = ({
|
||||
extensionPointId,
|
||||
context,
|
||||
}: {
|
||||
extensionPointId: string;
|
||||
context?: object | Record<string | symbol, unknown>;
|
||||
}) => {
|
||||
extensions: PluginExtension[];
|
||||
extensions: T[];
|
||||
};
|
||||
|
||||
let singleton: GetPluginExtensions | undefined;
|
||||
@ -28,3 +30,19 @@ function getPluginExtensionGetter(): GetPluginExtensions {
|
||||
}
|
||||
|
||||
export const getPluginExtensions: GetPluginExtensions = (options) => getPluginExtensionGetter()(options);
|
||||
|
||||
export const getPluginLinkExtensions: GetPluginExtensions<PluginExtensionLink> = (options) => {
|
||||
const { extensions } = getPluginExtensions(options);
|
||||
|
||||
return {
|
||||
extensions: extensions.filter(isPluginExtensionLink),
|
||||
};
|
||||
};
|
||||
|
||||
export const getPluginComponentExtensions: GetPluginExtensions<PluginExtensionComponent> = (options) => {
|
||||
const { extensions } = getPluginExtensions(options);
|
||||
|
||||
return {
|
||||
extensions: extensions.filter(isPluginExtensionComponent),
|
||||
};
|
||||
};
|
||||
|
@ -1,4 +1,9 @@
|
||||
import { PluginExtension, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data';
|
||||
import {
|
||||
type PluginExtension,
|
||||
type PluginExtensionComponent,
|
||||
type PluginExtensionLink,
|
||||
PluginExtensionTypes,
|
||||
} from '@grafana/data';
|
||||
|
||||
export function isPluginExtensionLink(extension: PluginExtension | undefined): extension is PluginExtensionLink {
|
||||
if (!extension) {
|
||||
@ -6,3 +11,12 @@ export function isPluginExtensionLink(extension: PluginExtension | undefined): e
|
||||
}
|
||||
return extension.type === PluginExtensionTypes.link && ('path' in extension || 'onClick' in extension);
|
||||
}
|
||||
|
||||
export function isPluginExtensionComponent(
|
||||
extension: PluginExtension | undefined
|
||||
): extension is PluginExtensionComponent {
|
||||
if (!extension) {
|
||||
return false;
|
||||
}
|
||||
return extension.type === PluginExtensionTypes.component && 'component' in extension;
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { PluginExtensionLinkConfig } from '@grafana/data';
|
||||
import type { PluginExtensionConfig } from '@grafana/data';
|
||||
|
||||
import { MAX_EXTENSIONS_PER_POINT } from './constants';
|
||||
|
||||
export class ExtensionsPerPlugin {
|
||||
private extensionsByExtensionPoint: Record<string, string[]> = {};
|
||||
|
||||
allowedToAdd({ extensionPointId, title }: PluginExtensionLinkConfig): boolean {
|
||||
allowedToAdd({ extensionPointId, title }: PluginExtensionConfig): boolean {
|
||||
if (this.countByExtensionPoint(extensionPointId) >= MAX_EXTENSIONS_PER_POINT) {
|
||||
return false;
|
||||
}
|
||||
|
@ -1,8 +1,9 @@
|
||||
import {
|
||||
type PluginExtension,
|
||||
PluginExtensionTypes,
|
||||
PluginExtensionLink,
|
||||
PluginExtensionLinkConfig,
|
||||
type PluginExtensionLink,
|
||||
type PluginExtensionLinkConfig,
|
||||
type PluginExtensionComponent,
|
||||
} from '@grafana/data';
|
||||
|
||||
import type { PluginExtensionRegistry } from './types';
|
||||
@ -12,8 +13,15 @@ import {
|
||||
logWarning,
|
||||
generateExtensionId,
|
||||
getEventHelpers,
|
||||
isPluginExtensionComponentConfig,
|
||||
} from './utils';
|
||||
import { assertIsNotPromise, assertLinkPathIsValid, assertStringProps, isPromise } from './validators';
|
||||
import {
|
||||
assertIsReactComponent,
|
||||
assertIsNotPromise,
|
||||
assertLinkPathIsValid,
|
||||
assertStringProps,
|
||||
isPromise,
|
||||
} from './validators';
|
||||
|
||||
type GetExtensions = ({
|
||||
context,
|
||||
@ -36,10 +44,12 @@ export const getPluginExtensions: GetExtensions = ({ context, extensionPointId,
|
||||
try {
|
||||
const extensionConfig = registryItem.config;
|
||||
|
||||
// LINK
|
||||
if (isPluginExtensionLinkConfig(extensionConfig)) {
|
||||
// Run the configure() function with the current context, and apply the ovverides
|
||||
const overrides = getLinkExtensionOverrides(registryItem.pluginId, extensionConfig, frozenContext);
|
||||
|
||||
// Hide (configure() has returned `undefined`)
|
||||
// configure() returned an `undefined` -> hide the extension
|
||||
if (extensionConfig.configure && overrides === undefined) {
|
||||
continue;
|
||||
}
|
||||
@ -58,6 +68,23 @@ export const getPluginExtensions: GetExtensions = ({ context, extensionPointId,
|
||||
|
||||
extensions.push(extension);
|
||||
}
|
||||
|
||||
// COMPONENT
|
||||
if (isPluginExtensionComponentConfig(extensionConfig)) {
|
||||
assertIsReactComponent(extensionConfig.component);
|
||||
|
||||
const extension: PluginExtensionComponent = {
|
||||
id: generateExtensionId(registryItem.pluginId, extensionConfig),
|
||||
type: PluginExtensionTypes.component,
|
||||
pluginId: registryItem.pluginId,
|
||||
|
||||
title: extensionConfig.title,
|
||||
description: extensionConfig.description,
|
||||
component: extensionConfig.component,
|
||||
};
|
||||
|
||||
extensions.push(extension);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logWarning(error.message);
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { type PluginExtensionLinkConfig } from '@grafana/data';
|
||||
import type { PluginExtensionConfig } from '@grafana/data';
|
||||
|
||||
// The information that is stored in the registry
|
||||
export type PluginExtensionRegistryItem = {
|
||||
// Any additional meta information that we would like to store about the extension in the registry
|
||||
pluginId: string;
|
||||
|
||||
config: PluginExtensionLinkConfig;
|
||||
config: PluginExtensionConfig;
|
||||
};
|
||||
|
||||
// A map of placement names to a list of extensions
|
||||
|
@ -3,6 +3,7 @@ import React from 'react';
|
||||
|
||||
import {
|
||||
type PluginExtensionLinkConfig,
|
||||
type PluginExtensionComponentConfig,
|
||||
type PluginExtensionConfig,
|
||||
type PluginExtensionEventHelpers,
|
||||
PluginExtensionTypes,
|
||||
@ -21,6 +22,12 @@ export function isPluginExtensionLinkConfig(
|
||||
return typeof extension === 'object' && 'type' in extension && extension['type'] === PluginExtensionTypes.link;
|
||||
}
|
||||
|
||||
export function isPluginExtensionComponentConfig(
|
||||
extension: PluginExtensionConfig | undefined
|
||||
): extension is PluginExtensionComponentConfig {
|
||||
return typeof extension === 'object' && 'type' in extension && extension['type'] === PluginExtensionTypes.component;
|
||||
}
|
||||
|
||||
export function handleErrorsInFn(fn: Function, errorMessagePrefix = '') {
|
||||
return (...args: unknown[]) => {
|
||||
try {
|
||||
|
@ -1,3 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
import { PluginExtension, PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data';
|
||||
|
||||
import {
|
||||
@ -7,6 +9,7 @@ import {
|
||||
assertPluginExtensionLink,
|
||||
assertStringProps,
|
||||
isPluginExtensionConfigValid,
|
||||
isReactComponent,
|
||||
} from './validators';
|
||||
|
||||
describe('Plugin Extension Validators', () => {
|
||||
@ -84,7 +87,6 @@ describe('Plugin Extension Validators', () => {
|
||||
type: PluginExtensionTypes.link,
|
||||
title: 'Title',
|
||||
description: 'Description',
|
||||
path: '...',
|
||||
extensionPointId: 'wrong-extension-point-id',
|
||||
});
|
||||
}).toThrowError();
|
||||
@ -96,7 +98,6 @@ describe('Plugin Extension Validators', () => {
|
||||
type: PluginExtensionTypes.link,
|
||||
title: 'Title',
|
||||
description: 'Description',
|
||||
path: '...',
|
||||
extensionPointId: 'grafana/some-page/extension-point-a',
|
||||
});
|
||||
|
||||
@ -104,7 +105,6 @@ describe('Plugin Extension Validators', () => {
|
||||
type: PluginExtensionTypes.link,
|
||||
title: 'Title',
|
||||
description: 'Description',
|
||||
path: '...',
|
||||
extensionPointId: 'plugins/my-super-plugin/some-page/extension-point-a',
|
||||
});
|
||||
}).not.toThrowError();
|
||||
@ -266,4 +266,18 @@ describe('Plugin Extension Validators', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isReactComponent()', () => {
|
||||
it('should return TRUE if we pass in a valid React component', () => {
|
||||
expect(isReactComponent(() => <div>Some text</div>)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return FALSE if we pass in a valid React component', () => {
|
||||
expect(isReactComponent('Foo bar')).toBe(false);
|
||||
expect(isReactComponent(123)).toBe(false);
|
||||
expect(isReactComponent(false)).toBe(false);
|
||||
expect(isReactComponent(undefined)).toBe(false);
|
||||
expect(isReactComponent(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,7 +1,12 @@
|
||||
import type { PluginExtension, PluginExtensionLink, PluginExtensionLinkConfig } from '@grafana/data';
|
||||
import type {
|
||||
PluginExtension,
|
||||
PluginExtensionConfig,
|
||||
PluginExtensionLink,
|
||||
PluginExtensionLinkConfig,
|
||||
} from '@grafana/data';
|
||||
import { isPluginExtensionLink } from '@grafana/runtime';
|
||||
|
||||
import { isPluginExtensionLinkConfig, logWarning } from './utils';
|
||||
import { isPluginExtensionComponentConfig, isPluginExtensionLinkConfig, logWarning } from './utils';
|
||||
|
||||
export function assertPluginExtensionLink(
|
||||
extension: PluginExtension | undefined,
|
||||
@ -29,7 +34,13 @@ export function assertLinkPathIsValid(pluginId: string, path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function assertExtensionPointIdIsValid(extension: PluginExtensionLinkConfig) {
|
||||
export function assertIsReactComponent(component: React.ComponentType) {
|
||||
if (!isReactComponent(component)) {
|
||||
throw new Error(`Invalid component extension, the "component" property needs to be a valid React component.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertExtensionPointIdIsValid(extension: PluginExtensionConfig) {
|
||||
if (!isExtensionPointIdValid(extension)) {
|
||||
throw new Error(
|
||||
`Invalid extension "${extension.title}". The extensionPointId should start with either "grafana/" or "plugins/" (currently: "${extension.extensionPointId}"). Skipping the extension.`
|
||||
@ -65,7 +76,7 @@ export function isLinkPathValid(pluginId: string, path: string) {
|
||||
return Boolean(typeof path === 'string' && path.length > 0 && path.startsWith(`/a/${pluginId}/`));
|
||||
}
|
||||
|
||||
export function isExtensionPointIdValid(extension: PluginExtensionLinkConfig) {
|
||||
export function isExtensionPointIdValid(extension: PluginExtensionConfig) {
|
||||
return Boolean(
|
||||
extension.extensionPointId?.startsWith('grafana/') || extension.extensionPointId?.startsWith('plugins/')
|
||||
);
|
||||
@ -79,13 +90,14 @@ export function isStringPropValid(prop: unknown) {
|
||||
return typeof prop === 'string' && prop.length > 0;
|
||||
}
|
||||
|
||||
export function isPluginExtensionConfigValid(pluginId: string, extension: PluginExtensionLinkConfig): boolean {
|
||||
export function isPluginExtensionConfigValid(pluginId: string, extension: PluginExtensionConfig): boolean {
|
||||
try {
|
||||
assertStringProps(extension, ['title', 'description', 'extensionPointId']);
|
||||
assertExtensionPointIdIsValid(extension);
|
||||
assertConfigureIsValid(extension);
|
||||
|
||||
if (isPluginExtensionLinkConfig(extension)) {
|
||||
assertConfigureIsValid(extension);
|
||||
|
||||
if (!extension.path && !extension.onClick) {
|
||||
logWarning(`Invalid extension "${extension.title}". Either "path" or "onClick" is required.`);
|
||||
return false;
|
||||
@ -96,6 +108,10 @@ export function isPluginExtensionConfigValid(pluginId: string, extension: Plugin
|
||||
}
|
||||
}
|
||||
|
||||
if (isPluginExtensionComponentConfig(extension)) {
|
||||
assertIsReactComponent(extension.component);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
@ -111,3 +127,9 @@ export function isPromise(value: unknown): value is Promise<unknown> {
|
||||
value instanceof Promise || (typeof value === 'object' && value !== null && 'then' in value && 'catch' in value)
|
||||
);
|
||||
}
|
||||
|
||||
export function isReactComponent(component: unknown): component is React.ComponentType {
|
||||
// We currently don't have any strict runtime-checking for this.
|
||||
// (The main reason is that we don't want to start depending on React implementation details.)
|
||||
return typeof component === 'function';
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import type { PluginExtensionLinkConfig } from '@grafana/data';
|
||||
import type { PluginExtensionConfig } from '@grafana/data';
|
||||
import type { AppPluginConfig } from '@grafana/runtime';
|
||||
import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
|
||||
|
||||
@ -7,7 +7,7 @@ import * as pluginLoader from './plugin_loader';
|
||||
export type PluginPreloadResult = {
|
||||
pluginId: string;
|
||||
error?: unknown;
|
||||
extensionConfigs: PluginExtensionLinkConfig[];
|
||||
extensionConfigs: PluginExtensionConfig[];
|
||||
};
|
||||
|
||||
export async function preloadPlugins(apps: Record<string, AppPluginConfig> = {}): Promise<PluginPreloadResult[]> {
|
||||
|
Loading…
Reference in New Issue
Block a user