Plugins: Allow apps to expose components. Update the extensions API. (#87236)

* feat: introduce exposable components and update the public APIs

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>

* tests: fix the tests for `usePluginComponent()`

I broke them when I wrapped the component with the PluginContextProvider which fetches the plugin metadata.

* fix: typo

---------

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>
This commit is contained in:
Levente Balogh
2024-06-07 18:05:00 +02:00
committed by GitHub
co-authored by Marcus Andersson
parent 27a791db12
commit ebe42e1ada
17 changed files with 366 additions and 38 deletions
+62 -6
View File
@@ -102,23 +102,79 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
return this._extensionConfigs;
}
configureExtensionLink<Context extends object>(extension: Omit<PluginExtensionLinkConfig<Context>, 'type'>) {
this._extensionConfigs.push({
...extension,
type: PluginExtensionTypes.link,
} as PluginExtensionLinkConfig);
addLink<Context extends object>(
extensionConfig: { targets: string | string[] } & Omit<
PluginExtensionLinkConfig<Context>,
'type' | 'extensionPointId'
>
) {
const { targets, ...extension } = extensionConfig;
const targetsArray = Array.isArray(targets) ? targets : [targets];
targetsArray.forEach((target) => {
this._extensionConfigs.push({
...extension,
extensionPointId: target,
type: PluginExtensionTypes.link,
} as PluginExtensionLinkConfig);
});
return this;
}
configureExtensionComponent<Props = {}>(extension: Omit<PluginExtensionComponentConfig<Props>, 'type'>) {
addComponent<Props = {}>(
extensionConfig: { targets: string | string[] } & Omit<
PluginExtensionComponentConfig<Props>,
'type' | 'extensionPointId'
>
) {
const { targets, ...extension } = extensionConfig;
const targetsArray = Array.isArray(targets) ? targets : [targets];
targetsArray.forEach((target) => {
this._extensionConfigs.push({
...extension,
extensionPointId: target,
type: PluginExtensionTypes.component,
} as PluginExtensionComponentConfig);
});
return this;
}
exposeComponent<Props = {}>(
componentConfig: { id: string } & Omit<PluginExtensionComponentConfig<Props>, 'type' | 'extensionPointId'>
) {
const { id, ...extension } = componentConfig;
this._extensionConfigs.push({
...extension,
extensionPointId: `capabilities/${id}`,
type: PluginExtensionTypes.component,
} as PluginExtensionComponentConfig);
return this;
}
/** @deprecated Use .addLink() instead */
configureExtensionLink<Context extends object>(extension: Omit<PluginExtensionLinkConfig<Context>, 'type'>) {
this.addLink({
targets: [extension.extensionPointId],
...extension,
});
return this;
}
/** @deprecated Use .addComponent() instead */
configureExtensionComponent<Props = {}>(extension: Omit<PluginExtensionComponentConfig<Props>, 'type'>) {
this.addComponent({
targets: [extension.extensionPointId],
...extension,
});
return this;
}
}
/**
@@ -54,8 +54,10 @@ export type PluginExtensionLinkConfig<Context extends object = object> = {
// (It is called with the original event object)
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)
/**
* 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
@@ -86,8 +88,10 @@ export type PluginExtensionComponentConfig<Props = {}> = {
// (This component receives contextual information as props when it is rendered. You can just return `null` from the component to hide it.)
component: React.ComponentType<Props>;
// The unique identifier of the Extension Point
// (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum)
/**
* The unique identifier of the Extension Point
* (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum)
*/
extensionPointId: string;
};
@@ -19,11 +19,17 @@ export {
type GetPluginExtensionsResult,
type UsePluginExtensions,
type UsePluginExtensionsResult,
type UsePluginComponentResult,
} from './pluginExtensions/getPluginExtensions';
export {
setPluginExtensionsHook,
usePluginExtensions,
usePluginLinkExtensions,
usePluginComponentExtensions,
usePluginComponents,
usePluginLinks,
} from './pluginExtensions/usePluginExtensions';
export { setPluginComponentHook, usePluginComponent } from './pluginExtensions/usePluginComponent';
export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils';
@@ -25,6 +25,11 @@ export type UsePluginExtensionsResult<T = PluginExtension> = {
isLoading: boolean;
};
export type UsePluginComponentResult<Props = {}> = {
component: React.ComponentType<Props> | undefined | null;
isLoading: boolean;
};
let singleton: GetPluginExtensions | undefined;
export function setPluginExtensionGetter(instance: GetPluginExtensions): void {
@@ -0,0 +1,20 @@
import { UsePluginComponentResult } from './getPluginExtensions';
export type UsePluginComponent<Props extends object = {}> = (id: string) => UsePluginComponentResult<Props>;
let singleton: UsePluginComponent | undefined;
export function setPluginComponentHook(hook: UsePluginComponent): void {
// We allow overriding the registry in tests
if (singleton && process.env.NODE_ENV !== 'test') {
throw new Error('setPluginComponentHook() function should only be called once, when Grafana is starting.');
}
singleton = hook;
}
export function usePluginComponent<Props extends object = {}>(id: string): UsePluginComponentResult<Props> {
if (!singleton) {
throw new Error('setPluginComponentHook(options) can only be used after the Grafana instance has started.');
}
return singleton(id) as UsePluginComponentResult<Props>;
}
@@ -15,6 +15,9 @@ export function setPluginExtensionsHook(hook: UsePluginExtensions): void {
singleton = hook;
}
/**
* @deprecated Use either usePluginLinks() or usePluginComponents() instead.
*/
export function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult {
if (!singleton) {
throw new Error('usePluginExtensions(options) can only be used after the Grafana instance has started.');
@@ -22,6 +25,37 @@ export function usePluginExtensions(options: GetPluginExtensionsOptions): UsePlu
return singleton(options);
}
export function usePluginLinks(options: GetPluginExtensionsOptions): {
links: PluginExtensionLink[];
isLoading: boolean;
} {
const { extensions, isLoading } = usePluginExtensions(options);
return useMemo(() => {
return {
links: extensions.filter(isPluginExtensionLink),
isLoading,
};
}, [extensions, isLoading]);
}
export function usePluginComponents<Props = {}>(
options: GetPluginExtensionsOptions
): { components: Array<PluginExtensionComponent<Props>>; isLoading: boolean } {
const { extensions, isLoading } = usePluginExtensions(options);
return useMemo(
() => ({
components: extensions.filter(isPluginExtensionComponent) as Array<PluginExtensionComponent<Props>>,
isLoading,
}),
[extensions, isLoading]
);
}
/**
* @deprecated Use usePluginLinks() instead.
*/
export function usePluginLinkExtensions(
options: GetPluginExtensionsOptions
): UsePluginExtensionsResult<PluginExtensionLink> {
@@ -35,6 +69,9 @@ export function usePluginLinkExtensions(
}, [extensions, isLoading]);
}
/**
* @deprecated Use usePluginComponents() instead.
*/
export function usePluginComponentExtensions<Props = {}>(
options: GetPluginExtensionsOptions
): { extensions: Array<PluginExtensionComponent<Props>>; isLoading: boolean } {