mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -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": [
|
"packages/grafana-data/src/types/app.ts:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
[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": [
|
"packages/grafana-data/src/types/config.ts:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||||
|
@ -3,7 +3,12 @@ import { ComponentType } from 'react';
|
|||||||
import { KeyValue } from './data';
|
import { KeyValue } from './data';
|
||||||
import { NavModel } from './navModel';
|
import { NavModel } from './navModel';
|
||||||
import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin';
|
import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin';
|
||||||
import { type PluginExtensionLinkConfig, PluginExtensionTypes } from './pluginExtensions';
|
import {
|
||||||
|
type PluginExtensionLinkConfig,
|
||||||
|
PluginExtensionTypes,
|
||||||
|
PluginExtensionComponentConfig,
|
||||||
|
PluginExtensionConfig,
|
||||||
|
} from './pluginExtensions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @public
|
* @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>> {
|
export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppPluginMeta<T>> {
|
||||||
private _extensionConfigs: PluginExtensionLinkConfig[] = [];
|
private _extensionConfigs: PluginExtensionConfig[] = [];
|
||||||
|
|
||||||
// Content under: /a/${plugin-id}/*
|
// Content under: /a/${plugin-id}/*
|
||||||
root?: ComponentType<AppRootProps<T>>;
|
root?: ComponentType<AppRootProps<T>>;
|
||||||
@ -105,6 +110,17 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
|
|||||||
|
|
||||||
return this;
|
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,
|
PluginExtensionPoints,
|
||||||
type PluginExtension,
|
type PluginExtension,
|
||||||
type PluginExtensionLink,
|
type PluginExtensionLink,
|
||||||
|
type PluginExtensionComponent,
|
||||||
type PluginExtensionConfig,
|
type PluginExtensionConfig,
|
||||||
type PluginExtensionLinkConfig,
|
type PluginExtensionLinkConfig,
|
||||||
|
type PluginExtensionComponentConfig,
|
||||||
type PluginExtensionEventHelpers,
|
type PluginExtensionEventHelpers,
|
||||||
type PluginExtensionPanelContext,
|
type PluginExtensionPanelContext,
|
||||||
} from './pluginExtensions';
|
} from './pluginExtensions';
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { DataQuery } from '@grafana/schema';
|
import { DataQuery } from '@grafana/schema';
|
||||||
|
|
||||||
import { ScopedVars } from './ScopedVars';
|
import { ScopedVars } from './ScopedVars';
|
||||||
@ -9,9 +11,10 @@ import { RawTimeRange, TimeZone } from './time';
|
|||||||
|
|
||||||
export enum PluginExtensionTypes {
|
export enum PluginExtensionTypes {
|
||||||
link = 'link',
|
link = 'link',
|
||||||
|
component = 'component',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PluginExtension = {
|
type PluginExtensionBase = {
|
||||||
id: string;
|
id: string;
|
||||||
type: PluginExtensionTypes;
|
type: PluginExtensionTypes;
|
||||||
title: string;
|
title: string;
|
||||||
@ -19,37 +22,64 @@ export type PluginExtension = {
|
|||||||
pluginId: string;
|
pluginId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PluginExtensionLink = PluginExtension & {
|
export type PluginExtensionLink = PluginExtensionBase & {
|
||||||
type: PluginExtensionTypes.link;
|
type: PluginExtensionTypes.link;
|
||||||
path?: string;
|
path?: string;
|
||||||
onClick?: (event?: React.MouseEvent) => void;
|
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)
|
// 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<
|
// A URL path that will be used as the href for the rendered link extension
|
||||||
PluginExtension,
|
// (It is optional, because in some cases the action will be handled by the `onClick` handler instead of navigating to a new page)
|
||||||
'title' | 'description'
|
path?: string;
|
||||||
> &
|
|
||||||
ExtraProps & {
|
|
||||||
// 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
|
// A function that will be called when the link is clicked
|
||||||
configure?: (
|
// (It is called with the original event object)
|
||||||
context?: Readonly<Context>
|
onClick?: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => void;
|
||||||
) => Partial<{ title: string; description: string } & ExtraProps> | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PluginExtensionLinkConfig<Context extends object = object> = PluginExtensionConfig<
|
// The unique identifier of the Extension Point
|
||||||
Context,
|
// (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum)
|
||||||
Pick<PluginExtensionLink, 'path'> & {
|
extensionPointId: string;
|
||||||
type: PluginExtensionTypes.link;
|
|
||||||
onClick?: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => void;
|
// (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> = {
|
export type PluginExtensionEventHelpers<Context extends object = object> = {
|
||||||
context?: Readonly<Context>;
|
context?: Readonly<Context>;
|
||||||
@ -68,6 +98,7 @@ export type PluginExtensionEventHelpers<Context extends object = object> = {
|
|||||||
// Extension Points available in core Grafana
|
// Extension Points available in core Grafana
|
||||||
export enum PluginExtensionPoints {
|
export enum PluginExtensionPoints {
|
||||||
DashboardPanelMenu = 'grafana/dashboard/panel/menu',
|
DashboardPanelMenu = 'grafana/dashboard/panel/menu',
|
||||||
|
DataSourceConfig = 'grafana/datasources/config',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PluginExtensionPanelContext = {
|
export type PluginExtensionPanelContext = {
|
||||||
|
@ -12,6 +12,8 @@ export * from './appEvents';
|
|||||||
export {
|
export {
|
||||||
setPluginExtensionGetter,
|
setPluginExtensionGetter,
|
||||||
getPluginExtensions,
|
getPluginExtensions,
|
||||||
|
getPluginLinkExtensions,
|
||||||
|
getPluginComponentExtensions,
|
||||||
type GetPluginExtensions,
|
type GetPluginExtensions,
|
||||||
} from './pluginExtensions/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,
|
extensionPointId,
|
||||||
context,
|
context,
|
||||||
}: {
|
}: {
|
||||||
extensionPointId: string;
|
extensionPointId: string;
|
||||||
context?: object | Record<string | symbol, unknown>;
|
context?: object | Record<string | symbol, unknown>;
|
||||||
}) => {
|
}) => {
|
||||||
extensions: PluginExtension[];
|
extensions: T[];
|
||||||
};
|
};
|
||||||
|
|
||||||
let singleton: GetPluginExtensions | undefined;
|
let singleton: GetPluginExtensions | undefined;
|
||||||
@ -28,3 +30,19 @@ function getPluginExtensionGetter(): GetPluginExtensions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getPluginExtensions: GetPluginExtensions = (options) => getPluginExtensionGetter()(options);
|
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 {
|
export function isPluginExtensionLink(extension: PluginExtension | undefined): extension is PluginExtensionLink {
|
||||||
if (!extension) {
|
if (!extension) {
|
||||||
@ -6,3 +11,12 @@ export function isPluginExtensionLink(extension: PluginExtension | undefined): e
|
|||||||
}
|
}
|
||||||
return extension.type === PluginExtensionTypes.link && ('path' in extension || 'onClick' in extension);
|
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';
|
import { MAX_EXTENSIONS_PER_POINT } from './constants';
|
||||||
|
|
||||||
export class ExtensionsPerPlugin {
|
export class ExtensionsPerPlugin {
|
||||||
private extensionsByExtensionPoint: Record<string, string[]> = {};
|
private extensionsByExtensionPoint: Record<string, string[]> = {};
|
||||||
|
|
||||||
allowedToAdd({ extensionPointId, title }: PluginExtensionLinkConfig): boolean {
|
allowedToAdd({ extensionPointId, title }: PluginExtensionConfig): boolean {
|
||||||
if (this.countByExtensionPoint(extensionPointId) >= MAX_EXTENSIONS_PER_POINT) {
|
if (this.countByExtensionPoint(extensionPointId) >= MAX_EXTENSIONS_PER_POINT) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
type PluginExtension,
|
type PluginExtension,
|
||||||
PluginExtensionTypes,
|
PluginExtensionTypes,
|
||||||
PluginExtensionLink,
|
type PluginExtensionLink,
|
||||||
PluginExtensionLinkConfig,
|
type PluginExtensionLinkConfig,
|
||||||
|
type PluginExtensionComponent,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
|
|
||||||
import type { PluginExtensionRegistry } from './types';
|
import type { PluginExtensionRegistry } from './types';
|
||||||
@ -12,8 +13,15 @@ import {
|
|||||||
logWarning,
|
logWarning,
|
||||||
generateExtensionId,
|
generateExtensionId,
|
||||||
getEventHelpers,
|
getEventHelpers,
|
||||||
|
isPluginExtensionComponentConfig,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import { assertIsNotPromise, assertLinkPathIsValid, assertStringProps, isPromise } from './validators';
|
import {
|
||||||
|
assertIsReactComponent,
|
||||||
|
assertIsNotPromise,
|
||||||
|
assertLinkPathIsValid,
|
||||||
|
assertStringProps,
|
||||||
|
isPromise,
|
||||||
|
} from './validators';
|
||||||
|
|
||||||
type GetExtensions = ({
|
type GetExtensions = ({
|
||||||
context,
|
context,
|
||||||
@ -36,10 +44,12 @@ export const getPluginExtensions: GetExtensions = ({ context, extensionPointId,
|
|||||||
try {
|
try {
|
||||||
const extensionConfig = registryItem.config;
|
const extensionConfig = registryItem.config;
|
||||||
|
|
||||||
|
// LINK
|
||||||
if (isPluginExtensionLinkConfig(extensionConfig)) {
|
if (isPluginExtensionLinkConfig(extensionConfig)) {
|
||||||
|
// Run the configure() function with the current context, and apply the ovverides
|
||||||
const overrides = getLinkExtensionOverrides(registryItem.pluginId, extensionConfig, frozenContext);
|
const overrides = getLinkExtensionOverrides(registryItem.pluginId, extensionConfig, frozenContext);
|
||||||
|
|
||||||
// Hide (configure() has returned `undefined`)
|
// configure() returned an `undefined` -> hide the extension
|
||||||
if (extensionConfig.configure && overrides === undefined) {
|
if (extensionConfig.configure && overrides === undefined) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -58,6 +68,23 @@ export const getPluginExtensions: GetExtensions = ({ context, extensionPointId,
|
|||||||
|
|
||||||
extensions.push(extension);
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
logWarning(error.message);
|
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
|
// The information that is stored in the registry
|
||||||
export type PluginExtensionRegistryItem = {
|
export type PluginExtensionRegistryItem = {
|
||||||
// Any additional meta information that we would like to store about the extension in the registry
|
// Any additional meta information that we would like to store about the extension in the registry
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
|
|
||||||
config: PluginExtensionLinkConfig;
|
config: PluginExtensionConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
// A map of placement names to a list of extensions
|
// A map of placement names to a list of extensions
|
||||||
|
@ -3,6 +3,7 @@ import React from 'react';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type PluginExtensionLinkConfig,
|
type PluginExtensionLinkConfig,
|
||||||
|
type PluginExtensionComponentConfig,
|
||||||
type PluginExtensionConfig,
|
type PluginExtensionConfig,
|
||||||
type PluginExtensionEventHelpers,
|
type PluginExtensionEventHelpers,
|
||||||
PluginExtensionTypes,
|
PluginExtensionTypes,
|
||||||
@ -21,6 +22,12 @@ export function isPluginExtensionLinkConfig(
|
|||||||
return typeof extension === 'object' && 'type' in extension && extension['type'] === PluginExtensionTypes.link;
|
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 = '') {
|
export function handleErrorsInFn(fn: Function, errorMessagePrefix = '') {
|
||||||
return (...args: unknown[]) => {
|
return (...args: unknown[]) => {
|
||||||
try {
|
try {
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { PluginExtension, PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data';
|
import { PluginExtension, PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -7,6 +9,7 @@ import {
|
|||||||
assertPluginExtensionLink,
|
assertPluginExtensionLink,
|
||||||
assertStringProps,
|
assertStringProps,
|
||||||
isPluginExtensionConfigValid,
|
isPluginExtensionConfigValid,
|
||||||
|
isReactComponent,
|
||||||
} from './validators';
|
} from './validators';
|
||||||
|
|
||||||
describe('Plugin Extension Validators', () => {
|
describe('Plugin Extension Validators', () => {
|
||||||
@ -84,7 +87,6 @@ describe('Plugin Extension Validators', () => {
|
|||||||
type: PluginExtensionTypes.link,
|
type: PluginExtensionTypes.link,
|
||||||
title: 'Title',
|
title: 'Title',
|
||||||
description: 'Description',
|
description: 'Description',
|
||||||
path: '...',
|
|
||||||
extensionPointId: 'wrong-extension-point-id',
|
extensionPointId: 'wrong-extension-point-id',
|
||||||
});
|
});
|
||||||
}).toThrowError();
|
}).toThrowError();
|
||||||
@ -96,7 +98,6 @@ describe('Plugin Extension Validators', () => {
|
|||||||
type: PluginExtensionTypes.link,
|
type: PluginExtensionTypes.link,
|
||||||
title: 'Title',
|
title: 'Title',
|
||||||
description: 'Description',
|
description: 'Description',
|
||||||
path: '...',
|
|
||||||
extensionPointId: 'grafana/some-page/extension-point-a',
|
extensionPointId: 'grafana/some-page/extension-point-a',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -104,7 +105,6 @@ describe('Plugin Extension Validators', () => {
|
|||||||
type: PluginExtensionTypes.link,
|
type: PluginExtensionTypes.link,
|
||||||
title: 'Title',
|
title: 'Title',
|
||||||
description: 'Description',
|
description: 'Description',
|
||||||
path: '...',
|
|
||||||
extensionPointId: 'plugins/my-super-plugin/some-page/extension-point-a',
|
extensionPointId: 'plugins/my-super-plugin/some-page/extension-point-a',
|
||||||
});
|
});
|
||||||
}).not.toThrowError();
|
}).not.toThrowError();
|
||||||
@ -266,4 +266,18 @@ describe('Plugin Extension Validators', () => {
|
|||||||
).toBe(false);
|
).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 { isPluginExtensionLink } from '@grafana/runtime';
|
||||||
|
|
||||||
import { isPluginExtensionLinkConfig, logWarning } from './utils';
|
import { isPluginExtensionComponentConfig, isPluginExtensionLinkConfig, logWarning } from './utils';
|
||||||
|
|
||||||
export function assertPluginExtensionLink(
|
export function assertPluginExtensionLink(
|
||||||
extension: PluginExtension | undefined,
|
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)) {
|
if (!isExtensionPointIdValid(extension)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid extension "${extension.title}". The extensionPointId should start with either "grafana/" or "plugins/" (currently: "${extension.extensionPointId}"). Skipping the extension.`
|
`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}/`));
|
return Boolean(typeof path === 'string' && path.length > 0 && path.startsWith(`/a/${pluginId}/`));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isExtensionPointIdValid(extension: PluginExtensionLinkConfig) {
|
export function isExtensionPointIdValid(extension: PluginExtensionConfig) {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
extension.extensionPointId?.startsWith('grafana/') || extension.extensionPointId?.startsWith('plugins/')
|
extension.extensionPointId?.startsWith('grafana/') || extension.extensionPointId?.startsWith('plugins/')
|
||||||
);
|
);
|
||||||
@ -79,13 +90,14 @@ export function isStringPropValid(prop: unknown) {
|
|||||||
return typeof prop === 'string' && prop.length > 0;
|
return typeof prop === 'string' && prop.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPluginExtensionConfigValid(pluginId: string, extension: PluginExtensionLinkConfig): boolean {
|
export function isPluginExtensionConfigValid(pluginId: string, extension: PluginExtensionConfig): boolean {
|
||||||
try {
|
try {
|
||||||
assertStringProps(extension, ['title', 'description', 'extensionPointId']);
|
assertStringProps(extension, ['title', 'description', 'extensionPointId']);
|
||||||
assertExtensionPointIdIsValid(extension);
|
assertExtensionPointIdIsValid(extension);
|
||||||
assertConfigureIsValid(extension);
|
|
||||||
|
|
||||||
if (isPluginExtensionLinkConfig(extension)) {
|
if (isPluginExtensionLinkConfig(extension)) {
|
||||||
|
assertConfigureIsValid(extension);
|
||||||
|
|
||||||
if (!extension.path && !extension.onClick) {
|
if (!extension.path && !extension.onClick) {
|
||||||
logWarning(`Invalid extension "${extension.title}". Either "path" or "onClick" is required.`);
|
logWarning(`Invalid extension "${extension.title}". Either "path" or "onClick" is required.`);
|
||||||
return false;
|
return false;
|
||||||
@ -96,6 +108,10 @@ export function isPluginExtensionConfigValid(pluginId: string, extension: Plugin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPluginExtensionComponentConfig(extension)) {
|
||||||
|
assertIsReactComponent(extension.component);
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof 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)
|
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 type { AppPluginConfig } from '@grafana/runtime';
|
||||||
import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
|
import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
|
||||||
|
|
||||||
@ -7,7 +7,7 @@ import * as pluginLoader from './plugin_loader';
|
|||||||
export type PluginPreloadResult = {
|
export type PluginPreloadResult = {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
error?: unknown;
|
error?: unknown;
|
||||||
extensionConfigs: PluginExtensionLinkConfig[];
|
extensionConfigs: PluginExtensionConfig[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function preloadPlugins(apps: Record<string, AppPluginConfig> = {}): Promise<PluginPreloadResult[]> {
|
export async function preloadPlugins(apps: Record<string, AppPluginConfig> = {}): Promise<PluginPreloadResult[]> {
|
||||||
|
Loading…
Reference in New Issue
Block a user