Skip to content

Commit 73bccdc

Browse files
CONSOLE-3093: Add Error Boundaries around extension components (#11607)
* Improve ErrorBoundary and fallbacks * Add new ErrorBoundary instances * Align withFallback to new Error components * Alignment to new ErrorBoundaryPage * Fix some imports for newly structured error content * Translation updates * Bug fix, status is optional * Fix a merge conflict in import paths * Fix circular references in tests * Handle an inline wrapper for the error fallback
1 parent e9ceaf5 commit 73bccdc

40 files changed

+296
-139
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import * as React from 'react';
22
import { UserPreferenceCustomField as CustomFieldType } from '@console/dynamic-plugin-sdk/src';
3+
import { ErrorBoundaryInline } from '@console/shared/src/components/error';
34
import { UserPreferenceFieldProps } from './types';
45

56
type UserPreferenceCustomFieldProps = UserPreferenceFieldProps<CustomFieldType>;
67

78
const UserPreferenceCustomField: React.FC<UserPreferenceCustomFieldProps> = ({
89
component: CustomComponent,
910
props: customComponentProps,
10-
}) => (CustomComponent ? <CustomComponent {...customComponentProps} /> : null);
11+
}) =>
12+
CustomComponent ? (
13+
<ErrorBoundaryInline>
14+
<CustomComponent {...customComponentProps} />
15+
</ErrorBoundaryInline>
16+
) : null;
1117
export default UserPreferenceCustomField;

frontend/packages/console-shared/locales/en/console-shared.json

+8-1
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,14 @@
103103
"View property descriptions": "View property descriptions",
104104
"Save": "Save",
105105
"View shortcuts": "View shortcuts",
106+
"Extension error": "Extension error",
107+
"Show details": "Show details",
108+
"Oh no! Something went wrong.": "Oh no! Something went wrong.",
109+
"Close": "Close",
110+
"Hide details": "Hide details",
111+
"Description:": "Description:",
112+
"Component trace:": "Component trace:",
113+
"Stack trace:": "Stack trace:",
106114
"You made changes to this page.": "You made changes to this page.",
107115
"Click {{submit}} to save changes or {{reset}} to cancel changes.": "Click {{submit}} to save changes or {{reset}} to cancel changes.",
108116
"Reload": "Reload",
@@ -181,7 +189,6 @@
181189
"Show waiting pods with errors": "Show waiting pods with errors",
182190
"Waiting for the first build to run successfully. You may temporarily see \"ImagePullBackOff\" and \"ErrImagePull\" errors while waiting.": "Waiting for the first build to run successfully. You may temporarily see \"ImagePullBackOff\" and \"ErrImagePull\" errors while waiting.",
183191
"View all {{podSize}}": "View all {{podSize}}",
184-
"Close": "Close",
185192
"Quick search bar": "Quick search bar",
186193
"No results": "No results",
187194
"Quick search list": "Quick search list",

frontend/packages/console-shared/src/components/dashboard/activity-card/ActivityBody.tsx

+15-10
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ErrorLoadingEvents, sortEvents } from '@console/internal/components/eve
1313
import { AsyncComponent } from '@console/internal/components/utils/async';
1414
import { Timestamp } from '@console/internal/components/utils/timestamp';
1515
import { EventKind } from '@console/internal/module/k8s';
16+
import { ErrorBoundaryInline } from '@console/shared/src/components/error';
1617
import EventItem from './EventItem';
1718

