mirror of
https://github.com/grafana/grafana.git
synced 2025-08-03 03:13:49 +08:00
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:
@ -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';
|
||||
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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';
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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[]>;
|
||||
|
22
public/app/features/plugins/extensions/types.ts
Normal file
22
public/app/features/plugins/extensions/types.ts
Normal 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[] };
|
Reference in New Issue
Block a user