Skip to content

Commit dc69b0e

Browse files
committed
feat(react): Add report dialog
1 parent 64a3af4 commit dc69b0e

File tree

3 files changed

+241
-26
lines changed

3 files changed

+241
-26
lines changed

packages/react/src/errorboundary.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import * as React from 'react';
44
export const FALLBACK_ERR_MESSAGE = 'No fallback component has been set';
55

66
export type ErrorBoundaryProps = {
7+
showDialog?: boolean;
8+
dialogOptions?: Sentry.ReportDialogOptions;
79
fallback?: React.ReactNode;
810
fallbackRender?(error: Error | null, componentStack: string | null, resetErrorBoundary: () => void): React.ReactNode;
911
onError?(error: Error, componentStack: string): void;
10-
onMount?(error: Error | null, componentStack: string | null): void;
12+
onMount?(): void;
1113
onReset?(error: Error | null, componentStack: string | null): void;
1214
onUnmount?(error: Error | null, componentStack: string | null): void;
1315
};
@@ -30,18 +32,20 @@ class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundarySta
3032
scope.setExtra('componentStack', componentStack);
3133
Sentry.captureException(error);
3234
});
33-
const { onError } = this.props;
35+
const { onError, showDialog, dialogOptions } = this.props;
3436
if (onError) {
3537
onError(error, componentStack);
3638
}
39+
if (showDialog) {
40+
Sentry.showReportDialog(dialogOptions);
41+
}
3742
this.setState({ error, componentStack });
3843
}
3944

4045
public componentDidMount(): void {
41-
const { error, componentStack } = this.state;
4246
const { onMount } = this.props;
4347
if (onMount) {
44-
onMount(error, componentStack);
48+
onMount();
4549
}
4650
}
4751

Lines changed: 230 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,240 @@
1-
import { render } from '@testing-library/react';
1+
import { fireEvent, render, screen } from '@testing-library/react';
22
import * as React from 'react';
33

4-
import { ErrorBoundary, ErrorBoundaryProps } from '../src/errorboundary';
4+
import { ErrorBoundary, ErrorBoundaryProps, FALLBACK_ERR_MESSAGE } from '../src/errorboundary';
5+
6+
const mockSetExtra = jest.fn();
7+
const mockCaptureException = jest.fn();
8+
const mockShowReportDialog = jest.fn();
9+
10+
jest.mock('@sentry/browser', () => ({
11+
captureException: (err: any) => {
12+
mockCaptureException(err);
13+
},
14+
showReportDialog: (options: any) => {
15+
mockShowReportDialog(options);
16+
},
17+
withScope: (callback: Function) => {
18+
callback({
19+
setExtra: mockSetExtra,
20+
});
21+
},
22+
}));
23+
24+
const TestApp: React.FC<ErrorBoundaryProps> = ({ children, ...props }) => {
25+
const [isError, setError] = React.useState(false);
26+
return (
27+
<ErrorBoundary {...props}>
28+
{isError ? <Bam /> : children}
29+
<button
30+
data-testid="errorBtn"
31+
onClick={() => {
32+
setError(true);
33+
}}
34+
/>
35+
</ErrorBoundary>
36+
);
37+
};
38+
39+
function Bam(): JSX.Element {
40+
throw new Error('boom');
41+
}
542

