diff --git a/packages/grafana-runtime/src/internal/index.ts b/packages/grafana-runtime/src/internal/index.ts index 4b085040190..e7119717bf3 100644 --- a/packages/grafana-runtime/src/internal/index.ts +++ b/packages/grafana-runtime/src/internal/index.ts @@ -16,3 +16,12 @@ export { type PageInfoItem, setPluginPage } from '../components/PluginPage'; export { ExpressionDatasourceRef } from '../utils/DataSourceWithBackend'; export { standardStreamOptionsProvider, toStreamingDataResponse } from '../utils/DataSourceWithBackend'; + +export { + setGetObservablePluginComponents, + type GetObservablePluginComponents, +} from '../services/pluginExtensions/getObservablePluginComponents'; +export { + setGetObservablePluginLinks, + type GetObservablePluginLinks, +} from '../services/pluginExtensions/getObservablePluginLinks'; diff --git a/packages/grafana-runtime/src/services/pluginExtensions/getObservablePluginComponents.ts b/packages/grafana-runtime/src/services/pluginExtensions/getObservablePluginComponents.ts new file mode 100644 index 00000000000..923646fafd8 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginExtensions/getObservablePluginComponents.ts @@ -0,0 +1,36 @@ +import { Observable } from 'rxjs'; + +import { PluginExtensionComponent } from '@grafana/data'; + +type GetObservablePluginComponentsOptions = { + context?: object | Record; + extensionPointId: string; + limitPerPlugin?: number; +}; + +export type GetObservablePluginComponents = ( + options: GetObservablePluginComponentsOptions +) => Observable; + +let singleton: GetObservablePluginComponents | undefined; + +export function setGetObservablePluginComponents(fn: GetObservablePluginComponents): void { + // We allow overriding the registry in tests + if (singleton && process.env.NODE_ENV !== 'test') { + throw new Error( + 'setGetObservablePluginComponents() function should only be called once, when Grafana is starting.' + ); + } + + singleton = fn; +} + +export function getObservablePluginComponents( + options: GetObservablePluginComponentsOptions +): Observable { + if (!singleton) { + throw new Error('setGetObservablePluginComponents() can only be used after the Grafana instance has started.'); + } + + return singleton(options); +} diff --git a/packages/grafana-runtime/src/services/pluginExtensions/getObservablePluginLinks.ts b/packages/grafana-runtime/src/services/pluginExtensions/getObservablePluginLinks.ts new file mode 100644 index 00000000000..004108c5696 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginExtensions/getObservablePluginLinks.ts @@ -0,0 +1,30 @@ +import { Observable } from 'rxjs'; + +import { PluginExtensionLink } from '@grafana/data'; + +type GetObservablePluginLinksOptions = { + context?: object | Record; + extensionPointId: string; + limitPerPlugin?: number; +}; + +export type GetObservablePluginLinks = (options: GetObservablePluginLinksOptions) => Observable; + +let singleton: GetObservablePluginLinks | undefined; + +export function setGetObservablePluginLinks(fn: GetObservablePluginLinks): void { + // We allow overriding the registry in tests + if (singleton && process.env.NODE_ENV !== 'test') { + throw new Error('setGetObservablePluginLinks() function should only be called once, when Grafana is starting.'); + } + + singleton = fn; +} + +export function getObservablePluginLinks(options: GetObservablePluginLinksOptions): Observable { + if (!singleton) { + throw new Error('setGetObservablePluginLinks() can only be used after the Grafana instance has started.'); + } + + return singleton(options); +} diff --git a/packages/grafana-runtime/src/unstable.ts b/packages/grafana-runtime/src/unstable.ts index 07bb817ac5b..a399c1e35e1 100644 --- a/packages/grafana-runtime/src/unstable.ts +++ b/packages/grafana-runtime/src/unstable.ts @@ -11,3 +11,6 @@ export { useTranslate, setUseTranslateHook, setTransComponent, Trans } from './utils/i18n'; export type { TransProps } from './types/i18n'; + +export { getObservablePluginLinks } from './services/pluginExtensions/getObservablePluginLinks'; +export { getObservablePluginComponents } from './services/pluginExtensions/getObservablePluginComponents'; diff --git a/public/app/app.ts b/public/app/app.ts index 1134fea8dbb..c21cfabd4c8 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -40,7 +40,13 @@ import { setCorrelationsService, setPluginFunctionsHook, } from '@grafana/runtime'; -import { setPanelDataErrorView, setPanelRenderer, setPluginPage } from '@grafana/runtime/internal'; +import { + setGetObservablePluginComponents, + setGetObservablePluginLinks, + setPanelDataErrorView, + setPanelRenderer, + setPluginPage, +} from '@grafana/runtime/internal'; import config, { updateConfig } from 'app/core/config'; import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; @@ -74,6 +80,10 @@ import { initGrafanaLive } from './features/live'; import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView'; import { PanelRenderer } from './features/panel/components/PanelRenderer'; import { DatasourceSrv } from './features/plugins/datasource_srv'; +import { + getObservablePluginComponents, + getObservablePluginLinks, +} from './features/plugins/extensions/getPluginExtensions'; import { usePluginComponent } from './features/plugins/extensions/usePluginComponent'; import { usePluginComponents } from './features/plugins/extensions/usePluginComponents'; import { usePluginFunctions } from './features/plugins/extensions/usePluginFunctions'; @@ -215,6 +225,8 @@ export class GrafanaApp { setPluginComponentHook(usePluginComponent); setPluginComponentsHook(usePluginComponents); setPluginFunctionsHook(usePluginFunctions); + setGetObservablePluginLinks(getObservablePluginLinks); + setGetObservablePluginComponents(getObservablePluginComponents); // initialize chrome service const queryParams = locationService.getSearchObject(); diff --git a/public/app/features/plugins/extensions/getPluginExtensions.test.tsx b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx index 14bf2cd9ba6..e3a2fcb6a71 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx @@ -1,13 +1,24 @@ import * as React from 'react'; -import { PluginExtensionAddedComponentConfig, PluginExtensionAddedLinkConfig } from '@grafana/data'; +import { + type PluginExtensionAddedLinkConfig, + type PluginExtensionAddedComponentConfig, + PluginExtensionTypes, +} from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; -import { getPluginExtensions } from './getPluginExtensions'; +import { + getObservablePluginComponents, + getObservablePluginExtensions, + getObservablePluginLinks, + getPluginExtensions, +} from './getPluginExtensions'; import { log } from './logs/log'; import { resetLogMock } from './logs/testUtils'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; +import { pluginExtensionRegistries } from './registry/setup'; +import type { GetExtensions } from './types'; import { isReadOnlyProxy } from './utils'; import { assertPluginExtensionLink } from './validators'; @@ -61,9 +72,9 @@ describe('getPluginExtensions()', () => { const extensionPoint3 = 'grafana/datasources/config/v1'; const pluginId = 'grafana-basic-app'; // Sample extension configs that are used in the tests below - let link1: PluginExtensionAddedLinkConfig, - link2: PluginExtensionAddedLinkConfig, - component1: PluginExtensionAddedComponentConfig; + let link1: PluginExtensionAddedLinkConfig; + let link2: PluginExtensionAddedLinkConfig; + let component1: PluginExtensionAddedComponentConfig; beforeEach(() => { link1 = { @@ -564,3 +575,174 @@ describe('getPluginExtensions()', () => { ); }); }); + +describe('getObservablePluginExtensions()', () => { + const extensionPointId = 'grafana/dashboard/panel/menu/v1'; + const pluginId = 'grafana-basic-app'; + + beforeEach(() => { + pluginExtensionRegistries.addedLinksRegistry = new AddedLinksRegistry(); + pluginExtensionRegistries.addedComponentsRegistry = new AddedComponentsRegistry(); + pluginExtensionRegistries.addedLinksRegistry.register({ + pluginId, + configs: [ + { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + targets: extensionPointId, + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + pluginExtensionRegistries.addedComponentsRegistry.register({ + pluginId, + configs: [ + { + title: 'Component 1', + description: 'Component 1 description', + targets: extensionPointId, + component: () => { + return
Hello world!
; + }, + }, + ], + }); + }); + + it('should emit the initial state when no changes are made to the registries', () => { + const observable = getObservablePluginExtensions({ extensionPointId }); + + observable.subscribe((value: ReturnType) => { + expect(value.extensions).toHaveLength(2); + expect(value.extensions[0].pluginId).toBe(pluginId); + expect(value.extensions[1].pluginId).toBe(pluginId); + }); + }); + + it('should emit the new state when the registries change', () => { + const observable = getObservablePluginExtensions({ extensionPointId }); + const subscriber = jest.fn(); + + observable.subscribe(subscriber); + + // Initial state + expect(subscriber).toHaveBeenCalledTimes(2); + expect(subscriber.mock.calls[1][0].extensions).toHaveLength(2); + expect(subscriber.mock.calls[1][0].extensions[0].pluginId).toBe(pluginId); + expect(subscriber.mock.calls[1][0].extensions[1].pluginId).toBe(pluginId); + + // Register new link extension + pluginExtensionRegistries.addedLinksRegistry.register({ + pluginId, + configs: [ + { + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId}/declare-incident`, + targets: extensionPointId, + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + expect(subscriber).toHaveBeenCalledTimes(3); + expect(subscriber.mock.calls[2][0].extensions).toHaveLength(3); + expect(subscriber.mock.calls[2][0].extensions[0].pluginId).toBe(pluginId); + expect(subscriber.mock.calls[2][0].extensions[1].pluginId).toBe(pluginId); + expect(subscriber.mock.calls[2][0].extensions[2].pluginId).toBe(pluginId); + }); +}); + +describe('getObservablePluginLinks()', () => { + const extensionPointId = 'grafana/dashboard/panel/menu/v1'; + const pluginId = 'grafana-basic-app'; + + beforeEach(() => { + pluginExtensionRegistries.addedLinksRegistry = new AddedLinksRegistry(); + pluginExtensionRegistries.addedComponentsRegistry = new AddedComponentsRegistry(); + pluginExtensionRegistries.addedLinksRegistry.register({ + pluginId, + configs: [ + { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + targets: extensionPointId, + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + pluginExtensionRegistries.addedComponentsRegistry.register({ + pluginId, + configs: [ + { + title: 'Component 1', + description: 'Component 1 description', + targets: extensionPointId, + component: () => { + return
Hello world!
; + }, + }, + ], + }); + }); + + it('should only emit the links', () => { + const observable = getObservablePluginLinks({ extensionPointId }); + + observable.subscribe((value: ReturnType) => { + expect(value.extensions).toHaveLength(1); + expect(value.extensions[0].pluginId).toBe(pluginId); + expect(value.extensions[0].type).toBe(PluginExtensionTypes.link); + }); + }); +}); + +describe('getObservablePluginComponents()', () => { + const extensionPointId = 'grafana/dashboard/panel/menu/v1'; + const pluginId = 'grafana-basic-app'; + + beforeEach(() => { + pluginExtensionRegistries.addedLinksRegistry = new AddedLinksRegistry(); + pluginExtensionRegistries.addedComponentsRegistry = new AddedComponentsRegistry(); + pluginExtensionRegistries.addedLinksRegistry.register({ + pluginId, + configs: [ + { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + targets: extensionPointId, + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + pluginExtensionRegistries.addedComponentsRegistry.register({ + pluginId, + configs: [ + { + title: 'Component 1', + description: 'Component 1 description', + targets: extensionPointId, + component: () => { + return
Hello world!
; + }, + }, + ], + }); + }); + + it('should only emit the components', () => { + const observable = getObservablePluginComponents({ extensionPointId }); + + observable.subscribe((value: ReturnType) => { + expect(value.extensions).toHaveLength(1); + expect(value.extensions[0].pluginId).toBe(pluginId); + expect(value.extensions[0].type).toBe(PluginExtensionTypes.component); + }); + }); +}); diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index fcf223f9d4d..55d75e07f91 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -1,4 +1,5 @@ import { isString } from 'lodash'; +import { filter, map, Observable } from 'rxjs'; import { PluginExtensionTypes, @@ -6,13 +7,15 @@ import { type PluginExtensionLink, type PluginExtensionComponent, } from '@grafana/data'; +import { type GetObservablePluginLinks, type GetObservablePluginComponents } from '@grafana/runtime/internal'; import { log } from './logs/log'; import { AddedComponentRegistryItem } from './registry/AddedComponentsRegistry'; import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry'; import { RegistryType } from './registry/Registry'; +import { pluginExtensionRegistries } from './registry/setup'; import type { PluginExtensionRegistries } from './registry/types'; -import { GetExtensions, GetPluginExtensions } from './types'; +import { GetExtensions, GetExtensionsOptions, GetPluginExtensions } from './types'; import { getReadOnlyProxy, generateExtensionId, @@ -22,6 +25,78 @@ import { getLinkExtensionPathWithTracking, } from './utils'; +/** + * Returns an observable that emits plugin extensions whenever the core extensions registries change. + * The observable will emit the initial state of the extensions and then emit again whenever + * either the added components registry or the added links registry changes. + * + * @param options - The options for getting plugin extensions + * @returns An Observable that emits the plugin extensions for the given extension point any time the registries change + */ + +export const getObservablePluginExtensions = ( + options: Omit +): Observable> => { + return new Observable((subscriber) => { + let addedComponentsRegistry: RegistryType | undefined; + let addedLinksRegistry: RegistryType>> | undefined; + + const emitExtensions = () => { + subscriber.next( + getPluginExtensions({ + ...options, + addedComponentsRegistry, + addedLinksRegistry, + }) + ); + }; + + // Reading the initial state of the registries + Promise.all([ + pluginExtensionRegistries.addedComponentsRegistry.getState(), + pluginExtensionRegistries.addedLinksRegistry.getState(), + ]).then(([currentAddedComponentsRegistry, currentAddedLinksRegistry]) => { + addedComponentsRegistry = currentAddedComponentsRegistry; + addedLinksRegistry = currentAddedLinksRegistry; + emitExtensions(); + }); + + const addedComponentsSub = pluginExtensionRegistries.addedComponentsRegistry + .asObservable() + .subscribe((currentAddedComponentsRegistry) => { + addedComponentsRegistry = currentAddedComponentsRegistry; + emitExtensions(); + }); + + const addedLinksSub = pluginExtensionRegistries.addedLinksRegistry + .asObservable() + .subscribe((currentAddedLinksRegistry) => { + addedLinksRegistry = currentAddedLinksRegistry; + emitExtensions(); + }); + + // Cleanup subscriptions + return () => { + addedComponentsSub?.unsubscribe(); + addedLinksSub?.unsubscribe(); + }; + }); +}; + +export const getObservablePluginLinks: GetObservablePluginLinks = (options) => { + return getObservablePluginExtensions(options).pipe( + map((value) => value.extensions.filter((extension) => extension.type === PluginExtensionTypes.link)), + filter((extensions) => extensions.length > 0) + ); +}; + +export const getObservablePluginComponents: GetObservablePluginComponents = (options) => { + return getObservablePluginExtensions(options).pipe( + map((value) => value.extensions.filter((extension) => extension.type === PluginExtensionTypes.component)), + filter((extensions) => extensions.length > 0) + ); +}; + export function createPluginExtensionsGetter(registries: PluginExtensionRegistries): GetPluginExtensions { let addedComponentsRegistry: RegistryType; let addedLinksRegistry: RegistryType>>; diff --git a/public/app/features/plugins/extensions/types.ts b/public/app/features/plugins/extensions/types.ts index 7181c249240..b83c2837fe7 100644 --- a/public/app/features/plugins/extensions/types.ts +++ b/public/app/features/plugins/extensions/types.ts @@ -4,19 +4,15 @@ import { AddedComponentRegistryItem } from './registry/AddedComponentsRegistry'; import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry'; import { RegistryType } from './registry/Registry'; -export type GetExtensions = ({ - context, - extensionPointId, - limitPerPlugin, - addedLinksRegistry, - addedComponentsRegistry, -}: { +export type GetExtensionsOptions = { context?: object | Record; extensionPointId: string; limitPerPlugin?: number; addedComponentsRegistry: RegistryType | undefined; addedLinksRegistry: RegistryType | undefined; -}) => { extensions: PluginExtension[] }; +}; + +export type GetExtensions = (options: GetExtensionsOptions) => { extensions: PluginExtension[] }; export type GetPluginExtensions = (options: { extensionPointId: string;