Plugins: Share the plugin context with apps and ui-extensions (#77933)

* feat: share the plugin context with app plugins

* feat: share the plugin context ui-extension modals

* feat: pre-fetch the app plugin settings

* feat: expose more utility hooks for plugins

* fix: use `location.pathname` directly

Previously it was referenced by `pluginRoot.props.path`, which stops
working in case the `pluginRoot` element is wrapped with anything.
This commit is contained in:
Levente Balogh
2023-11-14 08:35:40 +01:00
committed by GitHub
parent 867ff52b38
commit ea2b493937
6 changed files with 100 additions and 27 deletions
@@ -1,5 +1,7 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { PluginMeta } from '../../types';
import { Context, PluginContextType } from './PluginContext'; import { Context, PluginContextType } from './PluginContext';
export function usePluginContext(): PluginContextType { export function usePluginContext(): PluginContextType {
@@ -9,3 +11,21 @@ export function usePluginContext(): PluginContextType {
} }
return context; return context;
} }
export function usePluginMeta(): PluginMeta {
const context = usePluginContext();
return context.meta;
}
export function usePluginJsonData() {
const context = usePluginContext();
return context.meta.jsonData;
}
export function usePluginVersion() {
const context = usePluginContext();
return context.meta.info.version;
}
@@ -3,7 +3,16 @@ import { AnyAction, createSlice, PayloadAction } from '@reduxjs/toolkit';
import React, { useCallback, useEffect, useMemo, useReducer } from 'react'; import React, { useCallback, useEffect, useMemo, useReducer } from 'react';
import { useLocation, useRouteMatch } from 'react-router-dom'; import { useLocation, useRouteMatch } from 'react-router-dom';
import { AppEvents, AppPlugin, AppPluginMeta, NavModel, NavModelItem, OrgRole, PluginType } from '@grafana/data'; import {
AppEvents,
AppPlugin,
AppPluginMeta,
NavModel,
NavModelItem,
OrgRole,
PluginType,
PluginContextProvider,
} from '@grafana/data';
import { config, locationSearchToObject } from '@grafana/runtime'; import { config, locationSearchToObject } from '@grafana/runtime';
import { Alert } from '@grafana/ui'; import { Alert } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page'; import { Page } from 'app/core/components/Page/Page';
@@ -76,13 +85,15 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) {
} }
const pluginRoot = plugin.root && ( const pluginRoot = plugin.root && (
<plugin.root <PluginContextProvider meta={plugin.meta}>
meta={plugin.meta} <plugin.root
basename={match.url} meta={plugin.meta}
onNavChanged={onNavChanged} basename={match.url}
query={queryParams} onNavChanged={onNavChanged}
path={location.pathname} query={queryParams}
/> path={location.pathname}
/>
</PluginContextProvider>
); );
// Because of the fallback at plugin routes, we need to check // Because of the fallback at plugin routes, we need to check
@@ -93,7 +104,7 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) {
return true; return true;
} }
const pluginInclude = plugin.meta?.includes.find((include) => include.path === pluginRoot.props.path); const pluginInclude = plugin.meta?.includes.find((include) => include.path === location.pathname);
// Check if include configuration contains current path // Check if include configuration contains current path
if (!pluginInclude) { if (!pluginInclude) {
return true; return true;
@@ -189,7 +189,7 @@ function getLinkExtensionOnClick(
category: config.category, category: config.category,
}); });
const result = onClick(event, getEventHelpers(context)); const result = onClick(event, getEventHelpers(pluginId, context));
if (isPromise(result)) { if (isPromise(result)) {
result.catch((e) => { result.catch((e) => {
@@ -2,12 +2,17 @@ import { render, screen } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { type Unsubscribable } from 'rxjs'; import { type Unsubscribable } from 'rxjs';
import { type PluginExtensionLinkConfig, PluginExtensionTypes, dateTime } from '@grafana/data'; import { type PluginExtensionLinkConfig, PluginExtensionTypes, dateTime, usePluginContext } from '@grafana/data';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { ShowModalReactEvent } from 'app/types/events'; import { ShowModalReactEvent } from 'app/types/events';
import { deepFreeze, isPluginExtensionLinkConfig, handleErrorsInFn, getReadOnlyProxy, getEventHelpers } from './utils'; import { deepFreeze, isPluginExtensionLinkConfig, handleErrorsInFn, getReadOnlyProxy, getEventHelpers } from './utils';
jest.mock('app/features/plugins/pluginSettings', () => ({
...jest.requireActual('app/features/plugins/pluginSettings'),
getPluginSettings: () => Promise.resolve({ info: { version: '1.0.0' } }),
}));
describe('Plugin Extensions / Utils', () => { describe('Plugin Extensions / Utils', () => {
describe('deepFreeze()', () => { describe('deepFreeze()', () => {
test('should not fail when called with primitive values', () => { test('should not fail when called with primitive values', () => {
@@ -341,27 +346,29 @@ describe('Plugin Extensions / Utils', () => {
}); });
it('should open modal with provided title and body', async () => { it('should open modal with provided title and body', async () => {
const { openModal } = getEventHelpers(); const pluginId = 'grafana-worldmap-panel';
const { openModal } = getEventHelpers(pluginId);
openModal({ openModal({
title: 'Title in modal', title: 'Title in modal',
body: () => <div>Text in body</div>, body: () => <div>Text in body</div>,
}); });
expect(screen.getByRole('dialog')).toBeVisible(); expect(await screen.findByRole('dialog')).toBeVisible();
expect(screen.getByRole('heading')).toHaveTextContent('Title in modal'); expect(screen.getByRole('heading')).toHaveTextContent('Title in modal');
expect(screen.getByText('Text in body')).toBeVisible(); expect(screen.getByText('Text in body')).toBeVisible();
}); });
it('should open modal with default width if not specified', async () => { it('should open modal with default width if not specified', async () => {
const { openModal } = getEventHelpers(); const pluginId = 'grafana-worldmap-panel';
const { openModal } = getEventHelpers(pluginId);
openModal({ openModal({
title: 'Title in modal', title: 'Title in modal',
body: () => <div>Text in body</div>, body: () => <div>Text in body</div>,
}); });
const modal = screen.getByRole('dialog'); const modal = await screen.findByRole('dialog');
const style = window.getComputedStyle(modal); const style = window.getComputedStyle(modal);
expect(style.width).toBe('750px'); expect(style.width).toBe('750px');
@@ -369,7 +376,8 @@ describe('Plugin Extensions / Utils', () => {
}); });
it('should open modal with specified width', async () => { it('should open modal with specified width', async () => {
const { openModal } = getEventHelpers(); const pluginId = 'grafana-worldmap-panel';
const { openModal } = getEventHelpers(pluginId);
openModal({ openModal({
title: 'Title in modal', title: 'Title in modal',
@@ -377,14 +385,15 @@ describe('Plugin Extensions / Utils', () => {
width: '70%', width: '70%',
}); });
const modal = screen.getByRole('dialog'); const modal = await screen.findByRole('dialog');
const style = window.getComputedStyle(modal); const style = window.getComputedStyle(modal);
expect(style.width).toBe('70%'); expect(style.width).toBe('70%');
}); });
it('should open modal with specified height', async () => { it('should open modal with specified height', async () => {
const { openModal } = getEventHelpers(); const pluginId = 'grafana-worldmap-panel';
const { openModal } = getEventHelpers(pluginId);
openModal({ openModal({
title: 'Title in modal', title: 'Title in modal',
@@ -392,17 +401,37 @@ describe('Plugin Extensions / Utils', () => {
height: 600, height: 600,
}); });
const modal = screen.getByRole('dialog'); const modal = await screen.findByRole('dialog');
const style = window.getComputedStyle(modal); const style = window.getComputedStyle(modal);
expect(style.height).toBe('600px'); expect(style.height).toBe('600px');
}); });
it('should open modal with the plugin context being available', async () => {
const pluginId = 'grafana-worldmap-panel';
const { openModal } = getEventHelpers(pluginId);
const ModalContent = () => {
const context = usePluginContext();
return <div>Version: {context.meta.info.version}</div>;
};
openModal({
title: 'Title in modal',
body: ModalContent,
});
const modal = await screen.findByRole('dialog');
expect(modal).toHaveTextContent('Version: 1.0.0');
});
}); });
describe('context', () => { describe('context', () => {
it('should return same object as passed to getEventHelpers', () => { it('should return same object as passed to getEventHelpers', () => {
const pluginId = 'grafana-worldmap-panel';
const source = {}; const source = {};
const { context } = getEventHelpers(source); const { context } = getEventHelpers(pluginId, source);
expect(context).toBe(source); expect(context).toBe(source);
}); });
}); });
@@ -11,9 +11,12 @@ import {
type PluginExtensionOpenModalOptions, type PluginExtensionOpenModalOptions,
isDateTime, isDateTime,
dateTime, dateTime,
PluginContextProvider,
PluginMeta,
} from '@grafana/data'; } from '@grafana/data';
import { Modal } from '@grafana/ui'; import { Modal } from '@grafana/ui';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
import { ShowModalReactEvent } from 'app/types/events'; import { ShowModalReactEvent } from 'app/types/events';
export function logWarning(message: string) { export function logWarning(message: string) {
@@ -45,13 +48,14 @@ 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. // 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<object>): PluginExtensionEventHelpers { export function getEventHelpers(pluginId: string, context?: Readonly<object>): PluginExtensionEventHelpers {
const openModal: PluginExtensionEventHelpers['openModal'] = (options) => { const openModal: PluginExtensionEventHelpers['openModal'] = async (options) => {
const { title, body, width, height } = options; const { title, body, width, height } = options;
const pluginMeta = await getPluginSettings(pluginId);
appEvents.publish( appEvents.publish(
new ShowModalReactEvent({ new ShowModalReactEvent({
component: getModalWrapper({ title, body, width, height }), component: getModalWrapper({ title, body, width, height, pluginMeta }),
}) })
); );
}; };
@@ -72,14 +76,17 @@ const getModalWrapper = ({
body: Body, body: Body,
width, width,
height, height,
}: PluginExtensionOpenModalOptions) => { pluginMeta,
}: { pluginMeta: PluginMeta } & PluginExtensionOpenModalOptions) => {
const className = css({ width, height }); const className = css({ width, height });
const ModalWrapper = ({ onDismiss }: ModalWrapperProps) => { const ModalWrapper = ({ onDismiss }: ModalWrapperProps) => {
return ( return (
<Modal title={title} className={className} isOpen onDismiss={onDismiss} onClickBackdrop={onDismiss}> <PluginContextProvider meta={pluginMeta}>
<Body onDismiss={onDismiss} /> <Modal title={title} className={className} isOpen onDismiss={onDismiss} onClickBackdrop={onDismiss}>
</Modal> <Body onDismiss={onDismiss} />
</Modal>
</PluginContextProvider>
); );
}; };
@@ -1,6 +1,7 @@
import type { PluginExtensionConfig } from '@grafana/data'; import type { PluginExtensionConfig } from '@grafana/data';
import type { AppPluginConfig } from '@grafana/runtime'; import type { AppPluginConfig } from '@grafana/runtime';
import { startMeasure, stopMeasure } from 'app/core/utils/metrics'; import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
import * as pluginLoader from './plugin_loader'; import * as pluginLoader from './plugin_loader';
@@ -29,6 +30,11 @@ async function preload(config: AppPluginConfig): Promise<PluginPreloadResult> {
pluginId, pluginId,
}); });
const { extensionConfigs = [] } = plugin; const { extensionConfigs = [] } = plugin;
// Fetching meta-information for the preloaded app plugin and caching it for later.
// (The function below returns a promise, but it's not awaited for a reason: we don't want to block the preload process, we would only like to cache the result for later.)
getPluginSettings(pluginId);
return { pluginId, extensionConfigs }; return { pluginId, extensionConfigs };
} catch (error) { } catch (error) {
console.error(`[Plugins] Failed to preload plugin: ${path} (version: ${version})`, error); console.error(`[Plugins] Failed to preload plugin: ${path} (version: ${version})`, error);