Plugin Extensions: Clean up the deprecated APIs (#102102)

* PanelMenuBehaviour: stop using the deprecated `getPluginLinkExtensions()` API

* Wip

* grafana-runtime: remove deprecated APIs `usePluginExtensions()`, `usePluginLinkExtensions()` and `usePluginComponentExtensions()`

* Wip

* Wip

* wip

* wip

* Chore: removed PluginExtensionLinkConfig

* Chore: removed PluginExtensionComponentConfig

* Chore: fixed grafana-pyroscope-datasource QueryEditor test

* Chore: fixed PublicDashboardScenePage.test.tsx

* Chore: fix PanelDataQueriesTab test

* Chore: fix PanelMenuBehavior test

* Chore: fix transformSceneToSaveModel test

* Chore: fix last type errors

* Chore: fix alerting/unified/testSetup/plugins.ts

* Chore: break out types to separate file

* feat(Extensions): expose an observable API for added links and components

* chore: prettier fixes

* Revert "chore: prettier fixes"

This reverts commit 53aa767664.

* Revert "feat(Extensions): expose an observable API for added links and components"

This reverts commit bdc588250e.

---------

Co-authored-by: Hugo Häggmark <hugo.haggmark@gmail.com>
This commit is contained in:
Levente Balogh
2025-04-09 10:24:29 +02:00
committed by GitHub
co-authored by Hugo Häggmark
parent c1cadc7d6f
commit 39dcff23f9
42 changed files with 129 additions and 1374 deletions
@@ -9,24 +9,6 @@ export * from './appEvents';
export * from './SidecarService_EXPERIMENTAL';
export * from './SidecarContext_EXPERIMENTAL';
export {
setPluginExtensionGetter,
getPluginExtensions,
getPluginLinkExtensions,
getPluginComponentExtensions,
type GetPluginExtensions,
type GetPluginExtensionsOptions,
type GetPluginExtensionsResult,
type UsePluginExtensions,
type UsePluginExtensionsResult,
} from './pluginExtensions/getPluginExtensions';
export {
setPluginExtensionsHook,
usePluginExtensions,
usePluginLinkExtensions,
usePluginComponentExtensions,
} from './pluginExtensions/usePluginExtensions';
export {
setPluginComponentHook,
usePluginComponent,
@@ -1,39 +0,0 @@
import { setPluginExtensionGetter, type GetPluginExtensions, getPluginExtensions } from './getPluginExtensions';
describe('Plugin Extensions / Get Plugin Extensions', () => {
afterEach(() => {
process.env.NODE_ENV = 'test';
});
test('should always return the same extension-getter function that was previously set', () => {
const getter: GetPluginExtensions = jest.fn().mockReturnValue({ extensions: [] });
setPluginExtensionGetter(getter);
getPluginExtensions({ extensionPointId: 'panel-menu' });
expect(getter).toHaveBeenCalledTimes(1);
expect(getter).toHaveBeenCalledWith({ extensionPointId: 'panel-menu' });
});
test('should throw an error when trying to redefine the app-wide extension-getter 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 getter: GetPluginExtensions = () => ({ extensions: [] });
expect(() => {
setPluginExtensionGetter(getter);
setPluginExtensionGetter(getter);
}).toThrowError();
});
test('should throw an error when trying to access the extension-getter function before it was set', () => {
// "Unsetting" the registry
// @ts-ignore
setPluginExtensionGetter(undefined);
expect(() => {
getPluginExtensions({ extensionPointId: 'panel-menu' });
}).toThrowError();
});
});
@@ -1,67 +0,0 @@
import type { PluginExtension, PluginExtensionLink, PluginExtensionComponent } from '@grafana/data';
import { isPluginExtensionComponent, isPluginExtensionLink } from './utils';
export type GetPluginExtensions<T = PluginExtension> = (
options: GetPluginExtensionsOptions
) => GetPluginExtensionsResult<T>;
export type UsePluginExtensions<T = PluginExtension> = (
options: GetPluginExtensionsOptions
) => UsePluginExtensionsResult<T>;
export type GetPluginExtensionsOptions = {
extensionPointId: string;
// Make sure this object is properly memoized and not mutated.
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 {
// We allow overriding the registry in tests
if (singleton && process.env.NODE_ENV !== 'test') {
throw new Error('setPluginExtensionGetter() function should only be called once, when Grafana is starting.');
}
singleton = instance;
}
function getPluginExtensionGetter(): GetPluginExtensions {
if (!singleton) {
throw new Error('getPluginExtensionGetter() can only be used after the Grafana instance has started.');
}
return singleton;
}
export const getPluginExtensions: GetPluginExtensions = (options) => getPluginExtensionGetter()(options);
export const getPluginLinkExtensions: GetPluginExtensions<PluginExtensionLink> = (options) => {
const { extensions } = getPluginExtensions(options);
return {
extensions: extensions.filter(isPluginExtensionLink),
};
};
// This getter doesn't support the `context` option (contextual information can be passed in as component props)
export const getPluginComponentExtensions = <Props = {}>(options: {
extensionPointId: string;
limitPerPlugin?: number;
}): { extensions: Array<PluginExtensionComponent<Props>> } => {
const { extensions } = getPluginExtensions(options);
const componentExtensions = extensions.filter(isPluginExtensionComponent) as Array<PluginExtensionComponent<Props>>;
return {
extensions: componentExtensions,
};
};
@@ -1,262 +0,0 @@
import { renderHook } from '@testing-library/react';
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);
});
});
});
@@ -1,59 +0,0 @@
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;
}
/**
* @deprecated Use either usePluginLinks() or usePluginComponents() instead.
*/
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);
}
/**
* @deprecated Use usePluginLinks() instead.
*/
export function usePluginLinkExtensions(
options: GetPluginExtensionsOptions
): UsePluginExtensionsResult<PluginExtensionLink> {
const { extensions, isLoading } = usePluginExtensions(options);
return useMemo(() => {
return {
extensions: extensions.filter(isPluginExtensionLink),
isLoading,
};
}, [extensions, isLoading]);
}
/**
* @deprecated Use usePluginComponents() instead.
*/
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]
);
}