UI Extensions: Share the registries using a React context (#92014)

* feat: add a context for the extension registries

* feat: add a provider for registries to `AppWrapper`

* feat(extensions): add a read-only registry version

* feat: share the registry for exposed components using the context

* fix: tests

* feat: share the registry for added components using the context

* feat: share the addedLinks registry using react context

* use read-only registry versions
This commit is contained in:
Levente Balogh 2024-09-10 10:42:07 +02:00 committed by GitHub
parent 9255230d02
commit 831493278f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 561 additions and 234 deletions

View File

@ -19,6 +19,7 @@ import { sidecarService } from './core/services/SidecarService';
import { contextSrv } from './core/services/context_srv';
import { ThemeProvider } from './core/utils/ConfigProvider';
import { LiveConnectionWarning } from './features/live/LiveConnectionWarning';
import { ExtensionRegistriesProvider } from './features/plugins/extensions/ExtensionRegistriesContext';
import { ExperimentalSplitPaneRouterWrapper, RouterWrapper } from './routes/RoutesWrapper';
interface AppWrapperProps {
@ -110,15 +111,17 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
>
<GlobalStyles />
<SidecarContext.Provider value={sidecarService}>
<div className="grafana-app">
{config.featureToggles.appSidecar ? (
<ExperimentalSplitPaneRouterWrapper {...routerWrapperProps} />
) : (
<RouterWrapper {...routerWrapperProps} />
)}
<LiveConnectionWarning />
<PortalContainer />
</div>
<ExtensionRegistriesProvider registries={app.pluginExtensionsRegistries}>
<div className="grafana-app">
{config.featureToggles.appSidecar ? (
<ExperimentalSplitPaneRouterWrapper {...routerWrapperProps} />
) : (
<RouterWrapper {...routerWrapperProps} />
)}
<LiveConnectionWarning />
<PortalContainer />
</div>
</ExtensionRegistriesProvider>
</SidecarContext.Provider>
</KBarProvider>
</ThemeProvider>

View File

@ -84,10 +84,11 @@ import { PanelRenderer } from './features/panel/components/PanelRenderer';
import { DatasourceSrv } from './features/plugins/datasource_srv';
import { createPluginExtensionsGetter } from './features/plugins/extensions/getPluginExtensions';
import { setupPluginExtensionRegistries } from './features/plugins/extensions/registry/setup';
import { createUsePluginComponent } from './features/plugins/extensions/usePluginComponent';
import { createUsePluginComponents } from './features/plugins/extensions/usePluginComponents';
import { PluginExtensionRegistries } from './features/plugins/extensions/registry/types';
import { usePluginComponent } from './features/plugins/extensions/usePluginComponent';
import { usePluginComponents } from './features/plugins/extensions/usePluginComponents';
import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions';
import { createUsePluginLinks } from './features/plugins/extensions/usePluginLinks';
import { usePluginLinks } from './features/plugins/extensions/usePluginLinks';
import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin';
import { preloadPlugins } from './features/plugins/pluginPreloader';
import { QueryRunner } from './features/query/state/QueryRunner';
@ -124,6 +125,7 @@ if (process.env.NODE_ENV === 'development') {
export class GrafanaApp {
context!: GrafanaContextType;
pluginExtensionsRegistries!: PluginExtensionRegistries;
async init() {
try {
@ -210,7 +212,7 @@ export class GrafanaApp {
initWindowRuntime();
// Initialize plugin extensions
const pluginExtensionsRegistries = setupPluginExtensionRegistries();
this.pluginExtensionsRegistries = setupPluginExtensionRegistries();
if (contextSrv.user.orgRole !== '') {
// The "cloud-home-app" is registering banners once it's loaded, and this can cause a rerender in the AppChrome if it's loaded after the Grafana app init.
@ -219,15 +221,15 @@ export class GrafanaApp {
const awaitedAppPlugins = Object.values(config.apps).filter((app) => awaitedAppPluginIds.includes(app.id));
const appPlugins = Object.values(config.apps).filter((app) => !awaitedAppPluginIds.includes(app.id));
preloadPlugins(appPlugins, pluginExtensionsRegistries);
await preloadPlugins(awaitedAppPlugins, pluginExtensionsRegistries, 'frontend_awaited_plugins_preload');
preloadPlugins(appPlugins, this.pluginExtensionsRegistries);
await preloadPlugins(awaitedAppPlugins, this.pluginExtensionsRegistries, 'frontend_awaited_plugins_preload');
}
setPluginLinksHook(createUsePluginLinks(pluginExtensionsRegistries.addedLinksRegistry));
setPluginExtensionGetter(createPluginExtensionsGetter(pluginExtensionsRegistries));
setPluginExtensionsHook(createUsePluginExtensions(pluginExtensionsRegistries));
setPluginComponentHook(createUsePluginComponent(pluginExtensionsRegistries.exposedComponentsRegistry));
setPluginComponentsHook(createUsePluginComponents(pluginExtensionsRegistries.addedComponentsRegistry));
setPluginExtensionGetter(createPluginExtensionsGetter(this.pluginExtensionsRegistries));
setPluginExtensionsHook(createUsePluginExtensions(this.pluginExtensionsRegistries));
setPluginLinksHook(usePluginLinks);
setPluginComponentHook(usePluginComponent);
setPluginComponentsHook(usePluginComponents);
// initialize chrome service
const queryParams = locationService.getSearchObject();

View File

@ -11,6 +11,8 @@ import { RouteDescriptor } from 'app/core/navigation/types';
import { contextSrv } from 'app/core/services/context_srv';
import { Echo } from 'app/core/services/echo/Echo';
import { ExtensionRegistriesProvider } from '../extensions/ExtensionRegistriesContext';
import { setupPluginExtensionRegistries } from '../extensions/registry/setup';
import { getPluginSettings } from '../pluginSettings';
import { importAppPlugin } from '../plugin_loader';
@ -85,6 +87,7 @@ async function renderUnderRouter(page = '') {
appPluginNavItem.parentItem = appsSection;
const registries = setupPluginExtensionRegistries();
const pagePath = page ? `/${page}` : '';
const route = {
component: () => <AppRootPage pluginId="my-awesome-plugin" pluginNavSection={appsSection} />,
@ -96,7 +99,9 @@ async function renderUnderRouter(page = '') {
render(
<Router history={locationService.getHistory()}>
<Route path={`/a/:pluginId${pagePath}`} exact render={(props) => <GrafanaRoute {...props} route={route} />} />
<ExtensionRegistriesProvider registries={registries}>
<Route path={`/a/:pluginId${pagePath}`} exact render={(props) => <GrafanaRoute {...props} route={route} />} />
</ExtensionRegistriesProvider>
</Router>
);
}

View File

@ -24,6 +24,12 @@ import { appEvents, contextSrv } from 'app/core/core';
import { getNotFoundNav, getWarningNav, getExceptionNav } from 'app/core/navigation/errorModels';
import { getMessageFromError } from 'app/core/utils/errors';
import {
ExtensionRegistriesProvider,
useAddedLinksRegistry,
useAddedComponentsRegistry,
useExposedComponentsRegistry,
} from '../extensions/ExtensionRegistriesContext';
import { getPluginSettings } from '../pluginSettings';
import { importAppPlugin } from '../plugin_loader';
import { buildPluginSectionNav, pluginsLogger } from '../utils';
@ -49,6 +55,9 @@ interface State {
const initialState: State = { loading: true, loadingError: false, pluginNav: null, plugin: null };
export function AppRootPage({ pluginId, pluginNavSection }: Props) {
const addedLinksRegistry = useAddedLinksRegistry();
const addedComponentsRegistry = useAddedComponentsRegistry();
const exposedComponentsRegistry = useExposedComponentsRegistry();
const match = useRouteMatch();
const location = useLocation();
const [state, dispatch] = useReducer(stateSlice.reducer, initialState);
@ -89,13 +98,21 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) {
const pluginRoot = plugin.root && (
<PluginContextProvider meta={plugin.meta}>
<plugin.root
meta={plugin.meta}
basename={match.url}
onNavChanged={onNavChanged}
query={queryParams}
path={location.pathname}
/>
<ExtensionRegistriesProvider
registries={{
addedLinksRegistry: addedLinksRegistry.readOnly(),
addedComponentsRegistry: addedComponentsRegistry.readOnly(),
exposedComponentsRegistry: exposedComponentsRegistry.readOnly(),
}}
>
<plugin.root
meta={plugin.meta}
basename={match.url}
onNavChanged={onNavChanged}
query={queryParams}
path={location.pathname}
/>
</ExtensionRegistriesProvider>
</PluginContextProvider>
);

View File

@ -0,0 +1,55 @@
import { PropsWithChildren, createContext, useContext } from 'react';
import { AddedComponentsRegistry } from 'app/features/plugins/extensions/registry/AddedComponentsRegistry';
import { AddedLinksRegistry } from 'app/features/plugins/extensions/registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from 'app/features/plugins/extensions/registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
export interface ExtensionRegistriesContextType {
registries: PluginExtensionRegistries;
}
// Using a different context for each registry to avoid unnecessary re-renders
export const AddedLinksRegistryContext = createContext<AddedLinksRegistry | undefined>(undefined);
export const AddedComponentsRegistryContext = createContext<AddedComponentsRegistry | undefined>(undefined);
export const ExposedComponentsRegistryContext = createContext<ExposedComponentsRegistry | undefined>(undefined);
export function useAddedLinksRegistry(): AddedLinksRegistry {
const context = useContext(AddedLinksRegistryContext);
if (!context) {
throw new Error('No `AddedLinksRegistryContext` found.');
}
return context;
}
export function useAddedComponentsRegistry(): AddedComponentsRegistry {
const context = useContext(AddedComponentsRegistryContext);
if (!context) {
throw new Error('No `AddedComponentsRegistryContext` found.');
}
return context;
}
export function useExposedComponentsRegistry(): ExposedComponentsRegistry {
const context = useContext(ExposedComponentsRegistryContext);
if (!context) {
throw new Error('No `ExposedComponentsRegistryContext` found.');
}
return context;
}
export const ExtensionRegistriesProvider = ({
registries,
children,
}: PropsWithChildren<ExtensionRegistriesContextType>) => {
return (
<AddedLinksRegistryContext.Provider value={registries.addedLinksRegistry}>
<AddedComponentsRegistryContext.Provider value={registries.addedComponentsRegistry}>
<ExposedComponentsRegistryContext.Provider value={registries.exposedComponentsRegistry}>
{children}
</ExposedComponentsRegistryContext.Provider>
</AddedComponentsRegistryContext.Provider>
</AddedLinksRegistryContext.Provider>
);
};

View File

@ -2,6 +2,7 @@ import React from 'react';
import { firstValueFrom } from 'rxjs';
import { AddedComponentsRegistry } from './AddedComponentsRegistry';
import { MSG_CANNOT_REGISTER_READ_ONLY } from './Registry';
describe('AddedComponentsRegistry', () => {
const consoleWarn = jest.fn();
@ -377,4 +378,60 @@ describe('AddedComponentsRegistry', () => {
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(0);
});
it('should not be possible to register a component on a read-only registry', async () => {
const registry = new AddedComponentsRegistry();
const readOnlyRegistry = registry.readOnly();
expect(() => {
readOnlyRegistry.register({
pluginId: 'grafana-basic-app',
configs: [
{
title: 'Component 1 title',
description: '',
targets: ['grafana/alerting/home'],
component: () => React.createElement('div', null, 'Hello World1'),
},
],
});
}).toThrow(MSG_CANNOT_REGISTER_READ_ONLY);
const currentState = await readOnlyRegistry.getState();
expect(Object.keys(currentState)).toHaveLength(0);
});
it('should pass down fresh registrations to the read-only version of the registry', async () => {
const pluginId = 'grafana-basic-app';
const registry = new AddedComponentsRegistry();
const readOnlyRegistry = registry.readOnly();
const subscribeCallback = jest.fn();
let readOnlyState;
// Should have no extensions registered in the beginning
readOnlyState = await readOnlyRegistry.getState();
expect(Object.keys(readOnlyState)).toHaveLength(0);
readOnlyRegistry.asObservable().subscribe(subscribeCallback);
// Register an extension to the original (writable) registry
registry.register({
pluginId,
configs: [
{
title: 'Component 1 title',
description: 'Component 1 description',
targets: ['grafana/alerting/home'],
component: () => React.createElement('div', null, 'Hello World1'),
},
],
});
// The read-only registry should have received the new extension
readOnlyState = await readOnlyRegistry.getState();
expect(Object.keys(readOnlyState)).toHaveLength(1);
expect(subscribeCallback).toHaveBeenCalledTimes(2);
expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual(['grafana/alerting/home']);
});
});

View File

@ -1,3 +1,5 @@
import { ReplaySubject } from 'rxjs';
import { PluginExtensionAddedComponentConfig } from '@grafana/data';
import { logWarning, wrapWithPluginContext } from '../utils';
@ -21,10 +23,13 @@ export class AddedComponentsRegistry extends Registry<
AddedComponentRegistryItem[],
PluginExtensionAddedComponentConfig
> {
constructor(initialState: RegistryType<AddedComponentRegistryItem[]> = {}) {
super({
initialState,
});
constructor(
options: {
registrySubject?: ReplaySubject<RegistryType<AddedComponentRegistryItem[]>>;
initialState?: RegistryType<AddedComponentRegistryItem[]>;
} = {}
) {
super(options);
}
mapToRegistry(
@ -83,4 +88,11 @@ export class AddedComponentsRegistry extends Registry<
return registry;
}
// Returns a read-only version of the registry.
readOnly() {
return new AddedComponentsRegistry({
registrySubject: this.registrySubject,
});
}
}

View File

@ -1,6 +1,7 @@
import { firstValueFrom } from 'rxjs';
import { AddedLinksRegistry } from './AddedLinksRegistry';
import { MSG_CANNOT_REGISTER_READ_ONLY } from './Registry';
describe('AddedLinksRegistry', () => {
const consoleWarn = jest.fn();
@ -520,4 +521,63 @@ describe('AddedLinksRegistry', () => {
const registry = subscribeCallback.mock.calls[0][0];
expect(registry).toEqual({});
});
it('should not be possible to register a link on a read-only registry', async () => {
const pluginId = 'grafana-basic-app';
const registry = new AddedLinksRegistry();
const readOnlyRegistry = registry.readOnly();
expect(() => {
readOnlyRegistry.register({
pluginId,
configs: [
{
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident`,
targets: 'plugins/myorg-basic-app/start',
configure: jest.fn().mockReturnValue({}),
},
],
});
}).toThrow(MSG_CANNOT_REGISTER_READ_ONLY);
const currentState = await readOnlyRegistry.getState();
expect(Object.keys(currentState)).toHaveLength(0);
});
it('should pass down fresh registrations to the read-only version of the registry', async () => {
const pluginId = 'grafana-basic-app';
const registry = new AddedLinksRegistry();
const readOnlyRegistry = registry.readOnly();
const subscribeCallback = jest.fn();
let readOnlyState;
// Should have no extensions registered in the beginning
readOnlyState = await readOnlyRegistry.getState();
expect(Object.keys(readOnlyState)).toHaveLength(0);
readOnlyRegistry.asObservable().subscribe(subscribeCallback);
// Register an extension to the original (writable) registry
registry.register({
pluginId,
configs: [
{
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident`,
targets: 'plugins/myorg-basic-app/start',
configure: jest.fn().mockReturnValue({}),
},
],
});
// The read-only registry should have received the new extension
readOnlyState = await readOnlyRegistry.getState();
expect(Object.keys(readOnlyState)).toHaveLength(1);
expect(subscribeCallback).toHaveBeenCalledTimes(2);
expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual(['plugins/myorg-basic-app/start']);
});
});

View File

@ -1,3 +1,5 @@
import { ReplaySubject } from 'rxjs';
import { IconName, PluginExtensionAddedLinkConfig } from '@grafana/data';
import { PluginAddedLinksConfigureFunc, PluginExtensionEventHelpers } from '@grafana/data/src/types/pluginExtensions';
@ -25,10 +27,13 @@ export type AddedLinkRegistryItem<Context extends object = object> = {
};
export class AddedLinksRegistry extends Registry<AddedLinkRegistryItem[], PluginExtensionAddedLinkConfig> {
constructor(initialState: RegistryType<AddedLinkRegistryItem[]> = {}) {
super({
initialState,
});
constructor(
options: {
registrySubject?: ReplaySubject<RegistryType<AddedLinkRegistryItem[]>>;
initialState?: RegistryType<AddedLinkRegistryItem[]>;
} = {}
) {
super(options);
}
mapToRegistry(
@ -95,4 +100,11 @@ export class AddedLinksRegistry extends Registry<AddedLinkRegistryItem[], Plugin
return registry;
}
// Returns a read-only version of the registry.
readOnly() {
return new AddedLinksRegistry({
registrySubject: this.registrySubject,
});
}
}

View File

@ -2,6 +2,7 @@ import React from 'react';
import { firstValueFrom } from 'rxjs';
import { ExposedComponentsRegistry } from './ExposedComponentsRegistry';
import { MSG_CANNOT_REGISTER_READ_ONLY } from './Registry';
describe('ExposedComponentsRegistry', () => {
const consoleWarn = jest.fn();
@ -339,4 +340,61 @@ describe('ExposedComponentsRegistry', () => {
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(0);
});
it('should not be possible to register a component on a read-only registry', async () => {
const pluginId = 'grafana-basic-app';
const registry = new ExposedComponentsRegistry();
const readOnlyRegistry = registry.readOnly();
expect(() => {
readOnlyRegistry.register({
pluginId,
configs: [
{
id: `${pluginId}/hello-world/v1`,
title: 'not important',
description: 'not important',
component: () => React.createElement('div', null, 'Hello World1'),
},
],
});
}).toThrow(MSG_CANNOT_REGISTER_READ_ONLY);
const currentState = await readOnlyRegistry.getState();
expect(Object.keys(currentState)).toHaveLength(0);
});
it('should pass down fresh registrations to the read-only version of the registry', async () => {
const pluginId = 'grafana-basic-app';
const registry = new ExposedComponentsRegistry();
const readOnlyRegistry = registry.readOnly();
const subscribeCallback = jest.fn();
let readOnlyState;
// Should have no extensions registered in the beginning
readOnlyState = await readOnlyRegistry.getState();
expect(Object.keys(readOnlyState)).toHaveLength(0);
readOnlyRegistry.asObservable().subscribe(subscribeCallback);
// Register an extension to the original (writable) registry
registry.register({
pluginId,
configs: [
{
id: `${pluginId}/hello-world/v1`,
title: 'not important',
description: 'not important',
component: () => React.createElement('div', null, 'Hello World1'),
},
],
});
// The read-only registry should have received the new extension
readOnlyState = await readOnlyRegistry.getState();
expect(Object.keys(readOnlyState)).toHaveLength(1);
expect(subscribeCallback).toHaveBeenCalledTimes(2);
expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual([`${pluginId}/hello-world/v1`]);
});
});

View File

@ -1,3 +1,5 @@
import { ReplaySubject } from 'rxjs';
import { PluginExtensionExposedComponentConfig } from '@grafana/data';
import { logWarning } from '../utils';
@ -16,10 +18,13 @@ export class ExposedComponentsRegistry extends Registry<
ExposedComponentRegistryItem,
PluginExtensionExposedComponentConfig
> {
constructor(initialState: RegistryType<ExposedComponentRegistryItem> = {}) {
super({
initialState,
});
constructor(
options: {
registrySubject?: ReplaySubject<RegistryType<ExposedComponentRegistryItem>>;
initialState?: RegistryType<ExposedComponentRegistryItem>;
} = {}
) {
super(options);
}
mapToRegistry(
@ -68,4 +73,11 @@ export class ExposedComponentsRegistry extends Registry<
return registry;
}
// Returns a read-only version of the registry.
readOnly() {
return new ExposedComponentsRegistry({
registrySubject: this.registrySubject,
});
}
}

View File

@ -2,6 +2,8 @@ import { Observable, ReplaySubject, Subject, firstValueFrom, map, scan, startWit
import { deepFreeze } from '../utils';
export const MSG_CANNOT_REGISTER_READ_ONLY = 'Cannot register to a read-only registry';
export type PluginExtensionConfigs<T> = {
pluginId: string;
configs: T[];
@ -9,27 +11,39 @@ export type PluginExtensionConfigs<T> = {
export type RegistryType<T> = Record<string | symbol, T>;
type ConstructorOptions<T> = {
initialState: RegistryType<T>;
};
// This is the base-class used by the separate specific registries.
export abstract class Registry<TRegistryValue, TMapType> {
// Used in cases when we would like to pass a read-only registry to plugin.
// In these cases we are passing in the `registrySubject` to the constructor.
// (If TRUE `initialState` is ignored.)
private isReadOnly: boolean;
// This is the subject that receives extension configs for a loaded plugin.
private resultSubject: Subject<PluginExtensionConfigs<TMapType>>;
private registrySubject: ReplaySubject<RegistryType<TRegistryValue>>;
// This is the subject that we expose.
// (It will buffer the last value on the stream - the registry - and emit it to new subscribers immediately.)
protected registrySubject: ReplaySubject<RegistryType<TRegistryValue>>;
constructor(options: ConstructorOptions<TRegistryValue>) {
const { initialState } = options;
constructor(options: {
registrySubject?: ReplaySubject<RegistryType<TRegistryValue>>;
initialState?: RegistryType<TRegistryValue>;
}) {
this.resultSubject = new Subject<PluginExtensionConfigs<TMapType>>();
// This is the subject that we expose.
// (It will buffer the last value on the stream - the registry - and emit it to new subscribers immediately.)
this.registrySubject = new ReplaySubject<RegistryType<TRegistryValue>>(1);
this.isReadOnly = false;
// If the registry subject (observable) is provided, it means that all the registry updates are taken care of outside of this class -> it is read-only.
if (options.registrySubject) {
this.registrySubject = options.registrySubject;
this.isReadOnly = true;
return;
}
this.registrySubject = new ReplaySubject<RegistryType<TRegistryValue>>(1);
this.resultSubject
.pipe(
scan(this.mapToRegistry, initialState),
scan(this.mapToRegistry, options.initialState ?? {}),
// Emit an empty registry to start the stream (it is only going to do it once during construction, and then just passes down the values)
startWith(initialState),
startWith(options.initialState ?? {}),
map((registry) => deepFreeze(registry))
)
// Emitting the new registry to `this.registrySubject`
@ -42,6 +56,10 @@ export abstract class Registry<TRegistryValue, TMapType> {
): RegistryType<TRegistryValue>;
register(result: PluginExtensionConfigs<TMapType>): void {
if (this.isReadOnly) {
throw new Error(MSG_CANNOT_REGISTER_READ_ONLY);
}
this.resultSubject.next(result);
}

View File

@ -1,8 +1,13 @@
import { act, render, screen } from '@testing-library/react';
import { act, render, screen, waitFor } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { createUsePluginComponent } from './usePluginComponent';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { setupPluginExtensionRegistries } from './registry/setup';
import { PluginExtensionRegistries } from './registry/types';
import { usePluginComponent } from './usePluginComponent';
import * as utils from './utils';
const wrapWithPluginContext = jest.spyOn(utils, 'wrapWithPluginContext');
jest.mock('app/features/plugins/pluginSettings', () => ({
getPluginSettings: jest.fn().mockResolvedValue({
@ -16,15 +21,21 @@ jest.mock('app/features/plugins/pluginSettings', () => ({
}));
describe('usePluginComponent()', () => {
let registry: ExposedComponentsRegistry;
let registries: PluginExtensionRegistries;
let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element;
beforeEach(() => {
registry = new ExposedComponentsRegistry();
registries = setupPluginExtensionRegistries();
wrapWithPluginContext.mockClear();
wrapper = ({ children }: { children: React.ReactNode }) => (
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
);
});
it('should return null if there are no component exposed for the id', () => {
const usePluginComponent = createUsePluginComponent(registry);
const { result } = renderHook(() => usePluginComponent('foo/bar'));
const { result } = renderHook(() => usePluginComponent('foo/bar'), { wrapper });
expect(result.current.component).toEqual(null);
expect(result.current.isLoading).toEqual(false);
@ -34,13 +45,12 @@ describe('usePluginComponent()', () => {
const id = 'my-app-plugin/foo/bar/v1';
const pluginId = 'my-app-plugin';
registry.register({
registries.exposedComponentsRegistry.register({
pluginId,
configs: [{ id, title: 'not important', description: 'not important', component: () => <div>Hello World</div> }],
});
const usePluginComponent = createUsePluginComponent(registry);
const { result } = renderHook(() => usePluginComponent(id));
const { result } = renderHook(() => usePluginComponent(id), { wrapper });
const Component = result.current.component;
act(() => {
@ -55,8 +65,7 @@ describe('usePluginComponent()', () => {
it('should dynamically update when component is registered to the registry', async () => {
const id = 'my-app-plugin/foo/bar/v1';
const pluginId = 'my-app-plugin';
const usePluginComponent = createUsePluginComponent(registry);
const { result, rerender } = renderHook(() => usePluginComponent(id));
const { result, rerender } = renderHook(() => usePluginComponent(id), { wrapper });
// No extensions yet
expect(result.current.component).toBeNull();
@ -64,7 +73,7 @@ describe('usePluginComponent()', () => {
// Add extensions to the registry
act(() => {
registry.register({
registries.exposedComponentsRegistry.register({
pluginId,
configs: [
{
@ -91,12 +100,27 @@ describe('usePluginComponent()', () => {
expect(await screen.findByText('Hello World')).toBeVisible();
});
it('should only render the hook once', () => {
const spy = jest.spyOn(registry, 'asObservable');
const id = 'my-app-plugin/foo/bar';
const usePluginComponent = createUsePluginComponent(registry);
it('should only render the hook once', async () => {
const pluginId = 'my-app-plugin';
const id = `${pluginId}/foo/v1`;
renderHook(() => usePluginComponent(id));
expect(spy).toHaveBeenCalledTimes(1);
// Add extensions to the registry
act(() => {
registries.exposedComponentsRegistry.register({
pluginId,
configs: [
{
id,
title: 'not important',
description: 'not important',
component: () => <div>Hello World</div>,
},
],
});
});
expect(wrapWithPluginContext).toHaveBeenCalledTimes(0);
renderHook(() => usePluginComponent(id), { wrapper });
await waitFor(() => expect(wrapWithPluginContext).toHaveBeenCalledTimes(1));
});
});

View File

@ -3,31 +3,28 @@ import { useObservable } from 'react-use';
import { UsePluginComponentResult } from '@grafana/runtime';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { useExposedComponentsRegistry } from './ExtensionRegistriesContext';
import { wrapWithPluginContext } from './utils';
// Returns a component exposed by a plugin.
// (Exposed components can be defined in plugins by calling .exposeComponent() on the AppPlugin instance.)
export function createUsePluginComponent(registry: ExposedComponentsRegistry) {
const observableRegistry = registry.asObservable();
return function usePluginComponent<Props extends object = {}>(id: string): UsePluginComponentResult<Props> {
const registry = useObservable(observableRegistry);
return useMemo(() => {
if (!registry?.[id]) {
return {
isLoading: false,
component: null,
};
}
const registryItem = registry[id];
export function usePluginComponent<Props extends object = {}>(id: string): UsePluginComponentResult<Props> {
const registry = useExposedComponentsRegistry();
const registryState = useObservable(registry.asObservable());
return useMemo(() => {
if (!registryState?.[id]) {
return {
isLoading: false,
component: wrapWithPluginContext(registryItem.pluginId, registryItem.component),
component: null,
};
}, [id, registry]);
};
}
const registryItem = registryState[id];
return {
isLoading: false,
component: wrapWithPluginContext(registryItem.pluginId, registryItem.component),
};
}, [id, registryState]);
}

View File

@ -1,8 +1,13 @@
import { act, render, screen } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
import { createUsePluginComponents } from './usePluginComponents';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { setupPluginExtensionRegistries } from './registry/setup';
import { PluginExtensionRegistries } from './registry/types';
import { usePluginComponents } from './usePluginComponents';
import * as utils from './utils';
const wrapWithPluginContext = jest.spyOn(utils, 'wrapWithPluginContext');
jest.mock('app/features/plugins/pluginSettings', () => ({
getPluginSettings: jest.fn().mockResolvedValue({
@ -16,18 +21,26 @@ jest.mock('app/features/plugins/pluginSettings', () => ({
}));
describe('usePluginComponents()', () => {
let registry: AddedComponentsRegistry;
let registries: PluginExtensionRegistries;
let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element;
beforeEach(() => {
registry = new AddedComponentsRegistry();
registries = setupPluginExtensionRegistries();
wrapWithPluginContext.mockClear();
wrapper = ({ children }: { children: React.ReactNode }) => (
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
);
});
it('should return an empty array if there are no extensions registered for the extension point', () => {
const usePluginComponents = createUsePluginComponents(registry);
const { result } = renderHook(() =>
usePluginComponents({
extensionPointId: 'foo/bar',
})
const { result } = renderHook(
() =>
usePluginComponents({
extensionPointId: 'foo/bar',
}),
{ wrapper }
);
expect(result.current.components).toEqual([]);
@ -37,7 +50,7 @@ describe('usePluginComponents()', () => {
const extensionPointId = 'plugins/foo/bar/v1';
const pluginId = 'my-app-plugin';
registry.register({
registries.addedComponentsRegistry.register({
pluginId,
configs: [
{
@ -61,8 +74,7 @@ describe('usePluginComponents()', () => {
],
});
const usePluginComponents = createUsePluginComponents(registry);
const { result } = renderHook(() => usePluginComponents({ extensionPointId }));
const { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper });
expect(result.current.components.length).toBe(2);
@ -77,15 +89,14 @@ describe('usePluginComponents()', () => {
it('should dynamically update the extensions registered for a certain extension point', () => {
const extensionPointId = 'plugins/foo/bar/v1';
const pluginId = 'my-app-plugin';
const usePluginComponents = createUsePluginComponents(registry);
let { result, rerender } = renderHook(() => usePluginComponents({ extensionPointId }));
let { result, rerender } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper });
// No extensions yet
expect(result.current.components.length).toBe(0);
// Add extensions to the registry
act(() => {
registry.register({
registries.addedComponentsRegistry.register({
pluginId,
configs: [
{
@ -116,20 +127,12 @@ describe('usePluginComponents()', () => {
expect(result.current.components.length).toBe(2);
});
it('should only render the hook once', () => {
const spy = jest.spyOn(registry, 'asObservable');
const extensionPointId = 'plugins/foo/bar';
const usePluginComponents = createUsePluginComponents(registry);
renderHook(() => usePluginComponents({ extensionPointId }));
expect(spy).toHaveBeenCalledTimes(1);
});
it('should honour the limitPerPlugin arg if its set', () => {
const extensionPointId = 'plugins/foo/bar/v1';
const plugins = ['my-app-plugin1', 'my-app-plugin2', 'my-app-plugin3'];
const usePluginComponents = createUsePluginComponents(registry);
let { result, rerender } = renderHook(() => usePluginComponents({ extensionPointId, limitPerPlugin: 2 }));
let { result, rerender } = renderHook(() => usePluginComponents({ extensionPointId, limitPerPlugin: 2 }), {
wrapper,
});
// No extensions yet
expect(result.current.components.length).toBe(0);
@ -137,7 +140,7 @@ describe('usePluginComponents()', () => {
// Add extensions to the registry
act(() => {
for (let pluginId of plugins) {
registry.register({
registries.addedComponentsRegistry.register({
pluginId,
configs: [
{

View File

@ -6,42 +6,39 @@ import {
UsePluginComponentsResult,
} from '@grafana/runtime/src/services/pluginExtensions/getPluginExtensions';
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
import { useAddedComponentsRegistry } from './ExtensionRegistriesContext';
// Returns an array of component extensions for the given extension point
export function createUsePluginComponents(registry: AddedComponentsRegistry) {
const observableRegistry = registry.asObservable();
export function usePluginComponents<Props extends object = {}>({
limitPerPlugin,
extensionPointId,
}: UsePluginComponentOptions): UsePluginComponentsResult<Props> {
const registry = useAddedComponentsRegistry();
const registryState = useObservable(registry.asObservable());
return function usePluginComponents<Props extends object = {}>({
limitPerPlugin,
extensionPointId,
}: UsePluginComponentOptions): UsePluginComponentsResult<Props> {
const registry = useObservable(observableRegistry);
return useMemo(() => {
const components: Array<React.ComponentType<Props>> = [];
const extensionsByPlugin: Record<string, number> = {};
return useMemo(() => {
const components: Array<React.ComponentType<Props>> = [];
const extensionsByPlugin: Record<string, number> = {};
for (const registryItem of registryState?.[extensionPointId] ?? []) {
const { pluginId } = registryItem;
for (const registryItem of registry?.[extensionPointId] ?? []) {
const { pluginId } = registryItem;
// Only limit if the `limitPerPlugin` is set
if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) {
continue;
}
if (extensionsByPlugin[pluginId] === undefined) {
extensionsByPlugin[pluginId] = 0;
}
components.push(registryItem.component as React.ComponentType<Props>);
extensionsByPlugin[pluginId] += 1;
// Only limit if the `limitPerPlugin` is set
if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) {
continue;
}
return {
isLoading: false,
components,
};
}, [extensionPointId, limitPerPlugin, registry]);
};
if (extensionsByPlugin[pluginId] === undefined) {
extensionsByPlugin[pluginId] = 0;
}
components.push(registryItem.component as React.ComponentType<Props>);
extensionsByPlugin[pluginId] += 1;
}
return {
isLoading: false,
components,
};
}, [extensionPointId, limitPerPlugin, registryState]);
}

View File

@ -1,8 +1,10 @@
import { act } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { createUsePluginLinks } from './usePluginLinks';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { setupPluginExtensionRegistries } from './registry/setup';
import { PluginExtensionRegistries } from './registry/types';
import { usePluginLinks } from './usePluginLinks';
jest.mock('app/features/plugins/pluginSettings', () => ({
getPluginSettings: jest.fn().mockResolvedValue({
@ -16,18 +18,24 @@ jest.mock('app/features/plugins/pluginSettings', () => ({
}));
describe('usePluginLinks()', () => {
let registry: AddedLinksRegistry;
let registries: PluginExtensionRegistries;
let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element;
beforeEach(() => {
registry = new AddedLinksRegistry();
registries = setupPluginExtensionRegistries();
wrapper = ({ children }: { children: React.ReactNode }) => (
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
);
});
it('should return an empty array if there are no link extensions registered for the extension point', () => {
const usePluginComponents = createUsePluginLinks(registry);
const { result } = renderHook(() =>
usePluginComponents({
extensionPointId: 'foo/bar',
})
const { result } = renderHook(
() =>
usePluginLinks({
extensionPointId: 'foo/bar',
}),
{ wrapper }
);
expect(result.current.links).toEqual([]);
@ -37,7 +45,7 @@ describe('usePluginLinks()', () => {
const extensionPointId = 'plugins/foo/bar/v1';
const pluginId = 'my-app-plugin';
registry.register({
registries.addedLinksRegistry.register({
pluginId,
configs: [
{
@ -61,8 +69,7 @@ describe('usePluginLinks()', () => {
],
});
const usePluginExtensions = createUsePluginLinks(registry);
const { result } = renderHook(() => usePluginExtensions({ extensionPointId }));
const { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
expect(result.current.links.length).toBe(2);
expect(result.current.links[0].title).toBe('1');
@ -72,15 +79,14 @@ describe('usePluginLinks()', () => {
it('should dynamically update the extensions registered for a certain extension point', () => {
const extensionPointId = 'plugins/foo/bar/v1';
const pluginId = 'my-app-plugin';
const usePluginExtensions = createUsePluginLinks(registry);
let { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId }));
let { result, rerender } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
// No extensions yet
expect(result.current.links.length).toBe(0);
// Add extensions to the registry
act(() => {
registry.register({
registries.addedLinksRegistry.register({
pluginId,
configs: [
{
@ -106,13 +112,4 @@ describe('usePluginLinks()', () => {
expect(result.current.links[0].title).toBe('1');
expect(result.current.links[1].title).toBe('2');
});
it('should only render the hook once', () => {
const addedLinksRegistrySpy = jest.spyOn(registry, 'asObservable');
const extensionPointId = 'plugins/foo/bar';
const usePluginLinks = createUsePluginLinks(registry);
renderHook(() => usePluginLinks({ extensionPointId }));
expect(addedLinksRegistrySpy).toHaveBeenCalledTimes(1);
});
});

View File

@ -8,7 +8,7 @@ import {
UsePluginLinksResult,
} from '@grafana/runtime/src/services/pluginExtensions/getPluginExtensions';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { useAddedLinksRegistry } from './ExtensionRegistriesContext';
import {
generateExtensionId,
getLinkExtensionOnClick,
@ -18,69 +18,67 @@ import {
} from './utils';
// Returns an array of component extensions for the given extension point
export function createUsePluginLinks(registry: AddedLinksRegistry) {
const observableRegistry = registry.asObservable();
return function usePluginLinks({
limitPerPlugin,
extensionPointId,
context,
}: UsePluginLinksOptions): UsePluginLinksResult {
const registry = useObservable(observableRegistry);
return useMemo(() => {
if (!registry || !registry[extensionPointId]) {
return {
isLoading: false,
links: [],
};
}
const frozenContext = context ? getReadOnlyProxy(context) : {};
const extensions: PluginExtensionLink[] = [];
const extensionsByPlugin: Record<string, number> = {};
for (const addedLink of registry[extensionPointId] ?? []) {
const { pluginId } = addedLink;
// Only limit if the `limitPerPlugin` is set
if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) {
continue;
}
if (extensionsByPlugin[pluginId] === undefined) {
extensionsByPlugin[pluginId] = 0;
}
// Run the configure() function with the current context, and apply the ovverides
const overrides = getLinkExtensionOverrides(pluginId, addedLink, frozenContext);
// configure() returned an `undefined` -> hide the extension
if (addedLink.configure && overrides === undefined) {
continue;
}
const path = overrides?.path || addedLink.path;
const extension: PluginExtensionLink = {
id: generateExtensionId(pluginId, extensionPointId, addedLink.title),
type: PluginExtensionTypes.link,
pluginId: pluginId,
onClick: getLinkExtensionOnClick(pluginId, extensionPointId, addedLink, frozenContext),
// Configurable properties
icon: overrides?.icon || addedLink.icon,
title: overrides?.title || addedLink.title,
description: overrides?.description || addedLink.description,
path: isString(path) ? getLinkExtensionPathWithTracking(pluginId, path, extensionPointId) : undefined,
category: overrides?.category || addedLink.category,
};
extensions.push(extension);
extensionsByPlugin[pluginId] += 1;
}
export function usePluginLinks({
limitPerPlugin,
extensionPointId,
context,
}: UsePluginLinksOptions): UsePluginLinksResult {
const registry = useAddedLinksRegistry();
const registryState = useObservable(registry.asObservable());
return useMemo(() => {
if (!registryState || !registryState[extensionPointId]) {
return {
isLoading: false,
links: extensions,
links: [],
};
}, [context, extensionPointId, limitPerPlugin, registry]);
};
}
const frozenContext = context ? getReadOnlyProxy(context) : {};
const extensions: PluginExtensionLink[] = [];
const extensionsByPlugin: Record<string, number> = {};
for (const addedLink of registryState[extensionPointId] ?? []) {
const { pluginId } = addedLink;
// Only limit if the `limitPerPlugin` is set
if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) {
continue;
}
if (extensionsByPlugin[pluginId] === undefined) {
extensionsByPlugin[pluginId] = 0;
}
// Run the configure() function with the current context, and apply the ovverides
const overrides = getLinkExtensionOverrides(pluginId, addedLink, frozenContext);
// configure() returned an `undefined` -> hide the extension
if (addedLink.configure && overrides === undefined) {
continue;
}
const path = overrides?.path || addedLink.path;
const extension: PluginExtensionLink = {
id: generateExtensionId(pluginId, extensionPointId, addedLink.title),
type: PluginExtensionTypes.link,
pluginId: pluginId,
onClick: getLinkExtensionOnClick(pluginId, extensionPointId, addedLink, frozenContext),
// Configurable properties
icon: overrides?.icon || addedLink.icon,
title: overrides?.title || addedLink.title,
description: overrides?.description || addedLink.description,
path: isString(path) ? getLinkExtensionPathWithTracking(pluginId, path, extensionPointId) : undefined,
category: overrides?.category || addedLink.category,
};
extensions.push(extension);
extensionsByPlugin[pluginId] += 1;
}
return {
isLoading: false,
links: extensions,
};
}, [context, extensionPointId, limitPerPlugin, registryState]);
}