Compare commits

...

1 Commits

Author SHA1 Message Date
Levente Balogh
22ab22d470 test: add test for the configure() function updates 2025-12-19 13:45:47 +01:00
15 changed files with 1023 additions and 392 deletions

View File

@@ -583,6 +583,7 @@ export {
type PluginExtensionComponentMeta,
type ComponentTypeWithExtensionMeta,
type PluginExtensionFunction,
type PluginExtensionLinkUpdate,
type PluginExtensionEventHelpers,
type DataSourceConfigErrorStatusContext,
type PluginExtensionPanelContext,

View File

@@ -99,17 +99,21 @@ export type PluginExtensionAddedFunctionConfig<Signature = unknown> = PluginExte
*/
fn: Signature;
};
export interface PluginExtensionLinkUpdate<Context extends object> {
title: string;
description: string;
path: string;
onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => void;
icon: IconName;
category: string;
openInNewTab: boolean;
}
export type PluginAddedLinksConfigureFunc<Context extends object> = (context: Readonly<Context> | undefined) =>
| Partial<{
title: string;
description: string;
path: string;
onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => void;
icon: IconName;
category: string;
openInNewTab: boolean;
}>
export type PluginAddedLinksConfigureFunc<Context extends object> = (
context: Readonly<Context> | undefined
) =>
| Partial<PluginExtensionLinkUpdate<Context>>
| Promise<Partial<PluginExtensionLinkUpdate<Context>> | undefined>
| undefined;
export type PluginExtensionAddedLinkConfig<Context extends object = object> = PluginExtensionConfigBase & {

View File

@@ -75,6 +75,12 @@ export const ExtensionSidebarContextProvider = ({ children }: ExtensionSidebarCo
);
const [currentPath, setCurrentPath] = useState(locationService.getLocation().pathname);
const context = useMemo(
() => ({
path: currentPath,
}),
[currentPath]
);
useEffect(() => {
const subscription = locationService.getLocationObservable().subscribe((location) => {
@@ -92,9 +98,10 @@ export const ExtensionSidebarContextProvider = ({ children }: ExtensionSidebarCo
// whether the component is rendered or not
const { links, isLoading } = usePluginLinks({
extensionPointId: PluginExtensionPoints.ExtensionSidebar,
context: {
path: currentPath,
},
context,
// context: {
// path: currentPath,
// },
});
// get all components for this extension point, but only for the permitted plugins

View File

@@ -1,3 +1,4 @@
import { of } from 'rxjs';
import {
FieldType,
LoadingState,
@@ -22,6 +23,7 @@ import { contextSrv } from 'app/core/services/context_srv';
import { GetExploreUrlArguments } from 'app/core/utils/explore';
import { grantUserPermissions } from 'app/features/alerting/unified/mocks';
import { scenesPanelToRuleFormValues } from 'app/features/alerting/unified/utils/rule-form';
import * as getPluginExtensions from 'app/features/plugins/extensions/getPluginExtensions';
import * as storeModule from 'app/store/store';
import { AccessControlAction } from 'app/types/accessControl';
@@ -36,6 +38,8 @@ const mocks = {
contextSrv: jest.mocked(contextSrv),
getExploreUrl: jest.fn(),
notifyApp: jest.fn(),
getPluginExtensions: jest.fn().mockReturnValue({ extensions: [] }),
getObservablePluginLinks: jest.fn().mockReturnValue(of([])),
};
jest.mock('app/core/utils/explore', () => ({
@@ -51,10 +55,10 @@ jest.mock('app/store/store', () => ({
dispatch: jest.fn(),
}));
const getPluginExtensionsMock = jest.fn().mockReturnValue({ extensions: [] });
jest.mock('app/features/plugins/extensions/getPluginExtensions', () => ({
...jest.requireActual('app/features/plugins/extensions/getPluginExtensions'),
createPluginExtensionsGetter: () => getPluginExtensionsMock,
createPluginExtensionsGetter: () => mocks.getPluginExtensions,
getObservablePluginLinks: () => mocks.getObservablePluginLinks(),
}));
describe('panelMenuBehavior', () => {
@@ -125,8 +129,8 @@ describe('panelMenuBehavior', () => {
describe('when extending panel menu from plugins', () => {
it('should contain menu item from link extension', async () => {
getPluginExtensionsMock.mockReturnValue({
extensions: [
mocks.getObservablePluginLinks.mockReturnValue(
of([
{
id: '1',
pluginId: '...',
@@ -135,8 +139,8 @@ describe('panelMenuBehavior', () => {
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
},
],
});
])
);
const { menu, panel } = await buildTestScene({});
@@ -164,7 +168,7 @@ describe('panelMenuBehavior', () => {
});
it('should truncate menu item title to 25 chars', async () => {
getPluginExtensionsMock.mockReturnValue({
mocks.getPluginExtensions.mockReturnValue({
extensions: [
{
id: '1',
@@ -203,7 +207,7 @@ describe('panelMenuBehavior', () => {
});
it('should show icons for link extensions (if they provide it)', async () => {
getPluginExtensionsMock.mockReturnValue({
mocks.getPluginExtensions.mockReturnValue({
extensions: [
{
id: '1',
@@ -246,7 +250,7 @@ describe('panelMenuBehavior', () => {
it('should pass onClick from plugin extension link to menu item', async () => {
const expectedOnClick = jest.fn();
getPluginExtensionsMock.mockReturnValue({
mocks.getPluginExtensions.mockReturnValue({
extensions: [
{
id: '1',
@@ -332,7 +336,7 @@ describe('panelMenuBehavior', () => {
data,
};
expect(getPluginExtensionsMock).toBeCalledWith(expect.objectContaining({ context }));
expect(mocks.getPluginExtensions).toBeCalledWith(expect.objectContaining({ context }));
});
it('should pass context with default time zone values when configuring extension', async () => {
@@ -389,12 +393,12 @@ describe('panelMenuBehavior', () => {
data,
};
expect(getPluginExtensionsMock).toBeCalledWith(expect.objectContaining({ context }));
expect(mocks.getPluginExtensions).toBeCalledWith(expect.objectContaining({ context }));
});
it('should contain menu item with category', async () => {
getPluginExtensionsMock.mockReturnValue({
extensions: [
it.only('should contain menu item with category', async () => {
mocks.getObservablePluginLinks.mockReturnValue(
of([
{
id: '1',
pluginId: '...',
@@ -404,8 +408,8 @@ describe('panelMenuBehavior', () => {
path: '/a/grafana-basic-app/declare-incident',
category: 'Incident',
},
],
});
])
);
const { menu, panel } = await buildTestScene({});
@@ -416,7 +420,7 @@ describe('panelMenuBehavior', () => {
menu.activate();
await new Promise((r) => setTimeout(r, 1));
await new Promise((r) => setTimeout(r, 100));
expect(menu.state.items?.length).toBe(7);
@@ -438,7 +442,7 @@ describe('panelMenuBehavior', () => {
});
it('should truncate category to 25 chars', async () => {
getPluginExtensionsMock.mockReturnValue({
mocks.getPluginExtensions.mockReturnValue({
extensions: [
{
id: '1',
@@ -483,7 +487,7 @@ describe('panelMenuBehavior', () => {
});
it('should contain menu item with category and append items without category after divider', async () => {
getPluginExtensionsMock.mockReturnValue({
mocks.getPluginExtensions.mockReturnValue({
extensions: [
{
id: '1',
@@ -596,7 +600,7 @@ describe('panelMenuBehavior', () => {
describe('plugin links', () => {
it('should not show Metrics Drilldown menu when no Metrics Drilldown links exist', async () => {
getPluginExtensionsMock.mockReturnValue({
mocks.getPluginExtensions.mockReturnValue({
extensions: [
{
id: '1',
@@ -634,7 +638,7 @@ describe('panelMenuBehavior', () => {
});
it('should separate Metrics Drilldown links into their own menu', async () => {
getPluginExtensionsMock.mockReturnValue({
mocks.getPluginExtensions.mockReturnValue({
extensions: [
{
id: '1',
@@ -698,7 +702,7 @@ describe('panelMenuBehavior', () => {
});
it('should not show extensions menu when no non-Metrics Drilldown links exist', async () => {
getPluginExtensionsMock.mockReturnValue({
mocks.getPluginExtensions.mockReturnValue({
extensions: [
{
id: '1',

View File

@@ -1,3 +1,5 @@
import { Subscription } from 'rxjs';
import {
getTimeZone,
InterpolateFunction,
@@ -8,7 +10,6 @@ import {
PluginExtensionLink,
PluginExtensionPanelContext,
PluginExtensionPoints,
PluginExtensionTypes,
urlUtil,
} from '@grafana/data';
import { t } from '@grafana/i18n';
@@ -25,9 +26,7 @@ import { scenesPanelToRuleFormValues } from 'app/features/alerting/unified/utils
import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
import { InspectTab } from 'app/features/inspector/types';
import { getScenePanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
import { createPluginExtensionsGetter } from 'app/features/plugins/extensions/getPluginExtensions';
import { pluginExtensionRegistries } from 'app/features/plugins/extensions/registry/setup';
import { GetPluginExtensions } from 'app/features/plugins/extensions/types';
import { getObservablePluginLinks } from 'app/features/plugins/extensions/getPluginExtensions';
import { createExtensionSubMenu } from 'app/features/plugins/extensions/utils';
import { dispatch } from 'app/store/store';
import { AccessControlAction } from 'app/types/accessControl';
@@ -45,18 +44,6 @@ import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks';
import { UnlinkLibraryPanelModal } from './UnlinkLibraryPanelModal';
import { PanelTimeRangeDrawer } from './panel-timerange/PanelTimeRangeDrawer';
let getPluginExtensions: GetPluginExtensions;
function setupGetPluginExtensions() {
if (getPluginExtensions) {
return getPluginExtensions;
}
getPluginExtensions = createPluginExtensionsGetter(pluginExtensionRegistries);
return getPluginExtensions;
}
// Define the category for metrics drilldown links
const METRICS_DRILLDOWN_CATEGORY = 'metrics-drilldown';
@@ -64,6 +51,8 @@ const METRICS_DRILLDOWN_CATEGORY = 'metrics-drilldown';
* Behavior is called when VizPanelMenu is activated (ie when it's opened).
*/
export function panelMenuBehavior(menu: VizPanelMenu) {
let extensionsSubscription: Subscription;
const asyncFunc = async () => {
// hm.. add another generic param to SceneObject to specify parent type?
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@@ -300,20 +289,32 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
});
}
setupGetPluginExtensions();
const { extensions } = getPluginExtensions({
// Extensions
// --------------
extensionsSubscription = getObservablePluginLinks({
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
context: createExtensionContext(panel, dashboard),
limitPerPlugin: 3,
});
}).subscribe((extensions) => {
console.log('EXTENSIONS 1', extensions);
if (dashboard.state.isEditing) {
return;
}
if (extensions.length > 0 && !dashboard.state.isEditing) {
const linkExtensions = extensions.filter((extension) => extension.type === PluginExtensionTypes.link);
const updatedItems = [...(menu.state.items ?? [])];
const metricsDrilldownText = t(
'dashboard-scene.panel-menu-behavior.async-func.text.metrics-drilldown',
'Metrics drilldown'
);
const extensionsText = t('dashboard-scene.panel-menu-behavior.async-func.text.extensions', 'Extensions');
const metricsDrilldownItem = updatedItems.find((item) => item.text === metricsDrilldownText);
const extensionsItem = updatedItems.find((item) => item.text === extensionsText);
const extensionsItemIndex = extensionsItem ? updatedItems.indexOf(extensionsItem) : -1;
const metricsDrilldownItemIndex = metricsDrilldownItem ? updatedItems.indexOf(metricsDrilldownItem) : -1;
// Separate metrics drilldown links from other links
const [metricsDrilldownLinks, otherLinks] = linkExtensions.reduce<[PluginExtensionLink[], PluginExtensionLink[]]>(
([metricsDrilldownLinks, otherLinks], link) => {
const [metricsDrilldownLinks, otherLinks] = extensions.reduce<[PluginExtensionLink[], PluginExtensionLink[]]>(
([metricsDrilldownLinks, otherLinks], link: PluginExtensionLink) => {
if (link.category === METRICS_DRILLDOWN_CATEGORY) {
metricsDrilldownLinks.push(link);
} else {
@@ -324,26 +325,46 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
[[], []]
);
// Add specific "Metrics drilldown" menu
if (metricsDrilldownLinks.length > 0) {
items.push({
text: t('dashboard-scene.panel-menu-behavior.async-func.text.metrics-drilldown', 'Metrics drilldown'),
const newMetricsDrilldownItem: PanelMenuItem = {
text: metricsDrilldownText,
iconClassName: 'code-branch',
type: 'submenu',
subMenu: createExtensionSubMenu(metricsDrilldownLinks),
});
};
// Replace existing menu item
if (metricsDrilldownItemIndex >= 0) {
updatedItems[metricsDrilldownItemIndex] = newMetricsDrilldownItem;
// Add new item
} else {
updatedItems.push(newMetricsDrilldownItem);
}
}
// Add generic "Extensions" menu for other links
// Nit: this code does not take care of reactively removing the extensions submenu,
// in case all the related extensions would be removed from the registry during runtime.
if (otherLinks.length > 0) {
items.push({
text: t('dashboard-scene.panel-menu-behavior.async-func.text.extensions', 'Extensions'),
const newExtensionsItem: PanelMenuItem = {
text: extensionsText,
iconClassName: 'plug',
type: 'submenu',
subMenu: createExtensionSubMenu(otherLinks),
});
};
// Replace existing menu item
if (extensionsItemIndex >= 0) {
updatedItems[extensionsItemIndex] = newExtensionsItem;
// Add new item
} else {
updatedItems.push(newExtensionsItem);
}
}
}
console.log('EXTENSIONS 2', updatedItems);
menu.setState({ items: updatedItems });
});
if (moreSubMenu.length) {
items.push({
@@ -378,6 +399,11 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
};
asyncFunc();
// Deactivation
return () => {
extensionsSubscription.unsubscribe();
};
}
async function getExploreMenuItem(panel: VizPanel): Promise<PanelMenuItem | undefined> {

View File

@@ -1,10 +1,12 @@
import * as React from 'react';
import { first, firstValueFrom, take } from 'rxjs';
import { first, lastValueFrom, Observable, take, takeUntil, timer } from 'rxjs';
import {
type PluginExtensionAddedLinkConfig,
type PluginExtensionAddedComponentConfig,
PluginExtensionLink,
PluginExtensionTypes,
PluginExtension,
} from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
@@ -61,11 +63,15 @@ async function createRegistries(
}
return {
addedLinksRegistry: await addedLinksRegistry.getState(),
addedComponentsRegistry: await addedComponentsRegistry.getState(),
addedLinksRegistry,
addedComponentsRegistry,
};
}
function getLastEmittedValue<T = { extensions: PluginExtension[] }>(observable: Observable<T>) {
return lastValueFrom(observable.pipe(takeUntil(timer(100))));
}
describe('getPluginExtensions()', () => {
const extensionPoint1 = 'grafana/dashboard/panel/menu/v1';
const extensionPoint2 = 'plugins/myorg-basic-app/start/v1';
@@ -108,10 +114,12 @@ describe('getPluginExtensions()', () => {
const registries = await createRegistries([
{ pluginId, addedLinkConfigs: [link1, link2], addedComponentConfigs: [] },
]);
const { extensions } = getPluginExtensions({
...registries,
extensionPointId: extensionPoint1,
});
const { extensions } = await getLastEmittedValue(
getPluginExtensions({
...registries,
extensionPointId: extensionPoint1,
})
);
expect(extensions).toHaveLength(1);
expect(extensions[0]).toEqual(
@@ -127,12 +135,19 @@ describe('getPluginExtensions()', () => {
test('should not limit the number of extensions per plugin by default', async () => {
// Registering 3 extensions for the same plugin for the same placement
const registries = await createRegistries([
{ pluginId, addedLinkConfigs: [link1, link1, link1, link2], addedComponentConfigs: [] },
{
pluginId,
addedLinkConfigs: [link1, link1, link1, link2],
addedComponentConfigs: [],
},
]);
const { extensions } = getPluginExtensions({
...registries,
extensionPointId: extensionPoint1,
});
const { extensions } = await getLastEmittedValue(
getPluginExtensions({
...registries,
extensionPointId: extensionPoint1,
})
);
expect(extensions).toHaveLength(3);
expect(extensions[0]).toEqual(
@@ -143,6 +158,7 @@ describe('getPluginExtensions()', () => {
path: expect.stringContaining(link1.path!),
})
);
// });
});
test('should be possible to limit the number of extensions per plugin for a given placement', async () => {
@@ -161,11 +177,13 @@ describe('getPluginExtensions()', () => {
]);
// Limit to 1 extension per plugin
const { extensions } = getPluginExtensions({
...registries,
extensionPointId: extensionPoint1,
limitPerPlugin: 1,
});
const { extensions } = await getLastEmittedValue(
getPluginExtensions({
...registries,
extensionPointId: extensionPoint1,
limitPerPlugin: 1,
})
);
expect(extensions).toHaveLength(2);
expect(extensions[0]).toEqual(
@@ -182,10 +200,12 @@ describe('getPluginExtensions()', () => {
const registries = await createRegistries([
{ pluginId, addedLinkConfigs: [link1, link2], addedComponentConfigs: [] },
]);
const { extensions } = getPluginExtensions({
...registries,
extensionPointId: 'placement-with-no-extensions',
});
const { extensions } = await getLastEmittedValue(
getPluginExtensions({
...registries,
extensionPointId: 'placement-with-no-extensions',
})
);
expect(extensions).toEqual([]);
});
@@ -194,7 +214,7 @@ describe('getPluginExtensions()', () => {
const context = { title: 'New title from the context!' };
const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 });
await getLastEmittedValue(getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 }));
expect(link2.configure).toHaveBeenCalledTimes(1);
expect(link2.configure).toHaveBeenCalledWith(context);
@@ -210,10 +230,12 @@ describe('getPluginExtensions()', () => {
}));
const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({
...registries,
extensionPointId: extensionPoint2,
});
const { extensions } = await getLastEmittedValue(
getPluginExtensions({
...registries,
extensionPointId: extensionPoint2,
})
);
const [extension] = extensions;
assertPluginExtensionLink(extension);
@@ -236,10 +258,12 @@ describe('getPluginExtensions()', () => {
}));
const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({
...registries,
extensionPointId: extensionPoint2,
});
const { extensions } = await getLastEmittedValue(
getPluginExtensions({
...registries,
extensionPointId: extensionPoint2,
})
);
const [extension] = extensions;
assertPluginExtensionLink(extension);
@@ -264,10 +288,12 @@ describe('getPluginExtensions()', () => {
}));
const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({
...registries,
extensionPointId: extensionPoint2,
});
const { extensions } = await getLastEmittedValue(
getPluginExtensions({
...registries,
extensionPointId: extensionPoint2,
})
);
const [extension] = extensions;
expect(link2.configure).toHaveBeenCalledTimes(1);
@@ -281,11 +307,13 @@ describe('getPluginExtensions()', () => {
test('should pass a read only context to the configure() function', async () => {
const context = { title: 'New title from the context!' };
const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({
...registries,
context,
extensionPointId: extensionPoint2,
});
const { extensions } = await getLastEmittedValue(
getPluginExtensions({
...registries,
context,
extensionPointId: extensionPoint2,
})
);
const [extension] = extensions;
const readOnlyContext = (link2.configure as jest.Mock).mock.calls[0][0];
@@ -306,8 +334,8 @@ describe('getPluginExtensions()', () => {
const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
expect(() => {
getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
expect(async () => {
await getLastEmittedValue(getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }));
}).not.toThrow();
expect(link2.configure).toHaveBeenCalledTimes(1);
@@ -327,29 +355,35 @@ describe('getPluginExtensions()', () => {
link2.configure = jest.fn().mockImplementation(() => overrides);
const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
const { extensions } = await getLastEmittedValue(
getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 })
);
expect(extensions).toHaveLength(0);
expect(link2.configure).toHaveBeenCalledTimes(1);
expect(log.error).toHaveBeenCalledTimes(1);
});
test('should skip the extension if the configure() function returns a promise', async () => {
test('should not skip the extension if the configure() function returns a promise', async () => {
link2.configure = jest.fn().mockImplementation(() => Promise.resolve({}));
const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
const { extensions } = await getLastEmittedValue(
getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 })
);
expect(extensions).toHaveLength(0);
expect(extensions).toHaveLength(1);
expect(link2.configure).toHaveBeenCalledTimes(1);
expect(log.error).toHaveBeenCalledTimes(1);
expect(log.error).toHaveBeenCalledTimes(0);
});
test('should skip (hide) the extension if the configure() function returns undefined', async () => {
link2.configure = jest.fn().mockImplementation(() => undefined);
const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
const { extensions } = await getLastEmittedValue(
getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 })
);
expect(extensions).toHaveLength(0);
expect(log.warning).toHaveBeenCalledTimes(0); // As this is intentional, no warning should be logged
@@ -363,7 +397,9 @@ describe('getPluginExtensions()', () => {
const context = {};
const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
const { extensions } = await getLastEmittedValue(
getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 })
);
const [extension] = extensions;
assertPluginExtensionLink(extension);
@@ -387,7 +423,9 @@ describe('getPluginExtensions()', () => {
link2.onClick = jest.fn().mockRejectedValue(new Error('testing'));
const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
const { extensions } = await getLastEmittedValue(
getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 })
);
const [extension] = extensions;
assertPluginExtensionLink(extension);
@@ -406,7 +444,9 @@ describe('getPluginExtensions()', () => {
});
const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 });
const { extensions } = await getLastEmittedValue(
getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 })
);
const [extension] = extensions;
assertPluginExtensionLink(extension);
@@ -427,7 +467,9 @@ describe('getPluginExtensions()', () => {
link2.onClick = jest.fn();
const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]);
const { extensions } = getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 });
const { extensions } = await getLastEmittedValue(
getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 })
);
const [extension] = extensions;
assertPluginExtensionLink(extension);
@@ -475,7 +517,9 @@ describe('getPluginExtensions()', () => {
addedComponentConfigs: [],
},
]);
const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint1 });
const { extensions } = await getLastEmittedValue(
getPluginExtensions({ ...registries, extensionPointId: extensionPoint1 })
);
const [extension] = extensions;
assertPluginExtensionLink(extension);
@@ -499,10 +543,12 @@ describe('getPluginExtensions()', () => {
addedComponentConfigs: [component1],
},
]);
const { extensions } = getPluginExtensions({
...registries,
extensionPointId: Array.isArray(component1.targets) ? component1.targets[0] : component1.targets,
});
const { extensions } = await getLastEmittedValue(
getPluginExtensions({
...registries,
extensionPointId: Array.isArray(component1.targets) ? component1.targets[0] : component1.targets,
})
);
expect(extensions).toHaveLength(1);
expect(extensions[0]).toEqual(
@@ -532,11 +578,13 @@ describe('getPluginExtensions()', () => {
],
},
]);
const { extensions } = getPluginExtensions({
...registries,
limitPerPlugin: 1,
extensionPointId: Array.isArray(component1.targets) ? component1.targets[0] : component1.targets,
});
const { extensions } = await getLastEmittedValue(
getPluginExtensions({
...registries,
limitPerPlugin: 1,
extensionPointId: Array.isArray(component1.targets) ? component1.targets[0] : component1.targets,
})
);
expect(extensions).toHaveLength(1);
expect(extensions[0]).toEqual(
@@ -585,10 +633,10 @@ describe('getObservablePluginExtensions()', () => {
});
it('should emit the initial state when no changes are made to the registries', async () => {
const observable = getObservablePluginExtensions({ extensionPointId }).pipe(first());
const observable = getObservablePluginExtensions({ extensionPointId }).pipe(takeUntil(timer(10)));
await expect(observable).toEmitValuesWith((received) => {
const { extensions } = received[0];
const { extensions } = received[received.length - 1];
expect(extensions).toHaveLength(2);
expect(extensions[0].pluginId).toBe(pluginId);
expect(extensions[1].pluginId).toBe(pluginId);
@@ -596,7 +644,7 @@ describe('getObservablePluginExtensions()', () => {
});
it('should emit the new state when the registries change', async () => {
const observable = getObservablePluginExtensions({ extensionPointId }).pipe(take(2));
const observable = getObservablePluginExtensions({ extensionPointId }).pipe(takeUntil(timer(10)));
setTimeout(() => {
pluginExtensionRegistries.addedLinksRegistry.register({
@@ -614,12 +662,12 @@ describe('getObservablePluginExtensions()', () => {
}, 0);
await expect(observable).toEmitValuesWith((received) => {
const { extensions } = received[0];
const { extensions } = received[1];
expect(extensions).toHaveLength(2);
expect(extensions[0].pluginId).toBe(pluginId);
expect(extensions[1].pluginId).toBe(pluginId);
const { extensions: extensions2 } = received[1];
const { extensions: extensions2 } = received[received.length - 1];
expect(extensions2).toHaveLength(3);
expect(extensions2[0].pluginId).toBe(pluginId);
expect(extensions2[1].pluginId).toBe(pluginId);
@@ -676,7 +724,7 @@ describe('getObservablePluginLinks()', () => {
it('should be possible to get the last value from the observable', async () => {
const observable = getObservablePluginLinks({ extensionPointId });
const links = await firstValueFrom(observable);
const links = await getLastEmittedValue(observable);
expect(links).toHaveLength(1);
expect(links[0].pluginId).toBe(pluginId);
@@ -699,7 +747,7 @@ describe('getObservablePluginLinks()', () => {
});
const observable = getObservablePluginLinks({ extensionPointId });
const links = await firstValueFrom(observable);
const links = await getLastEmittedValue(observable);
expect(links).toHaveLength(2);
expect(links[0].pluginId).toBe(pluginId);
@@ -708,14 +756,220 @@ describe('getObservablePluginLinks()', () => {
expect(links[1].type).toBe(PluginExtensionTypes.link);
});
it('should receive an empty array if there are no links', async () => {
it('should emit static links immediately and then emit again as async configure() functions resolve', async () => {
pluginExtensionRegistries.addedLinksRegistry = new AddedLinksRegistry();
pluginExtensionRegistries.addedComponentsRegistry = new AddedComponentsRegistry();
const observable = getObservablePluginLinks({ extensionPointId }).pipe(first());
const links = await firstValueFrom(observable);
const staticLinkPromise = new Promise<{ title: string }>((resolve) =>
setTimeout(() => resolve({ title: 'Updated Async' }), 50)
);
expect(links).toHaveLength(0);
pluginExtensionRegistries.addedLinksRegistry.register({
pluginId,
configs: [
{
title: 'Static Link',
description: 'Static Description',
path: `/a/${pluginId}/static`,
targets: extensionPointId,
// No configure function - should appear immediately
},
{
title: 'Async Link',
description: 'Async Description',
path: `/a/${pluginId}/async`,
targets: extensionPointId,
configure: jest.fn().mockReturnValue(staticLinkPromise),
},
],
});
const observable = getObservablePluginLinks({ extensionPointId });
const emittedValues: PluginExtensionLink[][] = [];
const subscription = observable.subscribe((links) => {
emittedValues.push([...links]);
});
// Wait for initial emission (should include static link)
await new Promise((resolve) => setTimeout(resolve, 10));
expect(emittedValues.length).toBeGreaterThanOrEqual(1);
const firstEmission = emittedValues[0];
expect(firstEmission.length).toBeGreaterThanOrEqual(1);
const staticLink = firstEmission.find((link) => link.title === 'Static Link');
expect(staticLink).toBeDefined();
// Wait for async configure to resolve
await new Promise((resolve) => setTimeout(resolve, 100));
// Should have emitted again with the async link
expect(emittedValues.length).toBeGreaterThanOrEqual(2);
const lastEmission = emittedValues[emittedValues.length - 1];
expect(lastEmission.length).toBe(2);
const asyncLink = lastEmission.find((link) => link.title === 'Updated Async');
expect(asyncLink).toBeDefined();
subscription.unsubscribe();
});
it('should emit the links with a sync configure() function first', async () => {
pluginExtensionRegistries.addedLinksRegistry = new AddedLinksRegistry();
pluginExtensionRegistries.addedComponentsRegistry = new AddedComponentsRegistry();
pluginExtensionRegistries.addedLinksRegistry.register({
pluginId,
configs: [
{
title: 'Sync Link',
description: 'Sync Description',
path: `/a/${pluginId}/sync`,
targets: extensionPointId,
configure: jest.fn().mockReturnValue({ title: 'Sync Link (updated)' }),
},
{
title: 'Async Link',
description: 'Async Description',
path: `/a/${pluginId}/async`,
targets: extensionPointId,
configure: jest.fn().mockReturnValue(Promise.resolve({ title: 'Async Link (updated)' })),
},
],
});
await expect(getObservablePluginLinks({ extensionPointId }).pipe(take(2))).toEmitValuesWith((received) => {
expect(received.length).toBe(2);
const links1 = received[0];
expect(links1).toHaveLength(1);
expect(links1[0].title).toBe('Sync Link (updated)');
const links2 = received[1];
expect(links2).toHaveLength(2);
expect(links2[0].title).toBe('Sync Link (updated)');
expect(links2[1].title).toBe('Async Link (updated)');
});
});
it('should emit incrementally as multiple async configure() functions resolve at different times', async () => {
pluginExtensionRegistries.addedLinksRegistry = new AddedLinksRegistry();
pluginExtensionRegistries.addedComponentsRegistry = new AddedComponentsRegistry();
const fastConfigure = new Promise<{ title: string }>((resolve) =>
setTimeout(() => resolve({ title: 'Fast Link (updated)' }), 20)
);
const slowConfigure = new Promise<{ title: string }>((resolve) =>
setTimeout(() => resolve({ title: 'Slow Link (updated)' }), 80)
);
pluginExtensionRegistries.addedLinksRegistry.register({
pluginId,
configs: [
{
title: 'Static Link', // no dynamic behaviour = no configure() function
description: 'Static Description',
path: `/a/${pluginId}/static`,
targets: extensionPointId,
},
{
title: 'Sync Link',
description: 'Sync Description',
path: `/a/${pluginId}/sync`,
targets: extensionPointId,
configure: jest.fn().mockReturnValue({ title: 'Sync Link (updated)' }),
},
{
title: 'Fast Link',
description: 'Fast Description',
path: `/a/${pluginId}/fast`,
targets: extensionPointId,
configure: jest.fn().mockReturnValue(fastConfigure),
},
{
title: 'Slow Link',
description: 'Slow Description',
path: `/a/${pluginId}/slow`,
targets: extensionPointId,
configure: jest.fn().mockReturnValue(slowConfigure),
},
],
});
await expect(getObservablePluginLinks({ extensionPointId }).pipe(take(4))).toEmitValuesWith((received) => {
expect(received.length).toBe(4);
const links0 = received[0];
expect(links0).toHaveLength(1);
expect(links0[0].title).toBe('Static Link');
const links1 = received[1];
expect(links1).toHaveLength(2);
expect(links1[0].title).toBe('Static Link');
expect(links1[1].title).toBe('Sync Link (updated)');
const links2 = received[2];
expect(links2).toHaveLength(3);
expect(links2[0].title).toBe('Static Link');
expect(links2[1].title).toBe('Sync Link (updated)');
expect(links2[2].title).toBe('Fast Link (updated)');
const links3 = received[3];
expect(links3).toHaveLength(4);
expect(links3[0].title).toBe('Static Link');
expect(links3[1].title).toBe('Sync Link (updated)');
expect(links3[2].title).toBe('Fast Link (updated)');
expect(links3[3].title).toBe('Slow Link (updated)');
});
});
it('should handle buggy async configure() functions that never resolve without blocking', async () => {
pluginExtensionRegistries.addedLinksRegistry = new AddedLinksRegistry();
pluginExtensionRegistries.addedComponentsRegistry = new AddedComponentsRegistry();
const buggyPromise = new Promise(() => {
// Never resolves - simulating a buggy implementation
});
pluginExtensionRegistries.addedLinksRegistry.register({
pluginId,
configs: [
{
title: 'Static Link',
description: 'Static Description',
path: `/a/${pluginId}/static`,
targets: extensionPointId,
},
{
title: 'Buggy Async Link',
description: 'Buggy Description',
path: `/a/${pluginId}/buggy`,
targets: extensionPointId,
configure: jest.fn().mockReturnValue(buggyPromise),
},
],
});
const observable = getObservablePluginLinks({ extensionPointId });
const emittedValues: PluginExtensionLink[][] = [];
const subscription = observable.subscribe((links) => {
emittedValues.push([...links]);
});
// Wait a bit
await new Promise((resolve) => setTimeout(resolve, 50));
// Should have emitted initial value with static link
expect(emittedValues.length).toBeGreaterThanOrEqual(1);
const firstEmission = emittedValues[0];
expect(firstEmission.length).toBe(1);
expect(firstEmission[0].title).toBe('Static Link');
// Buggy link should never appear
const hasBuggyLink = emittedValues.some((emission) => emission.some((link) => link.title === 'Buggy Async Link'));
expect(hasBuggyLink).toBe(false);
subscription.unsubscribe();
});
});
@@ -767,7 +1021,7 @@ describe('getObservablePluginComponents()', () => {
it('should be possible to get the last value from the observable', async () => {
const observable = getObservablePluginComponents({ extensionPointId });
const components = await firstValueFrom(observable);
const components = await getLastEmittedValue(observable);
expect(components).toHaveLength(1);
expect(components[0].pluginId).toBe(pluginId);
@@ -791,7 +1045,7 @@ describe('getObservablePluginComponents()', () => {
});
const observable = getObservablePluginComponents({ extensionPointId });
const components = await firstValueFrom(observable);
const components = await getLastEmittedValue(observable);
expect(components).toHaveLength(2);
expect(components[0].pluginId).toBe(pluginId);
@@ -799,14 +1053,4 @@ describe('getObservablePluginComponents()', () => {
expect(components[1].pluginId).toBe(pluginId);
expect(components[1].type).toBe(PluginExtensionTypes.component);
});
it('should receive an empty array if there are no components', async () => {
pluginExtensionRegistries.addedLinksRegistry = new AddedLinksRegistry();
pluginExtensionRegistries.addedComponentsRegistry = new AddedComponentsRegistry();
const observable = getObservablePluginComponents({ extensionPointId }).pipe(first());
const components = await firstValueFrom(observable);
expect(components).toHaveLength(0);
});
});

View File

@@ -1,5 +1,18 @@
import { isString } from 'lodash';
import { combineLatest, map, Observable } from 'rxjs';
import {
combineLatest,
lastValueFrom,
from,
map,
merge,
mergeMap,
Observable,
of,
scan,
startWith,
identity,
filter,
} from 'rxjs';
import {
PluginExtensionTypes,
@@ -9,13 +22,12 @@ import {
} 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 { ExtensionsLog, log } from './logs/log';
import { AddedComponentRegistryItem, AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { pluginExtensionRegistries } from './registry/setup';
import type { PluginExtensionRegistries } from './registry/types';
import { GetExtensions, GetExtensionsOptions, GetPluginExtensions } from './types';
import { GetExtensionsOptions, GetPluginExtensions } from './types';
import {
getReadOnlyProxy,
generateExtensionId,
@@ -36,159 +48,258 @@ import {
export const getObservablePluginExtensions = (
options: Omit<GetExtensionsOptions, 'addedComponentsRegistry' | 'addedLinksRegistry'>
): Observable<ReturnType<GetExtensions>> => {
const { extensionPointId } = options;
): Observable<{ extensions: PluginExtension[] }> => {
const { addedComponentsRegistry, addedLinksRegistry } = pluginExtensionRegistries;
return combineLatest([
addedComponentsRegistry.asObservableSlice((state) => state[extensionPointId]),
addedLinksRegistry.asObservableSlice((state) => state[extensionPointId]),
]).pipe(
map(([components, links]) =>
getPluginExtensions({
...options,
addedComponentsRegistry: {
[extensionPointId]: components,
},
addedLinksRegistry: {
[extensionPointId]: links,
},
})
)
);
return getPluginExtensions({
...options,
addedComponentsRegistry,
addedLinksRegistry,
});
};
export const getObservablePluginLinks: GetObservablePluginLinks = (options) => {
return getObservablePluginExtensions(options).pipe(
map((value) => value.extensions.filter((extension) => extension.type === PluginExtensionTypes.link))
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))
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>>>;
return async (options) => {
const observable = getPluginExtensions({
...options,
addedComponentsRegistry: registries.addedComponentsRegistry,
addedLinksRegistry: registries.addedLinksRegistry,
});
// Create registry subscriptions to keep an copy of the registry state for use in the non-async
// plugin extensions getter.
registries.addedComponentsRegistry.asObservable().subscribe((componentsRegistry) => {
addedComponentsRegistry = componentsRegistry;
});
registries.addedLinksRegistry.asObservable().subscribe((linksRegistry) => {
addedLinksRegistry = linksRegistry;
});
return (options) => getPluginExtensions({ ...options, addedComponentsRegistry, addedLinksRegistry });
// Convert Observable to Promise by taking the last emitted value
// This will wait for all configure() functions to resolve and return the final state
return lastValueFrom(observable, { defaultValue: { extensions: [] } });
};
}
// Returns with a list of plugin extensions for the given extension point
export const getPluginExtensions: GetExtensions = ({
function getAddedComponentLog(registryItem: AddedComponentRegistryItem) {
return log.child({
title: registryItem.title,
description: registryItem.description ?? '',
pluginId: registryItem.pluginId,
});
}
function createPluginExtensionComponent({
extensionPointId,
registryItem,
log,
}: {
extensionPointId: string;
registryItem: AddedComponentRegistryItem;
log?: ExtensionsLog;
}): PluginExtensionComponent {
return {
id: generateExtensionId(registryItem.pluginId, extensionPointId, registryItem.title),
type: PluginExtensionTypes.component,
pluginId: registryItem.pluginId,
title: registryItem.title,
description: registryItem.description ?? '',
component: wrapWithPluginContext({
pluginId: registryItem.pluginId,
extensionTitle: registryItem.title,
Component: registryItem.component,
log: log ?? getAddedComponentLog(registryItem),
}),
};
}
// Returns an observable that emits plugin extensions for the given extension point
// Emits incrementally as configure() functions resolve for link extensions
export function getPluginExtensions({
context,
extensionPointId,
limitPerPlugin,
addedLinksRegistry,
addedComponentsRegistry,
}) => {
}: {
context?: object | Record<string | symbol, unknown>;
extensionPointId: string;
limitPerPlugin?: number;
addedLinksRegistry: AddedLinksRegistry;
addedComponentsRegistry: AddedComponentsRegistry;
}): Observable<{ extensions: PluginExtension[] }> {
const frozenContext = context ? getReadOnlyProxy(context) : {};
// We don't return the extensions separated by type, because in that case it would be much harder to define a sort-order for them.
const extensions: PluginExtension[] = [];
const extensionsByPlugin: Record<string, number> = {};
for (const addedLink of addedLinksRegistry?.[extensionPointId] ?? []) {
try {
const { pluginId } = addedLink;
// Only limit if the `limitPerPlugin` is set
if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) {
continue;
return combineLatest([
addedComponentsRegistry.asObservableSlice((state) => state[extensionPointId]),
addedLinksRegistry.asObservableSlice((state) => state[extensionPointId]),
]).pipe(
mergeMap(([addedComponents, addedLinks]) => {
const staticLinkExtensionsByPlugin: Record<string, number> = {};
const componentExtensionsByPlugin: Record<string, number> = {};
// ADDED COMPONENTS ---------------------------------------------------
// Process components immediately (they don't have async configure)
const componentExtensions: PluginExtensionComponent[] = [];
for (const registryItem of addedComponents ?? []) {
// Only limit if the `limitPerPlugin` is set
if (limitPerPlugin && componentExtensionsByPlugin[registryItem.pluginId] >= limitPerPlugin) {
continue;
}
if (componentExtensionsByPlugin[registryItem.pluginId] === undefined) {
componentExtensionsByPlugin[registryItem.pluginId] = 0;
}
componentExtensions.push(
createPluginExtensionComponent({
registryItem,
extensionPointId,
})
);
componentExtensionsByPlugin[registryItem.pluginId] += 1;
}
if (extensionsByPlugin[pluginId] === undefined) {
extensionsByPlugin[pluginId] = 0;
}
// LINKS -------------------------------------------------------------
const links = addedLinks ?? [];
const linksWithConfigure = links.filter((addedLink) => addedLink.configure);
const linksWithoutConfigure = links.filter((addedLink) => !addedLink.configure);
const linkLog = log.child({
pluginId,
extensionPointId,
path: addedLink.path ?? '',
title: addedLink.title,
description: addedLink.description ?? '',
onClick: typeof addedLink.onClick,
});
// Run the configure() function with the current context, and apply the ovverides
const overrides = getLinkExtensionOverrides(pluginId, addedLink, linkLog, frozenContext);
// Process static links (without configure function) immediately
const staticLinkExtensions: PluginExtensionLink[] = [];
for (const addedLink of linksWithoutConfigure) {
const { pluginId } = addedLink;
// configure() returned an `undefined` -> hide the extension
if (addedLink.configure && overrides === undefined) {
continue;
}
// Only limit if the `limitPerPlugin` is set
if (limitPerPlugin && staticLinkExtensionsByPlugin[pluginId] >= limitPerPlugin) {
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, linkLog, frozenContext),
if (staticLinkExtensionsByPlugin[pluginId] === undefined) {
staticLinkExtensionsByPlugin[pluginId] = 0;
}
// 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;
} catch (error) {
if (error instanceof Error) {
log.error(error.message, {
stack: error.stack ?? '',
message: error.message,
const linkLog = log.child({
pluginId,
extensionPointId,
path: addedLink.path ?? '',
title: addedLink.title,
description: addedLink.description ?? '',
onClick: typeof addedLink.onClick,
});
const extension: PluginExtensionLink = {
id: generateExtensionId(pluginId, extensionPointId, addedLink.title),
type: PluginExtensionTypes.link,
pluginId: pluginId,
onClick: getLinkExtensionOnClick(pluginId, extensionPointId, addedLink, linkLog, frozenContext),
icon: addedLink.icon,
title: addedLink.title,
description: addedLink.description || '',
path: isString(addedLink.path)
? getLinkExtensionPathWithTracking(pluginId, addedLink.path, extensionPointId)
: undefined,
category: addedLink.category,
};
staticLinkExtensions.push(extension);
staticLinkExtensionsByPlugin[pluginId] += 1;
}
}
}
const addedComponents = addedComponentsRegistry?.[extensionPointId] ?? [];
for (const addedComponent of addedComponents) {
// Only limit if the `limitPerPlugin` is set
if (limitPerPlugin && extensionsByPlugin[addedComponent.pluginId] >= limitPerPlugin) {
continue;
}
// No links with configure, return components + static links immediately
if (linksWithConfigure.length === 0) {
return of({ extensions: [...componentExtensions, ...staticLinkExtensions] });
}
if (extensionsByPlugin[addedComponent.pluginId] === undefined) {
extensionsByPlugin[addedComponent.pluginId] = 0;
}
// Process links incrementally - emit as each configure() resolves
const linkObservables = linksWithConfigure.map((addedLink) => {
const { pluginId } = addedLink;
const linkId = generateExtensionId(pluginId, extensionPointId, addedLink.title);
const linkLog = log.child({
pluginId,
extensionPointId,
path: addedLink.path ?? '',
title: addedLink.title,
description: addedLink.description ?? '',
onClick: typeof addedLink.onClick,
});
const componentLog = log.child({
title: addedComponent.title,
description: addedComponent.description ?? '',
pluginId: addedComponent.pluginId,
});
return from(getLinkExtensionOverrides(addedLink, linkLog, frozenContext)).pipe(
map((overrides): PluginExtensionLink | null => {
// configure() returned an `undefined` -> hide the extension
if (overrides === undefined) {
return null;
}
const extension: PluginExtensionComponent = {
id: generateExtensionId(addedComponent.pluginId, extensionPointId, addedComponent.title),
type: PluginExtensionTypes.component,
pluginId: addedComponent.pluginId,
title: addedComponent.title,
description: addedComponent.description ?? '',
component: wrapWithPluginContext({
pluginId: addedComponent.pluginId,
extensionTitle: addedComponent.title,
Component: addedComponent.component,
log: componentLog,
}),
};
const path = overrides?.path || addedLink.path;
const extension: PluginExtensionLink = {
id: linkId,
type: PluginExtensionTypes.link,
pluginId: pluginId,
onClick: getLinkExtensionOnClick(pluginId, extensionPointId, addedLink, linkLog, frozenContext),
extensions.push(extension);
extensionsByPlugin[addedComponent.pluginId] += 1;
}
// 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,
};
return { extensions };
};
return extension;
})
);
});
// Merge all link observables and accumulate results as they resolve
// We use startWith to emit immediately with static links + components,
// then emit again as each configure() function resolves
return merge(...linkObservables).pipe(
scan((acc: Set<PluginExtensionLink>, result: PluginExtensionLink | null) => {
if (!result) {
return acc;
}
return new Set([...acc.values(), result]);
}, new Set()),
map((linkResults) => {
// Build extensions array: components + static links + resolved links (excluding null/hidden ones)
const linkExtensions: PluginExtensionLink[] = [];
const linkExtensionsByPlugin: Record<string, number> = {};
for (const result of [...staticLinkExtensions, ...linkResults.values()]) {
if (result) {
const { pluginId } = result;
// Only limit if the `limitPerPlugin` is set
if (limitPerPlugin && linkExtensionsByPlugin[pluginId] >= limitPerPlugin) {
continue;
}
if (linkExtensionsByPlugin[pluginId] === undefined) {
linkExtensionsByPlugin[pluginId] = 0;
}
linkExtensions.push(result);
linkExtensionsByPlugin[pluginId] += 1;
}
}
// Combine components and links
return {
extensions: [...componentExtensions, ...linkExtensions],
};
}),
[...componentExtensions, ...staticLinkExtensions].length > 0
? startWith({ extensions: [...componentExtensions, ...staticLinkExtensions] })
: identity
);
})
);
}

View File

@@ -12,11 +12,13 @@ export type GetExtensionsOptions = {
addedLinksRegistry: RegistryType<AddedLinkRegistryItem[]> | undefined;
};
export type GetExtensions = (options: GetExtensionsOptions) => { extensions: PluginExtension[] };
export type GetExtensions = (options: GetExtensionsOptions) => Promise<{ extensions: PluginExtension[] }>;
export type GetExtensionsSync = (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[] };
}) => Promise<{ extensions: T[] }>;

View File

@@ -11,7 +11,7 @@ import { UsePluginComponentsOptions, UsePluginComponentsResult } from '@grafana/
import { AddedComponentRegistryItem } from './registry/AddedComponentsRegistry';
import { useAddedComponentsRegistrySlice } from './registry/useRegistrySlice';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { generateExtensionId, getExtensionPointPluginDependencies } from './utils';
import { generateExtensionId, getExtensionPointPluginDependencies, useExtensionPointLog } from './utils';
import { validateExtensionPoint } from './validateExtensionPoint';
// Returns an array of component extensions for the given extension point
@@ -21,10 +21,16 @@ export function usePluginComponents<Props extends object = {}>({
}: UsePluginComponentsOptions): UsePluginComponentsResult<Props> {
const registryItems = useAddedComponentsRegistrySlice<Props>(extensionPointId);
const pluginContext = usePluginContext();
const extensionPointLog = useExtensionPointLog(extensionPointId);
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
return useMemo(() => {
const { result } = validateExtensionPoint({ extensionPointId, pluginContext, isLoadingAppPlugins });
const { result } = validateExtensionPoint({
extensionPointId,
pluginContext,
isLoadingAppPlugins,
extensionPointLog,
});
if (result) {
return {
@@ -58,7 +64,7 @@ export function usePluginComponents<Props extends object = {}>({
isLoading: false,
components,
};
}, [extensionPointId, limitPerPlugin, pluginContext, registryItems, isLoadingAppPlugins]);
}, [extensionPointId, limitPerPlugin, pluginContext, registryItems, extensionPointLog, isLoadingAppPlugins]);
}
export function createComponentWithMeta<Props extends JSX.IntrinsicAttributes>(

View File

@@ -5,7 +5,7 @@ import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from '@grafana/ru
import { useAddedFunctionsRegistrySlice } from './registry/useRegistrySlice';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { generateExtensionId, getExtensionPointPluginDependencies } from './utils';
import { generateExtensionId, getExtensionPointPluginDependencies, useExtensionPointLog } from './utils';
import { validateExtensionPoint } from './validateExtensionPoint';
// Returns an array of component extensions for the given extension point
@@ -15,11 +15,17 @@ export function usePluginFunctions<Signature>({
}: UsePluginFunctionsOptions): UsePluginFunctionsResult<Signature> {
const registryItems = useAddedFunctionsRegistrySlice<Signature>(extensionPointId);
const pluginContext = usePluginContext();
const extensionPointLog = useExtensionPointLog(extensionPointId);
const deps = getExtensionPointPluginDependencies(extensionPointId);
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(deps);
return useMemo(() => {
const { result } = validateExtensionPoint({ extensionPointId, pluginContext, isLoadingAppPlugins });
const { result } = validateExtensionPoint({
extensionPointId,
pluginContext,
isLoadingAppPlugins,
extensionPointLog,
});
if (result) {
return {
@@ -58,5 +64,5 @@ export function usePluginFunctions<Signature>({
isLoading: false,
functions: results,
};
}, [extensionPointId, limitPerPlugin, pluginContext, registryItems, isLoadingAppPlugins]);
}, [extensionPointId, limitPerPlugin, pluginContext, registryItems, isLoadingAppPlugins, extensionPointLog]);
}

View File

@@ -1,4 +1,4 @@
import { act, renderHook } from '@testing-library/react';
import { act, renderHook, waitFor } from '@testing-library/react';
import type { JSX } from 'react';
import {
@@ -222,6 +222,215 @@ describe('usePluginLinks()', () => {
expect(result.current.links[1].title).toBe('2');
});
it('should update link properties using the provided SYNC configure() function', async () => {
const context = { foo: 'bar' };
let { result, rerender } = renderHook(() => usePluginLinks({ extensionPointId, context }), { wrapper });
// Add extensions to the registry
act(() => {
registries.addedLinksRegistry.register({
pluginId,
configs: [
{
targets: extensionPointId,
title: 'Original Title',
description: 'Original Description',
path: `/a/${pluginId}/original`,
icon: 'heart',
category: 'original-category',
// @ts-ignore
configure: (context: { foo: 'bar' }) => {
return {
title: `Title: ${context.foo}`,
description: 'Updated Description',
path: 'updated/path',
icon: 'star',
category: 'updated-category',
};
},
},
],
});
});
// Check if the hook returns the new extensions
rerender();
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
expect(result.current.links.length).toBe(1);
});
rerender();
expect(result.current.links.length).toBe(1);
expect(result.current.links[0].title).toBe('Title: bar');
expect(result.current.links[0].description).toBe('Updated Description');
// We are adding link tracking parameters to the link when updating via the `configure()` function
expect(result.current.links[0].path).toBe(
`updated/path?uel_pid=${pluginId}&uel_epid=${encodeURIComponent(extensionPointId)}`
);
expect(result.current.links[0].icon).toBe('star');
expect(result.current.links[0].category).toBe('updated-category');
});
it('should update link properties using the provided ASYNC configure() function', async () => {
const context = { foo: 'bar' };
let { result, rerender } = renderHook(() => usePluginLinks({ extensionPointId, context }), { wrapper });
// Add extensions to the registry with async configure function
act(() => {
registries.addedLinksRegistry.register({
pluginId,
configs: [
{
targets: extensionPointId,
title: 'Original Title',
description: 'Original Description',
path: `/a/${pluginId}/original`,
icon: 'heart',
category: 'original-category',
// @ts-ignore
configure: async (context: { foo: 'bar' }) => {
// Simulate async operation
await new Promise((resolve) => setTimeout(resolve, 10));
return {
title: `Async Title: ${context.foo}`,
description: 'Async Updated Description',
path: 'async/updated/path',
icon: 'cloud',
category: 'async-updated-category',
};
},
},
],
});
});
// With the new implementation, links should appear incrementally as configure() resolves
// Initially, the link may not be visible yet (depending on timing)
rerender();
// Wait for async configure to complete - link should appear
await waitFor(() => {
expect(result.current.links.length).toBe(1);
});
rerender();
expect(result.current.links.length).toBe(1);
expect(result.current.links[0].title).toBe('Async Title: bar');
expect(result.current.links[0].description).toBe('Async Updated Description');
// We are adding link tracking parameters to the link when updating via the async `configure()` function
expect(result.current.links[0].path).toBe(
`async/updated/path?uel_pid=${pluginId}&uel_epid=${encodeURIComponent(extensionPointId)}`
);
expect(result.current.links[0].icon).toBe('cloud');
expect(result.current.links[0].category).toBe('async-updated-category');
});
it('should return static links immediately and update incrementally as async configure() functions resolve', async () => {
const context = { foo: 'bar' };
let { result, rerender } = renderHook(() => usePluginLinks({ extensionPointId, context }), { wrapper });
// Add a mix of static link and async configure link
act(() => {
registries.addedLinksRegistry.register({
pluginId,
configs: [
{
targets: extensionPointId,
title: 'Static Link',
description: 'Static Description',
path: `/a/${pluginId}/static`,
// No configure function - should appear immediately
},
{
targets: extensionPointId,
title: 'Async Link',
description: 'Async Description',
path: `/a/${pluginId}/async`,
// @ts-ignore
configure: async (context: { foo: 'bar' }) => {
await new Promise((resolve) => setTimeout(resolve, 50));
return {
title: 'Updated Async Title',
};
},
},
],
});
});
rerender();
// Static link should appear immediately
await waitFor(() => {
expect(result.current.links.length).toBeGreaterThanOrEqual(1);
const staticLink = result.current.links.find((link) => link.title === 'Static Link');
expect(staticLink).toBeDefined();
});
// Wait for async configure to complete
await waitFor(
() => {
expect(result.current.links.length).toBe(2);
const asyncLink = result.current.links.find((link) => link.title === 'Updated Async Title');
expect(asyncLink).toBeDefined();
},
{ timeout: 2000 }
);
});
it('should handle buggy async configure() functions that never resolve without blocking', async () => {
const context = { foo: 'bar' };
let { result, rerender } = renderHook(() => usePluginLinks({ extensionPointId, context }), { wrapper });
// Add a static link and a buggy async configure link that never resolves
act(() => {
registries.addedLinksRegistry.register({
pluginId,
configs: [
{
targets: extensionPointId,
title: 'Static Link',
description: 'Static Description',
path: `/a/${pluginId}/static`,
},
{
targets: extensionPointId,
title: 'Buggy Async Link',
description: 'Buggy Description',
path: `/a/${pluginId}/buggy`,
// @ts-ignore
configure: async () => {
// This promise never resolves - simulating a buggy implementation
return new Promise(() => {
// Never resolves
});
},
},
],
});
});
rerender();
// Static link should appear immediately, even though buggy async configure never resolves
await waitFor(() => {
expect(result.current.links.length).toBe(1);
expect(result.current.links[0].title).toBe('Static Link');
});
// Wait a bit to ensure buggy async configure doesn't cause issues
await new Promise((resolve) => setTimeout(resolve, 100));
// Static link should still be there, buggy link should not appear
rerender();
expect(result.current.links.length).toBe(1);
expect(result.current.links[0].title).toBe('Static Link');
});
it('should not validate the extension point meta-info in production mode', () => {
// Empty list of extension points in the plugin meta (from plugin.json)
wrapper = ({ children }: { children: React.ReactNode }) => (

View File

@@ -1,100 +1,79 @@
import { isString } from 'lodash';
import { useMemo } from 'react';
import { isEqual } from 'lodash';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useObservable } from 'react-use';
import { of } from 'rxjs';
import { PluginExtensionLink, PluginExtensionTypes, usePluginContext } from '@grafana/data';
import { usePluginContext } from '@grafana/data';
import { UsePluginLinksOptions, UsePluginLinksResult } from '@grafana/runtime';
import { useAddedLinksRegistrySlice } from './registry/useRegistrySlice';
import { getObservablePluginLinks } from './getPluginExtensions';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import {
generateExtensionId,
getExtensionPointPluginDependencies,
getLinkExtensionOnClick,
getLinkExtensionOverrides,
getLinkExtensionPathWithTracking,
getReadOnlyProxy,
} from './utils';
import { getExtensionPointPluginDependencies, getReadOnlyProxy, useExtensionPointLog } from './utils';
import { validateExtensionPoint } from './validateExtensionPoint';
// Returns an array of component extensions for the given extension point
// Returns an array of link extensions for the given extension point
export function usePluginLinks({
limitPerPlugin,
extensionPointId,
context,
context: contextProp,
}: UsePluginLinksOptions): UsePluginLinksResult {
const registryItems = useAddedLinksRegistrySlice(extensionPointId);
// Context:
// - protecting against inline object definitions at the call-site
// - protecting against mutating the common context object by freezing it
const pluginContext = usePluginContext();
const prevContext = useRef<typeof contextProp>();
const [context, setContext] = useState<typeof contextProp>();
// Preloading app plugins that register links to this extension point
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
const extensionPointLog = useExtensionPointLog(extensionPointId);
return useMemo(() => {
const { result, pointLog } = validateExtensionPoint({
// Context object equality check
// (Ideally the callsite passes in a memoized object, or an object that doesn't change between rerenders.)
useEffect(() => {
if (prevContext.current === undefined || !isEqual(prevContext.current, contextProp)) {
prevContext.current = contextProp;
setContext(getReadOnlyProxy(contextProp ?? {}));
}
}, [contextProp]);
// Extension point validation
const { result: validationResult } = useMemo(
() =>
validateExtensionPoint({
extensionPointId,
pluginContext,
isLoadingAppPlugins,
extensionPointLog,
}),
[extensionPointId, extensionPointLog, pluginContext, isLoadingAppPlugins]
);
// Create observable for plugin links that emits incrementally as configure() functions resolve
const observableLinks = useMemo(() => {
if (validationResult) {
// Return empty observable if validation failed
return of([]);
}
return getObservablePluginLinks({
extensionPointId,
pluginContext,
isLoadingAppPlugins,
context,
limitPerPlugin,
});
}, [extensionPointId, context, limitPerPlugin, validationResult]);
if (result) {
return {
isLoading: result.isLoading,
links: [],
};
}
// Subscribe to the observable - this will rerender as each configure() function resolves
const links = useObservable(observableLinks, []);
const frozenContext = context ? getReadOnlyProxy(context) : {};
const extensions: PluginExtensionLink[] = [];
const extensionsByPlugin: Record<string, number> = {};
// Determine loading state
// We're loading if:
// 1. App plugins are still loading
// 2. Validation is in progress
// Note: Links with async configure() functions will appear incrementally as they resolve,
// so we don't need to track individual loading states - the observable handles this.
const isLoading = (validationResult?.isLoading ?? false) || isLoadingAppPlugins;
for (const addedLink of registryItems ?? []) {
const { pluginId } = addedLink;
const linkLog = pointLog.child({
path: addedLink.path ?? '',
title: addedLink.title,
description: addedLink.description ?? '',
onClick: typeof addedLink.onClick,
openInNewTab: addedLink.openInNewTab ? 'true' : 'false',
});
// Only limit if the `limitPerPlugin` is set
if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) {
linkLog.debug(`Skipping link extension from plugin "${pluginId}". Reason: Limit reached.`);
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, linkLog, 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, linkLog, 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,
openInNewTab: overrides?.openInNewTab ?? addedLink.openInNewTab,
};
extensions.push(extension);
extensionsByPlugin[pluginId] += 1;
}
return {
isLoading: false,
links: extensions,
};
}, [context, extensionPointId, limitPerPlugin, registryItems, pluginContext, isLoadingAppPlugins]);
return {
isLoading,
links: validationResult ? [] : links,
};
}

View File

@@ -6,6 +6,7 @@ import { useAsync } from 'react-use';
import {
type PluginExtensionEventHelpers,
type PluginExtensionOpenModalOptions,
type IconName,
isDateTime,
dateTime,
PluginContextProvider,
@@ -15,6 +16,7 @@ import {
urlUtil,
PluginExtensionPoints,
ExtensionInfo,
usePluginContext,
} from '@grafana/data';
import { reportInteraction, config, AppPluginConfig } from '@grafana/runtime';
import { Modal } from '@grafana/ui';
@@ -32,7 +34,7 @@ import { RestrictedGrafanaApisProvider } from '../components/restrictedGrafanaAp
import { ExtensionErrorBoundary } from './ExtensionErrorBoundary';
import { ExtensionsLog, log as baseLog } from './logs/log';
import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry';
import { assertIsNotPromise, assertStringProps, isPromise } from './validators';
import { assertStringProps, isPromise } from './validators';
export function handleErrorsInFn(fn: Function, errorMessagePrefix = '') {
return (...args: unknown[]) => {
@@ -460,14 +462,24 @@ export function createExtensionSubMenu(extensions: PluginExtensionLink[]): Panel
return subMenu;
}
export function getLinkExtensionOverrides(
pluginId: string,
export async function getLinkExtensionOverrides(
config: AddedLinkRegistryItem,
log: ExtensionsLog,
context?: object
) {
): Promise<
| {
title: string;
description?: string;
path?: string;
icon?: IconName;
category?: string;
openInNewTab?: boolean;
}
| undefined
> {
try {
const overrides = config.configure?.(context);
const configureResult = config.configure?.(context);
const overrides = isPromise(configureResult) ? await configureResult : configureResult;
// Hiding the extension
if (overrides === undefined) {
@@ -485,11 +497,6 @@ export function getLinkExtensionOverrides(
...rest
} = overrides;
assertIsNotPromise(
overrides,
`The configure() function for "${config.title}" returned a promise, skipping updates.`
);
assertStringProps({ title, description }, ['title', 'description']);
if (Object.keys(rest).length > 0) {
@@ -730,3 +737,19 @@ export const getAppPluginsToPreload = () => {
return isNotAwaited(app) && (app.preload || dashboardPanelMenuPluginIds.includes(app.id));
});
};
export const createAddedLinkLog = (addedLink: AddedLinkRegistryItem, parentLog: ExtensionsLog) =>
parentLog.child({
path: addedLink.path ?? '',
title: addedLink.title,
description: addedLink.description ?? '',
onClick: typeof addedLink.onClick,
openInNewTab: addedLink.openInNewTab ? 'true' : 'false',
});
export const useExtensionPointLog = (extensionPointId: string) => {
const pluginContext = usePluginContext();
const pluginId = pluginContext?.meta.id ?? '';
return React.useMemo(() => baseLog.child({ pluginId, extensionPointId }), [pluginId, extensionPointId]);
};

View File

@@ -1,7 +1,7 @@
import { PluginContextType } from '@grafana/data';
import * as errors from './errors';
import { ExtensionsLog } from './logs/log';
import { ExtensionsLog, log } from './logs/log';
import { isGrafanaDevMode } from './utils';
import { validateExtensionPoint } from './validateExtensionPoint';
import * as validators from './validators';
@@ -37,8 +37,11 @@ const setup = ({
};
describe('getExtensionValidationResults', () => {
let extensionPointLog: ExtensionsLog;
beforeEach(() => {
jest.clearAllMocks();
extensionPointLog = log.child({});
});
describe('when calling in production mode', () => {
@@ -53,10 +56,10 @@ describe('getExtensionValidationResults', () => {
extensionPointId,
isLoadingAppPlugins: true,
pluginContext,
extensionPointLog,
});
expect(actual.result).toEqual({ isLoading: true });
expect(actual.pointLog).toBeDefined();
});
it('should return null when all validations pass', () => {
@@ -66,10 +69,10 @@ describe('getExtensionValidationResults', () => {
extensionPointId,
isLoadingAppPlugins: false,
pluginContext,
extensionPointLog,
});
expect(actual.result).toBe(null);
expect(actual.pointLog).toBeDefined();
});
});
@@ -93,10 +96,10 @@ describe('getExtensionValidationResults', () => {
extensionPointId,
isLoadingAppPlugins: true,
pluginContext,
extensionPointLog,
});
expect(actual.result).toEqual({ isLoading: false });
expect(actual.pointLog).toBeDefined();
expect(spyisExtensionPointMetaInfoMissing).not.toHaveBeenCalled();
expect(spyIsExtensionPointIdValid).toHaveBeenCalledTimes(1);
expect(spyIsExtensionPointIdValid).toHaveBeenCalledWith({
@@ -117,10 +120,10 @@ describe('getExtensionValidationResults', () => {
extensionPointId,
isLoadingAppPlugins: true,
pluginContext,
extensionPointLog,
});
expect(actual.result).toEqual({ isLoading: false });
expect(actual.pointLog).toBeDefined();
expect(spyisExtensionPointMetaInfoMissing).toHaveBeenCalled();
expect(spyisExtensionPointMetaInfoMissing).toHaveBeenCalledWith(extensionPointId, pluginContext);
expect(errorSpy).toHaveBeenCalled();
@@ -137,10 +140,10 @@ describe('getExtensionValidationResults', () => {
extensionPointId,
isLoadingAppPlugins: false,
pluginContext,
extensionPointLog,
});
expect(actual.result).toEqual(null);
expect(actual.pointLog).toBeDefined();
expect(spyisExtensionPointMetaInfoMissing).not.toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
});
@@ -152,10 +155,10 @@ describe('getExtensionValidationResults', () => {
extensionPointId,
isLoadingAppPlugins: true,
pluginContext,
extensionPointLog,
});
expect(actual.result).toEqual({ isLoading: true });
expect(actual.pointLog).toBeDefined();
});
it('should return null when all validations pass', () => {
@@ -165,10 +168,10 @@ describe('getExtensionValidationResults', () => {
extensionPointId,
isLoadingAppPlugins: false,
pluginContext,
extensionPointLog,
});
expect(actual.result).toBe(null);
expect(actual.pointLog).toBeDefined();
});
});
});

View File

@@ -1,7 +1,7 @@
import { PluginContextType } from '@grafana/data';
import * as errors from './errors';
import { ExtensionsLog, log } from './logs/log';
import { ExtensionsLog } from './logs/log';
import { isGrafanaDevMode } from './utils';
import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators';
@@ -9,6 +9,7 @@ interface ValidateExtensionPointOptions {
extensionPointId: string;
isLoadingAppPlugins: boolean;
pluginContext: PluginContextType | null;
extensionPointLog: ExtensionsLog;
}
interface ValidateExtensionPoint {
@@ -17,25 +18,30 @@ interface ValidateExtensionPoint {
type ValidateExtensionPointResult = {
result: ValidateExtensionPoint | null;
pointLog: ExtensionsLog;
};
export function validateExtensionPoint({
extensionPointId,
isLoadingAppPlugins,
pluginContext,
extensionPointLog,
}: ValidateExtensionPointOptions): ValidateExtensionPointResult {
const isInsidePlugin = Boolean(pluginContext);
const isCoreGrafanaPlugin = pluginContext?.meta.module.startsWith('core:') ?? false;
const pluginId = pluginContext?.meta.id ?? '';
const pointLog = log.child({ pluginId, extensionPointId });
// Don't show extensions if the extension-point id is invalid in DEV mode
if (
isGrafanaDevMode() &&
!isExtensionPointIdValid({ extensionPointId, pluginId, isInsidePlugin, isCoreGrafanaPlugin, log: pointLog })
!isExtensionPointIdValid({
extensionPointId,
pluginId,
isInsidePlugin,
isCoreGrafanaPlugin,
log: extensionPointLog,
})
) {
return { result: { isLoading: false }, pointLog };
return { result: { isLoading: false } };
}
// Don't show extensions if the extension-point misses meta info (plugin.json) in DEV mode
@@ -45,13 +51,13 @@ export function validateExtensionPoint({
pluginContext &&
isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)
) {
pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING);
return { result: { isLoading: false }, pointLog };
extensionPointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING);
return { result: { isLoading: false } };
}
if (isLoadingAppPlugins) {
return { result: { isLoading: true }, pointLog };
return { result: { isLoading: true } };
}
return { result: null, pointLog };
return { result: null };
}