From f012b75a3ade58ccac6d03c391fade69257fd65c Mon Sep 17 00:00:00 2001 From: Marcus Andersson Date: Thu, 12 Oct 2023 10:56:08 +0200 Subject: [PATCH] PluginExtensions: Made it possible to control modal size from extension (#76232) * Added possibility to change modal size from UI extension. * added tests for openModal. * fixed typings. * added test to verify default modal size. --- packages/grafana-data/src/types/index.ts | 1 + .../src/types/pluginExtensions.ts | 18 ++-- .../plugins/extensions/utils.test.tsx | 94 ++++++++++++++++++- .../app/features/plugins/extensions/utils.tsx | 24 +++-- 4 files changed, 123 insertions(+), 14 deletions(-) diff --git a/packages/grafana-data/src/types/index.ts b/packages/grafana-data/src/types/index.ts index 6183d52d61a..96f3c8f7d39 100644 --- a/packages/grafana-data/src/types/index.ts +++ b/packages/grafana-data/src/types/index.ts @@ -63,4 +63,5 @@ export { type PluginExtensionEventHelpers, type PluginExtensionPanelContext, type PluginExtensionDataSourceConfigContext, + type PluginExtensionOpenModalOptions, } from './pluginExtensions'; diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 568ad10d3dc..f541c56f571 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -95,15 +95,21 @@ export type PluginExtensionComponentConfig = { export type PluginExtensionConfig = PluginExtensionLinkConfig | PluginExtensionComponentConfig; +export type PluginExtensionOpenModalOptions = { + // The title of the modal + title: string; + // A React element that will be rendered inside the modal + body: React.ElementType<{ onDismiss?: () => void }>; + // Width of the modal in pixels or percentage + width?: string | number; + // Height of the modal in pixels or percentage + height?: string | number; +}; + export type PluginExtensionEventHelpers = { context?: Readonly; // 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; + openModal: (options: PluginExtensionOpenModalOptions) => void; }; // Extension Points & Contexts diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx index f814bd1df3b..967286a9710 100644 --- a/public/app/features/plugins/extensions/utils.test.tsx +++ b/public/app/features/plugins/extensions/utils.test.tsx @@ -1,6 +1,12 @@ -import { PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { type Unsubscribable } from 'rxjs'; -import { deepFreeze, isPluginExtensionLinkConfig, handleErrorsInFn, getReadOnlyProxy } from './utils'; +import { type PluginExtensionLinkConfig, PluginExtensionTypes } from '@grafana/data'; +import appEvents from 'app/core/app_events'; +import { ShowModalReactEvent } from 'app/types/events'; + +import { deepFreeze, isPluginExtensionLinkConfig, handleErrorsInFn, getReadOnlyProxy, getEventHelpers } from './utils'; describe('Plugin Extensions / Utils', () => { describe('deepFreeze()', () => { @@ -307,4 +313,88 @@ describe('Plugin Extensions / Utils', () => { expect(proxy.a()).toBe('testing'); }); }); + + describe('getEventHelpers', () => { + describe('openModal', () => { + let renderModalSubscription: Unsubscribable | undefined; + + beforeAll(() => { + renderModalSubscription = appEvents.subscribe(ShowModalReactEvent, (event) => { + const { payload } = event; + const Modal = payload.component; + render(); + }); + }); + + afterAll(() => { + renderModalSubscription?.unsubscribe(); + }); + + it('should open modal with provided title and body', async () => { + const { openModal } = getEventHelpers(); + + openModal({ + title: 'Title in modal', + body: () =>
Text in body
, + }); + + expect(screen.getByRole('dialog')).toBeVisible(); + expect(screen.getByRole('heading')).toHaveTextContent('Title in modal'); + expect(screen.getByText('Text in body')).toBeVisible(); + }); + + it('should open modal with default width if not specified', async () => { + const { openModal } = getEventHelpers(); + + openModal({ + title: 'Title in modal', + body: () =>
Text in body
, + }); + + const modal = screen.getByRole('dialog'); + const style = window.getComputedStyle(modal); + + expect(style.width).toBe('750px'); + expect(style.height).toBe(''); + }); + + it('should open modal with specified width', async () => { + const { openModal } = getEventHelpers(); + + openModal({ + title: 'Title in modal', + body: () =>
Text in body
, + width: '70%', + }); + + const modal = screen.getByRole('dialog'); + const style = window.getComputedStyle(modal); + + expect(style.width).toBe('70%'); + }); + + it('should open modal with specified height', async () => { + const { openModal } = getEventHelpers(); + + openModal({ + title: 'Title in modal', + body: () =>
Text in body
, + height: 600, + }); + + const modal = screen.getByRole('dialog'); + const style = window.getComputedStyle(modal); + + expect(style.height).toBe('600px'); + }); + }); + + describe('context', () => { + it('should return same object as passed to getEventHelpers', () => { + const source = {}; + const { context } = getEventHelpers(source); + expect(context).toBe(source); + }); + }); + }); }); diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index 96d6dd50a46..d1449484bd0 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -1,3 +1,4 @@ +import { css } from '@emotion/css'; import { isArray, isObject } from 'lodash'; import React from 'react'; @@ -7,6 +8,7 @@ import { type PluginExtensionConfig, type PluginExtensionEventHelpers, PluginExtensionTypes, + type PluginExtensionOpenModalOptions, } from '@grafana/data'; import { Modal } from '@grafana/ui'; import appEvents from 'app/core/app_events'; @@ -42,28 +44,38 @@ export function handleErrorsInFn(fn: Function, errorMessagePrefix = '') { // Event helpers are designed to make it easier to trigger "core actions" from an extension event handler, e.g. opening a modal or showing a notification. export function getEventHelpers(context?: Readonly): PluginExtensionEventHelpers { - const openModal: PluginExtensionEventHelpers['openModal'] = ({ title, body }) => { - appEvents.publish(new ShowModalReactEvent({ component: getModalWrapper({ title, body }) })); + const openModal: PluginExtensionEventHelpers['openModal'] = (options) => { + const { title, body, width, height } = options; + + appEvents.publish( + new ShowModalReactEvent({ + component: getModalWrapper({ title, body, width, height }), + }) + ); }; return { openModal, context }; } -export type ModalWrapperProps = { +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 = ({ +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]) => { + width, + height, +}: PluginExtensionOpenModalOptions) => { + const className = css({ width, height }); + const ModalWrapper = ({ onDismiss }: ModalWrapperProps) => { return ( - + );