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:
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user