From 12c245720c9eba415db1601f60b7fb9b0c9e1ee2 Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Mon, 20 Nov 2023 13:43:11 +0100 Subject: [PATCH] Plugins: Share plugin context with the component-type extensions (#78111) feat: wrap component type extension with plugin context --- ...s.test.ts => getPluginExtensions.test.tsx} | 33 ++++++++++++- .../plugins/extensions/getPluginExtensions.ts | 3 +- .../plugins/extensions/utils.test.tsx | 31 +++++++++++- .../app/features/plugins/extensions/utils.tsx | 48 +++++++++++++++---- 4 files changed, 101 insertions(+), 14 deletions(-) rename public/app/features/plugins/extensions/{getPluginExtensions.test.ts => getPluginExtensions.test.tsx} (93%) diff --git a/public/app/features/plugins/extensions/getPluginExtensions.test.ts b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx similarity index 93% rename from public/app/features/plugins/extensions/getPluginExtensions.test.ts rename to public/app/features/plugins/extensions/getPluginExtensions.test.tsx index 52408ecf6de..85c6304154e 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.test.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx @@ -1,4 +1,6 @@ -import { PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data'; +import React from 'react'; + +import { PluginExtensionComponentConfig, PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; import { createPluginExtensionRegistry } from './createPluginExtensionRegistry'; @@ -16,8 +18,10 @@ jest.mock('@grafana/runtime', () => { describe('getPluginExtensions()', () => { const extensionPoint1 = 'grafana/dashboard/panel/menu'; const extensionPoint2 = 'plugins/myorg-basic-app/start'; + const extensionPoint3 = 'grafana/datasources/config'; const pluginId = 'grafana-basic-app'; - let link1: PluginExtensionLinkConfig, link2: PluginExtensionLinkConfig; + // Sample extension configs that are used in the tests below + let link1: PluginExtensionLinkConfig, link2: PluginExtensionLinkConfig, component1: PluginExtensionComponentConfig; beforeEach(() => { link1 = { @@ -36,6 +40,15 @@ describe('getPluginExtensions()', () => { extensionPointId: extensionPoint2, configure: jest.fn().mockImplementation((context) => ({ title: context?.title })), }; + component1 = { + type: PluginExtensionTypes.component, + title: 'Component 1', + description: 'Component 1 description', + extensionPointId: extensionPoint3, + component: (context) => { + return
Hello world!
; + }, + }; global.console.warn = jest.fn(); jest.mocked(reportInteraction).mockReset(); @@ -409,4 +422,20 @@ describe('getPluginExtensions()', () => { category: extension.category, }); }); + + test('should be possible to register and get component type extensions', () => { + const extension = component1; + const registry = createPluginExtensionRegistry([{ pluginId, extensionConfigs: [extension] }]); + const { extensions } = getPluginExtensions({ registry, extensionPointId: extension.extensionPointId }); + + expect(extensions).toHaveLength(1); + expect(extensions[0]).toEqual( + expect.objectContaining({ + pluginId, + type: PluginExtensionTypes.component, + title: extension.title, + description: extension.description, + }) + ); + }); }); diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index 85b35929969..2db726b5e2a 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -18,6 +18,7 @@ import { generateExtensionId, getEventHelpers, isPluginExtensionComponentConfig, + wrapWithPluginContext, } from './utils'; import { assertIsReactComponent, @@ -101,7 +102,7 @@ export const getPluginExtensions: GetExtensions = ({ context, extensionPointId, title: extensionConfig.title, description: extensionConfig.description, - component: extensionConfig.component, + component: wrapWithPluginContext(pluginId, extensionConfig.component), }; extensions.push(extension); diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx index c7cc1169d8e..ee4b742f5f4 100644 --- a/public/app/features/plugins/extensions/utils.test.tsx +++ b/public/app/features/plugins/extensions/utils.test.tsx @@ -6,7 +6,14 @@ import { type PluginExtensionLinkConfig, PluginExtensionTypes, dateTime, usePlug import appEvents from 'app/core/app_events'; import { ShowModalReactEvent } from 'app/types/events'; -import { deepFreeze, isPluginExtensionLinkConfig, handleErrorsInFn, getReadOnlyProxy, getEventHelpers } from './utils'; +import { + deepFreeze, + isPluginExtensionLinkConfig, + handleErrorsInFn, + getReadOnlyProxy, + getEventHelpers, + wrapWithPluginContext, +} from './utils'; jest.mock('app/features/plugins/pluginSettings', () => ({ ...jest.requireActual('app/features/plugins/pluginSettings'), @@ -436,4 +443,26 @@ describe('Plugin Extensions / Utils', () => { }); }); }); + + describe('wrapExtensionComponentWithContext()', () => { + const ExampleComponent = () => { + const { meta } = usePluginContext(); + + return ( +
+

Hello Grafana!

Version: {meta.info.version} +
+ ); + }; + + it('should make the plugin context available for the wrapped component', async () => { + const pluginId = 'grafana-worldmap-panel'; + const Component = wrapWithPluginContext(pluginId, ExampleComponent); + + render(); + + expect(await screen.findByText('Hello Grafana!')).toBeVisible(); + expect(screen.getByText('Version: 1.0.0')).toBeVisible(); + }); + }); }); diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index be8fbb6762b..82ff3c34df9 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -1,6 +1,7 @@ import { css } from '@emotion/css'; import { isArray, isObject } from 'lodash'; import React from 'react'; +import { useAsync } from 'react-use'; import { type PluginExtensionLinkConfig, @@ -12,7 +13,6 @@ import { isDateTime, dateTime, PluginContextProvider, - PluginMeta, } from '@grafana/data'; import { Modal } from '@grafana/ui'; import appEvents from 'app/core/app_events'; @@ -51,11 +51,10 @@ export function handleErrorsInFn(fn: Function, errorMessagePrefix = '') { export function getEventHelpers(pluginId: string, context?: Readonly): PluginExtensionEventHelpers { const openModal: PluginExtensionEventHelpers['openModal'] = async (options) => { const { title, body, width, height } = options; - const pluginMeta = await getPluginSettings(pluginId); appEvents.publish( new ShowModalReactEvent({ - component: getModalWrapper({ title, body, width, height, pluginMeta }), + component: wrapWithPluginContext(pluginId, getModalWrapper({ title, body, width, height })), }) ); }; @@ -67,6 +66,38 @@ type ModalWrapperProps = { onDismiss: () => void; }; +export const wrapWithPluginContext = (pluginId: string, Component: React.ComponentType) => { + const WrappedExtensionComponent = (props: T & React.JSX.IntrinsicAttributes) => { + const { + error, + loading, + value: pluginMeta, + } = useAsync(() => getPluginSettings(pluginId, { showErrorAlert: false })); + + if (loading) { + return null; + } + + if (error) { + logWarning(`Could not fetch plugin meta information for "${pluginId}", aborting. (${error.message})`); + return null; + } + + if (!pluginMeta) { + logWarning(`Fetched plugin meta information is empty for "${pluginId}", aborting.`); + return null; + } + + return ( + + + + ); + }; + + return WrappedExtensionComponent; +}; + // Wraps a component with a modal. // This way we can make sure that the modal is closable, and we also make the usage simpler. const getModalWrapper = ({ @@ -76,17 +107,14 @@ const getModalWrapper = ({ body: Body, width, height, - pluginMeta, -}: { pluginMeta: PluginMeta } & PluginExtensionOpenModalOptions) => { +}: PluginExtensionOpenModalOptions) => { const className = css({ width, height }); const ModalWrapper = ({ onDismiss }: ModalWrapperProps) => { return ( - - - - - + + + ); };