From ebe42e1ada3258328a082e45e93b9d8418a73ada Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Fri, 7 Jun 2024 18:05:00 +0200 Subject: [PATCH] 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 * 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 --- .betterer.results | 9 +- packages/grafana-data/src/types/app.ts | 68 ++++++++++- .../src/types/pluginExtensions.ts | 12 +- .../grafana-runtime/src/services/index.ts | 6 + .../pluginExtensions/getPluginExtensions.ts | 5 + .../pluginExtensions/usePluginComponent.ts | 20 +++ .../pluginExtensions/usePluginExtensions.ts | 37 ++++++ public/app/app.ts | 7 +- .../plugins/extensions/getPluginExtensions.ts | 4 +- .../reactivePluginExtensionRegistry.ts | 25 +++- .../extensions/usePluginComponent.test.tsx | 114 ++++++++++++++++++ .../plugins/extensions/usePluginComponent.tsx | 42 +++++++ .../extensions/usePluginExtensions.test.tsx | 16 +-- .../extensions/usePluginExtensions.tsx | 2 +- .../app/features/plugins/extensions/utils.tsx | 15 ++- .../plugins/extensions/validators.test.tsx | 6 +- .../features/plugins/extensions/validators.ts | 16 ++- 17 files changed, 366 insertions(+), 38 deletions(-) create mode 100644 packages/grafana-runtime/src/services/pluginExtensions/usePluginComponent.ts create mode 100644 public/app/features/plugins/extensions/usePluginComponent.test.tsx create mode 100644 public/app/features/plugins/extensions/usePluginComponent.tsx diff --git a/.betterer.results b/.betterer.results index 3a181be57f2..076f8ed8568 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"] ], diff --git a/packages/grafana-data/src/types/app.ts b/packages/grafana-data/src/types/app.ts index d2ff50ad7dc..5f2fbc12a68 100644 --- a/packages/grafana-data/src/types/app.ts +++ b/packages/grafana-data/src/types/app.ts @@ -102,23 +102,79 @@ export class AppPlugin extends GrafanaPlugin(extension: Omit, 'type'>) { - this._extensionConfigs.push({ - ...extension, - type: PluginExtensionTypes.link, - } as PluginExtensionLinkConfig); + addLink( + extensionConfig: { targets: string | string[] } & Omit< + PluginExtensionLinkConfig, + '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(extension: Omit, 'type'>) { + addComponent( + extensionConfig: { targets: string | string[] } & Omit< + PluginExtensionComponentConfig, + '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( + componentConfig: { id: string } & Omit, '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(extension: Omit, 'type'>) { + this.addLink({ + targets: [extension.extensionPointId], + ...extension, + }); + + return this; + } + + /** @deprecated Use .addComponent() instead */ + configureExtensionComponent(extension: Omit, 'type'>) { + this.addComponent({ + targets: [extension.extensionPointId], + ...extension, + }); + + return this; + } } /** diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index c9ed2b0506d..d0c78553630 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -54,8 +54,10 @@ export type PluginExtensionLinkConfig = { // (It is called with the original event object) onClick?: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => 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 = { // (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; - // 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; }; diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts index f60715cbe5d..a0dc7db5ce5 100644 --- a/packages/grafana-runtime/src/services/index.ts +++ b/packages/grafana-runtime/src/services/index.ts @@ -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'; diff --git a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts index eff1975e8f4..7ab1014bfcb 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts @@ -25,6 +25,11 @@ export type UsePluginExtensionsResult = { isLoading: boolean; }; +export type UsePluginComponentResult = { + component: React.ComponentType | undefined | null; + isLoading: boolean; +}; + let singleton: GetPluginExtensions | undefined; export function setPluginExtensionGetter(instance: GetPluginExtensions): void { diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginComponent.ts b/packages/grafana-runtime/src/services/pluginExtensions/usePluginComponent.ts new file mode 100644 index 00000000000..a11a37389c3 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginExtensions/usePluginComponent.ts @@ -0,0 +1,20 @@ +import { UsePluginComponentResult } from './getPluginExtensions'; + +export type UsePluginComponent = (id: string) => UsePluginComponentResult; + +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(id: string): UsePluginComponentResult { + if (!singleton) { + throw new Error('setPluginComponentHook(options) can only be used after the Grafana instance has started.'); + } + return singleton(id) as UsePluginComponentResult; +} diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts index 4629fc7baab..801c1dcec0d 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts @@ -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( + options: GetPluginExtensionsOptions +): { components: Array>; isLoading: boolean } { + const { extensions, isLoading } = usePluginExtensions(options); + + return useMemo( + () => ({ + components: extensions.filter(isPluginExtensionComponent) as Array>, + isLoading, + }), + [extensions, isLoading] + ); +} + +/** + * @deprecated Use usePluginLinks() instead. + */ export function usePluginLinkExtensions( options: GetPluginExtensionsOptions ): UsePluginExtensionsResult { @@ -35,6 +69,9 @@ export function usePluginLinkExtensions( }, [extensions, isLoading]); } +/** + * @deprecated Use usePluginComponents() instead. + */ export function usePluginComponentExtensions( options: GetPluginExtensionsOptions ): { extensions: Array>; isLoading: boolean } { diff --git a/public/app/app.ts b/public/app/app.ts index de0f8814d68..770653fe974 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -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(); diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index 17f5ec77633..ec616707d8c 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -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) => { diff --git a/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.ts b/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.ts index 016b97e0a4b..2ef23fe4b2e 100644 --- a/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.ts +++ b/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.ts @@ -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); diff --git a/public/app/features/plugins/extensions/usePluginComponent.test.tsx b/public/app/features/plugins/extensions/usePluginComponent.test.tsx new file mode 100644 index 00000000000..c0941358dd7 --- /dev/null +++ b/public/app/features/plugins/extensions/usePluginComponent.test.tsx @@ -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: () =>
Hello World
, + }, + ], + }); + + const usePluginComponent = createUsePluginComponent(reactiveRegistry); + const { result } = renderHook(() => usePluginComponent(id)); + const Component = result.current.component; + + act(() => { + render(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: () =>
Hello World
, + }, + ], + }); + }); + + // 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 && ); + }); + + 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); + }); +}); diff --git a/public/app/features/plugins/extensions/usePluginComponent.tsx b/public/app/features/plugins/extensions/usePluginComponent.tsx new file mode 100644 index 00000000000..99a63c8c921 --- /dev/null +++ b/public/app/features/plugins/extensions/usePluginComponent.tsx @@ -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(id: string): UsePluginComponentResult { + 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(registryItem.config)) { + return { + isLoading: false, + component: wrapWithPluginContext(registryItem.pluginId, registryItem.config.component), + }; + } + + return { + isLoading: false, + component: null, + }; + }, [id, registry]); + }; +} diff --git a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx index 49f29e4b1b1..92c3d4c7f1c 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx @@ -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(() => { diff --git a/public/app/features/plugins/extensions/usePluginExtensions.tsx b/public/app/features/plugins/extensions/usePluginExtensions.tsx index 4eb55c700d3..d02deec66ec 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.tsx @@ -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; diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index 277dbddcaa6..0c3dd0a6192 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -31,10 +31,21 @@ export function isPluginExtensionLinkConfig( return typeof extension === 'object' && 'type' in extension && extension['type'] === PluginExtensionTypes.link; } -export function isPluginExtensionComponentConfig( +export function isPluginExtensionComponentConfig( + extension: PluginExtensionConfig | undefined | PluginExtensionComponentConfig +): extension is PluginExtensionComponentConfig { + 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 = '') { diff --git a/public/app/features/plugins/extensions/validators.test.tsx b/public/app/features/plugins/extensions/validators.test.tsx index b57f1f9e3a8..c0474896d43 100644 --- a/public/app/features/plugins/extensions/validators.test.tsx +++ b/public/app/features/plugins/extensions/validators.test.tsx @@ -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', diff --git a/public/app/features/plugins/extensions/validators.ts b/public/app/features/plugins/extensions/validators.ts index add198e178f..d9c0a45ebe7 100644 --- a/public/app/features/plugins/extensions/validators.ts +++ b/public/app/features/plugins/extensions/validators.ts @@ -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); }