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": [
|
||||
[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": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[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, "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": [
|
||||
[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,
|
||||
getPluginComponentExtensions,
|
||||
type GetPluginExtensions,
|
||||
type GetPluginExtensionsOptions,
|
||||
type GetPluginExtensionsResult,
|
||||
type UsePluginExtensions,
|
||||
type UsePluginExtensionsResult,
|
||||
} from './pluginExtensions/getPluginExtensions';
|
||||
export {
|
||||
setPluginExtensionsHook,
|
||||
usePluginExtensions,
|
||||
usePluginLinkExtensions,
|
||||
usePluginComponentExtensions,
|
||||
} from './pluginExtensions/usePluginExtensions';
|
||||
export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils';
|
||||
|
@ -2,18 +2,29 @@ import type { PluginExtension, PluginExtensionLink, PluginExtensionComponent } f
|
||||
|
||||
import { isPluginExtensionComponent, isPluginExtensionLink } from './utils';
|
||||
|
||||
export type GetPluginExtensions<T = PluginExtension> = ({
|
||||
extensionPointId,
|
||||
context,
|
||||
limitPerPlugin,
|
||||
}: {
|
||||
export type GetPluginExtensions<T = PluginExtension> = (
|
||||
options: GetPluginExtensionsOptions
|
||||
) => GetPluginExtensionsResult<T>;
|
||||
|
||||
export type UsePluginExtensions<T = PluginExtension> = (
|
||||
options: GetPluginExtensionsOptions
|
||||
) => UsePluginExtensionsResult<T>;
|
||||
|
||||
export type GetPluginExtensionsOptions = {
|
||||
extensionPointId: string;
|
||||
context?: object | Record<string | symbol, unknown>;
|
||||
limitPerPlugin?: number;
|
||||
}) => {
|
||||
};
|
||||
|
||||
export type GetPluginExtensionsResult<T = PluginExtension> = {
|
||||
extensions: T[];
|
||||
};
|
||||
|
||||
export type UsePluginExtensionsResult<T = PluginExtension> = {
|
||||
extensions: T[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
let singleton: GetPluginExtensions | undefined;
|
||||
|
||||
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,
|
||||
setAppEvents,
|
||||
setReturnToPreviousHook,
|
||||
type GetPluginExtensions,
|
||||
setPluginExtensionsHook,
|
||||
} from '@grafana/runtime';
|
||||
import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView';
|
||||
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 { PanelRenderer } from './features/panel/components/PanelRenderer';
|
||||
import { DatasourceSrv } from './features/plugins/datasource_srv';
|
||||
import { createPluginExtensionRegistry } from './features/plugins/extensions/createPluginExtensionRegistry';
|
||||
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 { PluginPreloadResult, preloadPlugins } from './features/plugins/pluginPreloader';
|
||||
import { preloadPlugins } from './features/plugins/pluginPreloader';
|
||||
import { QueryRunner } from './features/query/state/QueryRunner';
|
||||
import { runRequest } from './features/query/state/runRequest';
|
||||
import { initWindowRuntime } from './features/runtime/init';
|
||||
@ -206,24 +207,26 @@ export class GrafanaApp {
|
||||
setDataSourceSrv(dataSourceSrv);
|
||||
initWindowRuntime();
|
||||
|
||||
let preloadResults: PluginPreloadResult[] = [];
|
||||
// Initialize plugin extensions
|
||||
const extensionsRegistry = new ReactivePluginExtensionsRegistry();
|
||||
extensionsRegistry.register({
|
||||
pluginId: 'grafana',
|
||||
extensionConfigs: getCoreExtensionConfigurations(),
|
||||
});
|
||||
|
||||
if (contextSrv.user.orgRole !== '') {
|
||||
// Preload selected app plugins
|
||||
preloadResults = await preloadPlugins(config.apps);
|
||||
// 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.
|
||||
// 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
|
||||
const extensionRegistry = createPluginExtensionRegistry([
|
||||
{ pluginId: 'grafana', extensionConfigs: getCoreExtensionConfigurations() },
|
||||
...preloadResults,
|
||||
]);
|
||||
|
||||
// Expose the getPluginExtension function via grafana-runtime
|
||||
const pluginExtensionGetter: GetPluginExtensions = (options) =>
|
||||
getPluginExtensions({ ...options, registry: extensionRegistry });
|
||||
|
||||
setPluginExtensionGetter(pluginExtensionGetter);
|
||||
setPluginExtensionGetter(createPluginExtensionsGetter(extensionsRegistry));
|
||||
setPluginExtensionsHook(createPluginExtensionsHook(extensionsRegistry));
|
||||
|
||||
// initialize chrome service
|
||||
const queryParams = locationService.getSearchObject();
|
||||
|
@ -16,7 +16,7 @@ import { AppChrome } from './AppChrome';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
|
||||
usePluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
|
||||
}));
|
||||
|
||||
const searchData: DataFrame = {
|
||||
|
@ -14,6 +14,7 @@ import {
|
||||
locationService,
|
||||
setBackendSrv,
|
||||
setDataSourceSrv,
|
||||
usePluginLinkExtensions,
|
||||
} from '@grafana/runtime';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
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.requireActual('@grafana/runtime'),
|
||||
getPluginLinkExtensions: jest.fn(),
|
||||
usePluginLinkExtensions: jest.fn(),
|
||||
useReturnToPrevious: jest.fn(),
|
||||
}));
|
||||
jest.mock('./api/buildInfo');
|
||||
@ -81,6 +83,7 @@ jest.spyOn(actions, 'rulesInSameGroupHaveInvalidFor').mockReturnValue([]);
|
||||
const mocks = {
|
||||
getAllDataSourcesMock: jest.mocked(config.getAllDataSources),
|
||||
getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions),
|
||||
usePluginLinkExtensionsMock: jest.mocked(usePluginLinkExtensions),
|
||||
rulesInSameGroupHaveInvalidForMock: jest.mocked(actions.rulesInSameGroupHaveInvalidFor),
|
||||
|
||||
api: {
|
||||
@ -201,7 +204,7 @@ describe('RuleList', () => {
|
||||
AccessControlAction.AlertingRuleExternalWrite,
|
||||
]);
|
||||
mocks.rulesInSameGroupHaveInvalidForMock.mockReturnValue([]);
|
||||
mocks.getPluginLinkExtensionsMock.mockReturnValue({
|
||||
mocks.usePluginLinkExtensionsMock.mockReturnValue({
|
||||
extensions: [
|
||||
{
|
||||
pluginId: 'grafana-ml-app',
|
||||
@ -213,6 +216,7 @@ describe('RuleList', () => {
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { ReactElement, useMemo, useState } from 'react';
|
||||
|
||||
import { PluginExtensionLink, PluginExtensionPoints } from '@grafana/data';
|
||||
import { getPluginLinkExtensions } from '@grafana/runtime';
|
||||
import { usePluginLinkExtensions } from '@grafana/runtime';
|
||||
import { Dropdown, IconButton } from '@grafana/ui';
|
||||
import { ConfirmNavigationModal } from 'app/features/explore/extensions/ConfirmNavigationModal';
|
||||
import { Alert, CombinedRule } from 'app/types/unified-alerting';
|
||||
@ -20,8 +20,8 @@ export const AlertInstanceExtensionPoint = ({
|
||||
extensionPointId,
|
||||
}: AlertInstanceExtensionPointProps): ReactElement | null => {
|
||||
const [selectedExtension, setSelectedExtension] = useState<PluginExtensionLink | undefined>();
|
||||
const context = { instance, rule };
|
||||
const extensions = useExtensionLinks(context, extensionPointId);
|
||||
const context = useMemo(() => ({ instance, rule }), [instance, rule]);
|
||||
const { extensions } = usePluginLinkExtensions({ context, extensionPointId, limitPerPlugin: 3 });
|
||||
|
||||
if (extensions.length === 0) {
|
||||
return null;
|
||||
@ -48,18 +48,3 @@ export type PluginExtensionAlertInstanceContext = {
|
||||
rule?: CombinedRule;
|
||||
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 { 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 { contextSrv } from 'app/core/services/context_srv';
|
||||
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
|
||||
@ -25,14 +25,14 @@ import { RuleDetails } from './RuleDetails';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getPluginLinkExtensions: jest.fn(),
|
||||
usePluginLinkExtensions: jest.fn(),
|
||||
useReturnToPrevious: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../hooks/useIsRuleEditable');
|
||||
|
||||
const mocks = {
|
||||
getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions),
|
||||
usePluginLinkExtensionsMock: jest.mocked(usePluginLinkExtensions),
|
||||
useIsRuleEditable: jest.mocked(useIsRuleEditable),
|
||||
};
|
||||
|
||||
@ -68,7 +68,7 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.getPluginLinkExtensionsMock.mockReturnValue({
|
||||
mocks.usePluginLinkExtensionsMock.mockReturnValue({
|
||||
extensions: [
|
||||
{
|
||||
pluginId: 'grafana-ml-app',
|
||||
@ -80,6 +80,7 @@ beforeEach(() => {
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
server.resetHandlers();
|
||||
mockAlertmanagerChoiceResponse(server, alertmanagerChoiceMockedResponse);
|
||||
|
@ -5,7 +5,7 @@ import React from 'react';
|
||||
import { byLabelText, byRole, byTestId } from 'testing-library-selector';
|
||||
|
||||
import { PluginExtensionTypes } from '@grafana/data';
|
||||
import { getPluginLinkExtensions } from '@grafana/runtime';
|
||||
import { usePluginLinkExtensions } from '@grafana/runtime';
|
||||
|
||||
import { CombinedRuleNamespace } from '../../../../../types/unified-alerting';
|
||||
import { GrafanaAlertState, PromAlertingRuleState } from '../../../../../types/unified-alerting-dto';
|
||||
@ -17,10 +17,11 @@ import { RuleDetailsMatchingInstances } from './RuleDetailsMatchingInstances';
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getPluginLinkExtensions: jest.fn(),
|
||||
usePluginLinkExtensions: jest.fn(),
|
||||
}));
|
||||
|
||||
const mocks = {
|
||||
getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions),
|
||||
usePluginLinkExtensionsMock: jest.mocked(usePluginLinkExtensions),
|
||||
};
|
||||
|
||||
const ui = {
|
||||
@ -43,7 +44,7 @@ const ui = {
|
||||
|
||||
describe('RuleDetailsMatchingInstances', () => {
|
||||
beforeEach(() => {
|
||||
mocks.getPluginLinkExtensionsMock.mockReturnValue({
|
||||
mocks.usePluginLinkExtensionsMock.mockReturnValue({
|
||||
extensions: [
|
||||
{
|
||||
pluginId: 'grafana-ml-app',
|
||||
@ -55,6 +56,7 @@ describe('RuleDetailsMatchingInstances', () => {
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -3,14 +3,14 @@ import React from 'react';
|
||||
|
||||
import { PluginExtensionPoints } 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 { useStyles2 } from '@grafana/ui/';
|
||||
|
||||
export function PluginIntegrations() {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const { extensions } = getPluginComponentExtensions({
|
||||
const { extensions } = usePluginComponentExtensions({
|
||||
extensionPointId: PluginExtensionPoints.AlertingHomePage,
|
||||
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 { ACTIONS_PRIORITY, DEFAULT_PRIORITY, PREFERENCES_PRIORITY } from '../values';
|
||||
|
||||
import getExtensionActions from './extensionActions';
|
||||
|
||||
// TODO: Clean this once ID is mandatory on nav items
|
||||
function idForNavItem(navItem: NavModelItem) {
|
||||
return 'navModel.' + navItem.id ?? navItem.url ?? navItem.text ?? navItem.subTitle;
|
||||
@ -72,7 +70,7 @@ function navTreeToActions(navTree: NavModelItem[], parents: NavModelItem[] = [])
|
||||
return navActions;
|
||||
}
|
||||
|
||||
export default (navBarTree: NavModelItem[]): CommandPaletteAction[] => {
|
||||
export default (navBarTree: NavModelItem[], extensionActions: CommandPaletteAction[]): CommandPaletteAction[] => {
|
||||
const globalActions: CommandPaletteAction[] = [
|
||||
{
|
||||
id: 'preferences/theme',
|
||||
@ -99,7 +97,6 @@ export default (navBarTree: NavModelItem[]): CommandPaletteAction[] => {
|
||||
},
|
||||
];
|
||||
|
||||
const extensionActions = getExtensionActions();
|
||||
const navBarActions = navTreeToActions(navBarTree);
|
||||
|
||||
return [...globalActions, ...extensionActions, ...navBarActions];
|
||||
|
@ -6,18 +6,20 @@ import { CommandPaletteAction } from '../types';
|
||||
|
||||
import { getRecentDashboardActions } from './dashboardActions';
|
||||
import getStaticActions from './staticActions';
|
||||
import useExtensionActions from './useExtensionActions';
|
||||
|
||||
export default function useActions(searchQuery: string) {
|
||||
const [navTreeActions, setNavTreeActions] = useState<CommandPaletteAction[]>([]);
|
||||
const [recentDashboardActions, setRecentDashboardActions] = useState<CommandPaletteAction[]>([]);
|
||||
const extensionActions = useExtensionActions();
|
||||
|
||||
const navBarTree = useSelector((state) => state.navBarTree);
|
||||
|
||||
// Load standard static actions
|
||||
useEffect(() => {
|
||||
const staticActionsResp = getStaticActions(navBarTree);
|
||||
const staticActionsResp = getStaticActions(navBarTree, extensionActions);
|
||||
setNavTreeActions(staticActionsResp);
|
||||
}, [navBarTree]);
|
||||
}, [navBarTree, extensionActions]);
|
||||
|
||||
// Load recent dashboards - we don't want them to reload when the nav tree changes
|
||||
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));
|
||||
|
||||
// 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({
|
||||
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
|
||||
context: createExtensionContext(panel, dashboard),
|
||||
|
@ -68,6 +68,7 @@ jest.mock('app/core/core', () => ({
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
|
||||
usePluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
|
||||
}));
|
||||
|
||||
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 { useSelector } from 'app/types';
|
||||
|
||||
@ -21,10 +28,36 @@ interface Props {
|
||||
export function PanelHeaderMenuProvider({ panel, dashboard, loadingState, children }: Props) {
|
||||
const [items, setItems] = useState<PanelMenuItem[]>([]);
|
||||
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(() => {
|
||||
setItems(getPanelMenu(dashboard, panel, angularComponent));
|
||||
}, [dashboard, panel, angularComponent, loadingState, setItems]);
|
||||
setItems(getPanelMenu(dashboard, panel, extensions, angularComponent));
|
||||
}, [dashboard, panel, angularComponent, loadingState, setItems, extensions]);
|
||||
|
||||
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 {
|
||||
dateTime,
|
||||
FieldType,
|
||||
LoadingState,
|
||||
PanelData,
|
||||
PanelMenuItem,
|
||||
PluginExtensionPanelContext,
|
||||
PluginExtensionTypes,
|
||||
toDataFrame,
|
||||
} from '@grafana/data';
|
||||
import { AngularComponent, getPluginLinkExtensions } from '@grafana/runtime';
|
||||
import { PanelMenuItem, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data';
|
||||
import { AngularComponent, usePluginLinkExtensions } from '@grafana/runtime';
|
||||
import config from 'app/core/config';
|
||||
import { grantUserPermissions } from 'app/features/alerting/unified/mocks';
|
||||
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.requireActual('@grafana/runtime'),
|
||||
setPluginExtensionGetter: jest.fn(),
|
||||
getPluginLinkExtensions: jest.fn(),
|
||||
setPluginExtensionsHook: jest.fn(),
|
||||
usePluginLinkExtensions: jest.fn(),
|
||||
}));
|
||||
|
||||
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions);
|
||||
const usePluginLinkExtensionsMock = jest.mocked(usePluginLinkExtensions);
|
||||
|
||||
describe('getPanelMenu()', () => {
|
||||
beforeEach(() => {
|
||||
getPluginLinkExtensionsMock.mockRestore();
|
||||
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] });
|
||||
usePluginLinkExtensionsMock.mockRestore();
|
||||
usePluginLinkExtensionsMock.mockReturnValue({ extensions: [], isLoading: false });
|
||||
grantUserPermissions([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleUpdate]);
|
||||
config.unifiedAlertingEnabled = false;
|
||||
});
|
||||
@ -48,8 +39,9 @@ describe('getPanelMenu()', () => {
|
||||
it('should return the correct panel menu items', () => {
|
||||
const panel = new PanelModel({});
|
||||
const dashboard = createDashboardModelFixture({});
|
||||
const extensions: PluginExtensionLink[] = [];
|
||||
|
||||
const menuItems = getPanelMenu(dashboard, panel);
|
||||
const menuItems = getPanelMenu(dashboard, panel, extensions);
|
||||
expect(menuItems).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
@ -126,22 +118,20 @@ describe('getPanelMenu()', () => {
|
||||
|
||||
describe('when extending panel menu from plugins', () => {
|
||||
it('should contain menu item from link extension', () => {
|
||||
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',
|
||||
},
|
||||
],
|
||||
});
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
const panel = new PanelModel({});
|
||||
const dashboard = createDashboardModelFixture({});
|
||||
const menuItems = getPanelMenu(dashboard, panel);
|
||||
const menuItems = getPanelMenu(dashboard, panel, extensions);
|
||||
const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu;
|
||||
|
||||
expect(extensionsSubMenu).toEqual(
|
||||
@ -155,22 +145,19 @@ describe('getPanelMenu()', () => {
|
||||
});
|
||||
|
||||
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 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;
|
||||
|
||||
expect(extensionsSubMenu).toEqual(
|
||||
@ -185,230 +172,42 @@ describe('getPanelMenu()', () => {
|
||||
|
||||
it('should pass onClick from plugin extension link to menu item', () => {
|
||||
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 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 menuItem = extensionsSubMenu?.find((i) => (i.text = 'Declare incident when...'));
|
||||
|
||||
menuItem?.onClick?.({} as React.MouseEvent);
|
||||
expect(expectedOnClick).toBeCalledTimes(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 }));
|
||||
expect(expectedOnClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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 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;
|
||||
|
||||
expect(extensionsSubMenu).toEqual(
|
||||
@ -427,23 +226,20 @@ describe('getPanelMenu()', () => {
|
||||
});
|
||||
|
||||
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 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;
|
||||
|
||||
expect(extensionsSubMenu).toEqual(
|
||||
@ -462,31 +258,28 @@ describe('getPanelMenu()', () => {
|
||||
});
|
||||
|
||||
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 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;
|
||||
|
||||
expect(extensionsSubMenu).toEqual(
|
||||
@ -519,8 +312,9 @@ describe('getPanelMenu()', () => {
|
||||
const angularComponent = { getScope: () => scope } as AngularComponent;
|
||||
const panel = new PanelModel({ isViewing: true });
|
||||
const dashboard = createDashboardModelFixture({});
|
||||
const extensions: PluginExtensionLink[] = [];
|
||||
|
||||
const menuItems = getPanelMenu(dashboard, panel, angularComponent);
|
||||
const menuItems = getPanelMenu(dashboard, panel, extensions, angularComponent);
|
||||
expect(menuItems).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
@ -590,7 +384,8 @@ describe('getPanelMenu()', () => {
|
||||
beforeAll(() => {
|
||||
const panel = new PanelModel({});
|
||||
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;
|
||||
navigateSpy = jest.spyOn(actions, 'navigateToExplore');
|
||||
window.open = windowOpen;
|
||||
@ -624,14 +419,16 @@ describe('getPanelMenu()', () => {
|
||||
expect(windowOpen).toHaveBeenLastCalledWith(`${testSubUrl}${testUrl}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alerting menu', () => {
|
||||
it('should render "New alert rule" menu item if user has permissions to read and update alerts ', () => {
|
||||
const panel = new PanelModel({});
|
||||
|
||||
const dashboard = createDashboardModelFixture({});
|
||||
const extensions: PluginExtensionLink[] = [];
|
||||
|
||||
config.unifiedAlertingEnabled = true;
|
||||
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;
|
||||
|
||||
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 ', () => {
|
||||
const panel = new PanelModel({});
|
||||
const dashboard = createDashboardModelFixture({});
|
||||
const extensions: PluginExtensionLink[] = [];
|
||||
|
||||
grantUserPermissions([AccessControlAction.AlertingRuleRead]);
|
||||
config.unifiedAlertingEnabled = true;
|
||||
|
||||
const menuItems = getPanelMenu(dashboard, panel);
|
||||
const menuItems = getPanelMenu(dashboard, panel, extensions);
|
||||
|
||||
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 ', () => {
|
||||
const panel = new PanelModel({});
|
||||
|
||||
const dashboard = createDashboardModelFixture({});
|
||||
const extensions: PluginExtensionLink[] = [];
|
||||
|
||||
grantUserPermissions([]);
|
||||
config.unifiedAlertingEnabled = true;
|
||||
|
||||
const menuItems = getPanelMenu(dashboard, panel);
|
||||
const menuItems = getPanelMenu(dashboard, panel, extensions);
|
||||
|
||||
const moreSubMenu = menuItems.find((i) => i.text === 'More...')?.subMenu;
|
||||
const createAlertOption = moreSubMenu?.find((i) => i.text === 'New alert rule')?.subMenu;
|
||||
|
@ -1,11 +1,5 @@
|
||||
import {
|
||||
PanelMenuItem,
|
||||
PluginExtensionPoints,
|
||||
getTimeZone,
|
||||
urlUtil,
|
||||
type PluginExtensionPanelContext,
|
||||
} from '@grafana/data';
|
||||
import { AngularComponent, getPluginLinkExtensions, locationService } from '@grafana/runtime';
|
||||
import { PanelMenuItem, urlUtil, PluginExtensionLink } from '@grafana/data';
|
||||
import { AngularComponent, locationService } from '@grafana/runtime';
|
||||
import { PanelCtrl } from 'app/angular/panel/panel_ctrl';
|
||||
import config from 'app/core/config';
|
||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||
@ -42,6 +36,7 @@ import { getTimeSrv } from '../services/TimeSrv';
|
||||
export function getPanelMenu(
|
||||
dashboard: DashboardModel,
|
||||
panel: PanelModel,
|
||||
extensions: PluginExtensionLink[],
|
||||
angularComponent?: AngularComponent | null
|
||||
): PanelMenuItem[] {
|
||||
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) {
|
||||
menu.push({
|
||||
text: 'Extensions',
|
||||
@ -370,23 +359,3 @@ export function getPanelMenu(
|
||||
|
||||
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 { PluginExtensionTypes, PluginState } from '@grafana/data';
|
||||
import { setAngularLoader, setPluginExtensionGetter } from '@grafana/runtime';
|
||||
import { setAngularLoader, setPluginExtensionsHook } from '@grafana/runtime';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
|
||||
import { getMockDataSource, getMockDataSourceMeta, getMockDataSourceSettingsState } from '../__mocks__';
|
||||
@ -59,7 +59,7 @@ describe('<EditDataSource>', () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setPluginExtensionGetter(jest.fn().mockReturnValue({ extensions: [] }));
|
||||
setPluginExtensionsHook(jest.fn().mockReturnValue({ extensions: [] }));
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const message = "I'm a UI extension component!";
|
||||
|
||||
setPluginExtensionGetter(
|
||||
setPluginExtensionsHook(
|
||||
jest.fn().mockReturnValue({
|
||||
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', () => {
|
||||
const message = "I'm a UI extension component!";
|
||||
|
||||
setPluginExtensionGetter(
|
||||
setPluginExtensionsHook(
|
||||
jest.fn().mockReturnValue({
|
||||
extensions: [
|
||||
{
|
||||
@ -328,7 +328,7 @@ describe('<EditDataSource>', () => {
|
||||
const message = "I'm a UI extension component!";
|
||||
const component = jest.fn().mockReturnValue(<div>{message}</div>);
|
||||
|
||||
setPluginExtensionGetter(
|
||||
setPluginExtensionsHook(
|
||||
jest.fn().mockReturnValue({
|
||||
extensions: [
|
||||
{
|
||||
|
@ -11,7 +11,7 @@ import {
|
||||
DataSourceJsonData,
|
||||
DataSourceUpdatedSuccessfully,
|
||||
} from '@grafana/data';
|
||||
import { getDataSourceSrv, getPluginComponentExtensions } from '@grafana/runtime';
|
||||
import { getDataSourceSrv, usePluginComponentExtensions } from '@grafana/runtime';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||
import { DataSourceSettingsState, useDispatch } from 'app/types';
|
||||
@ -136,15 +136,15 @@ export function EditDataSourceView({
|
||||
onTest();
|
||||
};
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const allowedPluginIds = ['grafana-pdc-app', 'grafana-auth-app'];
|
||||
const extensionPointId = PluginExtensionPoints.DataSourceConfig;
|
||||
const { extensions } = getPluginComponentExtensions<{
|
||||
context: PluginExtensionDataSourceConfigContext<DataSourceJsonData>;
|
||||
}>({ extensionPointId });
|
||||
const extensionPointId = PluginExtensionPoints.DataSourceConfig;
|
||||
const { extensions } = usePluginComponentExtensions<{
|
||||
context: PluginExtensionDataSourceConfigContext<DataSourceJsonData>;
|
||||
}>({ extensionPointId });
|
||||
|
||||
const allowedExtensions = useMemo(() => {
|
||||
const allowedPluginIds = ['grafana-pdc-app', 'grafana-auth-app'];
|
||||
return extensions.filter((e) => allowedPluginIds.includes(e.pluginId));
|
||||
}, []);
|
||||
}, [extensions]);
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
@ -203,7 +203,7 @@ export function EditDataSourceView({
|
||||
)}
|
||||
|
||||
{/* Extension point */}
|
||||
{extensions.map((extension) => {
|
||||
{allowedExtensions.map((extension) => {
|
||||
const Component = extension.component;
|
||||
|
||||
return (
|
||||
|
@ -5,7 +5,7 @@ import { TestProvider } from 'test/helpers/TestProvider';
|
||||
|
||||
import { CoreApp, createTheme, DataSourceApi, EventBusSrv, LoadingState, PluginExtensionTypes } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { getPluginLinkExtensions } from '@grafana/runtime';
|
||||
import { usePluginLinkExtensions } from '@grafana/runtime';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
|
||||
import { ContentOutlineContextProvider } from './ContentOutline/ContentOutlineContext';
|
||||
@ -123,7 +123,7 @@ jest.mock('app/core/core', () => ({
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getPluginLinkExtensions: jest.fn(() => ({ extensions: [] })),
|
||||
usePluginLinkExtensions: jest.fn(() => ({ extensions: [] })),
|
||||
}));
|
||||
|
||||
// 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 store = configureStore({
|
||||
@ -179,7 +179,7 @@ describe('Explore', () => {
|
||||
});
|
||||
|
||||
it('should render toolbar extension point if extensions is available', async () => {
|
||||
getPluginLinkExtensionsMock.mockReturnValueOnce({
|
||||
usePluginLinkExtensionsMock.mockReturnValueOnce({
|
||||
extensions: [
|
||||
{
|
||||
id: '1',
|
||||
@ -198,6 +198,7 @@ describe('Explore', () => {
|
||||
onClick: () => {},
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
setup({ queryResponse: makeEmptyQueryResponse(LoadingState.Done) });
|
||||
|
@ -4,7 +4,7 @@ import React, { ReactNode } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { PluginExtensionPoints, PluginExtensionTypes } from '@grafana/data';
|
||||
import { getPluginLinkExtensions } from '@grafana/runtime';
|
||||
import { usePluginLinkExtensions } from '@grafana/runtime';
|
||||
import { DataQuery } from '@grafana/schema';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
@ -16,13 +16,13 @@ import { ToolbarExtensionPoint } from './ToolbarExtensionPoint';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getPluginLinkExtensions: jest.fn(),
|
||||
usePluginLinkExtensions: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('app/core/services/context_srv');
|
||||
|
||||
const contextSrvMock = jest.mocked(contextSrv);
|
||||
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions);
|
||||
const usePluginLinkExtensionsMock = jest.mocked(usePluginLinkExtensions);
|
||||
|
||||
type storeOptions = {
|
||||
targets: DataQuery[];
|
||||
@ -54,7 +54,7 @@ function renderWithExploreStore(
|
||||
describe('ToolbarExtensionPoint', () => {
|
||||
describe('with extension points', () => {
|
||||
beforeAll(() => {
|
||||
getPluginLinkExtensionsMock.mockReturnValue({
|
||||
usePluginLinkExtensionsMock.mockReturnValue({
|
||||
extensions: [
|
||||
{
|
||||
pluginId: 'grafana',
|
||||
@ -74,6 +74,7 @@ describe('ToolbarExtensionPoint', () => {
|
||||
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('menuitem', { name: 'Add to dashboard' }));
|
||||
|
||||
const { extensions } = getPluginLinkExtensions({ extensionPointId: PluginExtensionPoints.ExploreToolbarAction });
|
||||
const { extensions } = usePluginLinkExtensionsMock({
|
||||
extensionPointId: PluginExtensionPoints.ExploreToolbarAction,
|
||||
});
|
||||
const [extension] = extensions;
|
||||
|
||||
expect(jest.mocked(extension.onClick)).toBeCalledTimes(1);
|
||||
@ -125,7 +128,7 @@ describe('ToolbarExtensionPoint', () => {
|
||||
data,
|
||||
});
|
||||
|
||||
const [options] = getPluginLinkExtensionsMock.mock.calls[0];
|
||||
const [options] = usePluginLinkExtensionsMock.mock.calls[0];
|
||||
const { context } = options;
|
||||
|
||||
expect(context).toEqual({
|
||||
@ -150,7 +153,7 @@ describe('ToolbarExtensionPoint', () => {
|
||||
data,
|
||||
});
|
||||
|
||||
const [options] = getPluginLinkExtensionsMock.mock.calls[0];
|
||||
const [options] = usePluginLinkExtensionsMock.mock.calls[0];
|
||||
const { context } = options;
|
||||
|
||||
expect(context).toHaveProperty('timeZone', 'browser');
|
||||
@ -159,7 +162,7 @@ describe('ToolbarExtensionPoint', () => {
|
||||
it('should correct extension point id when fetching extensions', async () => {
|
||||
renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />);
|
||||
|
||||
const [options] = getPluginLinkExtensionsMock.mock.calls[0];
|
||||
const [options] = usePluginLinkExtensionsMock.mock.calls[0];
|
||||
const { extensionPointId } = options;
|
||||
|
||||
expect(extensionPointId).toBe(PluginExtensionPoints.ExploreToolbarAction);
|
||||
@ -168,7 +171,7 @@ describe('ToolbarExtensionPoint', () => {
|
||||
|
||||
describe('with extension points without categories', () => {
|
||||
beforeAll(() => {
|
||||
getPluginLinkExtensionsMock.mockReturnValue({
|
||||
usePluginLinkExtensionsMock.mockReturnValue({
|
||||
extensions: [
|
||||
{
|
||||
pluginId: 'grafana',
|
||||
@ -187,6 +190,7 @@ describe('ToolbarExtensionPoint', () => {
|
||||
path: '/a/grafana-ml-ap/forecast',
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
@ -211,7 +215,7 @@ describe('ToolbarExtensionPoint', () => {
|
||||
describe('without extension points', () => {
|
||||
beforeAll(() => {
|
||||
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 () => {
|
||||
@ -229,7 +233,7 @@ describe('ToolbarExtensionPoint', () => {
|
||||
describe('with insufficient permissions', () => {
|
||||
beforeAll(() => {
|
||||
contextSrvMock.hasPermission.mockReturnValue(false);
|
||||
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] });
|
||||
usePluginLinkExtensionsMock.mockReturnValue({ extensions: [], isLoading: false });
|
||||
});
|
||||
|
||||
it('should not render "add to dashboard" action button', async () => {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { lazy, ReactElement, Suspense, useMemo, useState } from 'react';
|
||||
|
||||
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 { Dropdown, ToolbarButton } from '@grafana/ui';
|
||||
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 [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const context = useExtensionPointContext(props);
|
||||
const extensions = useExtensionLinks(context);
|
||||
const { extensions } = usePluginLinkExtensions({
|
||||
extensionPointId: PluginExtensionPoints.ExploreToolbarAction,
|
||||
context: context,
|
||||
limitPerPlugin: 3,
|
||||
});
|
||||
const selectExploreItem = getExploreItemSelector(exploreId);
|
||||
const noQueriesInPane = useSelector(selectExploreItem)?.queries?.length;
|
||||
|
||||
@ -114,15 +118,3 @@ function useExtensionPointContext(props: Props): PluginExtensionExploreContext {
|
||||
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,
|
||||
HistoryWrapper,
|
||||
LocationService,
|
||||
setPluginExtensionGetter,
|
||||
setPluginExtensionsHook,
|
||||
setBackendSrv,
|
||||
getBackendSrv,
|
||||
getDataSourceSrv,
|
||||
@ -86,7 +86,7 @@ export function setupExplore(options?: SetupOptions): {
|
||||
request: jest.fn().mockRejectedValue(undefined),
|
||||
});
|
||||
|
||||
setPluginExtensionGetter(() => ({ extensions: [] }));
|
||||
setPluginExtensionsHook(() => ({ extensions: [], isLoading: false }));
|
||||
|
||||
// Clear this up otherwise it persists data source selection
|
||||
// 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 { reportInteraction } from '@grafana/runtime';
|
||||
|
||||
import { createPluginExtensionRegistry } from './createPluginExtensionRegistry';
|
||||
import { getPluginExtensions } from './getPluginExtensions';
|
||||
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
|
||||
import { isReadOnlyProxy } from './utils';
|
||||
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()', () => {
|
||||
const extensionPoint1 = 'grafana/dashboard/panel/menu';
|
||||
const extensionPoint2 = 'plugins/myorg-basic-app/start';
|
||||
@ -54,8 +67,8 @@ describe('getPluginExtensions()', () => {
|
||||
jest.mocked(reportInteraction).mockReset();
|
||||
});
|
||||
|
||||
test('should return the extensions for the given placement', () => {
|
||||
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]);
|
||||
test('should return the extensions for the given placement', async () => {
|
||||
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]);
|
||||
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint1 });
|
||||
|
||||
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
|
||||
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 });
|
||||
|
||||
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', () => {
|
||||
const registry = createPluginExtensionRegistry([
|
||||
test('should be possible to limit the number of extensions per plugin for a given placement', async () => {
|
||||
const registry = await createPluginExtensionRegistry([
|
||||
{ pluginId, extensionConfigs: [link1, link1, link1, link2] },
|
||||
{
|
||||
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', () => {
|
||||
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]);
|
||||
test('should return with an empty list if there are no extensions registered for a placement yet', async () => {
|
||||
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]);
|
||||
const { extensions } = getPluginExtensions({ registry, extensionPointId: 'placement-with-no-extensions' });
|
||||
|
||||
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 registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
|
||||
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
|
||||
|
||||
getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 });
|
||||
|
||||
@ -133,7 +148,7 @@ describe('getPluginExtensions()', () => {
|
||||
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(() => ({
|
||||
title: 'Updated title',
|
||||
description: 'Updated description',
|
||||
@ -142,7 +157,7 @@ describe('getPluginExtensions()', () => {
|
||||
category: 'Machine Learning',
|
||||
}));
|
||||
|
||||
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
|
||||
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
|
||||
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
|
||||
const [extension] = extensions;
|
||||
|
||||
@ -156,7 +171,7 @@ describe('getPluginExtensions()', () => {
|
||||
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(() => ({
|
||||
title: 'Updated title',
|
||||
description: 'Updated description',
|
||||
@ -165,7 +180,7 @@ describe('getPluginExtensions()', () => {
|
||||
category: 'Machine Learning',
|
||||
}));
|
||||
|
||||
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
|
||||
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
|
||||
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
|
||||
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(() => ({
|
||||
// The following props are not allowed to override
|
||||
type: 'unknown-type',
|
||||
@ -190,7 +205,7 @@ describe('getPluginExtensions()', () => {
|
||||
title: 'test',
|
||||
}));
|
||||
|
||||
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
|
||||
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
|
||||
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
|
||||
const [extension] = extensions;
|
||||
|
||||
@ -202,9 +217,9 @@ describe('getPluginExtensions()', () => {
|
||||
//@ts-ignore
|
||||
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 registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
|
||||
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
|
||||
const { extensions } = getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 });
|
||||
const [extension] = extensions;
|
||||
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!');
|
||||
});
|
||||
|
||||
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(() => {
|
||||
throw new Error('Something went wrong!');
|
||||
});
|
||||
|
||||
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
|
||||
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
|
||||
|
||||
expect(() => {
|
||||
getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
|
||||
@ -235,7 +250,7 @@ describe('getPluginExtensions()', () => {
|
||||
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(() => ({
|
||||
path: '/a/another-plugin/page-a',
|
||||
}));
|
||||
@ -243,7 +258,7 @@ describe('getPluginExtensions()', () => {
|
||||
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: extensionsAtPlacement2 } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
|
||||
|
||||
@ -255,7 +270,7 @@ describe('getPluginExtensions()', () => {
|
||||
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 = {
|
||||
title: '', // Invalid empty string for title - should be ignored
|
||||
description: 'A valid description.', // This should be updated
|
||||
@ -263,7 +278,7 @@ describe('getPluginExtensions()', () => {
|
||||
|
||||
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 });
|
||||
|
||||
expect(extensions).toHaveLength(0);
|
||||
@ -271,10 +286,10 @@ describe('getPluginExtensions()', () => {
|
||||
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({}));
|
||||
|
||||
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
|
||||
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
|
||||
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
|
||||
|
||||
expect(extensions).toHaveLength(0);
|
||||
@ -282,24 +297,24 @@ describe('getPluginExtensions()', () => {
|
||||
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);
|
||||
|
||||
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
|
||||
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
|
||||
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
|
||||
|
||||
expect(extensions).toHaveLength(0);
|
||||
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.onClick = jest.fn().mockImplementation(() => {
|
||||
throw new Error('Something went wrong!');
|
||||
});
|
||||
|
||||
const context = {};
|
||||
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
|
||||
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
|
||||
const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 });
|
||||
const [extension] = extensions;
|
||||
|
||||
@ -322,7 +337,7 @@ describe('getPluginExtensions()', () => {
|
||||
link2.path = undefined;
|
||||
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 [extension] = extensions;
|
||||
|
||||
@ -335,13 +350,13 @@ describe('getPluginExtensions()', () => {
|
||||
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.onClick = jest.fn().mockImplementation(() => {
|
||||
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 [extension] = extensions;
|
||||
|
||||
@ -353,13 +368,13 @@ describe('getPluginExtensions()', () => {
|
||||
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!' };
|
||||
|
||||
link2.path = undefined;
|
||||
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 [extension] = extensions;
|
||||
|
||||
@ -375,14 +390,14 @@ describe('getPluginExtensions()', () => {
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
test('should not make original context read only', () => {
|
||||
test('should not make original context read only', async () => {
|
||||
const context = {
|
||||
title: 'New title from the context!',
|
||||
nested: { title: 'title' },
|
||||
array: ['a'],
|
||||
};
|
||||
|
||||
const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
|
||||
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]);
|
||||
getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 });
|
||||
|
||||
expect(() => {
|
||||
@ -392,10 +407,10 @@ describe('getPluginExtensions()', () => {
|
||||
}).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 registry = createPluginExtensionRegistry([
|
||||
const registry = await createPluginExtensionRegistry([
|
||||
{
|
||||
pluginId,
|
||||
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 registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [extension] }]);
|
||||
const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [extension] }]);
|
||||
const { extensions } = getPluginExtensions({ registry, extensionPointId: extension.extensionPointId });
|
||||
|
||||
expect(extensions).toHaveLength(1);
|
||||
|
@ -8,8 +8,9 @@ import {
|
||||
type PluginExtensionComponent,
|
||||
urlUtil,
|
||||
} from '@grafana/data';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { GetPluginExtensions, reportInteraction } from '@grafana/runtime';
|
||||
|
||||
import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry';
|
||||
import type { PluginExtensionRegistry } from './types';
|
||||
import {
|
||||
isPluginExtensionLinkConfig,
|
||||
@ -40,10 +41,22 @@ type GetExtensions = ({
|
||||
registry: PluginExtensionRegistry;
|
||||
}) => { 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
|
||||
export const getPluginExtensions: GetExtensions = ({ context, extensionPointId, limitPerPlugin, registry }) => {
|
||||
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.
|
||||
const extensions: PluginExtension[] = [];
|
||||
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
|
||||
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 { getPluginSettings } from 'app/features/plugins/pluginSettings';
|
||||
|
||||
import { ReactivePluginExtensionsRegistry } from './extensions/reactivePluginExtensionRegistry';
|
||||
import * as pluginLoader from './plugin_loader';
|
||||
|
||||
export type PluginPreloadResult = {
|
||||
@ -11,12 +12,16 @@ export type PluginPreloadResult = {
|
||||
extensionConfigs: PluginExtensionConfig[];
|
||||
};
|
||||
|
||||
export async function preloadPlugins(apps: Record<string, AppPluginConfig> = {}): Promise<PluginPreloadResult[]> {
|
||||
export async function preloadPlugins(apps: AppPluginConfig[] = [], registry: ReactivePluginExtensionsRegistry) {
|
||||
startMeasure('frontend_plugins_preload');
|
||||
const pluginsToPreload = Object.values(apps).filter((app) => app.preload);
|
||||
const result = await Promise.all(pluginsToPreload.map(preload));
|
||||
const promises = apps.filter((config) => config.preload).map((config) => preload(config));
|
||||
const preloadedPlugins = await Promise.all(promises);
|
||||
|
||||
for (const preloadedPlugin of preloadedPlugins) {
|
||||
registry.register(preloadedPlugin);
|
||||
}
|
||||
|
||||
stopMeasure('frontend_plugins_preload');
|
||||
return result;
|
||||
}
|
||||
|
||||
async function preload(config: AppPluginConfig): Promise<PluginPreloadResult> {
|
||||
|
@ -4,7 +4,7 @@ import React from 'react';
|
||||
|
||||
import { OrgRole, PluginExtensionComponent, PluginExtensionTypes } from '@grafana/data';
|
||||
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 { 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' });
|
||||
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 { rerender } = render(
|
||||
|
@ -1,10 +1,10 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
import { useMount } from 'react-use';
|
||||
|
||||
import { PluginExtensionComponent, PluginExtensionPoints } from '@grafana/data';
|
||||
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 { Page } from 'app/core/components/Page/Page';
|
||||
import SharedPreferences from 'app/core/components/SharedPreferences/SharedPreferences';
|
||||
@ -76,30 +76,21 @@ export function UserProfileEditPage({
|
||||
|
||||
useMount(() => initUserProfilePage());
|
||||
|
||||
const extensionComponents = useMemo(() => {
|
||||
const { extensions } = getPluginComponentExtensions({
|
||||
extensionPointId: PluginExtensionPoints.UserProfileTab,
|
||||
});
|
||||
const { extensions } = usePluginComponentExtensions({ extensionPointId: PluginExtensionPoints.UserProfileTab });
|
||||
|
||||
return extensions;
|
||||
}, []);
|
||||
|
||||
const groupedExtensionComponents = extensionComponents.reduce<Record<string, PluginExtensionComponent[]>>(
|
||||
(acc, extension) => {
|
||||
const { title } = extension;
|
||||
if (acc[title]) {
|
||||
acc[title].push(extension);
|
||||
} else {
|
||||
acc[title] = [extension];
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
const groupedExtensionComponents = extensions.reduce<Record<string, PluginExtensionComponent[]>>((acc, extension) => {
|
||||
const { title } = extension;
|
||||
if (acc[title]) {
|
||||
acc[title].push(extension);
|
||||
} else {
|
||||
acc[title] = [extension];
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const convertExtensionComponentTitleToTabId = (title: string) => title.toLowerCase();
|
||||
|
||||
const showTabs = extensionComponents.length > 0;
|
||||
const showTabs = extensions.length > 0;
|
||||
const tabs: TabInfo[] = [
|
||||
{
|
||||
id: GENERAL_SETTINGS_TAB,
|
||||
|
@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { CoreApp, PluginType } from '@grafana/data';
|
||||
import { setPluginExtensionGetter } from '@grafana/runtime';
|
||||
import { setPluginExtensionsHook } from '@grafana/runtime';
|
||||
|
||||
import { PyroscopeDataSource } from '../datasource';
|
||||
import { mockFetchPyroscopeDatasourceSettings } from '../datasource.test';
|
||||
@ -13,7 +13,7 @@ import { Props, QueryEditor } from './QueryEditor';
|
||||
|
||||
describe('QueryEditor', () => {
|
||||
beforeEach(() => {
|
||||
setPluginExtensionGetter(() => ({ extensions: [] })); // No extensions
|
||||
setPluginExtensionsHook(() => ({ extensions: [], isLoading: false })); // No extensions
|
||||
mockFetchPyroscopeDatasourceSettings();
|
||||
});
|
||||
|
||||
|
@ -3,7 +3,7 @@ import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { PluginType, rangeUtil, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data';
|
||||
import { getPluginLinkExtensions } from '@grafana/runtime';
|
||||
import { usePluginLinkExtensions } from '@grafana/runtime';
|
||||
|
||||
import { PyroscopeDataSource } from '../datasource';
|
||||
import { mockFetchPyroscopeDatasourceSettings } from '../datasource.test';
|
||||
@ -15,8 +15,7 @@ const EXTENSION_POINT_ID = 'plugins/grafana-pyroscope-datasource/query-links';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
setPluginExtensionGetter: jest.fn(),
|
||||
getPluginLinkExtensions: jest.fn(),
|
||||
usePluginLinkExtensions: jest.fn(),
|
||||
getTemplateSrv: () => {
|
||||
return {
|
||||
replace: (query: string): string => {
|
||||
@ -26,7 +25,7 @@ jest.mock('@grafana/runtime', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions);
|
||||
const usePluginLinkExtensionsMock = jest.mocked(usePluginLinkExtensions);
|
||||
|
||||
const defaultPyroscopeDataSourceSettings = {
|
||||
uid: 'default-pyroscope',
|
||||
@ -60,12 +59,12 @@ describe('PyroscopeQueryLinkExtensions', () => {
|
||||
resetPyroscopeQueryLinkExtensionsFetches();
|
||||
mockFetchPyroscopeDatasourceSettings(defaultPyroscopeDataSourceSettings);
|
||||
|
||||
getPluginLinkExtensionsMock.mockRestore();
|
||||
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] }); // Unless stated otherwise, no extensions
|
||||
usePluginLinkExtensionsMock.mockRestore();
|
||||
usePluginLinkExtensionsMock.mockReturnValue({ extensions: [], isLoading: false }); // Unless stated otherwise, no extensions
|
||||
});
|
||||
|
||||
it('should render if extension present', async () => {
|
||||
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [createExtension()] }); // Default extension
|
||||
usePluginLinkExtensionsMock.mockReturnValue({ extensions: [createExtension()], isLoading: false }); // Default extension
|
||||
|
||||
await act(setup);
|
||||
expect(await screen.findAllByText(EXPECTED_BUTTON_LABEL)).toBeDefined();
|
||||
|
@ -3,7 +3,7 @@ import React from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
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 { PyroscopeDataSource } from '../datasource';
|
||||
@ -64,7 +64,7 @@ export function PyroscopeQueryLinkExtensions(props: Props) {
|
||||
datasourceSettings,
|
||||
};
|
||||
|
||||
const { extensions } = getPluginLinkExtensions({
|
||||
const { extensions } = usePluginLinkExtensions({
|
||||
extensionPointId: EXTENSION_POINT_ID,
|
||||
context,
|
||||
});
|
||||
|
@ -6,7 +6,7 @@ import {
|
||||
PluginType,
|
||||
DataSourceJsonData,
|
||||
} 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 { normalizeQuery, PyroscopeDataSource } from './datasource';
|
||||
@ -50,7 +50,7 @@ describe('Pyroscope data source', () => {
|
||||
let ds: PyroscopeDataSource;
|
||||
beforeEach(() => {
|
||||
mockFetchPyroscopeDatasourceSettings();
|
||||
setPluginExtensionGetter(() => ({ extensions: [] })); // No extensions
|
||||
setPluginExtensionsHook(() => ({ extensions: [], isLoading: false })); // No extensions
|
||||
ds = new PyroscopeDataSource(defaultSettings);
|
||||
});
|
||||
|
||||
|
@ -5,7 +5,7 @@ import { Provider } from 'react-redux';
|
||||
import { byRole, byText } from 'testing-library-selector';
|
||||
|
||||
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 { mockPromRulesApiResponse } from 'app/features/alerting/unified/mocks/alertRuleApi';
|
||||
import { mockRulerRulesApiResponse } from 'app/features/alerting/unified/mocks/rulerApi';
|
||||
@ -57,12 +57,12 @@ const grafanaRuleMock = {
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getPluginLinkExtensions: jest.fn(),
|
||||
usePluginLinkExtensions: jest.fn(),
|
||||
}));
|
||||
jest.mock('app/features/alerting/unified/api/alertmanager');
|
||||
|
||||
const mocks = {
|
||||
getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions),
|
||||
usePluginLinkExtensionsMock: jest.mocked(usePluginLinkExtensions),
|
||||
};
|
||||
|
||||
const fakeResponse: PromRulesResponse = {
|
||||
@ -85,7 +85,7 @@ beforeEach(() => {
|
||||
mockRulerRulesApiResponse(server, 'grafana', {
|
||||
'folder-one': [{ name: 'group1', interval: '20s', rules: [originRule] }],
|
||||
});
|
||||
mocks.getPluginLinkExtensionsMock.mockReturnValue({
|
||||
mocks.usePluginLinkExtensionsMock.mockReturnValue({
|
||||
extensions: [
|
||||
{
|
||||
pluginId: 'grafana-ml-app',
|
||||
@ -97,6 +97,7 @@ beforeEach(() => {
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user