diff --git a/.betterer.results b/.betterer.results index d12c0a98415..b1db9758f60 100644 --- a/.betterer.results +++ b/.betterer.results @@ -700,6 +700,9 @@ exports[`better eslint`] = { "packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], + "packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "packages/grafana-runtime/src/utils/DataSourceWithBackend.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] @@ -4072,6 +4075,9 @@ 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/loader/sharedDependencies.ts:5381": [ [0, 0, 0, "* import is invalid because \'Layout,HorizontalGroup,VerticalGroup\' from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"] ], diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts index 56208d451f8..f60715cbe5d 100644 --- a/packages/grafana-runtime/src/services/index.ts +++ b/packages/grafana-runtime/src/services/index.ts @@ -15,5 +15,15 @@ export { getPluginLinkExtensions, getPluginComponentExtensions, type GetPluginExtensions, + type GetPluginExtensionsOptions, + type GetPluginExtensionsResult, + type UsePluginExtensions, + type UsePluginExtensionsResult, } from './pluginExtensions/getPluginExtensions'; +export { + setPluginExtensionsHook, + usePluginExtensions, + usePluginLinkExtensions, + usePluginComponentExtensions, +} from './pluginExtensions/usePluginExtensions'; export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils'; diff --git a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts index dcd52b200b2..eff1975e8f4 100644 --- a/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts +++ b/packages/grafana-runtime/src/services/pluginExtensions/getPluginExtensions.ts @@ -2,18 +2,29 @@ import type { PluginExtension, PluginExtensionLink, PluginExtensionComponent } f import { isPluginExtensionComponent, isPluginExtensionLink } from './utils'; -export type GetPluginExtensions = ({ - extensionPointId, - context, - limitPerPlugin, -}: { +export type GetPluginExtensions = ( + options: GetPluginExtensionsOptions +) => GetPluginExtensionsResult; + +export type UsePluginExtensions = ( + options: GetPluginExtensionsOptions +) => UsePluginExtensionsResult; + +export type GetPluginExtensionsOptions = { extensionPointId: string; context?: object | Record; limitPerPlugin?: number; -}) => { +}; + +export type GetPluginExtensionsResult = { extensions: T[]; }; +export type UsePluginExtensionsResult = { + extensions: T[]; + isLoading: boolean; +}; + let singleton: GetPluginExtensions | undefined; export function setPluginExtensionGetter(instance: GetPluginExtensions): void { diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.test.tsx b/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.test.tsx new file mode 100644 index 00000000000..de943427e64 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.test.tsx @@ -0,0 +1,262 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import { PluginExtension, PluginExtensionTypes } from '@grafana/data'; + +import { UsePluginExtensions } from './getPluginExtensions'; +import { + setPluginExtensionsHook, + usePluginComponentExtensions, + usePluginExtensions, + usePluginLinkExtensions, +} from './usePluginExtensions'; + +describe('Plugin Extensions / usePluginExtensions', () => { + afterEach(() => { + process.env.NODE_ENV = 'test'; + }); + + test('should always return the same extension-hook function that was previously set', () => { + const hook: UsePluginExtensions = jest.fn().mockReturnValue({ extensions: [], isLoading: false }); + + setPluginExtensionsHook(hook); + usePluginExtensions({ extensionPointId: 'panel-menu' }); + + expect(hook).toHaveBeenCalledTimes(1); + expect(hook).toHaveBeenCalledWith({ extensionPointId: 'panel-menu' }); + }); + + test('should throw an error when trying to redefine the app-wide extension-hook function', () => { + // By default, NODE_ENV is set to 'test' in jest.config.js, which allows to override the registry in tests. + process.env.NODE_ENV = 'production'; + + const hook: UsePluginExtensions = () => ({ extensions: [], isLoading: false }); + + expect(() => { + setPluginExtensionsHook(hook); + setPluginExtensionsHook(hook); + }).toThrow(); + }); + + test('should throw an error when trying to access the extension-hook function before it was set', () => { + // "Unsetting" the registry + // @ts-ignore + setPluginExtensionsHook(undefined); + + expect(() => { + usePluginExtensions({ extensionPointId: 'panel-menu' }); + }).toThrow(); + }); + + describe('usePluginExtensionLinks()', () => { + test('should return only links extensions', () => { + const usePluginExtensionsMock: UsePluginExtensions = () => ({ + extensions: [ + { + id: '1', + pluginId: '', + title: '', + description: '', + type: PluginExtensionTypes.component, + component: () => undefined, + }, + { + id: '2', + pluginId: '', + title: '', + description: '', + path: '', + type: PluginExtensionTypes.link, + }, + { + id: '3', + pluginId: '', + title: '', + description: '', + path: '', + type: PluginExtensionTypes.link, + }, + ], + isLoading: false, + }); + + setPluginExtensionsHook(usePluginExtensionsMock); + + const { result } = renderHook(() => usePluginLinkExtensions({ extensionPointId: 'panel-menu' })); + const { extensions } = result.current; + + expect(extensions).toHaveLength(2); + expect(extensions[0].type).toBe('link'); + expect(extensions[1].type).toBe('link'); + expect(extensions.find(({ id }) => id === '2')).toBeDefined(); + expect(extensions.find(({ id }) => id === '3')).toBeDefined(); + }); + + test('should return the same object if the extensions do not change', () => { + const extensionPointId = 'foo'; + const extensions: PluginExtension[] = [ + { + id: '1', + pluginId: '', + title: '', + description: '', + path: '', + type: PluginExtensionTypes.link, + }, + ]; + + // Mimicing that the extensions do not change between renders + const usePluginExtensionsMock: UsePluginExtensions = () => ({ + extensions, + isLoading: false, + }); + + setPluginExtensionsHook(usePluginExtensionsMock); + + const { result, rerender } = renderHook(() => usePluginLinkExtensions({ extensionPointId })); + const firstExtensions = result.current.extensions; + + rerender(); + + const secondExtensions = result.current.extensions; + + expect(firstExtensions === secondExtensions).toBe(true); + }); + + test('should return a different object if the extensions do change', () => { + const extensionPointId = 'foo'; + + // Mimicing that the extensions is a new array object every time + const usePluginExtensionsMock: UsePluginExtensions = () => ({ + extensions: [ + { + id: '1', + pluginId: '', + title: '', + description: '', + path: '', + type: PluginExtensionTypes.link, + }, + ], + isLoading: false, + }); + + setPluginExtensionsHook(usePluginExtensionsMock); + + const { result, rerender } = renderHook(() => usePluginLinkExtensions({ extensionPointId })); + const firstExtensions = result.current.extensions; + + rerender(); + + const secondExtensions = result.current.extensions; + + // The results differ + expect(firstExtensions === secondExtensions).toBe(false); + }); + }); + + describe('usePluginExtensionComponents()', () => { + test('should return only component extensions', () => { + const hook: UsePluginExtensions = () => ({ + extensions: [ + { + id: '1', + pluginId: '', + title: '', + description: '', + type: PluginExtensionTypes.component, + component: () => undefined, + }, + { + id: '2', + pluginId: '', + title: '', + description: '', + path: '', + type: PluginExtensionTypes.link, + }, + { + id: '3', + pluginId: '', + title: '', + description: '', + path: '', + type: PluginExtensionTypes.link, + }, + ], + isLoading: false, + }); + + setPluginExtensionsHook(hook); + + const hookRender = renderHook(() => usePluginComponentExtensions({ extensionPointId: 'panel-menu' })); + const { extensions } = hookRender.result.current; + + expect(extensions).toHaveLength(1); + expect(extensions[0].type).toBe('component'); + expect(extensions.find(({ id }) => id === '1')).toBeDefined(); + }); + + test('should return the same object if the extensions do not change', () => { + const extensionPointId = 'foo'; + const extensions: PluginExtension[] = [ + { + id: '1', + pluginId: '', + title: '', + description: '', + type: PluginExtensionTypes.component, + component: () => undefined, + }, + ]; + + // Mimicing that the extensions do not change between renders + const usePluginExtensionsMock: UsePluginExtensions = () => ({ + extensions, + isLoading: false, + }); + + setPluginExtensionsHook(usePluginExtensionsMock); + + const { result, rerender } = renderHook(() => usePluginComponentExtensions({ extensionPointId })); + const firstExtensions = result.current.extensions; + + rerender(); + + const secondExtensions = result.current.extensions; + + // The results are the same + expect(firstExtensions === secondExtensions).toBe(true); + }); + + test('should return a different object if the extensions do change', () => { + const extensionPointId = 'foo'; + + // Mimicing that the extensions is a new array object every time + const usePluginExtensionsMock: UsePluginExtensions = () => ({ + extensions: [ + { + id: '1', + pluginId: '', + title: '', + description: '', + type: PluginExtensionTypes.component, + component: () => undefined, + }, + ], + isLoading: false, + }); + + setPluginExtensionsHook(usePluginExtensionsMock); + + const { result, rerender } = renderHook(() => usePluginComponentExtensions({ extensionPointId })); + const firstExtensions = result.current.extensions; + + rerender(); + + const secondExtensions = result.current.extensions; + + // The results differ + expect(firstExtensions === secondExtensions).toBe(false); + }); + }); +}); diff --git a/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts new file mode 100644 index 00000000000..4629fc7baab --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; + +import { PluginExtensionComponent, PluginExtensionLink } from '@grafana/data'; + +import { GetPluginExtensionsOptions, UsePluginExtensions, UsePluginExtensionsResult } from './getPluginExtensions'; +import { isPluginExtensionComponent, isPluginExtensionLink } from './utils'; + +let singleton: UsePluginExtensions | undefined; + +export function setPluginExtensionsHook(hook: UsePluginExtensions): void { + // We allow overriding the registry in tests + if (singleton && process.env.NODE_ENV !== 'test') { + throw new Error('setPluginExtensionsHook() function should only be called once, when Grafana is starting.'); + } + singleton = hook; +} + +export function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult { + if (!singleton) { + throw new Error('usePluginExtensions(options) can only be used after the Grafana instance has started.'); + } + return singleton(options); +} + +export function usePluginLinkExtensions( + options: GetPluginExtensionsOptions +): UsePluginExtensionsResult { + const { extensions, isLoading } = usePluginExtensions(options); + + return useMemo(() => { + return { + extensions: extensions.filter(isPluginExtensionLink), + isLoading, + }; + }, [extensions, isLoading]); +} + +export function usePluginComponentExtensions( + options: GetPluginExtensionsOptions +): { extensions: Array>; isLoading: boolean } { + const { extensions, isLoading } = usePluginExtensions(options); + + return useMemo( + () => ({ + extensions: extensions.filter(isPluginExtensionComponent) as Array>, + isLoading, + }), + [extensions, isLoading] + ); +} diff --git a/public/app/app.ts b/public/app/app.ts index 5796b18c21e..56fa4405843 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -36,7 +36,7 @@ import { setEmbeddedDashboard, setAppEvents, setReturnToPreviousHook, - type GetPluginExtensions, + setPluginExtensionsHook, } from '@grafana/runtime'; import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView'; import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer'; @@ -80,11 +80,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 { createPluginExtensionRegistry } from './features/plugins/extensions/createPluginExtensionRegistry'; import { getCoreExtensionConfigurations } from './features/plugins/extensions/getCoreExtensionConfigurations'; -import { getPluginExtensions } from './features/plugins/extensions/getPluginExtensions'; +import { createPluginExtensionsGetter } from './features/plugins/extensions/getPluginExtensions'; +import { ReactivePluginExtensionsRegistry } from './features/plugins/extensions/reactivePluginExtensionRegistry'; +import { createPluginExtensionsHook } from './features/plugins/extensions/usePluginExtensions'; import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin'; -import { PluginPreloadResult, preloadPlugins } from './features/plugins/pluginPreloader'; +import { preloadPlugins } from './features/plugins/pluginPreloader'; import { QueryRunner } from './features/query/state/QueryRunner'; import { runRequest } from './features/query/state/runRequest'; import { initWindowRuntime } from './features/runtime/init'; @@ -206,24 +207,26 @@ export class GrafanaApp { setDataSourceSrv(dataSourceSrv); initWindowRuntime(); - let preloadResults: PluginPreloadResult[] = []; + // Initialize plugin extensions + const extensionsRegistry = new ReactivePluginExtensionsRegistry(); + extensionsRegistry.register({ + pluginId: 'grafana', + extensionConfigs: getCoreExtensionConfigurations(), + }); if (contextSrv.user.orgRole !== '') { - // Preload selected app plugins - preloadResults = await preloadPlugins(config.apps); + // 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. + // TODO: remove the following exception once the issue mentioned above is fixed. + const awaitedAppPluginIds = ['cloud-home-app']; + const awaitedAppPlugins = Object.values(config.apps).filter((app) => awaitedAppPluginIds.includes(app.id)); + const appPlugins = Object.values(config.apps).filter((app) => !awaitedAppPluginIds.includes(app.id)); + + preloadPlugins(appPlugins, extensionsRegistry); + await preloadPlugins(awaitedAppPlugins, extensionsRegistry); } - // Create extension registry out of preloaded plugins and core extensions - const extensionRegistry = createPluginExtensionRegistry([ - { pluginId: 'grafana', extensionConfigs: getCoreExtensionConfigurations() }, - ...preloadResults, - ]); - - // Expose the getPluginExtension function via grafana-runtime - const pluginExtensionGetter: GetPluginExtensions = (options) => - getPluginExtensions({ ...options, registry: extensionRegistry }); - - setPluginExtensionGetter(pluginExtensionGetter); + setPluginExtensionGetter(createPluginExtensionsGetter(extensionsRegistry)); + setPluginExtensionsHook(createPluginExtensionsHook(extensionsRegistry)); // initialize chrome service const queryParams = locationService.getSearchObject(); diff --git a/public/app/core/components/AppChrome/AppChrome.test.tsx b/public/app/core/components/AppChrome/AppChrome.test.tsx index 870030ab187..7285224be50 100644 --- a/public/app/core/components/AppChrome/AppChrome.test.tsx +++ b/public/app/core/components/AppChrome/AppChrome.test.tsx @@ -16,7 +16,7 @@ import { AppChrome } from './AppChrome'; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), - getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }), + usePluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }), })); const searchData: DataFrame = { diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index 10d570e30f9..7cac56a2518 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -14,6 +14,7 @@ import { locationService, setBackendSrv, setDataSourceSrv, + usePluginLinkExtensions, } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; import * as ruleActionButtons from 'app/features/alerting/unified/components/rules/RuleActionsButtons'; @@ -57,6 +58,7 @@ import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getPluginLinkExtensions: jest.fn(), + usePluginLinkExtensions: jest.fn(), useReturnToPrevious: jest.fn(), })); jest.mock('./api/buildInfo'); @@ -81,6 +83,7 @@ jest.spyOn(actions, 'rulesInSameGroupHaveInvalidFor').mockReturnValue([]); const mocks = { getAllDataSourcesMock: jest.mocked(config.getAllDataSources), getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions), + usePluginLinkExtensionsMock: jest.mocked(usePluginLinkExtensions), rulesInSameGroupHaveInvalidForMock: jest.mocked(actions.rulesInSameGroupHaveInvalidFor), api: { @@ -201,7 +204,7 @@ describe('RuleList', () => { AccessControlAction.AlertingRuleExternalWrite, ]); mocks.rulesInSameGroupHaveInvalidForMock.mockReturnValue([]); - mocks.getPluginLinkExtensionsMock.mockReturnValue({ + mocks.usePluginLinkExtensionsMock.mockReturnValue({ extensions: [ { pluginId: 'grafana-ml-app', @@ -213,6 +216,7 @@ describe('RuleList', () => { onClick: jest.fn(), }, ], + isLoading: false, }); }); diff --git a/public/app/features/alerting/unified/components/extensions/AlertInstanceExtensionPoint.tsx b/public/app/features/alerting/unified/components/extensions/AlertInstanceExtensionPoint.tsx index 760c25fbca4..a75f70af622 100644 --- a/public/app/features/alerting/unified/components/extensions/AlertInstanceExtensionPoint.tsx +++ b/public/app/features/alerting/unified/components/extensions/AlertInstanceExtensionPoint.tsx @@ -1,7 +1,7 @@ import React, { ReactElement, useMemo, useState } from 'react'; import { PluginExtensionLink, PluginExtensionPoints } from '@grafana/data'; -import { getPluginLinkExtensions } from '@grafana/runtime'; +import { usePluginLinkExtensions } from '@grafana/runtime'; import { Dropdown, IconButton } from '@grafana/ui'; import { ConfirmNavigationModal } from 'app/features/explore/extensions/ConfirmNavigationModal'; import { Alert, CombinedRule } from 'app/types/unified-alerting'; @@ -20,8 +20,8 @@ export const AlertInstanceExtensionPoint = ({ extensionPointId, }: AlertInstanceExtensionPointProps): ReactElement | null => { const [selectedExtension, setSelectedExtension] = useState(); - const context = { instance, rule }; - const extensions = useExtensionLinks(context, extensionPointId); + const context = useMemo(() => ({ instance, rule }), [instance, rule]); + const { extensions } = usePluginLinkExtensions({ context, extensionPointId, limitPerPlugin: 3 }); if (extensions.length === 0) { return null; @@ -48,18 +48,3 @@ export type PluginExtensionAlertInstanceContext = { rule?: CombinedRule; instance: Alert; }; - -function useExtensionLinks( - context: PluginExtensionAlertInstanceContext, - extensionPointId: PluginExtensionPoints -): PluginExtensionLink[] { - return useMemo(() => { - const { extensions } = getPluginLinkExtensions({ - extensionPointId, - context, - limitPerPlugin: 3, - }); - - return extensions; - }, [context, extensionPointId]); -} diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx index e5220ac4295..9f34c8c11c5 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx @@ -7,7 +7,7 @@ import { MemoryRouter } from 'react-router-dom'; import { byRole } from 'testing-library-selector'; import { PluginExtensionTypes } from '@grafana/data'; -import { getPluginLinkExtensions, setBackendSrv } from '@grafana/runtime'; +import { usePluginLinkExtensions, setBackendSrv } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; import { contextSrv } from 'app/core/services/context_srv'; import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; @@ -25,14 +25,14 @@ import { RuleDetails } from './RuleDetails'; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), - getPluginLinkExtensions: jest.fn(), + usePluginLinkExtensions: jest.fn(), useReturnToPrevious: jest.fn(), })); jest.mock('../../hooks/useIsRuleEditable'); const mocks = { - getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions), + usePluginLinkExtensionsMock: jest.mocked(usePluginLinkExtensions), useIsRuleEditable: jest.mocked(useIsRuleEditable), }; @@ -68,7 +68,7 @@ afterAll(() => { }); beforeEach(() => { - mocks.getPluginLinkExtensionsMock.mockReturnValue({ + mocks.usePluginLinkExtensionsMock.mockReturnValue({ extensions: [ { pluginId: 'grafana-ml-app', @@ -80,6 +80,7 @@ beforeEach(() => { onClick: jest.fn(), }, ], + isLoading: false, }); server.resetHandlers(); mockAlertmanagerChoiceResponse(server, alertmanagerChoiceMockedResponse); diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.test.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.test.tsx index d9bfcb6b6e2..0770a0e184a 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.test.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { byLabelText, byRole, byTestId } from 'testing-library-selector'; import { PluginExtensionTypes } from '@grafana/data'; -import { getPluginLinkExtensions } from '@grafana/runtime'; +import { usePluginLinkExtensions } from '@grafana/runtime'; import { CombinedRuleNamespace } from '../../../../../types/unified-alerting'; import { GrafanaAlertState, PromAlertingRuleState } from '../../../../../types/unified-alerting-dto'; @@ -17,10 +17,11 @@ import { RuleDetailsMatchingInstances } from './RuleDetailsMatchingInstances'; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getPluginLinkExtensions: jest.fn(), + usePluginLinkExtensions: jest.fn(), })); const mocks = { - getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions), + usePluginLinkExtensionsMock: jest.mocked(usePluginLinkExtensions), }; const ui = { @@ -43,7 +44,7 @@ const ui = { describe('RuleDetailsMatchingInstances', () => { beforeEach(() => { - mocks.getPluginLinkExtensionsMock.mockReturnValue({ + mocks.usePluginLinkExtensionsMock.mockReturnValue({ extensions: [ { pluginId: 'grafana-ml-app', @@ -55,6 +56,7 @@ describe('RuleDetailsMatchingInstances', () => { onClick: jest.fn(), }, ], + isLoading: false, }); }); diff --git a/public/app/features/alerting/unified/home/PluginIntegrations.tsx b/public/app/features/alerting/unified/home/PluginIntegrations.tsx index a65b784211b..1e9baf298b6 100644 --- a/public/app/features/alerting/unified/home/PluginIntegrations.tsx +++ b/public/app/features/alerting/unified/home/PluginIntegrations.tsx @@ -3,14 +3,14 @@ import React from 'react'; import { PluginExtensionPoints } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data/'; -import { getPluginComponentExtensions } from '@grafana/runtime'; +import { usePluginComponentExtensions } from '@grafana/runtime'; import { Stack, Text } from '@grafana/ui'; import { useStyles2 } from '@grafana/ui/'; export function PluginIntegrations() { const styles = useStyles2(getStyles); - const { extensions } = getPluginComponentExtensions({ + const { extensions } = usePluginComponentExtensions({ extensionPointId: PluginExtensionPoints.AlertingHomePage, limitPerPlugin: 1, }); diff --git a/public/app/features/commandPalette/actions/extensionActions.ts b/public/app/features/commandPalette/actions/extensionActions.ts deleted file mode 100644 index c3df191e021..00000000000 --- a/public/app/features/commandPalette/actions/extensionActions.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { PluginExtensionCommandPaletteContext, PluginExtensionPoints } from '@grafana/data'; -import { getPluginLinkExtensions } from '@grafana/runtime'; - -import { CommandPaletteAction } from '../types'; -import { EXTENSIONS_PRIORITY } from '../values'; - -export default function getExtensionActions(): CommandPaletteAction[] { - const context: PluginExtensionCommandPaletteContext = {}; - const { extensions } = getPluginLinkExtensions({ - extensionPointId: PluginExtensionPoints.CommandPalette, - context, - limitPerPlugin: 3, - }); - return extensions.map((extension) => ({ - section: extension.category ?? 'Extensions', - priority: EXTENSIONS_PRIORITY, - id: extension.id, - name: extension.title, - target: extension.path, - perform: () => extension.onClick && extension.onClick(), - })); -} diff --git a/public/app/features/commandPalette/actions/staticActions.ts b/public/app/features/commandPalette/actions/staticActions.ts index b9bc77118b8..ed82e1cf994 100644 --- a/public/app/features/commandPalette/actions/staticActions.ts +++ b/public/app/features/commandPalette/actions/staticActions.ts @@ -6,8 +6,6 @@ import { changeTheme } from 'app/core/services/theme'; import { CommandPaletteAction } from '../types'; import { ACTIONS_PRIORITY, DEFAULT_PRIORITY, PREFERENCES_PRIORITY } from '../values'; -import getExtensionActions from './extensionActions'; - // TODO: Clean this once ID is mandatory on nav items function idForNavItem(navItem: NavModelItem) { return 'navModel.' + navItem.id ?? navItem.url ?? navItem.text ?? navItem.subTitle; @@ -72,7 +70,7 @@ function navTreeToActions(navTree: NavModelItem[], parents: NavModelItem[] = []) return navActions; } -export default (navBarTree: NavModelItem[]): CommandPaletteAction[] => { +export default (navBarTree: NavModelItem[], extensionActions: CommandPaletteAction[]): CommandPaletteAction[] => { const globalActions: CommandPaletteAction[] = [ { id: 'preferences/theme', @@ -99,7 +97,6 @@ export default (navBarTree: NavModelItem[]): CommandPaletteAction[] => { }, ]; - const extensionActions = getExtensionActions(); const navBarActions = navTreeToActions(navBarTree); return [...globalActions, ...extensionActions, ...navBarActions]; diff --git a/public/app/features/commandPalette/actions/useActions.ts b/public/app/features/commandPalette/actions/useActions.ts index 19e83721171..44200cba273 100644 --- a/public/app/features/commandPalette/actions/useActions.ts +++ b/public/app/features/commandPalette/actions/useActions.ts @@ -6,18 +6,20 @@ import { CommandPaletteAction } from '../types'; import { getRecentDashboardActions } from './dashboardActions'; import getStaticActions from './staticActions'; +import useExtensionActions from './useExtensionActions'; export default function useActions(searchQuery: string) { const [navTreeActions, setNavTreeActions] = useState([]); const [recentDashboardActions, setRecentDashboardActions] = useState([]); + const extensionActions = useExtensionActions(); const navBarTree = useSelector((state) => state.navBarTree); // Load standard static actions useEffect(() => { - const staticActionsResp = getStaticActions(navBarTree); + const staticActionsResp = getStaticActions(navBarTree, extensionActions); setNavTreeActions(staticActionsResp); - }, [navBarTree]); + }, [navBarTree, extensionActions]); // Load recent dashboards - we don't want them to reload when the nav tree changes useEffect(() => { diff --git a/public/app/features/commandPalette/actions/useExtensionActions.ts b/public/app/features/commandPalette/actions/useExtensionActions.ts new file mode 100644 index 00000000000..3861b685793 --- /dev/null +++ b/public/app/features/commandPalette/actions/useExtensionActions.ts @@ -0,0 +1,29 @@ +import { useMemo } from 'react'; + +import { PluginExtensionCommandPaletteContext, PluginExtensionPoints } from '@grafana/data'; +import { usePluginLinkExtensions } from '@grafana/runtime'; + +import { CommandPaletteAction } from '../types'; +import { EXTENSIONS_PRIORITY } from '../values'; + +// NOTE: we are defining this here, as if we would define it in the hook, it would be recreated on every render, which would cause unnecessary re-renders. +const context: PluginExtensionCommandPaletteContext = {}; + +export default function useExtensionActions(): CommandPaletteAction[] { + const { extensions } = usePluginLinkExtensions({ + extensionPointId: PluginExtensionPoints.CommandPalette, + context, + limitPerPlugin: 3, + }); + + return useMemo(() => { + return extensions.map((extension) => ({ + section: extension.category ?? 'Extensions', + priority: EXTENSIONS_PRIORITY, + id: extension.id, + name: extension.title, + target: extension.path, + perform: () => extension.onClick && extension.onClick(), + })); + }, [extensions]); +} diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx index 759cadc8d04..26d726f3d22 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx @@ -177,6 +177,8 @@ export function panelMenuBehavior(menu: VizPanelMenu, isRepeat = false) { items.push(getInspectMenuItem(plugin, panel, dashboard)); + // TODO: make sure that this works reliably with the reactive extension registry + // (we need to be able to know in advance what extensions should be loaded for this extension point, and make it possible to await for them.) const { extensions } = getPluginLinkExtensions({ extensionPointId: PluginExtensionPoints.DashboardPanelMenu, context: createExtensionContext(panel, dashboard), diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index 004f1d951aa..5df1f991e5c 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -68,6 +68,7 @@ jest.mock('app/core/core', () => ({ jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }), + usePluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }), })); function getTestDashboard(overrides?: Partial, metaOverrides?: Partial): DashboardModel { diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuProvider.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuProvider.tsx index 1875dfdb1c4..a3bcda64ee4 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuProvider.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuProvider.tsx @@ -1,6 +1,13 @@ -import { ReactElement, useEffect, useState } from 'react'; +import { ReactElement, useEffect, useMemo, useState } from 'react'; -import { LoadingState, PanelMenuItem } from '@grafana/data'; +import { + LoadingState, + PanelMenuItem, + PluginExtensionPanelContext, + PluginExtensionPoints, + getTimeZone, +} from '@grafana/data'; +import { usePluginLinkExtensions } from '@grafana/runtime'; import { getPanelStateForModel } from 'app/features/panel/state/selectors'; import { useSelector } from 'app/types'; @@ -21,10 +28,36 @@ interface Props { export function PanelHeaderMenuProvider({ panel, dashboard, loadingState, children }: Props) { const [items, setItems] = useState([]); const angularComponent = useSelector((state) => getPanelStateForModel(state, panel)?.angularComponent); + const context = useMemo(() => createExtensionContext(panel, dashboard), [panel, dashboard]); + const { extensions } = usePluginLinkExtensions({ + extensionPointId: PluginExtensionPoints.DashboardPanelMenu, + context, + limitPerPlugin: 3, + }); useEffect(() => { - setItems(getPanelMenu(dashboard, panel, angularComponent)); - }, [dashboard, panel, angularComponent, loadingState, setItems]); + setItems(getPanelMenu(dashboard, panel, extensions, angularComponent)); + }, [dashboard, panel, angularComponent, loadingState, setItems, extensions]); return children({ items }); } + +function createExtensionContext(panel: PanelModel, dashboard: DashboardModel): PluginExtensionPanelContext { + return { + id: panel.id, + pluginId: panel.type, + title: panel.title, + timeRange: dashboard.time, + timeZone: getTimeZone({ + timeZone: dashboard.timezone, + }), + dashboard: { + uid: dashboard.uid, + title: dashboard.title, + tags: Array.from(dashboard.tags), + }, + targets: panel.targets, + scopedVars: panel.scopedVars, + data: panel.getQueryRunner().getLastResult(), + }; +} diff --git a/public/app/features/dashboard/utils/getPanelMenu.test.ts b/public/app/features/dashboard/utils/getPanelMenu.test.ts index 6f25c38b942..162f716c49b 100644 --- a/public/app/features/dashboard/utils/getPanelMenu.test.ts +++ b/public/app/features/dashboard/utils/getPanelMenu.test.ts @@ -1,16 +1,7 @@ import { Store } from 'redux'; -import { - dateTime, - FieldType, - LoadingState, - PanelData, - PanelMenuItem, - PluginExtensionPanelContext, - PluginExtensionTypes, - toDataFrame, -} from '@grafana/data'; -import { AngularComponent, getPluginLinkExtensions } from '@grafana/runtime'; +import { PanelMenuItem, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data'; +import { AngularComponent, usePluginLinkExtensions } from '@grafana/runtime'; import config from 'app/core/config'; import { grantUserPermissions } from 'app/features/alerting/unified/mocks'; import * as actions from 'app/features/explore/state/main'; @@ -31,16 +22,16 @@ jest.mock('app/core/services/context_srv', () => ({ jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), - setPluginExtensionGetter: jest.fn(), - getPluginLinkExtensions: jest.fn(), + setPluginExtensionsHook: jest.fn(), + usePluginLinkExtensions: jest.fn(), })); -const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions); +const usePluginLinkExtensionsMock = jest.mocked(usePluginLinkExtensions); describe('getPanelMenu()', () => { beforeEach(() => { - getPluginLinkExtensionsMock.mockRestore(); - getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] }); + usePluginLinkExtensionsMock.mockRestore(); + usePluginLinkExtensionsMock.mockReturnValue({ extensions: [], isLoading: false }); grantUserPermissions([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleUpdate]); config.unifiedAlertingEnabled = false; }); @@ -48,8 +39,9 @@ describe('getPanelMenu()', () => { it('should return the correct panel menu items', () => { const panel = new PanelModel({}); const dashboard = createDashboardModelFixture({}); + const extensions: PluginExtensionLink[] = []; - const menuItems = getPanelMenu(dashboard, panel); + const menuItems = getPanelMenu(dashboard, panel, extensions); expect(menuItems).toMatchInlineSnapshot(` [ { @@ -126,22 +118,20 @@ describe('getPanelMenu()', () => { describe('when extending panel menu from plugins', () => { it('should contain menu item from link extension', () => { - getPluginLinkExtensionsMock.mockReturnValue({ - extensions: [ - { - id: '1', - pluginId: '...', - type: PluginExtensionTypes.link, - title: 'Declare incident', - description: 'Declaring an incident in the app', - path: '/a/grafana-basic-app/declare-incident', - }, - ], - }); + const extensions: PluginExtensionLink[] = [ + { + id: '1', + pluginId: '...', + type: PluginExtensionTypes.link, + title: 'Declare incident', + description: 'Declaring an incident in the app', + path: '/a/grafana-basic-app/declare-incident', + }, + ]; const panel = new PanelModel({}); const dashboard = createDashboardModelFixture({}); - const menuItems = getPanelMenu(dashboard, panel); + const menuItems = getPanelMenu(dashboard, panel, extensions); const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu; expect(extensionsSubMenu).toEqual( @@ -155,22 +145,19 @@ describe('getPanelMenu()', () => { }); it('should truncate menu item title to 25 chars', () => { - getPluginLinkExtensionsMock.mockReturnValue({ - extensions: [ - { - id: '1', - pluginId: '...', - type: PluginExtensionTypes.link, - title: 'Declare incident when pressing this amazing menu item', - description: 'Declaring an incident in the app', - path: '/a/grafana-basic-app/declare-incident', - }, - ], - }); - const panel = new PanelModel({}); const dashboard = createDashboardModelFixture({}); - const menuItems = getPanelMenu(dashboard, panel); + const extensions: PluginExtensionLink[] = [ + { + id: '1', + pluginId: '...', + type: PluginExtensionTypes.link, + title: 'Declare incident when pressing this amazing menu item', + description: 'Declaring an incident in the app', + path: '/a/grafana-basic-app/declare-incident', + }, + ]; + const menuItems = getPanelMenu(dashboard, panel, extensions); const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu; expect(extensionsSubMenu).toEqual( @@ -185,230 +172,42 @@ describe('getPanelMenu()', () => { it('should pass onClick from plugin extension link to menu item', () => { const expectedOnClick = jest.fn(); - - getPluginLinkExtensionsMock.mockReturnValue({ - extensions: [ - { - id: '1', - pluginId: '...', - type: PluginExtensionTypes.link, - title: 'Declare incident when pressing this amazing menu item', - description: 'Declaring an incident in the app', - onClick: expectedOnClick, - }, - ], - }); - const panel = new PanelModel({}); const dashboard = createDashboardModelFixture({}); - const menuItems = getPanelMenu(dashboard, panel); + const extensions: PluginExtensionLink[] = [ + { + id: '1', + pluginId: '...', + type: PluginExtensionTypes.link, + title: 'Declare incident when pressing this amazing menu item', + description: 'Declaring an incident in the app', + onClick: expectedOnClick, + }, + ]; + + const menuItems = getPanelMenu(dashboard, panel, extensions); const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu; const menuItem = extensionsSubMenu?.find((i) => (i.text = 'Declare incident when...')); menuItem?.onClick?.({} as React.MouseEvent); - expect(expectedOnClick).toBeCalledTimes(1); - }); - - it('should pass context with correct values when configuring extension', () => { - const data: PanelData = { - series: [ - toDataFrame({ - fields: [ - { name: 'time', type: FieldType.time }, - { name: 'score', type: FieldType.number }, - ], - }), - ], - timeRange: { - from: dateTime(), - to: dateTime(), - raw: { - from: 'now', - to: 'now-1h', - }, - }, - state: LoadingState.Done, - }; - - const panel = new PanelModel({ - type: 'timeseries', - id: 1, - title: 'My panel', - targets: [ - { - refId: 'A', - datasource: { - type: 'testdata', - }, - }, - ], - scopedVars: { - a: { - text: 'a', - value: 'a', - }, - }, - queryRunner: { - getLastResult: jest.fn(() => data), - }, - }); - - const dashboard = createDashboardModelFixture({ - timezone: 'utc', - time: { - from: 'now-5m', - to: 'now', - }, - tags: ['database', 'panel'], - uid: '123', - title: 'My dashboard', - }); - - getPanelMenu(dashboard, panel); - - const context: PluginExtensionPanelContext = { - pluginId: 'timeseries', - id: 1, - title: 'My panel', - timeZone: 'utc', - timeRange: { - from: 'now-5m', - to: 'now', - }, - targets: [ - { - refId: 'A', - datasource: { - type: 'testdata', - }, - }, - ], - dashboard: { - tags: ['database', 'panel'], - uid: '123', - title: 'My dashboard', - }, - scopedVars: { - a: { - text: 'a', - value: 'a', - }, - }, - data, - }; - - expect(getPluginLinkExtensionsMock).toBeCalledWith(expect.objectContaining({ context })); - }); - - it('should pass context with default time zone values when configuring extension', () => { - const data: PanelData = { - series: [ - toDataFrame({ - fields: [ - { name: 'time', type: FieldType.time }, - { name: 'score', type: FieldType.number }, - ], - }), - ], - timeRange: { - from: dateTime(), - to: dateTime(), - raw: { - from: 'now', - to: 'now-1h', - }, - }, - state: LoadingState.Done, - }; - - const panel = new PanelModel({ - type: 'timeseries', - id: 1, - title: 'My panel', - targets: [ - { - refId: 'A', - datasource: { - type: 'testdata', - }, - }, - ], - scopedVars: { - a: { - text: 'a', - value: 'a', - }, - }, - queryRunner: { - getLastResult: jest.fn(() => data), - }, - }); - - const dashboard = createDashboardModelFixture({ - timezone: '', - time: { - from: 'now-5m', - to: 'now', - }, - tags: ['database', 'panel'], - uid: '123', - title: 'My dashboard', - }); - - getPanelMenu(dashboard, panel); - - const context: PluginExtensionPanelContext = { - pluginId: 'timeseries', - id: 1, - title: 'My panel', - timeZone: 'browser', - timeRange: { - from: 'now-5m', - to: 'now', - }, - targets: [ - { - refId: 'A', - datasource: { - type: 'testdata', - }, - }, - ], - dashboard: { - tags: ['database', 'panel'], - uid: '123', - title: 'My dashboard', - }, - scopedVars: { - a: { - text: 'a', - value: 'a', - }, - }, - data, - }; - - expect(getPluginLinkExtensionsMock).toBeCalledWith(expect.objectContaining({ context })); + expect(expectedOnClick).toHaveBeenCalledTimes(1); }); it('should contain menu item with category', () => { - getPluginLinkExtensionsMock.mockReturnValue({ - extensions: [ - { - id: '1', - pluginId: '...', - type: PluginExtensionTypes.link, - title: 'Declare incident', - description: 'Declaring an incident in the app', - path: '/a/grafana-basic-app/declare-incident', - category: 'Incident', - }, - ], - }); - const panel = new PanelModel({}); const dashboard = createDashboardModelFixture({}); - const menuItems = getPanelMenu(dashboard, panel); + const extensions: PluginExtensionLink[] = [ + { + id: '1', + pluginId: '...', + type: PluginExtensionTypes.link, + title: 'Declare incident', + description: 'Declaring an incident in the app', + path: '/a/grafana-basic-app/declare-incident', + category: 'Incident', + }, + ]; + const menuItems = getPanelMenu(dashboard, panel, extensions); const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu; expect(extensionsSubMenu).toEqual( @@ -427,23 +226,20 @@ describe('getPanelMenu()', () => { }); it('should truncate category to 25 chars', () => { - getPluginLinkExtensionsMock.mockReturnValue({ - extensions: [ - { - id: '1', - pluginId: '...', - type: PluginExtensionTypes.link, - title: 'Declare incident', - description: 'Declaring an incident in the app', - path: '/a/grafana-basic-app/declare-incident', - category: 'Declare incident when pressing this amazing menu item', - }, - ], - }); - const panel = new PanelModel({}); const dashboard = createDashboardModelFixture({}); - const menuItems = getPanelMenu(dashboard, panel); + const extensions: PluginExtensionLink[] = [ + { + id: '1', + pluginId: '...', + type: PluginExtensionTypes.link, + title: 'Declare incident', + description: 'Declaring an incident in the app', + path: '/a/grafana-basic-app/declare-incident', + category: 'Declare incident when pressing this amazing menu item', + }, + ]; + const menuItems = getPanelMenu(dashboard, panel, extensions); const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu; expect(extensionsSubMenu).toEqual( @@ -462,31 +258,28 @@ describe('getPanelMenu()', () => { }); it('should contain menu item with category and append items without category after divider', () => { - getPluginLinkExtensionsMock.mockReturnValue({ - extensions: [ - { - id: '1', - pluginId: '...', - type: PluginExtensionTypes.link, - title: 'Declare incident', - description: 'Declaring an incident in the app', - path: '/a/grafana-basic-app/declare-incident', - category: 'Incident', - }, - { - id: '2', - pluginId: '...', - type: PluginExtensionTypes.link, - title: 'Create forecast', - description: 'Declaring an incident in the app', - path: '/a/grafana-basic-app/declare-incident', - }, - ], - }); - const panel = new PanelModel({}); const dashboard = createDashboardModelFixture({}); - const menuItems = getPanelMenu(dashboard, panel); + const extensions: PluginExtensionLink[] = [ + { + id: '1', + pluginId: '...', + type: PluginExtensionTypes.link, + title: 'Declare incident', + description: 'Declaring an incident in the app', + path: '/a/grafana-basic-app/declare-incident', + category: 'Incident', + }, + { + id: '2', + pluginId: '...', + type: PluginExtensionTypes.link, + title: 'Create forecast', + description: 'Declaring an incident in the app', + path: '/a/grafana-basic-app/declare-incident', + }, + ]; + const menuItems = getPanelMenu(dashboard, panel, extensions); const extensionsSubMenu = menuItems.find((i) => i.text === 'Extensions')?.subMenu; expect(extensionsSubMenu).toEqual( @@ -519,8 +312,9 @@ describe('getPanelMenu()', () => { const angularComponent = { getScope: () => scope } as AngularComponent; const panel = new PanelModel({ isViewing: true }); const dashboard = createDashboardModelFixture({}); + const extensions: PluginExtensionLink[] = []; - const menuItems = getPanelMenu(dashboard, panel, angularComponent); + const menuItems = getPanelMenu(dashboard, panel, extensions, angularComponent); expect(menuItems).toMatchInlineSnapshot(` [ { @@ -590,7 +384,8 @@ describe('getPanelMenu()', () => { beforeAll(() => { const panel = new PanelModel({}); const dashboard = createDashboardModelFixture({}); - const menuItems = getPanelMenu(dashboard, panel); + const extensions: PluginExtensionLink[] = []; + const menuItems = getPanelMenu(dashboard, panel, extensions); explore = menuItems.find((item) => item.text === 'Explore') as PanelMenuItem; navigateSpy = jest.spyOn(actions, 'navigateToExplore'); window.open = windowOpen; @@ -624,14 +419,16 @@ describe('getPanelMenu()', () => { expect(windowOpen).toHaveBeenLastCalledWith(`${testSubUrl}${testUrl}`); }); }); + describe('Alerting menu', () => { it('should render "New alert rule" menu item if user has permissions to read and update alerts ', () => { const panel = new PanelModel({}); - const dashboard = createDashboardModelFixture({}); + const extensions: PluginExtensionLink[] = []; + config.unifiedAlertingEnabled = true; grantUserPermissions([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleUpdate]); - const menuItems = getPanelMenu(dashboard, panel); + const menuItems = getPanelMenu(dashboard, panel, extensions); const moreSubMenu = menuItems.find((i) => i.text === 'More...')?.subMenu; expect(moreSubMenu).toEqual( @@ -646,11 +443,12 @@ describe('getPanelMenu()', () => { it('should not render "New alert rule" menu item, if user does not have permissions to update alerts ', () => { const panel = new PanelModel({}); const dashboard = createDashboardModelFixture({}); + const extensions: PluginExtensionLink[] = []; grantUserPermissions([AccessControlAction.AlertingRuleRead]); config.unifiedAlertingEnabled = true; - const menuItems = getPanelMenu(dashboard, panel); + const menuItems = getPanelMenu(dashboard, panel, extensions); const moreSubMenu = menuItems.find((i) => i.text === 'More...')?.subMenu; @@ -662,14 +460,16 @@ describe('getPanelMenu()', () => { ]) ); }); + it('should not render "New alert rule" menu item, if user does not have permissions to read update alerts ', () => { const panel = new PanelModel({}); - const dashboard = createDashboardModelFixture({}); + const extensions: PluginExtensionLink[] = []; + grantUserPermissions([]); config.unifiedAlertingEnabled = true; - const menuItems = getPanelMenu(dashboard, panel); + const menuItems = getPanelMenu(dashboard, panel, extensions); const moreSubMenu = menuItems.find((i) => i.text === 'More...')?.subMenu; const createAlertOption = moreSubMenu?.find((i) => i.text === 'New alert rule')?.subMenu; diff --git a/public/app/features/dashboard/utils/getPanelMenu.ts b/public/app/features/dashboard/utils/getPanelMenu.ts index 14ca792ff29..6dda0cc8b93 100644 --- a/public/app/features/dashboard/utils/getPanelMenu.ts +++ b/public/app/features/dashboard/utils/getPanelMenu.ts @@ -1,11 +1,5 @@ -import { - PanelMenuItem, - PluginExtensionPoints, - getTimeZone, - urlUtil, - type PluginExtensionPanelContext, -} from '@grafana/data'; -import { AngularComponent, getPluginLinkExtensions, locationService } from '@grafana/runtime'; +import { PanelMenuItem, urlUtil, PluginExtensionLink } from '@grafana/data'; +import { AngularComponent, locationService } from '@grafana/runtime'; import { PanelCtrl } from 'app/angular/panel/panel_ctrl'; import config from 'app/core/config'; import { createErrorNotification } from 'app/core/copy/appNotification'; @@ -42,6 +36,7 @@ import { getTimeSrv } from '../services/TimeSrv'; export function getPanelMenu( dashboard: DashboardModel, panel: PanelModel, + extensions: PluginExtensionLink[], angularComponent?: AngularComponent | null ): PanelMenuItem[] { const onViewPanel = (event: React.MouseEvent) => { @@ -332,12 +327,6 @@ export function getPanelMenu( }); } - const { extensions } = getPluginLinkExtensions({ - extensionPointId: PluginExtensionPoints.DashboardPanelMenu, - context: createExtensionContext(panel, dashboard), - limitPerPlugin: 3, - }); - if (extensions.length > 0 && !panel.isEditing) { menu.push({ text: 'Extensions', @@ -370,23 +359,3 @@ export function getPanelMenu( return menu; } - -function createExtensionContext(panel: PanelModel, dashboard: DashboardModel): PluginExtensionPanelContext { - return { - id: panel.id, - pluginId: panel.type, - title: panel.title, - timeRange: dashboard.time, - timeZone: getTimeZone({ - timeZone: dashboard.timezone, - }), - dashboard: { - uid: dashboard.uid, - title: dashboard.title, - tags: Array.from(dashboard.tags), - }, - targets: panel.targets, - scopedVars: panel.scopedVars, - data: panel.getQueryRunner().getLastResult(), - }; -} diff --git a/public/app/features/datasources/components/EditDataSource.test.tsx b/public/app/features/datasources/components/EditDataSource.test.tsx index 8ba9ff47546..5962d39b6c0 100644 --- a/public/app/features/datasources/components/EditDataSource.test.tsx +++ b/public/app/features/datasources/components/EditDataSource.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { Provider } from 'react-redux'; import { PluginExtensionTypes, PluginState } from '@grafana/data'; -import { setAngularLoader, setPluginExtensionGetter } from '@grafana/runtime'; +import { setAngularLoader, setPluginExtensionsHook } from '@grafana/runtime'; import { configureStore } from 'app/store/configureStore'; import { getMockDataSource, getMockDataSourceMeta, getMockDataSourceSettingsState } from '../__mocks__'; @@ -59,7 +59,7 @@ describe('', () => { }); beforeEach(() => { - setPluginExtensionGetter(jest.fn().mockReturnValue({ extensions: [] })); + setPluginExtensionsHook(jest.fn().mockReturnValue({ extensions: [] })); }); describe('On loading errors', () => { @@ -269,7 +269,7 @@ describe('', () => { it('should be possible to extend the form with a "component" extension in case the plugin ID is whitelisted', () => { const message = "I'm a UI extension component!"; - setPluginExtensionGetter( + setPluginExtensionsHook( jest.fn().mockReturnValue({ extensions: [ { @@ -298,7 +298,7 @@ describe('', () => { it('should NOT be possible to extend the form with a "component" extension in case the plugin ID is NOT whitelisted', () => { const message = "I'm a UI extension component!"; - setPluginExtensionGetter( + setPluginExtensionsHook( jest.fn().mockReturnValue({ extensions: [ { @@ -328,7 +328,7 @@ describe('', () => { const message = "I'm a UI extension component!"; const component = jest.fn().mockReturnValue(
{message}
); - setPluginExtensionGetter( + setPluginExtensionsHook( jest.fn().mockReturnValue({ extensions: [ { diff --git a/public/app/features/datasources/components/EditDataSource.tsx b/public/app/features/datasources/components/EditDataSource.tsx index e4802f21b49..b23508a020c 100644 --- a/public/app/features/datasources/components/EditDataSource.tsx +++ b/public/app/features/datasources/components/EditDataSource.tsx @@ -11,7 +11,7 @@ import { DataSourceJsonData, DataSourceUpdatedSuccessfully, } from '@grafana/data'; -import { getDataSourceSrv, getPluginComponentExtensions } from '@grafana/runtime'; +import { getDataSourceSrv, usePluginComponentExtensions } from '@grafana/runtime'; import appEvents from 'app/core/app_events'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; import { DataSourceSettingsState, useDispatch } from 'app/types'; @@ -136,15 +136,15 @@ export function EditDataSourceView({ onTest(); }; - const extensions = useMemo(() => { - const allowedPluginIds = ['grafana-pdc-app', 'grafana-auth-app']; - const extensionPointId = PluginExtensionPoints.DataSourceConfig; - const { extensions } = getPluginComponentExtensions<{ - context: PluginExtensionDataSourceConfigContext; - }>({ extensionPointId }); + const extensionPointId = PluginExtensionPoints.DataSourceConfig; + const { extensions } = usePluginComponentExtensions<{ + context: PluginExtensionDataSourceConfigContext; + }>({ extensionPointId }); + const allowedExtensions = useMemo(() => { + const allowedPluginIds = ['grafana-pdc-app', 'grafana-auth-app']; return extensions.filter((e) => allowedPluginIds.includes(e.pluginId)); - }, []); + }, [extensions]); if (loadError) { return ( @@ -203,7 +203,7 @@ export function EditDataSourceView({ )} {/* Extension point */} - {extensions.map((extension) => { + {allowedExtensions.map((extension) => { const Component = extension.component; return ( diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index 1b9af90f81a..d004eb99473 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -5,7 +5,7 @@ import { TestProvider } from 'test/helpers/TestProvider'; import { CoreApp, createTheme, DataSourceApi, EventBusSrv, LoadingState, PluginExtensionTypes } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { getPluginLinkExtensions } from '@grafana/runtime'; +import { usePluginLinkExtensions } from '@grafana/runtime'; import { configureStore } from 'app/store/configureStore'; import { ContentOutlineContextProvider } from './ContentOutline/ContentOutlineContext'; @@ -123,7 +123,7 @@ jest.mock('app/core/core', () => ({ jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), - getPluginLinkExtensions: jest.fn(() => ({ extensions: [] })), + usePluginLinkExtensions: jest.fn(() => ({ extensions: [] })), })); // for the AutoSizer component to have a width @@ -137,7 +137,7 @@ jest.mock('react-virtualized-auto-sizer', () => { }); }); -const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions); +const usePluginLinkExtensionsMock = jest.mocked(usePluginLinkExtensions); const setup = (overrideProps?: Partial) => { const store = configureStore({ @@ -179,7 +179,7 @@ describe('Explore', () => { }); it('should render toolbar extension point if extensions is available', async () => { - getPluginLinkExtensionsMock.mockReturnValueOnce({ + usePluginLinkExtensionsMock.mockReturnValueOnce({ extensions: [ { id: '1', @@ -198,6 +198,7 @@ describe('Explore', () => { onClick: () => {}, }, ], + isLoading: false, }); setup({ queryResponse: makeEmptyQueryResponse(LoadingState.Done) }); diff --git a/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx b/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx index 7d4e6c95c93..8dca4a16b8e 100644 --- a/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx +++ b/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx @@ -4,7 +4,7 @@ import React, { ReactNode } from 'react'; import { Provider } from 'react-redux'; import { PluginExtensionPoints, PluginExtensionTypes } from '@grafana/data'; -import { getPluginLinkExtensions } from '@grafana/runtime'; +import { usePluginLinkExtensions } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { contextSrv } from 'app/core/services/context_srv'; import { configureStore } from 'app/store/configureStore'; @@ -16,13 +16,13 @@ import { ToolbarExtensionPoint } from './ToolbarExtensionPoint'; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), - getPluginLinkExtensions: jest.fn(), + usePluginLinkExtensions: jest.fn(), })); jest.mock('app/core/services/context_srv'); const contextSrvMock = jest.mocked(contextSrv); -const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions); +const usePluginLinkExtensionsMock = jest.mocked(usePluginLinkExtensions); type storeOptions = { targets: DataQuery[]; @@ -54,7 +54,7 @@ function renderWithExploreStore( describe('ToolbarExtensionPoint', () => { describe('with extension points', () => { beforeAll(() => { - getPluginLinkExtensionsMock.mockReturnValue({ + usePluginLinkExtensionsMock.mockReturnValue({ extensions: [ { pluginId: 'grafana', @@ -74,6 +74,7 @@ describe('ToolbarExtensionPoint', () => { path: '/a/grafana-ml-ap/forecast', }, ], + isLoading: false, }); }); @@ -99,7 +100,9 @@ describe('ToolbarExtensionPoint', () => { await userEvent.click(screen.getByRole('button', { name: 'Add' })); await userEvent.click(screen.getByRole('menuitem', { name: 'Add to dashboard' })); - const { extensions } = getPluginLinkExtensions({ extensionPointId: PluginExtensionPoints.ExploreToolbarAction }); + const { extensions } = usePluginLinkExtensionsMock({ + extensionPointId: PluginExtensionPoints.ExploreToolbarAction, + }); const [extension] = extensions; expect(jest.mocked(extension.onClick)).toBeCalledTimes(1); @@ -125,7 +128,7 @@ describe('ToolbarExtensionPoint', () => { data, }); - const [options] = getPluginLinkExtensionsMock.mock.calls[0]; + const [options] = usePluginLinkExtensionsMock.mock.calls[0]; const { context } = options; expect(context).toEqual({ @@ -150,7 +153,7 @@ describe('ToolbarExtensionPoint', () => { data, }); - const [options] = getPluginLinkExtensionsMock.mock.calls[0]; + const [options] = usePluginLinkExtensionsMock.mock.calls[0]; const { context } = options; expect(context).toHaveProperty('timeZone', 'browser'); @@ -159,7 +162,7 @@ describe('ToolbarExtensionPoint', () => { it('should correct extension point id when fetching extensions', async () => { renderWithExploreStore(); - const [options] = getPluginLinkExtensionsMock.mock.calls[0]; + const [options] = usePluginLinkExtensionsMock.mock.calls[0]; const { extensionPointId } = options; expect(extensionPointId).toBe(PluginExtensionPoints.ExploreToolbarAction); @@ -168,7 +171,7 @@ describe('ToolbarExtensionPoint', () => { describe('with extension points without categories', () => { beforeAll(() => { - getPluginLinkExtensionsMock.mockReturnValue({ + usePluginLinkExtensionsMock.mockReturnValue({ extensions: [ { pluginId: 'grafana', @@ -187,6 +190,7 @@ describe('ToolbarExtensionPoint', () => { path: '/a/grafana-ml-ap/forecast', }, ], + isLoading: false, }); }); @@ -211,7 +215,7 @@ describe('ToolbarExtensionPoint', () => { describe('without extension points', () => { beforeAll(() => { contextSrvMock.hasPermission.mockReturnValue(true); - getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] }); + usePluginLinkExtensionsMock.mockReturnValue({ extensions: [], isLoading: false }); }); it('should render "add to dashboard" action button if one pane is visible', async () => { @@ -229,7 +233,7 @@ describe('ToolbarExtensionPoint', () => { describe('with insufficient permissions', () => { beforeAll(() => { contextSrvMock.hasPermission.mockReturnValue(false); - getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] }); + usePluginLinkExtensionsMock.mockReturnValue({ extensions: [], isLoading: false }); }); it('should not render "add to dashboard" action button', async () => { diff --git a/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx index 1a46d538547..477f413fa98 100644 --- a/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx +++ b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx @@ -1,7 +1,7 @@ import React, { lazy, ReactElement, Suspense, useMemo, useState } from 'react'; import { type PluginExtensionLink, PluginExtensionPoints, RawTimeRange, getTimeZone } from '@grafana/data'; -import { getPluginLinkExtensions, config } from '@grafana/runtime'; +import { config, usePluginLinkExtensions } from '@grafana/runtime'; import { DataQuery, TimeZone } from '@grafana/schema'; import { Dropdown, ToolbarButton } from '@grafana/ui'; import { contextSrv } from 'app/core/services/context_srv'; @@ -26,7 +26,11 @@ export function ToolbarExtensionPoint(props: Props): ReactElement | null { const [selectedExtension, setSelectedExtension] = useState(); const [isOpen, setIsOpen] = useState(false); const context = useExtensionPointContext(props); - const extensions = useExtensionLinks(context); + const { extensions } = usePluginLinkExtensions({ + extensionPointId: PluginExtensionPoints.ExploreToolbarAction, + context: context, + limitPerPlugin: 3, + }); const selectExploreItem = getExploreItemSelector(exploreId); const noQueriesInPane = useSelector(selectExploreItem)?.queries?.length; @@ -114,15 +118,3 @@ function useExtensionPointContext(props: Props): PluginExtensionExploreContext { numUniqueIds, ]); } - -function useExtensionLinks(context: PluginExtensionExploreContext): PluginExtensionLink[] { - return useMemo(() => { - const { extensions } = getPluginLinkExtensions({ - extensionPointId: PluginExtensionPoints.ExploreToolbarAction, - context: context, - limitPerPlugin: 3, - }); - - return extensions; - }, [context]); -} diff --git a/public/app/features/explore/spec/helper/setup.tsx b/public/app/features/explore/spec/helper/setup.tsx index 6630602fa53..2611e1c30f1 100644 --- a/public/app/features/explore/spec/helper/setup.tsx +++ b/public/app/features/explore/spec/helper/setup.tsx @@ -22,7 +22,7 @@ import { locationService, HistoryWrapper, LocationService, - setPluginExtensionGetter, + setPluginExtensionsHook, setBackendSrv, getBackendSrv, getDataSourceSrv, @@ -86,7 +86,7 @@ export function setupExplore(options?: SetupOptions): { request: jest.fn().mockRejectedValue(undefined), }); - setPluginExtensionGetter(() => ({ extensions: [] })); + setPluginExtensionsHook(() => ({ extensions: [], isLoading: false })); // Clear this up otherwise it persists data source selection // TODO: probably add test for that too diff --git a/public/app/features/plugins/extensions/createPluginExtensionRegistry.test.ts b/public/app/features/plugins/extensions/createPluginExtensionRegistry.test.ts deleted file mode 100644 index 571b0f16243..00000000000 --- a/public/app/features/plugins/extensions/createPluginExtensionRegistry.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data'; - -import { createPluginExtensionRegistry } from './createPluginExtensionRegistry'; - -describe('createRegistry()', () => { - const placement1 = 'grafana/dashboard/panel/menu'; - const placement2 = 'plugins/myorg-basic-app/start'; - const pluginId = 'grafana-basic-app'; - let link1: PluginExtensionLinkConfig, link2: PluginExtensionLinkConfig; - - beforeEach(() => { - link1 = { - type: PluginExtensionTypes.link, - title: 'Link 1', - description: 'Link 1 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: placement1, - configure: jest.fn().mockReturnValue({}), - }; - link2 = { - type: PluginExtensionTypes.link, - title: 'Link 2', - description: 'Link 2 description', - path: `/a/${pluginId}/declare-incident`, - extensionPointId: placement2, - configure: jest.fn().mockImplementation((context) => ({ title: context?.title })), - }; - - global.console.warn = jest.fn(); - }); - - it('should be possible to register extensions', () => { - const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]); - - expect(Object.getOwnPropertyNames(registry)).toEqual([placement1, placement2]); - - // Placement 1 - expect(registry[placement1]).toHaveLength(1); - expect(registry[placement1]).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - pluginId, - config: { - ...link1, - configure: expect.any(Function), - }, - }), - ]) - ); - - // Placement 2 - expect(registry[placement2]).toHaveLength(1); - expect(registry[placement2]).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - pluginId, - config: { - ...link2, - configure: expect.any(Function), - }, - }), - ]) - ); - }); - - it('should not register link extensions with invalid path configured', () => { - const registry = createPluginExtensionRegistry([ - { pluginId, extensionConfigs: [{ ...link1, path: 'invalid-path' }, link2] }, - ]); - - expect(Object.getOwnPropertyNames(registry)).toEqual([placement2]); - - // Placement 2 - expect(registry[placement2]).toHaveLength(1); - expect(registry[placement2]).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - pluginId, - config: { - ...link2, - configure: expect.any(Function), - }, - }), - ]) - ); - }); - - it('should not register extensions for a plugin that had errors', () => { - const registry = createPluginExtensionRegistry([ - { pluginId, extensionConfigs: [link1, link2], error: new Error('Plugin failed to load') }, - ]); - - expect(Object.getOwnPropertyNames(registry)).toEqual([]); - }); - - it('should not register an extension if it has an invalid configure() function', () => { - const registry = createPluginExtensionRegistry([ - // @ts-ignore (We would like to provide an invalid configure function on purpose) - { pluginId, extensionConfigs: [{ ...link1, configure: '...' }, link2] }, - ]); - - expect(Object.getOwnPropertyNames(registry)).toEqual([placement2]); - - // Placement 2 (checking if it still registers the extension with a valid configuration) - expect(registry[placement2]).toHaveLength(1); - expect(registry[placement2]).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - pluginId, - config: { - ...link2, - configure: expect.any(Function), - }, - }), - ]) - ); - }); - - it('should not register an extension if it has invalid properties (empty title / description)', () => { - const registry = createPluginExtensionRegistry([ - { pluginId, extensionConfigs: [{ ...link1, title: '', description: '' }, link2] }, - ]); - - expect(Object.getOwnPropertyNames(registry)).toEqual([placement2]); - - // Placement 2 (checking if it still registers the extension with a valid configuration) - expect(registry[placement2]).toHaveLength(1); - expect(registry[placement2]).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - pluginId, - config: { - ...link2, - configure: expect.any(Function), - }, - }), - ]) - ); - }); -}); diff --git a/public/app/features/plugins/extensions/createPluginExtensionRegistry.ts b/public/app/features/plugins/extensions/createPluginExtensionRegistry.ts deleted file mode 100644 index 37090c8a736..00000000000 --- a/public/app/features/plugins/extensions/createPluginExtensionRegistry.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { PluginPreloadResult } from '../pluginPreloader'; - -import type { PluginExtensionRegistryItem, PluginExtensionRegistry } from './types'; -import { deepFreeze, logWarning } from './utils'; -import { isPluginExtensionConfigValid } from './validators'; - -export function createPluginExtensionRegistry(pluginPreloadResults: PluginPreloadResult[]): PluginExtensionRegistry { - const registry: PluginExtensionRegistry = {}; - - for (const { pluginId, extensionConfigs, error } of pluginPreloadResults) { - if (error) { - logWarning(`"${pluginId}" plugin failed to load, skip registering its extensions.`); - continue; - } - - for (const extensionConfig of extensionConfigs) { - const { extensionPointId } = extensionConfig; - - if (!extensionConfig || !isPluginExtensionConfigValid(pluginId, extensionConfig)) { - continue; - } - - let registryItem: PluginExtensionRegistryItem = { - config: extensionConfig, - - // Additional meta information about the extension - pluginId, - }; - - if (!Array.isArray(registry[extensionPointId])) { - registry[extensionPointId] = [registryItem]; - } else { - registry[extensionPointId].push(registryItem); - } - } - } - - return deepFreeze(registry); -} diff --git a/public/app/features/plugins/extensions/getPluginExtensions.test.tsx b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx index 85c6304154e..c98b1e32772 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx @@ -3,8 +3,8 @@ import React from 'react'; import { PluginExtensionComponentConfig, PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; -import { createPluginExtensionRegistry } from './createPluginExtensionRegistry'; import { getPluginExtensions } from './getPluginExtensions'; +import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry'; import { isReadOnlyProxy } from './utils'; import { assertPluginExtensionLink } from './validators'; @@ -15,6 +15,19 @@ jest.mock('@grafana/runtime', () => { }; }); +function createPluginExtensionRegistry(preloadResults: Array<{ pluginId: string; extensionConfigs: any[] }>) { + const registry = new ReactivePluginExtensionsRegistry(); + + for (const { pluginId, extensionConfigs } of preloadResults) { + registry.register({ + pluginId, + extensionConfigs, + }); + } + + return registry.getRegistry(); +} + describe('getPluginExtensions()', () => { const extensionPoint1 = 'grafana/dashboard/panel/menu'; const extensionPoint2 = 'plugins/myorg-basic-app/start'; @@ -54,8 +67,8 @@ describe('getPluginExtensions()', () => { jest.mocked(reportInteraction).mockReset(); }); - test('should return the extensions for the given placement', () => { - const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]); + test('should return the extensions for the given placement', async () => { + const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint1 }); expect(extensions).toHaveLength(1); @@ -70,9 +83,11 @@ describe('getPluginExtensions()', () => { ); }); - test('should not limit the number of extensions per plugin by default', () => { + 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 registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link1, link1, link2] }]); + const registry = await createPluginExtensionRegistry([ + { pluginId, extensionConfigs: [link1, link1, link1, link2] }, + ]); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint1 }); expect(extensions).toHaveLength(3); @@ -87,8 +102,8 @@ describe('getPluginExtensions()', () => { ); }); - test('should be possible to limit the number of extensions per plugin for a given placement', () => { - const registry = createPluginExtensionRegistry([ + test('should be possible to limit the number of extensions per plugin for a given placement', async () => { + const registry = await createPluginExtensionRegistry([ { pluginId, extensionConfigs: [link1, link1, link1, link2] }, { pluginId: 'my-plugin', @@ -116,16 +131,16 @@ describe('getPluginExtensions()', () => { ); }); - test('should return with an empty list if there are no extensions registered for a placement yet', () => { - const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]); + test('should return with an empty list if there are no extensions registered for a placement yet', async () => { + const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]); const { extensions } = getPluginExtensions({ registry, extensionPointId: 'placement-with-no-extensions' }); expect(extensions).toEqual([]); }); - test('should pass the context to the configure() function', () => { + test('should pass the context to the configure() function', async () => { const context = { title: 'New title from the context!' }; - const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 }); @@ -133,7 +148,7 @@ describe('getPluginExtensions()', () => { expect(link2.configure).toHaveBeenCalledWith(context); }); - test('should be possible to update the basic properties with the configure() function', () => { + test('should be possible to update the basic properties with the configure() function', async () => { link2.configure = jest.fn().mockImplementation(() => ({ title: 'Updated title', description: 'Updated description', @@ -142,7 +157,7 @@ describe('getPluginExtensions()', () => { category: 'Machine Learning', })); - const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); const [extension] = extensions; @@ -156,7 +171,7 @@ describe('getPluginExtensions()', () => { expect(extension.category).toBe('Machine Learning'); }); - test('should append link tracking to path when running configure() function', () => { + test('should append link tracking to path when running configure() function', async () => { link2.configure = jest.fn().mockImplementation(() => ({ title: 'Updated title', description: 'Updated description', @@ -165,7 +180,7 @@ describe('getPluginExtensions()', () => { category: 'Machine Learning', })); - const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); const [extension] = extensions; @@ -177,7 +192,7 @@ describe('getPluginExtensions()', () => { ); }); - test('should ignore restricted properties passed via the configure() function', () => { + test('should ignore restricted properties passed via the configure() function', async () => { link2.configure = jest.fn().mockImplementation(() => ({ // The following props are not allowed to override type: 'unknown-type', @@ -190,7 +205,7 @@ describe('getPluginExtensions()', () => { title: 'test', })); - const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); const [extension] = extensions; @@ -202,9 +217,9 @@ describe('getPluginExtensions()', () => { //@ts-ignore expect(extension.testing).toBeUndefined(); }); - test('should pass a read only context to the configure() function', () => { + test('should pass a read only context to the configure() function', async () => { const context = { title: 'New title from the context!' }; - const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const { extensions } = getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 }); const [extension] = extensions; const readOnlyContext = (link2.configure as jest.Mock).mock.calls[0][0]; @@ -219,12 +234,12 @@ describe('getPluginExtensions()', () => { expect(context.title).toBe('New title from the context!'); }); - test('should catch errors in the configure() function and log them as warnings', () => { + test('should catch errors in the configure() function and log them as warnings', async () => { link2.configure = jest.fn().mockImplementation(() => { throw new Error('Something went wrong!'); }); - const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); expect(() => { getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); @@ -235,7 +250,7 @@ describe('getPluginExtensions()', () => { expect(global.console.warn).toHaveBeenCalledWith('[Plugin Extensions] Something went wrong!'); }); - test('should skip the link extension if the configure() function returns with an invalid path', () => { + test('should skip the link extension if the configure() function returns with an invalid path', async () => { link1.configure = jest.fn().mockImplementation(() => ({ path: '/a/another-plugin/page-a', })); @@ -243,7 +258,7 @@ describe('getPluginExtensions()', () => { path: 'invalid-path', })); - const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]); + const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link1, link2] }]); const { extensions: extensionsAtPlacement1 } = getPluginExtensions({ registry, extensionPointId: extensionPoint1 }); const { extensions: extensionsAtPlacement2 } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); @@ -255,7 +270,7 @@ describe('getPluginExtensions()', () => { expect(global.console.warn).toHaveBeenCalledTimes(2); }); - test('should skip the extension if any of the updated props returned by the configure() function are invalid', () => { + test('should skip the extension if any of the updated props returned by the configure() function are invalid', async () => { const overrides = { title: '', // Invalid empty string for title - should be ignored description: 'A valid description.', // This should be updated @@ -263,7 +278,7 @@ describe('getPluginExtensions()', () => { link2.configure = jest.fn().mockImplementation(() => overrides); - const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); expect(extensions).toHaveLength(0); @@ -271,10 +286,10 @@ describe('getPluginExtensions()', () => { expect(global.console.warn).toHaveBeenCalledTimes(1); }); - test('should skip the extension if the configure() function returns a promise', () => { + test('should skip the extension if the configure() function returns a promise', async () => { link2.configure = jest.fn().mockImplementation(() => Promise.resolve({})); - const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); expect(extensions).toHaveLength(0); @@ -282,24 +297,24 @@ describe('getPluginExtensions()', () => { expect(global.console.warn).toHaveBeenCalledTimes(1); }); - test('should skip (hide) the extension if the configure() function returns undefined', () => { + test('should skip (hide) the extension if the configure() function returns undefined', async () => { link2.configure = jest.fn().mockImplementation(() => undefined); - const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); expect(extensions).toHaveLength(0); expect(global.console.warn).toHaveBeenCalledTimes(0); // As this is intentional, no warning should be logged }); - test('should pass event, context and helper to extension onClick()', () => { + test('should pass event, context and helper to extension onClick()', async () => { link2.path = undefined; link2.onClick = jest.fn().mockImplementation(() => { throw new Error('Something went wrong!'); }); const context = {}; - const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); const [extension] = extensions; @@ -322,7 +337,7 @@ describe('getPluginExtensions()', () => { link2.path = undefined; link2.onClick = jest.fn().mockRejectedValue(new Error('testing')); - const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); const [extension] = extensions; @@ -335,13 +350,13 @@ describe('getPluginExtensions()', () => { expect(global.console.warn).toHaveBeenCalledTimes(1); }); - test('should catch errors in the onClick() function and log them as warnings', () => { + test('should catch errors in the onClick() function and log them as warnings', async () => { link2.path = undefined; link2.onClick = jest.fn().mockImplementation(() => { throw new Error('Something went wrong!'); }); - const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const { extensions } = getPluginExtensions({ registry, extensionPointId: extensionPoint2 }); const [extension] = extensions; @@ -353,13 +368,13 @@ describe('getPluginExtensions()', () => { expect(global.console.warn).toHaveBeenCalledWith('[Plugin Extensions] Something went wrong!'); }); - test('should pass a read only context to the onClick() function', () => { + test('should pass a read only context to the onClick() function', async () => { const context = { title: 'New title from the context!' }; link2.path = undefined; link2.onClick = jest.fn(); - const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); const { extensions } = getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 }); const [extension] = extensions; @@ -375,14 +390,14 @@ describe('getPluginExtensions()', () => { }).toThrow(); }); - test('should not make original context read only', () => { + test('should not make original context read only', async () => { const context = { title: 'New title from the context!', nested: { title: 'title' }, array: ['a'], }; - const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); + const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [link2] }]); getPluginExtensions({ registry, context, extensionPointId: extensionPoint2 }); expect(() => { @@ -392,10 +407,10 @@ describe('getPluginExtensions()', () => { }).not.toThrow(); }); - test('should report interaction when onClick is triggered', () => { + test('should report interaction when onClick is triggered', async () => { const reportInteractionMock = jest.mocked(reportInteraction); - const registry = createPluginExtensionRegistry([ + const registry = await createPluginExtensionRegistry([ { pluginId, extensionConfigs: [ @@ -423,9 +438,9 @@ describe('getPluginExtensions()', () => { }); }); - test('should be possible to register and get component type extensions', () => { + test('should be possible to register and get component type extensions', async () => { const extension = component1; - const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [extension] }]); + const registry = await createPluginExtensionRegistry([{ pluginId, extensionConfigs: [extension] }]); const { extensions } = getPluginExtensions({ registry, extensionPointId: extension.extensionPointId }); expect(extensions).toHaveLength(1); diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index 2db726b5e2a..17f5ec77633 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -8,8 +8,9 @@ import { type PluginExtensionComponent, urlUtil, } from '@grafana/data'; -import { reportInteraction } from '@grafana/runtime'; +import { GetPluginExtensions, reportInteraction } from '@grafana/runtime'; +import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry'; import type { PluginExtensionRegistry } from './types'; import { isPluginExtensionLinkConfig, @@ -40,10 +41,22 @@ type GetExtensions = ({ registry: PluginExtensionRegistry; }) => { extensions: PluginExtension[] }; +let registry: PluginExtensionRegistry = { id: '', extensions: {} }; + +export function createPluginExtensionsGetter(extensionRegistry: ReactivePluginExtensionsRegistry): GetPluginExtensions { + // Create a subscription to keep an copy of the registry state for use in the non-async + // plugin extensions getter. + extensionRegistry.asObservable().subscribe((r) => { + registry = r; + }); + + return (options) => getPluginExtensions({ ...options, registry }); +} + // Returns with a list of plugin extensions for the given extension point export const getPluginExtensions: GetExtensions = ({ context, extensionPointId, limitPerPlugin, registry }) => { const frozenContext = context ? getReadOnlyProxy(context) : {}; - const registryItems = registry[extensionPointId] ?? []; + 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 = {}; diff --git a/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.test.ts b/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.test.ts new file mode 100644 index 00000000000..b958016764a --- /dev/null +++ b/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.test.ts @@ -0,0 +1,682 @@ +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({}), + }, + ], + }); + + 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({}), + }, + ], + }); + + 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({}), + }, + ], + }); + + 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 })), + }, + ], + }); + + 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({}), + }, + ], + }); + + 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({}), + }, + ], + }); + + 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({}), + }, + ], + }); + + 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({}), + }, + ], + }); + + 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({}), + }, + ], + }); + + // 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({}), + }, + ], + }); + + 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({}), + }, + ], + }); + + // 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({}), + }, + ], + }); + + 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({}), + }, + ], + }); + + 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({}), + }, + ], + }); + + 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({}), + }, + ], + }); + + 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({}), + }, + ], + }); + + 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({}), + }, + ], + }); + + 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({}), + }, + ], + }); + + 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 new file mode 100644 index 00000000000..016b97e0a4b --- /dev/null +++ b/public/app/features/plugins/extensions/reactivePluginExtensionRegistry.ts @@ -0,0 +1,79 @@ +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; + + 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/types.ts b/public/app/features/plugins/extensions/types.ts index b5e5e31cc7e..9642cc6627d 100644 --- a/public/app/features/plugins/extensions/types.ts +++ b/public/app/features/plugins/extensions/types.ts @@ -9,4 +9,7 @@ export type PluginExtensionRegistryItem = { }; // A map of placement names to a list of extensions -export type PluginExtensionRegistry = Record; +export type PluginExtensionRegistry = { + id: string; + extensions: Record; +}; diff --git a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx new file mode 100644 index 00000000000..49f29e4b1b1 --- /dev/null +++ b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx @@ -0,0 +1,225 @@ +import { act } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; + +import { PluginExtensionTypes } from '@grafana/data'; + +import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry'; +import { createPluginExtensionsHook } from './usePluginExtensions'; + +describe('usePluginExtensions()', () => { + let reactiveRegistry: ReactivePluginExtensionsRegistry; + + beforeEach(() => { + reactiveRegistry = new ReactivePluginExtensionsRegistry(); + }); + + it('should return an empty array if there are no extensions registered for the extension point', () => { + const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry); + const { result } = renderHook(() => + usePluginExtensions({ + extensionPointId: 'foo/bar', + }) + ); + + expect(result.current.extensions).toEqual([]); + }); + + it('should return the plugin extensions from the registry', () => { + const extensionPointId = 'plugins/foo/bar'; + const pluginId = 'my-app-plugin'; + + reactiveRegistry.register({ + pluginId, + extensionConfigs: [ + { + type: PluginExtensionTypes.link, + extensionPointId, + title: '1', + description: '1', + path: `/a/${pluginId}/2`, + }, + { + type: PluginExtensionTypes.link, + extensionPointId, + title: '2', + description: '2', + path: `/a/${pluginId}/2`, + }, + ], + }); + + const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry); + const { result } = renderHook(() => usePluginExtensions({ extensionPointId })); + + expect(result.current.extensions.length).toBe(2); + expect(result.current.extensions[0].title).toBe('1'); + expect(result.current.extensions[1].title).toBe('2'); + }); + + it('should dynamically update the extensions registered for a certain extension point', () => { + const extensionPointId = 'plugins/foo/bar'; + const pluginId = 'my-app-plugin'; + const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry); + let { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId })); + + // No extensions yet + expect(result.current.extensions.length).toBe(0); + + // Add extensions to the registry + act(() => { + reactiveRegistry.register({ + pluginId, + extensionConfigs: [ + { + type: PluginExtensionTypes.link, + extensionPointId, + title: '1', + description: '1', + path: `/a/${pluginId}/2`, + }, + { + type: PluginExtensionTypes.link, + extensionPointId, + title: '2', + description: '2', + path: `/a/${pluginId}/2`, + }, + ], + }); + }); + + // Check if the hook returns the new extensions + rerender(); + + expect(result.current.extensions.length).toBe(2); + expect(result.current.extensions[0].title).toBe('1'); + expect(result.current.extensions[1].title).toBe('2'); + }); + + it('should only render the hook once', () => { + const spy = jest.spyOn(reactiveRegistry, 'asObservable'); + const extensionPointId = 'plugins/foo/bar'; + const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry); + + renderHook(() => usePluginExtensions({ extensionPointId })); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should return the same extensions object if the context object is the same', () => { + const extensionPointId = 'plugins/foo/bar'; + const pluginId = 'my-app-plugin'; + const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry); + + // Add extensions to the registry + act(() => { + reactiveRegistry.register({ + pluginId, + extensionConfigs: [ + { + type: PluginExtensionTypes.link, + extensionPointId, + title: '1', + description: '1', + path: `/a/${pluginId}/2`, + }, + { + type: PluginExtensionTypes.link, + extensionPointId, + title: '2', + description: '2', + path: `/a/${pluginId}/2`, + }, + ], + }); + }); + + // 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); + }); + + it('should return a new extensions object if the context object is different', () => { + const extensionPointId = 'plugins/foo/bar'; + const pluginId = 'my-app-plugin'; + const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry); + + // Add extensions to the registry + act(() => { + reactiveRegistry.register({ + pluginId, + extensionConfigs: [ + { + type: PluginExtensionTypes.link, + extensionPointId, + title: '1', + description: '1', + path: `/a/${pluginId}/2`, + }, + { + type: PluginExtensionTypes.link, + extensionPointId, + title: '2', + description: '2', + path: `/a/${pluginId}/2`, + }, + ], + }); + }); + + // Check if it returns a different extensions object in case the context object changes + const firstResults = renderHook(() => usePluginExtensions({ extensionPointId, context: {} })); + const secondResults = renderHook(() => usePluginExtensions({ extensionPointId, context: {} })); + expect(firstResults.result.current.extensions === secondResults.result.current.extensions).toBe(false); + }); + + it('should return a new extensions object if the registry changes but the context object is the same', () => { + const extensionPointId = 'plugins/foo/bar'; + const pluginId = 'my-app-plugin'; + const context = {}; + const usePluginExtensions = createPluginExtensionsHook(reactiveRegistry); + + // Add the first extension + act(() => { + reactiveRegistry.register({ + pluginId, + extensionConfigs: [ + { + type: PluginExtensionTypes.link, + extensionPointId, + title: '1', + description: '1', + path: `/a/${pluginId}/2`, + }, + ], + }); + }); + + const { result, rerender } = renderHook(() => usePluginExtensions({ extensionPointId, context })); + const firstExtensions = result.current.extensions; + + // Add the second extension + act(() => { + reactiveRegistry.register({ + pluginId, + extensionConfigs: [ + { + type: PluginExtensionTypes.link, + extensionPointId, + // extensionPointId: 'plugins/foo/bar/zed', // A different extension point (to be sure that it's also returning a new object when the actual extension point doesn't change) + title: '2', + description: '2', + path: `/a/${pluginId}/2`, + }, + ], + }); + }); + + rerender(); + + const secondExtensions = result.current.extensions; + + expect(firstExtensions === secondExtensions).toBe(false); + }); +}); diff --git a/public/app/features/plugins/extensions/usePluginExtensions.tsx b/public/app/features/plugins/extensions/usePluginExtensions.tsx new file mode 100644 index 00000000000..4eb55c700d3 --- /dev/null +++ b/public/app/features/plugins/extensions/usePluginExtensions.tsx @@ -0,0 +1,54 @@ +import { useObservable } from 'react-use'; + +import { PluginExtension } from '@grafana/data'; +import { GetPluginExtensionsOptions, UsePluginExtensionsResult } from '@grafana/runtime'; + +import { getPluginExtensions } from './getPluginExtensions'; +import { ReactivePluginExtensionsRegistry } from './reactivePluginExtensionRegistry'; + +export function createPluginExtensionsHook(extensionsRegistry: ReactivePluginExtensionsRegistry) { + const observableRegistry = extensionsRegistry.asObservable(); + const cache: { + id: string; + extensions: Record; + } = { + id: '', + extensions: {}, + }; + + return function usePluginExtensions(options: GetPluginExtensionsOptions): UsePluginExtensionsResult { + const registry = useObservable(observableRegistry); + + if (!registry) { + 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 }); + + cache.extensions[key] = { + context: options.context, + extensions, + }; + + return { + extensions, + isLoading: false, + }; + }; +} diff --git a/public/app/features/plugins/pluginPreloader.ts b/public/app/features/plugins/pluginPreloader.ts index 6306a443183..25ea2f1f11a 100644 --- a/public/app/features/plugins/pluginPreloader.ts +++ b/public/app/features/plugins/pluginPreloader.ts @@ -3,6 +3,7 @@ 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 * as pluginLoader from './plugin_loader'; export type PluginPreloadResult = { @@ -11,12 +12,16 @@ export type PluginPreloadResult = { extensionConfigs: PluginExtensionConfig[]; }; -export async function preloadPlugins(apps: Record = {}): Promise { +export async function preloadPlugins(apps: AppPluginConfig[] = [], registry: ReactivePluginExtensionsRegistry) { startMeasure('frontend_plugins_preload'); - const pluginsToPreload = Object.values(apps).filter((app) => app.preload); - const result = await Promise.all(pluginsToPreload.map(preload)); + const promises = apps.filter((config) => config.preload).map((config) => preload(config)); + const preloadedPlugins = await Promise.all(promises); + + for (const preloadedPlugin of preloadedPlugins) { + registry.register(preloadedPlugin); + } + stopMeasure('frontend_plugins_preload'); - return result; } async function preload(config: AppPluginConfig): Promise { diff --git a/public/app/features/profile/UserProfileEditPage.test.tsx b/public/app/features/profile/UserProfileEditPage.test.tsx index 1f69b45c495..e81b5bfc50e 100644 --- a/public/app/features/profile/UserProfileEditPage.test.tsx +++ b/public/app/features/profile/UserProfileEditPage.test.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { OrgRole, PluginExtensionComponent, PluginExtensionTypes } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { setPluginExtensionGetter, GetPluginExtensions } from '@grafana/runtime'; +import { setPluginExtensionsHook, UsePluginExtensions } from '@grafana/runtime'; import * as useQueryParams from 'app/core/hooks/useQueryParams'; import { TestProvider } from '../../../test/helpers/TestProvider'; @@ -170,9 +170,11 @@ async function getTestContext(overrides: Partial = jest.fn().mockReturnValue({ extensions }); + const getter: UsePluginExtensions = jest + .fn() + .mockReturnValue({ extensions, isLoading: false }); - setPluginExtensionGetter(getter); + setPluginExtensionsHook(getter); const props = { ...defaultProps, ...overrides }; const { rerender } = render( diff --git a/public/app/features/profile/UserProfileEditPage.tsx b/public/app/features/profile/UserProfileEditPage.tsx index 2e99bca080f..613bf924891 100644 --- a/public/app/features/profile/UserProfileEditPage.tsx +++ b/public/app/features/profile/UserProfileEditPage.tsx @@ -1,10 +1,10 @@ -import React, { useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { useMount } from 'react-use'; import { PluginExtensionComponent, PluginExtensionPoints } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { getPluginComponentExtensions } from '@grafana/runtime'; +import { usePluginComponentExtensions } from '@grafana/runtime'; import { Tab, TabsBar, TabContent, Stack } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import SharedPreferences from 'app/core/components/SharedPreferences/SharedPreferences'; @@ -76,30 +76,21 @@ export function UserProfileEditPage({ useMount(() => initUserProfilePage()); - const extensionComponents = useMemo(() => { - const { extensions } = getPluginComponentExtensions({ - extensionPointId: PluginExtensionPoints.UserProfileTab, - }); + const { extensions } = usePluginComponentExtensions({ extensionPointId: PluginExtensionPoints.UserProfileTab }); - return extensions; - }, []); - - const groupedExtensionComponents = extensionComponents.reduce>( - (acc, extension) => { - const { title } = extension; - if (acc[title]) { - acc[title].push(extension); - } else { - acc[title] = [extension]; - } - return acc; - }, - {} - ); + const groupedExtensionComponents = extensions.reduce>((acc, extension) => { + const { title } = extension; + if (acc[title]) { + acc[title].push(extension); + } else { + acc[title] = [extension]; + } + return acc; + }, {}); const convertExtensionComponentTitleToTabId = (title: string) => title.toLowerCase(); - const showTabs = extensionComponents.length > 0; + const showTabs = extensions.length > 0; const tabs: TabInfo[] = [ { id: GENERAL_SETTINGS_TAB, diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx index 14b89c583b7..1f1b89830ec 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryEditor.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { CoreApp, PluginType } from '@grafana/data'; -import { setPluginExtensionGetter } from '@grafana/runtime'; +import { setPluginExtensionsHook } from '@grafana/runtime'; import { PyroscopeDataSource } from '../datasource'; import { mockFetchPyroscopeDatasourceSettings } from '../datasource.test'; @@ -13,7 +13,7 @@ import { Props, QueryEditor } from './QueryEditor'; describe('QueryEditor', () => { beforeEach(() => { - setPluginExtensionGetter(() => ({ extensions: [] })); // No extensions + setPluginExtensionsHook(() => ({ extensions: [], isLoading: false })); // No extensions mockFetchPyroscopeDatasourceSettings(); }); diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryLinkExtension.test.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryLinkExtension.test.tsx index 893a23f5b53..dd57142644a 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryLinkExtension.test.tsx +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryLinkExtension.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { PluginType, rangeUtil, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data'; -import { getPluginLinkExtensions } from '@grafana/runtime'; +import { usePluginLinkExtensions } from '@grafana/runtime'; import { PyroscopeDataSource } from '../datasource'; import { mockFetchPyroscopeDatasourceSettings } from '../datasource.test'; @@ -15,8 +15,7 @@ const EXTENSION_POINT_ID = 'plugins/grafana-pyroscope-datasource/query-links'; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), - setPluginExtensionGetter: jest.fn(), - getPluginLinkExtensions: jest.fn(), + usePluginLinkExtensions: jest.fn(), getTemplateSrv: () => { return { replace: (query: string): string => { @@ -26,7 +25,7 @@ jest.mock('@grafana/runtime', () => ({ }, })); -const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions); +const usePluginLinkExtensionsMock = jest.mocked(usePluginLinkExtensions); const defaultPyroscopeDataSourceSettings = { uid: 'default-pyroscope', @@ -60,12 +59,12 @@ describe('PyroscopeQueryLinkExtensions', () => { resetPyroscopeQueryLinkExtensionsFetches(); mockFetchPyroscopeDatasourceSettings(defaultPyroscopeDataSourceSettings); - getPluginLinkExtensionsMock.mockRestore(); - getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] }); // Unless stated otherwise, no extensions + usePluginLinkExtensionsMock.mockRestore(); + usePluginLinkExtensionsMock.mockReturnValue({ extensions: [], isLoading: false }); // Unless stated otherwise, no extensions }); it('should render if extension present', async () => { - getPluginLinkExtensionsMock.mockReturnValue({ extensions: [createExtension()] }); // Default extension + usePluginLinkExtensionsMock.mockReturnValue({ extensions: [createExtension()], isLoading: false }); // Default extension await act(setup); expect(await screen.findAllByText(EXPECTED_BUTTON_LABEL)).toBeDefined(); diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryLinkExtension.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryLinkExtension.tsx index 21a8a727ad5..8a6761966df 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryLinkExtension.tsx +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/QueryEditor/QueryLinkExtension.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { useAsync } from 'react-use'; import { GrafanaTheme2, QueryEditorProps, TimeRange } from '@grafana/data'; -import { getBackendSrv, getPluginLinkExtensions } from '@grafana/runtime'; +import { getBackendSrv, usePluginLinkExtensions } from '@grafana/runtime'; import { LinkButton, useStyles2 } from '@grafana/ui'; import { PyroscopeDataSource } from '../datasource'; @@ -64,7 +64,7 @@ export function PyroscopeQueryLinkExtensions(props: Props) { datasourceSettings, }; - const { extensions } = getPluginLinkExtensions({ + const { extensions } = usePluginLinkExtensions({ extensionPointId: EXTENSION_POINT_ID, context, }); diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts index b2bc6791e09..6c4720c0600 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/datasource.test.ts @@ -6,7 +6,7 @@ import { PluginType, DataSourceJsonData, } from '@grafana/data'; -import { setPluginExtensionGetter, getBackendSrv, setBackendSrv, getTemplateSrv } from '@grafana/runtime'; +import { setPluginExtensionsHook, getBackendSrv, setBackendSrv, getTemplateSrv } from '@grafana/runtime'; import { defaultPyroscopeQueryType } from './dataquery.gen'; import { normalizeQuery, PyroscopeDataSource } from './datasource'; @@ -50,7 +50,7 @@ describe('Pyroscope data source', () => { let ds: PyroscopeDataSource; beforeEach(() => { mockFetchPyroscopeDatasourceSettings(); - setPluginExtensionGetter(() => ({ extensions: [] })); // No extensions + setPluginExtensionsHook(() => ({ extensions: [], isLoading: false })); // No extensions ds = new PyroscopeDataSource(defaultSettings); }); diff --git a/public/app/plugins/panel/alertlist/UnifiedalertList.test.tsx b/public/app/plugins/panel/alertlist/UnifiedalertList.test.tsx index d4847dd8289..bc8430d453a 100644 --- a/public/app/plugins/panel/alertlist/UnifiedalertList.test.tsx +++ b/public/app/plugins/panel/alertlist/UnifiedalertList.test.tsx @@ -5,7 +5,7 @@ import { Provider } from 'react-redux'; import { byRole, byText } from 'testing-library-selector'; import { FieldConfigSource, getDefaultTimeRange, LoadingState, PanelProps, PluginExtensionTypes } from '@grafana/data'; -import { getPluginLinkExtensions, TimeRangeUpdatedEvent } from '@grafana/runtime'; +import { TimeRangeUpdatedEvent, usePluginLinkExtensions } from '@grafana/runtime'; import { setupMswServer } from 'app/features/alerting/unified/mockApi'; import { mockPromRulesApiResponse } from 'app/features/alerting/unified/mocks/alertRuleApi'; import { mockRulerRulesApiResponse } from 'app/features/alerting/unified/mocks/rulerApi'; @@ -57,12 +57,12 @@ const grafanaRuleMock = { jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), - getPluginLinkExtensions: jest.fn(), + usePluginLinkExtensions: jest.fn(), })); jest.mock('app/features/alerting/unified/api/alertmanager'); const mocks = { - getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions), + usePluginLinkExtensionsMock: jest.mocked(usePluginLinkExtensions), }; const fakeResponse: PromRulesResponse = { @@ -85,7 +85,7 @@ beforeEach(() => { mockRulerRulesApiResponse(server, 'grafana', { 'folder-one': [{ name: 'group1', interval: '20s', rules: [originRule] }], }); - mocks.getPluginLinkExtensionsMock.mockReturnValue({ + mocks.usePluginLinkExtensionsMock.mockReturnValue({ extensions: [ { pluginId: 'grafana-ml-app', @@ -97,6 +97,7 @@ beforeEach(() => { onClick: jest.fn(), }, ], + isLoading: false, }); });