From 9eb5ed5db99f545d30e117bc119eeab5b756f52b Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Tue, 1 Apr 2025 09:07:44 +0200 Subject: [PATCH] Extensions: Expose new observable APIs for accessing components and links (#103063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(Extensions): expose an observable API for added links and components * refactor: make `getObservablePluginExtensions()` more RxJS style * refactor(getPluginExtensions): remove unnecessary types * fix(getPluginExtensions): remove unused imports * Apply suggestions from code review Co-authored-by: Hugo Häggmark * refactor(getPluginExtensions): stop using `shareReply()` * fix(grafana-runtime/extensions): typo in error messages --------- Co-authored-by: Hugo Häggmark --- .../grafana-runtime/src/internal/index.ts | 9 + .../getObservablePluginComponents.ts | 36 ++++ .../getObservablePluginLinks.ts | 30 +++ packages/grafana-runtime/src/unstable.ts | 3 + public/app/app.ts | 16 +- .../extensions/getPluginExtensions.test.tsx | 194 +++++++++++++++++- .../plugins/extensions/getPluginExtensions.ts | 58 ++++-- .../app/features/plugins/extensions/types.ts | 22 ++ 8 files changed, 347 insertions(+), 21 deletions(-) create mode 100644 packages/grafana-runtime/src/services/pluginExtensions/getObservablePluginComponents.ts create mode 100644 packages/grafana-runtime/src/services/pluginExtensions/getObservablePluginLinks.ts create mode 100644 public/app/features/plugins/extensions/types.ts 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..c6eac361346 --- /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('getObservablePluginComponents() 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..8799b8818a1 --- /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('getObservablePluginLinks() 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 aae4cd66c3d..9ba23db15e7 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -42,7 +42,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'; @@ -76,7 +82,11 @@ 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 { createPluginExtensionsGetter } from './features/plugins/extensions/getPluginExtensions'; +import { + createPluginExtensionsGetter, + getObservablePluginComponents, + getObservablePluginLinks, +} from './features/plugins/extensions/getPluginExtensions'; import { pluginExtensionRegistries } from './features/plugins/extensions/registry/setup'; import { usePluginComponent } from './features/plugins/extensions/usePluginComponent'; import { usePluginComponents } from './features/plugins/extensions/usePluginComponents'; @@ -222,6 +232,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..b32f8f93320 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 { first, take } from 'rxjs'; -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 { 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,176 @@ 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', async () => { + const observable = getObservablePluginExtensions({ extensionPointId }).pipe(first()); + + await expect(observable).toEmitValuesWith((received) => { + const { extensions } = received[0]; + expect(extensions).toHaveLength(2); + expect(extensions[0].pluginId).toBe(pluginId); + expect(extensions[1].pluginId).toBe(pluginId); + }); + }); + + it('should emit the new state when the registries change', async () => { + const observable = getObservablePluginExtensions({ extensionPointId }).pipe(take(2)); + + setTimeout(() => { + pluginExtensionRegistries.addedLinksRegistry.register({ + pluginId, + configs: [ + { + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId}/declare-incident`, + targets: extensionPointId, + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + }, 0); + + await expect(observable).toEmitValuesWith((received) => { + const { extensions } = received[0]; + expect(extensions).toHaveLength(2); + expect(extensions[0].pluginId).toBe(pluginId); + expect(extensions[1].pluginId).toBe(pluginId); + + const { extensions: extensions2 } = received[1]; + expect(extensions2).toHaveLength(3); + expect(extensions2[0].pluginId).toBe(pluginId); + expect(extensions2[1].pluginId).toBe(pluginId); + expect(extensions2[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', async () => { + const observable = getObservablePluginLinks({ extensionPointId }).pipe(first()); + + await expect(observable).toEmitValuesWith((received) => { + const links = received[0]; + expect(links).toHaveLength(1); + expect(links[0].pluginId).toBe(pluginId); + expect(links[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', async () => { + const observable = getObservablePluginComponents({ extensionPointId }).pipe(first()); + + await expect(observable).toEmitValuesWith((received) => { + const components = received[0]; + expect(components).toHaveLength(1); + expect(components[0].pluginId).toBe(pluginId); + expect(components[0].type).toBe(PluginExtensionTypes.component); + }); + }); +}); diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index 30f5865b259..9012a3c662e 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 { combineLatest, filter, map, Observable } from 'rxjs'; import { PluginExtensionTypes, @@ -6,13 +7,15 @@ import { type PluginExtensionLink, type PluginExtensionComponent, } from '@grafana/data'; -import { GetPluginExtensions } from '@grafana/runtime'; +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, GetExtensionsOptions, GetPluginExtensions } from './types'; import { getReadOnlyProxy, generateExtensionId, @@ -22,19 +25,46 @@ import { getLinkExtensionPathWithTracking, } from './utils'; -type GetExtensions = ({ - context, - extensionPointId, - limitPerPlugin, - addedLinksRegistry, - addedComponentsRegistry, -}: { - context?: object | Record; - extensionPointId: string; - limitPerPlugin?: number; - addedComponentsRegistry: RegistryType | undefined; - addedLinksRegistry: RegistryType | undefined; -}) => { extensions: PluginExtension[] }; +/** + * 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 combineLatest([ + pluginExtensionRegistries.addedComponentsRegistry.asObservable(), + pluginExtensionRegistries.addedLinksRegistry.asObservable(), + ]).pipe( + filter(([components, links]) => Boolean(components) && Boolean(links)), // filter out uninitialized registries + map(([components, links]) => + getPluginExtensions({ + ...options, + addedComponentsRegistry: components, + addedLinksRegistry: links, + }) + ) + ); +}; + +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; diff --git a/public/app/features/plugins/extensions/types.ts b/public/app/features/plugins/extensions/types.ts new file mode 100644 index 00000000000..b83c2837fe7 --- /dev/null +++ b/public/app/features/plugins/extensions/types.ts @@ -0,0 +1,22 @@ +import { PluginExtension } from '@grafana/data'; + +import { AddedComponentRegistryItem } from './registry/AddedComponentsRegistry'; +import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry'; +import { RegistryType } from './registry/Registry'; + +export type GetExtensionsOptions = { + context?: object | Record; + extensionPointId: string; + limitPerPlugin?: number; + addedComponentsRegistry: RegistryType | undefined; + addedLinksRegistry: RegistryType | undefined; +}; + +export type GetExtensions = (options: GetExtensionsOptions) => { extensions: PluginExtension[] }; + +export type GetPluginExtensions = (options: { + extensionPointId: string; + // Make sure this object is properly memoized and not mutated. + context?: object | Record; + limitPerPlugin?: number; +}) => { extensions: T[] };