From d44dc0f10079c7973c2799586b774acff2a4c3ef Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Wed, 8 Mar 2023 15:44:48 +0100 Subject: [PATCH] Plugins: Allow command extensions to open modals (#64029) feat: make it possible to open modals from commands --- packages/grafana-data/src/types/app.ts | 13 ++- .../plugins/extensions/getModalWrapper.tsx | 27 ++++++ .../extensions/registryFactory.test.ts | 84 ++++++++++++------- .../plugins/extensions/registryFactory.ts | 18 +++- 4 files changed, 111 insertions(+), 31 deletions(-) create mode 100644 public/app/features/plugins/extensions/getModalWrapper.tsx diff --git a/packages/grafana-data/src/types/app.ts b/packages/grafana-data/src/types/app.ts index 49e6ef78883..a37a9c85fb3 100644 --- a/packages/grafana-data/src/types/app.ts +++ b/packages/grafana-data/src/types/app.ts @@ -56,6 +56,17 @@ export interface AppPluginMeta extends PluginMeta */ export type AppPluginExtensionLink = Pick; +// A list of helpers that can be used in the command handler +export type AppPluginExtensionCommandHelpers = { + // Opens a modal dialog and renders the provided React component inside it + openModal: (options: { + // The title of the modal + title: string; + // A React element that will be rendered inside the modal + body: React.ElementType<{ onDismiss?: () => void }>; + }) => void; +}; + export type AppPluginExtensionCommand = Pick; export type AppPluginExtensionLinkConfig = { @@ -70,7 +81,7 @@ export type AppPluginExtensionCommandConfig = { title: string; description: string; placement: string; - handler: (context?: C) => void; + handler: (context?: C, helpers?: AppPluginExtensionCommandHelpers) => void; configure?: (extension: AppPluginExtensionCommand, context?: C) => Partial | undefined; }; diff --git a/public/app/features/plugins/extensions/getModalWrapper.tsx b/public/app/features/plugins/extensions/getModalWrapper.tsx new file mode 100644 index 00000000000..8961ecdf49f --- /dev/null +++ b/public/app/features/plugins/extensions/getModalWrapper.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { AppPluginExtensionCommandHelpers } from '@grafana/data'; +import { Modal } from '@grafana/ui'; + +export type ModalWrapperProps = { + onDismiss: () => void; +}; + +// Wraps a component with a modal. +// This way we can make sure that the modal is closable, and we also make the usage simpler. +export const getModalWrapper = ({ + // The title of the modal (appears in the header) + title, + // A component that serves the body of the modal + body: Body, +}: Parameters[0]) => { + const ModalWrapper = ({ onDismiss }: ModalWrapperProps) => { + return ( + + + + ); + }; + + return ModalWrapper; +}; diff --git a/public/app/features/plugins/extensions/registryFactory.test.ts b/public/app/features/plugins/extensions/registryFactory.test.ts index 903a2e09ad4..891594dba9d 100644 --- a/public/app/features/plugins/extensions/registryFactory.test.ts +++ b/public/app/features/plugins/extensions/registryFactory.test.ts @@ -269,18 +269,22 @@ describe('createPluginExtensionRegistry()', () => { describe('when registering commands', () => { const pluginId = 'belugacdn-app'; // Sample command configurations to be used in tests - const commandConfig1 = { - placement: 'grafana/dashboard/panel/menu', - title: 'Open incident', - description: 'You can create an incident from this context', - handler: () => {}, - }; - const commandConfig2 = { - placement: 'plugins/grafana-slo-app/slo-breached', - title: 'Open incident', - description: 'You can create an incident from this context', - handler: () => {}, - }; + let commandConfig1: AppPluginExtensionCommandConfig, commandConfig2: AppPluginExtensionCommandConfig; + + beforeEach(() => { + commandConfig1 = { + placement: 'grafana/dashboard/panel/menu', + title: 'Open incident', + description: 'You can create an incident from this context', + handler: jest.fn(), + }; + commandConfig2 = { + placement: 'plugins/grafana-slo-app/slo-breached', + title: 'Open incident', + description: 'You can create an incident from this context', + handler: jest.fn(), + }; + }); it('should register a command extension', () => { const registry = createPluginExtensionRegistry([ @@ -428,26 +432,25 @@ describe('createPluginExtensionRegistry()', () => { linkExtensions: [], commandExtensions: [ { - placement: 'grafana/dashboard/panel/menu', - title: 'Open incident', - description: 'You can create an incident from this context', - handler: () => {}, + ...commandConfig1, configure: () => ({}), }, ], }, ]); - const extensions = registry['grafana/dashboard/panel/menu']; + const extensions = registry[commandConfig1.placement]; const [configure] = extensions; const context = {}; - const extension = configure?.(context); + const extension = configure(context); assertPluginExtensionCommand(extension); extension.callHandlerWithContext(); + expect(commandErrorHandler).toBeCalledTimes(1); expect(commandErrorHandler).toBeCalledWith(expect.any(Function), context); + expect(commandConfig1.handler).toBeCalledTimes(1); }); it('should wrap handler function with extension error handling when no configure function is added', () => { @@ -455,27 +458,52 @@ describe('createPluginExtensionRegistry()', () => { { pluginId, linkExtensions: [], - commandExtensions: [ - { - placement: 'grafana/dashboard/panel/menu', - title: 'Open incident', - description: 'You can create an incident from this context', - handler: () => {}, - }, - ], + commandExtensions: [commandConfig1], }, ]); - const extensions = registry['grafana/dashboard/panel/menu']; + const extensions = registry[commandConfig1.placement]; const [configure] = extensions; const context = {}; - const extension = configure?.(context); + const extension = configure(context); assertPluginExtensionCommand(extension); extension.callHandlerWithContext(); + expect(commandErrorHandler).toBeCalledTimes(1); expect(commandErrorHandler).toBeCalledWith(expect.any(Function), context); + expect(commandConfig1.handler).toBeCalledTimes(1); + }); + + it('should call the `handler()` function with the context and a `helpers` object', () => { + const registry = createPluginExtensionRegistry([ + { + pluginId, + linkExtensions: [], + commandExtensions: [commandConfig1, { ...commandConfig2, configure: () => ({}) }], + }, + ]); + + const context = {}; + const command1 = registry[commandConfig1.placement][0](context); + const command2 = registry[commandConfig2.placement][0](context); + + assertPluginExtensionCommand(command1); + assertPluginExtensionCommand(command2); + + command1.callHandlerWithContext(); + command2.callHandlerWithContext(); + + expect(commandConfig1.handler).toBeCalledTimes(1); + expect(commandConfig1.handler).toBeCalledWith(context, { + openModal: expect.any(Function), + }); + + expect(commandConfig2.handler).toBeCalledTimes(1); + expect(commandConfig2.handler).toBeCalledWith(context, { + openModal: expect.any(Function), + }); }); }); }); diff --git a/public/app/features/plugins/extensions/registryFactory.ts b/public/app/features/plugins/extensions/registryFactory.ts index e2bc4db28ca..807d7f6d7b7 100644 --- a/public/app/features/plugins/extensions/registryFactory.ts +++ b/public/app/features/plugins/extensions/registryFactory.ts @@ -1,6 +1,7 @@ import { type AppPluginExtensionCommand, type AppPluginExtensionCommandConfig, + type AppPluginExtensionCommandHelpers, type AppPluginExtensionLink, type AppPluginExtensionLinkConfig, type PluginExtension, @@ -9,12 +10,15 @@ import { PluginExtensionTypes, } from '@grafana/data'; import type { PluginExtensionRegistry, PluginExtensionRegistryItem } from '@grafana/runtime'; +import appEvents from 'app/core/app_events'; +import { ShowModalReactEvent } from 'app/types/events'; import type { PluginPreloadResult } from '../pluginPreloader'; import { handleErrorsInHandler, handleErrorsInConfigure } from './errorHandling'; +import { getModalWrapper } from './getModalWrapper'; import { PlacementsPerPlugin } from './placementsPerPlugin'; -import { ConfigureFunc } from './types'; +import { CommandHandlerFunc, ConfigureFunc } from './types'; import { createLinkValidator, isValidLinkPath } from './validateLink'; export function createPluginExtensionRegistry(preloadResults: PluginPreloadResult[]): PluginExtensionRegistry { @@ -69,6 +73,7 @@ function createCommandRegistryItem( config: AppPluginExtensionCommandConfig ): PluginExtensionRegistryItem | undefined { const configure = config.configure ?? defaultConfigure; + const helpers = getCommandHelpers(); const options = { pluginId: pluginId, @@ -76,8 +81,9 @@ function createCommandRegistryItem( logger: console.warn, }; + const handlerWithHelpers: CommandHandlerFunc = (context) => config.handler(context, helpers); const catchErrorsInHandler = handleErrorsInHandler(options); - const handler = catchErrorsInHandler(config.handler); + const handler = catchErrorsInHandler(handlerWithHelpers); const extensionFactory = createCommandFactory(pluginId, config, handler); @@ -175,3 +181,11 @@ function hashKey(key: string): number { function defaultConfigure() { return {}; } + +function getCommandHelpers() { + const openModal: AppPluginExtensionCommandHelpers['openModal'] = ({ title, body }) => { + appEvents.publish(new ShowModalReactEvent({ component: getModalWrapper({ title, body }) })); + }; + + return { openModal }; +}