Plugins: Allow command extensions to open modals (#64029)

feat: make it possible to open modals from commands
This commit is contained in:
Levente Balogh 2023-03-08 15:44:48 +01:00 committed by GitHub
parent 09341a0cd6
commit d44dc0f100
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 111 additions and 31 deletions

View File

@ -56,6 +56,17 @@ export interface AppPluginMeta<T extends KeyValue = KeyValue> extends PluginMeta
*/
export type AppPluginExtensionLink = Pick<PluginExtensionLink, 'description' | 'path' | 'title'>;
// 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<PluginExtensionCommand, 'description' | 'title'>;
export type AppPluginExtensionLinkConfig<C extends object = object> = {
@ -70,7 +81,7 @@ export type AppPluginExtensionCommandConfig<C extends object = object> = {
title: string;
description: string;
placement: string;
handler: (context?: C) => void;
handler: (context?: C, helpers?: AppPluginExtensionCommandHelpers) => void;
configure?: (extension: AppPluginExtensionCommand, context?: C) => Partial<AppPluginExtensionCommand> | undefined;
};

View File

@ -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<AppPluginExtensionCommandHelpers['openModal']>[0]) => {
const ModalWrapper = ({ onDismiss }: ModalWrapperProps) => {
return (
<Modal title={title} isOpen onDismiss={onDismiss} onClickBackdrop={onDismiss}>
<Body onDismiss={onDismiss} />
</Modal>
);
};
return ModalWrapper;
};

View File

@ -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),
});
});
});
});

View File

@ -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<PluginExtensionCommand> | 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 };
}