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
@@ -3,20 +3,18 @@ import { Route, Routes } from 'react-router-dom';
import { AppRootProps } from '@grafana/data';
import { ROUTES } from '../../constants';
import { AddedComponents, AddedLinks, ExposedComponents, LegacyGetters, LegacyHooks } from '../../pages';
import { AddedComponents, AddedLinks, ExposedComponents } from '../../pages';
import { testIds } from '../../testIds';
export function App(props: AppRootProps) {
return (
<div data-testid={testIds.container} style={{ marginTop: '5%' }}>
<Routes>
<Route path={ROUTES.LegacyGetters} element={<LegacyGetters />} />
<Route path={ROUTES.LegacyHooks} element={<LegacyHooks />} />
<Route path={ROUTES.ExposedComponents} element={<ExposedComponents />} />
<Route path={ROUTES.AddedComponents} element={<AddedComponents />} />
<Route path={ROUTES.AddedLinks} element={<AddedLinks />} />
<Route path={'*'} element={<LegacyGetters />} />
<Route path={'*'} element={<ExposedComponents />} />
</Routes>
</div>
);
@@ -6,10 +6,10 @@ import pluginJson from './plugin.json';
export const plugin = new AppPlugin<{}>()
.setRootPage(App)
.configureExtensionLink<PluginExtensionPanelContext>({
.addLink<PluginExtensionPanelContext>({
title: 'Open from time series or pie charts (path)',
description: 'This link will only be visible on time series and pie charts',
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
targets: PluginExtensionPoints.DashboardPanelMenu,
path: `/a/${pluginJson.id}/`,
configure: (context) => {
// Will only be visible for the Link Extensions dashboard
@@ -31,10 +31,10 @@ export const plugin = new AppPlugin<{}>()
}
},
})
.configureExtensionLink<PluginExtensionPanelContext>({
.addLink<PluginExtensionPanelContext>({
title: 'Open from time series or pie charts (onClick)',
description: 'This link will only be visible on time series and pie charts',
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
targets: PluginExtensionPoints.DashboardPanelMenu,
onClick: (_, { openModal, context }) => {
const targets = context?.targets ?? [];
const title = context?.title;
@@ -42,6 +42,5 @@
},
"peerDependencies": {
"@grafana/runtime": "*"
},
"packageManager": "yarn@4.4.0"
}
}
@@ -1,62 +0,0 @@
import {
PluginPage,
getPluginComponentExtensions,
getPluginExtensions,
getPluginLinkExtensions,
} from '@grafana/runtime';
import { Stack } from '@grafana/ui';
import { ActionButton } from '../components/ActionButton';
import { testIds } from '../testIds';
type AppExtensionContext = {};
type ReusableComponentProps = {
name: string;
};
export function LegacyGetters() {
const extensionPointId1 = 'plugins/grafana-extensionstest-app/actions';
const extensionPointId2 = 'plugins/grafana-extensionstest-app/configure-extension-component/v1';
const context: AppExtensionContext = {};
const { extensions } = getPluginExtensions({
extensionPointId: extensionPointId1,
context,
});
const { extensions: linkExtensions } = getPluginLinkExtensions({
extensionPointId: extensionPointId1,
});
const { extensions: componentExtensions } = getPluginComponentExtensions<ReusableComponentProps>({
extensionPointId: extensionPointId2,
});
return (
<PluginPage>
<Stack direction={'column'} gap={4} data-testid={testIds.legacyGettersPage.container}>
<section data-testid={testIds.legacyGettersPage.section1}>
<h3>
Link extensions defined with configureExtensionLink or configureExtensionComponent and retrived using
getPluginExtensions
</h3>
<ActionButton extensions={extensions} />
</section>
<section data-testid={testIds.legacyGettersPage.section2}>
<h3>Link extensions defined with configureExtensionLink and retrived using getPluginLinkExtensions</h3>
<ActionButton extensions={linkExtensions} />
</section>
<section data-testid={testIds.legacyGettersPage.section3}>
<h3>
Component extensions defined with configureExtensionComponent and retrived using
getPluginComponentExtensions
</h3>
{componentExtensions.map((extension) => {
const Component = extension.component;
return <Component key={extension.id} name="World" />;
})}
</section>
</Stack>
</PluginPage>
);
}
@@ -1,62 +0,0 @@
import {
PluginPage,
usePluginComponentExtensions,
usePluginExtensions,
usePluginLinkExtensions,
} from '@grafana/runtime';
import { Stack } from '@grafana/ui';
import { ActionButton } from '../components/ActionButton';
import { testIds } from '../testIds';
type AppExtensionContext = {};
type ReusableComponentProps = {
name: string;
};
export function LegacyHooks() {
const extensionPointId1 = 'plugins/grafana-extensionstest-app/actions';
const extensionPointId2 = 'plugins/grafana-extensionstest-app/configure-extension-component/v1';
const context: AppExtensionContext = {};
const { extensions } = usePluginExtensions({
extensionPointId: extensionPointId1,
context,
});
const { extensions: linkExtensions } = usePluginLinkExtensions({
extensionPointId: extensionPointId1,
});
const { extensions: componentExtensions } = usePluginComponentExtensions<ReusableComponentProps>({
extensionPointId: extensionPointId2,
});
return (
<PluginPage>
<Stack direction={'column'} gap={4} data-testid={testIds.legacyHooksPage.container}>
<section data-testid={testIds.legacyHooksPage.section1}>
<h3>
Link extensions defined with configureExtensionLink or configureExtensionComponent and retrived using
usePluginExtensions
</h3>
<ActionButton extensions={extensions} />
</section>
<section data-testid={testIds.legacyHooksPage.section2}>
<h3>Link extensions defined with configureExtensionLink and retrived using usePluginLinkExtensions</h3>
<ActionButton extensions={linkExtensions} />
</section>
<section data-testid={testIds.legacyHooksPage.section3}>
<h3>
Component extensions defined with configureExtensionComponent and retrived using
usePluginComponentExtensions
</h3>
{componentExtensions.map((extension) => {
const Component = extension.component;
return <Component key={extension.id} name="World" />;
})}
</section>
</Stack>
</PluginPage>
);
}
@@ -1,5 +1,3 @@
export { ExposedComponents } from './ExposedComponents';
export { LegacyGetters } from './LegacyGetters';
export { LegacyHooks } from './LegacyHooks';
export { AddedComponents } from './AddedComponents';
export { AddedLinks } from './AddedLinks';
@@ -19,22 +19,6 @@
"updated": "%TODAY%"
},
"includes": [
{
"type": "page",
"name": "Legacy Getters",
"path": "/a/grafana-extensionstest-app/legacy-getters",
"role": "Admin",
"addToNav": true,
"defaultNav": false
},
{
"type": "page",
"name": "Legacy Hooks",
"path": "/a/grafana-extensionstest-app/legacy-hooks",
"role": "Admin",
"addToNav": true,
"defaultNav": false
},
{
"type": "page",
"name": "Exposed components",
@@ -6,7 +6,7 @@ export class App extends React.PureComponent<AppRootProps> {
render() {
return (
<div data-testid={testIds.appA.container} className="page-container">
Hello Grafana!
Hello Grafana!!!!!
</div>
);
}
@@ -7,12 +7,6 @@ import { App } from './components/App';
export const plugin = new AppPlugin<{}>()
.setRootPage(App)
.configureExtensionLink({
title: 'Go to A',
description: 'Navigating to pluging A',
extensionPointId: 'plugins/grafana-extensionstest-app/actions',
path: '/a/grafana-extensionexample1-app/',
})
.exposeComponent({
id: 'grafana-extensionexample1-app/reusable-component/v1',
title: 'Exposed component',
@@ -7,23 +7,6 @@ import { App } from './components/App';
export const plugin = new AppPlugin<{}>()
.setRootPage(App)
.configureExtensionLink({
title: 'Open from B',
description: 'Open a modal from plugin B',
extensionPointId: 'plugins/grafana-extensionstest-app/actions',
onClick: (_, { openModal }) => {
openModal({
title: 'Modal from app B',
body: () => <div data-testid={testIds.appB.modal}>From plugin B</div>,
});
},
})
.configureExtensionComponent({
extensionPointId: 'plugins/grafana-extensionstest-app/configure-extension-component/v1',
title: 'Configure extension component from B',
description: 'A component that can be reused by other app plugins. Shared using configureExtensionComponent api',
component: ({ name }: { name: string }) => <div data-testid={testIds.appB.reusableComponent}>Hello {name}!</div>,
})
.addComponent<{ name: string }>({
targets: 'plugins/grafana-extensionstest-app/addComponent/v1',
title: 'Added component from B',
@@ -1,4 +1,4 @@
import { usePluginExtensions, usePluginLinks } from '@grafana/runtime';
import { usePluginLinks } from '@grafana/runtime';
import { Stack } from '@grafana/ui';
import { testIds } from '../../../../testIds';
import { ActionButton } from '../../../../components/ActionButton';
@@ -7,18 +7,12 @@ export const LINKS_EXTENSION_POINT_ID = 'plugins/grafana-extensionstest-app/use-
export function AddedLinks() {
const { links } = usePluginLinks({ extensionPointId: LINKS_EXTENSION_POINT_ID });
const { extensions } = usePluginExtensions({ extensionPointId: LINKS_EXTENSION_POINT_ID });
return (
<Stack direction={'column'} gap={4}>
<section data-testid={testIds.appC.section1}>
<h3>Link extensions defined with addLink and retrieved using usePluginLinks</h3>
<ActionButton extensions={links} />
</section>
<section data-testid={testIds.appC.section2}>
<h3>Link extensions defined with addLink and retrieved using usePluginExtensions</h3>
<ActionButton extensions={extensions} />
</section>
</Stack>
);
}
@@ -6,23 +6,6 @@ import { App } from '../../components/App';
export const plugin = new AppPlugin<{}>()
.setRootPage(App)
.configureExtensionLink({
title: 'configureExtensionLink (where meta data is missing)',
description: 'Open a modal from plugin B',
extensionPointId: 'plugins/grafana-extensionstest-app/actions',
onClick: (_, { openModal }) => {
openModal({
title: 'Modal from app B',
body: () => <div data-testid={testIds.appB.modal}>From plugin B</div>,
});
},
})
.configureExtensionComponent({
extensionPointId: 'plugins/grafana-extensionstest-app/configure-extension-component/v1',
title: 'configureExtensionComponent (where meta data is missing)',
description: 'A component that can be reused by other app plugins. Shared using configureExtensionComponent api',
component: ({ name }: { name: string }) => <div data-testid={testIds.appB.reusableComponent}>Hello {name}!</div>,
})
.addComponent<{ name: string }>({
targets: ['plugins/grafana-extensionstest-app/addComponent/v1'],
title: 'Added component (where meta data is missing)',
@@ -1,52 +0,0 @@
import { test, expect } from '@grafana/plugin-e2e';
import { ensureExtensionRegistryIsPopulated } from '../utils';
import { testIds } from '../../testIds';
import pluginJson from '../../plugin.json';
test.describe('getPluginExtensions + configureExtensionLink', () => {
test('should extend the actions menu with a link to a-app plugin', async ({ page }) => {
await page.goto(`/a/${pluginJson.id}/legacy-getters`);
await ensureExtensionRegistryIsPopulated(page);
const section = await page.getByTestId(testIds.legacyGettersPage.section1);
await section.getByTestId(testIds.actions.button).click();
await page.getByTestId(testIds.container).getByText('Go to A').click();
await page.getByTestId(testIds.modal.open).click();
await expect(page.getByTestId(testIds.appA.container)).toBeVisible();
});
});
test.describe('getPluginExtensions + configureExtensionComponent', () => {
test('should extend main app with component extension from app B', async ({ page }) => {
await page.goto(`/a/${pluginJson.id}/legacy-getters`);
await ensureExtensionRegistryIsPopulated(page);
const section = await page.getByTestId(testIds.legacyGettersPage.section1);
await section.getByTestId(testIds.actions.button).click();
await page.getByTestId(testIds.container).getByText('Open from B').click();
await expect(page.getByTestId(testIds.appB.modal)).toBeVisible();
});
});
test.describe('getPluginLinkExtensions + configureExtensionLink', () => {
test('should extend the actions menu with a link to a-app plugin', async ({ page }) => {
await page.goto(`/a/${pluginJson.id}/legacy-getters`);
await ensureExtensionRegistryIsPopulated(page);
const section = await page.getByTestId(testIds.legacyGettersPage.section2);
await section.getByTestId(testIds.actions.button).click();
await page.getByTestId(testIds.container).getByText('Go to A').click();
await page.getByTestId(testIds.modal.open).click();
await expect(page.getByTestId(testIds.appA.container)).toBeVisible();
});
});
test.describe('getPluginComponentExtensions + configureExtensionComponent', () => {
test('should extend the actions menu with a command triggered from b-app plugin', async ({ page }) => {
await page.goto(`/a/${pluginJson.id}/legacy-getters`);
await ensureExtensionRegistryIsPopulated(page);
await expect(
page
.getByTestId('configure-extension-component-get-plugin-component-extensions')
.getByTestId(testIds.appB.reusableComponent)
).toHaveText('Hello World!');
});
});
@@ -1,67 +0,0 @@
import { test, expect } from '@grafana/plugin-e2e';
import { testIds } from '../../testIds';
import pluginJson from '../../plugin.json';
import testApp3pluginJson from '../../plugins/grafana-extensionexample3-app/plugin.json';
test.describe('usePluginExtensions + configureExtensionLink', () => {
test('should extend the actions menu with a link to a-app plugin', async ({ page }) => {
await page.goto(`/a/${pluginJson.id}/legacy-hooks`);
const section = await page.getByTestId(testIds.legacyHooksPage.section1);
await section.getByTestId(testIds.actions.button).click();
await page.getByTestId(testIds.container).getByText('Go to A').click();
await page.getByTestId(testIds.modal.open).click();
await expect(page.getByTestId(testIds.appA.container)).toBeVisible();
});
test('should not display extensions that have not been declared in plugin.json when in development mode', async ({
page,
}) => {
await page.goto(`/a/${pluginJson.id}/legacy-hooks`);
const section = await page.getByTestId(testIds.legacyHooksPage.section1);
await section.getByTestId(testIds.actions.button).click();
await expect(
page.getByTestId(testIds.container).getByText('configureExtensionLink (where meta data is missing)')
).not.toBeVisible();
});
});
test.describe('usePluginExtensions + configureExtensionComponent', () => {
test('should extend main app with component extension from app B', async ({ page }) => {
await page.goto(`/a/${pluginJson.id}/legacy-hooks`);
const section = await page.getByTestId(testIds.legacyHooksPage.section1);
await section.getByTestId(testIds.actions.button).click();
await page.getByTestId(testIds.container).getByText('Open from B').click();
await expect(page.getByTestId(testIds.appB.modal)).toBeVisible();
});
});
test.describe('usePluginLinkExtensions + configureExtensionLink', () => {
test('should extend the actions menu with a link to a-app plugin', async ({ page }) => {
await page.goto(`/a/${pluginJson.id}/legacy-hooks`);
const section = await page.getByTestId(testIds.legacyHooksPage.section2);
await section.getByTestId(testIds.actions.button).click();
await page.getByTestId(testIds.container).getByText('Go to A').click();
await page.getByTestId(testIds.modal.open).click();
await expect(page.getByTestId(testIds.appA.container)).toBeVisible();
});
});
test.describe('usePluginComponentExtensions + configureExtensionComponent', () => {
test('should extend the actions menu with a command triggered from b-app plugin', async ({ page }) => {
await page.goto(`/a/${pluginJson.id}/legacy-hooks`);
await expect(
page.getByTestId(testIds.legacyHooksPage.section3).getByTestId(testIds.appB.reusableComponent)
).toHaveText('Hello World!');
});
});
test.describe('usePluginExtensions + addLink', () => {
test('should not display extensions in case extension point has not been declared in plugin json (dev mode only)', async ({
page,
}) => {
await page.goto(`/a/${testApp3pluginJson.id}/legacy-hooks`);
const section = await page.getByTestId(testIds.appC.section2);
await expect(section.getByTestId(testIds.actions.button)).not.toBeVisible();
});
});
@@ -1,42 +0,0 @@
import { expect, test } from '@grafana/plugin-e2e';
import { ensureExtensionRegistryIsPopulated } from '../utils';
const panelTitle = 'Link with defaults';
const extensionTitle = 'Open from time series...';
const linkOnClickDashboardUid = 'dbfb47c5-e5e5-4d28-8ac7-35f349b95946';
const linkPathDashboardUid = 'd1fbb077-cd44-4738-8c8a-d4e66748b719';
test.describe('configureExtensionLink targeting core extension points', () => {
test('configureExtensionLink - should add link extension (path) with defaults to time series panel.', async ({
gotoDashboardPage,
page,
}) => {
const dashboardPage = await gotoDashboardPage({ uid: linkPathDashboardUid });
await ensureExtensionRegistryIsPopulated(page);
const panel = await dashboardPage.getPanelByTitle(panelTitle);
await panel.clickOnMenuItem(extensionTitle, { parentItem: 'Extensions' });
await expect(page.getByRole('heading', { name: 'Extensions test app' })).toBeVisible();
});
test('should add link extension (onclick) with defaults to time series panel', async ({
gotoDashboardPage,
page,
}) => {
const dashboardPage = await gotoDashboardPage({ uid: linkOnClickDashboardUid });
await ensureExtensionRegistryIsPopulated(page);
const panel = await dashboardPage.getPanelByTitle(panelTitle);
await panel.clickOnMenuItem(extensionTitle, { parentItem: 'Extensions' });
await expect(page.getByRole('dialog')).toContainText('Select query from "Link with defaults"');
});
test('should add link extension (onclick) with new title to pie chart panel', async ({ gotoDashboardPage, page }) => {
const panelTitle = 'Link with new name';
const extensionTitle = 'Open from piechart';
const dashboardPage = await gotoDashboardPage({ uid: linkOnClickDashboardUid });
await ensureExtensionRegistryIsPopulated(page);
const panel = await dashboardPage.getPanelByTitle(panelTitle);
await panel.clickOnMenuItem(extensionTitle, { parentItem: 'Extensions' });
await expect(page.getByRole('dialog')).toContainText('Select query from "Link with new name"');
});
});
@@ -37,3 +37,35 @@ test('should not display any extensions when extension point is not declared in
const container = await page.getByTestId(testIds.appC.section1);
await expect(container.getByTestId(testIds.actions.button)).not.toBeVisible();
});
const panelTitle = 'Link with defaults';
const extensionTitle = 'Open from time series...';
const linkOnClickDashboardUid = 'dbfb47c5-e5e5-4d28-8ac7-35f349b95946';
const linkPathDashboardUid = 'd1fbb077-cd44-4738-8c8a-d4e66748b719';
test('configureExtensionLink - should add link extension (path) with defaults to time series panel.', async ({
gotoDashboardPage,
page,
}) => {
const dashboardPage = await gotoDashboardPage({ uid: linkPathDashboardUid });
const panel = await dashboardPage.getPanelByTitle(panelTitle);
await panel.clickOnMenuItem(extensionTitle, { parentItem: 'Extensions' });
await expect(page.getByRole('heading', { name: 'Extensions test app' })).toBeVisible();
});
test('should add link extension (onclick) with defaults to time series panel', async ({ gotoDashboardPage, page }) => {
const dashboardPage = await gotoDashboardPage({ uid: linkOnClickDashboardUid });
const panel = await dashboardPage.getPanelByTitle(panelTitle);
await panel.clickOnMenuItem(extensionTitle, { parentItem: 'Extensions' });
await expect(page.getByRole('dialog')).toContainText('Select query from "Link with defaults"');
});
test('should add link extension (onclick) with new title to pie chart panel', async ({ gotoDashboardPage, page }) => {
const panelTitle = 'Link with new name';
const extensionTitle = 'Open from piechart';
const dashboardPage = await gotoDashboardPage({ uid: linkOnClickDashboardUid });
const panel = await dashboardPage.getPanelByTitle(panelTitle);
await panel.clickOnMenuItem(extensionTitle, { parentItem: 'Extensions' });
await expect(page.getByRole('dialog')).toContainText('Select query from "Link with new name"');
});