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>
pull/102527/head^2
Levente Balogh 3 months ago committed by GitHub
parent c1cadc7d6f
commit 39dcff23f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .betterer.results
  2. 6
      e2e/test-plugins/grafana-extensionstest-app/components/App/App.tsx
  3. 8
      e2e/test-plugins/grafana-extensionstest-app/module.tsx
  4. 3
      e2e/test-plugins/grafana-extensionstest-app/package.json
  5. 62
      e2e/test-plugins/grafana-extensionstest-app/pages/LegacyGetters.tsx
  6. 62
      e2e/test-plugins/grafana-extensionstest-app/pages/LegacyHooks.tsx
  7. 2
      e2e/test-plugins/grafana-extensionstest-app/pages/index.tsx
  8. 16
      e2e/test-plugins/grafana-extensionstest-app/plugin.json
  9. 2
      e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/components/App/App.tsx
  10. 6
      e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/module.tsx
  11. 17
      e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/module.tsx
  12. 8
      e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/components/App/AddedLinks.tsx
  13. 17
      e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/module.tsx
  14. 52
      e2e/test-plugins/grafana-extensionstest-app/tests/legacy/extensionPoints.getters.spec.ts
  15. 67
      e2e/test-plugins/grafana-extensionstest-app/tests/legacy/extensionPoints.hooks.spec.ts
  16. 42
      e2e/test-plugins/grafana-extensionstest-app/tests/legacy/linkExtensions.spec.ts
  17. 32
      e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts
  18. 3
      packages/grafana-data/src/index.ts
  19. 22
      packages/grafana-data/src/types/app.ts
  20. 60
      packages/grafana-data/src/types/pluginExtensions.ts
  21. 18
      packages/grafana-runtime/src/services/index.ts
  22. 39
      packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.test.ts
  23. 67
      packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts
  24. 262
      packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.test.tsx
  25. 59
      packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts
  26. 7
      public/app/app.ts
  27. 6
      public/app/features/alerting/unified/testSetup/plugins.ts
  28. 19
      public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx
  29. 8
      public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx
  30. 7
      public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx
  31. 35
      public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx
  32. 28
      public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx
  33. 9
      public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts
  34. 273
      public/app/features/plugins/extensions/usePluginExtensions.test.tsx
  35. 86
      public/app/features/plugins/extensions/usePluginExtensions.tsx
  36. 9
      public/app/features/plugins/extensions/utils.tsx
  37. 18
      public/app/features/plugins/extensions/validators.test.tsx
  38. 13
      public/app/features/plugins/sandbox/sandbox_components.tsx
  39. 15
      public/app/features/sandbox/TestStuffPage.tsx
  40. 27
      public/app/features/trails/Menu/PanelMenu.tsx
  41. 2
      public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx
  42. 3
      public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts

