From db0cc24f2bfd5f2bed3458e03950c06ab3f40317 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Fri, 30 Aug 2024 10:09:01 +0200 Subject: [PATCH] Plugin extensions: Introduce new registry for added links (#92343) * add added component registry * fix broken test * add tests for usePluginComponents hook * readd expose components * add type assertion exceptions to betterer results * use new addedComponent registry in legacy endpoints * remove unused code * cleanup * revert test code * remove commented code * initial commit * refactor sync method and hook * fix tests * subscribe to the correct registry * remove old registry * cleanup types * add use usePluginLinks hook * add more tests * fix import order * fix typo * fix and temporarly skip failing tests * wip * add hook tests * add more tests * remove old hook * fix versioning * add version to all extension point ids * remove cleanup * remove unused imports * revert touched file * fix test * test: remove hook creation * catch init error * send error to faro * fix broken hook * comment out call hook initialization * use the right import ofr isString * remove unused import * remove registryState type * pr feedback * Update public/app/features/plugins/extensions/validators.test.tsx Co-authored-by: Levente Balogh * Update public/app/features/plugins/extensions/validators.test.tsx Co-authored-by: Levente Balogh * remove no longer relevant comment * fix broken tests * Fixed test to verify that the memotization works properly. * simplify hooks --------- Co-authored-by: Levente Balogh Co-authored-by: Marcus Andersson --- .betterer.results | 3 - .../pages/AddedLinks.tsx | 21 +- .../grafana-extensionexample1-app/module.tsx | 9 +- .../grafana-extensionexample2-app/module.tsx | 12 + .../grafana-extensionstest-app/testIds.ts | 1 + .../tests/usePluginLinks.spec.ts | 26 +- packages/grafana-data/src/index.ts | 5 +- packages/grafana-data/src/types/app.ts | 43 +- .../src/types/pluginExtensions.ts | 167 ++-- .../grafana-runtime/src/services/index.ts | 2 +- .../pluginExtensions/getPluginExtensions.ts | 11 + .../pluginExtensions/usePluginExtensions.ts | 14 - .../pluginExtensions/usePluginLinks.ts | 20 + public/app/app.ts | 34 +- .../getExploreExtensionConfigs.test.tsx | 6 +- .../extensions/getExploreExtensionConfigs.tsx | 14 +- .../getCoreExtensionConfigurations.ts | 4 +- .../extensions/getPluginExtensions.test.tsx | 121 ++- .../plugins/extensions/getPluginExtensions.ts | 262 ++----- .../reactivePluginExtensionRegistry.test.ts | 718 ------------------ .../reactivePluginExtensionRegistry.ts | 80 -- .../registry/AddedComponentsRegistry.test.ts | 6 +- .../registry/AddedComponentsRegistry.ts | 18 +- .../registry/AddedLinksRegistry.test.ts | 523 +++++++++++++ .../extensions/registry/AddedLinksRegistry.ts | 98 +++ .../registry/ExposedComponentsRegistry.ts | 9 +- .../plugins/extensions/registry/setup.ts | 21 + .../plugins/extensions/registry/types.ts | 9 + .../app/features/plugins/extensions/types.ts | 20 - .../plugins/extensions/usePluginComponent.tsx | 2 +- .../extensions/usePluginComponents.tsx | 10 +- .../extensions/usePluginExtensions.test.tsx | 151 ++-- .../extensions/usePluginExtensions.tsx | 74 +- .../extensions/usePluginLinks.test.tsx | 118 +++ .../plugins/extensions/usePluginLinks.tsx | 90 +++ .../plugins/extensions/utils.test.tsx | 33 +- .../app/features/plugins/extensions/utils.tsx | 123 ++- .../plugins/extensions/validators.test.tsx | 148 +--- .../features/plugins/extensions/validators.ts | 72 +- .../app/features/plugins/pluginPreloader.ts | 39 +- 40 files changed, 1498 insertions(+), 1639 deletions(-) create mode 100644 packages/grafana-runtime/src/services/pluginExtensions/usePluginLinks.ts delete mode 100644 public/app/features/plugins/extensions/reactivePluginExtensionRegistry.test.ts delete mode 100644 public/app/features/plugins/extensions/reactivePluginExtensionRegistry.ts create mode 100644 public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts create mode 100644 public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts create mode 100644 public/app/features/plugins/extensions/registry/setup.ts create mode 100644 public/app/features/plugins/extensions/registry/types.ts delete mode 100644 public/app/features/plugins/extensions/types.ts create mode 100644 public/app/features/plugins/extensions/usePluginLinks.test.tsx create mode 100644 public/app/features/plugins/extensions/usePluginLinks.tsx diff --git a/.betterer.results b/.betterer.results index adbcfdd1d1e..6f5a994bf67 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4988,9 +4988,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "11"], [0, 0, 0, "Do not use any type assertions.", "12"] ], - "public/app/features/plugins/extensions/getPluginExtensions.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/features/plugins/extensions/usePluginComponents.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], diff --git a/e2e/test-plugins/grafana-extensionstest-app/pages/AddedLinks.tsx b/e2e/test-plugins/grafana-extensionstest-app/pages/AddedLinks.tsx index dbfd00c36c4..201fa2305cd 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/pages/AddedLinks.tsx +++ b/e2e/test-plugins/grafana-extensionstest-app/pages/AddedLinks.tsx @@ -1,25 +1,22 @@ import { PluginPage, usePluginLinks } from '@grafana/runtime'; +import { Stack } from '@grafana/ui'; +import { ActionButton } from '../components/ActionButton'; import { testIds } from '../testIds'; export const LINKS_EXTENSION_POINT_ID = 'plugins/grafana-extensionstest-app/use-plugin-links/v1'; export function AddedLinks() { - const { links, isLoading } = usePluginLinks({ extensionPointId: LINKS_EXTENSION_POINT_ID }); + const { links } = usePluginLinks({ extensionPointId: LINKS_EXTENSION_POINT_ID }); return ( -
- {isLoading ? ( -
Loading...
- ) : ( - links.map(({ id, title, path, onClick }) => ( - - {title} - - )) - )} -
+ +
+

Link extensions defined with addLink and retrieved using usePluginLinks

+ +
+
); } 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 ae1a2efe4d9..4b4dda98d1b 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 @@ -4,7 +4,6 @@ import { LINKS_EXTENSION_POINT_ID } from '../../pages/AddedLinks'; import { testIds } from '../../testIds'; import { App } from './components/App'; -import pluginJson from './plugin.json'; export const plugin = new AppPlugin<{}>() .setRootPage(App) @@ -24,5 +23,11 @@ export const plugin = new AppPlugin<{}>() title: 'Basic link', description: '...', targets: [LINKS_EXTENSION_POINT_ID], - path: `/a/${pluginJson.id}/`, + path: '/a/grafana-extensionexample1-app/', + }) + .addLink({ + title: 'Go to A', + description: 'Navigating to pluging A', + targets: [LINKS_EXTENSION_POINT_ID], + path: '/a/grafana-extensionexample1-app/', }); 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 7c68c104597..071370cf592 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 @@ -1,5 +1,6 @@ import { AppPlugin } from '@grafana/data'; +import { LINKS_EXTENSION_POINT_ID } from '../../pages/AddedLinks'; import { testIds } from '../../testIds'; import { App } from './components/App'; @@ -30,4 +31,15 @@ export const plugin = new AppPlugin<{}>() component: ({ name }: { name: string }) => (
Hello {name}!
), + }) + .addLink({ + title: 'Open from B', + description: 'Open a modal from plugin B', + targets: [LINKS_EXTENSION_POINT_ID], + onClick: (_, { openModal }) => { + openModal({ + title: 'Modal from app B', + body: () =>
From plugin B
, + }); + }, }); diff --git a/e2e/test-plugins/grafana-extensionstest-app/testIds.ts b/e2e/test-plugins/grafana-extensionstest-app/testIds.ts index 29a9c0b0726..debed95957b 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/testIds.ts +++ b/e2e/test-plugins/grafana-extensionstest-app/testIds.ts @@ -36,5 +36,6 @@ export const testIds = { }, addedLinksPage: { container: 'data-testid pg-added-links-container', + section1: 'use-plugin-links', }, }; 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 cfeef4bfcdf..df5f64796d4 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts +++ b/e2e/test-plugins/grafana-extensionstest-app/tests/usePluginLinks.spec.ts @@ -3,8 +3,28 @@ import { test, expect } from '@grafana/plugin-e2e'; import pluginJson from '../plugin.json'; import { testIds } from '../testIds'; -test('path link', async ({ page }) => { +test('should extend the actions menu with a link to a-app plugin', async ({ page }) => { await page.goto(`/a/${pluginJson.id}/added-links`); - await page.getByTestId(testIds.addedLinksPage.container).getByText('Basic link').click(); - await expect(page.getByTestId(testIds.appA.container)).toHaveText('Hello Grafana!'); + const section = await page.getByTestId(testIds.addedLinksPage.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 extend main app with link extension from app B', async ({ page }) => { + await page.goto(`/a/${pluginJson.id}/added-links`); + const section = await page.getByTestId(testIds.addedLinksPage.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('should extend main app with basic link extension from app A', async ({ page }) => { + await page.goto(`/a/${pluginJson.id}/added-links`); + const section = await page.getByTestId(testIds.addedLinksPage.section1); + await section.getByTestId(testIds.actions.button).click(); + await page.getByTestId(testIds.container).getByText('Basic link').click(); + await page.getByTestId(testIds.modal.open).click(); + await expect(page.getByTestId(testIds.appA.container)).toBeVisible(); }); diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 2db23e03fca..4d7e6ed1201 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -555,8 +555,9 @@ export { type PluginExtensionDataSourceConfigContext, type PluginExtensionCommandPaletteContext, type PluginExtensionOpenModalOptions, - type PluginExposedComponentConfig, - type PluginAddedComponentConfig, + type PluginExtensionExposedComponentConfig, + type PluginExtensionAddedComponentConfig, + type PluginExtensionAddedLinkConfig, } from './types/pluginExtensions'; export { type ScopeDashboardBindingSpec, diff --git a/packages/grafana-data/src/types/app.ts b/packages/grafana-data/src/types/app.ts index 87756072998..6ada93ab099 100644 --- a/packages/grafana-data/src/types/app.ts +++ b/packages/grafana-data/src/types/app.ts @@ -5,11 +5,10 @@ import { NavModel } from './navModel'; import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin'; import { type PluginExtensionLinkConfig, - PluginExtensionTypes, PluginExtensionComponentConfig, - PluginExposedComponentConfig, - PluginExtensionConfig, - PluginAddedComponentConfig, + PluginExtensionExposedComponentConfig, + PluginExtensionAddedComponentConfig, + PluginExtensionAddedLinkConfig, } from './pluginExtensions'; /** @@ -58,9 +57,9 @@ export interface AppPluginMeta extends PluginMeta } export class AppPlugin extends GrafanaPlugin> { - private _exposedComponentConfigs: PluginExposedComponentConfig[] = []; - private _addedComponentConfigs: PluginAddedComponentConfig[] = []; - private _extensionConfigs: PluginExtensionConfig[] = []; + private _exposedComponentConfigs: PluginExtensionExposedComponentConfig[] = []; + private _addedComponentConfigs: PluginExtensionAddedComponentConfig[] = []; + private _addedLinkConfigs: PluginExtensionAddedLinkConfig[] = []; // Content under: /a/${plugin-id}/* root?: ComponentType>; @@ -110,38 +109,24 @@ export class AppPlugin extends GrafanaPlugin( - extensionConfig: { targets: string | string[] } & Omit< - PluginExtensionLinkConfig, - 'type' | 'extensionPointId' - > - ) { - const { targets, ...extension } = extensionConfig; - const targetsArray = Array.isArray(targets) ? targets : [targets]; - - targetsArray.forEach((target) => { - this._extensionConfigs.push({ - ...extension, - extensionPointId: target, - type: PluginExtensionTypes.link, - } as PluginExtensionLinkConfig); - }); + addLink(linkConfig: PluginExtensionAddedLinkConfig) { + this._addedLinkConfigs.push(linkConfig as PluginExtensionAddedLinkConfig); return this; } - addComponent(addedComponentConfig: PluginAddedComponentConfig) { - this._addedComponentConfigs.push(addedComponentConfig as PluginAddedComponentConfig); + addComponent(addedComponentConfig: PluginExtensionAddedComponentConfig) { + this._addedComponentConfigs.push(addedComponentConfig as PluginExtensionAddedComponentConfig); return this; } - exposeComponent(componentConfig: PluginExposedComponentConfig) { - this._exposedComponentConfigs.push(componentConfig as PluginExposedComponentConfig); + exposeComponent(componentConfig: PluginExtensionExposedComponentConfig) { + this._exposedComponentConfigs.push(componentConfig as PluginExtensionExposedComponentConfig); return this; } diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index f5dd20c62aa..bdb3e06b100 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -23,7 +23,6 @@ type PluginExtensionBase = { description: string; pluginId: string; }; - export type PluginExtensionLink = PluginExtensionBase & { type: PluginExtensionTypes.link; path?: string; @@ -41,61 +40,20 @@ export type PluginExtension = PluginExtensionLink | PluginExtensionComponent; // Objects used for registering extensions (in app plugins) // -------------------------------------------------------- -export type PluginExtensionLinkConfig = { - type: PluginExtensionTypes.link; + +type PluginExtensionConfigBase = { + /** + * The title of the link extension + */ 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) + * A short description */ - 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; -}; - -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; }; -export type PluginAddedComponentConfig = { +export type PluginExtensionAddedComponentConfig = PluginExtensionConfigBase & { /** * The target extension points where the component will be added */ @@ -117,23 +75,54 @@ export type PluginAddedComponentConfig = { component: React.ComponentType; }; -export type PluginExposedComponentConfig = { +export type PluginAddedLinksConfigureFunc = (context?: Readonly) => + | Partial<{ + title: string; + description: string; + path: string; + onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => void; + icon: IconName; + category: string; + }> + | undefined; + +export type PluginExtensionAddedLinkConfig = PluginExtensionConfigBase & { + /** + * The target extension points where the link will be added + */ + targets: string | 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 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; + + // (Optional) A function that can be used to configure the extension dynamically based on the extension point's context + configure?: PluginAddedLinksConfigureFunc; + + // (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; +}; + +export type PluginExtensionExposedComponentConfig = PluginExtensionConfigBase & { /** * The unique identifier of the component * Shoud be in the format of `//`. e.g. `myorg-todo-app/todo-list/v1` */ id: string; - /** - * The title of the component - */ - title: string; - - /** - * A short description of the component - */ - description: string; - /** * The React component that will be exposed to other plugins */ @@ -212,3 +201,61 @@ type Dashboard = { title: string; tags: string[]; }; + +// deprecated types + +/** @deprecated - use PluginAddedComponentConfig 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 e1415c47a9e..8f9aaa6281f 100644 --- a/packages/grafana-runtime/src/services/index.ts +++ b/packages/grafana-runtime/src/services/index.ts @@ -26,11 +26,11 @@ export { usePluginExtensions, usePluginLinkExtensions, usePluginComponentExtensions, - usePluginLinks, } from './pluginExtensions/usePluginExtensions'; export { setPluginComponentHook, usePluginComponent } from './pluginExtensions/usePluginComponent'; export { setPluginComponentsHook, usePluginComponents } from './pluginExtensions/usePluginComponents'; +export { setPluginLinksHook, usePluginLinks } from './pluginExtensions/usePluginLinks'; export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils'; export { setCurrentUser } from './user'; diff --git a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts index 664661dcaf6..cf6d48d7e6b 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts @@ -40,6 +40,17 @@ export type UsePluginComponentsResult = { isLoading: boolean; }; +export type UsePluginLinksOptions = { + extensionPointId: string; + context?: object | Record; + limitPerPlugin?: number; +}; + +export type UsePluginLinksResult = { + isLoading: boolean; + links: PluginExtensionLink[]; +}; + let singleton: GetPluginExtensions | undefined; export function setPluginExtensionGetter(instance: GetPluginExtensions): void { diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts index 439d8163ccf..f195707efc4 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts @@ -25,20 +25,6 @@ export function usePluginExtensions(options: GetPluginExtensionsOptions): UsePlu return singleton(options); } -export function usePluginLinks(options: GetPluginExtensionsOptions): { - links: PluginExtensionLink[]; - isLoading: boolean; -} { - const { extensions, isLoading } = usePluginExtensions(options); - - return useMemo(() => { - return { - links: extensions.filter(isPluginExtensionLink), - isLoading, - }; - }, [extensions, isLoading]); -} - /** * @deprecated Use usePluginLinks() instead. */ diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginLinks.ts b/packages/grafana-runtime/src/services/pluginExtensions/usePluginLinks.ts new file mode 100644 index 00000000000..d90d88cf4f7 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginExtensions/usePluginLinks.ts @@ -0,0 +1,20 @@ +import { UsePluginLinksOptions, UsePluginLinksResult } from './getPluginExtensions'; + +export type UsePluginLinks = (options: UsePluginLinksOptions) => UsePluginLinksResult; + +let singleton: UsePluginLinks | undefined; + +export function setPluginLinksHook(hook: UsePluginLinks): void { + // We allow overriding the registry in tests + if (singleton && process.env.NODE_ENV !== 'test') { + throw new Error('setPluginLinksHook() function should only be called once, when Grafana is starting.'); + } + singleton = hook; +} + +export function usePluginLinks(options: UsePluginLinksOptions): UsePluginLinksResult { + if (!singleton) { + throw new Error('setPluginLinksHook(options) can only be used after the Grafana instance has started.'); + } + return singleton(options); +} diff --git a/public/app/app.ts b/public/app/app.ts index 1d164a5baef..6e6141e0d86 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -41,6 +41,7 @@ import { setPluginComponentsHook, setCurrentUser, setChromeHeaderHeightHook, + setPluginLinksHook, } from '@grafana/runtime'; import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView'; import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer'; @@ -83,14 +84,12 @@ import { initGrafanaLive } from './features/live'; import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView'; import { PanelRenderer } from './features/panel/components/PanelRenderer'; import { DatasourceSrv } from './features/plugins/datasource_srv'; -import { getCoreExtensionConfigurations } from './features/plugins/extensions/getCoreExtensionConfigurations'; import { createPluginExtensionsGetter } from './features/plugins/extensions/getPluginExtensions'; -import { ReactivePluginExtensionsRegistry } from './features/plugins/extensions/reactivePluginExtensionRegistry'; -import { AddedComponentsRegistry } from './features/plugins/extensions/registry/AddedComponentsRegistry'; -import { ExposedComponentsRegistry } from './features/plugins/extensions/registry/ExposedComponentsRegistry'; +import { setupPluginExtensionRegistries } from './features/plugins/extensions/registry/setup'; import { createUsePluginComponent } from './features/plugins/extensions/usePluginComponent'; import { createUsePluginComponents } from './features/plugins/extensions/usePluginComponents'; import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions'; +import { createUsePluginLinks } from './features/plugins/extensions/usePluginLinks'; import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin'; import { preloadPlugins } from './features/plugins/pluginPreloader'; import { QueryRunner } from './features/query/state/QueryRunner'; @@ -213,17 +212,7 @@ export class GrafanaApp { initWindowRuntime(); // Initialize plugin extensions - const pluginExtensionsRegistries = { - extensionsRegistry: new ReactivePluginExtensionsRegistry(), - addedComponentsRegistry: new AddedComponentsRegistry(), - exposedComponentsRegistry: new ExposedComponentsRegistry(), - }; - pluginExtensionsRegistries.extensionsRegistry.register({ - pluginId: 'grafana', - extensionConfigs: getCoreExtensionConfigurations(), - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); + const pluginExtensionsRegistries = setupPluginExtensionRegistries(); if (contextSrv.user.orgRole !== '') { // The "cloud-home-app" is registering banners once it's loaded, and this can cause a rerender in the AppChrome if it's loaded after the Grafana app init. @@ -236,18 +225,9 @@ export class GrafanaApp { await preloadPlugins(awaitedAppPlugins, pluginExtensionsRegistries, 'frontend_awaited_plugins_preload'); } - setPluginExtensionGetter( - createPluginExtensionsGetter( - pluginExtensionsRegistries.extensionsRegistry, - pluginExtensionsRegistries.addedComponentsRegistry - ) - ); - setPluginExtensionsHook( - createUsePluginExtensions( - pluginExtensionsRegistries.extensionsRegistry, - pluginExtensionsRegistries.addedComponentsRegistry - ) - ); + setPluginLinksHook(createUsePluginLinks(pluginExtensionsRegistries.addedLinksRegistry)); + setPluginExtensionGetter(createPluginExtensionsGetter(pluginExtensionsRegistries)); + setPluginExtensionsHook(createUsePluginExtensions(pluginExtensionsRegistries)); setPluginComponentHook(createUsePluginComponent(pluginExtensionsRegistries.exposedComponentsRegistry)); setPluginComponentsHook(createUsePluginComponents(pluginExtensionsRegistries.addedComponentsRegistry)); diff --git a/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx b/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx index 74d843248ef..5d197f79dde 100644 --- a/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx +++ b/public/app/features/explore/extensions/getExploreExtensionConfigs.test.tsx @@ -14,20 +14,18 @@ describe('getExploreExtensionConfigs', () => { expect(extensions).toEqual([ { - type: 'link', title: 'Add to dashboard', description: 'Use the query and panel from explore and create/add it to a dashboard', - extensionPointId: PluginExtensionPoints.ExploreToolbarAction, + targets: [PluginExtensionPoints.ExploreToolbarAction], icon: 'apps', configure: expect.any(Function), onClick: expect.any(Function), category: 'Dashboards', }, { - type: 'link', title: 'Add correlation', description: 'Create a correlation from this query', - extensionPointId: PluginExtensionPoints.ExploreToolbarAction, + targets: [PluginExtensionPoints.ExploreToolbarAction], icon: 'link', configure: expect.any(Function), onClick: expect.any(Function), diff --git a/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx b/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx index a23cf1fc3a8..d842e451aa0 100644 --- a/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx +++ b/public/app/features/explore/extensions/getExploreExtensionConfigs.tsx @@ -1,9 +1,9 @@ -import { PluginExtensionPoints, type PluginExtensionLinkConfig } from '@grafana/data'; +import { PluginExtensionAddedLinkConfig, PluginExtensionPoints } from '@grafana/data'; import { contextSrv } from 'app/core/core'; import { dispatch } from 'app/store/store'; import { AccessControlAction } from 'app/types'; -import { createExtensionLinkConfig, logWarning } from '../../plugins/extensions/utils'; +import { createAddedLinkConfig, logWarning } from '../../plugins/extensions/utils'; import { changeCorrelationEditorDetails } from '../state/main'; import { runQueries } from '../state/query'; @@ -11,13 +11,13 @@ import { AddToDashboardForm } from './AddToDashboard/AddToDashboardForm'; import { getAddToDashboardTitle } from './AddToDashboard/getAddToDashboardTitle'; import { type PluginExtensionExploreContext } from './ToolbarExtensionPoint'; -export function getExploreExtensionConfigs(): PluginExtensionLinkConfig[] { +export function getExploreExtensionConfigs(): PluginExtensionAddedLinkConfig[] { try { return [ - createExtensionLinkConfig({ + createAddedLinkConfig({ title: 'Add to dashboard', description: 'Use the query and panel from explore and create/add it to a dashboard', - extensionPointId: PluginExtensionPoints.ExploreToolbarAction, + targets: [PluginExtensionPoints.ExploreToolbarAction], icon: 'apps', category: 'Dashboards', configure: () => { @@ -39,10 +39,10 @@ export function getExploreExtensionConfigs(): PluginExtensionLinkConfig[] { }); }, }), - createExtensionLinkConfig({ + createAddedLinkConfig({ title: 'Add correlation', description: 'Create a correlation from this query', - extensionPointId: PluginExtensionPoints.ExploreToolbarAction, + targets: [PluginExtensionPoints.ExploreToolbarAction], icon: 'link', configure: (context) => { return context?.shouldShowAddCorrelation ? {} : undefined; diff --git a/public/app/features/plugins/extensions/getCoreExtensionConfigurations.ts b/public/app/features/plugins/extensions/getCoreExtensionConfigurations.ts index 9d4578febf0..5429ada00bf 100644 --- a/public/app/features/plugins/extensions/getCoreExtensionConfigurations.ts +++ b/public/app/features/plugins/extensions/getCoreExtensionConfigurations.ts @@ -1,6 +1,6 @@ -import { type PluginExtensionLinkConfig } from '@grafana/data'; +import { PluginExtensionAddedLinkConfig } from '@grafana/data'; import { getExploreExtensionConfigs } from 'app/features/explore/extensions/getExploreExtensionConfigs'; -export function getCoreExtensionConfigurations(): PluginExtensionLinkConfig[] { +export function getCoreExtensionConfigurations(): PluginExtensionAddedLinkConfig[] { return [...getExploreExtensionConfigs()]; } diff --git a/public/app/features/plugins/extensions/getPluginExtensions.test.tsx b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx index d055c74d060..b3af3582ea7 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx @@ -1,16 +1,11 @@ import * as React from 'react'; -import { - PluginAddedComponentConfig, - PluginExtensionComponentConfig, - PluginExtensionLinkConfig, - PluginExtensionTypes, -} from '@grafana/data'; +import { PluginExtensionAddedComponentConfig, PluginExtensionAddedLinkConfig } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; import { getPluginExtensions } from './getPluginExtensions'; -import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; import { isReadOnlyProxy } from './utils'; import { assertPluginExtensionLink } from './validators'; @@ -24,19 +19,17 @@ jest.mock('@grafana/runtime', () => { async function createRegistries( preloadResults: Array<{ pluginId: string; - addedComponentConfigs: PluginAddedComponentConfig[]; - extensionConfigs: any[]; + addedComponentConfigs: PluginExtensionAddedComponentConfig[]; + addedLinkConfigs: PluginExtensionAddedLinkConfig[]; }> ) { - const registry = new ReactivePluginExtensionsRegistry(); + const addedLinksRegistry = new AddedLinksRegistry(); const addedComponentsRegistry = new AddedComponentsRegistry(); - for (const { pluginId, extensionConfigs, addedComponentConfigs } of preloadResults) { - registry.register({ + for (const { pluginId, addedLinkConfigs, addedComponentConfigs } of preloadResults) { + addedLinksRegistry.register({ pluginId, - exposedComponentConfigs: [], - extensionConfigs, - addedComponentConfigs: [], + configs: addedLinkConfigs, }); addedComponentsRegistry.register({ pluginId, @@ -44,39 +37,41 @@ async function createRegistries( }); } - return { registry: await registry.getRegistry(), addedComponentsRegistry: await addedComponentsRegistry.getState() }; + return { + addedLinksRegistry: await addedLinksRegistry.getState(), + addedComponentsRegistry: await addedComponentsRegistry.getState(), + }; } describe('getPluginExtensions()', () => { - const extensionPoint1 = 'grafana/dashboard/panel/menu'; - const extensionPoint2 = 'plugins/myorg-basic-app/start'; - const extensionPoint3 = 'grafana/datasources/config'; + const extensionPoint1 = 'grafana/dashboard/panel/menu/v1'; + const extensionPoint2 = 'plugins/myorg-basic-app/start/v1'; + const extensionPoint3 = 'grafana/datasources/config/v1'; const pluginId = 'grafana-basic-app'; // Sample extension configs that are used in the tests below - let link1: PluginExtensionLinkConfig, link2: PluginExtensionLinkConfig, component1: PluginExtensionComponentConfig; + let link1: PluginExtensionAddedLinkConfig, + link2: PluginExtensionAddedLinkConfig, + component1: PluginExtensionAddedComponentConfig; beforeEach(() => { link1 = { - type: PluginExtensionTypes.link, title: 'Link 1', description: 'Link 1 description', path: `/a/${pluginId}/declare-incident`, - extensionPointId: extensionPoint1, + targets: extensionPoint1, configure: jest.fn().mockReturnValue({}), }; link2 = { - type: PluginExtensionTypes.link, title: 'Link 2', description: 'Link 2 description', path: `/a/${pluginId}/declare-incident`, - extensionPointId: extensionPoint2, + targets: extensionPoint2, configure: jest.fn().mockImplementation((context) => ({ title: context?.title })), }; component1 = { - type: PluginExtensionTypes.component, title: 'Component 1', description: 'Component 1 description', - extensionPointId: extensionPoint3, + targets: extensionPoint3, component: (context) => { return
Hello world!
; }, @@ -88,7 +83,7 @@ describe('getPluginExtensions()', () => { test('should return the extensions for the given placement', async () => { const registries = await createRegistries([ - { pluginId, extensionConfigs: [link1, link2], addedComponentConfigs: [] }, + { pluginId, addedLinkConfigs: [link1, link2], addedComponentConfigs: [] }, ]); const { extensions } = getPluginExtensions({ ...registries, @@ -99,7 +94,6 @@ describe('getPluginExtensions()', () => { expect(extensions[0]).toEqual( expect.objectContaining({ pluginId, - type: PluginExtensionTypes.link, title: link1.title, description: link1.description, path: expect.stringContaining(link1.path!), @@ -110,7 +104,7 @@ describe('getPluginExtensions()', () => { test('should not limit the number of extensions per plugin by default', async () => { // Registering 3 extensions for the same plugin for the same placement const registries = await createRegistries([ - { pluginId, extensionConfigs: [link1, link1, link1, link2], addedComponentConfigs: [] }, + { pluginId, addedLinkConfigs: [link1, link1, link1, link2], addedComponentConfigs: [] }, ]); const { extensions } = getPluginExtensions({ ...registries, @@ -121,7 +115,6 @@ describe('getPluginExtensions()', () => { expect(extensions[0]).toEqual( expect.objectContaining({ pluginId, - type: PluginExtensionTypes.link, title: link1.title, description: link1.description, path: expect.stringContaining(link1.path!), @@ -131,11 +124,11 @@ describe('getPluginExtensions()', () => { test('should be possible to limit the number of extensions per plugin for a given placement', async () => { const registries = await createRegistries([ - { pluginId, extensionConfigs: [link1, link1, link1, link2], addedComponentConfigs: [] }, + { pluginId, addedLinkConfigs: [link1, link1, link1, link2], addedComponentConfigs: [] }, { pluginId: 'my-plugin', addedComponentConfigs: [], - extensionConfigs: [ + addedLinkConfigs: [ { ...link1, path: '/a/my-plugin/declare-incident' }, { ...link1, path: '/a/my-plugin/declare-incident' }, { ...link1, path: '/a/my-plugin/declare-incident' }, @@ -155,7 +148,6 @@ describe('getPluginExtensions()', () => { expect(extensions[0]).toEqual( expect.objectContaining({ pluginId, - type: PluginExtensionTypes.link, title: link1.title, description: link1.description, path: expect.stringContaining(link1.path!), @@ -165,7 +157,7 @@ describe('getPluginExtensions()', () => { test('should return with an empty list if there are no extensions registered for a placement yet', async () => { const registries = await createRegistries([ - { pluginId, extensionConfigs: [link1, link2], addedComponentConfigs: [] }, + { pluginId, addedLinkConfigs: [link1, link2], addedComponentConfigs: [] }, ]); const { extensions } = getPluginExtensions({ ...registries, @@ -177,7 +169,7 @@ describe('getPluginExtensions()', () => { test('should pass the context to the configure() function', async () => { const context = { title: 'New title from the context!' }; - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 }); @@ -194,7 +186,7 @@ describe('getPluginExtensions()', () => { category: 'Machine Learning', })); - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2, @@ -220,7 +212,7 @@ describe('getPluginExtensions()', () => { category: 'Machine Learning', })); - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2, @@ -231,7 +223,7 @@ describe('getPluginExtensions()', () => { expect(link2.configure).toHaveBeenCalledTimes(1); expect(extension.path).toBe( - `/a/${pluginId}/updated-path?uel_pid=grafana-basic-app&uel_epid=plugins%2Fmyorg-basic-app%2Fstart` + `/a/${pluginId}/updated-path?uel_pid=grafana-basic-app&uel_epid=plugins%2Fmyorg-basic-app%2Fstart%2Fv1` ); }); @@ -248,7 +240,7 @@ describe('getPluginExtensions()', () => { title: 'test', })); - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2, @@ -265,7 +257,7 @@ describe('getPluginExtensions()', () => { }); test('should pass a read only context to the configure() function', async () => { const context = { title: 'New title from the context!' }; - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, context, @@ -289,7 +281,7 @@ describe('getPluginExtensions()', () => { throw new Error('Something went wrong!'); }); - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); expect(() => { getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); @@ -309,7 +301,7 @@ describe('getPluginExtensions()', () => { })); const registries = await createRegistries([ - { pluginId, extensionConfigs: [link1, link2], addedComponentConfigs: [] }, + { pluginId, addedLinkConfigs: [link1, link2], addedComponentConfigs: [] }, ]); const { extensions: extensionsAtPlacement1 } = getPluginExtensions({ ...registries, @@ -336,7 +328,7 @@ describe('getPluginExtensions()', () => { link2.configure = jest.fn().mockImplementation(() => overrides); - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); expect(extensions).toHaveLength(0); @@ -347,7 +339,7 @@ describe('getPluginExtensions()', () => { test('should skip the extension if the configure() function returns a promise', async () => { link2.configure = jest.fn().mockImplementation(() => Promise.resolve({})); - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); expect(extensions).toHaveLength(0); @@ -358,7 +350,7 @@ describe('getPluginExtensions()', () => { test('should skip (hide) the extension if the configure() function returns undefined', async () => { link2.configure = jest.fn().mockImplementation(() => undefined); - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); expect(extensions).toHaveLength(0); @@ -372,7 +364,7 @@ describe('getPluginExtensions()', () => { }); const context = {}; - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); const [extension] = extensions; @@ -395,7 +387,7 @@ describe('getPluginExtensions()', () => { link2.path = undefined; link2.onClick = jest.fn().mockRejectedValue(new Error('testing')); - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); const [extension] = extensions; @@ -414,7 +406,7 @@ describe('getPluginExtensions()', () => { throw new Error('Something went wrong!'); }); - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, extensionPointId: extensionPoint2 }); const [extension] = extensions; @@ -432,7 +424,7 @@ describe('getPluginExtensions()', () => { link2.path = undefined; link2.onClick = jest.fn(); - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); const { extensions } = getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 }); const [extension] = extensions; @@ -455,7 +447,7 @@ describe('getPluginExtensions()', () => { array: ['a'], }; - const registries = await createRegistries([{ pluginId, extensionConfigs: [link2], addedComponentConfigs: [] }]); + const registries = await createRegistries([{ pluginId, addedLinkConfigs: [link2], addedComponentConfigs: [] }]); getPluginExtensions({ ...registries, context, extensionPointId: extensionPoint2 }); expect(() => { @@ -471,7 +463,7 @@ describe('getPluginExtensions()', () => { const registries = await createRegistries([ { pluginId, - extensionConfigs: [ + addedLinkConfigs: [ { ...link1, path: undefined, @@ -501,22 +493,19 @@ describe('getPluginExtensions()', () => { const registries = await createRegistries([ { pluginId, - extensionConfigs: [], - addedComponentConfigs: [ - { - ...component1, - targets: component1.extensionPointId, - }, - ], + addedLinkConfigs: [], + addedComponentConfigs: [component1], }, ]); - const { extensions } = getPluginExtensions({ ...registries, extensionPointId: component1.extensionPointId }); + const { extensions } = getPluginExtensions({ + ...registries, + extensionPointId: Array.isArray(component1.targets) ? component1.targets[0] : component1.targets, + }); expect(extensions).toHaveLength(1); expect(extensions[0]).toEqual( expect.objectContaining({ pluginId, - type: PluginExtensionTypes.component, title: component1.title, description: component1.description, }) @@ -527,16 +516,13 @@ describe('getPluginExtensions()', () => { const registries = await createRegistries([ { pluginId, - extensionConfigs: [], + addedLinkConfigs: [], addedComponentConfigs: [ - { - ...component1, - targets: component1.extensionPointId, - }, + component1, { title: 'Component 2', description: 'Component 2 description', - targets: component1.extensionPointId, + targets: component1.targets, component: (context) => { return
Hello world2!
; }, @@ -547,14 +533,13 @@ describe('getPluginExtensions()', () => { const { extensions } = getPluginExtensions({ ...registries, limitPerPlugin: 1, - extensionPointId: component1.extensionPointId, + extensionPointId: Array.isArray(component1.targets) ? component1.targets[0] : component1.targets, }); expect(extensions).toHaveLength(1); expect(extensions[0]).toEqual( expect.objectContaining({ pluginId, - type: PluginExtensionTypes.component, title: component1.title, description: component1.description, }) diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index 6560ca8e393..241f26ca15f 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -4,58 +4,53 @@ import { type PluginExtension, PluginExtensionTypes, type PluginExtensionLink, - type PluginExtensionLinkConfig, type PluginExtensionComponent, - urlUtil, } from '@grafana/data'; -import { GetPluginExtensions, reportInteraction } from '@grafana/runtime'; +import { GetPluginExtensions } from '@grafana/runtime'; -import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry'; -import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; -import type { AddedComponentsRegistryState, PluginExtensionRegistry } from './types'; +import { AddedComponentRegistryItem } from './registry/AddedComponentsRegistry'; +import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry'; +import { RegistryType } from './registry/Registry'; +import type { PluginExtensionRegistries } from './registry/types'; import { - isPluginExtensionLinkConfig, getReadOnlyProxy, logWarning, generateExtensionId, - getEventHelpers, wrapWithPluginContext, + getLinkExtensionOnClick, + getLinkExtensionOverrides, + getLinkExtensionPathWithTracking, } from './utils'; -import { assertIsNotPromise, assertLinkPathIsValid, assertStringProps, isPromise } from './validators'; type GetExtensions = ({ context, extensionPointId, limitPerPlugin, - registry, + addedLinksRegistry, addedComponentsRegistry, }: { context?: object | Record; extensionPointId: string; limitPerPlugin?: number; - registry: PluginExtensionRegistry; - addedComponentsRegistry: AddedComponentsRegistryState; + addedComponentsRegistry: RegistryType | undefined; + addedLinksRegistry: RegistryType | undefined; }) => { extensions: PluginExtension[] }; -export function createPluginExtensionsGetter( - extensionRegistry: ReactivePluginExtensionsRegistry, - addedComponentRegistry: AddedComponentsRegistry -): GetPluginExtensions { - let registry: PluginExtensionRegistry = { id: '', extensions: {} }; - let addedComponentsRegistryState: AddedComponentsRegistryState = {}; +export function createPluginExtensionsGetter(registries: PluginExtensionRegistries): GetPluginExtensions { + let addedComponentsRegistry: RegistryType; + let addedLinksRegistry: RegistryType>>; - // Create a subscription to keep an copy of the registry state for use in the non-async + // Create registry subscriptions to keep an copy of the registry state for use in the non-async // plugin extensions getter. - extensionRegistry.asObservable().subscribe((r) => { - registry = r; + registries.addedComponentsRegistry.asObservable().subscribe((componentsRegistry) => { + addedComponentsRegistry = componentsRegistry; }); - addedComponentRegistry.asObservable().subscribe((r) => { - addedComponentsRegistryState = r; + registries.addedLinksRegistry.asObservable().subscribe((linksRegistry) => { + addedLinksRegistry = linksRegistry; }); - return (options) => - getPluginExtensions({ ...options, registry, addedComponentsRegistry: addedComponentsRegistryState }); + return (options) => getPluginExtensions({ ...options, addedComponentsRegistry, addedLinksRegistry }); } // Returns with a list of plugin extensions for the given extension point @@ -63,20 +58,17 @@ export const getPluginExtensions: GetExtensions = ({ context, extensionPointId, limitPerPlugin, - registry, + addedLinksRegistry, addedComponentsRegistry, }) => { const frozenContext = context ? getReadOnlyProxy(context) : {}; - const registryItems = registry.extensions[extensionPointId] ?? []; // We don't return the extensions separated by type, because in that case it would be much harder to define a sort-order for them. const extensions: PluginExtension[] = []; const extensionsByPlugin: Record = {}; - for (const registryItem of registryItems) { + for (const addedLink of addedLinksRegistry?.[extensionPointId] ?? []) { try { - const extensionConfig = registryItem.config; - const { pluginId } = registryItem; - + const { pluginId } = addedLink; // Only limit if the `limitPerPlugin` is set if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) { continue; @@ -86,34 +78,35 @@ export const getPluginExtensions: GetExtensions = ({ extensionsByPlugin[pluginId] = 0; } - // LINK - if (isPluginExtensionLinkConfig(extensionConfig)) { - // Run the configure() function with the current context, and apply the ovverides - const overrides = getLinkExtensionOverrides(pluginId, extensionConfig, frozenContext); + // Run the configure() function with the current context, and apply the ovverides + const overrides = getLinkExtensionOverrides(pluginId, addedLink, frozenContext); - // configure() returned an `undefined` -> hide the extension - if (extensionConfig.configure && overrides === undefined) { - continue; - } - - const path = overrides?.path || extensionConfig.path; - const extension: PluginExtensionLink = { - id: generateExtensionId(pluginId, extensionConfig), - type: PluginExtensionTypes.link, - pluginId: pluginId, - onClick: getLinkExtensionOnClick(pluginId, extensionConfig, frozenContext), - - // Configurable properties - icon: overrides?.icon || extensionConfig.icon, - title: overrides?.title || extensionConfig.title, - description: overrides?.description || extensionConfig.description, - path: isString(path) ? getLinkExtensionPathWithTracking(pluginId, path, extensionConfig) : undefined, - category: overrides?.category || extensionConfig.category, - }; - - extensions.push(extension); - extensionsByPlugin[pluginId] += 1; + // configure() returned an `undefined` -> hide the extension + if (addedLink.configure && overrides === undefined) { + continue; } + + const path = overrides?.path || addedLink.path; + const extension: PluginExtensionLink = { + id: generateExtensionId(pluginId, { + ...addedLink, + extensionPointId, + type: PluginExtensionTypes.link, + }), + type: PluginExtensionTypes.link, + pluginId: pluginId, + onClick: getLinkExtensionOnClick(pluginId, extensionPointId, addedLink, frozenContext), + + // Configurable properties + icon: overrides?.icon || addedLink.icon, + title: overrides?.title || addedLink.title, + description: overrides?.description || addedLink.description, + path: isString(path) ? getLinkExtensionPathWithTracking(pluginId, path, extensionPointId) : undefined, + category: overrides?.category || addedLink.category, + }; + + extensions.push(extension); + extensionsByPlugin[pluginId] += 1; } catch (error) { if (error instanceof Error) { logWarning(error.message); @@ -121,139 +114,32 @@ export const getPluginExtensions: GetExtensions = ({ } } - if (extensionPointId in addedComponentsRegistry) { - try { - const addedComponents = addedComponentsRegistry[extensionPointId]; - for (const addedComponent of addedComponents) { - // Only limit if the `limitPerPlugin` is set - if (limitPerPlugin && extensionsByPlugin[addedComponent.pluginId] >= limitPerPlugin) { - continue; - } - - if (extensionsByPlugin[addedComponent.pluginId] === undefined) { - extensionsByPlugin[addedComponent.pluginId] = 0; - } - const extension: PluginExtensionComponent = { - id: generateExtensionId(addedComponent.pluginId, { - ...addedComponent, - extensionPointId, - type: PluginExtensionTypes.component, - }), - type: PluginExtensionTypes.component, - pluginId: addedComponent.pluginId, - title: addedComponent.title, - description: addedComponent.description, - component: wrapWithPluginContext(addedComponent.pluginId, addedComponent.component), - }; - - extensions.push(extension); - extensionsByPlugin[addedComponent.pluginId] += 1; - } - } catch (error) { - if (error instanceof Error) { - logWarning(error.message); - } + const addedComponents = addedComponentsRegistry?.[extensionPointId] ?? []; + for (const addedComponent of addedComponents) { + // Only limit if the `limitPerPlugin` is set + if (limitPerPlugin && extensionsByPlugin[addedComponent.pluginId] >= limitPerPlugin) { + continue; } + + if (extensionsByPlugin[addedComponent.pluginId] === undefined) { + extensionsByPlugin[addedComponent.pluginId] = 0; + } + const extension: PluginExtensionComponent = { + id: generateExtensionId(addedComponent.pluginId, { + ...addedComponent, + extensionPointId, + type: PluginExtensionTypes.component, + }), + type: PluginExtensionTypes.component, + pluginId: addedComponent.pluginId, + title: addedComponent.title, + description: addedComponent.description, + component: wrapWithPluginContext(addedComponent.pluginId, addedComponent.component), + }; + + extensions.push(extension); + extensionsByPlugin[addedComponent.pluginId] += 1; } return { extensions }; }; - -function getLinkExtensionOverrides(pluginId: string, config: PluginExtensionLinkConfig, context?: object) { - try { - const overrides = config.configure?.(context); - - // Hiding the extension - if (overrides === undefined) { - return undefined; - } - - let { - title = config.title, - description = config.description, - path = config.path, - icon = config.icon, - category = config.category, - ...rest - } = overrides; - - assertIsNotPromise( - overrides, - `The configure() function for "${config.title}" returned a promise, skipping updates.` - ); - - path && assertLinkPathIsValid(pluginId, path); - assertStringProps({ title, description }, ['title', 'description']); - - if (Object.keys(rest).length > 0) { - logWarning( - `Extension "${config.title}", is trying to override restricted properties: ${Object.keys(rest).join( - ', ' - )} which will be ignored.` - ); - } - - return { - title, - description, - path, - icon, - category, - }; - } catch (error) { - if (error instanceof Error) { - logWarning(error.message); - } - - // If there is an error, we hide the extension - // (This seems to be safest option in case the extension is doing something wrong.) - return undefined; - } -} - -function getLinkExtensionOnClick( - pluginId: string, - config: PluginExtensionLinkConfig, - context?: object -): ((event?: React.MouseEvent) => void) | undefined { - const { onClick } = config; - - if (!onClick) { - return; - } - - return function onClickExtensionLink(event?: React.MouseEvent) { - try { - reportInteraction('ui_extension_link_clicked', { - pluginId: pluginId, - extensionPointId: config.extensionPointId, - title: config.title, - category: config.category, - }); - - const result = onClick(event, getEventHelpers(pluginId, context)); - - if (isPromise(result)) { - result.catch((e) => { - if (e instanceof Error) { - logWarning(e.message); - } - }); - } - } catch (error) { - if (error instanceof Error) { - logWarning(error.message); - } - } - }; -} - -function getLinkExtensionPathWithTracking(pluginId: string, path: string, config: PluginExtensionLinkConfig): string { - return urlUtil.appendQueryToUrl( - path, - urlUtil.toUrlParams({ - uel_pid: pluginId, - uel_epid: config.extensionPointId, - }) - ); -} diff --git a/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.test.ts b/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.test.ts deleted file mode 100644 index 5ab6abee781..00000000000 --- a/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.test.ts +++ /dev/null @@ -1,718 +0,0 @@ -import { firstValueFrom } from 'rxjs'; - -import { PluginExtensionTypes } from '@grafana/data'; - -import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry'; - -describe('createPluginExtensionsRegistry', () => { - const consoleWarn = jest.fn(); - - beforeEach(() => { - global.console.warn = consoleWarn; - consoleWarn.mockReset(); - }); - - it('should return empty registry when no extensions registered', async () => { - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - const observable = reactiveRegistry.asObservable(); - const registry = await firstValueFrom(observable); - expect(registry).toEqual({ - id: '', - extensions: {}, - }); - }); - - it('should generate an id for the registry once we register an extension to it', async () => { - const pluginId = 'grafana-basic-app'; - const extensionPointId = 'grafana/dashboard/panel/menu'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - - reactiveRegistry.register({ - pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId, - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - const registry = await reactiveRegistry.getRegistry(); - - expect(registry.id).toBeDefined(); - expect(registry.extensions[extensionPointId]).toHaveLength(1); - }); - - it('should generate an a new id every time the registry changes', async () => { - const pluginId = 'grafana-basic-app'; - const extensionPointId = 'grafana/dashboard/panel/menu'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - - reactiveRegistry.register({ - pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId, - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - const registry1 = await reactiveRegistry.getRegistry(); - const id1 = registry1.id; - - expect(id1).toBeDefined(); - - reactiveRegistry.register({ - pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId, - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - const registry2 = await reactiveRegistry.getRegistry(); - const id2 = registry2.id; - - expect(id2).toBeDefined(); - expect(id2).not.toEqual(id1); - }); - - it('should be possible to register extensions in the registry', async () => { - const pluginId = 'grafana-basic-app'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - - reactiveRegistry.register({ - pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'plugins/myorg-basic-app/start', - configure: jest.fn().mockImplementation((context) => ({ title: context?.title })), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - const registry = await reactiveRegistry.getRegistry(); - - expect(registry.extensions).toEqual({ - 'grafana/dashboard/panel/menu': [ - { - pluginId: pluginId, - config: { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - ], - 'plugins/myorg-basic-app/start': [ - { - pluginId: pluginId, - config: { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'plugins/myorg-basic-app/start', - configure: expect.any(Function), - }, - }, - ], - }); - }); - - it('should be possible to asynchronously register extensions for the same placement (different plugins)', async () => { - const pluginId1 = 'grafana-basic-app'; - const pluginId2 = 'grafana-basic-app2'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - - // Register extensions for the first plugin - reactiveRegistry.register({ - pluginId: pluginId1, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId1}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - const registry1 = await reactiveRegistry.getRegistry(); - - expect(registry1.extensions).toEqual({ - 'grafana/dashboard/panel/menu': [ - { - pluginId: pluginId1, - config: { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId1}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - ], - }); - - // Register extensions for the second plugin to a different placement - reactiveRegistry.register({ - pluginId: pluginId2, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId2}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - const registry2 = await reactiveRegistry.getRegistry(); - - expect(registry2.extensions).toEqual({ - 'grafana/dashboard/panel/menu': [ - { - pluginId: pluginId1, - config: { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId1}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - { - pluginId: pluginId2, - config: { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId2}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - ], - }); - }); - - it('should be possible to asynchronously register extensions for a different placement (different plugin)', async () => { - const pluginId1 = 'grafana-basic-app'; - const pluginId2 = 'grafana-basic-app2'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - - // Register extensions for the first plugin - reactiveRegistry.register({ - pluginId: pluginId1, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId1}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - const registry1 = await reactiveRegistry.getRegistry(); - - expect(registry1.extensions).toEqual({ - 'grafana/dashboard/panel/menu': [ - { - pluginId: pluginId1, - config: { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId1}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - ], - }); - - // Register extensions for the second plugin to a different placement - reactiveRegistry.register({ - pluginId: pluginId2, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId2}/declare-incident`, - extensionPointId: 'plugins/myorg-basic-app/start', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - const registry2 = await reactiveRegistry.getRegistry(); - - expect(registry2.extensions).toEqual({ - 'grafana/dashboard/panel/menu': [ - { - pluginId: pluginId1, - config: { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId1}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - ], - 'plugins/myorg-basic-app/start': [ - { - pluginId: pluginId2, - config: { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId2}/declare-incident`, - extensionPointId: 'plugins/myorg-basic-app/start', - configure: expect.any(Function), - }, - }, - ], - }); - }); - - it('should be possible to asynchronously register extensions for the same placement (same plugin)', async () => { - const pluginId = 'grafana-basic-app'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - - // Register extensions for the first extension point - reactiveRegistry.register({ - pluginId: pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident-1`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - // Register extensions to a different extension point - reactiveRegistry.register({ - pluginId: pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId}/declare-incident-2`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - const registry2 = await reactiveRegistry.getRegistry(); - - expect(registry2.extensions).toEqual({ - 'grafana/dashboard/panel/menu': [ - { - pluginId: pluginId, - config: { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident-1`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - { - pluginId: pluginId, - config: { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId}/declare-incident-2`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - ], - }); - }); - - it('should be possible to asynchronously register extensions for a different placement (same plugin)', async () => { - const pluginId = 'grafana-basic-app'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - - // Register extensions for the first extension point - reactiveRegistry.register({ - pluginId: pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - // Register extensions to a different extension point - reactiveRegistry.register({ - pluginId: pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'plugins/myorg-basic-app/start', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - const registry2 = await reactiveRegistry.getRegistry(); - - expect(registry2.extensions).toEqual({ - 'grafana/dashboard/panel/menu': [ - { - pluginId: pluginId, - config: { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - ], - 'plugins/myorg-basic-app/start': [ - { - pluginId: pluginId, - config: { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'plugins/myorg-basic-app/start', - configure: expect.any(Function), - }, - }, - ], - }); - }); - - it('should notify subscribers when the registry changes', async () => { - const pluginId = 'grafana-basic-app'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - const observable = reactiveRegistry.asObservable(); - const subscribeCallback = jest.fn(); - - observable.subscribe(subscribeCallback); - - // Register extensions for the first plugin - reactiveRegistry.register({ - pluginId: pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - expect(subscribeCallback).toHaveBeenCalledTimes(2); - - // Register extensions for the first plugin - reactiveRegistry.register({ - pluginId: 'another-plugin', - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/another-plugin/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - expect(subscribeCallback).toHaveBeenCalledTimes(3); - - const registry = subscribeCallback.mock.calls[2][0]; - - expect(registry.extensions).toEqual({ - 'grafana/dashboard/panel/menu': [ - { - pluginId: pluginId, - config: { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - { - pluginId: 'another-plugin', - config: { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/another-plugin/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - ], - }); - }); - - it('should give the last version of the registry for new subscribers', async () => { - const pluginId = 'grafana-basic-app'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - const observable = reactiveRegistry.asObservable(); - const subscribeCallback = jest.fn(); - - reactiveRegistry.register({ - pluginId: pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - observable.subscribe(subscribeCallback); - expect(subscribeCallback).toHaveBeenCalledTimes(1); - - const registry = subscribeCallback.mock.calls[0][0]; - - expect(registry.extensions).toEqual({ - 'grafana/dashboard/panel/menu': [ - { - pluginId: pluginId, - config: { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: expect.any(Function), - }, - }, - ], - }); - }); - - it('should not register extensions for a plugin that had errors', () => { - const pluginId = 'grafana-basic-app'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - const observable = reactiveRegistry.asObservable(); - const subscribeCallback = jest.fn(); - - reactiveRegistry.register({ - error: new Error('Something is broken'), - pluginId: pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - expect(consoleWarn).toHaveBeenCalled(); - - observable.subscribe(subscribeCallback); - expect(subscribeCallback).toHaveBeenCalledTimes(1); - - const registry = subscribeCallback.mock.calls[0][0]; - expect(registry.extensions).toEqual({}); - }); - - it('should not register an extension if it has an invalid configure() function', () => { - const pluginId = 'grafana-basic-app'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - const observable = reactiveRegistry.asObservable(); - const subscribeCallback = jest.fn(); - - reactiveRegistry.register({ - pluginId: pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - //@ts-ignore - configure: '...', - }, - ], - }); - - expect(consoleWarn).toHaveBeenCalled(); - - observable.subscribe(subscribeCallback); - expect(subscribeCallback).toHaveBeenCalledTimes(1); - - const registry = subscribeCallback.mock.calls[0][0]; - expect(registry.extensions).toEqual({}); - }); - - it('should not register an extension if it has invalid properties (empty title / description)', () => { - const pluginId = 'grafana-basic-app'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - const observable = reactiveRegistry.asObservable(); - const subscribeCallback = jest.fn(); - - reactiveRegistry.register({ - pluginId: pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: '', - description: '', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - expect(consoleWarn).toHaveBeenCalled(); - - observable.subscribe(subscribeCallback); - expect(subscribeCallback).toHaveBeenCalledTimes(1); - - const registry = subscribeCallback.mock.calls[0][0]; - expect(registry.extensions).toEqual({}); - }); - - it('should not register link extensions with invalid path configured', () => { - const pluginId = 'grafana-basic-app'; - const reactiveRegistry = new ReactivePluginExtensionsRegistry(); - const observable = reactiveRegistry.asObservable(); - const subscribeCallback = jest.fn(); - - reactiveRegistry.register({ - pluginId: pluginId, - extensionConfigs: [ - { - type: PluginExtensionTypes.link, - title: 'Title 1', - description: 'Description 1', - path: `/a/another-plugin/declare-incident`, - extensionPointId: 'grafana/dashboard/panel/menu', - configure: jest.fn().mockReturnValue({}), - }, - ], - exposedComponentConfigs: [], - addedComponentConfigs: [], - }); - - expect(consoleWarn).toHaveBeenCalled(); - - observable.subscribe(subscribeCallback); - expect(subscribeCallback).toHaveBeenCalledTimes(1); - - const registry = subscribeCallback.mock.calls[0][0]; - expect(registry.extensions).toEqual({}); - }); -}); diff --git a/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.ts b/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.ts deleted file mode 100644 index d5d29cb22e1..00000000000 --- a/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Observable, ReplaySubject, Subject, firstValueFrom, map, scan, startWith } from 'rxjs'; -import { v4 as uuidv4 } from 'uuid'; - -import { PluginPreloadResult } from '../pluginPreloader'; - -import { PluginExtensionRegistry, PluginExtensionRegistryItem } from './types'; -import { deepFreeze, logWarning } from './utils'; -import { isPluginExtensionConfigValid } from './validators'; - -export class ReactivePluginExtensionsRegistry { - private resultSubject: Subject; - private registrySubject: ReplaySubject; - - constructor() { - this.resultSubject = new Subject(); - // This is the subject that we expose. - // (It will buffer the last value on the stream - the registry - and emit it to new subscribers immediately.) - this.registrySubject = new ReplaySubject(1); - - this.resultSubject - .pipe( - scan(resultsToRegistry, { id: '', extensions: {} }), - // Emit an empty registry to start the stream (it is only going to do it once during construction, and then just passes down the values) - startWith({ id: '', extensions: {} }), - map((registry) => deepFreeze(registry)) - ) - // Emitting the new registry to `this.registrySubject` - .subscribe(this.registrySubject); - } - - register(result: PluginPreloadResult): void { - this.resultSubject.next(result); - } - - asObservable(): Observable { - return this.registrySubject.asObservable(); - } - - getRegistry(): Promise { - return firstValueFrom(this.asObservable()); - } -} - -function resultsToRegistry(registry: PluginExtensionRegistry, result: PluginPreloadResult): PluginExtensionRegistry { - const { pluginId, extensionConfigs, error } = result; - - // TODO: We should probably move this section to where we load the plugin since this is only used - // to provide a log to the user. - if (error) { - logWarning(`"${pluginId}" plugin failed to load, skip registering its extensions.`); - return registry; - } - - for (const extensionConfig of extensionConfigs) { - const { extensionPointId } = extensionConfig; - - // Check if the config is valid - if (!extensionConfig || !isPluginExtensionConfigValid(pluginId, extensionConfig)) { - return registry; - } - - let registryItem: PluginExtensionRegistryItem = { - config: extensionConfig, - - // Additional meta information about the extension - pluginId, - }; - - if (!Array.isArray(registry.extensions[extensionPointId])) { - registry.extensions[extensionPointId] = [registryItem]; - } else { - registry.extensions[extensionPointId].push(registryItem); - } - } - - // Add a unique ID to the registry (the registry object itself is immutable) - registry.id = uuidv4(); - - return registry; -} diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts index 01799ecce62..5d5263b5e95 100644 --- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts @@ -314,7 +314,7 @@ describe('AddedComponentsRegistry', () => { expect(Object.keys(currentState)).toHaveLength(0); }); - it('should log a warning when exposed component id is not suffixed with component version', async () => { + it('should log a warning when added component id is not suffixed with component version', async () => { const registry = new AddedComponentsRegistry(); registry.register({ pluginId: 'grafana-basic-app', @@ -322,14 +322,14 @@ describe('AddedComponentsRegistry', () => { { title: 'Component 1 title', description: 'Component 1 description', - targets: ['grafana/alerting/home'], + targets: ['grafana/test/home'], component: () => React.createElement('div', null, 'Hello World1'), }, ], }); expect(consoleWarn).toHaveBeenCalledWith( - "[Plugin Extensions] Added component with id 'grafana/alerting/home' does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'." + "[Plugin Extensions] Added component with id 'grafana/test/home' does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'." ); const currentState = await registry.getState(); expect(Object.keys(currentState)).toHaveLength(1); diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts index 78420c42a17..954e49b0f34 100644 --- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts +++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts @@ -1,7 +1,12 @@ -import { PluginAddedComponentConfig } from '@grafana/data'; +import { PluginExtensionAddedComponentConfig } from '@grafana/data'; import { logWarning, wrapWithPluginContext } from '../utils'; -import { extensionPointEndsWithVersion, isExtensionPointIdValid, isReactComponent } from '../validators'; +import { + extensionPointEndsWithVersion, + isExtensionPointIdValid, + isGrafanaCoreExtensionPoint, + isReactComponent, +} from '../validators'; import { PluginExtensionConfigs, Registry, RegistryType } from './Registry'; @@ -12,7 +17,10 @@ export type AddedComponentRegistryItem = { component: React.ComponentType; }; -export class AddedComponentsRegistry extends Registry { +export class AddedComponentsRegistry extends Registry< + AddedComponentRegistryItem[], + PluginExtensionAddedComponentConfig +> { constructor(initialState: RegistryType = {}) { super({ initialState, @@ -21,7 +29,7 @@ export class AddedComponentsRegistry extends Registry, - item: PluginExtensionConfigs + item: PluginExtensionConfigs ): RegistryType { const { pluginId, configs } = item; @@ -52,7 +60,7 @@ export class AddedComponentsRegistry extends Registry { + const consoleWarn = jest.fn(); + + beforeEach(() => { + global.console.warn = consoleWarn; + consoleWarn.mockReset(); + }); + + it('should return empty registry when no extensions registered', async () => { + const addedLinksRegistry = new AddedLinksRegistry(); + const observable = addedLinksRegistry.asObservable(); + const registry = await firstValueFrom(observable); + expect(registry).toEqual({}); + }); + + it('should be possible to register link extensions in the registry', async () => { + const pluginId = 'grafana-basic-app'; + const addedLinksRegistry = new AddedLinksRegistry(); + + addedLinksRegistry.register({ + pluginId, + configs: [ + { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + { + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId}/declare-incident`, + targets: 'plugins/myorg-basic-app/start', + configure: jest.fn().mockImplementation((context) => ({ title: context?.title })), + }, + ], + }); + + const registry = await addedLinksRegistry.getState(); + + expect(registry).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + ], + 'plugins/myorg-basic-app/start': [ + { + pluginId: pluginId, + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId}/declare-incident`, + extensionPointId: 'plugins/myorg-basic-app/start', + configure: expect.any(Function), + }, + ], + }); + }); + it('should be possible to asynchronously register link extensions for the same placement (different plugins)', async () => { + const pluginId1 = 'grafana-basic-app'; + const pluginId2 = 'grafana-basic-app2'; + const reactiveRegistry = new AddedLinksRegistry(); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: pluginId1, + configs: [ + { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId1}/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry1 = await reactiveRegistry.getState(); + + expect(registry1).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId1, + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId1}/declare-incident`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + ], + }); + + // Register extensions for the second plugin to a different placement + reactiveRegistry.register({ + pluginId: pluginId2, + configs: [ + { + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId2}/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + + expect(registry2).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId1, + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId1}/declare-incident`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + { + pluginId: pluginId2, + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId2}/declare-incident`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + ], + }); + }); + + it('should be possible to asynchronously register link extensions for a different placement (different plugin)', async () => { + const pluginId1 = 'grafana-basic-app'; + const pluginId2 = 'grafana-basic-app2'; + const reactiveRegistry = new AddedLinksRegistry(); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: pluginId1, + configs: [ + { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId1}/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry1 = await reactiveRegistry.getState(); + + expect(registry1).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId1, + + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId1}/declare-incident`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + ], + }); + + // Register extensions for the second plugin to a different placement + reactiveRegistry.register({ + pluginId: pluginId2, + configs: [ + { + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId2}/declare-incident`, + targets: 'plugins/myorg-basic-app/start', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + + expect(registry2).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId1, + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId1}/declare-incident`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + ], + 'plugins/myorg-basic-app/start': [ + { + pluginId: pluginId2, + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId2}/declare-incident`, + extensionPointId: 'plugins/myorg-basic-app/start', + configure: expect.any(Function), + }, + ], + }); + }); + + it('should be possible to asynchronously register link extensions for the same placement (same plugin)', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedLinksRegistry(); + + // Register extensions for the first extension point + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident-1`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + // Register extensions to a different extension point + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId}/declare-incident-2`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + + expect(registry2).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident-1`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + { + pluginId: pluginId, + + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId}/declare-incident-2`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + ], + }); + }); + + it('should be possible to asynchronously register link extensions for a different placement (same plugin)', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedLinksRegistry(); + + // Register extensions for the first extension point + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + // Register extensions to a different extension point + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId}/declare-incident`, + targets: 'plugins/myorg-basic-app/start', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + const registry2 = await reactiveRegistry.getState(); + + expect(registry2).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + ], + 'plugins/myorg-basic-app/start': [ + { + pluginId: pluginId, + + title: 'Link 2', + description: 'Link 2 description', + path: `/a/${pluginId}/declare-incident`, + extensionPointId: 'plugins/myorg-basic-app/start', + configure: expect.any(Function), + }, + ], + }); + }); + + it('should notify subscribers when the registry changes', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedLinksRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + observable.subscribe(subscribeCallback); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + expect(subscribeCallback).toHaveBeenCalledTimes(2); + + // Register extensions for the first plugin + reactiveRegistry.register({ + pluginId: 'another-plugin', + configs: [ + { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/another-plugin/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + expect(subscribeCallback).toHaveBeenCalledTimes(3); + + const registry = subscribeCallback.mock.calls[2][0]; + + expect(registry).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + { + pluginId: 'another-plugin', + + title: 'Link 1', + description: 'Link 1 description', + path: `/a/another-plugin/declare-incident`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + ], + }); + }); + + it('should give the last version of the registry for new subscribers', async () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedLinksRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + observable.subscribe(subscribeCallback); + expect(subscribeCallback).toHaveBeenCalledTimes(1); + + const registry = subscribeCallback.mock.calls[0][0]; + + expect(registry).toEqual({ + 'grafana/dashboard/panel/menu': [ + { + pluginId: pluginId, + + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + extensionPointId: 'grafana/dashboard/panel/menu', + configure: expect.any(Function), + }, + ], + }); + }); + + it('should not register a link extension if it has an invalid configure() function', () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedLinksRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Link 1', + description: 'Link 1 description', + path: `/a/${pluginId}/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + //@ts-ignore + configure: '...', + }, + ], + }); + + expect(consoleWarn).toHaveBeenCalled(); + + observable.subscribe(subscribeCallback); + expect(subscribeCallback).toHaveBeenCalledTimes(1); + + const registry = subscribeCallback.mock.calls[0][0]; + expect(registry).toEqual({}); + }); + + it('should not register a link extension if it has invalid properties (empty title / description)', () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedLinksRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: '', + description: '', + path: `/a/${pluginId}/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + expect(consoleWarn).toHaveBeenCalled(); + + observable.subscribe(subscribeCallback); + expect(subscribeCallback).toHaveBeenCalledTimes(1); + + const registry = subscribeCallback.mock.calls[0][0]; + expect(registry).toEqual({}); + }); + + it('should not register link extensions with invalid path configured', () => { + const pluginId = 'grafana-basic-app'; + const reactiveRegistry = new AddedLinksRegistry(); + const observable = reactiveRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + reactiveRegistry.register({ + pluginId: pluginId, + configs: [ + { + title: 'Title 1', + description: 'Description 1', + path: `/a/another-plugin/declare-incident`, + targets: 'grafana/dashboard/panel/menu', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + expect(consoleWarn).toHaveBeenCalled(); + + observable.subscribe(subscribeCallback); + expect(subscribeCallback).toHaveBeenCalledTimes(1); + + const registry = subscribeCallback.mock.calls[0][0]; + expect(registry).toEqual({}); + }); +}); diff --git a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts new file mode 100644 index 00000000000..30b4ebbdfcc --- /dev/null +++ b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts @@ -0,0 +1,98 @@ +import { IconName, PluginExtensionAddedLinkConfig } from '@grafana/data'; +import { PluginAddedLinksConfigureFunc, PluginExtensionEventHelpers } from '@grafana/data/src/types/pluginExtensions'; + +import { logWarning } from '../utils'; +import { + extensionPointEndsWithVersion, + isConfigureFnValid, + isExtensionPointIdValid, + isGrafanaCoreExtensionPoint, + isLinkPathValid, +} from '../validators'; + +import { PluginExtensionConfigs, Registry, RegistryType } from './Registry'; + +export type AddedLinkRegistryItem = { + pluginId: string; + extensionPointId: string; + title: string; + description: string; + path?: string; + onClick?: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => void; + configure?: PluginAddedLinksConfigureFunc; + icon?: IconName; + category?: string; +}; + +export class AddedLinksRegistry extends Registry { + constructor(initialState: RegistryType = {}) { + super({ + initialState, + }); + } + + mapToRegistry( + registry: RegistryType, + item: PluginExtensionConfigs + ): RegistryType { + const { pluginId, configs } = item; + + for (const config of configs) { + const { path, title, description, configure, onClick, targets } = config; + if (!title) { + logWarning(`Could not register added link with title '${title}'. Reason: Title is missing.`); + continue; + } + + if (!description) { + logWarning(`Could not register added link with title '${title}'. Reason: Description is missing.`); + continue; + } + + if (!isConfigureFnValid(configure)) { + logWarning(`Could not register added link with title '${title}'. Reason: configure is not a function.`); + continue; + } + + if (!path && !onClick) { + logWarning( + `Could not register added link with title '${title}'. Reason: Either "path" or "onClick" is required.` + ); + continue; + } + + if (path && !isLinkPathValid(pluginId, path)) { + logWarning( + `Could not register added link with title '${title}'. Reason: The "path" is required and should start with "/a/${pluginId}/" (currently: "${path}"). Skipping the extension.` + ); + continue; + } + + const extensionPointIds = Array.isArray(targets) ? targets : [targets]; + for (const extensionPointId of extensionPointIds) { + if (!isExtensionPointIdValid(pluginId, extensionPointId)) { + logWarning( + `Could not register added link with id '${extensionPointId}'. Reason: Target extension point id must start with grafana, plugins or plugin id.` + ); + continue; + } + + if (!isGrafanaCoreExtensionPoint(extensionPointId) && !extensionPointEndsWithVersion(extensionPointId)) { + logWarning( + `Added component with id '${extensionPointId}' does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'.` + ); + } + + const { targets, ...registryItem } = config; + + if (!(extensionPointId in registry)) { + registry[extensionPointId] = []; + } + + registry[extensionPointId].push({ ...registryItem, pluginId, extensionPointId }); + } + } + + return registry; + } +} diff --git a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts index 52b8e97a9c4..40cba5bf44b 100644 --- a/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts +++ b/public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts @@ -1,4 +1,4 @@ -import { PluginExposedComponentConfig } from '@grafana/data'; +import { PluginExtensionExposedComponentConfig } from '@grafana/data'; import { logWarning } from '../utils'; import { extensionPointEndsWithVersion } from '../validators'; @@ -12,7 +12,10 @@ export type ExposedComponentRegistryItem = { component: React.ComponentType; }; -export class ExposedComponentsRegistry extends Registry { +export class ExposedComponentsRegistry extends Registry< + ExposedComponentRegistryItem, + PluginExtensionExposedComponentConfig +> { constructor(initialState: RegistryType = {}) { super({ initialState, @@ -21,7 +24,7 @@ export class ExposedComponentsRegistry extends Registry, - { pluginId, configs }: PluginExtensionConfigs + { pluginId, configs }: PluginExtensionConfigs ): RegistryType { if (!configs) { return registry; diff --git a/public/app/features/plugins/extensions/registry/setup.ts b/public/app/features/plugins/extensions/registry/setup.ts new file mode 100644 index 00000000000..05fe0f21b7a --- /dev/null +++ b/public/app/features/plugins/extensions/registry/setup.ts @@ -0,0 +1,21 @@ +import { getCoreExtensionConfigurations } from '../getCoreExtensionConfigurations'; + +import { AddedComponentsRegistry } from './AddedComponentsRegistry'; +import { AddedLinksRegistry } from './AddedLinksRegistry'; +import { ExposedComponentsRegistry } from './ExposedComponentsRegistry'; +import { PluginExtensionRegistries } from './types'; + +export function setupPluginExtensionRegistries(): PluginExtensionRegistries { + const pluginExtensionsRegistries = { + addedComponentsRegistry: new AddedComponentsRegistry(), + exposedComponentsRegistry: new ExposedComponentsRegistry(), + addedLinksRegistry: new AddedLinksRegistry(), + }; + + pluginExtensionsRegistries.addedLinksRegistry.register({ + pluginId: 'grafana', + configs: getCoreExtensionConfigurations(), + }); + + return pluginExtensionsRegistries; +} diff --git a/public/app/features/plugins/extensions/registry/types.ts b/public/app/features/plugins/extensions/registry/types.ts new file mode 100644 index 00000000000..115e859b7d9 --- /dev/null +++ b/public/app/features/plugins/extensions/registry/types.ts @@ -0,0 +1,9 @@ +import { AddedComponentsRegistry } from './AddedComponentsRegistry'; +import { AddedLinksRegistry } from './AddedLinksRegistry'; +import { ExposedComponentsRegistry } from './ExposedComponentsRegistry'; + +export type PluginExtensionRegistries = { + addedComponentsRegistry: AddedComponentsRegistry; + exposedComponentsRegistry: ExposedComponentsRegistry; + addedLinksRegistry: AddedLinksRegistry; +}; diff --git a/public/app/features/plugins/extensions/types.ts b/public/app/features/plugins/extensions/types.ts deleted file mode 100644 index b5e5d1ebbc8..00000000000 --- a/public/app/features/plugins/extensions/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { PluginExtensionConfig } from '@grafana/data'; - -import { AddedComponentRegistryItem } from './registry/AddedComponentsRegistry'; -import { RegistryType } from './registry/Registry'; - -// The information that is stored in the registry -export type PluginExtensionRegistryItem = { - // Any additional meta information that we would like to store about the extension in the registry - pluginId: string; - - config: PluginExtensionConfig; -}; - -// A map of placement names to a list of extensions -export type PluginExtensionRegistry = { - id: string; - extensions: Record; -}; - -export type AddedComponentsRegistryState = RegistryType>>; diff --git a/public/app/features/plugins/extensions/usePluginComponent.tsx b/public/app/features/plugins/extensions/usePluginComponent.tsx index 269baf3df0f..8985d182aea 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.tsx @@ -15,7 +15,7 @@ export function createUsePluginComponent(registry: ExposedComponentsRegistry) { const registry = useObservable(observableRegistry); return useMemo(() => { - if (!registry || !registry[id]) { + if (!registry?.[id]) { return { isLoading: false, component: null, diff --git a/public/app/features/plugins/extensions/usePluginComponents.tsx b/public/app/features/plugins/extensions/usePluginComponents.tsx index db3d1c6710c..b2ad9804d6b 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.tsx @@ -19,16 +19,10 @@ export function createUsePluginComponents(registry: AddedComponentsRegistry) { const registry = useObservable(observableRegistry); return useMemo(() => { - if (!registry || !registry[extensionPointId]) { - return { - isLoading: false, - components: [], - }; - } const components: Array> = []; - const registryItems = registry[extensionPointId]; const extensionsByPlugin: Record = {}; - for (const registryItem of registryItems) { + + for (const registryItem of registry?.[extensionPointId] ?? []) { const { pluginId } = registryItem; // Only limit if the `limitPerPlugin` is set diff --git a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx index 886dfecdec8..17ff510f8ef 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx @@ -1,26 +1,28 @@ import { act } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; -import { PluginExtensionTypes } from '@grafana/data'; - -import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; +import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry'; +import { PluginExtensionRegistries } from './registry/types'; import { createUsePluginExtensions } from './usePluginExtensions'; describe('usePluginExtensions()', () => { - let reactiveRegistry: ReactivePluginExtensionsRegistry; - let addedComponentsRegistry: AddedComponentsRegistry; + let registries: PluginExtensionRegistries; beforeEach(() => { - reactiveRegistry = new ReactivePluginExtensionsRegistry(); - addedComponentsRegistry = new AddedComponentsRegistry(); + registries = { + addedComponentsRegistry: new AddedComponentsRegistry(), + addedLinksRegistry: new AddedLinksRegistry(), + exposedComponentsRegistry: new ExposedComponentsRegistry(), + }; }); it('should return an empty array if there are no extensions registered for the extension point', () => { - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); + const usePluginExtensions = createUsePluginExtensions(registries); const { result } = renderHook(() => usePluginExtensions({ - extensionPointId: 'foo/bar', + extensionPointId: 'foo/bar/v1', }) ); @@ -28,32 +30,28 @@ describe('usePluginExtensions()', () => { }); it('should return the plugin link extensions from the registry', () => { - const extensionPointId = 'plugins/foo/bar'; + const extensionPointId = 'plugins/foo/bar/v1'; const pluginId = 'my-app-plugin'; - reactiveRegistry.register({ + registries.addedLinksRegistry.register({ pluginId, - extensionConfigs: [ + configs: [ { - type: PluginExtensionTypes.link, - extensionPointId, + targets: extensionPointId, title: '1', description: '1', path: `/a/${pluginId}/2`, }, { - type: PluginExtensionTypes.link, - extensionPointId, + targets: extensionPointId, title: '2', description: '2', path: `/a/${pluginId}/2`, }, ], - exposedComponentConfigs: [], - addedComponentConfigs: [], }); - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); + const usePluginExtensions = createUsePluginExtensions(registries); const { result } = renderHook(() => usePluginExtensions({ extensionPointId })); expect(result.current.extensions.length).toBe(2); @@ -62,33 +60,29 @@ describe('usePluginExtensions()', () => { }); it('should return the plugin component extensions from the registry', () => { - const linkExtensionPointId = 'plugins/foo/bar'; + const linkExtensionPointId = 'plugins/foo/bar/v1'; const componentExtensionPointId = 'plugins/component/bar/v1'; const pluginId = 'my-app-plugin'; - reactiveRegistry.register({ + registries.addedLinksRegistry.register({ pluginId, - extensionConfigs: [ + configs: [ { - type: PluginExtensionTypes.link, - extensionPointId: linkExtensionPointId, + targets: linkExtensionPointId, title: '1', description: '1', path: `/a/${pluginId}/2`, }, { - type: PluginExtensionTypes.link, - extensionPointId: linkExtensionPointId, + targets: linkExtensionPointId, title: '2', description: '2', path: `/a/${pluginId}/2`, }, ], - exposedComponentConfigs: [], - addedComponentConfigs: [], }); - addedComponentsRegistry.register({ + registries.addedComponentsRegistry.register({ pluginId, configs: [ { @@ -106,7 +100,7 @@ describe('usePluginExtensions()', () => { ], }); - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); + const usePluginExtensions = createUsePluginExtensions(registries); const { result } = renderHook(() => usePluginExtensions({ extensionPointId: componentExtensionPointId })); expect(result.current.extensions.length).toBe(2); @@ -115,9 +109,9 @@ describe('usePluginExtensions()', () => { }); it('should dynamically update the extensions registered for a certain extension point', () => { - const extensionPointId = 'plugins/foo/bar'; + const extensionPointId = 'plugins/foo/bar/v1'; const pluginId = 'my-app-plugin'; - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); + const usePluginExtensions = createUsePluginExtensions(registries); let { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId })); // No extensions yet @@ -125,26 +119,22 @@ describe('usePluginExtensions()', () => { // Add extensions to the registry act(() => { - reactiveRegistry.register({ + registries.addedLinksRegistry.register({ pluginId, - extensionConfigs: [ + configs: [ { - type: PluginExtensionTypes.link, - extensionPointId, + targets: extensionPointId, title: '1', description: '1', path: `/a/${pluginId}/2`, }, { - type: PluginExtensionTypes.link, - extensionPointId, + targets: extensionPointId, title: '2', description: '2', path: `/a/${pluginId}/2`, }, ], - exposedComponentConfigs: [], - addedComponentConfigs: [], }); }); @@ -157,78 +147,81 @@ describe('usePluginExtensions()', () => { }); it('should only render the hook once', () => { - const spy = jest.spyOn(reactiveRegistry, 'asObservable'); - const extensionPointId = 'plugins/foo/bar'; - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); + const addedComponentsRegistrySpy = jest.spyOn(registries.addedComponentsRegistry, 'asObservable'); + const addedLinksRegistrySpy = jest.spyOn(registries.addedLinksRegistry, 'asObservable'); + const extensionPointId = 'plugins/foo/bar/v1'; + const usePluginExtensions = createUsePluginExtensions(registries); renderHook(() => usePluginExtensions({ extensionPointId })); - expect(spy).toHaveBeenCalledTimes(1); + expect(addedComponentsRegistrySpy).toHaveBeenCalledTimes(1); + expect(addedLinksRegistrySpy).toHaveBeenCalledTimes(1); }); - it('should return the same extensions object if the context object is the same', () => { - const extensionPointId = 'plugins/foo/bar'; + it('should return the same extensions object if the context object is the same', async () => { + const extensionPointId = 'plugins/foo/bar/v1'; const pluginId = 'my-app-plugin'; - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); + const usePluginExtensions = createUsePluginExtensions(registries); // Add extensions to the registry act(() => { - reactiveRegistry.register({ + registries.addedLinksRegistry.register({ pluginId, - extensionConfigs: [ + configs: [ { - type: PluginExtensionTypes.link, - extensionPointId, + targets: extensionPointId, title: '1', description: '1', path: `/a/${pluginId}/2`, }, { - type: PluginExtensionTypes.link, - extensionPointId, + targets: extensionPointId, title: '2', description: '2', path: `/a/${pluginId}/2`, }, ], - exposedComponentConfigs: [], - addedComponentConfigs: [], }); }); // Check if it returns the same extensions object in case nothing changes const context = {}; - const firstResults = renderHook(() => usePluginExtensions({ extensionPointId, context })); - const secondResults = renderHook(() => usePluginExtensions({ extensionPointId, context })); - expect(firstResults.result.current.extensions === secondResults.result.current.extensions).toBe(true); + 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 extensionPointId = 'plugins/foo/bar'; + const extensionPointId = 'plugins/foo/bar/v1'; const pluginId = 'my-app-plugin'; - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); + const usePluginExtensions = createUsePluginExtensions(registries); // Add extensions to the registry act(() => { - reactiveRegistry.register({ + registries.addedLinksRegistry.register({ pluginId, - extensionConfigs: [ + configs: [ { - type: PluginExtensionTypes.link, - extensionPointId, + targets: extensionPointId, title: '1', description: '1', path: `/a/${pluginId}/2`, }, { - type: PluginExtensionTypes.link, - extensionPointId, + targets: extensionPointId, title: '2', description: '2', path: `/a/${pluginId}/2`, }, ], - exposedComponentConfigs: [], - addedComponentConfigs: [], }); }); @@ -239,26 +232,23 @@ describe('usePluginExtensions()', () => { }); it('should return a new extensions object if the registry changes but the context object is the same', () => { - const extensionPointId = 'plugins/foo/bar'; + const extensionPointId = 'plugins/foo/bar/v1'; const pluginId = 'my-app-plugin'; const context = {}; - const usePluginExtensions = createUsePluginExtensions(reactiveRegistry, addedComponentsRegistry); + const usePluginExtensions = createUsePluginExtensions(registries); // Add the first extension act(() => { - reactiveRegistry.register({ + registries.addedLinksRegistry.register({ pluginId, - extensionConfigs: [ + configs: [ { - type: PluginExtensionTypes.link, - extensionPointId, + targets: extensionPointId, title: '1', description: '1', path: `/a/${pluginId}/2`, }, ], - exposedComponentConfigs: [], - addedComponentConfigs: [], }); }); @@ -267,20 +257,17 @@ describe('usePluginExtensions()', () => { // Add the second extension act(() => { - reactiveRegistry.register({ + registries.addedLinksRegistry.register({ pluginId, - extensionConfigs: [ + configs: [ { - type: PluginExtensionTypes.link, - extensionPointId, + 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`, }, ], - exposedComponentConfigs: [], - addedComponentConfigs: [], }); }); diff --git a/public/app/features/plugins/extensions/usePluginExtensions.tsx b/public/app/features/plugins/extensions/usePluginExtensions.tsx index b00d4517265..62e583ad857 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.tsx @@ -1,64 +1,40 @@ +import { useMemo } from 'react'; import { useObservable } from 'react-use'; import { PluginExtension } from '@grafana/data'; import { GetPluginExtensionsOptions, UsePluginExtensionsResult } from '@grafana/runtime'; import { getPluginExtensions } from './getPluginExtensions'; -import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry'; -import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { PluginExtensionRegistries } from './registry/types'; -export function createUsePluginExtensions( - extensionsRegistry: ReactivePluginExtensionsRegistry, - addedComponentsRegistry: AddedComponentsRegistry -) { - const observableRegistry = extensionsRegistry.asObservable(); - const observableAddedComponentRegistry = addedComponentsRegistry.asObservable(); - const cache: { - id: string; - extensions: Record; - } = { - id: '', - extensions: {}, - }; +export function createUsePluginExtensions(registries: PluginExtensionRegistries) { + const observableAddedComponentsRegistry = registries.addedComponentsRegistry.asObservable(); + const observableAddedLinksRegistry = registries.addedLinksRegistry.asObservable(); return function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult { - const registry = useObservable(observableRegistry); - const addedComponentsRegistry = useObservable(observableAddedComponentRegistry); + const addedComponentsRegistry = useObservable(observableAddedComponentsRegistry); + const addedLinksRegistry = useObservable(observableAddedLinksRegistry); - if (!registry || !addedComponentsRegistry) { - return { extensions: [], isLoading: false }; - } + const { extensions } = useMemo(() => { + if (!addedLinksRegistry && !addedComponentsRegistry) { + return { extensions: [], isLoading: false }; + } - if (registry.id !== cache.id) { - cache.id = registry.id; - cache.extensions = {}; - } - - // `getPluginExtensions` will return a new array of objects even if it is called with the same options, as it always constructing a frozen objects. - // Due to this we are caching the result of `getPluginExtensions` to avoid unnecessary re-renders for components that are using this hook. - // (NOTE: we are only checking referential equality of `context` object, so it is important to not mutate the object passed to this hook.) - const key = `${options.extensionPointId}-${options.limitPerPlugin}`; - if (cache.extensions[key] && cache.extensions[key].context === options.context) { - return { - extensions: cache.extensions[key].extensions, - isLoading: false, - }; - } - - const { extensions } = getPluginExtensions({ - ...options, - registry, + return getPluginExtensions({ + extensionPointId: options.extensionPointId, + context: options.context, + limitPerPlugin: options.limitPerPlugin, + addedComponentsRegistry, + addedLinksRegistry, + }); + }, [ + addedLinksRegistry, addedComponentsRegistry, - }); + options.extensionPointId, + options.context, + options.limitPerPlugin, + ]); - cache.extensions[key] = { - context: options.context, - extensions, - }; - - return { - extensions, - isLoading: false, - }; + return { extensions, isLoading: false }; }; } diff --git a/public/app/features/plugins/extensions/usePluginLinks.test.tsx b/public/app/features/plugins/extensions/usePluginLinks.test.tsx new file mode 100644 index 00000000000..ceefed61393 --- /dev/null +++ b/public/app/features/plugins/extensions/usePluginLinks.test.tsx @@ -0,0 +1,118 @@ +import { act } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; + +import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; +import { createUsePluginLinks } from './usePluginLinks'; + +jest.mock('app/features/plugins/pluginSettings', () => ({ + getPluginSettings: jest.fn().mockResolvedValue({ + id: 'my-app-plugin', + enabled: true, + jsonData: {}, + type: 'panel', + name: 'My App Plugin', + module: 'app/plugins/my-app-plugin/module', + }), +})); + +describe('usePluginLinks()', () => { + let registry: AddedLinksRegistry; + + beforeEach(() => { + registry = new AddedLinksRegistry(); + }); + + it('should return an empty array if there are no link extensions registered for the extension point', () => { + const usePluginComponents = createUsePluginLinks(registry); + const { result } = renderHook(() => + usePluginComponents({ + extensionPointId: 'foo/bar', + }) + ); + + expect(result.current.links).toEqual([]); + }); + + it('should only return the link extensions for the given extension point ids', async () => { + const extensionPointId = 'plugins/foo/bar/v1'; + const pluginId = 'my-app-plugin'; + + registry.register({ + pluginId, + configs: [ + { + targets: extensionPointId, + title: '1', + description: '1', + path: `/a/${pluginId}/2`, + }, + { + targets: extensionPointId, + title: '2', + description: '2', + path: `/a/${pluginId}/2`, + }, + { + targets: 'plugins/another-extension/v1', + title: '3', + description: '3', + path: `/a/${pluginId}/3`, + }, + ], + }); + + const usePluginExtensions = createUsePluginLinks(registry); + const { result } = renderHook(() => usePluginExtensions({ extensionPointId })); + + expect(result.current.links.length).toBe(2); + expect(result.current.links[0].title).toBe('1'); + expect(result.current.links[1].title).toBe('2'); + }); + + it('should dynamically update the extensions registered for a certain extension point', () => { + const extensionPointId = 'plugins/foo/bar/v1'; + const pluginId = 'my-app-plugin'; + const usePluginExtensions = createUsePluginLinks(registry); + let { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId })); + + // No extensions yet + expect(result.current.links.length).toBe(0); + + // Add extensions to the registry + act(() => { + registry.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.links.length).toBe(2); + expect(result.current.links[0].title).toBe('1'); + expect(result.current.links[1].title).toBe('2'); + }); + + it('should only render the hook once', () => { + const addedLinksRegistrySpy = jest.spyOn(registry, 'asObservable'); + const extensionPointId = 'plugins/foo/bar'; + const usePluginLinks = createUsePluginLinks(registry); + + renderHook(() => usePluginLinks({ extensionPointId })); + expect(addedLinksRegistrySpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/public/app/features/plugins/extensions/usePluginLinks.tsx b/public/app/features/plugins/extensions/usePluginLinks.tsx new file mode 100644 index 00000000000..0350209636a --- /dev/null +++ b/public/app/features/plugins/extensions/usePluginLinks.tsx @@ -0,0 +1,90 @@ +import { isString } from 'lodash'; +import { useMemo } from 'react'; +import { useObservable } from 'react-use'; + +import { PluginExtensionLink, PluginExtensionTypes } from '@grafana/data'; +import { + UsePluginLinksOptions, + UsePluginLinksResult, +} from '@grafana/runtime/src/services/pluginExtensions/getPluginExtensions'; + +import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; +import { + generateExtensionId, + getLinkExtensionOnClick, + getLinkExtensionOverrides, + getLinkExtensionPathWithTracking, + getReadOnlyProxy, +} from './utils'; + +// Returns an array of component extensions for the given extension point +export function createUsePluginLinks(registry: AddedLinksRegistry) { + const observableRegistry = registry.asObservable(); + + return function usePluginLinks({ + limitPerPlugin, + extensionPointId, + context, + }: UsePluginLinksOptions): UsePluginLinksResult { + const registry = useObservable(observableRegistry); + + return useMemo(() => { + if (!registry || !registry[extensionPointId]) { + return { + isLoading: false, + links: [], + }; + } + const frozenContext = context ? getReadOnlyProxy(context) : {}; + const extensions: PluginExtensionLink[] = []; + const extensionsByPlugin: Record = {}; + + for (const addedLink of registry[extensionPointId] ?? []) { + const { pluginId } = addedLink; + // Only limit if the `limitPerPlugin` is set + if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) { + continue; + } + + if (extensionsByPlugin[pluginId] === undefined) { + extensionsByPlugin[pluginId] = 0; + } + + // Run the configure() function with the current context, and apply the ovverides + const overrides = getLinkExtensionOverrides(pluginId, addedLink, frozenContext); + + // configure() returned an `undefined` -> hide the extension + if (addedLink.configure && overrides === undefined) { + continue; + } + + const path = overrides?.path || addedLink.path; + const extension: PluginExtensionLink = { + id: generateExtensionId(pluginId, { + ...addedLink, + extensionPointId, + type: PluginExtensionTypes.link, + }), + type: PluginExtensionTypes.link, + pluginId: pluginId, + onClick: getLinkExtensionOnClick(pluginId, extensionPointId, addedLink, frozenContext), + + // Configurable properties + icon: overrides?.icon || addedLink.icon, + title: overrides?.title || addedLink.title, + description: overrides?.description || addedLink.description, + path: isString(path) ? getLinkExtensionPathWithTracking(pluginId, path, extensionPointId) : undefined, + category: overrides?.category || addedLink.category, + }; + + extensions.push(extension); + extensionsByPlugin[pluginId] += 1; + } + + return { + isLoading: false, + links: extensions, + }; + }, [context, extensionPointId, limitPerPlugin, registry]); + }; +} diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx index c0189806dd2..f579138e76a 100644 --- a/public/app/features/plugins/extensions/utils.test.tsx +++ b/public/app/features/plugins/extensions/utils.test.tsx @@ -1,18 +1,11 @@ import { render, screen } from '@testing-library/react'; import { type Unsubscribable } from 'rxjs'; -import { type PluginExtensionLinkConfig, PluginExtensionTypes, dateTime, usePluginContext } from '@grafana/data'; +import { dateTime, usePluginContext } from '@grafana/data'; import appEvents from 'app/core/app_events'; import { ShowModalReactEvent } from 'app/types/events'; -import { - deepFreeze, - isPluginExtensionLinkConfig, - handleErrorsInFn, - getReadOnlyProxy, - getEventHelpers, - wrapWithPluginContext, -} from './utils'; +import { deepFreeze, handleErrorsInFn, getReadOnlyProxy, getEventHelpers, wrapWithPluginContext } from './utils'; jest.mock('app/features/plugins/pluginSettings', () => ({ ...jest.requireActual('app/features/plugins/pluginSettings'), @@ -198,28 +191,6 @@ describe('Plugin Extensions / Utils', () => { }); }); - describe('isPluginExtensionLinkConfig()', () => { - test('should return TRUE if the object is a command extension config', () => { - expect( - isPluginExtensionLinkConfig({ - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - path: '...', - } as PluginExtensionLinkConfig) - ).toBe(true); - }); - test('should return FALSE if the object is NOT a link extension', () => { - expect( - isPluginExtensionLinkConfig({ - title: 'Title', - description: 'Description', - path: '...', - } as PluginExtensionLinkConfig) - ).toBe(false); - }); - }); - describe('handleErrorsInFn()', () => { test('should catch errors thrown by the provided function and print them as console warnings', () => { global.console.warn = jest.fn(); diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index fba4871185c..8c3075dd402 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -14,12 +14,18 @@ import { PluginContextProvider, PluginExtensionLink, PanelMenuItem, + PluginExtensionAddedLinkConfig, + urlUtil, } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; import { Modal } from '@grafana/ui'; import appEvents from 'app/core/app_events'; import { getPluginSettings } from 'app/features/plugins/pluginSettings'; import { ShowModalReactEvent } from 'app/types/events'; +import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry'; +import { assertIsNotPromise, assertLinkPathIsValid, assertStringProps, isPromise } from './validators'; + export function logWarning(message: string) { console.warn(`[Plugin Extensions] ${message}`); } @@ -218,11 +224,10 @@ export function isReadOnlyProxy(value: unknown): boolean { return isRecord(value) && value[_isProxy] === true; } -export function createExtensionLinkConfig( - config: Omit, 'type'> -): PluginExtensionLinkConfig { - const linkConfig: PluginExtensionLinkConfig = { - type: PluginExtensionTypes.link, +export function createAddedLinkConfig( + config: PluginExtensionAddedLinkConfig +): PluginExtensionAddedLinkConfig { + const linkConfig: PluginExtensionAddedLinkConfig = { ...config, }; assertLinkConfig(linkConfig); @@ -230,12 +235,8 @@ export function createExtensionLinkConfig( } function assertLinkConfig( - config: PluginExtensionLinkConfig -): asserts config is PluginExtensionLinkConfig { - if (config.type !== PluginExtensionTypes.link) { - throw Error('config is not a extension link'); - } -} + config: PluginExtensionAddedLinkConfig +): asserts config is PluginExtensionAddedLinkConfig {} export function truncateTitle(title: string, length: number): string { if (title.length < length) { @@ -294,3 +295,103 @@ export function createExtensionSubMenu(extensions: PluginExtensionLink[]): Panel return subMenu; } + +export function getLinkExtensionOverrides(pluginId: string, config: AddedLinkRegistryItem, context?: object) { + try { + const overrides = config.configure?.(context); + + // Hiding the extension + if (overrides === undefined) { + return undefined; + } + + let { + title = config.title, + description = config.description, + path = config.path, + icon = config.icon, + category = config.category, + ...rest + } = overrides; + + assertIsNotPromise( + overrides, + `The configure() function for "${config.title}" returned a promise, skipping updates.` + ); + + path && assertLinkPathIsValid(pluginId, path); + assertStringProps({ title, description }, ['title', 'description']); + + if (Object.keys(rest).length > 0) { + logWarning( + `Extension "${config.title}", is trying to override restricted properties: ${Object.keys(rest).join( + ', ' + )} which will be ignored.` + ); + } + + return { + title, + description, + path, + icon, + category, + }; + } catch (error) { + if (error instanceof Error) { + logWarning(error.message); + } + + // If there is an error, we hide the extension + // (This seems to be safest option in case the extension is doing something wrong.) + return undefined; + } +} + +export function getLinkExtensionOnClick( + pluginId: string, + extensionPointId: string, + config: AddedLinkRegistryItem, + context?: object +): ((event?: React.MouseEvent) => void) | undefined { + const { onClick } = config; + + if (!onClick) { + return; + } + + return function onClickExtensionLink(event?: React.MouseEvent) { + try { + reportInteraction('ui_extension_link_clicked', { + pluginId: pluginId, + extensionPointId, + title: config.title, + category: config.category, + }); + + const result = onClick(event, getEventHelpers(pluginId, context)); + + if (isPromise(result)) { + result.catch((e) => { + if (e instanceof Error) { + logWarning(e.message); + } + }); + } + } catch (error) { + if (error instanceof Error) { + logWarning(error.message); + } + } + }; +} + +export function getLinkExtensionPathWithTracking(pluginId: string, path: string, extensionPointId: string): string { + return urlUtil.appendQueryToUrl( + path, + urlUtil.toUrlParams({ + uel_pid: pluginId, + uel_epid: extensionPointId, + }) + ); +} diff --git a/public/app/features/plugins/extensions/validators.test.tsx b/public/app/features/plugins/extensions/validators.test.tsx index 761acaa6e31..c7ad7a8f271 100644 --- a/public/app/features/plugins/extensions/validators.test.tsx +++ b/public/app/features/plugins/extensions/validators.test.tsx @@ -1,43 +1,16 @@ import { memo } from 'react'; -import { PluginExtension, PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data'; +import { PluginExtensionAddedLinkConfig, PluginExtensionLinkConfig, PluginExtensionPoints } from '@grafana/data'; import { assertConfigureIsValid, assertLinkPathIsValid, - assertExtensionPointIdIsValid, - assertPluginExtensionLink, assertStringProps, - isPluginExtensionConfigValid, + isGrafanaCoreExtensionPoint, isReactComponent, } from './validators'; describe('Plugin Extension Validators', () => { - describe('assertPluginExtensionLink()', () => { - it('should NOT throw an error if it is a link extension', () => { - expect(() => { - assertPluginExtensionLink({ - id: 'id', - pluginId: 'myorg-b-app', - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - path: '...', - } as PluginExtension); - }).not.toThrowError(); - }); - - it('should throw an error if it is not a link extension', () => { - expect(() => { - assertPluginExtensionLink({ - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - } as PluginExtension); - }).toThrowError(); - }); - }); - describe('assertLinkPathIsValid()', () => { it('should not throw an error if the link path is valid', () => { expect(() => { @@ -80,45 +53,14 @@ describe('Plugin Extension Validators', () => { }); }); - describe('assertExtensionPointIdIsValid()', () => { - it('should throw an error if the extensionPointId does not have the right prefix', () => { - expect(() => { - assertExtensionPointIdIsValid('my-org-app', { - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - extensionPointId: 'wrong-extension-point-id', - }); - }).toThrowError(); - }); - - it('should NOT throw an error if the extensionPointId is correct', () => { - expect(() => { - assertExtensionPointIdIsValid('my-org-app', { - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - extensionPointId: 'grafana/some-page/extension-point-a', - }); - - assertExtensionPointIdIsValid('my-org-app', { - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - extensionPointId: 'plugins/my-super-plugin/some-page/extension-point-a', - }); - }).not.toThrowError(); - }); - }); - describe('assertConfigureIsValid()', () => { it('should NOT throw an error if the configure() function is missing', () => { expect(() => { assertConfigureIsValid({ title: 'Title', description: 'Description', - extensionPointId: 'grafana/some-page/extension-point-a', - } as PluginExtensionLinkConfig); + targets: 'grafana/some-page/extension-point-a', + } as PluginExtensionAddedLinkConfig); }).not.toThrowError(); }); @@ -127,9 +69,9 @@ describe('Plugin Extension Validators', () => { assertConfigureIsValid({ title: 'Title', description: 'Description', - extensionPointId: 'grafana/some-page/extension-point-a', + targets: 'grafana/some-page/extension-point-a', configure: () => {}, - } as PluginExtensionLinkConfig); + } as PluginExtensionAddedLinkConfig); }).not.toThrowError(); }); @@ -203,70 +145,6 @@ describe('Plugin Extension Validators', () => { }); }); - describe('isPluginExtensionConfigValid()', () => { - it('should return TRUE if the plugin extension configuration is valid', () => { - const pluginId = 'my-super-plugin'; - - expect( - isPluginExtensionConfigValid(pluginId, { - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - onClick: jest.fn(), - extensionPointId: 'grafana/some-page/extension-point-a', - } as PluginExtensionLinkConfig) - ).toBe(true); - - expect( - isPluginExtensionConfigValid(pluginId, { - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - extensionPointId: 'grafana/some-page/extension-point-a', - path: `/a/${pluginId}/page`, - } as PluginExtensionLinkConfig) - ).toBe(true); - }); - - it('should return FALSE if the plugin extension configuration is invalid', () => { - const pluginId = 'my-super-plugin'; - - global.console.warn = jest.fn(); - - // Link (wrong path) - expect( - isPluginExtensionConfigValid(pluginId, { - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - extensionPointId: 'grafana/some-page/extension-point-a', - path: '/administration/users', - } as PluginExtensionLinkConfig) - ).toBe(false); - - // Link (no path and no onClick) - expect( - isPluginExtensionConfigValid(pluginId, { - type: PluginExtensionTypes.link, - title: 'Title', - description: 'Description', - extensionPointId: 'grafana/some-page/extension-point-a', - } as PluginExtensionLinkConfig) - ).toBe(false); - - // Link (missing title) - expect( - isPluginExtensionConfigValid(pluginId, { - type: PluginExtensionTypes.link, - title: '', - description: 'Description', - extensionPointId: 'grafana/some-page/extension-point-a', - path: `/a/${pluginId}/page`, - } as PluginExtensionLinkConfig) - ).toBe(false); - }); - }); - describe('isReactComponent()', () => { it('should return TRUE if we pass in a valid React component', () => { expect(isReactComponent(() =>
Some text
)).toBe(true); @@ -292,4 +170,18 @@ describe('Plugin Extension Validators', () => { expect(isReactComponent(null)).toBe(false); }); }); + + describe('isGrafanaCoreExtensionPoint()', () => { + it('should return TRUE if we pass an PluginExtensionPoints value', () => { + expect(isGrafanaCoreExtensionPoint(PluginExtensionPoints.AlertingAlertingRuleAction)).toBe(true); + }); + + it('should return TRUE if we pass a string that is not listed under the PluginExtensionPoints enum', () => { + expect(isGrafanaCoreExtensionPoint('grafana/alerting/alertingrule/action')).toBe(true); + }); + + it('should return FALSE if we pass a string that is not listed under the PluginExtensionPoints enum', () => { + expect(isGrafanaCoreExtensionPoint('grafana/dashboard/alertingrule/action')).toBe(false); + }); + }); }); diff --git a/public/app/features/plugins/extensions/validators.ts b/public/app/features/plugins/extensions/validators.ts index 1584992b670..96970860973 100644 --- a/public/app/features/plugins/extensions/validators.ts +++ b/public/app/features/plugins/extensions/validators.ts @@ -1,13 +1,7 @@ -import type { - PluginExtension, - PluginExtensionConfig, - PluginExtensionLink, - PluginExtensionLinkConfig, -} from '@grafana/data'; +import type { PluginExtensionAddedLinkConfig, PluginExtension, PluginExtensionLink } from '@grafana/data'; +import { PluginAddedLinksConfigureFunc, PluginExtensionPoints } from '@grafana/data/src/types/pluginExtensions'; import { isPluginExtensionLink } from '@grafana/runtime'; -import { isPluginExtensionLinkConfig, logWarning } from './utils'; - export function assertPluginExtensionLink( extension: PluginExtension | undefined, errorMessage = 'extension is not a link extension' @@ -17,15 +11,6 @@ export function assertPluginExtensionLink( } } -export function assertPluginExtensionLinkConfig( - extension: PluginExtensionLinkConfig, - errorMessage = 'extension is not a command extension config' -): asserts extension is PluginExtensionLinkConfig { - if (!isPluginExtensionLinkConfig(extension)) { - throw new Error(errorMessage); - } -} - export function assertLinkPathIsValid(pluginId: string, path: string) { if (!isLinkPathValid(pluginId, path)) { throw new Error( @@ -40,18 +25,10 @@ export function assertIsReactComponent(component: React.ComponentType) { } } -export function assertExtensionPointIdIsValid(pluginId: string, extension: PluginExtensionConfig) { - if (!isExtensionPointIdValid(pluginId, extension.extensionPointId)) { +export function assertConfigureIsValid(config: PluginExtensionAddedLinkConfig) { + if (!isConfigureFnValid(config.configure)) { throw new Error( - `Invalid extension "${extension.title}". The extensionPointId should start with either "grafana/", "plugins/" or "capabilities/${pluginId}" (currently: "${extension.extensionPointId}"). Skipping the extension.` - ); - } -} - -export function assertConfigureIsValid(extension: PluginExtensionLinkConfig) { - if (!isConfigureFnValid(extension)) { - throw new Error( - `Invalid extension "${extension.title}". The "configure" property must be a function. Skipping the extension.` + `Invalid extension "${config.title}". The "configure" property must be a function. Skipping the extension.` ); } } @@ -88,43 +65,20 @@ export function extensionPointEndsWithVersion(extensionPointId: string) { return extensionPointId.match(/.*\/v\d+$/); } -export function isConfigureFnValid(extension: PluginExtensionLinkConfig) { - return extension.configure ? typeof extension.configure === 'function' : true; +export function isGrafanaCoreExtensionPoint(extensionPointId: string) { + return Object.values(PluginExtensionPoints) + .map((v) => v.toString()) + .includes(extensionPointId); +} + +export function isConfigureFnValid(configure?: PluginAddedLinksConfigureFunc | undefined) { + return configure ? typeof configure === 'function' : true; } export function isStringPropValid(prop: unknown) { return typeof prop === 'string' && prop.length > 0; } -export function isPluginExtensionConfigValid(pluginId: string, extension: PluginExtensionConfig): boolean { - try { - assertStringProps(extension, ['title', 'description', 'extensionPointId']); - assertExtensionPointIdIsValid(pluginId, extension); - - // Link - if (isPluginExtensionLinkConfig(extension)) { - assertConfigureIsValid(extension); - - if (!extension.path && !extension.onClick) { - logWarning(`Invalid extension "${extension.title}". Either "path" or "onClick" is required.`); - return false; - } - - if (extension.path) { - assertLinkPathIsValid(pluginId, extension.path); - } - } - - return true; - } catch (error) { - if (error instanceof Error) { - logWarning(error.message); - } - - return false; - } -} - export function isPromise(value: unknown): value is Promise { return ( value instanceof Promise || (typeof value === 'object' && value !== null && 'then' in value && 'catch' in value) diff --git a/public/app/features/plugins/pluginPreloader.ts b/public/app/features/plugins/pluginPreloader.ts index 7fa1c536896..024d743a652 100644 --- a/public/app/features/plugins/pluginPreloader.ts +++ b/public/app/features/plugins/pluginPreloader.ts @@ -1,26 +1,18 @@ -import type { PluginExposedComponentConfig, PluginExtensionConfig } from '@grafana/data'; -import { PluginAddedComponentConfig } from '@grafana/data/src/types/pluginExtensions'; +import type { PluginExtensionAddedLinkConfig, PluginExtensionExposedComponentConfig } from '@grafana/data'; +import { PluginExtensionAddedComponentConfig } from '@grafana/data/src/types/pluginExtensions'; import type { AppPluginConfig } from '@grafana/runtime'; import { startMeasure, stopMeasure } from 'app/core/utils/metrics'; import { getPluginSettings } from 'app/features/plugins/pluginSettings'; -import { ReactivePluginExtensionsRegistry } from './extensions/reactivePluginExtensionRegistry'; -import { AddedComponentsRegistry } from './extensions/registry/AddedComponentsRegistry'; -import { ExposedComponentsRegistry } from './extensions/registry/ExposedComponentsRegistry'; +import { PluginExtensionRegistries } from './extensions/registry/types'; import * as pluginLoader from './plugin_loader'; export type PluginPreloadResult = { pluginId: string; error?: unknown; - extensionConfigs: PluginExtensionConfig[]; - exposedComponentConfigs: PluginExposedComponentConfig[]; - addedComponentConfigs: PluginAddedComponentConfig[]; -}; - -type PluginExtensionRegistries = { - extensionsRegistry: ReactivePluginExtensionsRegistry; - addedComponentsRegistry: AddedComponentsRegistry; - exposedComponentsRegistry: ExposedComponentsRegistry; + exposedComponentConfigs: PluginExtensionExposedComponentConfig[]; + addedComponentConfigs?: PluginExtensionAddedComponentConfig[]; + addedLinkConfigs?: PluginExtensionAddedLinkConfig[]; }; export async function preloadPlugins( @@ -38,14 +30,17 @@ export async function preloadPlugins( continue; } - registries.extensionsRegistry.register(preloadedPlugin); registries.exposedComponentsRegistry.register({ pluginId: preloadedPlugin.pluginId, configs: preloadedPlugin.exposedComponentConfigs, }); registries.addedComponentsRegistry.register({ pluginId: preloadedPlugin.pluginId, - configs: preloadedPlugin.addedComponentConfigs, + configs: preloadedPlugin.addedComponentConfigs || [], + }); + registries.addedLinksRegistry.register({ + pluginId: preloadedPlugin.pluginId, + configs: preloadedPlugin.addedLinkConfigs || [], }); } @@ -62,16 +57,22 @@ async function preload(config: AppPluginConfig): Promise { isAngular: config.angular.detected, pluginId, }); - const { extensionConfigs = [], exposedComponentConfigs = [], addedComponentConfigs = [] } = plugin; + const { exposedComponentConfigs = [], addedComponentConfigs = [], addedLinkConfigs = [] } = plugin; // Fetching meta-information for the preloaded app plugin and caching it for later. // (The function below returns a promise, but it's not awaited for a reason: we don't want to block the preload process, we would only like to cache the result for later.) getPluginSettings(pluginId); - return { pluginId, extensionConfigs, exposedComponentConfigs, addedComponentConfigs }; + return { pluginId, exposedComponentConfigs, addedComponentConfigs, addedLinkConfigs }; } catch (error) { console.error(`[Plugins] Failed to preload plugin: ${path} (version: ${version})`, error); - return { pluginId, extensionConfigs: [], error, exposedComponentConfigs: [], addedComponentConfigs: [] }; + return { + pluginId, + error, + exposedComponentConfigs: [], + addedComponentConfigs: [], + addedLinkConfigs: [], + }; } finally { stopMeasure(`frontend_plugin_preload_${pluginId}`); }