MM-61734 Add withErrorBoundary HOC and use for PostComponent (#29801)
* MM-61734 Add withErrorBoundary HOC and use for PostComponent I originally planned to add error boundaries to some individual parts of the post, but it seems sufficient to add them to just the outer PostComponent. * Update button label
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
ce29be0f38
Коммит
c7eb908e38
@@ -49,6 +49,7 @@ import {getDateForUnixTicks, makeIsEligibleForClick} from 'utils/utils';
|
|||||||
|
|
||||||
import type {PostActionComponent, PostPluginComponent} from 'types/store/plugins';
|
import type {PostActionComponent, PostPluginComponent} from 'types/store/plugins';
|
||||||
|
|
||||||
|
import {withPostErrorBoundary} from './post_error_boundary';
|
||||||
import PostOptions from './post_options';
|
import PostOptions from './post_options';
|
||||||
import PostUserProfile from './user_profile';
|
import PostUserProfile from './user_profile';
|
||||||
|
|
||||||
@@ -119,7 +120,7 @@ export type Props = {
|
|||||||
pluginActions: PostActionComponent[];
|
pluginActions: PostActionComponent[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const PostComponent = (props: Props): JSX.Element => {
|
function PostComponent(props: Props) {
|
||||||
const {post, shouldHighlight, togglePostMenu} = props;
|
const {post, shouldHighlight, togglePostMenu} = props;
|
||||||
|
|
||||||
const isSearchResultItem = (props.matches && props.matches.length > 0) || props.isMentionSearch || (props.term && props.term.length > 0);
|
const isSearchResultItem = (props.matches && props.matches.length > 0) || props.isMentionSearch || (props.term && props.term.length > 0);
|
||||||
@@ -670,6 +671,6 @@ const PostComponent = (props: Props): JSX.Element => {
|
|||||||
</PostAriaLabelDiv>
|
</PostAriaLabelDiv>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default PostComponent;
|
export default withPostErrorBoundary(PostComponent);
|
||||||
|
|||||||
43
webapp/channels/src/components/post/post_error_boundary.tsx
Обычный файл
43
webapp/channels/src/components/post/post_error_boundary.tsx
Обычный файл
@@ -0,0 +1,43 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {FormattedMessage, useIntl} from 'react-intl';
|
||||||
|
|
||||||
|
import type {FallbackProps} from 'components/with_error_boundary';
|
||||||
|
import withErrorBoundary from 'components/with_error_boundary';
|
||||||
|
|
||||||
|
export function withPostErrorBoundary<P>(component: React.ComponentType<P>) {
|
||||||
|
return withErrorBoundary<P>(component, {
|
||||||
|
renderFallback: ({clearError}) => {
|
||||||
|
return (
|
||||||
|
<div className='a11y__section post'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='post.renderError.message'
|
||||||
|
defaultMessage='An error occurred while rendering this post.'
|
||||||
|
tagName='p'
|
||||||
|
/>
|
||||||
|
<br/>
|
||||||
|
<RetryButton clearError={clearError}/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function RetryButton({clearError}: FallbackProps) {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className='btn btn-tertiary'
|
||||||
|
aria-label={intl.formatMessage({id: 'post.renderError.retryLabel', defaultMessage: 'Retry rendering this post'})}
|
||||||
|
onClick={clearError}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id='post.renderError.retry'
|
||||||
|
defaultMessage='Retry'
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ exports[`components/post_view/post_list_row should have class hideAnimation for
|
|||||||
exports[`components/post_view/post_list_row should render channel intro message 1`] = `<Connect(ChannelIntroMessage) />`;
|
exports[`components/post_view/post_list_row should render channel intro message 1`] = `<Connect(ChannelIntroMessage) />`;
|
||||||
|
|
||||||
exports[`components/post_view/post_list_row should render combined post 1`] = `
|
exports[`components/post_view/post_list_row should render combined post 1`] = `
|
||||||
<Connect(Connect(PostComponent))
|
<Connect(Connect(ErrorBoundary(PostComponent)))
|
||||||
combinedId="user-activity-1234-5678"
|
combinedId="user-activity-1234-5678"
|
||||||
isLastPost={false}
|
isLastPost={false}
|
||||||
location="CENTER"
|
location="CENTER"
|
||||||
@@ -101,7 +101,7 @@ exports[`components/post_view/post_list_row should render new messages line 1`]
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`components/post_view/post_list_row should render post 1`] = `
|
exports[`components/post_view/post_list_row should render post 1`] = `
|
||||||
<Connect(PostComponent)
|
<Connect(ErrorBoundary(PostComponent))
|
||||||
isLastPost={false}
|
isLastPost={false}
|
||||||
location="CENTER"
|
location="CENTER"
|
||||||
post={
|
post={
|
||||||
|
|||||||
138
webapp/channels/src/components/with_error_boundary/index.test.tsx
Обычный файл
138
webapp/channels/src/components/with_error_boundary/index.test.tsx
Обычный файл
@@ -0,0 +1,138 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {useSelector} from 'react-redux';
|
||||||
|
|
||||||
|
import {getChannelsInCategoryOrder} from 'selectors/views/channel_sidebar';
|
||||||
|
|
||||||
|
import {render, screen} from 'tests/react_testing_utils';
|
||||||
|
|
||||||
|
import type {FallbackProps} from '.';
|
||||||
|
import withErrorBoundary from '.';
|
||||||
|
|
||||||
|
function renderFallbackWithRetry({clearError}: FallbackProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>{'A rendering error occurred'}</p>
|
||||||
|
<button onClick={clearError}>{'Try again?'}</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('withErrorBoundary', () => {
|
||||||
|
const origError = console.error;
|
||||||
|
beforeAll(() => {
|
||||||
|
console.error = jest.fn();
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
console.error = origError;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render the component normally', () => {
|
||||||
|
function TestComponent() {
|
||||||
|
return <span>{'TestComponent'}</span>;
|
||||||
|
}
|
||||||
|
const WrappedTestComponent = withErrorBoundary(TestComponent, {
|
||||||
|
renderFallback: renderFallbackWithRetry,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<WrappedTestComponent/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('TestComponent')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render fallback when an error occurs during rendering', () => {
|
||||||
|
function TestComponent(): JSX.Element {
|
||||||
|
const obj = {} as any;
|
||||||
|
|
||||||
|
return <span>{'TestComponent' + obj.someField.thatDoesnt.exist.toString()}</span>;
|
||||||
|
}
|
||||||
|
const WrappedTestComponent = withErrorBoundary(TestComponent, {
|
||||||
|
renderFallback: renderFallbackWithRetry,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<WrappedTestComponent/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('A rendering error occurred')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render fallback when an error occurs in a hook', () => {
|
||||||
|
function useAnErrorForSomeReason(): string {
|
||||||
|
throw new Error('hook error');
|
||||||
|
}
|
||||||
|
function TestComponent(): JSX.Element {
|
||||||
|
const extraText = useAnErrorForSomeReason();
|
||||||
|
|
||||||
|
return <span>{'TestComponent' + extraText}</span>;
|
||||||
|
}
|
||||||
|
const WrappedTestComponent = withErrorBoundary(TestComponent, {
|
||||||
|
renderFallback: renderFallbackWithRetry,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<WrappedTestComponent/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('A rendering error occurred')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render fallback when an error occurs in a selector', () => {
|
||||||
|
function TestComponent(): JSX.Element {
|
||||||
|
const extraText = useSelector(getChannelsInCategoryOrder);
|
||||||
|
|
||||||
|
return <span>{'TestComponent' + extraText}</span>;
|
||||||
|
}
|
||||||
|
const WrappedTestComponent = withErrorBoundary(TestComponent, {
|
||||||
|
renderFallback: renderFallbackWithRetry,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<WrappedTestComponent/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('A rendering error occurred')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the user should be able to retry rendering the component', () => {
|
||||||
|
let throwError = true;
|
||||||
|
|
||||||
|
function TestComponent(): JSX.Element {
|
||||||
|
let obj: any;
|
||||||
|
if (throwError) {
|
||||||
|
obj = {};
|
||||||
|
} else {
|
||||||
|
obj = {
|
||||||
|
someField: {
|
||||||
|
thatDoesnt: {
|
||||||
|
exist: [1, 2, 3],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span>{'TestComponent ' + obj.someField.thatDoesnt.exist.toString()}</span>;
|
||||||
|
}
|
||||||
|
const WrappedTestComponent = withErrorBoundary(TestComponent, {
|
||||||
|
renderFallback: renderFallbackWithRetry,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<WrappedTestComponent/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('A rendering error occurred')).toBeVisible();
|
||||||
|
expect(screen.queryByText('TestComponent 1,2,3')).toBeNull();
|
||||||
|
|
||||||
|
throwError = false;
|
||||||
|
|
||||||
|
screen.getByText('Try again?').click();
|
||||||
|
|
||||||
|
expect(screen.queryByText('A rendering error occurred')).toBeNull();
|
||||||
|
expect(screen.queryByText('TestComponent 1,2,3')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
54
webapp/channels/src/components/with_error_boundary/index.tsx
Обычный файл
54
webapp/channels/src/components/with_error_boundary/index.tsx
Обычный файл
@@ -0,0 +1,54 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type ErrorBoundaryState = {
|
||||||
|
hasError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FallbackProps = {
|
||||||
|
clearError: (e: React.MouseEvent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ErrorBoundaryOptions = {
|
||||||
|
renderFallback: (props: FallbackProps) => React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function withErrorBoundary<P>(component: React.ComponentType<P>, options: ErrorBoundaryOptions) {
|
||||||
|
const Component = component;
|
||||||
|
const displayName = component.displayName ?? component.name ?? 'Component';
|
||||||
|
|
||||||
|
const WrappedComponent = class WrappedComponent extends React.PureComponent<P, ErrorBoundaryState> {
|
||||||
|
static displayName = `ErrorBoundary(${displayName})`;
|
||||||
|
|
||||||
|
state = {
|
||||||
|
hasError: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
static getDerivedStateFromError() {
|
||||||
|
return {
|
||||||
|
hasError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
clearError = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
this.setState({hasError: false});
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return options.renderFallback({
|
||||||
|
clearError: this.clearError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Component {...this.props}/>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return WrappedComponent;
|
||||||
|
}
|
||||||
@@ -4731,6 +4731,9 @@
|
|||||||
"post.ariaLabel.replyMessage": "At {time} {date}, {authorName} replied, {message}",
|
"post.ariaLabel.replyMessage": "At {time} {date}, {authorName} replied, {message}",
|
||||||
"post.reminder.acknowledgement": "You will be reminded at {reminderTime}, {reminderDate} about this message from {username}: {permaLink}",
|
"post.reminder.acknowledgement": "You will be reminded at {reminderTime}, {reminderDate} about this message from {username}: {permaLink}",
|
||||||
"post.reminder.systemBot": "Hi there, here's your reminder about this message from {username}: {permaLink}",
|
"post.reminder.systemBot": "Hi there, here's your reminder about this message from {username}: {permaLink}",
|
||||||
|
"post.renderError.message": "An error occurred while rendering this post.",
|
||||||
|
"post.renderError.retry": "Retry",
|
||||||
|
"post.renderError.retryLabel": "Retry rendering this post",
|
||||||
"postlist.toast.history": "Viewing message history",
|
"postlist.toast.history": "Viewing message history",
|
||||||
"postlist.toast.newMessages": "{count, number} new {count, plural, one {message} other {messages}}",
|
"postlist.toast.newMessages": "{count, number} new {count, plural, one {message} other {messages}}",
|
||||||
"postlist.toast.newMessagesSince": "{count, number} new {count, plural, one {message} other {messages}} {isToday, select, true {} other {since}} {date}",
|
"postlist.toast.newMessagesSince": "{count, number} new {count, plural, one {message} other {messages}} {isToday, select, true {} other {since}} {date}",
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user