@ -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.", "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": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-runtime/src/services/pluginExtensions/usePluginComponents.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts:5381": [
[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 { 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"');
});

@ -552,10 +552,7 @@ export {
type PluginExtensionComponent,
type PluginExtensionComponentMeta,
type ComponentTypeWithExtensionMeta,
type PluginExtensionConfig,
type PluginExtensionFunction,
type PluginExtensionLinkConfig,
type PluginExtensionComponentConfig,
type PluginExtensionEventHelpers,
type PluginExtensionPanelContext,
type PluginExtensionQueryEditorRowAdaptiveTelemetryV1Context,

@ -6,8 +6,6 @@ import { KeyValue } from './data';
import { NavModel } from './navModel';
import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin';
import {
type PluginExtensionLinkConfig,
PluginExtensionComponentConfig,
PluginExtensionExposedComponentConfig,
PluginExtensionAddedComponentConfig,
PluginExtensionAddedLinkConfig,
@ -142,26 +140,6 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
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>;
};
export type PluginExtensionConfig = PluginExtensionLinkConfig | PluginExtensionComponentConfig;
export type PluginExtensionOpenModalOptions = {
// The title of the modal
title: string;
@ -236,61 +234,3 @@ type Dashboard = {
title: 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 './SidecarContext_EXPERIMENTAL';
export {
setPluginExtensionGetter,
getPluginExtensions,
getPluginLinkExtensions,
getPluginComponentExtensions,
type GetPluginExtensions,
type GetPluginExtensionsOptions,
type GetPluginExtensionsResult,
type UsePluginExtensions,
type UsePluginExtensionsResult,
} from './pluginExtensions/getPluginExtensions';
export {
setPluginExtensionsHook,
usePluginExtensions,
usePluginLinkExtensions,
usePluginComponentExtensions,
} from './pluginExtensions/usePluginExtensions';
export {
setPluginComponentHook,
usePluginComponent,

@ -1,39 +0,0 @@
import { setPluginExtensionGetter, type GetPluginExtensions, getPluginExtensions } from './getPluginExtensions';
describe('Plugin Extensions / Get Plugin Extensions', () => {
afterEach(() => {
process.env.NODE_ENV = 'test';
});
test('should always return the same extension-getter function that was previously set', () => {
const getter: GetPluginExtensions = jest.fn().mockReturnValue({ extensions: [] });
setPluginExtensionGetter(getter);
getPluginExtensions({ extensionPointId: 'panel-menu' });
expect(getter).toHaveBeenCalledTimes(1);
expect(getter).toHaveBeenCalledWith({ extensionPointId: 'panel-menu' });
});
test('should throw an error when trying to redefine the app-wide extension-getter function', () => {
// By default, NODE_ENV is set to 'test' in jest.config.js, which allows to override the registry in tests.
process.env.NODE_ENV = 'production';
const getter: GetPluginExtensions = () => ({ extensions: [] });
expect(() => {
setPluginExtensionGetter(getter);
setPluginExtensionGetter(getter);
}).toThrowError();
});
test('should throw an error when trying to access the extension-getter function before it was set', () => {
// "Unsetting" the registry
// @ts-ignore
setPluginExtensionGetter(undefined);
expect(() => {
getPluginExtensions({ extensionPointId: 'panel-menu' });
}).toThrowError();
});
});

@ -1,67 +0,0 @@
import type { PluginExtension, PluginExtensionLink, PluginExtensionComponent } from '@grafana/data';
import { isPluginExtensionComponent, isPluginExtensionLink } from './utils';
export type GetPluginExtensions<T = PluginExtension> = (
options: GetPluginExtensionsOptions
) => GetPluginExtensionsResult<T>;
export type UsePluginExtensions<T = PluginExtension> = (
options: GetPluginExtensionsOptions
) => UsePluginExtensionsResult<T>;
export type GetPluginExtensionsOptions = {
extensionPointId: string;
// Make sure this object is properly memoized and not mutated.
context?: object | Record<string | symbol, unknown>;
limitPerPlugin?: number;
};
export type GetPluginExtensionsResult<T = PluginExtension> = {
extensions: T[];
};
export type UsePluginExtensionsResult<T = PluginExtension> = {
extensions: T[];
isLoading: boolean;
};
let singleton: GetPluginExtensions | undefined;
export function setPluginExtensionGetter(instance: GetPluginExtensions): void {
// We allow overriding the registry in tests
if (singleton && process.env.NODE_ENV !== 'test') {
throw new Error('setPluginExtensionGetter() function should only be called once, when Grafana is starting.');
}
singleton = instance;
}
function getPluginExtensionGetter(): GetPluginExtensions {
if (!singleton) {
throw new Error('getPluginExtensionGetter() can only be used after the Grafana instance has started.');
}
return singleton;
}
export const getPluginExtensions: GetPluginExtensions = (options) => getPluginExtensionGetter()(options);
export const getPluginLinkExtensions: GetPluginExtensions<PluginExtensionLink> = (options) => {
const { extensions } = getPluginExtensions(options);
return {
extensions: extensions.filter(isPluginExtensionLink),
};
};
// This getter doesn't support the `context` option (contextual information can be passed in as component props)
export const getPluginComponentExtensions = <Props = {}>(options: {
extensionPointId: string;
limitPerPlugin?: number;
}): { extensions: Array<PluginExtensionComponent<Props>> } => {
const { extensions } = getPluginExtensions(options);
const componentExtensions = extensions.filter(isPluginExtensionComponent) as Array<PluginExtensionComponent<Props>>;
return {
extensions: componentExtensions,
};
};

@ -1,262 +0,0 @@
import { renderHook } from '@testing-library/react';
import { PluginExtension, PluginExtensionTypes } from '@grafana/data';
import { UsePluginExtensions } from './getPluginExtensions';
import {
setPluginExtensionsHook,
usePluginComponentExtensions,
usePluginExtensions,
usePluginLinkExtensions,
} from './usePluginExtensions';
describe('Plugin Extensions / usePluginExtensions', () => {
afterEach(() => {
process.env.NODE_ENV = 'test';
});
test('should always return the same extension-hook function that was previously set', () => {
const hook: UsePluginExtensions = jest.fn().mockReturnValue({ extensions: [], isLoading: false });
setPluginExtensionsHook(hook);
usePluginExtensions({ extensionPointId: 'panel-menu' });
expect(hook).toHaveBeenCalledTimes(1);
expect(hook).toHaveBeenCalledWith({ extensionPointId: 'panel-menu' });
});
test('should throw an error when trying to redefine the app-wide extension-hook function', () => {
// By default, NODE_ENV is set to 'test' in jest.config.js, which allows to override the registry in tests.
process.env.NODE_ENV = 'production';
const hook: UsePluginExtensions = () => ({ extensions: [], isLoading: false });
expect(() => {
setPluginExtensionsHook(hook);
setPluginExtensionsHook(hook);
}).toThrow();
});
test('should throw an error when trying to access the extension-hook function before it was set', () => {
// "Unsetting" the registry
// @ts-ignore
setPluginExtensionsHook(undefined);
expect(() => {
usePluginExtensions({ extensionPointId: 'panel-menu' });
}).toThrow();
});
describe('usePluginExtensionLinks()', () => {
test('should return only links extensions', () => {
const usePluginExtensionsMock: UsePluginExtensions = () => ({
extensions: [
{
id: '1',
pluginId: '',
title: '',
description: '',
type: PluginExtensionTypes.component,
component: () => undefined,
},
{
id: '2',
pluginId: '',
title: '',
description: '',
path: '',
type: PluginExtensionTypes.link,
},
{
id: '3',
pluginId: '',
title: '',
description: '',
path: '',
type: PluginExtensionTypes.link,
},
],
isLoading: false,
});
setPluginExtensionsHook(usePluginExtensionsMock);
const { result } = renderHook(() => usePluginLinkExtensions({ extensionPointId: 'panel-menu' }));
const { extensions } = result.current;
expect(extensions).toHaveLength(2);
expect(extensions[0].type).toBe('link');
expect(extensions[1].type).toBe('link');
expect(extensions.find(({ id }) => id === '2')).toBeDefined();
expect(extensions.find(({ id }) => id === '3')).toBeDefined();
});
test('should return the same object if the extensions do not change', () => {
const extensionPointId = 'foo';
const extensions: PluginExtension[] = [
{
id: '1',
pluginId: '',
title: '',
description: '',
path: '',
type: PluginExtensionTypes.link,
},
];
// Mimicing that the extensions do not change between renders
const usePluginExtensionsMock: UsePluginExtensions = () => ({
extensions,
isLoading: false,
});
setPluginExtensionsHook(usePluginExtensionsMock);
const { result, rerender } = renderHook(() => usePluginLinkExtensions({ extensionPointId }));
const firstExtensions = result.current.extensions;
rerender();
const secondExtensions = result.current.extensions;
expect(firstExtensions === secondExtensions).toBe(true);
});
test('should return a different object if the extensions do change', () => {
const extensionPointId = 'foo';
// Mimicing that the extensions is a new array object every time
const usePluginExtensionsMock: UsePluginExtensions = () => ({
extensions: [
{
id: '1',
pluginId: '',
title: '',
description: '',
path: '',
type: PluginExtensionTypes.link,
},
],
isLoading: false,
});
setPluginExtensionsHook(usePluginExtensionsMock);
const { result, rerender } = renderHook(() => usePluginLinkExtensions({ extensionPointId }));
const firstExtensions = result.current.extensions;
rerender();
const secondExtensions = result.current.extensions;
// The results differ
expect(firstExtensions === secondExtensions).toBe(false);
});
});
describe('usePluginExtensionComponents()', () => {
test('should return only component extensions', () => {
const hook: UsePluginExtensions = () => ({
extensions: [
{
id: '1',
pluginId: '',
title: '',
description: '',
type: PluginExtensionTypes.component,
component: () => undefined,
},
{
id: '2',
pluginId: '',
title: '',
description: '',
path: '',
type: PluginExtensionTypes.link,
},
{
id: '3',
pluginId: '',
title: '',
description: '',
path: '',
type: PluginExtensionTypes.link,
},
],
isLoading: false,
});
setPluginExtensionsHook(hook);
const hookRender = renderHook(() => usePluginComponentExtensions({ extensionPointId: 'panel-menu' }));
const { extensions } = hookRender.result.current;
expect(extensions).toHaveLength(1);
expect(extensions[0].type).toBe('component');
expect(extensions.find(({ id }) => id === '1')).toBeDefined();
});
test('should return the same object if the extensions do not change', () => {
const extensionPointId = 'foo';
const extensions: PluginExtension[] = [
{
id: '1',
pluginId: '',
title: '',
description: '',
type: PluginExtensionTypes.component,
component: () => undefined,
},
];
// Mimicing that the extensions do not change between renders
const usePluginExtensionsMock: UsePluginExtensions = () => ({
extensions,
isLoading: false,
});
setPluginExtensionsHook(usePluginExtensionsMock);
const { result, rerender } = renderHook(() => usePluginComponentExtensions({ extensionPointId }));
const firstExtensions = result.current.extensions;
rerender();
const secondExtensions = result.current.extensions;
// The results are the same
expect(firstExtensions === secondExtensions).toBe(true);
});
test('should return a different object if the extensions do change', () => {
const extensionPointId = 'foo';
// Mimicing that the extensions is a new array object every time
const usePluginExtensionsMock: UsePluginExtensions = () => ({
extensions: [
{
id: '1',
pluginId: '',
title: '',
description: '',
type: PluginExtensionTypes.component,
component: () => undefined,
},
],
isLoading: false,
});
setPluginExtensionsHook(usePluginExtensionsMock);
const { result, rerender } = renderHook(() => usePluginComponentExtensions({ extensionPointId }));
const firstExtensions = result.current.extensions;
rerender();
const secondExtensions = result.current.extensions;
// The results differ
expect(firstExtensions === secondExtensions).toBe(false);
});
});
});

@ -1,59 +0,0 @@
import { useMemo } from 'react';
import { PluginExtensionComponent, PluginExtensionLink } from '@grafana/data';
import { GetPluginExtensionsOptions, UsePluginExtensions, UsePluginExtensionsResult } from './getPluginExtensions';
import { isPluginExtensionComponent, isPluginExtensionLink } from './utils';
let singleton: UsePluginExtensions | undefined;
export function setPluginExtensionsHook(hook: UsePluginExtensions): void {
// We allow overriding the registry in tests
if (singleton && process.env.NODE_ENV !== 'test') {
throw new Error('setPluginExtensionsHook() function should only be called once, when Grafana is starting.');
}
singleton = hook;
}
/**
* @deprecated Use either usePluginLinks() or usePluginComponents() instead.
*/
export function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult {
if (!singleton) {
throw new Error('usePluginExtensions(options) can only be used after the Grafana instance has started.');
}
return singleton(options);
}
/**
* @deprecated Use usePluginLinks() instead.
*/
export function usePluginLinkExtensions(
options: GetPluginExtensionsOptions
): UsePluginExtensionsResult<PluginExtensionLink> {
const { extensions, isLoading } = usePluginExtensions(options);
return useMemo(() => {
return {
extensions: extensions.filter(isPluginExtensionLink),
isLoading,
};
}, [extensions, isLoading]);
}
/**
* @deprecated Use usePluginComponents() instead.
*/
export function usePluginComponentExtensions<Props = {}>(
options: GetPluginExtensionsOptions
): { extensions: Array<PluginExtensionComponent<Props>>; isLoading: boolean } {
const { extensions, isLoading } = usePluginExtensions(options);
return useMemo(
() => ({
extensions: extensions.filter(isPluginExtensionComponent) as Array<PluginExtensionComponent<Props>>,
isLoading,
}),
[extensions, isLoading]
);
}

@ -28,11 +28,9 @@ import {
setQueryRunnerFactory,
setRunRequest,
setPluginImportUtils,
setPluginExtensionGetter,
setEmbeddedDashboard,
setAppEvents,
setReturnToPreviousHook,
setPluginExtensionsHook,
setPluginComponentHook,
setPluginComponentsHook,
setCurrentUser,
@ -83,14 +81,11 @@ import { PanelDataErrorView } from './features/panel/components/PanelDataErrorVi
import { PanelRenderer } from './features/panel/components/PanelRenderer';
import { DatasourceSrv } from './features/plugins/datasource_srv';
import {
createPluginExtensionsGetter,
getObservablePluginComponents,
getObservablePluginLinks,
} from './features/plugins/extensions/getPluginExtensions';
import { pluginExtensionRegistries } from './features/plugins/extensions/registry/setup';
import { usePluginComponent } from './features/plugins/extensions/usePluginComponent';
import { usePluginComponents } from './features/plugins/extensions/usePluginComponents';
import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions';
import { usePluginFunctions } from './features/plugins/extensions/usePluginFunctions';
import { usePluginLinks } from './features/plugins/extensions/usePluginLinks';
import { getAppPluginsToAwait, getAppPluginsToPreload } from './features/plugins/extensions/utils';
@ -226,8 +221,6 @@ export class GrafanaApp {
await preloadPlugins(appPluginsToAwait);
}
setPluginExtensionGetter(createPluginExtensionsGetter(pluginExtensionRegistries));
setPluginExtensionsHook(createUsePluginExtensions(pluginExtensionRegistries));
setPluginLinksHook(usePluginLinks);
setPluginComponentHook(usePluginComponent);
setPluginComponentsHook(usePluginComponents);

@ -1,12 +1,12 @@
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 { mockPluginLinkExtension } from '../mocks';
export function setupPluginsExtensionsHook() {
setPluginExtensionsHook(() => ({
extensions: plugins.map((plugin) =>
setPluginLinksHook(() => ({
links: plugins.map((plugin) =>
mockPluginLinkExtension({
pluginId: plugin.id,
title: plugin.name,

@ -8,13 +8,7 @@ import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { PanelProps, systemDateFormats, SystemDateFormatsState } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test';
import { selectors } from '@grafana/e2e-selectors';
import {
LocationServiceProvider,
config,
getPluginLinkExtensions,
locationService,
setPluginImportUtils,
} from '@grafana/runtime';
import { LocationServiceProvider, config, locationService, setPluginImportUtils } from '@grafana/runtime';
import { VizPanel } from '@grafana/scenes';
import { Dashboard } from '@grafana/schema';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
@ -32,7 +26,6 @@ import { getDashboardScenePageStateManager } from './DashboardScenePageStateMana
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
setPluginExtensionGetter: jest.fn(),
getPluginLinkExtensions: jest.fn(),
useChromeHeaderHeight: jest.fn().mockReturnValue(80),
getBackendSrv: () => {
return {
@ -55,7 +48,11 @@ jest.mock('react-router-dom-v5-compat', () => ({
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> } = {}) {
const context = getGrafanaContextMock();
@ -156,8 +153,8 @@ describe('DashboardScenePage', () => {
// hacky way because mocking autosizer does not work
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 1000 });
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 1000 });
getPluginLinkExtensionsMock.mockRestore();
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] });
getPluginExtensionsMock.mockRestore();
getPluginExtensionsMock.mockReturnValue({ extensions: [] });
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 { getPanelPlugin } from '@grafana/data/test';
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 { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { DashboardRoutes } from 'app/types/dashboard';
@ -18,8 +18,6 @@ import { PublicDashboardScenePage, Props as PublicDashboardSceneProps } from './
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
setPluginExtensionGetter: jest.fn(),
getPluginLinkExtensions: jest.fn(),
getDataSourceSrv: () => {
return {
get: jest.fn().mockResolvedValue({}),
@ -28,8 +26,6 @@ jest.mock('@grafana/runtime', () => ({
},
}));
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions);
function setup(token = 'an-access-token') {
const pubdashProps: PublicDashboardSceneProps = {
...getRouteComponentProps({
@ -126,8 +122,6 @@ describe('PublicDashboardScenePage', () => {
// // hacky way because mocking autosizer does not work
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 1000 });
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 1000 });
getPluginLinkExtensionsMock.mockRestore();
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] });
});
it('can render public dashboard', async () => {

@ -18,7 +18,7 @@ import {
} from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test';
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 { InspectTab } from 'app/features/inspector/types';
import { SHARED_DASHBOARD_QUERY, DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/constants';
@ -56,11 +56,6 @@ async function createModelMock() {
return queriesTab;
}
setPluginExtensionsHook(() => ({
extensions: [],
isLoading: false,
}));
const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => {
const result: PanelData = {
state: LoadingState.Loading,

@ -9,7 +9,7 @@ import {
urlUtil,
} from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test';
import { config, getPluginLinkExtensions, locationService } from '@grafana/runtime';
import { config, locationService } from '@grafana/runtime';
import {
LocalValueVariable,
SceneQueryRunner,
@ -47,24 +47,17 @@ jest.mock('app/core/utils/explore', () => ({
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', () => ({
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', () => {
beforeEach(() => {
getPluginLinkExtensionsMock.mockRestore();
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] });
});
beforeAll(() => {
locationService.push('/d/dash-1?from=now-5m&to=now');
});
@ -133,7 +126,7 @@ describe('panelMenuBehavior', () => {
describe('when extending panel menu from plugins', () => {
it('should contain menu item from link extension', async () => {
getPluginLinkExtensionsMock.mockReturnValue({
getPluginExtensionsMock.mockReturnValue({
extensions: [
{
id: '1',
@ -172,7 +165,7 @@ describe('panelMenuBehavior', () => {
});
it('should truncate menu item title to 25 chars', async () => {
getPluginLinkExtensionsMock.mockReturnValue({
getPluginExtensionsMock.mockReturnValue({
extensions: [
{
id: '1',
@ -213,7 +206,7 @@ describe('panelMenuBehavior', () => {
it('should pass onClick from plugin extension link to menu item', async () => {
const expectedOnClick = jest.fn();
getPluginLinkExtensionsMock.mockReturnValue({
getPluginExtensionsMock.mockReturnValue({
extensions: [
{
id: '1',
@ -299,7 +292,7 @@ describe('panelMenuBehavior', () => {
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 () => {
@ -356,11 +349,11 @@ describe('panelMenuBehavior', () => {
data,
};
expect(getPluginLinkExtensionsMock).toBeCalledWith(expect.objectContaining({ context }));
expect(getPluginExtensionsMock).toBeCalledWith(expect.objectContaining({ context }));
});
it('should contain menu item with category', async () => {
getPluginLinkExtensionsMock.mockReturnValue({
getPluginExtensionsMock.mockReturnValue({
extensions: [
{
id: '1',
@ -405,7 +398,7 @@ describe('panelMenuBehavior', () => {
});
it('should truncate category to 25 chars', async () => {
getPluginLinkExtensionsMock.mockReturnValue({
getPluginExtensionsMock.mockReturnValue({
extensions: [
{
id: '1',
@ -450,7 +443,7 @@ describe('panelMenuBehavior', () => {
});
it('should contain menu item with category and append items without category after divider', async () => {
getPluginLinkExtensionsMock.mockReturnValue({
getPluginExtensionsMock.mockReturnValue({
extensions: [
{
id: '1',

@ -6,9 +6,10 @@ import {
PanelPlugin,
PluginExtensionPanelContext,
PluginExtensionPoints,
PluginExtensionTypes,
urlUtil,
} 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 { DataQuery, OptionsWithLegend } from '@grafana/schema';
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 { InspectTab } from 'app/features/inspector/types';
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 { addDataTrailPanelAction } from 'app/features/trails/Integrations/dashboardIntegration';
import { dispatch } from 'app/store/store';
@ -39,6 +43,18 @@ import { DashboardScene } from './DashboardScene';
import { VizPanelLinks, VizPanelLinksMenu } from './PanelLinks';
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).
*/
@ -286,20 +302,22 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
items.push(getInspectMenuItem(plugin, panel, dashboard));
// TODO: make sure that this works reliably with the reactive extension registry
// (we need to be able to know in advance what extensions should be loaded for this extension point, and make it possible to await for them.)
const { extensions } = getPluginLinkExtensions({
setupGetPluginExtensions();
const { extensions } = getPluginExtensions({
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
context: createExtensionContext(panel, dashboard),
limitPerPlugin: 3,
});
const linkExtensions = extensions.filter((extension) => extension.type === PluginExtensionTypes.link);
if (extensions.length > 0 && !dashboard.state.isEditing) {
items.push({
text: 'Extensions',
iconClassName: 'plug',
type: 'submenu',
subMenu: createExtensionSubMenu(extensions),
subMenu: createExtensionSubMenu(linkExtensions),
});
}

@ -14,7 +14,7 @@ import {
VariableSupportType,
} from '@grafana/data';
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 { Dashboard, LoadingState, Panel, RowPanel, VariableRefresh } from '@grafana/schema';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
@ -154,8 +154,6 @@ jest.mock('@grafana/data', () => ({
setWeekStart: jest.fn(),
}));
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions);
jest.mock('@grafana/scenes', () => ({
...jest.requireActual('@grafana/scenes'),
sceneUtils: {
@ -165,11 +163,6 @@ jest.mock('@grafana/scenes', () => ({
}));
describe('transformSceneToSaveModel', () => {
beforeEach(() => {
getPluginLinkExtensionsMock.mockRestore();
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] });
});
describe('Given a simple scene with custom settings', () => {
it('Should transform back to persisted model', () => {
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 {
type PluginExtensionLinkConfig,
type PluginExtensionConfig,
type PluginExtensionEventHelpers,
PluginExtensionTypes,
type PluginExtensionOpenModalOptions,
isDateTime,
dateTime,
@ -29,12 +26,6 @@ import { ExtensionsLog, log } from './logs/log';
import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry';
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 = '') {
return (...args: unknown[]) => {
try {

@ -3,7 +3,6 @@ import { memo } from 'react';
import {
PluginContextType,
PluginExtensionAddedLinkConfig,
PluginExtensionLinkConfig,
PluginExtensionPoints,
PluginLoadingStrategy,
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', () => {
expect(() => {
assertConfigureIsValid(
// @ts-ignore
{
title: 'Title',
description: 'Description',
extensionPointId: 'grafana/some-page/extension-point-a',
handler: () => {},
configure: '() => {}',
} as PluginExtensionLinkConfig
);
assertConfigureIsValid({
title: 'Title',
description: 'Description',
extensionPointId: 'grafana/some-page/extension-point-a',
handler: () => {},
configure: '() => {}',
} as unknown as PluginExtensionAddedLinkConfig); // We are casting to unknown to test it with a unvalid argument
}).toThrowError();
});
});

@ -2,7 +2,7 @@ import { isFunction } from 'lodash';
import { ComponentType, FC } 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 { isSandboxedPluginObject } from './utils';
@ -58,17 +58,6 @@ export async function sandboxPluginComponents(
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
if (Reflect.has(pluginObject, 'configPages')) {
const configPages: NonNullable<GrafanaPlugin['configPages']> = Reflect.get(pluginObject, 'configPages') ?? [];

@ -1,5 +1,5 @@
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 { Page } from 'app/core/components/Page/Page';
import { useAppNotification } from 'app/core/copy/appNotification';
@ -46,21 +46,18 @@ export const TestStuffPage = () => {
};
function LinkToBasicApp({ extensionPointId }: { extensionPointId: string }) {
const { extensions } = getPluginExtensions({ extensionPointId });
const { links } = usePluginLinks({ extensionPointId });
if (extensions.length === 0) {
if (links.length === 0) {
return null;
}
return (
<div>
{extensions.map((extension, i) => {
if (!isPluginExtensionLink(extension)) {
return null;
}
{links.map((link, i) => {
return (
<LinkButton href={extension.path} title={extension.description} key={extension.id}>
{extension.title}
<LinkButton href={link.path} title={link.description} key={link.id}>
{link.title}
</LinkButton>
);
})}

@ -1,5 +1,5 @@
import { DataFrame, PanelMenuItem } from '@grafana/data';
import { getPluginLinkExtensions } from '@grafana/runtime';
import { isPluginExtensionLink } from '@grafana/runtime';
import {
SceneComponentProps,
sceneGraph,
@ -11,6 +11,9 @@ import {
} from '@grafana/scenes';
import { getExploreUrl } from 'app/core/utils/explore';
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 { getDataSource, getTrailFor } from '../utils';
@ -28,6 +31,18 @@ interface PanelMenuState extends SceneObjectState {
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
*/
@ -93,6 +108,8 @@ export class PanelMenu extends SceneObjectBase<PanelMenuState> implements VizPan
this.state.explorationsButton?.activate();
}
});
setupGetPluginExtensions();
}
addItem(item: PanelMenuItem): void {
@ -119,12 +136,12 @@ export class PanelMenu extends SceneObjectBase<PanelMenuState> implements VizPan
}
const getInvestigationLink = (addToExplorations: AddToExplorationButton) => {
const links = getPluginLinkExtensions({
extensionPointId: extensionPointId,
const links = getPluginExtensions({
extensionPointId,
context: addToExplorations.state.context,
});
}).extensions.filter((ext) => isPluginExtensionLink(ext));
return links.extensions[0];
return links[0];
};
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 { CoreApp, PluginType } from '@grafana/data';
import { setPluginExtensionsHook } from '@grafana/runtime';
import { PyroscopeDataSource } from '../datasource';
import { mockFetchPyroscopeDatasourceSettings } from '../datasource.test';
@ -12,7 +11,6 @@ import { Props, QueryEditor } from './QueryEditor';
describe('QueryEditor', () => {
beforeEach(() => {
setPluginExtensionsHook(() => ({ extensions: [], isLoading: false })); // No extensions
mockFetchPyroscopeDatasourceSettings();
});

@ -7,7 +7,7 @@ import {
DataSourceJsonData,
makeTimeRange,
} from '@grafana/data';
import { setPluginExtensionsHook, getBackendSrv, setBackendSrv, TemplateSrv } from '@grafana/runtime';
import { getBackendSrv, setBackendSrv, TemplateSrv } from '@grafana/runtime';
import { defaultPyroscopeQueryType } from './dataquery.gen';
import { normalizeQuery, PyroscopeDataSource } from './datasource';
@ -35,7 +35,6 @@ export function mockFetchPyroscopeDatasourceSettings(
function setupDatasource() {
mockFetchPyroscopeDatasourceSettings();
setPluginExtensionsHook(() => ({ extensions: [], isLoading: false })); // No extensions
const templateSrv = {
replace: (query: string): string => {
return query.replace(/\$var/g, 'interpolated');

Loading…
Cancel
Save