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:
Levente Balogh 2023-05-31 09:26:37 +02:00 committed by GitHub
parent 82c770115e
commit a91033c025
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 203 additions and 49 deletions

View File

@ -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"]

View File

@ -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;
}
}
/**

View File

@ -56,8 +56,10 @@ export {
PluginExtensionPoints,
type PluginExtension,
type PluginExtensionLink,
type PluginExtensionComponent,
type PluginExtensionConfig,
type PluginExtensionLinkConfig,
type PluginExtensionComponentConfig,
type PluginExtensionEventHelpers,
type PluginExtensionPanelContext,
} from './pluginExtensions';

View File

@ -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 = {

View File

@ -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';

View File

@ -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),
};
};

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);

View File

@ -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

View File

@ -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 {

View File

@ -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);
});
});
});

View File

@ -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';
}

View File

@ -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[]> {