643
describe('ErrorBoundary', () => {
7-
const DEFAULT_PROPS: ErrorBoundaryProps = {
8-
fallback: <h1>Error Component</h1>,
9-
fallbackRender: (error: Error, componentStack: string, resetErrorBoundary: () => void) => (
10-
<React.Fragment>
11-
<h1>{error.toString()}</h1>
12-
<h2>{componentStack}</h2>
13-
<button onClick={resetErrorBoundary} />
14-
</React.Fragment>
15-
),
16-
onError: jest.fn(),
17-
onReset: jest.fn(),
18-
};
19-
20-
it('Renders children with no failure', () => {
21-
function Bomb(): JSX.Element {
22-
return <p>Testing children</p>;
23-
}
44+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
2445

46+
afterEach(() => {
47+
consoleErrorSpy.mockClear();
48+
mockSetExtra.mockClear();
49+
mockCaptureException.mockClear();
50+
mockShowReportDialog.mockClear();
51+
});
52+
53+
it('throws an error if not given a valid `fallbackRender` prop', () => {
54+
expect(() => {
55+
render(
56+
// @ts-ignore
57+
<ErrorBoundary fallbackRender={'ok'}>
58+
<Bam />
59+
</ErrorBoundary>,
60+
);
61+
}).toThrowError(FALLBACK_ERR_MESSAGE);
62+
expect(consoleErrorSpy).toHaveBeenCalled();
63+
});
64+
65+
it('throws an error if not given a valid `fallback` prop', () => {
66+
expect(() => {
67+
render(
68+
<ErrorBoundary fallback={new Error('true')}>
69+
<Bam />
70+
</ErrorBoundary>,
71+
);
72+
}).toThrowError(FALLBACK_ERR_MESSAGE);
73+
expect(consoleErrorSpy).toHaveBeenCalled();
74+
});
75+
76+
it('does not throw an error if a fallback is given', () => {
77+
expect(() => {
78+
render(
79+
<ErrorBoundary fallback={<h1>Error Component</h1>}>
80+
<h1>children</h1>
81+
</ErrorBoundary>,
82+
);
83+
}).not.toThrowError();
84+
});
85+
86+
it('calls `onMount` when mounted', () => {
87+
const mockOnMount = jest.fn();
2588
render(
26-
<ErrorBoundary>
27-
<Bomb />
89+
<ErrorBoundary fallback={<h1>Error Component</h1>} onMount={mockOnMount}>
90+
<h1>children</h1>
2891
</ErrorBoundary>,
2992
);
93+
94+
expect(mockOnMount).toHaveBeenCalledTimes(1);
95+
});
96+
97+
it('calls `onUnmount` when unmounted', () => {
98+
const mockOnUnmount = jest.fn();
99+
const { unmount } = render(
100+
<ErrorBoundary fallback={<h1>Error Component</h1>} onUnmount={mockOnUnmount}>
101+
<h1>children</h1>
102+
</ErrorBoundary>,
103+
);
104+
105+
expect(mockOnUnmount).toHaveBeenCalledTimes(0);
106+
unmount();
107+
expect(mockOnUnmount).toHaveBeenCalledTimes(1);
108+
expect(mockOnUnmount).toHaveBeenCalledWith(null, null);
109+
});
110+
111+
it('renders children correctly when there is no error', () => {
112+
const { baseElement } = render(
113+
<ErrorBoundary fallback={<h1>Error Component</h1>}>
114+
<h1>children</h1>
115+
</ErrorBoundary>,
116+
);
117+
118+
expect(baseElement.outerHTML).toContain('<h1>children</h1>');
119+
});
120+
121+
describe('fallback', () => {
122+
it('renders a fallback component', async () => {
123+
const { baseElement } = render(
124+
<TestApp fallback={<p>You have hit an error</p>}>
125+
<h1>children</h1>
126+
</TestApp>,
127+
);
128+
129+
expect(baseElement.outerHTML).toContain('<h1>children</h1>');
130+
131+
const btn = screen.getByTestId('errorBtn');
132+
fireEvent.click(btn);
133+
134+
expect(baseElement.outerHTML).not.toContain('<h1>children</h1>');
135+
expect(baseElement.outerHTML).toContain('<p>You have hit an error</p>');
136+
});
137+
138+
it('renders a fallbackRender component', async () => {
139+
let errorString = '';
140+
let compStack = '';
141+
const { baseElement } = render(
142+
<TestApp
143+
fallbackRender={(error: Error, componentStack: string) => {
144+
errorString = error.toString();
145+
compStack = componentStack;
146+
return <div>Fallback here</div>;
147+
}}
148+
>
149+
<h1>children</h1>
150+
</TestApp>,
151+
);
152+
153+
expect(baseElement.outerHTML).toContain('<h1>children</h1>');
154+
155+
const btn = screen.getByTestId('errorBtn');
156+
fireEvent.click(btn);
157+
158+
expect(baseElement.outerHTML).not.toContain('<h1>children</h1');
159+
expect(baseElement.outerHTML).toContain('<div>Fallback here</div>');
160+
161+
expect(errorString).toBe('Error: boom');
162+
expect(compStack).toBe(`
163+
in Bam (created by TestApp)
164+
in ErrorBoundary (created by TestApp)
165+
in TestApp`);
166+
});
167+
});
168+
169+
describe('error', () => {
170+
it('calls `componentDidCatch() when an error occurs`', () => {
171+
const mockOnError = jest.fn();
172+
render(
173+
<TestApp fallback={<p>You have hit an error</p>} onError={mockOnError}>
174+
<h1>children</h1>
175+
</TestApp>,
176+
);
177+
178+
expect(mockOnError).toHaveBeenCalledTimes(0);
179+
expect(mockCaptureException).toHaveBeenCalledTimes(0);
180+
expect(mockSetExtra).toHaveBeenCalledTimes(0);
181+
182+
const btn = screen.getByTestId('errorBtn');
183+
fireEvent.click(btn);
184+
185+
expect(mockOnError).toHaveBeenCalledTimes(1);
186+
expect(mockOnError).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
187+
188+
expect(mockCaptureException).toHaveBeenCalledTimes(1);
189+
expect(mockCaptureException).toHaveBeenCalledWith(expect.any(Error));
190+
191+
expect(mockSetExtra).toHaveBeenCalledTimes(1);
192+
expect(mockSetExtra).toHaveBeenCalledWith('componentStack', expect.any(String));
193+
});
194+
195+
it('shows a Sentry Report Dialog with correct options', () => {
196+
const options = { title: 'custom title' };
197+
render(
198+
<TestApp fallback={<p>You have hit an error</p>} showDialog dialogOptions={options}>
199+
<h1>children</h1>
200+
</TestApp>,
201+
);
202+
203+
expect(mockShowReportDialog).toHaveBeenCalledTimes(0);
204+
205+
const btn = screen.getByTestId('errorBtn');
206+
fireEvent.click(btn);
207+
208+
expect(mockShowReportDialog).toHaveBeenCalledTimes(1);
209+
expect(mockShowReportDialog).toHaveBeenCalledWith(options);
210+
});
211+
212+
it('it resets to initial state when reset', async () => {
213+
const mockOnReset = jest.fn();
214+
const { baseElement, debug } = render(
215+
<TestApp
216+
onReset={mockOnReset}
217+
fallbackRender={(_, __, resetErrorBoundary: () => void) => (
218+
<button data-testid="reset" onClick={resetErrorBoundary} />
219+
)}
220+
>
221+
<h1>children</h1>
222+
</TestApp>,
223+
);
224+
225+
expect(baseElement.outerHTML).toContain('<h1>children</h1>');
226+
expect(mockOnReset).toHaveBeenCalledTimes(0);
227+
228+
const btn = screen.getByTestId('errorBtn');
229+
fireEvent.click(btn);
230+
231+
expect(baseElement.outerHTML).toContain('<button data-testid="reset">');
232+
expect(mockOnReset).toHaveBeenCalledTimes(0);
233+
234+
const reset = screen.getByTestId('reset');
235+
fireEvent.click(reset);
236+
expect(mockOnReset).toHaveBeenCalledTimes(1);
237+
expect(mockOnReset).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
238+
});
30239
});
31240
});

packages/react/tslint.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
],
88
"variable-name": false,
99
"completed-docs": false,
10-
"interface-over-type-literal": false
10+
"interface-over-type-literal": false,
11+
"jsx-no-lambda": false,
12+
"jsx-boolean-value": false
1113
}
1214
}

0 commit comments

Comments
 (0)