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
parent 27a791db12
commit ebe42e1ada
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 366 additions and 38 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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