Add generic error boundary component (#48363)

* Fix storybook

* Add experimental error boundary component

* Add error boundary component

* Fix empty content button

* Add changelog

* Fix storybook

* Reset state after actioned

* Fix story

* Address feedback
This commit is contained in:
Chi-Hsuan Huang 2024-06-13 13:05:28 +08:00 committed by GitHub
parent 4112c97d13
commit 54321a5a60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1094 additions and 172 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add error boundary component and fix empty content button

View File

@ -45,12 +45,13 @@ class EmptyContent extends Component {
: this.props.actionCallback; : this.props.actionCallback;
const isPrimary = type === 'secondary' ? false : true; const isPrimary = type === 'secondary' ? false : true;
const buttonVariant = isPrimary ? 'primary' : 'secondary';
if ( actionURL && actionCallback ) { if ( actionURL && actionCallback ) {
return ( return (
<Button <Button
className="woocommerce-empty-content__action" className="woocommerce-empty-content__action"
isPrimary={ isPrimary } variant={ buttonVariant }
onClick={ actionCallback } onClick={ actionCallback }
href={ actionURL } href={ actionURL }
> >
@ -61,7 +62,7 @@ class EmptyContent extends Component {
return ( return (
<Button <Button
className="woocommerce-empty-content__action" className="woocommerce-empty-content__action"
isPrimary={ isPrimary } variant={ buttonVariant }
href={ actionURL } href={ actionURL }
> >
{ actionLabel } { actionLabel }
@ -71,7 +72,7 @@ class EmptyContent extends Component {
return ( return (
<Button <Button
className="woocommerce-empty-content__action" className="woocommerce-empty-content__action"
isPrimary={ isPrimary } variant={ buttonVariant }
onClick={ actionCallback } onClick={ actionCallback }
> >
{ actionLabel } { actionLabel }

View File

@ -0,0 +1,2 @@
export const alertIcon =
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTguNTc0NjUgMy4yMTYzNUwxLjUxNjMyIDE0Ljk5OTdDMS4zNzA3OSAxNS4yNTE3IDEuMjkzNzkgMTUuNTM3NCAxLjI5Mjk4IDE1LjgyODRDMS4yOTIxNiAxNi4xMTk1IDEuMzY3NTYgMTYuNDA1NiAxLjUxMTY3IDE2LjY1ODVDMS42NTU3OSAxNi45MTEzIDEuODYzNTkgMTcuMTIyIDIuMTE0NDEgMTcuMjY5NkMyLjM2NTIzIDE3LjQxNzEgMi42NTAzMiAxNy40OTY1IDIuOTQxMzIgMTcuNDk5N0gxNy4wNThDMTcuMzQ5IDE3LjQ5NjUgMTcuNjM0MSAxNy40MTcxIDE3Ljg4NDkgMTcuMjY5NkMxOC4xMzU3IDE3LjEyMiAxOC4zNDM1IDE2LjkxMTMgMTguNDg3NiAxNi42NTg1QzE4LjYzMTcgMTYuNDA1NiAxOC43MDcxIDE2LjExOTUgMTguNzA2MyAxNS44Mjg0QzE4LjcwNTUgMTUuNTM3NCAxOC42Mjg1IDE1LjI1MTcgMTguNDgzIDE0Ljk5OTdMMTEuNDI0NyAzLjIxNjM1QzExLjI3NjEgMi45NzE0NCAxMS4wNjY5IDIuNzY4OTUgMTAuODE3MyAyLjYyODQyQzEwLjU2NzcgMi40ODc4OSAxMC4yODYxIDIuNDE0MDYgOS45OTk2NSAyLjQxNDA2QzkuNzEzMjEgMi40MTQwNiA5LjQzMTU5IDIuNDg3ODkgOS4xODE5OSAyLjYyODQyQzguOTMyMzggMi43Njg5NSA4LjcyMzIxIDIuOTcxNDQgOC41NzQ2NSAzLjIxNjM1VjMuMjE2MzVaIiBzdHJva2U9IiMxZTFlMWUiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+CjxwYXRoIGQ9Ik0xMCA3LjVWMTAuODMzMyIgc3Ryb2tlPSIjMWUxZTFlIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8cGF0aCBkPSJNMTAgMTQuMTY4SDEwLjAwODMiIHN0cm9rZT0iIzFlMWUxZSIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KPC9zdmc+Cg==';

View File

@ -0,0 +1,137 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { Component, ReactNode, ErrorInfo } from 'react';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import EmptyContent from '../empty-content';
import { alertIcon } from './constants';
export type ErrorBoundaryProps = {
/**
* The content to be rendered inside the ErrorBoundary component.
*/
children: ReactNode;
/**
* The custom error message to be displayed. Defaults to a generic message.
*/
errorMessage?: ReactNode;
/**
* Determines whether to show an action button. Defaults to true.
*/
showActionButton?: boolean;
/**
* The label to be used for the action button. Defaults to 'Reload'.
*/
actionLabel?: string;
/**
* The callback function to be executed when the action button is clicked. Defaults to window.location.reload.
*
* @param error - The error that was caught.
*/
actionCallback?: ( error: Error ) => void;
/**
* Determines whether to reset the error boundary state after the action is performed. Defaults to true.
*/
resetErrorAfterAction?: boolean;
/**
* Callback function to be executed when an error is caught.
*
* @param error - The error that was caught.
* @param errorInfo - The error info object.
*/
onError?: ( error: Error, errorInfo: ErrorInfo ) => void;
};
type ErrorBoundaryState = {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
};
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
static defaultProps: Partial< ErrorBoundaryProps > = {
showActionButton: true,
resetErrorAfterAction: true,
};
constructor( props: ErrorBoundaryProps ) {
super( props );
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(
error: Error
): Partial< ErrorBoundaryState > {
return { hasError: true, error };
}
componentDidCatch( _error: Error, errorInfo: ErrorInfo ) {
this.setState( { errorInfo } );
if ( this.props.onError ) {
this.props.onError( _error, errorInfo );
}
// TODO: Log error to error tracking service
}
handleReload = () => {
window.location.reload();
};
handleAction = () => {
const { actionCallback, resetErrorAfterAction } = this.props;
if ( actionCallback ) {
actionCallback( this.state.error as Error );
} else {
this.handleReload();
}
if ( resetErrorAfterAction ) {
this.setState( { hasError: false, error: null, errorInfo: null } );
}
};
render() {
const { children, errorMessage, showActionButton, actionLabel } =
this.props;
if ( this.state.hasError ) {
return (
<div className="woocommerce-error-boundary">
<EmptyContent
title=""
actionLabel=""
message={
errorMessage ||
__(
'Oops, something went wrong. Please try again',
'woocommerce'
)
}
secondaryActionLabel={
actionLabel || __( 'Reload', 'woocommerce' )
}
secondaryActionURL={ null }
secondaryActionCallback={
showActionButton ? this.handleAction : undefined
}
illustrationWidth={ 36 }
illustrationHeight={ 36 }
illustration={ alertIcon }
/>
</div>
);
}
return children;
}
}

View File

@ -0,0 +1,69 @@
// ErrorBoundary.stories.tsx
/**
* External dependencies
*/
import React, { createElement } from '@wordpress/element';
import { Meta, Story } from '@storybook/react';
/**
* Internal dependencies
*/
import { ErrorBoundary, ErrorBoundaryProps } from '../';
const ChildComponent = () => {
throw new Error( 'This is a test error' );
};
const Template: Story< ErrorBoundaryProps > = ( args ) => (
<ErrorBoundary { ...args }>
<ChildComponent />
</ErrorBoundary>
);
export const Default = Template.bind( {} );
Default.args = {};
export const CustomErrorMessage = Template.bind( {} );
CustomErrorMessage.args = {
errorMessage: 'Custom error message',
};
export const WithoutActionButton = Template.bind( {} );
WithoutActionButton.args = {
showActionButton: false,
};
export const CustomActionLabel = Template.bind( {} );
CustomActionLabel.args = {
actionLabel: 'Retry',
};
export const CustomActionCallback = Template.bind( {} );
CustomActionCallback.args = {
actionCallback: () => {
// eslint-disable-next-line no-alert
window.alert( 'Custom action callback triggered' );
},
};
export default {
title: 'WooCommerce Admin/experimental/Error Boundary',
component: ErrorBoundary,
argTypes: {
errorMessage: {
control: 'text',
defaultValue: 'Oops, something went wrong. Please try again',
},
showActionButton: {
control: 'boolean',
defaultValue: true,
},
actionLabel: {
control: 'text',
defaultValue: 'Reload',
},
actionCallback: {
action: 'clicked',
},
},
} as Meta;

View File

@ -0,0 +1,15 @@
.woocommerce-error-boundary {
margin-block: 16px;
.woocommerce-empty-content__message {
margin-block: 12px;
}
.woocommerce-empty-content {
margin: 0;
}
.woocommerce-empty-content__illustration {
margin-bottom: -7px;
}
}

View File

@ -0,0 +1,159 @@
/**
* External dependencies
*/
import { render, screen, fireEvent } from '@testing-library/react';
import React, { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { ErrorBoundary } from '..';
const ThrowError = () => {
throw new Error( 'Test error' );
return null;
};
describe( 'ErrorBoundary', () => {
function onError( event: Event ) {
// Note: this will swallow reports about unhandled errors!
// Use with extreme caution.
event.preventDefault();
}
// Mock window.location.reload by using a global variable
const originalLocation = window.location;
beforeAll( () => {
// Opt Out of the jsdom error messages
window.addEventListener( 'error', onError );
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Ignore TS error for deleting window.location
delete window.location;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Ignore TS error for assigning window.location
window.location = { reload: jest.fn() };
} );
afterAll( () => {
window.location = originalLocation;
window.removeEventListener( 'error', onError );
} );
it( 'catches errors and displays an error message', () => {
render(
<ErrorBoundary>
<ThrowError />
</ErrorBoundary>
);
expect(
screen.getByText( 'Oops, something went wrong. Please try again' )
).toBeInTheDocument();
} );
it( 'shows reload button', () => {
render(
<ErrorBoundary>
<ThrowError />
</ErrorBoundary>
);
expect( screen.getByText( 'Reload' ) ).toBeInTheDocument();
} );
it( 'refreshes the page when Reload Page button is clicked', () => {
const reloadMock = jest.fn();
Object.defineProperty( window.location, 'reload', {
configurable: true,
value: reloadMock,
} );
render(
<ErrorBoundary>
<ThrowError />
</ErrorBoundary>
);
fireEvent.click( screen.getByText( 'Reload' ) );
expect( reloadMock ).toHaveBeenCalled();
} );
it( 'triggers on error callback when provided', () => {
const onError = jest.fn();
render(
<ErrorBoundary onError={ onError }>
<ThrowError />
</ErrorBoundary>
);
expect( onError ).toHaveBeenCalled();
} );
it( 'triggers custom action callback when provided', () => {
const customActionCallback = jest.fn();
render(
<ErrorBoundary actionCallback={ customActionCallback }>
<ThrowError />
</ErrorBoundary>
);
fireEvent.click( screen.getByText( 'Reload' ) );
expect( customActionCallback ).toHaveBeenCalledWith(
new Error( 'Test error' )
);
} );
it( 'does not display the action button when showActionButton is false', () => {
render(
<ErrorBoundary showActionButton={ false }>
<ThrowError />
</ErrorBoundary>
);
expect( screen.queryByText( 'Reload' ) ).not.toBeInTheDocument();
} );
it( 'resets error boundary state after action is performed when resetErrorAfterAction is true', () => {
const { rerender } = render(
<ErrorBoundary>
<ThrowError />
</ErrorBoundary>
);
rerender(
<ErrorBoundary>
<div>Child Component</div>
</ErrorBoundary>
);
fireEvent.click( screen.getByText( 'Reload' ) );
expect( screen.getByText( 'Child Component' ) ).toBeInTheDocument();
} );
it( 'does not reset error boundary state after action is performed when resetErrorAfterAction is false', () => {
const { rerender } = render(
<ErrorBoundary resetErrorAfterAction={ false }>
<ThrowError />
</ErrorBoundary>
);
fireEvent.click( screen.getByText( 'Reload' ) );
rerender(
<ErrorBoundary resetErrorAfterAction={ false }>
<div>Child Component</div>
</ErrorBoundary>
);
expect(
screen.queryByText( 'Child Component' )
).not.toBeInTheDocument();
} );
} );

View File

@ -114,3 +114,4 @@ export {
export { DisplayState } from './display-state'; export { DisplayState } from './display-state';
export { ProgressBar } from './progress-bar'; export { ProgressBar } from './progress-bar';
export { ConfettiAnimation } from './confetti-animation'; export { ConfettiAnimation } from './confetti-animation';
export { ErrorBoundary as __experimentalErrorBoundary } from './error-boundary';

View File

@ -61,3 +61,4 @@
@import 'tree-select-control/index.scss'; @import 'tree-select-control/index.scss';
@import 'progress-bar/style.scss'; @import 'progress-bar/style.scss';
@import 'phone-number-input/style.scss'; @import 'phone-number-input/style.scss';
@import 'error-boundary/style.scss';

File diff suppressed because it is too large Load Diff

View File

@ -47,6 +47,7 @@
"@storybook/theming": "6.5.17-alpha.0", "@storybook/theming": "6.5.17-alpha.0",
"@woocommerce/eslint-plugin": "workspace:*", "@woocommerce/eslint-plugin": "workspace:*",
"react": "^17.0.2", "react": "^17.0.2",
"react18": "npm:react@^18.3.0",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"webpack": "^5.89.0", "webpack": "^5.89.0",

View File

@ -3,6 +3,7 @@
*/ */
const path = require( 'path' ); const path = require( 'path' );
const CopyWebpackPlugin = require( 'copy-webpack-plugin' ); const CopyWebpackPlugin = require( 'copy-webpack-plugin' );
const webpack = require( 'webpack' );
/** /**
* External dependencies * External dependencies
@ -39,6 +40,13 @@ module.exports = ( storybookConfig ) => {
'./setting.mock.js' './setting.mock.js'
); );
storybookConfig.resolve.alias[ 'react/jsx-runtime' ] =
require.resolve( 'react/jsx-runtime' );
// We need to use react 18 for the storybook since some dependencies are not compatible with react 17
// Once we upgrade react to 18 in repo, we can remove this alias
storybookConfig.resolve.alias.react = require.resolve( 'react18' );
storybookConfig.resolve.modules = [ storybookConfig.resolve.modules = [
path.join( __dirname, '../../plugins/woocommerce-admin/client' ), path.join( __dirname, '../../plugins/woocommerce-admin/client' ),
path.join( __dirname, '../../packages/js/product-editor/src' ), path.join( __dirname, '../../packages/js/product-editor/src' ),