Plugin Extensions: Add error boundaries (#107515)

* feat(grafana-data): expose PluginContext

This is aimed to be used in the `PluginErrorBoundary` (which is a class component, and cannot use the hook.)

* feat(PluginErrorBoundary): add an error boundary for plugins

* feat(ExtensionsErrorBoundary): add an error boundary for extensions)

* feat(Extensions/Utils): wrap components with error boundaries

* feat(Plugins): wrap root plugin page with an error boundary

* fix: Fallback component should always be visible for onClick() modals

* review: use object arguments instead of positional ones for `renderWithPluginContext()`

* review: update `wrapWithPluginContext()` to receive args as an object

* refactor(AppChromeExtensionPoint): remove the error boundary

We have an error boundary on the extensions-framework level now

* refactor(ExtensionSidebar): remove the ErrorBoundary from the extensions

This is handled on the extensions-framework level now.

* test(ExtensionSidebar): add tests

* chore: translation extraction

* chore: prettier formatting

* fix(PluginErrorBoundary): remove unnecessary type casting
This commit is contained in:
Levente Balogh
2025-07-07 14:51:04 +02:00
committed by GitHub
parent c2b7acae35
commit 77f84e494d
16 changed files with 737 additions and 52 deletions
+5 -1
View File
@@ -432,7 +432,11 @@ export {
export { createFieldConfigRegistry } from './panel/registryFactories';
export { type QueryRunner, type QueryRunnerOptions } from './types/queryRunner';
export { type GroupingToMatrixTransformerOptions } from './transformations/transformers/groupingToMatrix';
export { type PluginContextType, type DataSourcePluginContextType } from './context/plugins/PluginContext';
export {
type PluginContextType,
type DataSourcePluginContextType,
Context as PluginContext,
} from './context/plugins/PluginContext';
export { type PluginContextProviderProps, PluginContextProvider } from './context/plugins/PluginContextProvider';
export {
type DataSourcePluginContextProviderProps,
@@ -2,7 +2,6 @@ import { useLocation } from 'react-router-dom';
import { PluginExtensionPoints } from '@grafana/data';
import { config, renderLimitedComponents, usePluginComponents } from '@grafana/runtime';
import { ErrorBoundaryAlert } from '@grafana/ui';
const excludedRoutes: Record<string, boolean> = {
'/login': true,
@@ -24,13 +23,10 @@ export function AppChromeExtensionPoint(): JSX.Element | null {
return null;
}
return (
<ErrorBoundaryAlert>
<InternalAppChromeExtensionPoint />
</ErrorBoundaryAlert>
);
return <InternalAppChromeExtensionPoint />;
}
// We have this "internal" component so we can prevent pre-loading the plugins associated with the extension-point if the feature is not enabled.
function InternalAppChromeExtensionPoint(): JSX.Element | null {
const { components, isLoading } = usePluginComponents({
extensionPointId: PluginExtensionPoints.AppChrome,
@@ -0,0 +1,167 @@
import { render, screen } from '@testing-library/react';
import { ExtensionInfo, PluginExtensionTypes } from '@grafana/data';
import { config, usePluginComponents } from '@grafana/runtime';
import { AddedComponentRegistryItem } from 'app/features/plugins/extensions/registry/AddedComponentsRegistry';
import { createComponentWithMeta } from 'app/features/plugins/extensions/usePluginComponents';
import { ExtensionSidebar } from './ExtensionSidebar';
import {
ExtensionSidebarContextType,
getComponentIdFromComponentMeta,
useExtensionSidebarContext,
} from './ExtensionSidebarProvider';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
usePluginComponents: jest.fn(),
}));
jest.mock('./ExtensionSidebarProvider', () => ({
...jest.requireActual('./ExtensionSidebarProvider'),
useExtensionSidebarContext: jest.fn(),
}));
const mockUsePluginComponents = jest.mocked(usePluginComponents);
const mockUseExtensionSidebarContext = jest.mocked(useExtensionSidebarContext);
const MockComponent = () => <div data-testid="working-component">Mock Component</div>;
const pluginId = 'test-plugin';
const extensionPointId = 'grafana/extension-sidebar/v0-alpha';
MockComponent.meta = {
pluginId,
title: 'Test Component',
id: 'test-component-id',
type: PluginExtensionTypes.component,
description: 'Test Component',
};
const addedComponentConfigMock: ExtensionInfo = {
targets: extensionPointId,
title: 'Test Component',
};
const extensionSidebarContextMock: ExtensionSidebarContextType = {
dockedComponentId: getComponentIdFromComponentMeta(pluginId, addedComponentConfigMock),
isEnabled: true,
props: {},
isOpen: true,
setDockedComponentId: jest.fn(),
availableComponents: new Map(),
extensionSidebarWidth: 300,
setExtensionSidebarWidth: jest.fn(),
};
const addedComponentRegistryItemMock: AddedComponentRegistryItem = {
pluginId,
title: addedComponentConfigMock.title,
component: MockComponent,
};
describe('ExtensionSidebar', () => {
const originalEnv = config.buildInfo.env;
beforeEach(() => {
jest.clearAllMocks();
config.buildInfo.env = 'development';
});
afterEach(() => {
config.buildInfo.env = originalEnv;
});
it('should render nothing when the extension sidebar is not enabled', () => {
mockUseExtensionSidebarContext.mockReturnValue({
...extensionSidebarContextMock,
isEnabled: false,
});
mockUsePluginComponents.mockReturnValue({
components: [createComponentWithMeta(addedComponentRegistryItemMock, extensionPointId)],
isLoading: false,
});
const { container } = render(<ExtensionSidebar />);
expect(container.firstChild).toBeNull();
});
it('should render nothing when the extension sidebar is enabled but no component is docked', () => {
mockUseExtensionSidebarContext.mockReturnValue({
...extensionSidebarContextMock,
isEnabled: true,
dockedComponentId: undefined,
});
mockUsePluginComponents.mockReturnValue({
components: [createComponentWithMeta(addedComponentRegistryItemMock, extensionPointId)],
isLoading: false,
});
const { container } = render(<ExtensionSidebar />);
expect(container.firstChild).toBeNull();
});
it('should render nothing when the extension sidebar is enabled but the component docked is not found in the available components', () => {
mockUseExtensionSidebarContext.mockReturnValue({
...extensionSidebarContextMock,
isEnabled: true,
dockedComponentId: 'test-component-id-not-found',
});
mockUsePluginComponents.mockReturnValue({
components: [createComponentWithMeta(addedComponentRegistryItemMock, extensionPointId)],
isLoading: false,
});
const { container } = render(<ExtensionSidebar />);
expect(container.firstChild).toBeNull();
});
it('should render nothing when the extension sidebar is enabled but the component docked is not found in the available components', () => {
mockUseExtensionSidebarContext.mockReturnValue({
...extensionSidebarContextMock,
isEnabled: true,
dockedComponentId: 'test-component-id-not-found',
});
mockUsePluginComponents.mockReturnValue({
components: [createComponentWithMeta(addedComponentRegistryItemMock, extensionPointId)],
isLoading: false,
});
const { container } = render(<ExtensionSidebar />);
expect(container.firstChild).toBeNull();
});
it('should render nothing when components are loading', () => {
mockUseExtensionSidebarContext.mockReturnValue({
...extensionSidebarContextMock,
isEnabled: true,
});
mockUsePluginComponents.mockReturnValue({
components: [createComponentWithMeta(addedComponentRegistryItemMock, extensionPointId)],
isLoading: true,
});
const { container } = render(<ExtensionSidebar />);
expect(container.firstChild).toBeNull();
});
it('should render the component when all conditions are met', () => {
mockUseExtensionSidebarContext.mockReturnValue({
...extensionSidebarContextMock,
isEnabled: true,
});
mockUsePluginComponents.mockReturnValue({
components: [createComponentWithMeta(addedComponentRegistryItemMock, extensionPointId)],
isLoading: false,
});
const { container } = render(<ExtensionSidebar />);
expect(container.firstChild).toBeInTheDocument();
expect(screen.getByText('Mock Component')).toBeInTheDocument();
});
});
@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { usePluginComponents } from '@grafana/runtime';
import { ErrorBoundaryAlert, useTheme2 } from '@grafana/ui';
import { useTheme2 } from '@grafana/ui';
import {
EXTENSION_SIDEBAR_EXTENSION_POINT_ID,
@@ -45,9 +45,7 @@ export function ExtensionSidebar() {
return (
<div className={styles.sidebarWrapper}>
<div className={styles.content}>
<ErrorBoundaryAlert>
<ExtensionComponent {...props} />
</ErrorBoundaryAlert>
<ExtensionComponent {...props} />
</div>
</div>
);
@@ -18,7 +18,7 @@ const PERMITTED_EXTENSION_SIDEBAR_PLUGINS = [
'grafana-grafanadocsplugin-app',
];
type ExtensionSidebarContextType = {
export type ExtensionSidebarContextType = {
/**
* Whether the extension sidebar is enabled.
*/
@@ -16,7 +16,7 @@ import {
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config, locationSearchToObject } from '@grafana/runtime';
import { Alert } from '@grafana/ui';
import { Alert, ErrorWithStack } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
@@ -36,6 +36,7 @@ import { getPluginSettings } from '../pluginSettings';
import { importAppPlugin } from '../plugin_loader';
import { buildPluginSectionNav, pluginsLogger } from '../utils';
import { PluginErrorBoundary } from './PluginErrorBoundary';
import { buildPluginPageContext, PluginPageContext } from './PluginPageContext';
interface Props {
@@ -106,22 +107,32 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) {
const pluginRoot = plugin.root && (
<PluginContextProvider meta={plugin.meta}>
<ExtensionRegistriesProvider
registries={{
addedLinksRegistry: addedLinksRegistry.readOnly(),
addedComponentsRegistry: addedComponentsRegistry.readOnly(),
exposedComponentsRegistry: exposedComponentsRegistry.readOnly(),
addedFunctionsRegistry: addedFunctionsRegistry.readOnly(),
}}
<PluginErrorBoundary
fallback={({ error, errorInfo }) => (
<ErrorWithStack
title={t('plugins.app-root-page.error-loading-plugin', 'Plugin failed to load')}
error={error}
errorInfo={errorInfo}
/>
)}
>
<plugin.root
meta={plugin.meta}
basename={location.pathname}
onNavChanged={onNavChanged}
query={queryParams}
path={location.pathname}
/>
</ExtensionRegistriesProvider>
<ExtensionRegistriesProvider
registries={{
addedLinksRegistry: addedLinksRegistry.readOnly(),
addedComponentsRegistry: addedComponentsRegistry.readOnly(),
exposedComponentsRegistry: exposedComponentsRegistry.readOnly(),
addedFunctionsRegistry: addedFunctionsRegistry.readOnly(),
}}
>
<plugin.root
meta={plugin.meta}
basename={location.pathname}
onNavChanged={onNavChanged}
query={queryParams}
path={location.pathname}
/>
</ExtensionRegistriesProvider>
</PluginErrorBoundary>
</PluginContextProvider>
);
@@ -0,0 +1,184 @@
import { render, screen } from '@testing-library/react';
import * as React from 'react';
import { PluginMeta, PluginType, PluginContext } from '@grafana/data';
import { getMockPlugin } from '@grafana/data/test';
import { PluginErrorBoundary } from './PluginErrorBoundary';
const ThrowingComponent = ({ shouldThrow }: { shouldThrow: boolean }) => {
if (shouldThrow) {
throw new Error('Test error message');
}
return <div>Working component</div>;
};
const TestFallback = ({ error, errorInfo }: { error: Error | null; errorInfo: React.ErrorInfo | null }) => (
<div>
<div>Fallback rendered</div>
<div>Error: {error?.message}</div>
{errorInfo && <div>Error info available</div>}
</div>
);
const renderWithPluginContext = ({
children,
pluginMeta,
fallback,
onError,
}: {
children: React.ReactNode;
pluginMeta?: PluginMeta;
fallback?: React.ComponentType<{ error: Error | null; errorInfo: React.ErrorInfo | null }>;
onError?: (error: Error, info: React.ErrorInfo) => void;
}) => {
const mockPluginMeta = pluginMeta || getMockPlugin({ id: 'test-plugin', type: PluginType.panel });
return render(
<PluginContext.Provider value={{ meta: mockPluginMeta }}>
<PluginErrorBoundary fallback={fallback} onError={onError}>
{children}
</PluginErrorBoundary>
</PluginContext.Provider>
);
};
describe('PluginErrorBoundary', () => {
let consoleErrorSpy: jest.SpyInstance;
beforeEach(() => {
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it('should render children normally when no error occurs', () => {
renderWithPluginContext({ children: <ThrowingComponent shouldThrow={false} /> });
expect(screen.getByText('Working component')).toBeInTheDocument();
});
it('should render null when an error occurs and no fallback is provided', () => {
const { container } = renderWithPluginContext({ children: <ThrowingComponent shouldThrow={true} /> });
expect(container.firstChild).toBeNull();
});
it('should render custom fallback component when an error occurs', () => {
renderWithPluginContext({ children: <ThrowingComponent shouldThrow={true} />, fallback: TestFallback });
expect(screen.getByText('Fallback rendered')).toBeInTheDocument();
expect(screen.getByText('Error: Test error message')).toBeInTheDocument();
expect(screen.getByText('Error info available')).toBeInTheDocument();
});
it('should call onError callback when an error occurs', () => {
const onErrorMock = jest.fn();
renderWithPluginContext({ children: <ThrowingComponent shouldThrow={true} />, onError: onErrorMock });
expect(onErrorMock).toHaveBeenCalledTimes(1);
expect(onErrorMock).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Test error message' }),
expect.objectContaining({
componentStack: expect.any(String),
})
);
});
it('should log error to console with plugin ID when no onError callback is provided', () => {
const mockPluginMeta = getMockPlugin({ id: 'my-test-plugin', type: PluginType.datasource });
renderWithPluginContext({ children: <ThrowingComponent shouldThrow={true} />, pluginMeta: mockPluginMeta });
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Plugin "my-test-plugin" failed to load:',
expect.objectContaining({ message: 'Test error message' }),
expect.objectContaining({
componentStack: expect.any(String),
})
);
});
it('should handle error when plugin context is not available', () => {
render(
<PluginErrorBoundary>
<ThrowingComponent shouldThrow={true} />
</PluginErrorBoundary>
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Plugin "undefined" failed to load:',
expect.objectContaining({ message: 'Test error message' }),
expect.objectContaining({
componentStack: expect.any(String),
})
);
});
it('should update state correctly when error occurs', () => {
renderWithPluginContext({ children: <ThrowingComponent shouldThrow={true} />, fallback: TestFallback });
// Verify that both error and errorInfo are available in the fallback
expect(screen.getByText('Error: Test error message')).toBeInTheDocument();
expect(screen.getByText('Error info available')).toBeInTheDocument();
});
it('should reset error state when children change to non-throwing component', () => {
const { rerender } = renderWithPluginContext({
children: <ThrowingComponent shouldThrow={true} />,
fallback: TestFallback,
});
// Initially should show fallback
expect(screen.getByText('Fallback rendered')).toBeInTheDocument();
// Re-render with non-throwing component
rerender(
<PluginContext.Provider value={{ meta: getMockPlugin({ id: 'test-plugin', type: PluginType.panel }) }}>
<PluginErrorBoundary fallback={TestFallback}>
<ThrowingComponent shouldThrow={false} />
</PluginErrorBoundary>
</PluginContext.Provider>
);
// Should still show fallback since error boundary doesn't reset automatically
expect(screen.getByText('Fallback rendered')).toBeInTheDocument();
});
it('should handle multiple children correctly', () => {
renderWithPluginContext({
children: (
<>
<div>First child</div>
<ThrowingComponent shouldThrow={false} />
<div>Third child</div>
</>
),
});
expect(screen.getByText('First child')).toBeInTheDocument();
expect(screen.getByText('Working component')).toBeInTheDocument();
expect(screen.getByText('Third child')).toBeInTheDocument();
});
it('should handle error in one of multiple children', () => {
renderWithPluginContext({
children: (
<>
<div>First child</div>
<ThrowingComponent shouldThrow={true} />
<div>Third child</div>
</>
),
fallback: TestFallback,
});
// Should show fallback and not render any of the children
expect(screen.getByText('Fallback rendered')).toBeInTheDocument();
expect(screen.queryByText('First child')).not.toBeInTheDocument();
expect(screen.queryByText('Third child')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,49 @@
import * as React from 'react';
import { PluginContext } from '@grafana/data';
interface PluginErrorBoundaryProps {
children: React.ReactNode;
fallback?: React.ComponentType<{ error: Error | null; errorInfo: React.ErrorInfo | null }>;
onError?: (error: Error, info: React.ErrorInfo) => void;
}
interface PluginErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorInfo: React.ErrorInfo | null;
}
export class PluginErrorBoundary extends React.Component<PluginErrorBoundaryProps, PluginErrorBoundaryState> {
static contextType = PluginContext;
declare context: React.ContextType<typeof PluginContext>;
constructor(props: PluginErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error: Error): PluginErrorBoundaryState {
return { hasError: true, error: error, errorInfo: null };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
if (this.props.onError) {
this.props.onError(error, info);
} else {
console.error(`Plugin "${this.context?.meta.id}" failed to load:`, error, info);
}
this.setState({ error, errorInfo: info });
}
render() {
const Fallback = this.props.fallback;
if (this.state.hasError) {
return Fallback ? <Fallback error={this.state.error} errorInfo={this.state.errorInfo} /> : null;
}
return this.props.children;
}
}
@@ -0,0 +1,20 @@
import { t } from '@grafana/i18n';
import { Alert } from '@grafana/ui';
export const ExtensionErrorAlert = ({ pluginId, extensionTitle }: { pluginId: string; extensionTitle: string }) => {
return (
<Alert
title={t(
'plugins.extensions.extension-error-alert-title',
'Extension failed to load: "{{pluginId}}/{{extensionTitle}}"',
{
pluginId,
extensionTitle,
}
)}
severity="error"
>
{t('plugins.extensions.extension-error-alert-description', 'Check the console for more details on the error.')}
</Alert>
);
};
@@ -0,0 +1,42 @@
import * as React from 'react';
import { PluginErrorBoundary } from '../components/PluginErrorBoundary';
import { ExtensionErrorAlert } from './ExtensionErrorAlert';
import { ExtensionsLog, log as baseLog } from './logs/log';
import { isGrafanaDevMode } from './utils';
export const ExtensionErrorBoundary = ({
children,
pluginId,
extensionTitle,
log = baseLog,
fallbackAlwaysVisible = false,
}: {
children: React.ReactNode;
pluginId: string;
extensionTitle: string;
log?: ExtensionsLog;
fallbackAlwaysVisible?: boolean;
}) => {
return (
<PluginErrorBoundary
onError={(error, errorInfo) => {
log.error(`Extension "${pluginId}/${extensionTitle}" failed to load.`, {
message: error.message,
componentStack: errorInfo.componentStack ?? '',
digest: errorInfo.digest ?? '',
});
}}
fallback={() => {
if (isGrafanaDevMode() || fallbackAlwaysVisible) {
return <ExtensionErrorAlert pluginId={pluginId} extensionTitle={extensionTitle} />;
}
return null;
}}
>
{children}
</PluginErrorBoundary>
);
};
@@ -172,7 +172,12 @@ export const getPluginExtensions: GetExtensions = ({
pluginId: addedComponent.pluginId,
title: addedComponent.title,
description: addedComponent.description ?? '',
component: wrapWithPluginContext(addedComponent.pluginId, addedComponent.component, componentLog),
component: wrapWithPluginContext({
pluginId: addedComponent.pluginId,
extensionTitle: addedComponent.title,
Component: addedComponent.component,
log: componentLog,
}),
};
extensions.push(extension);
@@ -62,7 +62,12 @@ export class AddedComponentsRegistry extends Registry<
const result = {
pluginId,
component: wrapWithPluginContext(pluginId, config.component, pointIdLog),
component: wrapWithPluginContext({
pluginId,
extensionTitle: config.title,
Component: config.component,
log: pointIdLog,
}),
description: config.description,
title: config.title,
};
@@ -54,7 +54,12 @@ export function usePluginComponent<Props extends object = {}>(id: string): UsePl
return {
isLoading: false,
component: wrapWithPluginContext(registryItem.pluginId, registryItem.component, componentLog),
component: wrapWithPluginContext({
pluginId: registryItem.pluginId,
extensionTitle: registryItem.title,
Component: registryItem.component,
log: componentLog,
}),
};
}, [id, pluginContext, registryState, isLoadingAppPlugins]);
}
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import { type Unsubscribable } from 'rxjs';
import { dateTime, usePluginContext, PluginLoadingStrategy } from '@grafana/data';
@@ -30,6 +30,8 @@ jest.mock('app/features/plugins/pluginSettings', () => ({
}));
describe('Plugin Extensions / Utils', () => {
const originalEnv = config.buildInfo.env;
beforeEach(() => {
jest.spyOn(log, 'error').mockImplementation(() => {});
jest.spyOn(log, 'warning').mockImplementation(() => {});
@@ -38,6 +40,8 @@ describe('Plugin Extensions / Utils', () => {
afterEach(() => {
jest.resetAllMocks();
config.buildInfo.env = originalEnv;
});
describe('deepFreeze()', () => {
@@ -620,7 +624,11 @@ describe('Plugin Extensions / Utils', () => {
it('should open modal with provided title and body', async () => {
const pluginId = 'grafana-worldmap-panel';
const openModal = createOpenModalFunction(pluginId);
const openModal = createOpenModalFunction({
pluginId,
extensionPointId: 'myorg-extensions-app/link/v1',
title: 'Title in modal',
});
openModal({
title: 'Title in modal',
@@ -634,7 +642,11 @@ describe('Plugin Extensions / Utils', () => {
it('should open modal with default width if not specified', async () => {
const pluginId = 'grafana-worldmap-panel';
const openModal = createOpenModalFunction(pluginId);
const openModal = createOpenModalFunction({
pluginId,
extensionPointId: 'myorg-extensions-app/link/v1',
title: 'Title in modal',
});
openModal({
title: 'Title in modal',
@@ -650,7 +662,11 @@ describe('Plugin Extensions / Utils', () => {
it('should open modal with specified width', async () => {
const pluginId = 'grafana-worldmap-panel';
const openModal = createOpenModalFunction(pluginId);
const openModal = createOpenModalFunction({
pluginId,
extensionPointId: 'myorg-extensions-app/link/v1',
title: 'Title in modal',
});
openModal({
title: 'Title in modal',
@@ -666,7 +682,11 @@ describe('Plugin Extensions / Utils', () => {
it('should open modal with specified height', async () => {
const pluginId = 'grafana-worldmap-panel';
const openModal = createOpenModalFunction(pluginId);
const openModal = createOpenModalFunction({
pluginId,
extensionPointId: 'myorg-extensions-app/link/v1',
title: 'Title in modal',
});
openModal({
title: 'Title in modal',
@@ -682,7 +702,11 @@ describe('Plugin Extensions / Utils', () => {
it('should open modal with the plugin context being available', async () => {
const pluginId = 'grafana-worldmap-panel';
const openModal = createOpenModalFunction(pluginId);
const openModal = createOpenModalFunction({
pluginId,
extensionPointId: 'myorg-extensions-app/link/v1',
title: 'Title in modal',
});
const ModalContent = () => {
const context = usePluginContext();
@@ -698,6 +722,72 @@ describe('Plugin Extensions / Utils', () => {
const modal = await screen.findByRole('dialog');
expect(modal).toHaveTextContent('Version: 1.0.0');
});
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(() => {});
const pluginId = 'grafana-worldmap-panel';
const extensionTitle = 'Title in modal';
const openModal = createOpenModalFunction({
pluginId,
extensionPointId: 'myorg-extensions-app/link/v1',
title: extensionTitle,
});
const ModalContent = () => {
throw new Error('Test error');
};
openModal({
title: extensionTitle,
body: ModalContent,
});
await screen.findByRole('dialog');
expect(log.error).toHaveBeenCalledTimes(1);
expect(log.error).toHaveBeenCalledWith(`Extension "${pluginId}/${extensionTitle}" failed to load.`, {
message: 'Test error',
componentStack: expect.any(String),
digest: expect.any(String),
});
expect(screen.getByText(`Extension failed to load: "${pluginId}/${extensionTitle}"`)).toBeVisible();
});
it('should also show an error alert in the modal IN PRODUCTION MODE if the extension throws an error', async () => {
config.buildInfo.env = 'production';
jest.spyOn(console, 'error').mockImplementation(() => {});
const pluginId = 'grafana-worldmap-panel';
const extensionTitle = 'Title in modal';
const openModal = createOpenModalFunction({
pluginId,
extensionPointId: 'myorg-extensions-app/link/v1',
title: extensionTitle,
});
const ModalContent = () => {
throw new Error('Test error');
};
openModal({
title: extensionTitle,
body: ModalContent,
});
await screen.findByRole('dialog');
expect(log.error).toHaveBeenCalledTimes(1);
expect(log.error).toHaveBeenCalledWith(`Extension "${pluginId}/${extensionTitle}" failed to load.`, {
message: 'Test error',
componentStack: expect.any(String),
digest: expect.any(String),
});
expect(screen.getByText(`Extension failed to load: "${pluginId}/${extensionTitle}"`)).toBeVisible();
});
});
describe('wrapWithPluginContext()', () => {
@@ -728,7 +818,12 @@ describe('Plugin Extensions / Utils', () => {
it('should make the plugin context available for the wrapped component', async () => {
const pluginId = 'grafana-worldmap-panel';
const Component = wrapWithPluginContext(pluginId, ExampleComponent, log);
const Component = wrapWithPluginContext({
pluginId,
extensionTitle: 'ExampleComponent',
Component: ExampleComponent,
log,
});
render(<Component a={{ b: { c: 'Grafana' } }} />);
@@ -738,7 +833,12 @@ describe('Plugin Extensions / Utils', () => {
it('should pass the properties into the wrapped component', async () => {
const pluginId = 'grafana-worldmap-panel';
const Component = wrapWithPluginContext(pluginId, ExampleComponent, log);
const Component = wrapWithPluginContext({
pluginId,
extensionTitle: 'ExampleComponent',
Component: ExampleComponent,
log,
});
render(<Component a={{ b: { c: 'Grafana' } }} />);
@@ -749,7 +849,12 @@ describe('Plugin Extensions / Utils', () => {
it('should not be possible to mutate the props in development mode, but it logs an error', async () => {
config.buildInfo.env = 'development';
const pluginId = 'grafana-worldmap-panel';
const Component = wrapWithPluginContext(pluginId, ExampleComponent, log);
const Component = wrapWithPluginContext({
pluginId,
extensionTitle: 'ExampleComponent',
Component: ExampleComponent,
log,
});
const props = { a: { b: { c: 'Grafana' } } };
render(<Component {...props} override />);
@@ -772,7 +877,12 @@ describe('Plugin Extensions / Utils', () => {
it('should not be possible to mutate the props in production mode either, but it logs a warning', async () => {
config.buildInfo.env = 'production';
const pluginId = 'grafana-worldmap-panel';
const Component = wrapWithPluginContext(pluginId, ExampleComponent, log);
const Component = wrapWithPluginContext({
pluginId,
extensionTitle: 'ExampleComponent',
Component: ExampleComponent,
log,
});
const props = { a: { b: { c: 'Grafana' } } };
render(<Component {...props} override />);
@@ -791,6 +901,64 @@ describe('Plugin Extensions / Utils', () => {
// Not able to mutate the props in production mode either
expect(props.a.b.c).toBe('Grafana');
});
it('should render an error alert IN DEV MODE if the extension throws an error', async () => {
config.buildInfo.env = 'development';
jest.spyOn(console, 'error').mockImplementation(() => {});
const pluginId = 'grafana-worldmap-panel';
const ComponentWithError = () => {
throw new Error('Test error');
};
const extensionTitle = 'ComponentWithError';
const WrappedComponent = wrapWithPluginContext({
pluginId,
extensionTitle,
Component: ComponentWithError,
log,
});
render(<WrappedComponent />);
expect(await screen.findByText(`Extension failed to load: "${pluginId}/${extensionTitle}"`)).toBeVisible();
expect(log.error).toHaveBeenCalledTimes(1);
expect(log.error).toHaveBeenCalledWith(`Extension "${pluginId}/${extensionTitle}" failed to load.`, {
message: 'Test error',
componentStack: expect.any(String),
digest: expect.any(String),
});
});
it('should not render anything IN PRODUCTION MODE if the extension throws an error, but still logs an error', async () => {
config.buildInfo.env = 'production';
jest.spyOn(console, 'error').mockImplementation(() => {});
const pluginId = 'grafana-worldmap-panel';
const ComponentWithError = () => {
throw new Error('Test error');
};
const extensionTitle = 'ComponentWithError';
const WrappedComponent = wrapWithPluginContext({
pluginId,
extensionTitle,
Component: ComponentWithError,
log,
});
render(<WrappedComponent />);
await waitFor(() =>
expect(screen.queryByText(`Extension failed to load: "${pluginId}/${extensionTitle}"`)).not.toBeInTheDocument()
);
expect(log.error).toHaveBeenCalledTimes(1);
expect(log.error).toHaveBeenCalledWith(`Extension "${pluginId}/${extensionTitle}" failed to load.`, {
message: 'Test error',
componentStack: expect.any(String),
digest: expect.any(String),
});
});
});
describe('getAppPluginConfigs()', () => {
@@ -22,6 +22,7 @@ import appEvents from 'app/core/app_events';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
import { OpenExtensionSidebarEvent, ShowModalReactEvent } from 'app/types/events';
import { ExtensionErrorBoundary } from './ExtensionErrorBoundary';
import { ExtensionsLog, log as baseLog } from './logs/log';
import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry';
import { assertIsNotPromise, assertLinkPathIsValid, assertStringProps, isPromise } from './validators';
@@ -38,17 +39,18 @@ export function handleErrorsInFn(fn: Function, errorMessagePrefix = '') {
};
}
export function createOpenModalFunction(pluginId: string): PluginExtensionEventHelpers['openModal'] {
export function createOpenModalFunction(config: AddedLinkRegistryItem): PluginExtensionEventHelpers['openModal'] {
return async (options) => {
const { title, body, width, height } = options;
appEvents.publish(
new ShowModalReactEvent({
component: wrapWithPluginContext<ModalWrapperProps>(
pluginId,
getModalWrapper({ title, body, width, height }),
baseLog
),
component: wrapWithPluginContext<ModalWrapperProps>({
pluginId: config.pluginId,
extensionTitle: config.title,
Component: getModalWrapper({ title, body, width, height, config }),
log: baseLog,
}),
})
);
};
@@ -58,7 +60,17 @@ type ModalWrapperProps = {
onDismiss: () => void;
};
export const wrapWithPluginContext = <T,>(pluginId: string, Component: React.ComponentType<T>, log: ExtensionsLog) => {
export const wrapWithPluginContext = <T,>({
pluginId,
extensionTitle,
Component,
log,
}: {
pluginId: string;
extensionTitle: string;
Component: React.ComponentType<T>;
log: ExtensionsLog;
}) => {
const WrappedExtensionComponent = (props: T & React.JSX.IntrinsicAttributes) => {
const {
error,
@@ -85,7 +97,9 @@ export const wrapWithPluginContext = <T,>(pluginId: string, Component: React.Com
return (
<PluginContextProvider meta={pluginMeta}>
<Component {...writableProxy(props, { log, source: 'extension', pluginId })} />
<ExtensionErrorBoundary pluginId={pluginId} extensionTitle={extensionTitle} log={log}>
<Component {...writableProxy(props, { log, source: 'extension', pluginId })} />
</ExtensionErrorBoundary>
</PluginContextProvider>
);
};
@@ -102,13 +116,25 @@ const getModalWrapper = ({
body: Body,
width,
height,
}: PluginExtensionOpenModalOptions) => {
config,
}: PluginExtensionOpenModalOptions & { config: AddedLinkRegistryItem }) => {
const className = css({ width, height });
const ModalWrapper = ({ onDismiss }: ModalWrapperProps) => {
return (
<Modal title={title} className={className} isOpen onDismiss={onDismiss} onClickBackdrop={onDismiss}>
<Body onDismiss={onDismiss} />
{/*
We also add an error boundary here (apart from the one in the `wrapWithPluginContext`)
so the error appears inside the modal (and not at the bottom of the page.)
*/}
<ExtensionErrorBoundary
pluginId={config.pluginId}
extensionTitle={config.title}
fallbackAlwaysVisible={true}
log={baseLog}
>
<Body onDismiss={onDismiss} />
</ExtensionErrorBoundary>
</Modal>
);
};
@@ -491,7 +517,7 @@ export function getLinkExtensionOnClick(
const helpers: PluginExtensionEventHelpers = {
context,
openModal: createOpenModalFunction(pluginId),
openModal: createOpenModalFunction(config),
openSidebar: (componentTitle, context) => {
appEvents.publish(
new OpenExtensionSidebarEvent({
+5
View File
@@ -9825,6 +9825,7 @@
"permission": "You do not have permission to see this page.",
"title-access-denied": "Access denied"
},
"error-loading-plugin": "Plugin failed to load",
"no-root-app-page-component-found": "No root app page component found"
},
"browse": {
@@ -9923,6 +9924,10 @@
"empty-state": {
"message": "No plugins found"
},
"extensions": {
"extension-error-alert-description": "Check the console for more details on the error.",
"extension-error-alert-title": "Extension failed to load: \"{{pluginId}}/{{extensionTitle}}\""
},
"extensions-log-data-source": {
"message": {
"ok": "OK"