Extensions: Wrap extension components with a sandbox wrapper div (#103064)

* feat(sandbox): wrap extension components with sandbox wrapper div

* fix: remove circular dependency

This was caused by sandbox_plugin_loader_registry -> helpers -> backend_srv -> ... plugin_loader -> setup.

* fix: add dependency to react hook

* tests: add tests for extensions sandbox wrapper

* fix: only wrap modal content after plugin loading

* chore: remove unused code

* Wip

* review(actions.ts): extract logic to function

* test: remove testing app id

* tests: fix extension utils tests

* tests: remove unnecessary code
pull/107763/head
Levente Balogh 2 weeks ago committed by GitHub
parent 6c2574848f
commit 5b7f06c24e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 10
      public/app/features/plugins/admin/helpers.ts
  2. 22
      public/app/features/plugins/admin/pages/PluginDetails.test.tsx
  3. 11
      public/app/features/plugins/admin/state/actions.ts
  4. 31
      public/app/features/plugins/extensions/utils.test.tsx
  5. 4
      public/app/features/plugins/extensions/utils.tsx
  6. 36
      public/app/features/plugins/sandbox/sandbox_components.tsx

@ -2,9 +2,7 @@ import uFuzzy from '@leeoniya/ufuzzy';
import { PluginSignatureStatus, dateTimeParse, PluginError, PluginType, PluginErrorCode } from '@grafana/data';
import { config, featureEnabled } from '@grafana/runtime';
import { Settings } from 'app/core/config';
import { contextSrv } from 'app/core/core';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { AccessControlAction } from 'app/types';
import {
@ -341,14 +339,6 @@ function getPluginSignature(options: {
return PluginSignatureStatus.missing;
}
// Updates the core Grafana config to have the correct list available panels
export const updatePanels = () =>
getBackendSrv()
.get('/api/frontend/settings')
.then((settings: Settings) => {
config.panels = settings.panels;
});
export function getLatestCompatibleVersion(versions: Version[] | undefined): Version | undefined {
if (!versions) {
return;

@ -11,7 +11,7 @@ import {
} from '@grafana/data';
import { GrafanaEdition } from '@grafana/data/internal';
import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { config, getBackendSrv, setBackendSrv } from '@grafana/runtime';
import { configureStore } from 'app/store/configureStore';
import * as api from '../api';
@ -30,10 +30,10 @@ import {
import PluginDetailsPage from './PluginDetails';
jest.mock('@grafana/runtime', () => {
const original = jest.requireActual('@grafana/runtime');
const mockedRuntime = { ...original };
mockedRuntime.config.buildInfo.version = 'v8.1.0';
return mockedRuntime;
const runtime = jest.requireActual('@grafana/runtime');
runtime.config.buildInfo.version = 'v8.1.0';
return runtime;
});
jest.mock('../hooks/usePluginConfig.tsx', () => ({
@ -44,11 +44,6 @@ jest.mock('../hooks/usePluginConfig.tsx', () => ({
})),
}));
jest.mock('../helpers.ts', () => ({
...jest.requireActual('../helpers.ts'),
updatePanels: jest.fn(),
}));
jest.mock('app/core/core', () => ({
contextSrv: {
hasPermission: (action: string) => true,
@ -85,6 +80,7 @@ describe('Plugin details page', () => {
const id = 'my-plugin';
const originalWindowLocation = window.location;
let dateNow: jest.SpyInstance<number, []>;
const originalBackendSrv = getBackendSrv();
beforeAll(() => {
dateNow = jest.spyOn(Date, 'now').mockImplementation(() => 1609470000000); // 2021-01-01 04:00:00
@ -100,6 +96,7 @@ describe('Plugin details page', () => {
jest.clearAllMocks();
config.pluginAdminExternalManageEnabled = false;
config.licenseInfo.enabledFeatures = {};
setBackendSrv(originalBackendSrv);
});
afterAll(() => {
@ -422,6 +419,11 @@ describe('Plugin details page', () => {
// @ts-ignore
api.uninstallPlugin = jest.fn();
setBackendSrv({
...originalBackendSrv,
get: jest.fn().mockResolvedValue({ panels: [] }),
});
const { queryByText, getByRole, findByRole, user } = renderPluginDetails({
id,
name: 'Akumuli',

@ -3,6 +3,7 @@ import { from, forkJoin, timeout, lastValueFrom, catchError, of } from 'rxjs';
import { PanelPlugin, PluginError } from '@grafana/data';
import { config, getBackendSrv, isFetchError } from '@grafana/runtime';
import { Settings } from 'app/core/config';
import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin';
import { StoreState, ThunkResult } from 'app/types';
@ -18,7 +19,7 @@ import {
getProvisionedPlugins,
} from '../api';
import { STATE_PREFIX } from '../constants';
import { mapLocalToCatalog, mergeLocalsAndRemotes, updatePanels } from '../helpers';
import { mapLocalToCatalog, mergeLocalsAndRemotes } from '../helpers';
import { CatalogPlugin, RemotePlugin, LocalPlugin, InstancePlugin, ProvisionedPlugin, PluginStatus } from '../types';
// Fetches
@ -278,3 +279,11 @@ export const loadPanelPlugin = (id: string): ThunkResult<Promise<PanelPlugin>> =
return plugin;
};
};
function updatePanels() {
return getBackendSrv()
.get('/api/frontend/settings')
.then((settings: Settings) => {
config.panels = settings.panels;
});
}

@ -7,6 +7,7 @@ import appEvents from 'app/core/app_events';
import { ShowModalReactEvent } from 'app/types/events';
import { log } from './logs/log';
import { resetLogMock } from './logs/testUtils';
import {
deepFreeze,
handleErrorsInFn,
@ -26,7 +27,7 @@ import {
jest.mock('app/features/plugins/pluginSettings', () => ({
...jest.requireActual('app/features/plugins/pluginSettings'),
getPluginSettings: () => Promise.resolve({ info: { version: '1.0.0' } }),
getPluginSettings: () => Promise.resolve({ info: { version: '1.0.0' }, id: 'test-plugin' }),
}));
describe('Plugin Extensions / Utils', () => {
@ -36,6 +37,9 @@ describe('Plugin Extensions / Utils', () => {
jest.spyOn(log, 'error').mockImplementation(() => {});
jest.spyOn(log, 'warning').mockImplementation(() => {});
jest.spyOn(log, 'debug').mockImplementation(() => {});
jest.spyOn(log, 'info').mockImplementation(() => {});
jest.spyOn(log, 'trace').mockImplementation(() => {});
jest.spyOn(log, 'fatal').mockImplementation(() => {});
});
afterEach(() => {
@ -723,6 +727,27 @@ describe('Plugin Extensions / Utils', () => {
expect(modal).toHaveTextContent('Version: 1.0.0');
});
it('should add a wrapper div with a "data-plugin-sandbox" attribute', async () => {
const pluginId = 'grafana-worldmap-panel';
const openModal = createOpenModalFunction({
pluginId,
extensionPointId: 'myorg-extensions-app/link/v1',
title: 'Title in modal',
});
openModal({
title: 'Title in modal',
body: () => <div>Text in body</div>,
});
expect(await screen.findByRole('dialog')).toBeVisible();
expect(screen.getByTestId('plugin-sandbox-wrapper')).toHaveAttribute(
'data-plugin-sandbox',
'grafana-worldmap-panel'
);
});
it('should show an error alert in the modal IN DEV MODE if the extension throws an error', async () => {
config.buildInfo.env = 'development';
jest.spyOn(console, 'error').mockImplementation(() => {});
@ -816,6 +841,10 @@ describe('Plugin Extensions / Utils', () => {
);
};
beforeEach(() => {
resetLogMock(log);
});
it('should make the plugin context available for the wrapped component', async () => {
const pluginId = 'grafana-worldmap-panel';
const Component = wrapWithPluginContext({

@ -133,7 +133,9 @@ const getModalWrapper = ({
fallbackAlwaysVisible={true}
log={baseLog}
>
<Body onDismiss={onDismiss} />
<div data-plugin-sandbox={config.pluginId} data-testid="plugin-sandbox-wrapper">
<Body onDismiss={onDismiss} />
</div>
</ExtensionErrorBoundary>
</Modal>
);

@ -2,7 +2,12 @@ import { isFunction } from 'lodash';
import { ComponentType, FC } from 'react';
import * as React from 'react';
import { GrafanaPlugin, PluginType } from '@grafana/data';
import {
GrafanaPlugin,
PluginExtensionAddedComponentConfig,
PluginExtensionExposedComponentConfig,
PluginType,
} from '@grafana/data';
import { SandboxPluginMeta, SandboxedPluginObject } from './types';
import { isSandboxedPluginObject } from './utils';
@ -58,6 +63,35 @@ export async function sandboxPluginComponents(
Reflect.set(pluginObject, 'root', withSandboxWrapper(Reflect.get(pluginObject, 'root'), meta));
}
// Extensions: added components
if (Reflect.has(pluginObject, 'addedComponentConfigs')) {
const addedComponents: PluginExtensionAddedComponentConfig[] = Reflect.get(pluginObject, 'addedComponentConfigs');
for (const addedComponent of addedComponents) {
if (Reflect.has(addedComponent, 'component')) {
Reflect.set(addedComponent, 'component', withSandboxWrapper(Reflect.get(addedComponent, 'component'), meta));
}
}
Reflect.set(pluginObject, 'addedComponentConfigs', addedComponents);
}
// Extensions: exposed components
if (Reflect.has(pluginObject, 'exposedComponentConfigs')) {
const exposedComponents: PluginExtensionExposedComponentConfig[] = Reflect.get(
pluginObject,
'exposedComponentConfigs'
);
for (const exposedComponent of exposedComponents) {
if (Reflect.has(exposedComponent, 'component')) {
Reflect.set(
exposedComponent,
'component',
withSandboxWrapper(Reflect.get(exposedComponent, 'component'), meta)
);
}
}
Reflect.set(pluginObject, 'exposedComponentConfigs', exposedComponents);
}
// config pages
if (Reflect.has(pluginObject, 'configPages')) {
const configPages: NonNullable<GrafanaPlugin['configPages']> = Reflect.get(pluginObject, 'configPages') ?? [];

Loading…
Cancel
Save