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": [ "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"]

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

View File

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

View File

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

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

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