mirror of
https://github.com/grafana/grafana.git
synced 2024-11-24 09:50:29 -06:00
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:
parent
27a791db12
commit
ebe42e1ada
@ -199,7 +199,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.", "2"]
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "3"]
|
||||
],
|
||||
"packages/grafana-data/src/types/config.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
@ -551,9 +552,13 @@ exports[`better eslint`] = {
|
||||
"packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts:5381": [
|
||||
"packages/grafana-runtime/src/services/pluginExtensions/usePluginComponent.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
],
|
||||
"packages/grafana-runtime/src/utils/DataSourceWithBackend.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
|
@ -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 } {
|
||||
|
@ -37,6 +37,7 @@ import {
|
||||
setAppEvents,
|
||||
setReturnToPreviousHook,
|
||||
setPluginExtensionsHook,
|
||||
setPluginComponentHook,
|
||||
} from '@grafana/runtime';
|
||||
import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView';
|
||||
import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer';
|
||||
@ -83,7 +84,8 @@ import { DatasourceSrv } from './features/plugins/datasource_srv';
|
||||
import { getCoreExtensionConfigurations } from './features/plugins/extensions/getCoreExtensionConfigurations';
|
||||
import { createPluginExtensionsGetter } from './features/plugins/extensions/getPluginExtensions';
|
||||
import { ReactivePluginExtensionsRegistry } from './features/plugins/extensions/reactivePluginExtensionRegistry';
|
||||
import { createPluginExtensionsHook } from './features/plugins/extensions/usePluginExtensions';
|
||||
import { createUsePluginComponent } from './features/plugins/extensions/usePluginComponent';
|
||||
import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions';
|
||||
import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin';
|
||||
import { preloadPlugins } from './features/plugins/pluginPreloader';
|
||||
import { QueryRunner } from './features/query/state/QueryRunner';
|
||||
@ -226,7 +228,8 @@ export class GrafanaApp {
|
||||
}
|
||||
|
||||
setPluginExtensionGetter(createPluginExtensionsGetter(extensionsRegistry));
|
||||
setPluginExtensionsHook(createPluginExtensionsHook(extensionsRegistry));
|
||||
setPluginExtensionsHook(createUsePluginExtensions(extensionsRegistry));
|
||||
setPluginComponentHook(createUsePluginComponent(extensionsRegistry));
|
||||
|
||||
// initialize chrome service
|
||||
const queryParams = locationService.getSearchObject();
|
||||
|
@ -41,9 +41,9 @@ type GetExtensions = ({
|
||||
registry: PluginExtensionRegistry;
|
||||
}) => { extensions: PluginExtension[] };
|
||||
|
||||
let registry: PluginExtensionRegistry = { id: '', extensions: {} };
|
||||
|
||||
export function createPluginExtensionsGetter(extensionRegistry: ReactivePluginExtensionsRegistry): GetPluginExtensions {
|
||||
let registry: PluginExtensionRegistry = { id: '', extensions: {} };
|
||||
|
||||
// Create a subscription to keep an copy of the registry state for use in the non-async
|
||||
// plugin extensions getter.
|
||||
extensionRegistry.asObservable().subscribe((r) => {
|
||||
|
@ -4,7 +4,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { PluginPreloadResult } from '../pluginPreloader';
|
||||
|
||||
import { PluginExtensionRegistry, PluginExtensionRegistryItem } from './types';
|
||||
import { deepFreeze, logWarning } from './utils';
|
||||
import { deepFreeze, isPluginCapability, logWarning } from './utils';
|
||||
import { isPluginExtensionConfigValid } from './validators';
|
||||
|
||||
export class ReactivePluginExtensionsRegistry {
|
||||
@ -54,6 +54,22 @@ function resultsToRegistry(registry: PluginExtensionRegistry, result: PluginPrel
|
||||
for (const extensionConfig of extensionConfigs) {
|
||||
const { extensionPointId } = extensionConfig;
|
||||
|
||||
// Change the extension point id for capabilities
|
||||
if (isPluginCapability(extensionConfig)) {
|
||||
const regex = /capabilities\/([a-zA-Z0-9_.\-\/]+)$/;
|
||||
const match = regex.exec(extensionPointId);
|
||||
|
||||
if (!match) {
|
||||
logWarning(
|
||||
`"${pluginId}" plugin has an invalid capability ID: ${extensionPointId.replace('capabilities/', '')} (It must be a string)`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
extensionConfig.extensionPointId = `capabilities/${match[1]}`;
|
||||
}
|
||||
|
||||
// Check if the config is valid
|
||||
if (!extensionConfig || !isPluginExtensionConfigValid(pluginId, extensionConfig)) {
|
||||
return registry;
|
||||
}
|
||||
@ -65,7 +81,12 @@ function resultsToRegistry(registry: PluginExtensionRegistry, result: PluginPrel
|
||||
pluginId,
|
||||
};
|
||||
|
||||
if (!Array.isArray(registry.extensions[extensionPointId])) {
|
||||
// Capability (only a single value per identifier, can be overriden)
|
||||
if (isPluginCapability(extensionConfig)) {
|
||||
registry.extensions[extensionPointId] = [registryItem];
|
||||
}
|
||||
// Extension (multiple extensions per extension point identifier)
|
||||
else if (!Array.isArray(registry.extensions[extensionPointId])) {
|
||||
registry.extensions[extensionPointId] = [registryItem];
|
||||
} else {
|
||||
registry.extensions[extensionPointId].push(registryItem);
|
||||
|
@ -0,0 +1,114 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import React from 'react';
|
||||
|
||||
import { PluginExtensionTypes } from '@grafana/data';
|
||||
|
||||
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
|
||||
import { createUsePluginComponent } from './usePluginComponent';
|
||||
|
||||
jest.mock('app/features/plugins/pluginSettings', () => ({
|
||||
getPluginSettings: jest.fn().mockResolvedValue({
|
||||
id: 'my-app-plugin',
|
||||
enabled: true,
|
||||
jsonData: {},
|
||||
type: 'panel',
|
||||
name: 'My App Plugin',
|
||||
module: 'app/plugins/my-app-plugin/module',
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('usePluginComponent()', () => {
|
||||
let reactiveRegistry: ReactivePluginExtensionsRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
reactiveRegistry = new ReactivePluginExtensionsRegistry();
|
||||
});
|
||||
|
||||
it('should return null if there are no component exposed for the id', () => {
|
||||
const usePluginComponent = createUsePluginComponent(reactiveRegistry);
|
||||
const { result } = renderHook(() => usePluginComponent('foo/bar'));
|
||||
|
||||
expect(result.current.component).toEqual(null);
|
||||
expect(result.current.isLoading).toEqual(false);
|
||||
});
|
||||
|
||||
it('should return component, that can be rendered, from the registry', async () => {
|
||||
const id = 'my-app-plugin/foo/bar';
|
||||
const pluginId = 'my-app-plugin';
|
||||
|
||||
reactiveRegistry.register({
|
||||
pluginId,
|
||||
extensionConfigs: [
|
||||
{
|
||||
extensionPointId: `capabilities/${id}`,
|
||||
type: PluginExtensionTypes.component,
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => <div>Hello World</div>,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const usePluginComponent = createUsePluginComponent(reactiveRegistry);
|
||||
const { result } = renderHook(() => usePluginComponent(id));
|
||||
const Component = result.current.component;
|
||||
|
||||
act(() => {
|
||||
render(Component && <Component />);
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toEqual(false);
|
||||
expect(result.current.component).not.toBeNull();
|
||||
expect(await screen.findByText('Hello World')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should dynamically update when component is registered to the registry', async () => {
|
||||
const id = 'my-app-plugin/foo/bar';
|
||||
const pluginId = 'my-app-plugin';
|
||||
const usePluginComponent = createUsePluginComponent(reactiveRegistry);
|
||||
const { result, rerender } = renderHook(() => usePluginComponent(id));
|
||||
|
||||
// No extensions yet
|
||||
expect(result.current.component).toBeNull();
|
||||
expect(result.current.isLoading).toEqual(false);
|
||||
|
||||
// Add extensions to the registry
|
||||
act(() => {
|
||||
reactiveRegistry.register({
|
||||
pluginId,
|
||||
extensionConfigs: [
|
||||
{
|
||||
extensionPointId: `capabilities/${id}`,
|
||||
type: PluginExtensionTypes.component,
|
||||
title: 'not important',
|
||||
description: 'not important',
|
||||
component: () => <div>Hello World</div>,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// Check if the hook returns the new extensions
|
||||
rerender();
|
||||
|
||||
const Component = result.current.component;
|
||||
expect(result.current.isLoading).toEqual(false);
|
||||
expect(result.current.component).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
render(Component && <Component />);
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Hello World')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should only render the hook once', () => {
|
||||
const spy = jest.spyOn(reactiveRegistry, 'asObservable');
|
||||
const id = 'my-app-plugin/foo/bar';
|
||||
const usePluginComponent = createUsePluginComponent(reactiveRegistry);
|
||||
|
||||
renderHook(() => usePluginComponent(id));
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
@ -0,0 +1,42 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useObservable } from 'react-use';
|
||||
|
||||
import { UsePluginComponentResult } from '@grafana/runtime';
|
||||
|
||||
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
|
||||
import { isPluginExtensionComponentConfig, wrapWithPluginContext } from './utils';
|
||||
|
||||
// Returns a component exposed by a plugin.
|
||||
// (Exposed components can be defined in plugins by calling .exposeComponent() on the AppPlugin instance.)
|
||||
export function createUsePluginComponent(extensionsRegistry: ReactivePluginExtensionsRegistry) {
|
||||
const observableRegistry = extensionsRegistry.asObservable();
|
||||
|
||||
return function usePluginComponent<Props extends object = {}>(id: string): UsePluginComponentResult<Props> {
|
||||
const registry = useObservable(observableRegistry);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!registry) {
|
||||
return {
|
||||
isLoading: false,
|
||||
component: null,
|
||||
};
|
||||
}
|
||||
|
||||
const registryId = `capabilities/${id}`;
|
||||
const registryItems = registry.extensions[registryId];
|
||||
const registryItem = Array.isArray(registryItems) ? registryItems[0] : null;
|
||||
|
||||
if (registryItem && isPluginExtensionComponentConfig<Props>(registryItem.config)) {
|
||||
return {
|
||||
isLoading: false,
|
||||
component: wrapWithPluginContext(registryItem.pluginId, registryItem.config.component),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isLoading: false,
|
||||
component: null,
|
||||
};
|
||||
}, [id, registry]);
|
||||
};
|
||||
}
|
@ -4,7 +4,7 @@ import { renderHook } from '@testing-library/react-hooks';
|
||||
import { PluginExtensionTypes } from '@grafana/data';
|
||||
|
||||
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
|
||||
import { createPluginExtensionsHook } from './usePluginExtensions';
|
||||
import { createUsePluginExtensions } from './usePluginExtensions';
|
||||
|
||||
describe('usePluginExtensions()', () => {
|
||||
let reactiveRegistry: ReactivePluginExtensionsRegistry;
|
||||
@ -14,7 +14,7 @@ describe('usePluginExtensions()', () => {
|
||||
});
|
||||
|
||||
it('should return an empty array if there are no extensions registered for the extension point', () => {
|
||||
const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry);
|
||||
const usePluginExtensions = createUsePluginExtensions(reactiveRegistry);
|
||||
const { result } = renderHook(() =>
|
||||
usePluginExtensions({
|
||||
extensionPointId: 'foo/bar',
|
||||
@ -48,7 +48,7 @@ describe('usePluginExtensions()', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry);
|
||||
const usePluginExtensions = createUsePluginExtensions(reactiveRegistry);
|
||||
const { result } = renderHook(() => usePluginExtensions({ extensionPointId }));
|
||||
|
||||
expect(result.current.extensions.length).toBe(2);
|
||||
@ -59,7 +59,7 @@ describe('usePluginExtensions()', () => {
|
||||
it('should dynamically update the extensions registered for a certain extension point', () => {
|
||||
const extensionPointId = 'plugins/foo/bar';
|
||||
const pluginId = 'my-app-plugin';
|
||||
const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry);
|
||||
const usePluginExtensions = createUsePluginExtensions(reactiveRegistry);
|
||||
let { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId }));
|
||||
|
||||
// No extensions yet
|
||||
@ -99,7 +99,7 @@ describe('usePluginExtensions()', () => {
|
||||
it('should only render the hook once', () => {
|
||||
const spy = jest.spyOn(reactiveRegistry, 'asObservable');
|
||||
const extensionPointId = 'plugins/foo/bar';
|
||||
const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry);
|
||||
const usePluginExtensions = createUsePluginExtensions(reactiveRegistry);
|
||||
|
||||
renderHook(() => usePluginExtensions({ extensionPointId }));
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
@ -108,7 +108,7 @@ describe('usePluginExtensions()', () => {
|
||||
it('should return the same extensions object if the context object is the same', () => {
|
||||
const extensionPointId = 'plugins/foo/bar';
|
||||
const pluginId = 'my-app-plugin';
|
||||
const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry);
|
||||
const usePluginExtensions = createUsePluginExtensions(reactiveRegistry);
|
||||
|
||||
// Add extensions to the registry
|
||||
act(() => {
|
||||
@ -143,7 +143,7 @@ describe('usePluginExtensions()', () => {
|
||||
it('should return a new extensions object if the context object is different', () => {
|
||||
const extensionPointId = 'plugins/foo/bar';
|
||||
const pluginId = 'my-app-plugin';
|
||||
const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry);
|
||||
const usePluginExtensions = createUsePluginExtensions(reactiveRegistry);
|
||||
|
||||
// Add extensions to the registry
|
||||
act(() => {
|
||||
@ -178,7 +178,7 @@ describe('usePluginExtensions()', () => {
|
||||
const extensionPointId = 'plugins/foo/bar';
|
||||
const pluginId = 'my-app-plugin';
|
||||
const context = {};
|
||||
const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry);
|
||||
const usePluginExtensions = createUsePluginExtensions(reactiveRegistry);
|
||||
|
||||
// Add the first extension
|
||||
act(() => {
|
||||
|
@ -6,7 +6,7 @@ import { GetPluginExtensionsOptions, UsePluginExtensionsResult } from '@grafana/
|
||||
import { getPluginExtensions } from './getPluginExtensions';
|
||||
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
|
||||
|
||||
export function createPluginExtensionsHook(extensionsRegistry: ReactivePluginExtensionsRegistry) {
|
||||
export function createUsePluginExtensions(extensionsRegistry: ReactivePluginExtensionsRegistry) {
|
||||
const observableRegistry = extensionsRegistry.asObservable();
|
||||
const cache: {
|
||||
id: string;
|
||||
|
@ -31,10 +31,21 @@ export function isPluginExtensionLinkConfig(
|
||||
return typeof extension === 'object' && 'type' in extension && extension['type'] === PluginExtensionTypes.link;
|
||||
}
|
||||
|
||||
export function isPluginExtensionComponentConfig(
|
||||
export function isPluginExtensionComponentConfig<Props extends object>(
|
||||
extension: PluginExtensionConfig | undefined | PluginExtensionComponentConfig<Props>
|
||||
): extension is PluginExtensionComponentConfig<Props> {
|
||||
return typeof extension === 'object' && 'type' in extension && extension['type'] === PluginExtensionTypes.component;
|
||||
}
|
||||
|
||||
export function isPluginCapability(
|
||||
extension: PluginExtensionConfig | undefined
|
||||
): extension is PluginExtensionComponentConfig {
|
||||
return typeof extension === 'object' && 'type' in extension && extension['type'] === PluginExtensionTypes.component;
|
||||
return (
|
||||
typeof extension === 'object' &&
|
||||
'type' in extension &&
|
||||
extension['type'] === PluginExtensionTypes.component &&
|
||||
extension.extensionPointId.startsWith('capabilities/')
|
||||
);
|
||||
}
|
||||
|
||||
export function handleErrorsInFn(fn: Function, errorMessagePrefix = '') {
|
||||
|
@ -83,7 +83,7 @@ describe('Plugin Extension Validators', () => {
|
||||
describe('assertExtensionPointIdIsValid()', () => {
|
||||
it('should throw an error if the extensionPointId does not have the right prefix', () => {
|
||||
expect(() => {
|
||||
assertExtensionPointIdIsValid({
|
||||
assertExtensionPointIdIsValid('my-org-app', {
|
||||
type: PluginExtensionTypes.link,
|
||||
title: 'Title',
|
||||
description: 'Description',
|
||||
@ -94,14 +94,14 @@ describe('Plugin Extension Validators', () => {
|
||||
|
||||
it('should NOT throw an error if the extensionPointId is correct', () => {
|
||||
expect(() => {
|
||||
assertExtensionPointIdIsValid({
|
||||
assertExtensionPointIdIsValid('my-org-app', {
|
||||
type: PluginExtensionTypes.link,
|
||||
title: 'Title',
|
||||
description: 'Description',
|
||||
extensionPointId: 'grafana/some-page/extension-point-a',
|
||||
});
|
||||
|
||||
assertExtensionPointIdIsValid({
|
||||
assertExtensionPointIdIsValid('my-org-app', {
|
||||
type: PluginExtensionTypes.link,
|
||||
title: 'Title',
|
||||
description: 'Description',
|
||||
|
@ -40,10 +40,10 @@ export function assertIsReactComponent(component: React.ComponentType) {
|
||||
}
|
||||
}
|
||||
|
||||
export function assertExtensionPointIdIsValid(extension: PluginExtensionConfig) {
|
||||
if (!isExtensionPointIdValid(extension)) {
|
||||
export function assertExtensionPointIdIsValid(pluginId: string, extension: PluginExtensionConfig) {
|
||||
if (!isExtensionPointIdValid(pluginId, extension)) {
|
||||
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/", "plugins/" or "capabilities/${pluginId}" (currently: "${extension.extensionPointId}"). Skipping the extension.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -76,9 +76,11 @@ export function isLinkPathValid(pluginId: string, path: string) {
|
||||
return Boolean(typeof path === 'string' && path.length > 0 && path.startsWith(`/a/${pluginId}/`));
|
||||
}
|
||||
|
||||
export function isExtensionPointIdValid(extension: PluginExtensionConfig) {
|
||||
export function isExtensionPointIdValid(pluginId: string, extension: PluginExtensionConfig) {
|
||||
return Boolean(
|
||||
extension.extensionPointId?.startsWith('grafana/') || extension.extensionPointId?.startsWith('plugins/')
|
||||
extension.extensionPointId?.startsWith('grafana/') ||
|
||||
extension.extensionPointId?.startsWith('plugins/') ||
|
||||
extension.extensionPointId?.startsWith(`capabilities/${pluginId}/`)
|
||||
);
|
||||
}
|
||||
|
||||
@ -93,8 +95,9 @@ export function isStringPropValid(prop: unknown) {
|
||||
export function isPluginExtensionConfigValid(pluginId: string, extension: PluginExtensionConfig): boolean {
|
||||
try {
|
||||
assertStringProps(extension, ['title', 'description', 'extensionPointId']);
|
||||
assertExtensionPointIdIsValid(extension);
|
||||
assertExtensionPointIdIsValid(pluginId, extension);
|
||||
|
||||
// Link
|
||||
if (isPluginExtensionLinkConfig(extension)) {
|
||||
assertConfigureIsValid(extension);
|
||||
|
||||
@ -108,6 +111,7 @@ export function isPluginExtensionConfigValid(pluginId: string, extension: Plugin
|
||||
}
|
||||
}
|
||||
|
||||
// Component
|
||||
if (isPluginExtensionComponentConfig(extension)) {
|
||||
assertIsReactComponent(extension.component);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user