mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
867ff52b38
commit
ea2b493937
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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) => {
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user