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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 100 additions and 27 deletions

View File

@ -1,5 +1,7 @@
import { useContext } from 'react';
import { PluginMeta } from '../../types';
import { Context, PluginContextType } from './PluginContext';
export function usePluginContext(): PluginContextType {
@ -9,3 +11,21 @@ export function usePluginContext(): PluginContextType {
}
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;
}

View File

@ -3,7 +3,16 @@ import { AnyAction, createSlice, PayloadAction } from '@reduxjs/toolkit';
import React, { useCallback, useEffect, useMemo, useReducer } from 'react';
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 { Alert } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
@ -76,13 +85,15 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) {
}
const pluginRoot = plugin.root && (
<plugin.root
meta={plugin.meta}
basename={match.url}
onNavChanged={onNavChanged}
query={queryParams}
path={location.pathname}
/>
<PluginContextProvider meta={plugin.meta}>
<plugin.root
meta={plugin.meta}
basename={match.url}
onNavChanged={onNavChanged}
query={queryParams}
path={location.pathname}
/>
</PluginContextProvider>
);
// Because of the fallback at plugin routes, we need to check
@ -93,7 +104,7 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) {
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
if (!pluginInclude) {
return true;

View File

@ -189,7 +189,7 @@ function getLinkExtensionOnClick(
category: config.category,
});
const result = onClick(event, getEventHelpers(context));
const result = onClick(event, getEventHelpers(pluginId, context));
if (isPromise(result)) {
result.catch((e) => {

View File

@ -2,12 +2,17 @@ import { render, screen } from '@testing-library/react';
import React from 'react';
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 { ShowModalReactEvent } from 'app/types/events';
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('deepFreeze()', () => {
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 () => {
const { openModal } = getEventHelpers();
const pluginId = 'grafana-worldmap-panel';
const { openModal } = getEventHelpers(pluginId);
openModal({
title: 'Title in modal',
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.getByText('Text in body')).toBeVisible();
});
it('should open modal with default width if not specified', async () => {
const { openModal } = getEventHelpers();
const pluginId = 'grafana-worldmap-panel';
const { openModal } = getEventHelpers(pluginId);
openModal({
title: 'Title in modal',
body: () => <div>Text in body</div>,
});
const modal = screen.getByRole('dialog');
const modal = await screen.findByRole('dialog');
const style = window.getComputedStyle(modal);
expect(style.width).toBe('750px');
@ -369,7 +376,8 @@ describe('Plugin Extensions / Utils', () => {
});
it('should open modal with specified width', async () => {
const { openModal } = getEventHelpers();
const pluginId = 'grafana-worldmap-panel';
const { openModal } = getEventHelpers(pluginId);
openModal({
title: 'Title in modal',
@ -377,14 +385,15 @@ describe('Plugin Extensions / Utils', () => {
width: '70%',
});
const modal = screen.getByRole('dialog');
const modal = await screen.findByRole('dialog');
const style = window.getComputedStyle(modal);
expect(style.width).toBe('70%');
});
it('should open modal with specified height', async () => {
const { openModal } = getEventHelpers();
const pluginId = 'grafana-worldmap-panel';
const { openModal } = getEventHelpers(pluginId);
openModal({
title: 'Title in modal',
@ -392,17 +401,37 @@ describe('Plugin Extensions / Utils', () => {
height: 600,
});
const modal = screen.getByRole('dialog');
const modal = await screen.findByRole('dialog');
const style = window.getComputedStyle(modal);
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', () => {
it('should return same object as passed to getEventHelpers', () => {
const pluginId = 'grafana-worldmap-panel';
const source = {};
const { context } = getEventHelpers(source);
const { context } = getEventHelpers(pluginId, source);
expect(context).toBe(source);
});
});

View File

@ -11,9 +11,12 @@ import {
type PluginExtensionOpenModalOptions,
isDateTime,
dateTime,
PluginContextProvider,
PluginMeta,
} from '@grafana/data';
import { Modal } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
import { ShowModalReactEvent } from 'app/types/events';
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.
export function getEventHelpers(context?: Readonly<object>): PluginExtensionEventHelpers {
const openModal: PluginExtensionEventHelpers['openModal'] = (options) => {
export function getEventHelpers(pluginId: string, context?: Readonly<object>): 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 }),
component: getModalWrapper({ title, body, width, height, pluginMeta }),
})
);
};
@ -72,14 +76,17 @@ const getModalWrapper = ({
body: Body,
width,
height,
}: PluginExtensionOpenModalOptions) => {
pluginMeta,
}: { pluginMeta: PluginMeta } & PluginExtensionOpenModalOptions) => {
const className = css({ width, height });
const ModalWrapper = ({ onDismiss }: ModalWrapperProps) => {
return (
<Modal title={title} className={className} isOpen onDismiss={onDismiss} onClickBackdrop={onDismiss}>
<Body onDismiss={onDismiss} />
</Modal>
<PluginContextProvider meta={pluginMeta}>
<Modal title={title} className={className} isOpen onDismiss={onDismiss} onClickBackdrop={onDismiss}>
<Body onDismiss={onDismiss} />
</Modal>
</PluginContextProvider>
);
};

View File

@ -1,6 +1,7 @@
import type { PluginExtensionConfig } from '@grafana/data';
import type { AppPluginConfig } from '@grafana/runtime';
import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
import * as pluginLoader from './plugin_loader';
@ -29,6 +30,11 @@ async function preload(config: AppPluginConfig): Promise<PluginPreloadResult> {
pluginId,
});
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 };
} catch (error) {
console.error(`[Plugins] Failed to preload plugin: ${path} (version: ${version})`, error);