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
-6
View File
@@ -489,18 +489,12 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "10"], [0, 0, 0, "Unexpected any. Specify a different type.", "10"],
[0, 0, 0, "Unexpected any. Specify a different type.", "11"] [0, 0, 0, "Unexpected any. Specify a different type.", "11"]
], ],
"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/usePluginComponent.ts:5381": [ "packages/grafana-runtime/src/services/pluginExtensions/usePluginComponent.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [0, 0, 0, "Do not use any type assertions.", "0"]
], ],
"packages/grafana-runtime/src/services/pluginExtensions/usePluginComponents.ts:5381": [ "packages/grafana-runtime/src/services/pluginExtensions/usePluginComponents.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [0, 0, 0, "Do not use any type assertions.", "0"]
], ],
"packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts:5381": [ "packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [0, 0, 0, "Do not use any type assertions.", "0"]
], ],
@@ -3,20 +3,18 @@ import { Route, Routes } from 'react-router-dom';
import { AppRootProps } from '@grafana/data'; import { AppRootProps } from '@grafana/data';
import { ROUTES } from '../../constants'; import { ROUTES } from '../../constants';
import { AddedComponents, AddedLinks, ExposedComponents, LegacyGetters, LegacyHooks } from '../../pages'; import { AddedComponents, AddedLinks, ExposedComponents } from '../../pages';
import { testIds } from '../../testIds'; import { testIds } from '../../testIds';
export function App(props: AppRootProps) { export function App(props: AppRootProps) {
return ( return (
<div data-testid={testIds.container} style={{ marginTop: '5%' }}> <div data-testid={testIds.container} style={{ marginTop: '5%' }}>
<Routes> <Routes>
<Route path={ROUTES.LegacyGetters} element={<LegacyGetters />} />
<Route path={ROUTES.LegacyHooks} element={<LegacyHooks />} />
<Route path={ROUTES.ExposedComponents} element={<ExposedComponents />} /> <Route path={ROUTES.ExposedComponents} element={<ExposedComponents />} />
<Route path={ROUTES.AddedComponents} element={<AddedComponents />} /> <Route path={ROUTES.AddedComponents} element={<AddedComponents />} />
<Route path={ROUTES.AddedLinks} element={<AddedLinks />} /> <Route path={ROUTES.AddedLinks} element={<AddedLinks />} />
<Route path={'*'} element={<LegacyGetters />} /> <Route path={'*'} element={<ExposedComponents />} />
</Routes> </Routes>
</div> </div>
); );
@@ -6,10 +6,10 @@ import pluginJson from './plugin.json';
export const plugin = new AppPlugin<{}>() export const plugin = new AppPlugin<{}>()
.setRootPage(App) .setRootPage(App)
.configureExtensionLink<PluginExtensionPanelContext>({ .addLink<PluginExtensionPanelContext>({
title: 'Open from time series or pie charts (path)', title: 'Open from time series or pie charts (path)',
description: 'This link will only be visible on time series and pie charts', description: 'This link will only be visible on time series and pie charts',
extensionPointId: PluginExtensionPoints.DashboardPanelMenu, targets: PluginExtensionPoints.DashboardPanelMenu,
path: `/a/${pluginJson.id}/`, path: `/a/${pluginJson.id}/`,
configure: (context) => { configure: (context) => {
// Will only be visible for the Link Extensions dashboard // 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)', title: 'Open from time series or pie charts (onClick)',
description: 'This link will only be visible on time series and pie charts', description: 'This link will only be visible on time series and pie charts',
extensionPointId: PluginExtensionPoints.DashboardPanelMenu, targets: PluginExtensionPoints.DashboardPanelMenu,
onClick: (_, { openModal, context }) => { onClick: (_, { openModal, context }) => {
const targets = context?.targets ?? []; const targets = context?.targets ?? [];
const title = context?.title; const title = context?.title;
@@ -42,6 +42,5 @@
}, },
"peerDependencies": { "peerDependencies": {
"@grafana/runtime": "*" "@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 { ExposedComponents } from './ExposedComponents';
export { LegacyGetters } from './LegacyGetters';
export { LegacyHooks } from './LegacyHooks';
export { AddedComponents } from './AddedComponents'; export { AddedComponents } from './AddedComponents';
export { AddedLinks } from './AddedLinks'; export { AddedLinks } from './AddedLinks';
@@ -19,22 +19,6 @@
"updated": "%TODAY%" "updated": "%TODAY%"
}, },
"includes": [ "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", "type": "page",
"name": "Exposed components", "name": "Exposed components",
@@ -6,7 +6,7 @@ export class App extends React.PureComponent<AppRootProps> {
render() { render() {
return ( return (
<div data-testid={testIds.appA.container} className="page-container"> <div data-testid={testIds.appA.container} className="page-container">
Hello Grafana! Hello Grafana!!!!!
</div> </div>
); );
} }
@@ -7,12 +7,6 @@ import { App } from './components/App';
export const plugin = new AppPlugin<{}>() export const plugin = new AppPlugin<{}>()
.setRootPage(App) .setRootPage(App)
.configureExtensionLink({
title: 'Go to A',
description: 'Navigating to pluging A',
extensionPointId: 'plugins/grafana-extensionstest-app/actions',
path: '/a/grafana-extensionexample1-app/',
})
.exposeComponent({ .exposeComponent({
id: 'grafana-extensionexample1-app/reusable-component/v1', id: 'grafana-extensionexample1-app/reusable-component/v1',
title: 'Exposed component', title: 'Exposed component',
@@ -7,23 +7,6 @@ import { App } from './components/App';
export const plugin = new AppPlugin<{}>() export const plugin = new AppPlugin<{}>()
.setRootPage(App) .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 }>({ .addComponent<{ name: string }>({
targets: 'plugins/grafana-extensionstest-app/addComponent/v1', targets: 'plugins/grafana-extensionstest-app/addComponent/v1',
title: 'Added component from B', 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 { Stack } from '@grafana/ui';
import { testIds } from '../../../../testIds'; import { testIds } from '../../../../testIds';
import { ActionButton } from '../../../../components/ActionButton'; import { ActionButton } from '../../../../components/ActionButton';
@@ -7,18 +7,12 @@ export const LINKS_EXTENSION_POINT_ID = 'plugins/grafana-extensionstest-app/use-
export function AddedLinks() { export function AddedLinks() {
const { links } = usePluginLinks({ extensionPointId: LINKS_EXTENSION_POINT_ID }); const { links } = usePluginLinks({ extensionPointId: LINKS_EXTENSION_POINT_ID });
const { extensions } = usePluginExtensions({ extensionPointId: LINKS_EXTENSION_POINT_ID });
return ( return (
<Stack direction={'column'} gap={4}> <Stack direction={'column'} gap={4}>
<section data-testid={testIds.appC.section1}> <section data-testid={testIds.appC.section1}>
<h3>Link extensions defined with addLink and retrieved using usePluginLinks</h3> <h3>Link extensions defined with addLink and retrieved using usePluginLinks</h3>
<ActionButton extensions={links} /> <ActionButton extensions={links} />
</section> </section>
<section data-testid={testIds.appC.section2}>
<h3>Link extensions defined with addLink and retrieved using usePluginExtensions</h3>
<ActionButton extensions={extensions} />
</section>
</Stack> </Stack>
); );
} }
@@ -6,23 +6,6 @@ import { App } from '../../components/App';
export const plugin = new AppPlugin<{}>() export const plugin = new AppPlugin<{}>()
.setRootPage(App) .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 }>({ .addComponent<{ name: string }>({
targets: ['plugins/grafana-extensionstest-app/addComponent/v1'], targets: ['plugins/grafana-extensionstest-app/addComponent/v1'],
title: 'Added component (where meta data is missing)', 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); const container = await page.getByTestId(testIds.appC.section1);
await expect(container.getByTestId(testIds.actions.button)).not.toBeVisible(); 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"');
});
-3
View File
@@ -552,10 +552,7 @@ export {
type PluginExtensionComponent, type PluginExtensionComponent,
type PluginExtensionComponentMeta, type PluginExtensionComponentMeta,
type ComponentTypeWithExtensionMeta, type ComponentTypeWithExtensionMeta,
type PluginExtensionConfig,
type PluginExtensionFunction, type PluginExtensionFunction,
type PluginExtensionLinkConfig,
type PluginExtensionComponentConfig,
type PluginExtensionEventHelpers, type PluginExtensionEventHelpers,
type PluginExtensionPanelContext, type PluginExtensionPanelContext,
type PluginExtensionQueryEditorRowAdaptiveTelemetryV1Context, type PluginExtensionQueryEditorRowAdaptiveTelemetryV1Context,
-22
View File
@@ -6,8 +6,6 @@ import { KeyValue } from './data';
import { NavModel } from './navModel'; import { NavModel } from './navModel';
import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin'; import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin';
import { import {
type PluginExtensionLinkConfig,
PluginExtensionComponentConfig,
PluginExtensionExposedComponentConfig, PluginExtensionExposedComponentConfig,
PluginExtensionAddedComponentConfig, PluginExtensionAddedComponentConfig,
PluginExtensionAddedLinkConfig, PluginExtensionAddedLinkConfig,
@@ -142,26 +140,6 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
return this; return this;
} }
/** @deprecated Use .addLink() instead */
configureExtensionLink<Context extends object>(extension: Omit<PluginExtensionLinkConfig<Context>, 'type'>) {
this.addLink({
targets: [extension.extensionPointId],
...extension,
});
return this;
}
/** @deprecated Use .addComponent() instead */
configureExtensionComponent<Props = {}>(extension: Omit<PluginExtensionComponentConfig<Props>, 'type'>) {
this.addComponent({
targets: [extension.extensionPointId],
...extension,
component: extension.component,
});
return this;
}
} }
/** /**
@@ -152,8 +152,6 @@ export type PluginExtensionExposedComponentConfig<Props = {}> = PluginExtensionC
component: React.ComponentType<Props>; component: React.ComponentType<Props>;
}; };
export type PluginExtensionConfig = PluginExtensionLinkConfig | PluginExtensionComponentConfig;
export type PluginExtensionOpenModalOptions = { export type PluginExtensionOpenModalOptions = {
// The title of the modal // The title of the modal
title: string; title: string;
@@ -236,61 +234,3 @@ type Dashboard = {
title: string; title: string;
tags: string[]; tags: string[];
}; };
// deprecated types
/** @deprecated - use PluginExtensionAddedLinkConfig instead */
export type PluginExtensionLinkConfig<Context extends object = object> = {
type: PluginExtensionTypes.link;
title: string;
description: string;
// A URL path that will be used as the href for the rendered link extension
// (It is optional, because in some cases the action will be handled by the `onClick` handler instead of navigating to a new page)
path?: string;
// A function that will be called when the link is clicked
// (It is called with the original event object)
onClick?: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => void;
/**
* The unique identifier of the Extension Point
* (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum)
*/
extensionPointId: string;
// (Optional) A function that can be used to configure the extension dynamically based on the extension point's context
configure?: (context?: Readonly<Context>) =>
| Partial<{
title: string;
description: string;
path: string;
onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers<Context>) => void;
icon: IconName;
category: string;
}>
| undefined;
// (Optional) A icon that can be displayed in the ui for the extension option.
icon?: IconName;
// (Optional) A category to be used when grouping the options in the ui
category?: string;
};
/** @deprecated - use PluginAddedLinkConfig instead */
export type PluginExtensionComponentConfig<Props = {}> = {
type: PluginExtensionTypes.component;
title: string;
description: string;
// The React component that will be rendered as the extension
// (This component receives contextual information as props when it is rendered. You can just return `null` from the component to hide it.)
component: React.ComponentType<Props>;
/**
* The unique identifier of the Extension Point
* (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum)
*/
extensionPointId: string;
};
@@ -9,24 +9,6 @@ export * from './appEvents';
export * from './SidecarService_EXPERIMENTAL'; export * from './SidecarService_EXPERIMENTAL';
export * from './SidecarContext_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 { export {
setPluginComponentHook, setPluginComponentHook,
usePluginComponent, 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]
);
}
-7
View File
@@ -28,11 +28,9 @@ import {
setQueryRunnerFactory, setQueryRunnerFactory,
setRunRequest, setRunRequest,
setPluginImportUtils, setPluginImportUtils,
setPluginExtensionGetter,
setEmbeddedDashboard, setEmbeddedDashboard,
setAppEvents, setAppEvents,
setReturnToPreviousHook, setReturnToPreviousHook,
setPluginExtensionsHook,
setPluginComponentHook, setPluginComponentHook,
setPluginComponentsHook, setPluginComponentsHook,
setCurrentUser, setCurrentUser,
@@ -83,14 +81,11 @@ import { PanelDataErrorView } from './features/panel/components/PanelDataErrorVi
import { PanelRenderer } from './features/panel/components/PanelRenderer'; import { PanelRenderer } from './features/panel/components/PanelRenderer';
import { DatasourceSrv } from './features/plugins/datasource_srv'; import { DatasourceSrv } from './features/plugins/datasource_srv';
import { import {
createPluginExtensionsGetter,
getObservablePluginComponents, getObservablePluginComponents,
getObservablePluginLinks, getObservablePluginLinks,
} from './features/plugins/extensions/getPluginExtensions'; } from './features/plugins/extensions/getPluginExtensions';
import { pluginExtensionRegistries } from './features/plugins/extensions/registry/setup';
import { usePluginComponent } from './features/plugins/extensions/usePluginComponent'; import { usePluginComponent } from './features/plugins/extensions/usePluginComponent';
import { usePluginComponents } from './features/plugins/extensions/usePluginComponents'; import { usePluginComponents } from './features/plugins/extensions/usePluginComponents';
import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions';
import { usePluginFunctions } from './features/plugins/extensions/usePluginFunctions'; import { usePluginFunctions } from './features/plugins/extensions/usePluginFunctions';
import { usePluginLinks } from './features/plugins/extensions/usePluginLinks'; import { usePluginLinks } from './features/plugins/extensions/usePluginLinks';
import { getAppPluginsToAwait, getAppPluginsToPreload } from './features/plugins/extensions/utils'; import { getAppPluginsToAwait, getAppPluginsToPreload } from './features/plugins/extensions/utils';
@@ -226,8 +221,6 @@ export class GrafanaApp {
await preloadPlugins(appPluginsToAwait); await preloadPlugins(appPluginsToAwait);
} }
setPluginExtensionGetter(createPluginExtensionsGetter(pluginExtensionRegistries));
setPluginExtensionsHook(createUsePluginExtensions(pluginExtensionRegistries));
setPluginLinksHook(usePluginLinks); setPluginLinksHook(usePluginLinks);
setPluginComponentHook(usePluginComponent); setPluginComponentHook(usePluginComponent);
setPluginComponentsHook(usePluginComponents); setPluginComponentsHook(usePluginComponents);
@@ -1,12 +1,12 @@
import { PluginLoadingStrategy, PluginMeta, PluginType } from '@grafana/data'; import { PluginLoadingStrategy, PluginMeta, PluginType } from '@grafana/data';
import { AppPluginConfig, setPluginComponentsHook, setPluginExtensionsHook } from '@grafana/runtime'; import { AppPluginConfig, setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime';
import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges'; import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges';
import { mockPluginLinkExtension } from '../mocks'; import { mockPluginLinkExtension } from '../mocks';
export function setupPluginsExtensionsHook() { export function setupPluginsExtensionsHook() {
setPluginExtensionsHook(() => ({ setPluginLinksHook(() => ({
extensions: plugins.map((plugin) => links: plugins.map((plugin) =>
mockPluginLinkExtension({ mockPluginLinkExtension({
pluginId: plugin.id, pluginId: plugin.id,
title: plugin.name, title: plugin.name,
@@ -8,13 +8,7 @@ import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { PanelProps, systemDateFormats, SystemDateFormatsState } from '@grafana/data'; import { PanelProps, systemDateFormats, SystemDateFormatsState } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test'; import { getPanelPlugin } from '@grafana/data/test';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { import { LocationServiceProvider, config, locationService, setPluginImportUtils } from '@grafana/runtime';
LocationServiceProvider,
config,
getPluginLinkExtensions,
locationService,
setPluginImportUtils,
} from '@grafana/runtime';
import { VizPanel } from '@grafana/scenes'; import { VizPanel } from '@grafana/scenes';
import { Dashboard } from '@grafana/schema'; import { Dashboard } from '@grafana/schema';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
@@ -32,7 +26,6 @@ import { getDashboardScenePageStateManager } from './DashboardScenePageStateMana
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
setPluginExtensionGetter: jest.fn(), setPluginExtensionGetter: jest.fn(),
getPluginLinkExtensions: jest.fn(),
useChromeHeaderHeight: jest.fn().mockReturnValue(80), useChromeHeaderHeight: jest.fn().mockReturnValue(80),
getBackendSrv: () => { getBackendSrv: () => {
return { return {
@@ -55,7 +48,11 @@ jest.mock('react-router-dom-v5-compat', () => ({
useParams: jest.fn().mockReturnValue({ uid: 'my-dash-uid' }), useParams: jest.fn().mockReturnValue({ uid: 'my-dash-uid' }),
})); }));
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions); const getPluginExtensionsMock = jest.fn().mockReturnValue({ extensions: [] });
jest.mock('app/features/plugins/extensions/getPluginExtensions', () => ({
...jest.requireActual('app/features/plugins/extensions/getPluginExtensions'),
createPluginExtensionsGetter: () => getPluginExtensionsMock,
}));
function setup({ routeProps }: { routeProps?: Partial<GrafanaRouteComponentProps> } = {}) { function setup({ routeProps }: { routeProps?: Partial<GrafanaRouteComponentProps> } = {}) {
const context = getGrafanaContextMock(); const context = getGrafanaContextMock();
@@ -156,8 +153,8 @@ describe('DashboardScenePage', () => {
// hacky way because mocking autosizer does not work // hacky way because mocking autosizer does not work
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 1000 }); Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 1000 });
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 1000 }); Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 1000 });
getPluginLinkExtensionsMock.mockRestore(); getPluginExtensionsMock.mockRestore();
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] }); getPluginExtensionsMock.mockReturnValue({ extensions: [] });
store.delete(DASHBOARD_FROM_LS_KEY); store.delete(DASHBOARD_FROM_LS_KEY);
}); });
@@ -6,7 +6,7 @@ import { render } from 'test/test-utils';
import { getDefaultTimeRange, LoadingState, PanelData, PanelProps } from '@grafana/data'; import { getDefaultTimeRange, LoadingState, PanelData, PanelProps } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test'; import { getPanelPlugin } from '@grafana/data/test';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { config, getPluginLinkExtensions, setPluginImportUtils, setRunRequest } from '@grafana/runtime'; import { config, setPluginImportUtils, setRunRequest } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema'; import { Dashboard } from '@grafana/schema';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { DashboardRoutes } from 'app/types/dashboard'; import { DashboardRoutes } from 'app/types/dashboard';
@@ -18,8 +18,6 @@ import { PublicDashboardScenePage, Props as PublicDashboardSceneProps } from './
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
setPluginExtensionGetter: jest.fn(),
getPluginLinkExtensions: jest.fn(),
getDataSourceSrv: () => { getDataSourceSrv: () => {
return { return {
get: jest.fn().mockResolvedValue({}), get: jest.fn().mockResolvedValue({}),
@@ -28,8 +26,6 @@ jest.mock('@grafana/runtime', () => ({
}, },
})); }));
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions);
function setup(token = 'an-access-token') { function setup(token = 'an-access-token') {
const pubdashProps: PublicDashboardSceneProps = { const pubdashProps: PublicDashboardSceneProps = {
...getRouteComponentProps({ ...getRouteComponentProps({
@@ -126,8 +122,6 @@ describe('PublicDashboardScenePage', () => {
// // hacky way because mocking autosizer does not work // // hacky way because mocking autosizer does not work
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 1000 }); Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 1000 });
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 1000 }); Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 1000 });
getPluginLinkExtensionsMock.mockRestore();
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] });
}); });
it('can render public dashboard', async () => { it('can render public dashboard', async () => {
@@ -18,7 +18,7 @@ import {
} from '@grafana/data'; } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test'; import { getPanelPlugin } from '@grafana/data/test';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { config, locationService, setPluginExtensionsHook } from '@grafana/runtime'; import { config, locationService } from '@grafana/runtime';
import { PANEL_EDIT_LAST_USED_DATASOURCE } from 'app/features/dashboard/utils/dashboard'; import { PANEL_EDIT_LAST_USED_DATASOURCE } from 'app/features/dashboard/utils/dashboard';
import { InspectTab } from 'app/features/inspector/types'; import { InspectTab } from 'app/features/inspector/types';
import { SHARED_DASHBOARD_QUERY, DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/constants'; import { SHARED_DASHBOARD_QUERY, DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/constants';
@@ -56,11 +56,6 @@ async function createModelMock() {
return queriesTab; return queriesTab;
} }
setPluginExtensionsHook(() => ({
extensions: [],
isLoading: false,
}));
const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => { const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => {
const result: PanelData = { const result: PanelData = {
state: LoadingState.Loading, state: LoadingState.Loading,
@@ -9,7 +9,7 @@ import {
urlUtil, urlUtil,
} from '@grafana/data'; } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test'; import { getPanelPlugin } from '@grafana/data/test';
import { config, getPluginLinkExtensions, locationService } from '@grafana/runtime'; import { config, locationService } from '@grafana/runtime';
import { import {
LocalValueVariable, LocalValueVariable,
SceneQueryRunner, SceneQueryRunner,
@@ -47,24 +47,17 @@ jest.mock('app/core/utils/explore', () => ({
jest.mock('app/core/services/context_srv'); jest.mock('app/core/services/context_srv');
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
setPluginExtensionGetter: jest.fn(),
getPluginLinkExtensions: jest.fn(),
}));
jest.mock('app/store/store', () => ({ jest.mock('app/store/store', () => ({
dispatch: jest.fn(), dispatch: jest.fn(),
})); }));
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions); const getPluginExtensionsMock = jest.fn().mockReturnValue({ extensions: [] });
jest.mock('app/features/plugins/extensions/getPluginExtensions', () => ({
...jest.requireActual('app/features/plugins/extensions/getPluginExtensions'),
createPluginExtensionsGetter: () => getPluginExtensionsMock,
}));
describe('panelMenuBehavior', () => { describe('panelMenuBehavior', () => {
beforeEach(() => {
getPluginLinkExtensionsMock.mockRestore();
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] });
});
beforeAll(() => { beforeAll(() => {
locationService.push('/d/dash-1?from=now-5m&to=now'); locationService.push('/d/dash-1?from=now-5m&to=now');
}); });
@@ -133,7 +126,7 @@ describe('panelMenuBehavior', () => {
describe('when extending panel menu from plugins', () => { describe('when extending panel menu from plugins', () => {
it('should contain menu item from link extension', async () => { it('should contain menu item from link extension', async () => {
getPluginLinkExtensionsMock.mockReturnValue({ getPluginExtensionsMock.mockReturnValue({
extensions: [ extensions: [
{ {
id: '1', id: '1',
@@ -172,7 +165,7 @@ describe('panelMenuBehavior', () => {
}); });
it('should truncate menu item title to 25 chars', async () => { it('should truncate menu item title to 25 chars', async () => {
getPluginLinkExtensionsMock.mockReturnValue({ getPluginExtensionsMock.mockReturnValue({
extensions: [ extensions: [
{ {
id: '1', id: '1',
@@ -213,7 +206,7 @@ describe('panelMenuBehavior', () => {
it('should pass onClick from plugin extension link to menu item', async () => { it('should pass onClick from plugin extension link to menu item', async () => {
const expectedOnClick = jest.fn(); const expectedOnClick = jest.fn();
getPluginLinkExtensionsMock.mockReturnValue({ getPluginExtensionsMock.mockReturnValue({
extensions: [ extensions: [
{ {
id: '1', id: '1',
@@ -299,7 +292,7 @@ describe('panelMenuBehavior', () => {
data, data,
}; };
expect(getPluginLinkExtensionsMock).toBeCalledWith(expect.objectContaining({ context })); expect(getPluginExtensionsMock).toBeCalledWith(expect.objectContaining({ context }));
}); });
it('should pass context with default time zone values when configuring extension', async () => { it('should pass context with default time zone values when configuring extension', async () => {
@@ -356,11 +349,11 @@ describe('panelMenuBehavior', () => {
data, data,
}; };
expect(getPluginLinkExtensionsMock).toBeCalledWith(expect.objectContaining({ context })); expect(getPluginExtensionsMock).toBeCalledWith(expect.objectContaining({ context }));
}); });
it('should contain menu item with category', async () => { it('should contain menu item with category', async () => {
getPluginLinkExtensionsMock.mockReturnValue({ getPluginExtensionsMock.mockReturnValue({
extensions: [ extensions: [
{ {
id: '1', id: '1',
@@ -405,7 +398,7 @@ describe('panelMenuBehavior', () => {
}); });
it('should truncate category to 25 chars', async () => { it('should truncate category to 25 chars', async () => {
getPluginLinkExtensionsMock.mockReturnValue({ getPluginExtensionsMock.mockReturnValue({
extensions: [ extensions: [
{ {
id: '1', id: '1',
@@ -450,7 +443,7 @@ describe('panelMenuBehavior', () => {
}); });
it('should contain menu item with category and append items without category after divider', async () => { it('should contain menu item with category and append items without category after divider', async () => {
getPluginLinkExtensionsMock.mockReturnValue({ getPluginExtensionsMock.mockReturnValue({
extensions: [ extensions: [
{ {
id: '1', id: '1',
@@ -6,9 +6,10 @@ import {
PanelPlugin, PanelPlugin,
PluginExtensionPanelContext, PluginExtensionPanelContext,
PluginExtensionPoints, PluginExtensionPoints,
PluginExtensionTypes,
urlUtil, urlUtil,
} from '@grafana/data'; } from '@grafana/data';
import { config, getPluginLinkExtensions, locationService } from '@grafana/runtime'; import { config, locationService } from '@grafana/runtime';
import { LocalValueVariable, sceneGraph, SceneGridRow, VizPanel, VizPanelMenu } from '@grafana/scenes'; import { LocalValueVariable, sceneGraph, SceneGridRow, VizPanel, VizPanelMenu } from '@grafana/scenes';
import { DataQuery, OptionsWithLegend } from '@grafana/schema'; import { DataQuery, OptionsWithLegend } from '@grafana/schema';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
@@ -22,6 +23,9 @@ import { scenesPanelToRuleFormValues } from 'app/features/alerting/unified/utils
import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils'; import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
import { InspectTab } from 'app/features/inspector/types'; import { InspectTab } from 'app/features/inspector/types';
import { getScenePanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers'; import { getScenePanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
import { createPluginExtensionsGetter } from 'app/features/plugins/extensions/getPluginExtensions';
import { pluginExtensionRegistries } from 'app/features/plugins/extensions/registry/setup';
import { GetPluginExtensions } from 'app/features/plugins/extensions/types';
import { createExtensionSubMenu } from 'app/features/plugins/extensions/utils'; import { createExtensionSubMenu } from 'app/features/plugins/extensions/utils';
import { addDataTrailPanelAction } from 'app/features/trails/Integrations/dashboardIntegration'; import { addDataTrailPanelAction } from 'app/features/trails/Integrations/dashboardIntegration';
import { dispatch } from 'app/store/store'; import { dispatch } from 'app/store/store';
@@ -39,6 +43,18 @@ import { DashboardScene } from './DashboardScene';
import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks'; import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks';
import { UnlinkLibraryPanelModal } from './UnlinkLibraryPanelModal'; import { UnlinkLibraryPanelModal } from './UnlinkLibraryPanelModal';
let getPluginExtensions: GetPluginExtensions;
function setupGetPluginExtensions() {
if (getPluginExtensions) {
return getPluginExtensions;
}
getPluginExtensions = createPluginExtensionsGetter(pluginExtensionRegistries);
return getPluginExtensions;
}
/** /**
* Behavior is called when VizPanelMenu is activated (ie when it's opened). * Behavior is called when VizPanelMenu is activated (ie when it's opened).
*/ */
@@ -286,20 +302,22 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
items.push(getInspectMenuItem(plugin, panel, dashboard)); items.push(getInspectMenuItem(plugin, panel, dashboard));
// TODO: make sure that this works reliably with the reactive extension registry setupGetPluginExtensions();
// (we need to be able to know in advance what extensions should be loaded for this extension point, and make it possible to await for them.)
const { extensions } = getPluginLinkExtensions({ const { extensions } = getPluginExtensions({
extensionPointId: PluginExtensionPoints.DashboardPanelMenu, extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
context: createExtensionContext(panel, dashboard), context: createExtensionContext(panel, dashboard),
limitPerPlugin: 3, limitPerPlugin: 3,
}); });
const linkExtensions = extensions.filter((extension) => extension.type === PluginExtensionTypes.link);
if (extensions.length > 0 && !dashboard.state.isEditing) { if (extensions.length > 0 && !dashboard.state.isEditing) {
items.push({ items.push({
text: 'Extensions', text: 'Extensions',
iconClassName: 'plug', iconClassName: 'plug',
type: 'submenu', type: 'submenu',
subMenu: createExtensionSubMenu(extensions), subMenu: createExtensionSubMenu(linkExtensions),
}); });
} }
@@ -14,7 +14,7 @@ import {
VariableSupportType, VariableSupportType,
} from '@grafana/data'; } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test'; import { getPanelPlugin } from '@grafana/data/test';
import { getPluginLinkExtensions, setPluginImportUtils } from '@grafana/runtime'; import { setPluginImportUtils } from '@grafana/runtime';
import { MultiValueVariable, sceneGraph, SceneGridRow, VizPanel } from '@grafana/scenes'; import { MultiValueVariable, sceneGraph, SceneGridRow, VizPanel } from '@grafana/scenes';
import { Dashboard, LoadingState, Panel, RowPanel, VariableRefresh } from '@grafana/schema'; import { Dashboard, LoadingState, Panel, RowPanel, VariableRefresh } from '@grafana/schema';
import { PanelModel } from 'app/features/dashboard/state/PanelModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel';
@@ -154,8 +154,6 @@ jest.mock('@grafana/data', () => ({
setWeekStart: jest.fn(), setWeekStart: jest.fn(),
})); }));
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions);
jest.mock('@grafana/scenes', () => ({ jest.mock('@grafana/scenes', () => ({
...jest.requireActual('@grafana/scenes'), ...jest.requireActual('@grafana/scenes'),
sceneUtils: { sceneUtils: {
@@ -165,11 +163,6 @@ jest.mock('@grafana/scenes', () => ({
})); }));
describe('transformSceneToSaveModel', () => { describe('transformSceneToSaveModel', () => {
beforeEach(() => {
getPluginLinkExtensionsMock.mockRestore();
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] });
});
describe('Given a simple scene with custom settings', () => { describe('Given a simple scene with custom settings', () => {
it('Should transform back to persisted model', () => { it('Should transform back to persisted model', () => {
const dashboardWithCustomSettings = { const dashboardWithCustomSettings = {
@@ -1,273 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { createUsePluginExtensions } from './usePluginExtensions';
jest.mock('./useLoadAppPlugins');
describe('usePluginExtensions()', () => {
let registries: PluginExtensionRegistries;
const pluginId = 'myorg-extensions-app';
const extensionPointId = `${pluginId}/extension-point/v1`;
beforeEach(() => {
registries = {
addedComponentsRegistry: new AddedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedFunctionsRegistry: new AddedFunctionsRegistry(),
};
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
});
it('should return an empty array if there are no extensions registered for the extension point', () => {
const usePluginExtensions = createUsePluginExtensions(registries);
const { result } = renderHook(() =>
usePluginExtensions({
extensionPointId: 'foo/bar/v1',
})
);
expect(result.current.extensions).toEqual([]);
});
it('should return the plugin link extensions from the registry', () => {
registries.addedLinksRegistry.register({
pluginId,
configs: [
{
targets: extensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
},
{
targets: extensionPointId,
title: '2',
description: '2',
path: `/a/${pluginId}/2`,
},
],
});
const usePluginExtensions = createUsePluginExtensions(registries);
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 return the plugin component extensions from the registry', () => {
const componentExtensionPointId = `${pluginId}/component/v1`;
registries.addedLinksRegistry.register({
pluginId,
configs: [
{
targets: extensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
},
{
targets: extensionPointId,
title: '2',
description: '2',
path: `/a/${pluginId}/2`,
},
],
});
registries.addedComponentsRegistry.register({
pluginId,
configs: [
{
targets: componentExtensionPointId,
title: 'Component 1',
description: '1',
component: () => <div>Hello World1</div>,
},
{
targets: componentExtensionPointId,
title: 'Component 2',
description: '2',
component: () => <div>Hello World2</div>,
},
],
});
const usePluginExtensions = createUsePluginExtensions(registries);
const { result } = renderHook(() => usePluginExtensions({ extensionPointId: componentExtensionPointId }));
expect(result.current.extensions.length).toBe(2);
expect(result.current.extensions[0].title).toBe('Component 1');
expect(result.current.extensions[1].title).toBe('Component 2');
});
it('should dynamically update the extensions registered for a certain extension point', () => {
const usePluginExtensions = createUsePluginExtensions(registries);
let { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId }));
// No extensions yet
expect(result.current.extensions.length).toBe(0);
// Add extensions to the registry
act(() => {
registries.addedLinksRegistry.register({
pluginId,
configs: [
{
targets: extensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
},
{
targets: 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 addedComponentsRegistrySpy = jest.spyOn(registries.addedComponentsRegistry, 'asObservable');
const addedLinksRegistrySpy = jest.spyOn(registries.addedLinksRegistry, 'asObservable');
const usePluginExtensions = createUsePluginExtensions(registries);
renderHook(() => usePluginExtensions({ extensionPointId }));
expect(addedComponentsRegistrySpy).toHaveBeenCalledTimes(1);
expect(addedLinksRegistrySpy).toHaveBeenCalledTimes(1);
});
it('should return the same extensions object if the context object is the same', async () => {
const usePluginExtensions = createUsePluginExtensions(registries);
// Add extensions to the registry
act(() => {
registries.addedLinksRegistry.register({
pluginId,
configs: [
{
targets: extensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
},
{
targets: extensionPointId,
title: '2',
description: '2',
path: `/a/${pluginId}/2`,
},
],
});
});
// Check if it returns the same extensions object in case nothing changes
const context = {};
const { rerender, result } = renderHook(usePluginExtensions, {
initialProps: {
extensionPointId,
context,
},
});
const firstResult = result.current;
rerender({ context, extensionPointId });
const secondResult = result.current;
expect(firstResult.extensions).toBe(secondResult.extensions);
});
it('should return a new extensions object if the context object is different', () => {
const usePluginExtensions = createUsePluginExtensions(registries);
// Add extensions to the registry
act(() => {
registries.addedLinksRegistry.register({
pluginId,
configs: [
{
targets: extensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
},
{
targets: 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 context = {};
const usePluginExtensions = createUsePluginExtensions(registries);
// Add the first extension
act(() => {
registries.addedLinksRegistry.register({
pluginId,
configs: [
{
targets: 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(() => {
registries.addedLinksRegistry.register({
pluginId,
configs: [
{
targets: 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);
});
});
@@ -1,86 +0,0 @@
import { useMemo } from 'react';
import { useObservable } from 'react-use';
import { PluginExtension, usePluginContext } from '@grafana/data';
import { GetPluginExtensionsOptions, UsePluginExtensionsResult } from '@grafana/runtime';
import * as errors from './errors';
import { getPluginExtensions } from './getPluginExtensions';
import { log } from './logs/log';
import { PluginExtensionRegistries } from './registry/types';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils';
import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators';
export function createUsePluginExtensions(registries: PluginExtensionRegistries) {
const observableAddedComponentsRegistry = registries.addedComponentsRegistry.asObservable();
const observableAddedLinksRegistry = registries.addedLinksRegistry.asObservable();
return function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult<PluginExtension> {
const pluginContext = usePluginContext();
const addedComponentsRegistry = useObservable(observableAddedComponentsRegistry);
const addedLinksRegistry = useObservable(observableAddedLinksRegistry);
const { extensionPointId, context, limitPerPlugin } = options;
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
return useMemo(() => {
// For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
const enableRestrictions = isGrafanaDevMode() && pluginContext !== null;
const pluginId = pluginContext?.meta.id ?? '';
const pointLog = log.child({
pluginId,
extensionPointId,
});
if (!addedLinksRegistry && !addedComponentsRegistry) {
return { extensions: [], isLoading: false };
}
if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) {
pointLog.error(errors.INVALID_EXTENSION_POINT_ID);
return {
isLoading: false,
extensions: [],
};
}
if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) {
pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING);
return {
isLoading: false,
extensions: [],
};
}
if (isLoadingAppPlugins) {
return {
isLoading: true,
extensions: [],
};
}
const { extensions } = getPluginExtensions({
extensionPointId,
context,
limitPerPlugin,
addedComponentsRegistry,
addedLinksRegistry,
});
return { extensions, isLoading: false };
// Doing the deps like this instead of just `option` because users probably aren't going to memoize the
// options object so we are checking it's simple value attributes.
// The context though still has to be memoized though and not mutated.
// eslint-disable-next-line react-hooks/exhaustive-deps -- TODO: refactor `getPluginExtensions` to accept service dependencies as arguments instead of relying on the sidecar singleton under the hood
}, [
addedLinksRegistry,
addedComponentsRegistry,
extensionPointId,
context,
limitPerPlugin,
pluginContext,
isLoadingAppPlugins,
]);
};
}
@@ -4,10 +4,7 @@ import * as React from 'react';
import { useAsync } from 'react-use'; import { useAsync } from 'react-use';
import { import {
type PluginExtensionLinkConfig,
type PluginExtensionConfig,
type PluginExtensionEventHelpers, type PluginExtensionEventHelpers,
PluginExtensionTypes,
type PluginExtensionOpenModalOptions, type PluginExtensionOpenModalOptions,
isDateTime, isDateTime,
dateTime, dateTime,
@@ -29,12 +26,6 @@ import { ExtensionsLog, log } from './logs/log';
import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry'; import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry';
import { assertIsNotPromise, assertLinkPathIsValid, assertStringProps, isPromise } from './validators'; import { assertIsNotPromise, assertLinkPathIsValid, assertStringProps, isPromise } from './validators';
export function isPluginExtensionLinkConfig(
extension: PluginExtensionConfig | undefined
): extension is PluginExtensionLinkConfig {
return typeof extension === 'object' && 'type' in extension && extension['type'] === PluginExtensionTypes.link;
}
export function handleErrorsInFn(fn: Function, errorMessagePrefix = '') { export function handleErrorsInFn(fn: Function, errorMessagePrefix = '') {
return (...args: unknown[]) => { return (...args: unknown[]) => {
try { try {
@@ -3,7 +3,6 @@ import { memo } from 'react';
import { import {
PluginContextType, PluginContextType,
PluginExtensionAddedLinkConfig, PluginExtensionAddedLinkConfig,
PluginExtensionLinkConfig,
PluginExtensionPoints, PluginExtensionPoints,
PluginLoadingStrategy, PluginLoadingStrategy,
PluginType, PluginType,
@@ -92,16 +91,13 @@ describe('Plugin Extension Validators', () => {
it('should throw an error if the configure() function is defined but is not a function', () => { it('should throw an error if the configure() function is defined but is not a function', () => {
expect(() => { expect(() => {
assertConfigureIsValid( assertConfigureIsValid({
// @ts-ignore title: 'Title',
{ description: 'Description',
title: 'Title', extensionPointId: 'grafana/some-page/extension-point-a',
description: 'Description', handler: () => {},
extensionPointId: 'grafana/some-page/extension-point-a', configure: '() => {}',
handler: () => {}, } as unknown as PluginExtensionAddedLinkConfig); // We are casting to unknown to test it with a unvalid argument
configure: '() => {}',
} as PluginExtensionLinkConfig
);
}).toThrowError(); }).toThrowError();
}); });
}); });
@@ -2,7 +2,7 @@ import { isFunction } from 'lodash';
import { ComponentType, FC } from 'react'; import { ComponentType, FC } from 'react';
import * as React from 'react'; import * as React from 'react';
import { GrafanaPlugin, PluginExtensionConfig, PluginType } from '@grafana/data'; import { GrafanaPlugin, PluginType } from '@grafana/data';
import { SandboxPluginMeta, SandboxedPluginObject } from './types'; import { SandboxPluginMeta, SandboxedPluginObject } from './types';
import { isSandboxedPluginObject } from './utils'; import { isSandboxedPluginObject } from './utils';
@@ -58,17 +58,6 @@ export async function sandboxPluginComponents(
Reflect.set(pluginObject, 'root', withSandboxWrapper(Reflect.get(pluginObject, 'root'), meta)); Reflect.set(pluginObject, 'root', withSandboxWrapper(Reflect.get(pluginObject, 'root'), meta));
} }
// extension components
if (Reflect.has(pluginObject, 'extensionConfigs')) {
const extensions: PluginExtensionConfig[] = Reflect.get(pluginObject, 'extensionConfigs');
for (const extension of extensions) {
if (Reflect.has(extension, 'component')) {
Reflect.set(extension, 'component', withSandboxWrapper(Reflect.get(extension, 'component'), meta));
}
}
Reflect.set(pluginObject, 'extensionConfigs', extensions);
}
// config pages // config pages
if (Reflect.has(pluginObject, 'configPages')) { if (Reflect.has(pluginObject, 'configPages')) {
const configPages: NonNullable<GrafanaPlugin['configPages']> = Reflect.get(pluginObject, 'configPages') ?? []; const configPages: NonNullable<GrafanaPlugin['configPages']> = Reflect.get(pluginObject, 'configPages') ?? [];
@@ -1,5 +1,5 @@
import { NavModelItem } from '@grafana/data'; import { NavModelItem } from '@grafana/data';
import { getPluginExtensions, isPluginExtensionLink } from '@grafana/runtime'; import { usePluginLinks } from '@grafana/runtime';
import { Button, LinkButton, Stack, Text } from '@grafana/ui'; import { Button, LinkButton, Stack, Text } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page'; import { Page } from 'app/core/components/Page/Page';
import { useAppNotification } from 'app/core/copy/appNotification'; import { useAppNotification } from 'app/core/copy/appNotification';
@@ -46,21 +46,18 @@ export const TestStuffPage = () => {
}; };
function LinkToBasicApp({ extensionPointId }: { extensionPointId: string }) { function LinkToBasicApp({ extensionPointId }: { extensionPointId: string }) {
const { extensions } = getPluginExtensions({ extensionPointId }); const { links } = usePluginLinks({ extensionPointId });
if (extensions.length === 0) { if (links.length === 0) {
return null; return null;
} }
return ( return (
<div> <div>
{extensions.map((extension, i) => { {links.map((link, i) => {
if (!isPluginExtensionLink(extension)) {
return null;
}
return ( return (
<LinkButton href={extension.path} title={extension.description} key={extension.id}> <LinkButton href={link.path} title={link.description} key={link.id}>
{extension.title} {link.title}
</LinkButton> </LinkButton>
); );
})} })}
+22 -5
View File
@@ -1,5 +1,5 @@
import { DataFrame, PanelMenuItem } from '@grafana/data'; import { DataFrame, PanelMenuItem } from '@grafana/data';
import { getPluginLinkExtensions } from '@grafana/runtime'; import { isPluginExtensionLink } from '@grafana/runtime';
import { import {
SceneComponentProps, SceneComponentProps,
sceneGraph, sceneGraph,
@@ -11,6 +11,9 @@ import {
} from '@grafana/scenes'; } from '@grafana/scenes';
import { getExploreUrl } from 'app/core/utils/explore'; import { getExploreUrl } from 'app/core/utils/explore';
import { getQueryRunnerFor } from 'app/features/dashboard-scene/utils/utils'; import { getQueryRunnerFor } from 'app/features/dashboard-scene/utils/utils';
import { createPluginExtensionsGetter } from 'app/features/plugins/extensions/getPluginExtensions';
import { pluginExtensionRegistries } from 'app/features/plugins/extensions/registry/setup';
import { type GetPluginExtensions } from 'app/features/plugins/extensions/types';
import { AddToExplorationButton, extensionPointId } from '../MetricSelect/AddToExplorationsButton'; import { AddToExplorationButton, extensionPointId } from '../MetricSelect/AddToExplorationsButton';
import { getDataSource, getTrailFor } from '../utils'; import { getDataSource, getTrailFor } from '../utils';
@@ -28,6 +31,18 @@ interface PanelMenuState extends SceneObjectState {
explorationsButton?: AddToExplorationButton; explorationsButton?: AddToExplorationButton;
} }
let getPluginExtensions: GetPluginExtensions;
function setupGetPluginExtensions() {
if (getPluginExtensions) {
return getPluginExtensions;
}
getPluginExtensions = createPluginExtensionsGetter(pluginExtensionRegistries);
return getPluginExtensions;
}
/** /**
* @todo the VizPanelMenu interface is overly restrictive, doesn't allow any member functions on this class, so everything is currently inlined * @todo the VizPanelMenu interface is overly restrictive, doesn't allow any member functions on this class, so everything is currently inlined
*/ */
@@ -93,6 +108,8 @@ export class PanelMenu extends SceneObjectBase<PanelMenuState> implements VizPan
this.state.explorationsButton?.activate(); this.state.explorationsButton?.activate();
} }
}); });
setupGetPluginExtensions();
} }
addItem(item: PanelMenuItem): void { addItem(item: PanelMenuItem): void {
@@ -119,12 +136,12 @@ export class PanelMenu extends SceneObjectBase<PanelMenuState> implements VizPan
} }
const getInvestigationLink = (addToExplorations: AddToExplorationButton) => { const getInvestigationLink = (addToExplorations: AddToExplorationButton) => {
const links = getPluginLinkExtensions({ const links = getPluginExtensions({
extensionPointId: extensionPointId, extensionPointId,
context: addToExplorations.state.context, context: addToExplorations.state.context,
}); }).extensions.filter((ext) => isPluginExtensionLink(ext));
return links.extensions[0]; return links[0];
}; };
const onAddToInvestigationClick = (event: React.MouseEvent, addToExplorations: AddToExplorationButton) => { const onAddToInvestigationClick = (event: React.MouseEvent, addToExplorations: AddToExplorationButton) => {
@@ -2,7 +2,6 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { CoreApp, PluginType } from '@grafana/data'; import { CoreApp, PluginType } from '@grafana/data';
import { setPluginExtensionsHook } from '@grafana/runtime';
import { PyroscopeDataSource } from '../datasource'; import { PyroscopeDataSource } from '../datasource';
import { mockFetchPyroscopeDatasourceSettings } from '../datasource.test'; import { mockFetchPyroscopeDatasourceSettings } from '../datasource.test';
@@ -12,7 +11,6 @@ import { Props, QueryEditor } from './QueryEditor';
describe('QueryEditor', () => { describe('QueryEditor', () => {
beforeEach(() => { beforeEach(() => {
setPluginExtensionsHook(() => ({ extensions: [], isLoading: false })); // No extensions
mockFetchPyroscopeDatasourceSettings(); mockFetchPyroscopeDatasourceSettings();
}); });
@@ -7,7 +7,7 @@ import {
DataSourceJsonData, DataSourceJsonData,
makeTimeRange, makeTimeRange,
} from '@grafana/data'; } from '@grafana/data';
import { setPluginExtensionsHook, getBackendSrv, setBackendSrv, TemplateSrv } from '@grafana/runtime'; import { getBackendSrv, setBackendSrv, TemplateSrv } from '@grafana/runtime';
import { defaultPyroscopeQueryType } from './dataquery.gen'; import { defaultPyroscopeQueryType } from './dataquery.gen';
import { normalizeQuery, PyroscopeDataSource } from './datasource'; import { normalizeQuery, PyroscopeDataSource } from './datasource';
@@ -35,7 +35,6 @@ export function mockFetchPyroscopeDatasourceSettings(
function setupDatasource() { function setupDatasource() {
mockFetchPyroscopeDatasourceSettings(); mockFetchPyroscopeDatasourceSettings();
setPluginExtensionsHook(() => ({ extensions: [], isLoading: false })); // No extensions
const templateSrv = { const templateSrv = {
replace: (query: string): string => { replace: (query: string): string => {
return query.replace(/\$var/g, 'interpolated'); return query.replace(/\$var/g, 'interpolated');