1819
import './activity-card.scss';
@@ -173,11 +174,13 @@ export const OngoingActivityBody: React.FC<OngoingActivityBodyProps> = ({
173174
({ results, loader, component: Component }, idx) => (
174175
// eslint-disable-next-line react/no-array-index-key
175176
<Activity key={idx}>
176-
{loader ? (
177-
<AsyncComponent loader={loader} results={results} />
178-
) : (
179-
<Component results={results} />
180-
)}
177+
<ErrorBoundaryInline>
178+
{loader ? (
179+
<AsyncComponent loader={loader} results={results} />
180+
) : (
181+
<Component results={results} />
182+
)}
183+
</ErrorBoundaryInline>
181184
</Activity>
182185
),
183186
);
@@ -186,11 +189,13 @@ export const OngoingActivityBody: React.FC<OngoingActivityBodyProps> = ({
186189
.forEach(({ resource, timestamp, loader, component: Component }) =>
187190
allActivities.push(
188191
<Activity key={resource.metadata.uid} timestamp={timestamp}>
189-
{loader ? (
190-
<AsyncComponent loader={loader} resource={resource} />
191-
) : (
192-
<Component resource={resource} />
193-
)}
192+
<ErrorBoundaryInline>
193+
{loader ? (
194+
<AsyncComponent loader={loader} resource={resource} />
195+
) : (
196+
<Component resource={resource} />
197+
)}
198+
</ErrorBoundaryInline>
194199
</Activity>,
195200
),
196201
);

frontend/__tests__/components/utils/error-boundary.spec.tsx frontend/packages/console-shared/src/components/error/__tests__/error-boundary.spec.tsx

+3-6
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
import * as React from 'react';
22
import { shallow, ShallowWrapper } from 'enzyme';
3+
import { ErrorBoundary, withFallback } from '..';
4+
import { ErrorBoundaryState } from '../error-boundary'; // not for public consumption
35

4-
import {
5-
ErrorBoundary,
6-
ErrorBoundaryProps,
7-
ErrorBoundaryState,
8-
withFallback,
9-
} from '@console/shared/src/components/error/error-boundary';
6+
type ErrorBoundaryProps = React.ComponentProps<typeof ErrorBoundary>;
107

118
describe(ErrorBoundary.name, () => {
129
let wrapper: ShallowWrapper<ErrorBoundaryProps, ErrorBoundaryState>;
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
import * as React from 'react';
2+
import { ErrorBoundaryFallbackProps } from './types';
3+
4+
type ErrorBoundaryProps = {
5+
FallbackComponent?: React.ComponentType<ErrorBoundaryFallbackProps>;
6+
};
7+
8+
/** Needed for tests -- should not be imported by application logic */
9+
export type ErrorBoundaryState = {
10+
hasError: boolean;
11+
error: { message: string; stack: string; name: string };
12+
errorInfo: { componentStack: string };
13+
};
214

315
const DefaultFallback: React.FC = () => <div />;
416

5-
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
17+
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
618
constructor(props) {
719
super(props);
820
this.state = {
@@ -26,7 +38,7 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
2638
});
2739
// Log the error so something shows up in the JS console when `DefaultFallback` is used.
2840
// eslint-disable-next-line no-console
29-
console.error('Catched error in a child component:', error, errorInfo);
41+
console.error('Caught error in a child component:', error, errorInfo);
3042
}
3143

3244
render() {
@@ -45,34 +57,4 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
4557
}
4658
}
4759

48-
export const withFallback: WithFallback = (WrappedComponent, FallbackComponent) => {
49-
const Component = (props) => (
50-
<ErrorBoundary FallbackComponent={FallbackComponent}>
51-
<WrappedComponent {...props} />
52-
</ErrorBoundary>
53-
);
54-
Component.displayName = `withFallback(${WrappedComponent.displayName || WrappedComponent.name})`;
55-
return Component;
56-
};
57-
58-
export type WithFallback = <P = {}>(
59-
Component: React.ComponentType<P>,
60-
FallbackComponent?: React.ComponentType<any>,
61-
) => React.ComponentType<P>;
62-
63-
export type ErrorBoundaryFallbackProps = {
64-
errorMessage: string;
65-
componentStack: string;
66-
stack: string;
67-
title: string;
68-
};
69-
70-
export type ErrorBoundaryProps = {
71-
FallbackComponent?: React.ComponentType<ErrorBoundaryFallbackProps>;
72-
};
73-
74-
export type ErrorBoundaryState = {
75-
hasError: boolean;
76-
error: { message: string; stack: string; name: string };
77-
errorInfo: { componentStack: string };
78-
};
60+
export default ErrorBoundary;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import * as React from 'react';
2+
import { Alert, Button, Modal, ModalVariant, Split, SplitItem } from '@patternfly/react-core';
3+
import { useTranslation } from 'react-i18next';
4+
import { ErrorBoundaryFallbackProps } from '../types';
5+
import ErrorDetailsBlock from './ErrorDetailsBlock';
6+
7+
/**
8+
* Support for error boundary content that won't consume the whole page.
9+
*/
10+
const ErrorBoundaryFallbackInline: React.FC<ErrorBoundaryFallbackProps> = (props) => {
11+
const { t } = useTranslation();
12+
const [isOpen, setOpen] = React.useState(false);
13+
return (
14+
<>
15+
<Split hasGutter>
16+
<SplitItem>
17+
<Alert variant="danger" isInline isPlain title={t('console-shared~Extension error')} />
18+
</SplitItem>
19+
<SplitItem>
20+
<Button variant="link" isInline onClick={() => setOpen(true)}>
21+
{t('console-shared~Show details')}
22+
</Button>
23+
</SplitItem>
24+
</Split>
25+
<Modal
26+
variant={ModalVariant.large}
27+
title={t('console-shared~Oh no! Something went wrong.')}
28+
isOpen={isOpen}
29+
onClose={() => setOpen(false)}
30+
actions={[
31+
<Button key="confirm" variant="primary" onClick={() => setOpen(false)}>
32+
{t('console-shared~Close')}
33+
</Button>,
34+
]}
35+
>
36+
<ErrorDetailsBlock {...props} />
37+
</Modal>
38+
</>
39+
);
40+
};
41+
42+
export default ErrorBoundaryFallbackInline;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import * as React from 'react';
2+
import { Text, TextVariants } from '@patternfly/react-core';
3+
import { useTranslation } from 'react-i18next';
4+
import { ExpandCollapse } from '@console/internal/components/utils/expand-collapse';
5+
import { ErrorBoundaryFallbackProps } from '../types';
6+
import ErrorDetailsBlock from './ErrorDetailsBlock';
7+
8+
/**
9+
* Standard fallback catch -- expected to take up the whole page.
10+
*/
11+
const ErrorBoundaryFallbackPage: React.FC<ErrorBoundaryFallbackProps> = (props) => {
12+
const { t } = useTranslation();
13+
return (
14+
<div className="co-m-pane__body">
15+
<Text component={TextVariants.h1} className="co-m-pane__heading co-m-pane__heading--center">
16+
{t('console-shared~Oh no! Something went wrong.')}
17+
</Text>
18+
<ExpandCollapse
19+
textCollapsed={t('console-shared~Show details')}
20+
textExpanded={t('console-shared~Hide details')}
21+
>
22+
<ErrorDetailsBlock {...props} />
23+
</ExpandCollapse>
24+
</div>
25+
);
26+
};
27+
28+
export default ErrorBoundaryFallbackPage;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import * as React from 'react';
2+
import ErrorBoundary from '../error-boundary';
3+
import ErrorBoundaryFallbackInline from './ErrorBoundaryFallbackInline';
4+
5+
type ErrorBoundaryInlineProps = {
6+
wrapper?: React.ComponentType<{ children: React.ReactNode }>;
7+
};
8+
9+
/**
10+
* Mount an error boundary that will render an inline error with modal stack trace.
11+
* @see ErrorBoundaryPage if you do not need an inline fallback.
12+
*/
13+
const ErrorBoundaryInline: React.FC<ErrorBoundaryInlineProps> = ({
14+
wrapper: Wrapper,
15+
...props
16+
}) => {
17+
let fallback = ErrorBoundaryFallbackInline;
18+
if (Wrapper) {
19+
fallback = (innerProps) => (
20+
<Wrapper>
21+
<ErrorBoundaryFallbackInline {...innerProps} />
22+
</Wrapper>
23+
);
24+
}
25+
26+
return <ErrorBoundary {...props} FallbackComponent={fallback} />;
27+
};
28+
29+
export default ErrorBoundaryInline;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import * as React from 'react';
2+
import ErrorBoundary from '../error-boundary';
3+
import ErrorBoundaryFallbackPage from './ErrorBoundaryFallbackPage';
4+
5+
/**
6+
* Mount an error boundary that will render a full page error stack trace.
7+
* @see ErrorBoundaryInline for a more inline option.
8+
*/
9+
const ErrorBoundaryPage: React.FC = (props) => {
10+
return <ErrorBoundary {...props} FallbackComponent={ErrorBoundaryFallbackPage} />;
11+
};
12+
13+
export default ErrorBoundaryPage;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import * as React from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import { CopyToClipboard } from '@console/internal/components/utils/copy-to-clipboard';
4+
import { ErrorBoundaryFallbackProps } from '../types';
5+
6+
const ErrorDetailsBlock: React.FC<ErrorBoundaryFallbackProps> = (props) => {
7+
const { t } = useTranslation();
8+
return (
9+
<>
10+
<h3 className="co-section-heading-tertiary">{props.title}</h3>
11+
<div className="form-group">
12+
<label htmlFor="description">{t('console-shared~Description:')}</label>
13+
<p>{props.errorMessage}</p>
14+
</div>
15+
<div className="form-group">
16+
<label htmlFor="componentTrace">{t('console-shared~Component trace:')}</label>
17+
<div className="co-copy-to-clipboard__stacktrace-width-height">
18+
<CopyToClipboard value={props.componentStack.trim()} />
19+
</div>
20+
</div>
21+
<div className="form-group">
22+
<label htmlFor="stackTrace">{t('console-shared~Stack trace:')}</label>
23+
<div className="co-copy-to-clipboard__stacktrace-width-height">
24+
<CopyToClipboard value={props.stack.trim()} />
25+
</div>
26+
</div>
27+
</>
28+
);
29+
};
30+
31+
export default ErrorDetailsBlock;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as React from 'react';
2+
import ErrorBoundary from '../error-boundary';
3+
4+
type WithFallback = <P = {}>(
5+
Component: React.ComponentType<P>,
6+
FallbackComponent?: React.ComponentType<any>,
7+
) => React.ComponentType<P>;
8+
9+
const withFallback: WithFallback = (WrappedComponent, FallbackComponent) => {
10+
const Component = (props) => (
11+
<ErrorBoundary FallbackComponent={FallbackComponent}>
12+
<WrappedComponent {...props} />
13+
</ErrorBoundary>
14+
);
15+
Component.displayName = `withFallback(${WrappedComponent.displayName || WrappedComponent.name})`;
16+
return Component;
17+
};
18+
19+
export default withFallback;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Default ErrorBoundary usage
2+
export { default as ErrorBoundary } from './error-boundary';
3+
export * from './types';
4+
5+
// Packaged, easy to use, fallback options
6+
export { default as ErrorBoundaryPage } from './fallbacks/ErrorBoundaryPage';
7+
export { default as ErrorBoundaryInline } from './fallbacks/ErrorBoundaryInline';
8+
9+
// Custom fallback options
10+
export { default as withFallback } from './fallbacks/withFallback';
11+
export { default as ErrorBoundaryFallbackPage } from './fallbacks/ErrorBoundaryFallbackPage';
12+
export { default as ErrorBoundaryFallbackInline } from './fallbacks/ErrorBoundaryFallbackInline';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export type ErrorBoundaryFallbackProps = {
2+
errorMessage: string;
3+
componentStack: string;
4+
stack: string;
5+
title: string;
6+
};

frontend/packages/operator-lifecycle-manager/src/components/catalog-source.spec.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { DetailsPage } from '@console/internal/components/factory';
77
import { Firehose, LoadingBox, DetailsItem } from '@console/internal/components/utils';
88
import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook';
99
import { referenceForModel } from '@console/internal/module/k8s';
10-
import { ErrorBoundary } from '@console/shared/src/components/error/error-boundary';
10+
import { ErrorBoundary } from '@console/shared/src/components/error';
1111
import { testCatalogSource, testPackageManifest, dummyPackageManifest } from '../../mocks';
1212
import {
1313
SubscriptionModel,

frontend/packages/operator-lifecycle-manager/src/components/catalog-source.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
import i18n from '@console/internal/i18n';
3535
import { ConfigMapModel } from '@console/internal/models';
3636
import { referenceForModel, K8sKind, k8sPatch } from '@console/internal/module/k8s';
37-
import { withFallback } from '@console/shared/src/components/error/error-boundary';
37+
import { withFallback } from '@console/shared/src/components/error';
3838
import { DEFAULT_SOURCE_NAMESPACE } from '../const';
3939
import {
4040
SubscriptionModel,

frontend/packages/operator-lifecycle-manager/src/components/clusterserviceversion.spec.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
} from '@console/internal/components/utils';
2020
import * as operatorLogo from '@console/internal/imgs/operator.svg';
2121
import { referenceForModel } from '@console/internal/module/k8s';
22-
import { ErrorBoundary } from '@console/shared/src/components/error/error-boundary';
22+
import { ErrorBoundary } from '@console/shared/src/components/error';
2323
import { useActiveNamespace } from '@console/shared/src/hooks/redux-selectors';
2424
import {
2525
testClusterServiceVersion,

frontend/packages/operator-lifecycle-manager/src/components/clusterserviceversion.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ import {
6565
K8sResourceKind,
6666
} from '@console/internal/module/k8s';
6767
import { ALL_NAMESPACES_KEY, Status, getNamespace } from '@console/shared';
68-
import { withFallback } from '@console/shared/src/components/error/error-boundary';
68+
import { withFallback } from '@console/shared/src/components/error';
6969
import { consolePluginModal } from '@console/shared/src/components/modals';
7070
import { RedExclamationCircleIcon } from '@console/shared/src/components/status/icons';
7171
import { CONSOLE_OPERATOR_CONFIG_NAME } from '@console/shared/src/constants';

0 commit comments

Comments
 (0)