Skip to content

Commit

Permalink
implement "soft" error message
Browse files Browse the repository at this point in the history
  • Loading branch information
tsullivan committed Oct 13, 2023
1 parent 8ae90f5 commit 83bb621
Show file tree
Hide file tree
Showing 4 changed files with 120 additions and 21 deletions.
44 changes: 38 additions & 6 deletions packages/shared-ux/error_boundary/src/services/error_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<React.ErrorInfo> | null) {
private getErrorComponentName(errorInfo: Partial<React.ErrorInfo> | null) {
let errorComponentName: string | null = null;
const stackLines = errorInfo?.componentStack?.split('\n');
const errorIndicator = /^ at (\S+).*/;
Expand All @@ -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<React.ErrorInfo> | null
): ErrorServiceError {
const isFatal = this.getIsFatal(error);
const name = this.getErrorComponentName(errorInfo);

return {
error,
errorInfo,
isFatal,
name,
};
}
}

interface ErrorServiceError {
error: Error;
errorInfo?: Partial<React.ErrorInfo> | null;
name: string | null;
isFatal: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const Template: FC = ({ children }) => {
);
};

export const ErrorInCallout: Story = () => {
export const FatalErrorInCallout: Story = () => {
const BadComponent = () => {
const [hasError, setHasError] = useState(false);

Expand Down Expand Up @@ -68,3 +68,37 @@ export const ErrorInCallout: Story = () => {
</Template>
);
};

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 <EuiButton onClick={handleClick}>Click for error</EuiButton>;
};

const services = storybookMock.getServices();

return (
<Template>
<ErrorBoundaryProvider {...services}>
<ErrorBoundary>
<BadComponent />
</ErrorBoundary>
</ErrorBoundaryProvider>
</Template>
);
};
43 changes: 29 additions & 14 deletions packages/shared-ux/error_boundary/src/ui/error_boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<React.ErrorInfo>;
componentName: null | string;
isFatal: null | boolean;
}

interface ErrorBoundaryProps {
Expand All @@ -29,30 +31,43 @@ class ErrorBoundaryInternal extends React.Component<
this.state = {
error: null,
errorInfo: null,
componentName: null,
isFatal: null,
};
}

componentDidCatch(error: Error, errorInfo: Partial<React.ErrorInfo>) {
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 (
<ErrorCallout
error={error}
errorInfo={errorInfo}
name={errorComponentName}
reloadWindow={this.props.reloadWindow}
/>
);
if (isFatal === false) {
// display error message in a "soft" container
return (
<RefresherPrompt
error={error}
errorInfo={errorInfo}
name={componentName}
reloadWindow={this.props.reloadWindow}
/>
);
} else {
// display error message in a "loud" container
return (
<ErrorCallout
error={error}
errorInfo={errorInfo}
name={componentName}
reloadWindow={this.props.reloadWindow}
/>
);
}
}

// not in error state
Expand Down
18 changes: 18 additions & 0 deletions packages/shared-ux/error_boundary/src/ui/error_messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
EuiCallOut,
EuiCode,
EuiCodeBlock,
EuiEmptyPrompt,
EuiPanel,
EuiSpacer,
useGeneratedHtmlId,
Expand Down Expand Up @@ -54,3 +55,20 @@ export const ErrorCallout = (props: ErrorCalloutProps) => {
</EuiCallOut>
);
};

export const RefresherPrompt = (props: ErrorCalloutProps) => {
const { reloadWindow } = props;
return (
<EuiEmptyPrompt
iconType="broom"
title={<h2>Sorry, please refresh</h2>}
body={<p>An error occurred when trying to load a part of the page. Please try refreshing.</p>}
color="primary"
actions={
<EuiButton fill={true} onClick={reloadWindow}>
Refresh
</EuiButton>
}
/>
);
};

0 comments on commit 83bb621

Please sign in to comment.