diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index dde87b84498..80351af8bc2 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -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, diff --git a/public/app/core/components/AppChrome/AppChromeExtensionPoint.tsx b/public/app/core/components/AppChrome/AppChromeExtensionPoint.tsx index 9c1f0330ac7..16d651e72bd 100644 --- a/public/app/core/components/AppChrome/AppChromeExtensionPoint.tsx +++ b/public/app/core/components/AppChrome/AppChromeExtensionPoint.tsx @@ -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 = { '/login': true, @@ -24,13 +23,10 @@ export function AppChromeExtensionPoint(): JSX.Element | null { return null; } - return ( - - - - ); + return ; } +// 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, diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.test.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.test.tsx new file mode 100644 index 00000000000..0582e5778fe --- /dev/null +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.test.tsx @@ -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 = () =>
Mock Component
; +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(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(container.firstChild).toBeInTheDocument(); + expect(screen.getByText('Mock Component')).toBeInTheDocument(); + }); +}); diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.tsx index 5f1e1b826ba..89cb4b3db7c 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebar.tsx @@ -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 (
- - - +
); diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx index 63b4812955e..8693b71ed04 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx @@ -18,7 +18,7 @@ const PERMITTED_EXTENSION_SIDEBAR_PLUGINS = [ 'grafana-grafanadocsplugin-app', ]; -type ExtensionSidebarContextType = { +export type ExtensionSidebarContextType = { /** * Whether the extension sidebar is enabled. */ diff --git a/public/app/features/plugins/components/AppRootPage.tsx b/public/app/features/plugins/components/AppRootPage.tsx index 3bb8d1c3169..c6cdc47e810 100644 --- a/public/app/features/plugins/components/AppRootPage.tsx +++ b/public/app/features/plugins/components/AppRootPage.tsx @@ -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 && ( - ( + + )} > - - + + + + ); diff --git a/public/app/features/plugins/components/PluginErrorBoundary.test.tsx b/public/app/features/plugins/components/PluginErrorBoundary.test.tsx new file mode 100644 index 00000000000..377e508a33d --- /dev/null +++ b/public/app/features/plugins/components/PluginErrorBoundary.test.tsx @@ -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
Working component
; +}; + +const TestFallback = ({ error, errorInfo }: { error: Error | null; errorInfo: React.ErrorInfo | null }) => ( +
+
Fallback rendered
+
Error: {error?.message}
+ {errorInfo &&
Error info available
} +
+); + +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( + + + {children} + + + ); +}; + +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: }); + + expect(screen.getByText('Working component')).toBeInTheDocument(); + }); + + it('should render null when an error occurs and no fallback is provided', () => { + const { container } = renderWithPluginContext({ children: }); + + expect(container.firstChild).toBeNull(); + }); + + it('should render custom fallback component when an error occurs', () => { + renderWithPluginContext({ children: , 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: , 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: , 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( + + + + ); + + 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: , 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: , + fallback: TestFallback, + }); + + // Initially should show fallback + expect(screen.getByText('Fallback rendered')).toBeInTheDocument(); + + // Re-render with non-throwing component + rerender( + + + + + + ); + + // 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: ( + <> +
First child
+ +
Third child
+ + ), + }); + + 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: ( + <> +
First child
+ +
Third child
+ + ), + 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(); + }); +}); diff --git a/public/app/features/plugins/components/PluginErrorBoundary.tsx b/public/app/features/plugins/components/PluginErrorBoundary.tsx new file mode 100644 index 00000000000..6495acfeaf3 --- /dev/null +++ b/public/app/features/plugins/components/PluginErrorBoundary.tsx @@ -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 { + static contextType = PluginContext; + + declare context: React.ContextType; + + 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 ? : null; + } + + return this.props.children; + } +} diff --git a/public/app/features/plugins/extensions/ExtensionErrorAlert.tsx b/public/app/features/plugins/extensions/ExtensionErrorAlert.tsx new file mode 100644 index 00000000000..41b1b021d34 --- /dev/null +++ b/public/app/features/plugins/extensions/ExtensionErrorAlert.tsx @@ -0,0 +1,20 @@ +import { t } from '@grafana/i18n'; +import { Alert } from '@grafana/ui'; + +export const ExtensionErrorAlert = ({ pluginId, extensionTitle }: { pluginId: string; extensionTitle: string }) => { + return ( + + {t('plugins.extensions.extension-error-alert-description', 'Check the console for more details on the error.')} + + ); +}; diff --git a/public/app/features/plugins/extensions/ExtensionErrorBoundary.tsx b/public/app/features/plugins/extensions/ExtensionErrorBoundary.tsx new file mode 100644 index 00000000000..5d85e16eee1 --- /dev/null +++ b/public/app/features/plugins/extensions/ExtensionErrorBoundary.tsx @@ -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 ( + { + log.error(`Extension "${pluginId}/${extensionTitle}" failed to load.`, { + message: error.message, + componentStack: errorInfo.componentStack ?? '', + digest: errorInfo.digest ?? '', + }); + }} + fallback={() => { + if (isGrafanaDevMode() || fallbackAlwaysVisible) { + return ; + } + + return null; + }} + > + {children} + + ); +}; diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index aed7866e9b2..5968bb6fc42 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -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); diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts index d4b8b0b04f5..de732ce8c6d 100644 --- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts +++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts @@ -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, }; diff --git a/public/app/features/plugins/extensions/usePluginComponent.tsx b/public/app/features/plugins/extensions/usePluginComponent.tsx index 5e079beea93..7a2d3669b24 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.tsx @@ -54,7 +54,12 @@ export function usePluginComponent(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]); } diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx index 2755d98d064..e3b74d8cd1a 100644 --- a/public/app/features/plugins/extensions/utils.test.tsx +++ b/public/app/features/plugins/extensions/utils.test.tsx @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); + + 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(); + + 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()', () => { diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index ec994d81383..23d21c11058 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -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( - pluginId, - getModalWrapper({ title, body, width, height }), - baseLog - ), + component: wrapWithPluginContext({ + 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 = (pluginId: string, Component: React.ComponentType, log: ExtensionsLog) => { +export const wrapWithPluginContext = ({ + pluginId, + extensionTitle, + Component, + log, +}: { + pluginId: string; + extensionTitle: string; + Component: React.ComponentType; + log: ExtensionsLog; +}) => { const WrappedExtensionComponent = (props: T & React.JSX.IntrinsicAttributes) => { const { error, @@ -85,7 +97,9 @@ export const wrapWithPluginContext = (pluginId: string, Component: React.Com return ( - + + + ); }; @@ -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 ( - + {/* + 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.) + */} + + + ); }; @@ -491,7 +517,7 @@ export function getLinkExtensionOnClick( const helpers: PluginExtensionEventHelpers = { context, - openModal: createOpenModalFunction(pluginId), + openModal: createOpenModalFunction(config), openSidebar: (componentTitle, context) => { appEvents.publish( new OpenExtensionSidebarEvent({ diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 4ee07cfc845..226dc40018d 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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"