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:
parent
4112c97d13
commit
54321a5a60
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add error boundary component and fix empty content button
|
|
@ -45,12 +45,13 @@ class EmptyContent extends Component {
|
|||
: this.props.actionCallback;
|
||||
|
||||
const isPrimary = type === 'secondary' ? false : true;
|
||||
const buttonVariant = isPrimary ? 'primary' : 'secondary';
|
||||
|
||||
if ( actionURL && actionCallback ) {
|
||||
return (
|
||||
<Button
|
||||
className="woocommerce-empty-content__action"
|
||||
isPrimary={ isPrimary }
|
||||
variant={ buttonVariant }
|
||||
onClick={ actionCallback }
|
||||
href={ actionURL }
|
||||
>
|
||||
|
@ -61,7 +62,7 @@ class EmptyContent extends Component {
|
|||
return (
|
||||
<Button
|
||||
className="woocommerce-empty-content__action"
|
||||
isPrimary={ isPrimary }
|
||||
variant={ buttonVariant }
|
||||
href={ actionURL }
|
||||
>
|
||||
{ actionLabel }
|
||||
|
@ -71,7 +72,7 @@ class EmptyContent extends Component {
|
|||
return (
|
||||
<Button
|
||||
className="woocommerce-empty-content__action"
|
||||
isPrimary={ isPrimary }
|
||||
variant={ buttonVariant }
|
||||
onClick={ actionCallback }
|
||||
>
|
||||
{ actionLabel }
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
export const alertIcon =
|
||||
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTguNTc0NjUgMy4yMTYzNUwxLjUxNjMyIDE0Ljk5OTdDMS4zNzA3OSAxNS4yNTE3IDEuMjkzNzkgMTUuNTM3NCAxLjI5Mjk4IDE1LjgyODRDMS4yOTIxNiAxNi4xMTk1IDEuMzY3NTYgMTYuNDA1NiAxLjUxMTY3IDE2LjY1ODVDMS42NTU3OSAxNi45MTEzIDEuODYzNTkgMTcuMTIyIDIuMTE0NDEgMTcuMjY5NkMyLjM2NTIzIDE3LjQxNzEgMi42NTAzMiAxNy40OTY1IDIuOTQxMzIgMTcuNDk5N0gxNy4wNThDMTcuMzQ5IDE3LjQ5NjUgMTcuNjM0MSAxNy40MTcxIDE3Ljg4NDkgMTcuMjY5NkMxOC4xMzU3IDE3LjEyMiAxOC4zNDM1IDE2LjkxMTMgMTguNDg3NiAxNi42NTg1QzE4LjYzMTcgMTYuNDA1NiAxOC43MDcxIDE2LjExOTUgMTguNzA2MyAxNS44Mjg0QzE4LjcwNTUgMTUuNTM3NCAxOC42Mjg1IDE1LjI1MTcgMTguNDgzIDE0Ljk5OTdMMTEuNDI0NyAzLjIxNjM1QzExLjI3NjEgMi45NzE0NCAxMS4wNjY5IDIuNzY4OTUgMTAuODE3MyAyLjYyODQyQzEwLjU2NzcgMi40ODc4OSAxMC4yODYxIDIuNDE0MDYgOS45OTk2NSAyLjQxNDA2QzkuNzEzMjEgMi40MTQwNiA5LjQzMTU5IDIuNDg3ODkgOS4xODE5OSAyLjYyODQyQzguOTMyMzggMi43Njg5NSA4LjcyMzIxIDIuOTcxNDQgOC41NzQ2NSAzLjIxNjM1VjMuMjE2MzVaIiBzdHJva2U9IiMxZTFlMWUiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+CjxwYXRoIGQ9Ik0xMCA3LjVWMTAuODMzMyIgc3Ryb2tlPSIjMWUxZTFlIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8cGF0aCBkPSJNMTAgMTQuMTY4SDEwLjAwODMiIHN0cm9rZT0iIzFlMWUxZSIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KPC9zdmc+Cg==';
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
} );
|
||||
} );
|
|
@ -114,3 +114,4 @@ export {
|
|||
export { DisplayState } from './display-state';
|
||||
export { ProgressBar } from './progress-bar';
|
||||
export { ConfettiAnimation } from './confetti-animation';
|
||||
export { ErrorBoundary as __experimentalErrorBoundary } from './error-boundary';
|
||||
|
|
|
@ -61,3 +61,4 @@
|
|||
@import 'tree-select-control/index.scss';
|
||||
@import 'progress-bar/style.scss';
|
||||
@import 'phone-number-input/style.scss';
|
||||
@import 'error-boundary/style.scss';
|
||||
|
|
862
pnpm-lock.yaml
862
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -47,6 +47,7 @@
|
|||
"@storybook/theming": "6.5.17-alpha.0",
|
||||
"@woocommerce/eslint-plugin": "workspace:*",
|
||||
"react": "^17.0.2",
|
||||
"react18": "npm:react@^18.3.0",
|
||||
"react-dom": "^17.0.2",
|
||||
"typescript": "^5.3.3",
|
||||
"webpack": "^5.89.0",
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
const path = require( 'path' );
|
||||
const CopyWebpackPlugin = require( 'copy-webpack-plugin' );
|
||||
const webpack = require( 'webpack' );
|
||||
|
||||
/**
|
||||
* External dependencies
|
||||
|
@ -39,6 +40,13 @@ module.exports = ( storybookConfig ) => {
|
|||
'./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 = [
|
||||
path.join( __dirname, '../../plugins/woocommerce-admin/client' ),
|
||||
path.join( __dirname, '../../packages/js/product-editor/src' ),
|
||||
|
|
Loading…
Reference in New Issue