feat(Extensions): expose an observable API for added links and components

pull/102102/head
Levente Balogh 2 months ago
parent 5aa4e05038
commit bdc588250e
No known key found for this signature in database
  1. 9
      packages/grafana-runtime/src/internal/index.ts
  2. 36
      packages/grafana-runtime/src/services/pluginExtensions/getObservablePluginComponents.ts
  3. 30
      packages/grafana-runtime/src/services/pluginExtensions/getObservablePluginLinks.ts
  4. 3
      packages/grafana-runtime/src/unstable.ts
  5. 14
      public/app/app.ts
  6. 192
      public/app/features/plugins/extensions/getPluginExtensions.test.tsx
  7. 77
      public/app/features/plugins/extensions/getPluginExtensions.ts
  8. 12
      public/app/features/plugins/extensions/types.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';

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

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

@ -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 <div>Hello world!</div>;
},
},
],
});
});
it('should emit the initial state when no changes are made to the registries', () => {
const observable = getObservablePluginExtensions({ extensionPointId });
observable.subscribe((value: ReturnType<GetExtensions>) => {
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 <div>Hello world!</div>;
},
},
],
});
});
it('should only emit the links', () => {
const observable = getObservablePluginLinks({ extensionPointId });
observable.subscribe((value: ReturnType<GetExtensions>) => {
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 <div>Hello world!</div>;
},
},
],
});
});
it('should only emit the components', () => {
const observable = getObservablePluginComponents({ extensionPointId });
observable.subscribe((value: ReturnType<GetExtensions>) => {
expect(value.extensions).toHaveLength(1);
expect(value.extensions[0].pluginId).toBe(pluginId);
expect(value.extensions[0].type).toBe(PluginExtensionTypes.component);
});
});
});

@ -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<GetExtensionsOptions, 'addedComponentsRegistry' | 'addedLinksRegistry'>
): Observable<ReturnType<GetExtensions>> => {
return new Observable((subscriber) => {
let addedComponentsRegistry: RegistryType<AddedComponentRegistryItem[]> | undefined;
let addedLinksRegistry: RegistryType<Array<AddedLinkRegistryItem<object>>> | 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<AddedComponentRegistryItem[]>;
let addedLinksRegistry: RegistryType<Array<AddedLinkRegistryItem<object>>>;

@ -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<string | symbol, unknown>;
extensionPointId: string;
limitPerPlugin?: number;
addedComponentsRegistry: RegistryType<AddedComponentRegistryItem[]> | undefined;
addedLinksRegistry: RegistryType<AddedLinkRegistryItem[]> | undefined;
}) => { extensions: PluginExtension[] };
};
export type GetExtensions = (options: GetExtensionsOptions) => { extensions: PluginExtension[] };
export type GetPluginExtensions<T = PluginExtension> = (options: {
extensionPointId: string;

Loading…
Cancel
Save