From 39dcff23f901d3baecb7929c11da08b4e991e9ba Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Wed, 9 Apr 2025 10:24:29 +0200 Subject: [PATCH] Plugin Extensions: Clean up the deprecated APIs (#102102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 53aa7676647a50785cb18098c6cc4c81bad5b4c7. * Revert "feat(Extensions): expose an observable API for added links and components" This reverts commit bdc588250e790f91a3c93c7c79448280d39fbd22. --------- Co-authored-by: Hugo Häggmark --- .betterer.results | 6 - .../components/App/App.tsx | 6 +- .../grafana-extensionstest-app/module.tsx | 8 +- .../grafana-extensionstest-app/package.json | 3 +- .../pages/LegacyGetters.tsx | 62 ---- .../pages/LegacyHooks.tsx | 62 ---- .../pages/index.tsx | 2 - .../grafana-extensionstest-app/plugin.json | 16 - .../components/App/App.tsx | 2 +- .../grafana-extensionexample1-app/module.tsx | 6 - .../grafana-extensionexample2-app/module.tsx | 17 -- .../components/App/AddedLinks.tsx | 8 +- .../grafana-extensionexample3-app/module.tsx | 17 -- .../legacy/extensionPoints.getters.spec.ts | 52 ---- .../legacy/extensionPoints.hooks.spec.ts | 67 ----- .../tests/legacy/linkExtensions.spec.ts | 42 --- .../tests/usePluginLinks.spec.ts | 32 ++ packages/grafana-data/src/index.ts | 3 - packages/grafana-data/src/types/app.ts | 22 -- .../src/types/pluginExtensions.ts | 60 ---- .../grafana-runtime/src/services/index.ts | 18 -- .../getPluginExtensions.test.ts | 39 --- .../pluginExtensions/getPluginExtensions.ts | 67 ----- .../usePluginExtensions.test.tsx | 262 ----------------- .../pluginExtensions/usePluginExtensions.ts | 59 ---- public/app/app.ts | 7 - .../alerting/unified/testSetup/plugins.ts | 6 +- .../pages/DashboardScenePage.test.tsx | 19 +- .../pages/PublicDashboardScenePage.test.tsx | 8 +- .../PanelDataQueriesTab.test.tsx | 7 +- .../scene/PanelMenuBehavior.test.tsx | 35 +-- .../scene/PanelMenuBehavior.tsx | 28 +- .../transformSceneToSaveModel.test.ts | 9 +- .../extensions/usePluginExtensions.test.tsx | 273 ------------------ .../extensions/usePluginExtensions.tsx | 86 ------ .../app/features/plugins/extensions/utils.tsx | 9 - .../plugins/extensions/validators.test.tsx | 18 +- .../plugins/sandbox/sandbox_components.tsx | 13 +- public/app/features/sandbox/TestStuffPage.tsx | 15 +- public/app/features/trails/Menu/PanelMenu.tsx | 27 +- .../QueryEditor/QueryEditor.test.tsx | 2 - .../datasource.test.ts | 3 +- 42 files changed, 129 insertions(+), 1374 deletions(-) delete mode 100644 e2e/test-plugins/grafana-extensionstest-app/pages/LegacyGetters.tsx delete mode 100644 e2e/test-plugins/grafana-extensionstest-app/pages/LegacyHooks.tsx delete mode 100644 e2e/test-plugins/grafana-extensionstest-app/tests/legacy/extensionPoints.getters.spec.ts delete mode 100644 e2e/test-plugins/grafana-extensionstest-app/tests/legacy/extensionPoints.hooks.spec.ts delete mode 100644 e2e/test-plugins/grafana-extensionstest-app/tests/legacy/linkExtensions.spec.ts delete mode 100644 packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.test.ts delete mode 100644 packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts delete mode 100644 packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.test.tsx delete mode 100644 packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts delete mode 100644 public/app/features/plugins/extensions/usePluginExtensions.test.tsx delete mode 100644 public/app/features/plugins/extensions/usePluginExtensions.tsx diff --git a/.betterer.results b/.betterer.results index 0f6c8fba7a6..c1f4b9f9ec8 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"] ], diff --git a/e2e/test-plugins/grafana-extensionstest-app/components/App/App.tsx b/e2e/test-plugins/grafana-extensionstest-app/components/App/App.tsx index 2a413c665a6..d57a1476e19 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/components/App/App.tsx +++ b/e2e/test-plugins/grafana-extensionstest-app/components/App/App.tsx @@ -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 (
- } /> - } /> } /> } /> } /> - } /> + } />
); diff --git a/e2e/test-plugins/grafana-extensionstest-app/module.tsx b/e2e/test-plugins/grafana-extensionstest-app/module.tsx index f98d6629e48..25d69140a96 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/module.tsx +++ b/e2e/test-plugins/grafana-extensionstest-app/module.tsx @@ -6,10 +6,10 @@ import pluginJson from './plugin.json'; export const plugin = new AppPlugin<{}>() .setRootPage(App) - .configureExtensionLink({ + .addLink({ 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({ + .addLink({ 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; diff --git a/e2e/test-plugins/grafana-extensionstest-app/package.json b/e2e/test-plugins/grafana-extensionstest-app/package.json index 2e7c91d08e6..d5f05088d35 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/package.json +++ b/e2e/test-plugins/grafana-extensionstest-app/package.json @@ -42,6 +42,5 @@ }, "peerDependencies": { "@grafana/runtime": "*" - }, - "packageManager": "yarn@4.4.0" + } } diff --git a/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyGetters.tsx b/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyGetters.tsx deleted file mode 100644 index 5cebc74d744..00000000000 --- a/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyGetters.tsx +++ /dev/null @@ -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({ - extensionPointId: extensionPointId2, - }); - - return ( - - -
-

- Link extensions defined with configureExtensionLink or configureExtensionComponent and retrived using - getPluginExtensions -

- -
-
-

Link extensions defined with configureExtensionLink and retrived using getPluginLinkExtensions

- -
-
-

- Component extensions defined with configureExtensionComponent and retrived using - getPluginComponentExtensions -

- {componentExtensions.map((extension) => { - const Component = extension.component; - return ; - })} -
-
-
- ); -} diff --git a/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyHooks.tsx b/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyHooks.tsx deleted file mode 100644 index ef63caf9244..00000000000 --- a/e2e/test-plugins/grafana-extensionstest-app/pages/LegacyHooks.tsx +++ /dev/null @@ -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({ - extensionPointId: extensionPointId2, - }); - - return ( - - -
-

- Link extensions defined with configureExtensionLink or configureExtensionComponent and retrived using - usePluginExtensions -

- -
-
-

Link extensions defined with configureExtensionLink and retrived using usePluginLinkExtensions

- -
-
-

- Component extensions defined with configureExtensionComponent and retrived using - usePluginComponentExtensions -

- {componentExtensions.map((extension) => { - const Component = extension.component; - return ; - })} -
-
-
- ); -} diff --git a/e2e/test-plugins/grafana-extensionstest-app/pages/index.tsx b/e2e/test-plugins/grafana-extensionstest-app/pages/index.tsx index 6e955da302b..1326d3c7bdf 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/pages/index.tsx +++ b/e2e/test-plugins/grafana-extensionstest-app/pages/index.tsx @@ -1,5 +1,3 @@ export { ExposedComponents } from './ExposedComponents'; -export { LegacyGetters } from './LegacyGetters'; -export { LegacyHooks } from './LegacyHooks'; export { AddedComponents } from './AddedComponents'; export { AddedLinks } from './AddedLinks'; diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugin.json b/e2e/test-plugins/grafana-extensionstest-app/plugin.json index 39252838461..90978c2c8a2 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/plugin.json +++ b/e2e/test-plugins/grafana-extensionstest-app/plugin.json @@ -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", diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/components/App/App.tsx b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/components/App/App.tsx index cb3af35cd37..03d944bb881 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/components/App/App.tsx +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/components/App/App.tsx @@ -6,7 +6,7 @@ export class App extends React.PureComponent { render() { return (
- Hello Grafana! + Hello Grafana!!!!!
); } diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/module.tsx b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/module.tsx index 4b4dda98d1b..3f99d8f61f2 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/module.tsx +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample1-app/module.tsx @@ -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', diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/module.tsx b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/module.tsx index 5a75974e1ed..12ca716ac46 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/module.tsx +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample2-app/module.tsx @@ -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: () =>
From plugin B
, - }); - }, - }) - .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 }) =>
Hello {name}!
, - }) .addComponent<{ name: string }>({ targets: 'plugins/grafana-extensionstest-app/addComponent/v1', title: 'Added component from B', diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/components/App/AddedLinks.tsx b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/components/App/AddedLinks.tsx index 64df8da1575..422ed064163 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/components/App/AddedLinks.tsx +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/components/App/AddedLinks.tsx @@ -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 (

Link extensions defined with addLink and retrieved using usePluginLinks

-
-

Link extensions defined with addLink and retrieved using usePluginExtensions

- -
); } diff --git a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/module.tsx b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/module.tsx index bba11717f86..fda97702d4f 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/module.tsx +++ b/e2e/test-plugins/grafana-extensionstest-app/plugins/grafana-extensionexample3-app/module.tsx @@ -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: () =>
From plugin B
, - }); - }, - }) - .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 }) =>
Hello {name}!
, - }) .addComponent<{ name: string }>({ targets: ['plugins/grafana-extensionstest-app/addComponent/v1'], title: 'Added component (where meta data is missing)', diff --git a/e2e/test-plugins/grafana-extensionstest-app/tests/legacy/extensionPoints.getters.spec.ts b/e2e/test-plugins/grafana-extensionstest-app/tests/legacy/extensionPoints.getters.spec.ts deleted file mode 100644 index 0d451b56977..00000000000 --- a/e2e/test-plugins/grafana-extensionstest-app/tests/legacy/extensionPoints.getters.spec.ts +++ /dev/null @@ -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!'); - }); -}); diff --git a/e2e/test-plugins/grafana-extensionstest-app/tests/legacy/extensionPoints.hooks.spec.ts b/e2e/test-plugins/grafana-extensionstest-app/tests/legacy/extensionPoints.hooks.spec.ts deleted file mode 100644 index c4ae9d2d23a..00000000000 --- a/e2e/test-plugins/grafana-extensionstest-app/tests/legacy/extensionPoints.hooks.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/e2e/test-plugins/grafana-extensionstest-app/tests/legacy/linkExtensions.spec.ts b/e2e/test-plugins/grafana-extensionstest-app/tests/legacy/linkExtensions.spec.ts deleted file mode 100644 index 20be9c23d3f..00000000000 --- a/e2e/test-plugins/grafana-extensionstest-app/tests/legacy/linkExtensions.spec.ts +++ /dev/null @@ -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"'); - }); -}); diff --git a/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts b/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts index 2d471db1898..008d3ec5baf 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts +++ b/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts @@ -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"'); +}); diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index a3af39ded7c..631b4374ab6 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -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, diff --git a/packages/grafana-data/src/types/app.ts b/packages/grafana-data/src/types/app.ts index e9e792dfc7e..a3171ef76dd 100644 --- a/packages/grafana-data/src/types/app.ts +++ b/packages/grafana-data/src/types/app.ts @@ -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 extends GrafanaPlugin(extension: Omit, 'type'>) { - this.addLink({ - targets: [extension.extensionPointId], - ...extension, - }); - - return this; - } - /** @deprecated Use .addComponent() instead */ - configureExtensionComponent(extension: Omit, 'type'>) { - this.addComponent({ - targets: [extension.extensionPointId], - ...extension, - component: extension.component, - }); - - return this; - } } /** diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 8f9887a705a..ad5ef90be71 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -152,8 +152,6 @@ export type PluginExtensionExposedComponentConfig = PluginExtensionC component: React.ComponentType; }; -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 = { - 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) => 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) => - | Partial<{ - title: string; - description: string; - path: string; - onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => 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 = { - 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; - - /** - * The unique identifier of the Extension Point - * (Core Grafana extension point ids are available in the `PluginExtensionPoints` enum) - */ - extensionPointId: string; -}; diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts index c8bf24d6cb5..f1bf983ba1b 100644 --- a/packages/grafana-runtime/src/services/index.ts +++ b/packages/grafana-runtime/src/services/index.ts @@ -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, diff --git a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.test.ts b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.test.ts deleted file mode 100644 index cb1c9e35e03..00000000000 --- a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts deleted file mode 100644 index edc5a48db6b..00000000000 --- a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { PluginExtension, PluginExtensionLink, PluginExtensionComponent } from '@grafana/data'; - -import { isPluginExtensionComponent, isPluginExtensionLink } from './utils'; - -export type GetPluginExtensions = ( - options: GetPluginExtensionsOptions -) => GetPluginExtensionsResult; - -export type UsePluginExtensions = ( - options: GetPluginExtensionsOptions -) => UsePluginExtensionsResult; - -export type GetPluginExtensionsOptions = { - extensionPointId: string; - // Make sure this object is properly memoized and not mutated. - context?: object | Record; - limitPerPlugin?: number; -}; - -export type GetPluginExtensionsResult = { - extensions: T[]; -}; - -export type UsePluginExtensionsResult = { - 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 = (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 = (options: { - extensionPointId: string; - limitPerPlugin?: number; -}): { extensions: Array> } => { - const { extensions } = getPluginExtensions(options); - const componentExtensions = extensions.filter(isPluginExtensionComponent) as Array>; - - return { - extensions: componentExtensions, - }; -}; diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.test.tsx b/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.test.tsx deleted file mode 100644 index d309e6f67af..00000000000 --- a/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.test.tsx +++ /dev/null @@ -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); - }); - }); -}); diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts deleted file mode 100644 index f195707efc4..00000000000 --- a/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts +++ /dev/null @@ -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 { - const { extensions, isLoading } = usePluginExtensions(options); - - return useMemo(() => { - return { - extensions: extensions.filter(isPluginExtensionLink), - isLoading, - }; - }, [extensions, isLoading]); -} - -/** - * @deprecated Use usePluginComponents() instead. - */ -export function usePluginComponentExtensions( - options: GetPluginExtensionsOptions -): { extensions: Array>; isLoading: boolean } { - const { extensions, isLoading } = usePluginExtensions(options); - - return useMemo( - () => ({ - extensions: extensions.filter(isPluginExtensionComponent) as Array>, - isLoading, - }), - [extensions, isLoading] - ); -} diff --git a/public/app/app.ts b/public/app/app.ts index 9ba23db15e7..c21cfabd4c8 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -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); diff --git a/public/app/features/alerting/unified/testSetup/plugins.ts b/public/app/features/alerting/unified/testSetup/plugins.ts index 72a94187ef0..68c3b81944a 100644 --- a/public/app/features/alerting/unified/testSetup/plugins.ts +++ b/public/app/features/alerting/unified/testSetup/plugins.ts @@ -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, diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx index 3ca8b9c3e05..0597b9e076b 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx @@ -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 } = {}) { 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); }); diff --git a/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx b/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx index b5c05c830b8..7d1371e2cdd 100644 --- a/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx +++ b/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx @@ -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 () => { diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx index ba6f6f48461..c76896c4afd 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx @@ -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, diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx index 182ebac330f..5f0e1ae0e18 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx @@ -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', diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx index 1f8a9547c83..82909f80268 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx @@ -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), }); } diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts index f1ed038dfb5..9b6ec578234 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts @@ -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 = { diff --git a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx deleted file mode 100644 index 479e5cd4c6c..00000000000 --- a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx +++ /dev/null @@ -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: () =>
Hello World1
, - }, - { - targets: componentExtensionPointId, - title: 'Component 2', - description: '2', - component: () =>
Hello World2
, - }, - ], - }); - - 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); - }); -}); diff --git a/public/app/features/plugins/extensions/usePluginExtensions.tsx b/public/app/features/plugins/extensions/usePluginExtensions.tsx deleted file mode 100644 index 2dfc57e289e..00000000000 --- a/public/app/features/plugins/extensions/usePluginExtensions.tsx +++ /dev/null @@ -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 { - 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, - ]); - }; -} diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index ff452777688..4ba153fe94d 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -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 { diff --git a/public/app/features/plugins/extensions/validators.test.tsx b/public/app/features/plugins/extensions/validators.test.tsx index f083ff738ea..8e3350494d5 100644 --- a/public/app/features/plugins/extensions/validators.test.tsx +++ b/public/app/features/plugins/extensions/validators.test.tsx @@ -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(); }); }); diff --git a/public/app/features/plugins/sandbox/sandbox_components.tsx b/public/app/features/plugins/sandbox/sandbox_components.tsx index e1eeb590d35..7e11e8428bc 100644 --- a/public/app/features/plugins/sandbox/sandbox_components.tsx +++ b/public/app/features/plugins/sandbox/sandbox_components.tsx @@ -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 = Reflect.get(pluginObject, 'configPages') ?? []; diff --git a/public/app/features/sandbox/TestStuffPage.tsx b/public/app/features/sandbox/TestStuffPage.tsx index 0e5526e4d4f..6d1adc6a2ef 100644 --- a/public/app/features/sandbox/TestStuffPage.tsx +++ b/public/app/features/sandbox/TestStuffPage.tsx @@ -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 (
- {extensions.map((extension, i) => { - if (!isPluginExtensionLink(extension)) { - return null; - } + {links.map((link, i) => { return ( - - {extension.title} + + {link.title} ); })} diff --git a/public/app/features/trails/Menu/PanelMenu.tsx b/public/app/features/trails/Menu/PanelMenu.tsx index 2d100a8b36b..5cfe5ed7a8b 100644 --- a/public/app/features/trails/Menu/PanelMenu.tsx +++ b/public/app/features/trails/Menu/PanelMenu.tsx @@ -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 implements VizPan this.state.explorationsButton?.activate(); } }); + + setupGetPluginExtensions(); } addItem(item: PanelMenuItem): void { @@ -119,12 +136,12 @@ export class PanelMenu extends SceneObjectBase 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) => { diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx index a3030b619ff..5e7ed1e300d 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx @@ -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(); }); diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts index 9d1c9246dba..8eb122b9258 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts @@ -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');