mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
d48b5ea44d
commit
804c726413
@ -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"]
|
||||||
],
|
],
|
||||||
|
@ -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';
|
||||||
|
@ -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 {
|
||||||
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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]
|
||||||
|
);
|
||||||
|
}
|
@ -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();
|
||||||
|
@ -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 = {
|
||||||
|
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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]);
|
|
||||||
}
|
|
||||||
|
@ -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);
|
||||||
|
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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,
|
||||||
});
|
});
|
||||||
|
@ -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(),
|
|
||||||
}));
|
|
||||||
}
|
|
@ -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];
|
||||||
|
@ -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(() => {
|
||||||
|
@ -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]);
|
||||||
|
}
|
@ -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),
|
||||||
|
@ -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 {
|
||||||
|
@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
@ -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 (
|
||||||
|
@ -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) });
|
||||||
|
@ -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 () => {
|
||||||
|
@ -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]);
|
|
||||||
}
|
|
||||||
|
@ -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
|
||||||
|
@ -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),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
@ -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);
|
|
||||||
}
|
|
@ -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);
|
||||||
|
@ -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> = {};
|
||||||
|
@ -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({});
|
||||||
|
});
|
||||||
|
});
|
@ -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;
|
||||||
|
}
|
@ -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[]>;
|
||||||
|
};
|
||||||
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
@ -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> {
|
||||||
|
@ -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(
|
||||||
|
@ -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,
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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();
|
||||||
|
@ -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,
|
||||||
});
|
});
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user