From 83bb6215f1c0969918afd310404fce170ff6f582 Mon Sep 17 00:00:00 2001 From: Timothy Sullivan Date: Fri, 13 Oct 2023 13:38:41 -0700 Subject: [PATCH] implement "soft" error message --- .../src/services/error_service.ts | 44 ++++++++++++++++--- .../src/ui/error_boundary.stories.tsx | 36 ++++++++++++++- .../error_boundary/src/ui/error_boundary.tsx | 43 ++++++++++++------ .../error_boundary/src/ui/error_messages.tsx | 18 ++++++++ 4 files changed, 120 insertions(+), 21 deletions(-) diff --git a/packages/shared-ux/error_boundary/src/services/error_service.ts b/packages/shared-ux/error_boundary/src/services/error_service.ts index 53086bd59a3dc..e9d9b3fdfe8ec 100644 --- a/packages/shared-ux/error_boundary/src/services/error_service.ts +++ b/packages/shared-ux/error_boundary/src/services/error_service.ts @@ -8,11 +8,27 @@ import React from 'react'; +const MATCH_CHUNK_LOADERROR = /ChunkLoadError/; + +/** + * Kibana Error Boundary Services: Error Service + * Each Error Boundary tracks an instance of this class + * @internal + */ export class ErrorService { + /** + * Determines if the error fallback UI should appear as an apologetic but promising "Refresh" button, + * or treated with "danger" coloring and include a detailed error message. + */ + private getIsFatal(error: Error) { + const isChunkLoadError = MATCH_CHUNK_LOADERROR.test(error.name); + return !isChunkLoadError; // "ChunkLoadError" is recoverable by refreshing the page + } + /** * Derive the name of the component that threw the error */ - public getErrorComponentName(errorInfo: Partial | null) { + private getErrorComponentName(errorInfo: Partial | null) { let errorComponentName: string | null = null; const stackLines = errorInfo?.componentStack?.split('\n'); const errorIndicator = /^ at (\S+).*/; @@ -32,12 +48,28 @@ export class ErrorService { } } - return { errorComponentName }; + return errorComponentName; } - onError(error: Error) { - // TODO: determine if error is fatal - // TODO: track event for telemetry - error = error; + public registerError( + error: Error, + errorInfo: Partial | null + ): ErrorServiceError { + const isFatal = this.getIsFatal(error); + const name = this.getErrorComponentName(errorInfo); + + return { + error, + errorInfo, + isFatal, + name, + }; } } + +interface ErrorServiceError { + error: Error; + errorInfo?: Partial | null; + name: string | null; + isFatal: boolean; +} diff --git a/packages/shared-ux/error_boundary/src/ui/error_boundary.stories.tsx b/packages/shared-ux/error_boundary/src/ui/error_boundary.stories.tsx index 3809849866f17..0244c82399614 100644 --- a/packages/shared-ux/error_boundary/src/ui/error_boundary.stories.tsx +++ b/packages/shared-ux/error_boundary/src/ui/error_boundary.stories.tsx @@ -39,7 +39,7 @@ const Template: FC = ({ children }) => { ); }; -export const ErrorInCallout: Story = () => { +export const FatalErrorInCallout: Story = () => { const BadComponent = () => { const [hasError, setHasError] = useState(false); @@ -68,3 +68,37 @@ export const ErrorInCallout: Story = () => { ); }; + +export const RecoverableErrorInCallout: Story = () => { + const BadComponent = () => { + const [hasError, setHasError] = useState(false); + + if (hasError) { + const error = new Error(); + error.name = 'ChunkLoadError'; + error.message = 'Loading chunk 1234 failed.\\n'; + error.name = 'ChunkLoadError'; + throw error; + } + + const clickedForError = action('clicked for error'); + const handleClick = () => { + clickedForError(); + setHasError(true); + }; + + return Click for error; + }; + + const services = storybookMock.getServices(); + + return ( + + ); +}; diff --git a/packages/shared-ux/error_boundary/src/ui/error_boundary.tsx b/packages/shared-ux/error_boundary/src/ui/error_boundary.tsx index 671919c3964db..9b02a6df6c118 100644 --- a/packages/shared-ux/error_boundary/src/ui/error_boundary.tsx +++ b/packages/shared-ux/error_boundary/src/ui/error_boundary.tsx @@ -9,11 +9,13 @@ import React from 'react'; import { ErrorBoundaryServices } from '../../types'; import { useErrorBoundary } from '../services/error_boundary_services'; -import { ErrorCallout } from './error_messages'; +import { ErrorCallout, RefresherPrompt } from './error_messages'; interface ErrorBoundaryState { error: null | Error; errorInfo: null | Partial; + componentName: null | string; + isFatal: null | boolean; } interface ErrorBoundaryProps { @@ -29,30 +31,43 @@ class ErrorBoundaryInternal extends React.Component< this.state = { error: null, errorInfo: null, + componentName: null, + isFatal: null, }; } componentDidCatch(error: Error, errorInfo: Partial) { - this.props.errorService.onError(error); + const { name, isFatal } = this.props.errorService.registerError(error, errorInfo); this.setState(() => { - return { error, errorInfo }; + return { error, errorInfo, componentName: name, isFatal }; }); } render() { if (this.state.error != null) { - const { error, errorInfo } = this.state; - const { errorComponentName } = this.props.errorService.getErrorComponentName(errorInfo); + const { error, errorInfo, componentName, isFatal } = this.state; - // display error message in a "loud" container - return ( - - ); + if (isFatal === false) { + // display error message in a "soft" container + return ( + + ); + } else { + // display error message in a "loud" container + return ( + + ); + } } // not in error state diff --git a/packages/shared-ux/error_boundary/src/ui/error_messages.tsx b/packages/shared-ux/error_boundary/src/ui/error_messages.tsx index aee60d9247468..96d37cf053465 100644 --- a/packages/shared-ux/error_boundary/src/ui/error_messages.tsx +++ b/packages/shared-ux/error_boundary/src/ui/error_messages.tsx @@ -14,6 +14,7 @@ import { EuiCallOut, EuiCode, EuiCodeBlock, + EuiEmptyPrompt, EuiPanel, EuiSpacer, useGeneratedHtmlId, @@ -54,3 +55,20 @@ export const ErrorCallout = (props: ErrorCalloutProps) => { ); }; + +export const RefresherPrompt = (props: ErrorCalloutProps) => { + const { reloadWindow } = props; + return ( + Sorry, please refresh} + body={

An error occurred when trying to load a part of the page. Please try refreshing.

} + color="primary" + actions={ + + Refresh + + } + /> + ); +};