Extensions: Expose new observable APIs for accessing components and links (#103063)

* 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 <hugo.haggmark@gmail.com>

* refactor(getPluginExtensions): stop using `shareReply()`

* fix(grafana-runtime/extensions): typo in error messages

---------

Co-authored-by: Hugo Häggmark <hugo.haggmark@gmail.com>
This commit is contained in:
Levente Balogh
2025-04-01 09:07:44 +02:00
committed by GitHub
parent 40f6f3e6bd
commit 9eb5ed5db9
8 changed files with 347 additions and 21 deletions

View File

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

View File

@ -0,0 +1,36 @@
import { Observable } from 'rxjs';
import { PluginExtensionComponent } from '@grafana/data';
type GetObservablePluginComponentsOptions = {
context?: object | Record<string, unknown>;
extensionPointId: string;
limitPerPlugin?: number;
};
export type GetObservablePluginComponents = (
options: GetObservablePluginComponentsOptions
) => Observable<PluginExtensionComponent[]>;
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<PluginExtensionComponent[]> {
if (!singleton) {
throw new Error('getObservablePluginComponents() can only be used after the Grafana instance has started.');
}
return singleton(options);
}

View File

@ -0,0 +1,30 @@
import { Observable } from 'rxjs';
import { PluginExtensionLink } from '@grafana/data';
type GetObservablePluginLinksOptions = {
context?: object | Record<string | symbol, unknown>;
extensionPointId: string;
limitPerPlugin?: number;
};
export type GetObservablePluginLinks = (options: GetObservablePluginLinksOptions) => Observable<PluginExtensionLink[]>;
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<PluginExtensionLink[]> {
if (!singleton) {
throw new Error('getObservablePluginLinks() can only be used after the Grafana instance has started.');
}
return singleton(options);
}

View File

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

View File

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

View File

@ -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 <div>Hello world!</div>;
},
},
],
});
});
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 <div>Hello world!</div>;
},
},
],
});
});
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 <div>Hello world!</div>;
},
},
],
});
});
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);
});
});
});

View File

@ -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<string | symbol, unknown>;
extensionPointId: string;
limitPerPlugin?: number;
addedComponentsRegistry: RegistryType<AddedComponentRegistryItem[]> | undefined;
addedLinksRegistry: RegistryType<AddedLinkRegistryItem[]> | 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<GetExtensionsOptions, 'addedComponentsRegistry' | 'addedLinksRegistry'>
): Observable<ReturnType<GetExtensions>> => {
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<AddedComponentRegistryItem[]>;

View File

@ -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<string | symbol, unknown>;
extensionPointId: string;
limitPerPlugin?: number;
addedComponentsRegistry: RegistryType<AddedComponentRegistryItem[]> | undefined;
addedLinksRegistry: RegistryType<AddedLinkRegistryItem[]> | undefined;
};
export type GetExtensions = (options: GetExtensionsOptions) => { extensions: PluginExtension[] };
export type GetPluginExtensions<T = PluginExtension> = (options: {
extensionPointId: string;
// Make sure this object is properly memoized and not mutated.
context?: object | Record<string | symbol, unknown>;
limitPerPlugin?: number;
}) => { extensions: T[] };