PluginExtensions: Make the extensions registry reactive (#83085)

* feat: add a reactive extension registry

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>

* feat: add hooks to work with the reactive registry

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>

* feat: start using the reactive registry

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>

* feat: update the "command palette" extension point to use the hook

* feat: update the "alerting" extension point to use the hooks

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>

* feat: update the "explore" extension point to use the hooks

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>

* feat: update the "datasources config" extension point to use the hooks

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>

* feat: update the "panel menu" extension point to use the hooks

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>

* feat: update the "pyroscope datasource" extension point to use the hooks

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>

* feat: update the "user profile page" extension point to use the hooks

* chore: update betterer

* fix: update the hooks to not re-render unnecessarily

* chore: remove the old `createPluginExtensionRegistry` impementation

* chore: add "TODO" for `PanelMenuBehaviour` extension point

* feat: update the return value of the hooks to contain a `{ isLoading }` param

* tests: add more tests for the usePluginExtensions() hook

* fix: exclude the cloud-home-app from being non-awaited

* refactor: use uuidv4() for random ID generation (for the registry object)

* fix: linting issue

* feat: use the hooks for the new alerting extension point

* feat: use `useMemo()` for `AlertInstanceAction` extension point context

---------

Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>
This commit is contained in:
Marcus Andersson 2024-04-24 09:33:16 +02:00 committed by GitHub
parent d48b5ea44d
commit 804c726413
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 1768 additions and 736 deletions

View File

@ -700,6 +700,9 @@ exports[`better eslint`] = {
"packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts:5381": [ "packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [0, 0, 0, "Do not use any type assertions.", "0"]
], ],
"packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-runtime/src/utils/DataSourceWithBackend.ts:5381": [ "packages/grafana-runtime/src/utils/DataSourceWithBackend.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"] [0, 0, 0, "Unexpected any. Specify a different type.", "1"]
@ -4072,6 +4075,9 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "11"], [0, 0, 0, "Unexpected any. Specify a different type.", "11"],
[0, 0, 0, "Do not use any type assertions.", "12"] [0, 0, 0, "Do not use any type assertions.", "12"]
], ],
"public/app/features/plugins/extensions/getPluginExtensions.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/plugins/loader/sharedDependencies.ts:5381": [ "public/app/features/plugins/loader/sharedDependencies.ts:5381": [
[0, 0, 0, "* import is invalid because \'Layout,HorizontalGroup,VerticalGroup\' from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"] [0, 0, 0, "* import is invalid because \'Layout,HorizontalGroup,VerticalGroup\' from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"]
], ],

View File

@ -15,5 +15,15 @@ export {
getPluginLinkExtensions, getPluginLinkExtensions,
getPluginComponentExtensions, getPluginComponentExtensions,
type GetPluginExtensions, type GetPluginExtensions,
type GetPluginExtensionsOptions,
type GetPluginExtensionsResult,
type UsePluginExtensions,
type UsePluginExtensionsResult,
} from './pluginExtensions/getPluginExtensions'; } from './pluginExtensions/getPluginExtensions';
export {
setPluginExtensionsHook,
usePluginExtensions,
usePluginLinkExtensions,
usePluginComponentExtensions,
} from './pluginExtensions/usePluginExtensions';
export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils'; export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils';

View File

@ -2,18 +2,29 @@ import type { PluginExtension, PluginExtensionLink, PluginExtensionComponent } f
import { isPluginExtensionComponent, isPluginExtensionLink } from './utils'; import { isPluginExtensionComponent, isPluginExtensionLink } from './utils';
export type GetPluginExtensions<T = PluginExtension> = ({ export type GetPluginExtensions<T = PluginExtension> = (
extensionPointId, options: GetPluginExtensionsOptions
context, ) => GetPluginExtensionsResult<T>;
limitPerPlugin,
}: { export type UsePluginExtensions<T = PluginExtension> = (
options: GetPluginExtensionsOptions
) => UsePluginExtensionsResult<T>;
export type GetPluginExtensionsOptions = {
extensionPointId: string; extensionPointId: string;
context?: object | Record<string | symbol, unknown>; context?: object | Record<string | symbol, unknown>;
limitPerPlugin?: number; limitPerPlugin?: number;
}) => { };
export type GetPluginExtensionsResult<T = PluginExtension> = {
extensions: T[]; extensions: T[];
}; };
export type UsePluginExtensionsResult<T = PluginExtension> = {
extensions: T[];
isLoading: boolean;
};
let singleton: GetPluginExtensions | undefined; let singleton: GetPluginExtensions | undefined;
export function setPluginExtensionGetter(instance: GetPluginExtensions): void { export function setPluginExtensionGetter(instance: GetPluginExtensions): void {

View File

@ -0,0 +1,262 @@
import { renderHook } from '@testing-library/react-hooks';
import { PluginExtension, PluginExtensionTypes } from '@grafana/data';
import { UsePluginExtensions } from './getPluginExtensions';
import {
setPluginExtensionsHook,
usePluginComponentExtensions,
usePluginExtensions,
usePluginLinkExtensions,
} from './usePluginExtensions';
describe('Plugin Extensions / usePluginExtensions', () => {
afterEach(() => {
process.env.NODE_ENV = 'test';
});
test('should always return the same extension-hook function that was previously set', () => {
const hook: UsePluginExtensions = jest.fn().mockReturnValue({ extensions: [], isLoading: false });
setPluginExtensionsHook(hook);
usePluginExtensions({ extensionPointId: 'panel-menu' });
expect(hook).toHaveBeenCalledTimes(1);
expect(hook).toHaveBeenCalledWith({ extensionPointId: 'panel-menu' });
});
test('should throw an error when trying to redefine the app-wide extension-hook function', () => {
// By default, NODE_ENV is set to 'test' in jest.config.js, which allows to override the registry in tests.
process.env.NODE_ENV = 'production';
const hook: UsePluginExtensions = () => ({ extensions: [], isLoading: false });
expect(() => {
setPluginExtensionsHook(hook);
setPluginExtensionsHook(hook);
}).toThrow();
});
test('should throw an error when trying to access the extension-hook function before it was set', () => {
// "Unsetting" the registry
// @ts-ignore
setPluginExtensionsHook(undefined);
expect(() => {
usePluginExtensions({ extensionPointId: 'panel-menu' });
}).toThrow();
});
describe('usePluginExtensionLinks()', () => {
test('should return only links extensions', () => {
const usePluginExtensionsMock: UsePluginExtensions = () => ({
extensions: [
{
id: '1',
pluginId: '',
title: '',
description: '',
type: PluginExtensionTypes.component,
component: () => undefined,
},
{
id: '2',
pluginId: '',
title: '',
description: '',
path: '',
type: PluginExtensionTypes.link,
},
{
id: '3',
pluginId: '',
title: '',
description: '',
path: '',
type: PluginExtensionTypes.link,
},
],
isLoading: false,
});
setPluginExtensionsHook(usePluginExtensionsMock);
const { result } = renderHook(() => usePluginLinkExtensions({ extensionPointId: 'panel-menu' }));
const { extensions } = result.current;
expect(extensions).toHaveLength(2);
expect(extensions[0].type).toBe('link');
expect(extensions[1].type).toBe('link');
expect(extensions.find(({ id }) => id === '2')).toBeDefined();
expect(extensions.find(({ id }) => id === '3')).toBeDefined();
});
test('should return the same object if the extensions do not change', () => {
const extensionPointId = 'foo';
const extensions: PluginExtension[] = [
{
id: '1',
pluginId: '',
title: '',
description: '',
path: '',
type: PluginExtensionTypes.link,
},
];
// Mimicing that the extensions do not change between renders
const usePluginExtensionsMock: UsePluginExtensions = () => ({
extensions,
isLoading: false,
});
setPluginExtensionsHook(usePluginExtensionsMock);
const { result, rerender } = renderHook(() => usePluginLinkExtensions({ extensionPointId }));
const firstExtensions = result.current.extensions;
rerender();
const secondExtensions = result.current.extensions;
expect(firstExtensions === secondExtensions).toBe(true);
});
test('should return a different object if the extensions do change', () => {
const extensionPointId = 'foo';
// Mimicing that the extensions is a new array object every time
const usePluginExtensionsMock: UsePluginExtensions = () => ({
extensions: [
{
id: '1',
pluginId: '',
title: '',
description: '',
path: '',
type: PluginExtensionTypes.link,
},
],
isLoading: false,
});
setPluginExtensionsHook(usePluginExtensionsMock);
const { result, rerender } = renderHook(() => usePluginLinkExtensions({ extensionPointId }));
const firstExtensions = result.current.extensions;
rerender();
const secondExtensions = result.current.extensions;
// The results differ
expect(firstExtensions === secondExtensions).toBe(false);
});
});
describe('usePluginExtensionComponents()', () => {
test('should return only component extensions', () => {
const hook: UsePluginExtensions = () => ({
extensions: [
{
id: '1',
pluginId: '',
title: '',
description: '',
type: PluginExtensionTypes.component,
component: () => undefined,
},
{
id: '2',
pluginId: '',
title: '',
description: '',
path: '',
type: PluginExtensionTypes.link,
},
{
id: '3',
pluginId: '',
title: '',
description: '',
path: '',
type: PluginExtensionTypes.link,
},
],
isLoading: false,
});
setPluginExtensionsHook(hook);
const hookRender = renderHook(() => usePluginComponentExtensions({ extensionPointId: 'panel-menu' }));
const { extensions } = hookRender.result.current;
expect(extensions).toHaveLength(1);
expect(extensions[0].type).toBe('component');
expect(extensions.find(({ id }) => id === '1')).toBeDefined();
});
test('should return the same object if the extensions do not change', () => {
const extensionPointId = 'foo';
const extensions: PluginExtension[] = [
{
id: '1',
pluginId: '',
title: '',
description: '',
type: PluginExtensionTypes.component,
component: () => undefined,
},
];
// Mimicing that the extensions do not change between renders
const usePluginExtensionsMock: UsePluginExtensions = () => ({
extensions,
isLoading: false,
});
setPluginExtensionsHook(usePluginExtensionsMock);
const { result, rerender } = renderHook(() => usePluginComponentExtensions({ extensionPointId }));
const firstExtensions = result.current.extensions;
rerender();
const secondExtensions = result.current.extensions;
// The results are the same
expect(firstExtensions === secondExtensions).toBe(true);
});
test('should return a different object if the extensions do change', () => {
const extensionPointId = 'foo';
// Mimicing that the extensions is a new array object every time
const usePluginExtensionsMock: UsePluginExtensions = () => ({
extensions: [
{
id: '1',
pluginId: '',
title: '',
description: '',
type: PluginExtensionTypes.component,
component: () => undefined,
},
],
isLoading: false,
});
setPluginExtensionsHook(usePluginExtensionsMock);
const { result, rerender } = renderHook(() => usePluginComponentExtensions({ extensionPointId }));
const firstExtensions = result.current.extensions;
rerender();
const secondExtensions = result.current.extensions;
// The results differ
expect(firstExtensions === secondExtensions).toBe(false);
});
});
});

View File

@ -0,0 +1,50 @@
import { useMemo } from 'react';
import { PluginExtensionComponent, PluginExtensionLink } from '@grafana/data';
import { GetPluginExtensionsOptions, UsePluginExtensions, UsePluginExtensionsResult } from './getPluginExtensions';
import { isPluginExtensionComponent, isPluginExtensionLink } from './utils';
let singleton: UsePluginExtensions | undefined;
export function setPluginExtensionsHook(hook: UsePluginExtensions): void {
// We allow overriding the registry in tests
if (singleton && process.env.NODE_ENV !== 'test') {
throw new Error('setPluginExtensionsHook() function should only be called once, when Grafana is starting.');
}
singleton = hook;
}
export function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult {
if (!singleton) {
throw new Error('usePluginExtensions(options) can only be used after the Grafana instance has started.');
}
return singleton(options);
}
export function usePluginLinkExtensions(
options: GetPluginExtensionsOptions
): UsePluginExtensionsResult<PluginExtensionLink> {
const { extensions, isLoading } = usePluginExtensions(options);
return useMemo(() => {
return {
extensions: extensions.filter(isPluginExtensionLink),
isLoading,
};
}, [extensions, isLoading]);
}
export function usePluginComponentExtensions<Props = {}>(
options: GetPluginExtensionsOptions
): { extensions: Array<PluginExtensionComponent<Props>>; isLoading: boolean } {
const { extensions, isLoading } = usePluginExtensions(options);
return useMemo(
() => ({
extensions: extensions.filter(isPluginExtensionComponent) as Array<PluginExtensionComponent<Props>>,
isLoading,
}),
[extensions, isLoading]
);
}

View File

@ -36,7 +36,7 @@ import {
setEmbeddedDashboard, setEmbeddedDashboard,
setAppEvents, setAppEvents,
setReturnToPreviousHook, setReturnToPreviousHook,
type GetPluginExtensions, setPluginExtensionsHook,
} from '@grafana/runtime'; } from '@grafana/runtime';
import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView'; import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView';
import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer'; import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer';
@ -80,11 +80,12 @@ import { initGrafanaLive } from './features/live';
import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView'; import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView';
import { PanelRenderer } from './features/panel/components/PanelRenderer'; import { PanelRenderer } from './features/panel/components/PanelRenderer';
import { DatasourceSrv } from './features/plugins/datasource_srv'; import { DatasourceSrv } from './features/plugins/datasource_srv';
import { createPluginExtensionRegistry } from './features/plugins/extensions/createPluginExtensionRegistry';
import { getCoreExtensionConfigurations } from './features/plugins/extensions/getCoreExtensionConfigurations'; import { getCoreExtensionConfigurations } from './features/plugins/extensions/getCoreExtensionConfigurations';
import { getPluginExtensions } from './features/plugins/extensions/getPluginExtensions'; import { createPluginExtensionsGetter } from './features/plugins/extensions/getPluginExtensions';
import { ReactivePluginExtensionsRegistry } from './features/plugins/extensions/reactivePluginExtensionRegistry';
import { createPluginExtensionsHook } from './features/plugins/extensions/usePluginExtensions';
import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin'; import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin';
import { PluginPreloadResult, preloadPlugins } from './features/plugins/pluginPreloader'; import { preloadPlugins } from './features/plugins/pluginPreloader';
import { QueryRunner } from './features/query/state/QueryRunner'; import { QueryRunner } from './features/query/state/QueryRunner';
import { runRequest } from './features/query/state/runRequest'; import { runRequest } from './features/query/state/runRequest';
import { initWindowRuntime } from './features/runtime/init'; import { initWindowRuntime } from './features/runtime/init';
@ -206,24 +207,26 @@ export class GrafanaApp {
setDataSourceSrv(dataSourceSrv); setDataSourceSrv(dataSourceSrv);
initWindowRuntime(); initWindowRuntime();
let preloadResults: PluginPreloadResult[] = []; // Initialize plugin extensions
const extensionsRegistry = new ReactivePluginExtensionsRegistry();
extensionsRegistry.register({
pluginId: 'grafana',
extensionConfigs: getCoreExtensionConfigurations(),
});
if (contextSrv.user.orgRole !== '') { if (contextSrv.user.orgRole !== '') {
// Preload selected app plugins // 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.
preloadResults = await preloadPlugins(config.apps); // TODO: remove the following exception once the issue mentioned above is fixed.
const awaitedAppPluginIds = ['cloud-home-app'];
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, extensionsRegistry);
await preloadPlugins(awaitedAppPlugins, extensionsRegistry);
} }
// Create extension registry out of preloaded plugins and core extensions setPluginExtensionGetter(createPluginExtensionsGetter(extensionsRegistry));
const extensionRegistry = createPluginExtensionRegistry([ setPluginExtensionsHook(createPluginExtensionsHook(extensionsRegistry));
{ pluginId: 'grafana', extensionConfigs: getCoreExtensionConfigurations() },
...preloadResults,
]);
// Expose the getPluginExtension function via grafana-runtime
const pluginExtensionGetter: GetPluginExtensions = (options) =>
getPluginExtensions({ ...options, registry: extensionRegistry });
setPluginExtensionGetter(pluginExtensionGetter);
// initialize chrome service // initialize chrome service
const queryParams = locationService.getSearchObject(); const queryParams = locationService.getSearchObject();

View File

@ -16,7 +16,7 @@ import { AppChrome } from './AppChrome';
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }), usePluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
})); }));
const searchData: DataFrame = { const searchData: DataFrame = {

View File

@ -14,6 +14,7 @@ import {
locationService, locationService,
setBackendSrv, setBackendSrv,
setDataSourceSrv, setDataSourceSrv,
usePluginLinkExtensions,
} from '@grafana/runtime'; } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv'; import { backendSrv } from 'app/core/services/backend_srv';
import * as ruleActionButtons from 'app/features/alerting/unified/components/rules/RuleActionsButtons'; import * as ruleActionButtons from 'app/features/alerting/unified/components/rules/RuleActionsButtons';
@ -57,6 +58,7 @@ import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
getPluginLinkExtensions: jest.fn(), getPluginLinkExtensions: jest.fn(),
usePluginLinkExtensions: jest.fn(),
useReturnToPrevious: jest.fn(), useReturnToPrevious: jest.fn(),
})); }));
jest.mock('./api/buildInfo'); jest.mock('./api/buildInfo');
@ -81,6 +83,7 @@ jest.spyOn(actions, 'rulesInSameGroupHaveInvalidFor').mockReturnValue([]);
const mocks = { const mocks = {
getAllDataSourcesMock: jest.mocked(config.getAllDataSources), getAllDataSourcesMock: jest.mocked(config.getAllDataSources),
getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions), getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions),
usePluginLinkExtensionsMock: jest.mocked(usePluginLinkExtensions),
rulesInSameGroupHaveInvalidForMock: jest.mocked(actions.rulesInSameGroupHaveInvalidFor), rulesInSameGroupHaveInvalidForMock: jest.mocked(actions.rulesInSameGroupHaveInvalidFor),
api: { api: {
@ -201,7 +204,7 @@ describe('RuleList', () => {
AccessControlAction.AlertingRuleExternalWrite, AccessControlAction.AlertingRuleExternalWrite,
]); ]);
mocks.rulesInSameGroupHaveInvalidForMock.mockReturnValue([]); mocks.rulesInSameGroupHaveInvalidForMock.mockReturnValue([]);
mocks.getPluginLinkExtensionsMock.mockReturnValue({ mocks.usePluginLinkExtensionsMock.mockReturnValue({
extensions: [ extensions: [
{ {
pluginId: 'grafana-ml-app', pluginId: 'grafana-ml-app',
@ -213,6 +216,7 @@ describe('RuleList', () => {
onClick: jest.fn(), onClick: jest.fn(),
}, },
], ],
isLoading: false,
}); });
}); });

View File

@ -1,7 +1,7 @@
import React, { ReactElement, useMemo, useState } from 'react'; import React, { ReactElement, useMemo, useState } from 'react';
import { PluginExtensionLink, PluginExtensionPoints } from '@grafana/data'; import { PluginExtensionLink, PluginExtensionPoints } from '@grafana/data';
import { getPluginLinkExtensions } from '@grafana/runtime'; import { usePluginLinkExtensions } from '@grafana/runtime';
import { Dropdown, IconButton } from '@grafana/ui'; import { Dropdown, IconButton } from '@grafana/ui';
import { ConfirmNavigationModal } from 'app/features/explore/extensions/ConfirmNavigationModal'; import { ConfirmNavigationModal } from 'app/features/explore/extensions/ConfirmNavigationModal';
import { Alert, CombinedRule } from 'app/types/unified-alerting'; import { Alert, CombinedRule } from 'app/types/unified-alerting';
@ -20,8 +20,8 @@ export const AlertInstanceExtensionPoint = ({
extensionPointId, extensionPointId,
}: AlertInstanceExtensionPointProps): ReactElement | null => { }: AlertInstanceExtensionPointProps): ReactElement | null => {
const [selectedExtension, setSelectedExtension] = useState<PluginExtensionLink | undefined>(); const [selectedExtension, setSelectedExtension] = useState<PluginExtensionLink | undefined>();
const context = { instance, rule }; const context = useMemo(() => ({ instance, rule }), [instance, rule]);
const extensions = useExtensionLinks(context, extensionPointId); const { extensions } = usePluginLinkExtensions({ context, extensionPointId, limitPerPlugin: 3 });
if (extensions.length === 0) { if (extensions.length === 0) {
return null; return null;
@ -48,18 +48,3 @@ export type PluginExtensionAlertInstanceContext = {
rule?: CombinedRule; rule?: CombinedRule;
instance: Alert; instance: Alert;
}; };
function useExtensionLinks(
context: PluginExtensionAlertInstanceContext,
extensionPointId: PluginExtensionPoints
): PluginExtensionLink[] {
return useMemo(() => {
const { extensions } = getPluginLinkExtensions({
extensionPointId,
context,
limitPerPlugin: 3,
});
return extensions;
}, [context, extensionPointId]);
}

View File

@ -7,7 +7,7 @@ import { MemoryRouter } from 'react-router-dom';
import { byRole } from 'testing-library-selector'; import { byRole } from 'testing-library-selector';
import { PluginExtensionTypes } from '@grafana/data'; import { PluginExtensionTypes } from '@grafana/data';
import { getPluginLinkExtensions, setBackendSrv } from '@grafana/runtime'; import { usePluginLinkExtensions, setBackendSrv } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv'; import { backendSrv } from 'app/core/services/backend_srv';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
@ -25,14 +25,14 @@ import { RuleDetails } from './RuleDetails';
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
getPluginLinkExtensions: jest.fn(), usePluginLinkExtensions: jest.fn(),
useReturnToPrevious: jest.fn(), useReturnToPrevious: jest.fn(),
})); }));
jest.mock('../../hooks/useIsRuleEditable'); jest.mock('../../hooks/useIsRuleEditable');
const mocks = { const mocks = {
getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions), usePluginLinkExtensionsMock: jest.mocked(usePluginLinkExtensions),
useIsRuleEditable: jest.mocked(useIsRuleEditable), useIsRuleEditable: jest.mocked(useIsRuleEditable),
}; };
@ -68,7 +68,7 @@ afterAll(() => {
}); });
beforeEach(() => { beforeEach(() => {
mocks.getPluginLinkExtensionsMock.mockReturnValue({ mocks.usePluginLinkExtensionsMock.mockReturnValue({
extensions: [ extensions: [
{ {
pluginId: 'grafana-ml-app', pluginId: 'grafana-ml-app',
@ -80,6 +80,7 @@ beforeEach(() => {
onClick: jest.fn(), onClick: jest.fn(),
}, },
], ],
isLoading: false,
}); });
server.resetHandlers(); server.resetHandlers();
mockAlertmanagerChoiceResponse(server, alertmanagerChoiceMockedResponse); mockAlertmanagerChoiceResponse(server, alertmanagerChoiceMockedResponse);

View File

@ -5,7 +5,7 @@ import React from 'react';
import { byLabelText, byRole, byTestId } from 'testing-library-selector'; import { byLabelText, byRole, byTestId } from 'testing-library-selector';
import { PluginExtensionTypes } from '@grafana/data'; import { PluginExtensionTypes } from '@grafana/data';
import { getPluginLinkExtensions } from '@grafana/runtime'; import { usePluginLinkExtensions } from '@grafana/runtime';
import { CombinedRuleNamespace } from '../../../../../types/unified-alerting'; import { CombinedRuleNamespace } from '../../../../../types/unified-alerting';
import { GrafanaAlertState, PromAlertingRuleState } from '../../../../../types/unified-alerting-dto'; import { GrafanaAlertState, PromAlertingRuleState } from '../../../../../types/unified-alerting-dto';
@ -17,10 +17,11 @@ import { RuleDetailsMatchingInstances } from './RuleDetailsMatchingInstances';
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
getPluginLinkExtensions: jest.fn(), getPluginLinkExtensions: jest.fn(),
usePluginLinkExtensions: jest.fn(),
})); }));
const mocks = { const mocks = {
getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions), usePluginLinkExtensionsMock: jest.mocked(usePluginLinkExtensions),
}; };
const ui = { const ui = {
@ -43,7 +44,7 @@ const ui = {
describe('RuleDetailsMatchingInstances', () => { describe('RuleDetailsMatchingInstances', () => {
beforeEach(() => { beforeEach(() => {
mocks.getPluginLinkExtensionsMock.mockReturnValue({ mocks.usePluginLinkExtensionsMock.mockReturnValue({
extensions: [ extensions: [
{ {
pluginId: 'grafana-ml-app', pluginId: 'grafana-ml-app',
@ -55,6 +56,7 @@ describe('RuleDetailsMatchingInstances', () => {
onClick: jest.fn(), onClick: jest.fn(),
}, },
], ],
isLoading: false,
}); });
}); });

View File

@ -3,14 +3,14 @@ import React from 'react';
import { PluginExtensionPoints } from '@grafana/data'; import { PluginExtensionPoints } from '@grafana/data';
import { GrafanaTheme2 } from '@grafana/data/'; import { GrafanaTheme2 } from '@grafana/data/';
import { getPluginComponentExtensions } from '@grafana/runtime'; import { usePluginComponentExtensions } from '@grafana/runtime';
import { Stack, Text } from '@grafana/ui'; import { Stack, Text } from '@grafana/ui';
import { useStyles2 } from '@grafana/ui/'; import { useStyles2 } from '@grafana/ui/';
export function PluginIntegrations() { export function PluginIntegrations() {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const { extensions } = getPluginComponentExtensions({ const { extensions } = usePluginComponentExtensions({
extensionPointId: PluginExtensionPoints.AlertingHomePage, extensionPointId: PluginExtensionPoints.AlertingHomePage,
limitPerPlugin: 1, limitPerPlugin: 1,
}); });

View File

@ -1,22 +0,0 @@
import { PluginExtensionCommandPaletteContext, PluginExtensionPoints } from '@grafana/data';
import { getPluginLinkExtensions } from '@grafana/runtime';
import { CommandPaletteAction } from '../types';
import { EXTENSIONS_PRIORITY } from '../values';
export default function getExtensionActions(): CommandPaletteAction[] {
const context: PluginExtensionCommandPaletteContext = {};
const { extensions } = getPluginLinkExtensions({
extensionPointId: PluginExtensionPoints.CommandPalette,
context,
limitPerPlugin: 3,
});
return extensions.map((extension) => ({
section: extension.category ?? 'Extensions',
priority: EXTENSIONS_PRIORITY,
id: extension.id,
name: extension.title,
target: extension.path,
perform: () => extension.onClick && extension.onClick(),
}));
}

View File

@ -6,8 +6,6 @@ import { changeTheme } from 'app/core/services/theme';
import { CommandPaletteAction } from '../types'; import { CommandPaletteAction } from '../types';
import { ACTIONS_PRIORITY, DEFAULT_PRIORITY, PREFERENCES_PRIORITY } from '../values'; import { ACTIONS_PRIORITY, DEFAULT_PRIORITY, PREFERENCES_PRIORITY } from '../values';
import getExtensionActions from './extensionActions';
// TODO: Clean this once ID is mandatory on nav items // TODO: Clean this once ID is mandatory on nav items
function idForNavItem(navItem: NavModelItem) { function idForNavItem(navItem: NavModelItem) {
return 'navModel.' + navItem.id ?? navItem.url ?? navItem.text ?? navItem.subTitle; return 'navModel.' + navItem.id ?? navItem.url ?? navItem.text ?? navItem.subTitle;
@ -72,7 +70,7 @@ function navTreeToActions(navTree: NavModelItem[], parents: NavModelItem[] = [])
return navActions; return navActions;
} }
export default (navBarTree: NavModelItem[]): CommandPaletteAction[] => { export default (navBarTree: NavModelItem[], extensionActions: CommandPaletteAction[]): CommandPaletteAction[] => {
const globalActions: CommandPaletteAction[] = [ const globalActions: CommandPaletteAction[] = [
{ {
id: 'preferences/theme', id: 'preferences/theme',
@ -99,7 +97,6 @@ export default (navBarTree: NavModelItem[]): CommandPaletteAction[] => {
}, },
]; ];
const extensionActions = getExtensionActions();
const navBarActions = navTreeToActions(navBarTree); const navBarActions = navTreeToActions(navBarTree);
return [...globalActions, ...extensionActions, ...navBarActions]; return [...globalActions, ...extensionActions, ...navBarActions];

View File

@ -6,18 +6,20 @@ import { CommandPaletteAction } from '../types';
import { getRecentDashboardActions } from './dashboardActions'; import { getRecentDashboardActions } from './dashboardActions';
import getStaticActions from './staticActions'; import getStaticActions from './staticActions';
import useExtensionActions from './useExtensionActions';
export default function useActions(searchQuery: string) { export default function useActions(searchQuery: string) {
const [navTreeActions, setNavTreeActions] = useState<CommandPaletteAction[]>([]); const [navTreeActions, setNavTreeActions] = useState<CommandPaletteAction[]>([]);
const [recentDashboardActions, setRecentDashboardActions] = useState<CommandPaletteAction[]>([]); const [recentDashboardActions, setRecentDashboardActions] = useState<CommandPaletteAction[]>([]);
const extensionActions = useExtensionActions();
const navBarTree = useSelector((state) => state.navBarTree); const navBarTree = useSelector((state) => state.navBarTree);
// Load standard static actions // Load standard static actions
useEffect(() => { useEffect(() => {
const staticActionsResp = getStaticActions(navBarTree); const staticActionsResp = getStaticActions(navBarTree, extensionActions);
setNavTreeActions(staticActionsResp); setNavTreeActions(staticActionsResp);
}, [navBarTree]); }, [navBarTree, extensionActions]);
// Load recent dashboards - we don't want them to reload when the nav tree changes // Load recent dashboards - we don't want them to reload when the nav tree changes
useEffect(() => { useEffect(() => {

View File

@ -0,0 +1,29 @@
import { useMemo } from 'react';
import { PluginExtensionCommandPaletteContext, PluginExtensionPoints } from '@grafana/data';
import { usePluginLinkExtensions } from '@grafana/runtime';
import { CommandPaletteAction } from '../types';
import { EXTENSIONS_PRIORITY } from '../values';
// NOTE: we are defining this here, as if we would define it in the hook, it would be recreated on every render, which would cause unnecessary re-renders.
const context: PluginExtensionCommandPaletteContext = {};
export default function useExtensionActions(): CommandPaletteAction[] {
const { extensions } = usePluginLinkExtensions({
extensionPointId: PluginExtensionPoints.CommandPalette,
context,
limitPerPlugin: 3,
});
return useMemo(() => {
return extensions.map((extension) => ({
section: extension.category ?? 'Extensions',
priority: EXTENSIONS_PRIORITY,
id: extension.id,
name: extension.title,
target: extension.path,
perform: () => extension.onClick && extension.onClick(),
}));
}, [extensions]);
}

View File

@ -177,6 +177,8 @@ export function panelMenuBehavior(menu: VizPanelMenu, isRepeat = false) {
items.push(getInspectMenuItem(plugin, panel, dashboard)); items.push(getInspectMenuItem(plugin, panel, dashboard));
// TODO: make sure that this works reliably with the reactive extension registry
// (we need to be able to know in advance what extensions should be loaded for this extension point, and make it possible to await for them.)
const { extensions } = getPluginLinkExtensions({ const { extensions } = getPluginLinkExtensions({
extensionPointId: PluginExtensionPoints.DashboardPanelMenu, extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
context: createExtensionContext(panel, dashboard), context: createExtensionContext(panel, dashboard),

View File

@ -68,6 +68,7 @@ jest.mock('app/core/core', () => ({
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }), getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
usePluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
})); }));
function getTestDashboard(overrides?: Partial<Dashboard>, metaOverrides?: Partial<DashboardMeta>): DashboardModel { function getTestDashboard(overrides?: Partial<Dashboard>, metaOverrides?: Partial<DashboardMeta>): DashboardModel {

View File

@ -1,6 +1,13 @@
import { ReactElement, useEffect, useState } from 'react'; import { ReactElement, useEffect, useMemo, useState } from 'react';
import { LoadingState, PanelMenuItem } from '@grafana/data'; import {
LoadingState,
PanelMenuItem,
PluginExtensionPanelContext,
PluginExtensionPoints,
getTimeZone,
} from '@grafana/data';
import { usePluginLinkExtensions } from '@grafana/runtime';
import { getPanelStateForModel } from 'app/features/panel/state/selectors'; import { getPanelStateForModel } from 'app/features/panel/state/selectors';
import { useSelector } from 'app/types'; import { useSelector } from 'app/types';
@ -21,10 +28,36 @@ interface Props {
export function PanelHeaderMenuProvider({ panel, dashboard, loadingState, children }: Props) { export function PanelHeaderMenuProvider({ panel, dashboard, loadingState, children }: Props) {
const [items, setItems] = useState<PanelMenuItem[]>([]); const [items, setItems] = useState<PanelMenuItem[]>([]);
const angularComponent = useSelector((state) => getPanelStateForModel(state, panel)?.angularComponent); const angularComponent = useSelector((state) => getPanelStateForModel(state, panel)?.angularComponent);
const context = useMemo(() => createExtensionContext(panel, dashboard), [panel, dashboard]);
const { extensions } = usePluginLinkExtensions({
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
context,
limitPerPlugin: 3,
});
useEffect(() => { useEffect(() => {
setItems(getPanelMenu(dashboard, panel, angularComponent)); setItems(getPanelMenu(dashboard, panel, extensions, angularComponent));
}, [dashboard, panel, angularComponent, loadingState, setItems]); }, [dashboard, panel, angularComponent, loadingState, setItems, extensions]);
return children({ items }); return children({ items });
} }
function createExtensionContext(panel: PanelModel, dashboard: DashboardModel): PluginExtensionPanelContext {
return {
id: panel.id,
pluginId: panel.type,
title: panel.title,
timeRange: dashboard.time,
timeZone: getTimeZone({
timeZone: dashboard.timezone,
}),
dashboard: {
uid: dashboard.uid,
title: dashboard.title,
tags: Array.from<string>(dashboard.tags),
},
targets: panel.targets,
scopedVars: panel.scopedVars,
data: panel.getQueryRunner().getLastResult(),
};
}

View File

@ -1,16 +1,7 @@
import { Store } from 'redux'; import { Store } from 'redux';
import { import { PanelMenuItem, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data';
dateTime, import { AngularComponent, usePluginLinkExtensions } from '@grafana/runtime';
FieldType,
LoadingState,
PanelData,
PanelMenuItem,
PluginExtensionPanelContext,
PluginExtensionTypes,
toDataFrame,
} from '@grafana/data';
import { AngularComponent, getPluginLinkExtensions } from '@grafana/runtime';
import config from 'app/core/config'; import config from 'app/core/config';
import { grantUserPermissions } from 'app/features/alerting/unified/mocks'; import { grantUserPermissions } from 'app/features/alerting/unified/mocks';
import * as actions from 'app/features/explore/state/main'; import * as actions from 'app/features/explore/state/main';
@ -31,16 +22,16 @@ jest.mock('app/core/services/context_srv', () => ({
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
setPluginExtensionGetter: jest.fn(), setPluginExtensionsHook: jest.fn(),
getPluginLinkExtensions: jest.fn(), usePluginLinkExtensions: jest.fn(),
})); }));
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions); const usePluginLinkExtensionsMock = jest.mocked(usePluginLinkExtensions);
describe('getPanelMenu()', () => { describe('getPanelMenu()', () => {
beforeEach(() => { beforeEach(() => {
getPluginLinkExtensionsMock.mockRestore(); usePluginLinkExtensionsMock.mockRestore();
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] }); usePluginLinkExtensionsMock.mockReturnValue({ extensions: [], isLoading: false });
grantUserPermissions([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleUpdate]); grantUserPermissions([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleUpdate]);
config.unifiedAlertingEnabled = false; config.unifiedAlertingEnabled = false;
}); });
@ -48,8 +39,9 @@ describe('getPanelMenu()', () => {
it('should return the correct panel menu items', () => { it('should return the correct panel menu items', () => {
const panel = new PanelModel({}); const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({}); const dashboard = createDashboardModelFixture({});
const extensions: PluginExtensionLink[] = [];
const menuItems = getPanelMenu(dashboard, panel); const menuItems = getPanelMenu(dashboard, panel, extensions);
expect(menuItems).toMatchInlineSnapshot(` expect(menuItems).toMatchInlineSnapshot(`
[ [
{ {
@ -126,22 +118,20 @@ describe('getPanelMenu()', () => {
describe('when extending panel menu from plugins', () => { describe('when extending panel menu from plugins', () => {
it('should contain menu item from link extension', () => { it('should contain menu item from link extension', () => {
getPluginLinkExtensionsMock.mockReturnValue({ const extensions: PluginExtensionLink[] = [
extensions: [ {
{ id: '1',
id: '1', pluginId: '...',
pluginId: '...', type: PluginExtensionTypes.link,
type: PluginExtensionTypes.link, title: 'Declare incident',
title: 'Declare incident', description: 'Declaring an incident in the app',
description: 'Declaring an incident in the app', path: '/a/grafana-basic-app/declare-incident',
path: '/a/grafana-basic-app/declare-incident', },
}, ];
],
});
const panel = new PanelModel({}); const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({}); const dashboard = createDashboardModelFixture({});
const menuItems = getPanelMenu(dashboard, panel); const menuItems = getPanelMenu(dashboard, panel, extensions);
const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu; const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu;
expect(extensionsSubMenu).toEqual( expect(extensionsSubMenu).toEqual(
@ -155,22 +145,19 @@ describe('getPanelMenu()', () => {
}); });
it('should truncate menu item title to 25 chars', () => { it('should truncate menu item title to 25 chars', () => {
getPluginLinkExtensionsMock.mockReturnValue({
extensions: [
{
id: '1',
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Declare incident when pressing this amazing menu item',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
},
],
});
const panel = new PanelModel({}); const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({}); const dashboard = createDashboardModelFixture({});
const menuItems = getPanelMenu(dashboard, panel); const extensions: PluginExtensionLink[] = [
{
id: '1',
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Declare incident when pressing this amazing menu item',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
},
];
const menuItems = getPanelMenu(dashboard, panel, extensions);
const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu; const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu;
expect(extensionsSubMenu).toEqual( expect(extensionsSubMenu).toEqual(
@ -185,230 +172,42 @@ describe('getPanelMenu()', () => {
it('should pass onClick from plugin extension link to menu item', () => { it('should pass onClick from plugin extension link to menu item', () => {
const expectedOnClick = jest.fn(); const expectedOnClick = jest.fn();
getPluginLinkExtensionsMock.mockReturnValue({
extensions: [
{
id: '1',
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Declare incident when pressing this amazing menu item',
description: 'Declaring an incident in the app',
onClick: expectedOnClick,
},
],
});
const panel = new PanelModel({}); const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({}); const dashboard = createDashboardModelFixture({});
const menuItems = getPanelMenu(dashboard, panel); const extensions: PluginExtensionLink[] = [
{
id: '1',
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Declare incident when pressing this amazing menu item',
description: 'Declaring an incident in the app',
onClick: expectedOnClick,
},
];
const menuItems = getPanelMenu(dashboard, panel, extensions);
const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu; const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu;
const menuItem = extensionsSubMenu?.find((i) => (i.text = 'Declare incident when...')); const menuItem = extensionsSubMenu?.find((i) => (i.text = 'Declare incident when...'));
menuItem?.onClick?.({} as React.MouseEvent); menuItem?.onClick?.({} as React.MouseEvent);
expect(expectedOnClick).toBeCalledTimes(1); expect(expectedOnClick).toHaveBeenCalledTimes(1);
});
it('should pass context with correct values when configuring extension', () => {
const data: PanelData = {
series: [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time },
{ name: 'score', type: FieldType.number },
],
}),
],
timeRange: {
from: dateTime(),
to: dateTime(),
raw: {
from: 'now',
to: 'now-1h',
},
},
state: LoadingState.Done,
};
const panel = new PanelModel({
type: 'timeseries',
id: 1,
title: 'My panel',
targets: [
{
refId: 'A',
datasource: {
type: 'testdata',
},
},
],
scopedVars: {
a: {
text: 'a',
value: 'a',
},
},
queryRunner: {
getLastResult: jest.fn(() => data),
},
});
const dashboard = createDashboardModelFixture({
timezone: 'utc',
time: {
from: 'now-5m',
to: 'now',
},
tags: ['database', 'panel'],
uid: '123',
title: 'My dashboard',
});
getPanelMenu(dashboard, panel);
const context: PluginExtensionPanelContext = {
pluginId: 'timeseries',
id: 1,
title: 'My panel',
timeZone: 'utc',
timeRange: {
from: 'now-5m',
to: 'now',
},
targets: [
{
refId: 'A',
datasource: {
type: 'testdata',
},
},
],
dashboard: {
tags: ['database', 'panel'],
uid: '123',
title: 'My dashboard',
},
scopedVars: {
a: {
text: 'a',
value: 'a',
},
},
data,
};
expect(getPluginLinkExtensionsMock).toBeCalledWith(expect.objectContaining({ context }));
});
it('should pass context with default time zone values when configuring extension', () => {
const data: PanelData = {
series: [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time },
{ name: 'score', type: FieldType.number },
],
}),
],
timeRange: {
from: dateTime(),
to: dateTime(),
raw: {
from: 'now',
to: 'now-1h',
},
},
state: LoadingState.Done,
};
const panel = new PanelModel({
type: 'timeseries',
id: 1,
title: 'My panel',
targets: [
{
refId: 'A',
datasource: {
type: 'testdata',
},
},
],
scopedVars: {
a: {
text: 'a',
value: 'a',
},
},
queryRunner: {
getLastResult: jest.fn(() => data),
},
});
const dashboard = createDashboardModelFixture({
timezone: '',
time: {
from: 'now-5m',
to: 'now',
},
tags: ['database', 'panel'],
uid: '123',
title: 'My dashboard',
});
getPanelMenu(dashboard, panel);
const context: PluginExtensionPanelContext = {
pluginId: 'timeseries',
id: 1,
title: 'My panel',
timeZone: 'browser',
timeRange: {
from: 'now-5m',
to: 'now',
},
targets: [
{
refId: 'A',
datasource: {
type: 'testdata',
},
},
],
dashboard: {
tags: ['database', 'panel'],
uid: '123',
title: 'My dashboard',
},
scopedVars: {
a: {
text: 'a',
value: 'a',
},
},
data,
};
expect(getPluginLinkExtensionsMock).toBeCalledWith(expect.objectContaining({ context }));
}); });
it('should contain menu item with category', () => { it('should contain menu item with category', () => {
getPluginLinkExtensionsMock.mockReturnValue({
extensions: [
{
id: '1',
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Declare incident',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
category: 'Incident',
},
],
});
const panel = new PanelModel({}); const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({}); const dashboard = createDashboardModelFixture({});
const menuItems = getPanelMenu(dashboard, panel); const extensions: PluginExtensionLink[] = [
{
id: '1',
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Declare incident',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
category: 'Incident',
},
];
const menuItems = getPanelMenu(dashboard, panel, extensions);
const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu; const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu;
expect(extensionsSubMenu).toEqual( expect(extensionsSubMenu).toEqual(
@ -427,23 +226,20 @@ describe('getPanelMenu()', () => {
}); });
it('should truncate category to 25 chars', () => { it('should truncate category to 25 chars', () => {
getPluginLinkExtensionsMock.mockReturnValue({
extensions: [
{
id: '1',
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Declare incident',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
category: 'Declare incident when pressing this amazing menu item',
},
],
});
const panel = new PanelModel({}); const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({}); const dashboard = createDashboardModelFixture({});
const menuItems = getPanelMenu(dashboard, panel); const extensions: PluginExtensionLink[] = [
{
id: '1',
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Declare incident',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
category: 'Declare incident when pressing this amazing menu item',
},
];
const menuItems = getPanelMenu(dashboard, panel, extensions);
const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu; const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu;
expect(extensionsSubMenu).toEqual( expect(extensionsSubMenu).toEqual(
@ -462,31 +258,28 @@ describe('getPanelMenu()', () => {
}); });
it('should contain menu item with category and append items without category after divider', () => { it('should contain menu item with category and append items without category after divider', () => {
getPluginLinkExtensionsMock.mockReturnValue({
extensions: [
{
id: '1',
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Declare incident',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
category: 'Incident',
},
{
id: '2',
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Create forecast',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
},
],
});
const panel = new PanelModel({}); const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({}); const dashboard = createDashboardModelFixture({});
const menuItems = getPanelMenu(dashboard, panel); const extensions: PluginExtensionLink[] = [
{
id: '1',
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Declare incident',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
category: 'Incident',
},
{
id: '2',
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Create forecast',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
},
];
const menuItems = getPanelMenu(dashboard, panel, extensions);
const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu; const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu;
expect(extensionsSubMenu).toEqual( expect(extensionsSubMenu).toEqual(
@ -519,8 +312,9 @@ describe('getPanelMenu()', () => {
const angularComponent = { getScope: () => scope } as AngularComponent; const angularComponent = { getScope: () => scope } as AngularComponent;
const panel = new PanelModel({ isViewing: true }); const panel = new PanelModel({ isViewing: true });
const dashboard = createDashboardModelFixture({}); const dashboard = createDashboardModelFixture({});
const extensions: PluginExtensionLink[] = [];
const menuItems = getPanelMenu(dashboard, panel, angularComponent); const menuItems = getPanelMenu(dashboard, panel, extensions, angularComponent);
expect(menuItems).toMatchInlineSnapshot(` expect(menuItems).toMatchInlineSnapshot(`
[ [
{ {
@ -590,7 +384,8 @@ describe('getPanelMenu()', () => {
beforeAll(() => { beforeAll(() => {
const panel = new PanelModel({}); const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({}); const dashboard = createDashboardModelFixture({});
const menuItems = getPanelMenu(dashboard, panel); const extensions: PluginExtensionLink[] = [];
const menuItems = getPanelMenu(dashboard, panel, extensions);
explore = menuItems.find((item) => item.text === 'Explore') as PanelMenuItem; explore = menuItems.find((item) => item.text === 'Explore') as PanelMenuItem;
navigateSpy = jest.spyOn(actions, 'navigateToExplore'); navigateSpy = jest.spyOn(actions, 'navigateToExplore');
window.open = windowOpen; window.open = windowOpen;
@ -624,14 +419,16 @@ describe('getPanelMenu()', () => {
expect(windowOpen).toHaveBeenLastCalledWith(`${testSubUrl}${testUrl}`); expect(windowOpen).toHaveBeenLastCalledWith(`${testSubUrl}${testUrl}`);
}); });
}); });
describe('Alerting menu', () => { describe('Alerting menu', () => {
it('should render "New alert rule" menu item if user has permissions to read and update alerts ', () => { it('should render "New alert rule" menu item if user has permissions to read and update alerts ', () => {
const panel = new PanelModel({}); const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({}); const dashboard = createDashboardModelFixture({});
const extensions: PluginExtensionLink[] = [];
config.unifiedAlertingEnabled = true; config.unifiedAlertingEnabled = true;
grantUserPermissions([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleUpdate]); grantUserPermissions([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleUpdate]);
const menuItems = getPanelMenu(dashboard, panel); const menuItems = getPanelMenu(dashboard, panel, extensions);
const moreSubMenu = menuItems.find((i) => i.text === 'More...')?.subMenu; const moreSubMenu = menuItems.find((i) => i.text === 'More...')?.subMenu;
expect(moreSubMenu).toEqual( expect(moreSubMenu).toEqual(
@ -646,11 +443,12 @@ describe('getPanelMenu()', () => {
it('should not render "New alert rule" menu item, if user does not have permissions to update alerts ', () => { it('should not render "New alert rule" menu item, if user does not have permissions to update alerts ', () => {
const panel = new PanelModel({}); const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({}); const dashboard = createDashboardModelFixture({});
const extensions: PluginExtensionLink[] = [];
grantUserPermissions([AccessControlAction.AlertingRuleRead]); grantUserPermissions([AccessControlAction.AlertingRuleRead]);
config.unifiedAlertingEnabled = true; config.unifiedAlertingEnabled = true;
const menuItems = getPanelMenu(dashboard, panel); const menuItems = getPanelMenu(dashboard, panel, extensions);
const moreSubMenu = menuItems.find((i) => i.text === 'More...')?.subMenu; const moreSubMenu = menuItems.find((i) => i.text === 'More...')?.subMenu;
@ -662,14 +460,16 @@ describe('getPanelMenu()', () => {
]) ])
); );
}); });
it('should not render "New alert rule" menu item, if user does not have permissions to read update alerts ', () => { it('should not render "New alert rule" menu item, if user does not have permissions to read update alerts ', () => {
const panel = new PanelModel({}); const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({}); const dashboard = createDashboardModelFixture({});
const extensions: PluginExtensionLink[] = [];
grantUserPermissions([]); grantUserPermissions([]);
config.unifiedAlertingEnabled = true; config.unifiedAlertingEnabled = true;
const menuItems = getPanelMenu(dashboard, panel); const menuItems = getPanelMenu(dashboard, panel, extensions);
const moreSubMenu = menuItems.find((i) => i.text === 'More...')?.subMenu; const moreSubMenu = menuItems.find((i) => i.text === 'More...')?.subMenu;
const createAlertOption = moreSubMenu?.find((i) => i.text === 'New alert rule')?.subMenu; const createAlertOption = moreSubMenu?.find((i) => i.text === 'New alert rule')?.subMenu;

View File

@ -1,11 +1,5 @@
import { import { PanelMenuItem, urlUtil, PluginExtensionLink } from '@grafana/data';
PanelMenuItem, import { AngularComponent, locationService } from '@grafana/runtime';
PluginExtensionPoints,
getTimeZone,
urlUtil,
type PluginExtensionPanelContext,
} from '@grafana/data';
import { AngularComponent, getPluginLinkExtensions, locationService } from '@grafana/runtime';
import { PanelCtrl } from 'app/angular/panel/panel_ctrl'; import { PanelCtrl } from 'app/angular/panel/panel_ctrl';
import config from 'app/core/config'; import config from 'app/core/config';
import { createErrorNotification } from 'app/core/copy/appNotification'; import { createErrorNotification } from 'app/core/copy/appNotification';
@ -42,6 +36,7 @@ import { getTimeSrv } from '../services/TimeSrv';
export function getPanelMenu( export function getPanelMenu(
dashboard: DashboardModel, dashboard: DashboardModel,
panel: PanelModel, panel: PanelModel,
extensions: PluginExtensionLink[],
angularComponent?: AngularComponent | null angularComponent?: AngularComponent | null
): PanelMenuItem[] { ): PanelMenuItem[] {
const onViewPanel = (event: React.MouseEvent) => { const onViewPanel = (event: React.MouseEvent) => {
@ -332,12 +327,6 @@ export function getPanelMenu(
}); });
} }
const { extensions } = getPluginLinkExtensions({
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
context: createExtensionContext(panel, dashboard),
limitPerPlugin: 3,
});
if (extensions.length > 0 && !panel.isEditing) { if (extensions.length > 0 && !panel.isEditing) {
menu.push({ menu.push({
text: 'Extensions', text: 'Extensions',
@ -370,23 +359,3 @@ export function getPanelMenu(
return menu; return menu;
} }
function createExtensionContext(panel: PanelModel, dashboard: DashboardModel): PluginExtensionPanelContext {
return {
id: panel.id,
pluginId: panel.type,
title: panel.title,
timeRange: dashboard.time,
timeZone: getTimeZone({
timeZone: dashboard.timezone,
}),
dashboard: {
uid: dashboard.uid,
title: dashboard.title,
tags: Array.from<string>(dashboard.tags),
},
targets: panel.targets,
scopedVars: panel.scopedVars,
data: panel.getQueryRunner().getLastResult(),
};
}

View File

@ -3,7 +3,7 @@ import React from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { PluginExtensionTypes, PluginState } from '@grafana/data'; import { PluginExtensionTypes, PluginState } from '@grafana/data';
import { setAngularLoader, setPluginExtensionGetter } from '@grafana/runtime'; import { setAngularLoader, setPluginExtensionsHook } from '@grafana/runtime';
import { configureStore } from 'app/store/configureStore'; import { configureStore } from 'app/store/configureStore';
import { getMockDataSource, getMockDataSourceMeta, getMockDataSourceSettingsState } from '../__mocks__'; import { getMockDataSource, getMockDataSourceMeta, getMockDataSourceSettingsState } from '../__mocks__';
@ -59,7 +59,7 @@ describe('<EditDataSource>', () => {
}); });
beforeEach(() => { beforeEach(() => {
setPluginExtensionGetter(jest.fn().mockReturnValue({ extensions: [] })); setPluginExtensionsHook(jest.fn().mockReturnValue({ extensions: [] }));
}); });
describe('On loading errors', () => { describe('On loading errors', () => {
@ -269,7 +269,7 @@ describe('<EditDataSource>', () => {
it('should be possible to extend the form with a "component" extension in case the plugin ID is whitelisted', () => { it('should be possible to extend the form with a "component" extension in case the plugin ID is whitelisted', () => {
const message = "I'm a UI extension component!"; const message = "I'm a UI extension component!";
setPluginExtensionGetter( setPluginExtensionsHook(
jest.fn().mockReturnValue({ jest.fn().mockReturnValue({
extensions: [ extensions: [
{ {
@ -298,7 +298,7 @@ describe('<EditDataSource>', () => {
it('should NOT be possible to extend the form with a "component" extension in case the plugin ID is NOT whitelisted', () => { it('should NOT be possible to extend the form with a "component" extension in case the plugin ID is NOT whitelisted', () => {
const message = "I'm a UI extension component!"; const message = "I'm a UI extension component!";
setPluginExtensionGetter( setPluginExtensionsHook(
jest.fn().mockReturnValue({ jest.fn().mockReturnValue({
extensions: [ extensions: [
{ {
@ -328,7 +328,7 @@ describe('<EditDataSource>', () => {
const message = "I'm a UI extension component!"; const message = "I'm a UI extension component!";
const component = jest.fn().mockReturnValue(<div>{message}</div>); const component = jest.fn().mockReturnValue(<div>{message}</div>);
setPluginExtensionGetter( setPluginExtensionsHook(
jest.fn().mockReturnValue({ jest.fn().mockReturnValue({
extensions: [ extensions: [
{ {

View File

@ -11,7 +11,7 @@ import {
DataSourceJsonData, DataSourceJsonData,
DataSourceUpdatedSuccessfully, DataSourceUpdatedSuccessfully,
} from '@grafana/data'; } from '@grafana/data';
import { getDataSourceSrv, getPluginComponentExtensions } from '@grafana/runtime'; import { getDataSourceSrv, usePluginComponentExtensions } from '@grafana/runtime';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import PageLoader from 'app/core/components/PageLoader/PageLoader'; import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { DataSourceSettingsState, useDispatch } from 'app/types'; import { DataSourceSettingsState, useDispatch } from 'app/types';
@ -136,15 +136,15 @@ export function EditDataSourceView({
onTest(); onTest();
}; };
const extensions = useMemo(() => { const extensionPointId = PluginExtensionPoints.DataSourceConfig;
const allowedPluginIds = ['grafana-pdc-app', 'grafana-auth-app']; const { extensions } = usePluginComponentExtensions<{
const extensionPointId = PluginExtensionPoints.DataSourceConfig; context: PluginExtensionDataSourceConfigContext<DataSourceJsonData>;
const { extensions } = getPluginComponentExtensions<{ }>({ extensionPointId });
context: PluginExtensionDataSourceConfigContext<DataSourceJsonData>;
}>({ extensionPointId });
const allowedExtensions = useMemo(() => {
const allowedPluginIds = ['grafana-pdc-app', 'grafana-auth-app'];
return extensions.filter((e) => allowedPluginIds.includes(e.pluginId)); return extensions.filter((e) => allowedPluginIds.includes(e.pluginId));
}, []); }, [extensions]);
if (loadError) { if (loadError) {
return ( return (
@ -203,7 +203,7 @@ export function EditDataSourceView({
)} )}
{/* Extension point */} {/* Extension point */}
{extensions.map((extension) => { {allowedExtensions.map((extension) => {
const Component = extension.component; const Component = extension.component;
return ( return (

View File

@ -5,7 +5,7 @@ import { TestProvider } from 'test/helpers/TestProvider';
import { CoreApp, createTheme, DataSourceApi, EventBusSrv, LoadingState, PluginExtensionTypes } from '@grafana/data'; import { CoreApp, createTheme, DataSourceApi, EventBusSrv, LoadingState, PluginExtensionTypes } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { getPluginLinkExtensions } from '@grafana/runtime'; import { usePluginLinkExtensions } from '@grafana/runtime';
import { configureStore } from 'app/store/configureStore'; import { configureStore } from 'app/store/configureStore';
import { ContentOutlineContextProvider } from './ContentOutline/ContentOutlineContext'; import { ContentOutlineContextProvider } from './ContentOutline/ContentOutlineContext';
@ -123,7 +123,7 @@ jest.mock('app/core/core', () => ({
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
getPluginLinkExtensions: jest.fn(() => ({ extensions: [] })), usePluginLinkExtensions: jest.fn(() => ({ extensions: [] })),
})); }));
// for the AutoSizer component to have a width // for the AutoSizer component to have a width
@ -137,7 +137,7 @@ jest.mock('react-virtualized-auto-sizer', () => {
}); });
}); });
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions); const usePluginLinkExtensionsMock = jest.mocked(usePluginLinkExtensions);
const setup = (overrideProps?: Partial<Props>) => { const setup = (overrideProps?: Partial<Props>) => {
const store = configureStore({ const store = configureStore({
@ -179,7 +179,7 @@ describe('Explore', () => {
}); });
it('should render toolbar extension point if extensions is available', async () => { it('should render toolbar extension point if extensions is available', async () => {
getPluginLinkExtensionsMock.mockReturnValueOnce({ usePluginLinkExtensionsMock.mockReturnValueOnce({
extensions: [ extensions: [
{ {
id: '1', id: '1',
@ -198,6 +198,7 @@ describe('Explore', () => {
onClick: () => {}, onClick: () => {},
}, },
], ],
isLoading: false,
}); });
setup({ queryResponse: makeEmptyQueryResponse(LoadingState.Done) }); setup({ queryResponse: makeEmptyQueryResponse(LoadingState.Done) });

View File

@ -4,7 +4,7 @@ import React, { ReactNode } from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { PluginExtensionPoints, PluginExtensionTypes } from '@grafana/data'; import { PluginExtensionPoints, PluginExtensionTypes } from '@grafana/data';
import { getPluginLinkExtensions } from '@grafana/runtime'; import { usePluginLinkExtensions } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema'; import { DataQuery } from '@grafana/schema';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { configureStore } from 'app/store/configureStore'; import { configureStore } from 'app/store/configureStore';
@ -16,13 +16,13 @@ import { ToolbarExtensionPoint } from './ToolbarExtensionPoint';
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
getPluginLinkExtensions: jest.fn(), usePluginLinkExtensions: jest.fn(),
})); }));
jest.mock('app/core/services/context_srv'); jest.mock('app/core/services/context_srv');
const contextSrvMock = jest.mocked(contextSrv); const contextSrvMock = jest.mocked(contextSrv);
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions); const usePluginLinkExtensionsMock = jest.mocked(usePluginLinkExtensions);
type storeOptions = { type storeOptions = {
targets: DataQuery[]; targets: DataQuery[];
@ -54,7 +54,7 @@ function renderWithExploreStore(
describe('ToolbarExtensionPoint', () => { describe('ToolbarExtensionPoint', () => {
describe('with extension points', () => { describe('with extension points', () => {
beforeAll(() => { beforeAll(() => {
getPluginLinkExtensionsMock.mockReturnValue({ usePluginLinkExtensionsMock.mockReturnValue({
extensions: [ extensions: [
{ {
pluginId: 'grafana', pluginId: 'grafana',
@ -74,6 +74,7 @@ describe('ToolbarExtensionPoint', () => {
path: '/a/grafana-ml-ap/forecast', path: '/a/grafana-ml-ap/forecast',
}, },
], ],
isLoading: false,
}); });
}); });
@ -99,7 +100,9 @@ describe('ToolbarExtensionPoint', () => {
await userEvent.click(screen.getByRole('button', { name: 'Add' })); await userEvent.click(screen.getByRole('button', { name: 'Add' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'Add to dashboard' })); await userEvent.click(screen.getByRole('menuitem', { name: 'Add to dashboard' }));
const { extensions } = getPluginLinkExtensions({ extensionPointId: PluginExtensionPoints.ExploreToolbarAction }); const { extensions } = usePluginLinkExtensionsMock({
extensionPointId: PluginExtensionPoints.ExploreToolbarAction,
});
const [extension] = extensions; const [extension] = extensions;
expect(jest.mocked(extension.onClick)).toBeCalledTimes(1); expect(jest.mocked(extension.onClick)).toBeCalledTimes(1);
@ -125,7 +128,7 @@ describe('ToolbarExtensionPoint', () => {
data, data,
}); });
const [options] = getPluginLinkExtensionsMock.mock.calls[0]; const [options] = usePluginLinkExtensionsMock.mock.calls[0];
const { context } = options; const { context } = options;
expect(context).toEqual({ expect(context).toEqual({
@ -150,7 +153,7 @@ describe('ToolbarExtensionPoint', () => {
data, data,
}); });
const [options] = getPluginLinkExtensionsMock.mock.calls[0]; const [options] = usePluginLinkExtensionsMock.mock.calls[0];
const { context } = options; const { context } = options;
expect(context).toHaveProperty('timeZone', 'browser'); expect(context).toHaveProperty('timeZone', 'browser');
@ -159,7 +162,7 @@ describe('ToolbarExtensionPoint', () => {
it('should correct extension point id when fetching extensions', async () => { it('should correct extension point id when fetching extensions', async () => {
renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />); renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />);
const [options] = getPluginLinkExtensionsMock.mock.calls[0]; const [options] = usePluginLinkExtensionsMock.mock.calls[0];
const { extensionPointId } = options; const { extensionPointId } = options;
expect(extensionPointId).toBe(PluginExtensionPoints.ExploreToolbarAction); expect(extensionPointId).toBe(PluginExtensionPoints.ExploreToolbarAction);
@ -168,7 +171,7 @@ describe('ToolbarExtensionPoint', () => {
describe('with extension points without categories', () => { describe('with extension points without categories', () => {
beforeAll(() => { beforeAll(() => {
getPluginLinkExtensionsMock.mockReturnValue({ usePluginLinkExtensionsMock.mockReturnValue({
extensions: [ extensions: [
{ {
pluginId: 'grafana', pluginId: 'grafana',
@ -187,6 +190,7 @@ describe('ToolbarExtensionPoint', () => {
path: '/a/grafana-ml-ap/forecast', path: '/a/grafana-ml-ap/forecast',
}, },
], ],
isLoading: false,
}); });
}); });
@ -211,7 +215,7 @@ describe('ToolbarExtensionPoint', () => {
describe('without extension points', () => { describe('without extension points', () => {
beforeAll(() => { beforeAll(() => {
contextSrvMock.hasPermission.mockReturnValue(true); contextSrvMock.hasPermission.mockReturnValue(true);
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] }); usePluginLinkExtensionsMock.mockReturnValue({ extensions: [], isLoading: false });
}); });
it('should render "add to dashboard" action button if one pane is visible', async () => { it('should render "add to dashboard" action button if one pane is visible', async () => {
@ -229,7 +233,7 @@ describe('ToolbarExtensionPoint', () => {
describe('with insufficient permissions', () => { describe('with insufficient permissions', () => {
beforeAll(() => { beforeAll(() => {
contextSrvMock.hasPermission.mockReturnValue(false); contextSrvMock.hasPermission.mockReturnValue(false);
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] }); usePluginLinkExtensionsMock.mockReturnValue({ extensions: [], isLoading: false });
}); });
it('should not render "add to dashboard" action button', async () => { it('should not render "add to dashboard" action button', async () => {

View File

@ -1,7 +1,7 @@
import React, { lazy, ReactElement, Suspense, useMemo, useState } from 'react'; import React, { lazy, ReactElement, Suspense, useMemo, useState } from 'react';
import { type PluginExtensionLink, PluginExtensionPoints, RawTimeRange, getTimeZone } from '@grafana/data'; import { type PluginExtensionLink, PluginExtensionPoints, RawTimeRange, getTimeZone } from '@grafana/data';
import { getPluginLinkExtensions, config } from '@grafana/runtime'; import { config, usePluginLinkExtensions } from '@grafana/runtime';
import { DataQuery, TimeZone } from '@grafana/schema'; import { DataQuery, TimeZone } from '@grafana/schema';
import { Dropdown, ToolbarButton } from '@grafana/ui'; import { Dropdown, ToolbarButton } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
@ -26,7 +26,11 @@ export function ToolbarExtensionPoint(props: Props): ReactElement | null {
const [selectedExtension, setSelectedExtension] = useState<PluginExtensionLink | undefined>(); const [selectedExtension, setSelectedExtension] = useState<PluginExtensionLink | undefined>();
const [isOpen, setIsOpen] = useState<boolean>(false); const [isOpen, setIsOpen] = useState<boolean>(false);
const context = useExtensionPointContext(props); const context = useExtensionPointContext(props);
const extensions = useExtensionLinks(context); const { extensions } = usePluginLinkExtensions({
extensionPointId: PluginExtensionPoints.ExploreToolbarAction,
context: context,
limitPerPlugin: 3,
});
const selectExploreItem = getExploreItemSelector(exploreId); const selectExploreItem = getExploreItemSelector(exploreId);
const noQueriesInPane = useSelector(selectExploreItem)?.queries?.length; const noQueriesInPane = useSelector(selectExploreItem)?.queries?.length;
@ -114,15 +118,3 @@ function useExtensionPointContext(props: Props): PluginExtensionExploreContext {
numUniqueIds, numUniqueIds,
]); ]);
} }
function useExtensionLinks(context: PluginExtensionExploreContext): PluginExtensionLink[] {
return useMemo(() => {
const { extensions } = getPluginLinkExtensions({
extensionPointId: PluginExtensionPoints.ExploreToolbarAction,
context: context,
limitPerPlugin: 3,
});
return extensions;
}, [context]);
}

View File

@ -22,7 +22,7 @@ import {
locationService, locationService,
HistoryWrapper, HistoryWrapper,
LocationService, LocationService,
setPluginExtensionGetter, setPluginExtensionsHook,
setBackendSrv, setBackendSrv,
getBackendSrv, getBackendSrv,
getDataSourceSrv, getDataSourceSrv,
@ -86,7 +86,7 @@ export function setupExplore(options?: SetupOptions): {
request: jest.fn().mockRejectedValue(undefined), request: jest.fn().mockRejectedValue(undefined),
}); });
setPluginExtensionGetter(() => ({ extensions: [] })); setPluginExtensionsHook(() => ({ extensions: [], isLoading: false }));
// Clear this up otherwise it persists data source selection // Clear this up otherwise it persists data source selection
// TODO: probably add test for that too // TODO: probably add test for that too

View File

@ -1,140 +0,0 @@
import { PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data';
import { createPluginExtensionRegistry } from './createPluginExtensionRegistry';
describe('createRegistry()', () => {
const placement1 = 'grafana/dashboard/panel/menu';
const placement2 = 'plugins/myorg-basic-app/start';
const pluginId = 'grafana-basic-app';
let link1: PluginExtensionLinkConfig, link2: PluginExtensionLinkConfig;
beforeEach(() => {
link1 = {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: placement1,
configure: jest.fn().mockReturnValue({}),
};
link2 = {
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: placement2,
configure: jest.fn().mockImplementation((context) => ({ title: context?.title })),
};
global.console.warn = jest.fn();
});
it('should be possible to register extensions', () => {
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]);
expect(Object.getOwnPropertyNames(registry)).toEqual([placement1, placement2]);
// Placement 1
expect(registry[placement1]).toHaveLength(1);
expect(registry[placement1]).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId,
config: {
...link1,
configure: expect.any(Function),
},
}),
])
);
// Placement 2
expect(registry[placement2]).toHaveLength(1);
expect(registry[placement2]).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId,
config: {
...link2,
configure: expect.any(Function),
},
}),
])
);
});
it('should not register link extensions with invalid path configured', () => {
const registry = createPluginExtensionRegistry([
{ pluginId, extensionConfigs: [{ ...link1, path: 'invalid-path' }, link2] },
]);
expect(Object.getOwnPropertyNames(registry)).toEqual([placement2]);
// Placement 2
expect(registry[placement2]).toHaveLength(1);
expect(registry[placement2]).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId,
config: {
...link2,
configure: expect.any(Function),
},
}),
])
);
});
it('should not register extensions for a plugin that had errors', () => {
const registry = createPluginExtensionRegistry([
{ pluginId, extensionConfigs: [link1, link2], error: new Error('Plugin failed to load') },
]);
expect(Object.getOwnPropertyNames(registry)).toEqual([]);
});
it('should not register an extension if it has an invalid configure() function', () => {
const registry = createPluginExtensionRegistry([
// @ts-ignore (We would like to provide an invalid configure function on purpose)
{ pluginId, extensionConfigs: [{ ...link1, configure: '...' }, link2] },
]);
expect(Object.getOwnPropertyNames(registry)).toEqual([placement2]);
// Placement 2 (checking if it still registers the extension with a valid configuration)
expect(registry[placement2]).toHaveLength(1);
expect(registry[placement2]).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId,
config: {
...link2,
configure: expect.any(Function),
},
}),
])
);
});
it('should not register an extension if it has invalid properties (empty title / description)', () => {
const registry = createPluginExtensionRegistry([
{ pluginId, extensionConfigs: [{ ...link1, title: '', description: '' }, link2] },
]);
expect(Object.getOwnPropertyNames(registry)).toEqual([placement2]);
// Placement 2 (checking if it still registers the extension with a valid configuration)
expect(registry[placement2]).toHaveLength(1);
expect(registry[placement2]).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId,
config: {
...link2,
configure: expect.any(Function),
},
}),
])
);
});
});

View File

@ -1,39 +0,0 @@
import type { PluginPreloadResult } from '../pluginPreloader';
import type { PluginExtensionRegistryItem, PluginExtensionRegistry } from './types';
import { deepFreeze, logWarning } from './utils';
import { isPluginExtensionConfigValid } from './validators';
export function createPluginExtensionRegistry(pluginPreloadResults: PluginPreloadResult[]): PluginExtensionRegistry {
const registry: PluginExtensionRegistry = {};
for (const { pluginId, extensionConfigs, error } of pluginPreloadResults) {
if (error) {
logWarning(`"${pluginId}" plugin failed to load, skip registering its extensions.`);
continue;
}
for (const extensionConfig of extensionConfigs) {
const { extensionPointId } = extensionConfig;
if (!extensionConfig || !isPluginExtensionConfigValid(pluginId, extensionConfig)) {
continue;
}
let registryItem: PluginExtensionRegistryItem = {
config: extensionConfig,
// Additional meta information about the extension
pluginId,
};
if (!Array.isArray(registry[extensionPointId])) {
registry[extensionPointId] = [registryItem];
} else {
registry[extensionPointId].push(registryItem);
}
}
}
return deepFreeze(registry);
}

View File

@ -3,8 +3,8 @@ import React from 'react';
import { PluginExtensionComponentConfig, PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data'; import { PluginExtensionComponentConfig, PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime'; import { reportInteraction } from '@grafana/runtime';
import { createPluginExtensionRegistry } from './createPluginExtensionRegistry';
import { getPluginExtensions } from './getPluginExtensions'; import { getPluginExtensions } from './getPluginExtensions';
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
import { isReadOnlyProxy } from './utils'; import { isReadOnlyProxy } from './utils';
import { assertPluginExtensionLink } from './validators'; import { assertPluginExtensionLink } from './validators';
@ -15,6 +15,19 @@ jest.mock('@grafana/runtime', () => {
}; };
}); });
function createPluginExtensionRegistry(preloadResults: Array<{ pluginId: string; extensionConfigs: any[] }>) {
const registry = new ReactivePluginExtensionsRegistry();
for (const { pluginId, extensionConfigs } of preloadResults) {
registry.register({
pluginId,
extensionConfigs,
});
}
return registry.getRegistry();
}
describe('getPluginExtensions()', () => { describe('getPluginExtensions()', () => {
const extensionPoint1 = 'grafana/dashboard/panel/menu'; const extensionPoint1 = 'grafana/dashboard/panel/menu';
const extensionPoint2 = 'plugins/myorg-basic-app/start'; const extensionPoint2 = 'plugins/myorg-basic-app/start';
@ -54,8 +67,8 @@ describe('getPluginExtensions()', () => {
jest.mocked(reportInteraction).mockReset(); jest.mocked(reportInteraction).mockReset();
}); });
test('should return the extensions for the given placement', () => { test('should return the extensions for the given placement', async () => {
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]); const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint1 }); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint1 });
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);
@ -70,9 +83,11 @@ describe('getPluginExtensions()', () => {
); );
}); });
test('should not limit the number of extensions per plugin by default', () => { test('should not limit the number of extensions per plugin by default', async () => {
// Registering 3 extensions for the same plugin for the same placement // Registering 3 extensions for the same plugin for the same placement
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link1, link1, link2] }]); const registry = await createPluginExtensionRegistry([
{ pluginId, extensionConfigs: [link1, link1, link1, link2] },
]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint1 }); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint1 });
expect(extensions).toHaveLength(3); expect(extensions).toHaveLength(3);
@ -87,8 +102,8 @@ describe('getPluginExtensions()', () => {
); );
}); });
test('should be possible to limit the number of extensions per plugin for a given placement', () => { test('should be possible to limit the number of extensions per plugin for a given placement', async () => {
const registry = createPluginExtensionRegistry([ const registry = await createPluginExtensionRegistry([
{ pluginId, extensionConfigs: [link1, link1, link1, link2] }, { pluginId, extensionConfigs: [link1, link1, link1, link2] },
{ {
pluginId: 'my-plugin', pluginId: 'my-plugin',
@ -116,16 +131,16 @@ describe('getPluginExtensions()', () => {
); );
}); });
test('should return with an empty list if there are no extensions registered for a placement yet', () => { test('should return with an empty list if there are no extensions registered for a placement yet', async () => {
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]); const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: 'placement-with-no-extensions' }); const { extensions } = getPluginExtensions({ registry, extensionPointId: 'placement-with-no-extensions' });
expect(extensions).toEqual([]); expect(extensions).toEqual([]);
}); });
test('should pass the context to the configure() function', () => { test('should pass the context to the configure() function', async () => {
const context = { title: 'New title from the context!' }; const context = { title: 'New title from the context!' };
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 }); getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 });
@ -133,7 +148,7 @@ describe('getPluginExtensions()', () => {
expect(link2.configure).toHaveBeenCalledWith(context); expect(link2.configure).toHaveBeenCalledWith(context);
}); });
test('should be possible to update the basic properties with the configure() function', () => { test('should be possible to update the basic properties with the configure() function', async () => {
link2.configure = jest.fn().mockImplementation(() => ({ link2.configure = jest.fn().mockImplementation(() => ({
title: 'Updated title', title: 'Updated title',
description: 'Updated description', description: 'Updated description',
@ -142,7 +157,7 @@ describe('getPluginExtensions()', () => {
category: 'Machine Learning', category: 'Machine Learning',
})); }));
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
const [extension] = extensions; const [extension] = extensions;
@ -156,7 +171,7 @@ describe('getPluginExtensions()', () => {
expect(extension.category).toBe('Machine Learning'); expect(extension.category).toBe('Machine Learning');
}); });
test('should append link tracking to path when running configure() function', () => { test('should append link tracking to path when running configure() function', async () => {
link2.configure = jest.fn().mockImplementation(() => ({ link2.configure = jest.fn().mockImplementation(() => ({
title: 'Updated title', title: 'Updated title',
description: 'Updated description', description: 'Updated description',
@ -165,7 +180,7 @@ describe('getPluginExtensions()', () => {
category: 'Machine Learning', category: 'Machine Learning',
})); }));
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
const [extension] = extensions; const [extension] = extensions;
@ -177,7 +192,7 @@ describe('getPluginExtensions()', () => {
); );
}); });
test('should ignore restricted properties passed via the configure() function', () => { test('should ignore restricted properties passed via the configure() function', async () => {
link2.configure = jest.fn().mockImplementation(() => ({ link2.configure = jest.fn().mockImplementation(() => ({
// The following props are not allowed to override // The following props are not allowed to override
type: 'unknown-type', type: 'unknown-type',
@ -190,7 +205,7 @@ describe('getPluginExtensions()', () => {
title: 'test', title: 'test',
})); }));
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
const [extension] = extensions; const [extension] = extensions;
@ -202,9 +217,9 @@ describe('getPluginExtensions()', () => {
//@ts-ignore //@ts-ignore
expect(extension.testing).toBeUndefined(); expect(extension.testing).toBeUndefined();
}); });
test('should pass a read only context to the configure() function', () => { test('should pass a read only context to the configure() function', async () => {
const context = { title: 'New title from the context!' }; const context = { title: 'New title from the context!' };
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 }); const { extensions } = getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 });
const [extension] = extensions; const [extension] = extensions;
const readOnlyContext = (link2.configure as jest.Mock).mock.calls[0][0]; const readOnlyContext = (link2.configure as jest.Mock).mock.calls[0][0];
@ -219,12 +234,12 @@ describe('getPluginExtensions()', () => {
expect(context.title).toBe('New title from the context!'); expect(context.title).toBe('New title from the context!');
}); });
test('should catch errors in the configure() function and log them as warnings', () => { test('should catch errors in the configure() function and log them as warnings', async () => {
link2.configure = jest.fn().mockImplementation(() => { link2.configure = jest.fn().mockImplementation(() => {
throw new Error('Something went wrong!'); throw new Error('Something went wrong!');
}); });
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
expect(() => { expect(() => {
getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
@ -235,7 +250,7 @@ describe('getPluginExtensions()', () => {
expect(global.console.warn).toHaveBeenCalledWith('[Plugin Extensions] Something went wrong!'); expect(global.console.warn).toHaveBeenCalledWith('[Plugin Extensions] Something went wrong!');
}); });
test('should skip the link extension if the configure() function returns with an invalid path', () => { test('should skip the link extension if the configure() function returns with an invalid path', async () => {
link1.configure = jest.fn().mockImplementation(() => ({ link1.configure = jest.fn().mockImplementation(() => ({
path: '/a/another-plugin/page-a', path: '/a/another-plugin/page-a',
})); }));
@ -243,7 +258,7 @@ describe('getPluginExtensions()', () => {
path: 'invalid-path', path: 'invalid-path',
})); }));
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]); const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]);
const { extensions: extensionsAtPlacement1 } = getPluginExtensions({ registry, extensionPointId: extensionPoint1 }); const { extensions: extensionsAtPlacement1 } = getPluginExtensions({ registry, extensionPointId: extensionPoint1 });
const { extensions: extensionsAtPlacement2 } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); const { extensions: extensionsAtPlacement2 } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
@ -255,7 +270,7 @@ describe('getPluginExtensions()', () => {
expect(global.console.warn).toHaveBeenCalledTimes(2); expect(global.console.warn).toHaveBeenCalledTimes(2);
}); });
test('should skip the extension if any of the updated props returned by the configure() function are invalid', () => { test('should skip the extension if any of the updated props returned by the configure() function are invalid', async () => {
const overrides = { const overrides = {
title: '', // Invalid empty string for title - should be ignored title: '', // Invalid empty string for title - should be ignored
description: 'A valid description.', // This should be updated description: 'A valid description.', // This should be updated
@ -263,7 +278,7 @@ describe('getPluginExtensions()', () => {
link2.configure = jest.fn().mockImplementation(() => overrides); link2.configure = jest.fn().mockImplementation(() => overrides);
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
expect(extensions).toHaveLength(0); expect(extensions).toHaveLength(0);
@ -271,10 +286,10 @@ describe('getPluginExtensions()', () => {
expect(global.console.warn).toHaveBeenCalledTimes(1); expect(global.console.warn).toHaveBeenCalledTimes(1);
}); });
test('should skip the extension if the configure() function returns a promise', () => { test('should skip the extension if the configure() function returns a promise', async () => {
link2.configure = jest.fn().mockImplementation(() => Promise.resolve({})); link2.configure = jest.fn().mockImplementation(() => Promise.resolve({}));
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
expect(extensions).toHaveLength(0); expect(extensions).toHaveLength(0);
@ -282,24 +297,24 @@ describe('getPluginExtensions()', () => {
expect(global.console.warn).toHaveBeenCalledTimes(1); expect(global.console.warn).toHaveBeenCalledTimes(1);
}); });
test('should skip (hide) the extension if the configure() function returns undefined', () => { test('should skip (hide) the extension if the configure() function returns undefined', async () => {
link2.configure = jest.fn().mockImplementation(() => undefined); link2.configure = jest.fn().mockImplementation(() => undefined);
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
expect(extensions).toHaveLength(0); expect(extensions).toHaveLength(0);
expect(global.console.warn).toHaveBeenCalledTimes(0); // As this is intentional, no warning should be logged expect(global.console.warn).toHaveBeenCalledTimes(0); // As this is intentional, no warning should be logged
}); });
test('should pass event, context and helper to extension onClick()', () => { test('should pass event, context and helper to extension onClick()', async () => {
link2.path = undefined; link2.path = undefined;
link2.onClick = jest.fn().mockImplementation(() => { link2.onClick = jest.fn().mockImplementation(() => {
throw new Error('Something went wrong!'); throw new Error('Something went wrong!');
}); });
const context = {}; const context = {};
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
const [extension] = extensions; const [extension] = extensions;
@ -322,7 +337,7 @@ describe('getPluginExtensions()', () => {
link2.path = undefined; link2.path = undefined;
link2.onClick = jest.fn().mockRejectedValue(new Error('testing')); link2.onClick = jest.fn().mockRejectedValue(new Error('testing'));
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
const [extension] = extensions; const [extension] = extensions;
@ -335,13 +350,13 @@ describe('getPluginExtensions()', () => {
expect(global.console.warn).toHaveBeenCalledTimes(1); expect(global.console.warn).toHaveBeenCalledTimes(1);
}); });
test('should catch errors in the onClick() function and log them as warnings', () => { test('should catch errors in the onClick() function and log them as warnings', async () => {
link2.path = undefined; link2.path = undefined;
link2.onClick = jest.fn().mockImplementation(() => { link2.onClick = jest.fn().mockImplementation(() => {
throw new Error('Something went wrong!'); throw new Error('Something went wrong!');
}); });
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
const [extension] = extensions; const [extension] = extensions;
@ -353,13 +368,13 @@ describe('getPluginExtensions()', () => {
expect(global.console.warn).toHaveBeenCalledWith('[Plugin Extensions] Something went wrong!'); expect(global.console.warn).toHaveBeenCalledWith('[Plugin Extensions] Something went wrong!');
}); });
test('should pass a read only context to the onClick() function', () => { test('should pass a read only context to the onClick() function', async () => {
const context = { title: 'New title from the context!' }; const context = { title: 'New title from the context!' };
link2.path = undefined; link2.path = undefined;
link2.onClick = jest.fn(); link2.onClick = jest.fn();
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
const { extensions } = getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 }); const { extensions } = getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 });
const [extension] = extensions; const [extension] = extensions;
@ -375,14 +390,14 @@ describe('getPluginExtensions()', () => {
}).toThrow(); }).toThrow();
}); });
test('should not make original context read only', () => { test('should not make original context read only', async () => {
const context = { const context = {
title: 'New title from the context!', title: 'New title from the context!',
nested: { title: 'title' }, nested: { title: 'title' },
array: ['a'], array: ['a'],
}; };
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 }); getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 });
expect(() => { expect(() => {
@ -392,10 +407,10 @@ describe('getPluginExtensions()', () => {
}).not.toThrow(); }).not.toThrow();
}); });
test('should report interaction when onClick is triggered', () => { test('should report interaction when onClick is triggered', async () => {
const reportInteractionMock = jest.mocked(reportInteraction); const reportInteractionMock = jest.mocked(reportInteraction);
const registry = createPluginExtensionRegistry([ const registry = await createPluginExtensionRegistry([
{ {
pluginId, pluginId,
extensionConfigs: [ extensionConfigs: [
@ -423,9 +438,9 @@ describe('getPluginExtensions()', () => {
}); });
}); });
test('should be possible to register and get component type extensions', () => { test('should be possible to register and get component type extensions', async () => {
const extension = component1; const extension = component1;
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [extension] }]); const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [extension] }]);
const { extensions } = getPluginExtensions({ registry, extensionPointId: extension.extensionPointId }); const { extensions } = getPluginExtensions({ registry, extensionPointId: extension.extensionPointId });
expect(extensions).toHaveLength(1); expect(extensions).toHaveLength(1);

View File

@ -8,8 +8,9 @@ import {
type PluginExtensionComponent, type PluginExtensionComponent,
urlUtil, urlUtil,
} from '@grafana/data'; } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime'; import { GetPluginExtensions, reportInteraction } from '@grafana/runtime';
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
import type { PluginExtensionRegistry } from './types'; import type { PluginExtensionRegistry } from './types';
import { import {
isPluginExtensionLinkConfig, isPluginExtensionLinkConfig,
@ -40,10 +41,22 @@ type GetExtensions = ({
registry: PluginExtensionRegistry; registry: PluginExtensionRegistry;
}) => { extensions: PluginExtension[] }; }) => { extensions: PluginExtension[] };
let registry: PluginExtensionRegistry = { id: '', extensions: {} };
export function createPluginExtensionsGetter(extensionRegistry: ReactivePluginExtensionsRegistry): GetPluginExtensions {
// Create a subscription to keep an copy of the registry state for use in the non-async
// plugin extensions getter.
extensionRegistry.asObservable().subscribe((r) => {
registry = r;
});
return (options) => getPluginExtensions({ ...options, registry });
}
// Returns with a list of plugin extensions for the given extension point // Returns with a list of plugin extensions for the given extension point
export const getPluginExtensions: GetExtensions = ({ context, extensionPointId, limitPerPlugin, registry }) => { export const getPluginExtensions: GetExtensions = ({ context, extensionPointId, limitPerPlugin, registry }) => {
const frozenContext = context ? getReadOnlyProxy(context) : {}; const frozenContext = context ? getReadOnlyProxy(context) : {};
const registryItems = registry[extensionPointId] ?? []; const registryItems = registry.extensions[extensionPointId] ?? [];
// 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. // 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 extensions: PluginExtension[] = [];
const extensionsByPlugin: Record<string, number> = {}; const extensionsByPlugin: Record<string, number> = {};

View File

@ -0,0 +1,682 @@
import { firstValueFrom } from 'rxjs';
import { PluginExtensionTypes } from '@grafana/data';
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
describe('createPluginExtensionsRegistry', () => {
const consoleWarn = jest.fn();
beforeEach(() => {
global.console.warn = consoleWarn;
consoleWarn.mockReset();
});
it('should return empty registry when no extensions registered', async () => {
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
const observable = reactiveRegistry.asObservable();
const registry = await firstValueFrom(observable);
expect(registry).toEqual({
id: '',
extensions: {},
});
});
it('should generate an id for the registry once we register an extension to it', async () => {
const pluginId = 'grafana-basic-app';
const extensionPointId = 'grafana/dashboard/panel/menu';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
reactiveRegistry.register({
pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId,
configure: jest.fn().mockReturnValue({}),
},
],
});
const registry = await reactiveRegistry.getRegistry();
expect(registry.id).toBeDefined();
expect(registry.extensions[extensionPointId]).toHaveLength(1);
});
it('should generate an a new id every time the registry changes', async () => {
const pluginId = 'grafana-basic-app';
const extensionPointId = 'grafana/dashboard/panel/menu';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
reactiveRegistry.register({
pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId,
configure: jest.fn().mockReturnValue({}),
},
],
});
const registry1 = await reactiveRegistry.getRegistry();
const id1 = registry1.id;
expect(id1).toBeDefined();
reactiveRegistry.register({
pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId,
configure: jest.fn().mockReturnValue({}),
},
],
});
const registry2 = await reactiveRegistry.getRegistry();
const id2 = registry2.id;
expect(id2).toBeDefined();
expect(id2).not.toEqual(id1);
});
it('should be possible to register extensions in the registry', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
reactiveRegistry.register({
pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
{
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'plugins/myorg-basic-app/start',
configure: jest.fn().mockImplementation((context) => ({ title: context?.title })),
},
],
});
const registry = await reactiveRegistry.getRegistry();
expect(registry.extensions).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId,
config: {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
],
'plugins/myorg-basic-app/start': [
{
pluginId: pluginId,
config: {
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'plugins/myorg-basic-app/start',
configure: expect.any(Function),
},
},
],
});
});
it('should be possible to asynchronously register extensions for the same placement (different plugins)', async () => {
const pluginId1 = 'grafana-basic-app';
const pluginId2 = 'grafana-basic-app2';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
// Register extensions for the first plugin
reactiveRegistry.register({
pluginId: pluginId1,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId1}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
const registry1 = await reactiveRegistry.getRegistry();
expect(registry1.extensions).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId1,
config: {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId1}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
],
});
// Register extensions for the second plugin to a different placement
reactiveRegistry.register({
pluginId: pluginId2,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId2}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
const registry2 = await reactiveRegistry.getRegistry();
expect(registry2.extensions).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId1,
config: {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId1}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
{
pluginId: pluginId2,
config: {
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId2}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
],
});
});
it('should be possible to asynchronously register extensions for a different placement (different plugin)', async () => {
const pluginId1 = 'grafana-basic-app';
const pluginId2 = 'grafana-basic-app2';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
// Register extensions for the first plugin
reactiveRegistry.register({
pluginId: pluginId1,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId1}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
const registry1 = await reactiveRegistry.getRegistry();
expect(registry1.extensions).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId1,
config: {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId1}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
],
});
// Register extensions for the second plugin to a different placement
reactiveRegistry.register({
pluginId: pluginId2,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId2}/declare-incident`,
extensionPointId: 'plugins/myorg-basic-app/start',
configure: jest.fn().mockReturnValue({}),
},
],
});
const registry2 = await reactiveRegistry.getRegistry();
expect(registry2.extensions).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId1,
config: {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId1}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
],
'plugins/myorg-basic-app/start': [
{
pluginId: pluginId2,
config: {
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId2}/declare-incident`,
extensionPointId: 'plugins/myorg-basic-app/start',
configure: expect.any(Function),
},
},
],
});
});
it('should be possible to asynchronously register extensions for the same placement (same plugin)', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
// Register extensions for the first extension point
reactiveRegistry.register({
pluginId: pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident-1`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
// Register extensions to a different extension point
reactiveRegistry.register({
pluginId: pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident-2`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
const registry2 = await reactiveRegistry.getRegistry();
expect(registry2.extensions).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId,
config: {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident-1`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
{
pluginId: pluginId,
config: {
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident-2`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
],
});
});
it('should be possible to asynchronously register extensions for a different placement (same plugin)', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
// Register extensions for the first extension point
reactiveRegistry.register({
pluginId: pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
// Register extensions to a different extension point
reactiveRegistry.register({
pluginId: pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'plugins/myorg-basic-app/start',
configure: jest.fn().mockReturnValue({}),
},
],
});
const registry2 = await reactiveRegistry.getRegistry();
expect(registry2.extensions).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId,
config: {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
],
'plugins/myorg-basic-app/start': [
{
pluginId: pluginId,
config: {
type: PluginExtensionTypes.link,
title: 'Link 2',
description: 'Link 2 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'plugins/myorg-basic-app/start',
configure: expect.any(Function),
},
},
],
});
});
it('should notify subscribers when the registry changes', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
observable.subscribe(subscribeCallback);
// Register extensions for the first plugin
reactiveRegistry.register({
pluginId: pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
expect(subscribeCallback).toHaveBeenCalledTimes(2);
// Register extensions for the first plugin
reactiveRegistry.register({
pluginId: 'another-plugin',
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/another-plugin/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
expect(subscribeCallback).toHaveBeenCalledTimes(3);
const registry = subscribeCallback.mock.calls[2][0];
expect(registry.extensions).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId,
config: {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
{
pluginId: 'another-plugin',
config: {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/another-plugin/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
],
});
});
it('should give the last version of the registry for new subscribers', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
reactiveRegistry.register({
pluginId: pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
observable.subscribe(subscribeCallback);
expect(subscribeCallback).toHaveBeenCalledTimes(1);
const registry = subscribeCallback.mock.calls[0][0];
expect(registry.extensions).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId,
config: {
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: expect.any(Function),
},
},
],
});
});
it('should not register extensions for a plugin that had errors', () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
reactiveRegistry.register({
error: new Error('Something is broken'),
pluginId: pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
expect(consoleWarn).toHaveBeenCalled();
observable.subscribe(subscribeCallback);
expect(subscribeCallback).toHaveBeenCalledTimes(1);
const registry = subscribeCallback.mock.calls[0][0];
expect(registry.extensions).toEqual({});
});
it('should not register an extension if it has an invalid configure() function', () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
reactiveRegistry.register({
pluginId: pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Link 1',
description: 'Link 1 description',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
//@ts-ignore
configure: '...',
},
],
});
expect(consoleWarn).toHaveBeenCalled();
observable.subscribe(subscribeCallback);
expect(subscribeCallback).toHaveBeenCalledTimes(1);
const registry = subscribeCallback.mock.calls[0][0];
expect(registry.extensions).toEqual({});
});
it('should not register an extension if it has invalid properties (empty title / description)', () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
reactiveRegistry.register({
pluginId: pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: '',
description: '',
path: `/a/${pluginId}/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
expect(consoleWarn).toHaveBeenCalled();
observable.subscribe(subscribeCallback);
expect(subscribeCallback).toHaveBeenCalledTimes(1);
const registry = subscribeCallback.mock.calls[0][0];
expect(registry.extensions).toEqual({});
});
it('should not register link extensions with invalid path configured', () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new ReactivePluginExtensionsRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
reactiveRegistry.register({
pluginId: pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
title: 'Title 1',
description: 'Description 1',
path: `/a/another-plugin/declare-incident`,
extensionPointId: 'grafana/dashboard/panel/menu',
configure: jest.fn().mockReturnValue({}),
},
],
});
expect(consoleWarn).toHaveBeenCalled();
observable.subscribe(subscribeCallback);
expect(subscribeCallback).toHaveBeenCalledTimes(1);
const registry = subscribeCallback.mock.calls[0][0];
expect(registry.extensions).toEqual({});
});
});

View File

@ -0,0 +1,79 @@
import { Observable, ReplaySubject, Subject, firstValueFrom, map, scan, startWith } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { PluginPreloadResult } from '../pluginPreloader';
import { PluginExtensionRegistry, PluginExtensionRegistryItem } from './types';
import { deepFreeze, logWarning } from './utils';
import { isPluginExtensionConfigValid } from './validators';
export class ReactivePluginExtensionsRegistry {
private resultSubject: Subject<PluginPreloadResult>;
private registrySubject: ReplaySubject<PluginExtensionRegistry>;
constructor() {
this.resultSubject = new Subject<PluginPreloadResult>();
// 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<PluginExtensionRegistry>(1);
this.resultSubject
.pipe(
scan(resultsToRegistry, { id: '', extensions: {} }),
// 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({ id: '', extensions: {} }),
map((registry) => deepFreeze(registry))
)
// Emitting the new registry to `this.registrySubject`
.subscribe(this.registrySubject);
}
register(result: PluginPreloadResult): void {
this.resultSubject.next(result);
}
asObservable(): Observable<PluginExtensionRegistry> {
return this.registrySubject.asObservable();
}
getRegistry(): Promise<PluginExtensionRegistry> {
return firstValueFrom(this.asObservable());
}
}
function resultsToRegistry(registry: PluginExtensionRegistry, result: PluginPreloadResult): PluginExtensionRegistry {
const { pluginId, extensionConfigs, error } = result;
// TODO: We should probably move this section to where we load the plugin since this is only used
// to provide a log to the user.
if (error) {
logWarning(`"${pluginId}" plugin failed to load, skip registering its extensions.`);
return registry;
}
for (const extensionConfig of extensionConfigs) {
const { extensionPointId } = extensionConfig;
if (!extensionConfig || !isPluginExtensionConfigValid(pluginId, extensionConfig)) {
return registry;
}
let registryItem: PluginExtensionRegistryItem = {
config: extensionConfig,
// Additional meta information about the extension
pluginId,
};
if (!Array.isArray(registry.extensions[extensionPointId])) {
registry.extensions[extensionPointId] = [registryItem];
} else {
registry.extensions[extensionPointId].push(registryItem);
}
}
// Add a unique ID to the registry (the registry object itself is immutable)
registry.id = uuidv4();
return registry;
}

View File

@ -9,4 +9,7 @@ export type PluginExtensionRegistryItem = {
}; };
// A map of placement names to a list of extensions // A map of placement names to a list of extensions
export type PluginExtensionRegistry = Record<string, PluginExtensionRegistryItem[]>; export type PluginExtensionRegistry = {
id: string;
extensions: Record<string, PluginExtensionRegistryItem[]>;
};

View File

@ -0,0 +1,225 @@
import { act } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { PluginExtensionTypes } from '@grafana/data';
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
import { createPluginExtensionsHook } from './usePluginExtensions';
describe('usePluginExtensions()', () => {
let reactiveRegistry: ReactivePluginExtensionsRegistry;
beforeEach(() => {
reactiveRegistry = new ReactivePluginExtensionsRegistry();
});
it('should return an empty array if there are no extensions registered for the extension point', () => {
const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry);
const { result } = renderHook(() =>
usePluginExtensions({
extensionPointId: 'foo/bar',
})
);
expect(result.current.extensions).toEqual([]);
});
it('should return the plugin extensions from the registry', () => {
const extensionPointId = 'plugins/foo/bar';
const pluginId = 'my-app-plugin';
reactiveRegistry.register({
pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
extensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
},
{
type: PluginExtensionTypes.link,
extensionPointId,
title: '2',
description: '2',
path: `/a/${pluginId}/2`,
},
],
});
const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry);
const { result } = renderHook(() => usePluginExtensions({ extensionPointId }));
expect(result.current.extensions.length).toBe(2);
expect(result.current.extensions[0].title).toBe('1');
expect(result.current.extensions[1].title).toBe('2');
});
it('should dynamically update the extensions registered for a certain extension point', () => {
const extensionPointId = 'plugins/foo/bar';
const pluginId = 'my-app-plugin';
const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry);
let { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId }));
// No extensions yet
expect(result.current.extensions.length).toBe(0);
// Add extensions to the registry
act(() => {
reactiveRegistry.register({
pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
extensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
},
{
type: PluginExtensionTypes.link,
extensionPointId,
title: '2',
description: '2',
path: `/a/${pluginId}/2`,
},
],
});
});
// Check if the hook returns the new extensions
rerender();
expect(result.current.extensions.length).toBe(2);
expect(result.current.extensions[0].title).toBe('1');
expect(result.current.extensions[1].title).toBe('2');
});
it('should only render the hook once', () => {
const spy = jest.spyOn(reactiveRegistry, 'asObservable');
const extensionPointId = 'plugins/foo/bar';
const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry);
renderHook(() => usePluginExtensions({ extensionPointId }));
expect(spy).toHaveBeenCalledTimes(1);
});
it('should return the same extensions object if the context object is the same', () => {
const extensionPointId = 'plugins/foo/bar';
const pluginId = 'my-app-plugin';
const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry);
// Add extensions to the registry
act(() => {
reactiveRegistry.register({
pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
extensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
},
{
type: PluginExtensionTypes.link,
extensionPointId,
title: '2',
description: '2',
path: `/a/${pluginId}/2`,
},
],
});
});
// Check if it returns the same extensions object in case nothing changes
const context = {};
const firstResults = renderHook(() => usePluginExtensions({ extensionPointId, context }));
const secondResults = renderHook(() => usePluginExtensions({ extensionPointId, context }));
expect(firstResults.result.current.extensions === secondResults.result.current.extensions).toBe(true);
});
it('should return a new extensions object if the context object is different', () => {
const extensionPointId = 'plugins/foo/bar';
const pluginId = 'my-app-plugin';
const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry);
// Add extensions to the registry
act(() => {
reactiveRegistry.register({
pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
extensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
},
{
type: PluginExtensionTypes.link,
extensionPointId,
title: '2',
description: '2',
path: `/a/${pluginId}/2`,
},
],
});
});
// Check if it returns a different extensions object in case the context object changes
const firstResults = renderHook(() => usePluginExtensions({ extensionPointId, context: {} }));
const secondResults = renderHook(() => usePluginExtensions({ extensionPointId, context: {} }));
expect(firstResults.result.current.extensions === secondResults.result.current.extensions).toBe(false);
});
it('should return a new extensions object if the registry changes but the context object is the same', () => {
const extensionPointId = 'plugins/foo/bar';
const pluginId = 'my-app-plugin';
const context = {};
const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry);
// Add the first extension
act(() => {
reactiveRegistry.register({
pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
extensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
},
],
});
});
const { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId, context }));
const firstExtensions = result.current.extensions;
// Add the second extension
act(() => {
reactiveRegistry.register({
pluginId,
extensionConfigs: [
{
type: PluginExtensionTypes.link,
extensionPointId,
// extensionPointId: 'plugins/foo/bar/zed', // A different extension point (to be sure that it's also returning a new object when the actual extension point doesn't change)
title: '2',
description: '2',
path: `/a/${pluginId}/2`,
},
],
});
});
rerender();
const secondExtensions = result.current.extensions;
expect(firstExtensions === secondExtensions).toBe(false);
});
});

View File

@ -0,0 +1,54 @@
import { useObservable } from 'react-use';
import { PluginExtension } from '@grafana/data';
import { GetPluginExtensionsOptions, UsePluginExtensionsResult } from '@grafana/runtime';
import { getPluginExtensions } from './getPluginExtensions';
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
export function createPluginExtensionsHook(extensionsRegistry: ReactivePluginExtensionsRegistry) {
const observableRegistry = extensionsRegistry.asObservable();
const cache: {
id: string;
extensions: Record<string, { context: GetPluginExtensionsOptions['context']; extensions: PluginExtension[] }>;
} = {
id: '',
extensions: {},
};
return function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult<PluginExtension> {
const registry = useObservable(observableRegistry);
if (!registry) {
return { extensions: [], isLoading: false };
}
if (registry.id !== cache.id) {
cache.id = registry.id;
cache.extensions = {};
}
// `getPluginExtensions` will return a new array of objects even if it is called with the same options, as it always constructing a frozen objects.
// Due to this we are caching the result of `getPluginExtensions` to avoid unnecessary re-renders for components that are using this hook.
// (NOTE: we are only checking referential equality of `context` object, so it is important to not mutate the object passed to this hook.)
const key = `${options.extensionPointId}-${options.limitPerPlugin}`;
if (cache.extensions[key] && cache.extensions[key].context === options.context) {
return {
extensions: cache.extensions[key].extensions,
isLoading: false,
};
}
const { extensions } = getPluginExtensions({ ...options, registry });
cache.extensions[key] = {
context: options.context,
extensions,
};
return {
extensions,
isLoading: false,
};
};
}

View File

@ -3,6 +3,7 @@ import type { AppPluginConfig } from '@grafana/runtime';
import { startMeasure, stopMeasure } from 'app/core/utils/metrics'; import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
import { getPluginSettings } from 'app/features/plugins/pluginSettings'; import { getPluginSettings } from 'app/features/plugins/pluginSettings';
import { ReactivePluginExtensionsRegistry } from './extensions/reactivePluginExtensionRegistry';
import * as pluginLoader from './plugin_loader'; import * as pluginLoader from './plugin_loader';
export type PluginPreloadResult = { export type PluginPreloadResult = {
@ -11,12 +12,16 @@ export type PluginPreloadResult = {
extensionConfigs: PluginExtensionConfig[]; extensionConfigs: PluginExtensionConfig[];
}; };
export async function preloadPlugins(apps: Record<string, AppPluginConfig> = {}): Promise<PluginPreloadResult[]> { export async function preloadPlugins(apps: AppPluginConfig[] = [], registry: ReactivePluginExtensionsRegistry) {
startMeasure('frontend_plugins_preload'); startMeasure('frontend_plugins_preload');
const pluginsToPreload = Object.values(apps).filter((app) => app.preload); const promises = apps.filter((config) => config.preload).map((config) => preload(config));
const result = await Promise.all(pluginsToPreload.map(preload)); const preloadedPlugins = await Promise.all(promises);
for (const preloadedPlugin of preloadedPlugins) {
registry.register(preloadedPlugin);
}
stopMeasure('frontend_plugins_preload'); stopMeasure('frontend_plugins_preload');
return result;
} }
async function preload(config: AppPluginConfig): Promise<PluginPreloadResult> { async function preload(config: AppPluginConfig): Promise<PluginPreloadResult> {

View File

@ -4,7 +4,7 @@ import React from 'react';
import { OrgRole, PluginExtensionComponent, PluginExtensionTypes } from '@grafana/data'; import { OrgRole, PluginExtensionComponent, PluginExtensionTypes } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { setPluginExtensionGetter, GetPluginExtensions } from '@grafana/runtime'; import { setPluginExtensionsHook, UsePluginExtensions } from '@grafana/runtime';
import * as useQueryParams from 'app/core/hooks/useQueryParams'; import * as useQueryParams from 'app/core/hooks/useQueryParams';
import { TestProvider } from '../../../test/helpers/TestProvider'; import { TestProvider } from '../../../test/helpers/TestProvider';
@ -170,9 +170,11 @@ async function getTestContext(overrides: Partial<Props & { extensions: PluginExt
.mockResolvedValue({ timezone: 'UTC', homeDashboardUID: 'home-dashboard', theme: 'dark' }); .mockResolvedValue({ timezone: 'UTC', homeDashboardUID: 'home-dashboard', theme: 'dark' });
const searchSpy = jest.spyOn(backendSrv, 'search').mockResolvedValue([]); const searchSpy = jest.spyOn(backendSrv, 'search').mockResolvedValue([]);
const getter: GetPluginExtensions<PluginExtensionComponent> = jest.fn().mockReturnValue({ extensions }); const getter: UsePluginExtensions<PluginExtensionComponent> = jest
.fn()
.mockReturnValue({ extensions, isLoading: false });
setPluginExtensionGetter(getter); setPluginExtensionsHook(getter);
const props = { ...defaultProps, ...overrides }; const props = { ...defaultProps, ...overrides };
const { rerender } = render( const { rerender } = render(

View File

@ -1,10 +1,10 @@
import React, { useMemo, useState } from 'react'; import React, { useState } from 'react';
import { connect, ConnectedProps } from 'react-redux'; import { connect, ConnectedProps } from 'react-redux';
import { useMount } from 'react-use'; import { useMount } from 'react-use';
import { PluginExtensionComponent, PluginExtensionPoints } from '@grafana/data'; import { PluginExtensionComponent, PluginExtensionPoints } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { getPluginComponentExtensions } from '@grafana/runtime'; import { usePluginComponentExtensions } from '@grafana/runtime';
import { Tab, TabsBar, TabContent, Stack } from '@grafana/ui'; import { Tab, TabsBar, TabContent, Stack } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page'; import { Page } from 'app/core/components/Page/Page';
import SharedPreferences from 'app/core/components/SharedPreferences/SharedPreferences'; import SharedPreferences from 'app/core/components/SharedPreferences/SharedPreferences';
@ -76,30 +76,21 @@ export function UserProfileEditPage({
useMount(() => initUserProfilePage()); useMount(() => initUserProfilePage());
const extensionComponents = useMemo(() => { const { extensions } = usePluginComponentExtensions({ extensionPointId: PluginExtensionPoints.UserProfileTab });
const { extensions } = getPluginComponentExtensions({
extensionPointId: PluginExtensionPoints.UserProfileTab,
});
return extensions; const groupedExtensionComponents = extensions.reduce<Record<string, PluginExtensionComponent[]>>((acc, extension) => {
}, []); const { title } = extension;
if (acc[title]) {
const groupedExtensionComponents = extensionComponents.reduce<Record<string, PluginExtensionComponent[]>>( acc[title].push(extension);
(acc, extension) => { } else {
const { title } = extension; acc[title] = [extension];
if (acc[title]) { }
acc[title].push(extension); return acc;
} else { }, {});
acc[title] = [extension];
}
return acc;
},
{}
);
const convertExtensionComponentTitleToTabId = (title: string) => title.toLowerCase(); const convertExtensionComponentTitleToTabId = (title: string) => title.toLowerCase();
const showTabs = extensionComponents.length > 0; const showTabs = extensions.length > 0;
const tabs: TabInfo[] = [ const tabs: TabInfo[] = [
{ {
id: GENERAL_SETTINGS_TAB, id: GENERAL_SETTINGS_TAB,

View File

@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event';
import React from 'react'; import React from 'react';
import { CoreApp, PluginType } from '@grafana/data'; import { CoreApp, PluginType } from '@grafana/data';
import { setPluginExtensionGetter } from '@grafana/runtime'; import { setPluginExtensionsHook } from '@grafana/runtime';
import { PyroscopeDataSource } from '../datasource'; import { PyroscopeDataSource } from '../datasource';
import { mockFetchPyroscopeDatasourceSettings } from '../datasource.test'; import { mockFetchPyroscopeDatasourceSettings } from '../datasource.test';
@ -13,7 +13,7 @@ import { Props, QueryEditor } from './QueryEditor';
describe('QueryEditor', () => { describe('QueryEditor', () => {
beforeEach(() => { beforeEach(() => {
setPluginExtensionGetter(() => ({ extensions: [] })); // No extensions setPluginExtensionsHook(() => ({ extensions: [], isLoading: false })); // No extensions
mockFetchPyroscopeDatasourceSettings(); mockFetchPyroscopeDatasourceSettings();
}); });

View File

@ -3,7 +3,7 @@ import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { PluginType, rangeUtil, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data'; import { PluginType, rangeUtil, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data';
import { getPluginLinkExtensions } from '@grafana/runtime'; import { usePluginLinkExtensions } from '@grafana/runtime';
import { PyroscopeDataSource } from '../datasource'; import { PyroscopeDataSource } from '../datasource';
import { mockFetchPyroscopeDatasourceSettings } from '../datasource.test'; import { mockFetchPyroscopeDatasourceSettings } from '../datasource.test';
@ -15,8 +15,7 @@ const EXTENSION_POINT_ID = 'plugins/grafana-pyroscope-datasource/query-links';
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
setPluginExtensionGetter: jest.fn(), usePluginLinkExtensions: jest.fn(),
getPluginLinkExtensions: jest.fn(),
getTemplateSrv: () => { getTemplateSrv: () => {
return { return {
replace: (query: string): string => { replace: (query: string): string => {
@ -26,7 +25,7 @@ jest.mock('@grafana/runtime', () => ({
}, },
})); }));
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions); const usePluginLinkExtensionsMock = jest.mocked(usePluginLinkExtensions);
const defaultPyroscopeDataSourceSettings = { const defaultPyroscopeDataSourceSettings = {
uid: 'default-pyroscope', uid: 'default-pyroscope',
@ -60,12 +59,12 @@ describe('PyroscopeQueryLinkExtensions', () => {
resetPyroscopeQueryLinkExtensionsFetches(); resetPyroscopeQueryLinkExtensionsFetches();
mockFetchPyroscopeDatasourceSettings(defaultPyroscopeDataSourceSettings); mockFetchPyroscopeDatasourceSettings(defaultPyroscopeDataSourceSettings);
getPluginLinkExtensionsMock.mockRestore(); usePluginLinkExtensionsMock.mockRestore();
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] }); // Unless stated otherwise, no extensions usePluginLinkExtensionsMock.mockReturnValue({ extensions: [], isLoading: false }); // Unless stated otherwise, no extensions
}); });
it('should render if extension present', async () => { it('should render if extension present', async () => {
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [createExtension()] }); // Default extension usePluginLinkExtensionsMock.mockReturnValue({ extensions: [createExtension()], isLoading: false }); // Default extension
await act(setup); await act(setup);
expect(await screen.findAllByText(EXPECTED_BUTTON_LABEL)).toBeDefined(); expect(await screen.findAllByText(EXPECTED_BUTTON_LABEL)).toBeDefined();

View File

@ -3,7 +3,7 @@ import React from 'react';
import { useAsync } from 'react-use'; import { useAsync } from 'react-use';
import { GrafanaTheme2, QueryEditorProps, TimeRange } from '@grafana/data'; import { GrafanaTheme2, QueryEditorProps, TimeRange } from '@grafana/data';
import { getBackendSrv, getPluginLinkExtensions } from '@grafana/runtime'; import { getBackendSrv, usePluginLinkExtensions } from '@grafana/runtime';
import { LinkButton, useStyles2 } from '@grafana/ui'; import { LinkButton, useStyles2 } from '@grafana/ui';
import { PyroscopeDataSource } from '../datasource'; import { PyroscopeDataSource } from '../datasource';
@ -64,7 +64,7 @@ export function PyroscopeQueryLinkExtensions(props: Props) {
datasourceSettings, datasourceSettings,
}; };
const { extensions } = getPluginLinkExtensions({ const { extensions } = usePluginLinkExtensions({
extensionPointId: EXTENSION_POINT_ID, extensionPointId: EXTENSION_POINT_ID,
context, context,
}); });

View File

@ -6,7 +6,7 @@ import {
PluginType, PluginType,
DataSourceJsonData, DataSourceJsonData,
} from '@grafana/data'; } from '@grafana/data';
import { setPluginExtensionGetter, getBackendSrv, setBackendSrv, getTemplateSrv } from '@grafana/runtime'; import { setPluginExtensionsHook, getBackendSrv, setBackendSrv, getTemplateSrv } from '@grafana/runtime';
import { defaultPyroscopeQueryType } from './dataquery.gen'; import { defaultPyroscopeQueryType } from './dataquery.gen';
import { normalizeQuery, PyroscopeDataSource } from './datasource'; import { normalizeQuery, PyroscopeDataSource } from './datasource';
@ -50,7 +50,7 @@ describe('Pyroscope data source', () => {
let ds: PyroscopeDataSource; let ds: PyroscopeDataSource;
beforeEach(() => { beforeEach(() => {
mockFetchPyroscopeDatasourceSettings(); mockFetchPyroscopeDatasourceSettings();
setPluginExtensionGetter(() => ({ extensions: [] })); // No extensions setPluginExtensionsHook(() => ({ extensions: [], isLoading: false })); // No extensions
ds = new PyroscopeDataSource(defaultSettings); ds = new PyroscopeDataSource(defaultSettings);
}); });

View File

@ -5,7 +5,7 @@ import { Provider } from 'react-redux';
import { byRole, byText } from 'testing-library-selector'; import { byRole, byText } from 'testing-library-selector';
import { FieldConfigSource, getDefaultTimeRange, LoadingState, PanelProps, PluginExtensionTypes } from '@grafana/data'; import { FieldConfigSource, getDefaultTimeRange, LoadingState, PanelProps, PluginExtensionTypes } from '@grafana/data';
import { getPluginLinkExtensions, TimeRangeUpdatedEvent } from '@grafana/runtime'; import { TimeRangeUpdatedEvent, usePluginLinkExtensions } from '@grafana/runtime';
import { setupMswServer } from 'app/features/alerting/unified/mockApi'; import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { mockPromRulesApiResponse } from 'app/features/alerting/unified/mocks/alertRuleApi'; import { mockPromRulesApiResponse } from 'app/features/alerting/unified/mocks/alertRuleApi';
import { mockRulerRulesApiResponse } from 'app/features/alerting/unified/mocks/rulerApi'; import { mockRulerRulesApiResponse } from 'app/features/alerting/unified/mocks/rulerApi';
@ -57,12 +57,12 @@ const grafanaRuleMock = {
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
getPluginLinkExtensions: jest.fn(), usePluginLinkExtensions: jest.fn(),
})); }));
jest.mock('app/features/alerting/unified/api/alertmanager'); jest.mock('app/features/alerting/unified/api/alertmanager');
const mocks = { const mocks = {
getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions), usePluginLinkExtensionsMock: jest.mocked(usePluginLinkExtensions),
}; };
const fakeResponse: PromRulesResponse = { const fakeResponse: PromRulesResponse = {
@ -85,7 +85,7 @@ beforeEach(() => {
mockRulerRulesApiResponse(server, 'grafana', { mockRulerRulesApiResponse(server, 'grafana', {
'folder-one': [{ name: 'group1', interval: '20s', rules: [originRule] }], 'folder-one': [{ name: 'group1', interval: '20s', rules: [originRule] }],
}); });
mocks.getPluginLinkExtensionsMock.mockReturnValue({ mocks.usePluginLinkExtensionsMock.mockReturnValue({
extensions: [ extensions: [
{ {
pluginId: 'grafana-ml-app', pluginId: 'grafana-ml-app',
@ -97,6 +97,7 @@ beforeEach(() => {
onClick: jest.fn(), onClick: jest.fn(),
}, },
], ],
isLoading: false,
}); });
}); });