Skip to content

Commit 27e1bf3

Browse files
authored
Merge 14ac005 into f6c37bb
2 parents f6c37bb + 14ac005 commit 27e1bf3

File tree

8 files changed

+352
-0
lines changed

8 files changed

+352
-0
lines changed

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,29 @@
3535
});
3636
```
3737

38+
- Adds feedback form ([#4320](https://github.com/getsentry/sentry-react-native/pull/4328))
39+
40+
You can add the form component in your UI and customise it like:
41+
```jsx
42+
import { FeedbackFormScreen } from "@sentry/react-native";
43+
...
44+
<FeedbackFormScreen
45+
{...props}
46+
closeScreen={props.navigation.goBack}
47+
styles={{
48+
submitButton: {
49+
backgroundColor: '#6a1b9a',
50+
paddingVertical: 15,
51+
borderRadius: 5,
52+
alignItems: 'center',
53+
marginBottom: 10,
54+
},
55+
}}
56+
text={{namePlaceholder: 'Fullname'}}
57+
/>
58+
```
59+
Check [the documentation](https://docs.sentry.io/platforms/react-native/user-feedback/) for more configuration options.
60+
3861
### Fixes
3962

4063
- Return `lastEventId` export from `@sentry/core` ([#4315](https://github.com/getsentry/sentry-react-native/pull/4315))
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { StyleSheet } from 'react-native';
2+
3+
const defaultStyles = StyleSheet.create({
4+
container: {
5+
flex: 1,
6+
padding: 20,
7+
backgroundColor: '#fff',
8+
},
9+
title: {
10+
fontSize: 24,
11+
fontWeight: 'bold',
12+
marginBottom: 20,
13+
textAlign: 'center',
14+
},
15+
input: {
16+
height: 50,
17+
borderColor: '#ccc',
18+
borderWidth: 1,
19+
borderRadius: 5,
20+
paddingHorizontal: 10,
21+
marginBottom: 15,
22+
fontSize: 16,
23+
},
24+
textArea: {
25+
height: 100,
26+
textAlignVertical: 'top',
27+
},
28+
submitButton: {
29+
backgroundColor: '#6a1b9a',
30+
paddingVertical: 15,
31+
borderRadius: 5,
32+
alignItems: 'center',
33+
marginBottom: 10,
34+
},
35+
submitText: {
36+
color: '#fff',
37+
fontSize: 18,
38+
fontWeight: 'bold',
39+
},
40+
cancelButton: {
41+
paddingVertical: 15,
42+
alignItems: 'center',
43+
},
44+
cancelText: {
45+
color: '#6a1b9a',
46+
fontSize: 16,
47+
},
48+
});
49+
50+
export default defaultStyles;
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { captureFeedback } from '@sentry/core';
2+
import type { SendFeedbackParams } from '@sentry/types';
3+
import * as React from 'react';
4+
import type { KeyboardTypeOptions } from 'react-native';
5+
import { Alert, Text, TextInput, TouchableOpacity, View } from 'react-native';
6+
7+
import defaultStyles from './FeedbackFormScreen.styles';
8+
import type { FeedbackFormScreenProps, FeedbackFormScreenState } from './FeedbackFormScreen.types';
9+
10+
/**
11+
* Implements a feedback form screen that sends feedback to Sentry using Sentry.captureFeedback.
12+
*/
13+
export class FeedbackFormScreen extends React.Component<FeedbackFormScreenProps, FeedbackFormScreenState> {
14+
public constructor(props: FeedbackFormScreenProps) {
15+
super(props);
16+
this.state = {
17+
name: '',
18+
email: '',
19+
description: '',
20+
};
21+
}
22+
23+
public handleFeedbackSubmit: () => void = () => {
24+
const { name, email, description } = this.state;
25+
const { closeScreen, text } = this.props;
26+
27+
const trimmedName = name?.trim();
28+
const trimmedEmail = email?.trim();
29+
const trimmedDescription = description?.trim();
30+
31+
if (!trimmedName || !trimmedEmail || !trimmedDescription) {
32+
const errorMessage = text?.formError || 'Please fill out all required fields.';
33+
Alert.alert(text?.errorTitle || 'Error', errorMessage);
34+
return;
35+
}
36+
37+
if (!this._isValidEmail(trimmedEmail)) {
38+
const errorMessage = text?.emailError || 'Please enter a valid email address.';
39+
Alert.alert(text?.errorTitle || 'Error', errorMessage);
40+
return;
41+
}
42+
43+
const userFeedback: SendFeedbackParams = {
44+
message: trimmedDescription,
45+
name: trimmedName,
46+
email: trimmedEmail,
47+
};
48+
49+
captureFeedback(userFeedback);
50+
closeScreen();
51+
};
52+
53+
/**
54+
* Renders the feedback form screen.
55+
*/
56+
public render(): React.ReactNode {
57+
const { closeScreen, text, styles } = this.props;
58+
const { name, email, description } = this.state;
59+
60+
return (
61+
<View style={styles?.container || defaultStyles.container}>
62+
<Text style={styles?.title || defaultStyles.title}>{text?.formTitle || 'Feedback Form'}</Text>
63+
64+
<TextInput
65+
style={styles?.input || defaultStyles.input}
66+
placeholder={text?.namePlaceholder || 'Name'}
67+
value={name}
68+
onChangeText={(value) => this.setState({ name: value })}
69+
/>
70+
71+
<TextInput
72+
style={styles?.input || defaultStyles.input}
73+
placeholder={text?.emailPlaceholder || 'Email'}
74+
keyboardType={'email-address' as KeyboardTypeOptions}
75+
value={email}
76+
onChangeText={(value) => this.setState({ email: value })}
77+
/>
78+
79+
<TextInput
80+
style={[styles?.input || defaultStyles.input, styles?.textArea || defaultStyles.textArea]}
81+
placeholder={text?.descriptionPlaceholder || 'Description (required)'}
82+
value={description}
83+
onChangeText={(value) => this.setState({ description: value })}
84+
multiline
85+
/>
86+
87+
<TouchableOpacity style={styles?.submitButton || defaultStyles.submitButton} onPress={this.handleFeedbackSubmit}>
88+
<Text style={styles?.submitText || defaultStyles.submitText}>{text?.submitButton || 'Send Feedback'}</Text>
89+
</TouchableOpacity>
90+
91+
<TouchableOpacity style={styles?.cancelButton || defaultStyles.cancelButton} onPress={closeScreen}>
92+
<Text style={styles?.cancelText || defaultStyles.cancelText}>{text?.cancelButton || 'Cancel'}</Text>
93+
</TouchableOpacity>
94+
</View>
95+
);
96+
}
97+
98+
private _isValidEmail = (email: string): boolean => {
99+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
100+
return emailRegex.test(email);
101+
};
102+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { TextStyle, ViewStyle } from 'react-native';
2+
3+
export interface FeedbackFormScreenProps {
4+
closeScreen: () => void;
5+
text: FeedbackFormText;
6+
styles?: FeedbackFormScreenStyles;
7+
}
8+
9+
export interface FeedbackFormText {
10+
formTitle?: string;
11+
namePlaceholder?: string;
12+
emailPlaceholder?: string;
13+
descriptionPlaceholder?: string;
14+
submitButton?: string;
15+
cancelButton?: string;
16+
errorTitle?: string;
17+
formError?: string;
18+
emailError?: string;
19+
}
20+
21+
export interface FeedbackFormScreenStyles {
22+
container?: ViewStyle;
23+
title?: TextStyle;
24+
input?: TextStyle;
25+
textArea?: ViewStyle;
26+
submitButton?: ViewStyle;
27+
submitText?: TextStyle;
28+
cancelButton?: ViewStyle;
29+
cancelText?: TextStyle;
30+
}
31+
32+
export interface FeedbackFormScreenState {
33+
name: string;
34+
email: string;
35+
description: string;
36+
}

packages/core/src/js/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,5 @@ export {
8383
export type { TimeToDisplayProps } from './tracing';
8484

8585
export { Mask, Unmask } from './replay/CustomMask';
86+
87+
export { FeedbackFormScreen } from './feedback/FeedbackFormScreen';
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { captureFeedback } from '@sentry/core';
2+
import { fireEvent, render, waitFor } from '@testing-library/react-native';
3+
import * as React from 'react';
4+
import { Alert } from 'react-native';
5+
6+
import { FeedbackFormScreen } from '../../src/js/feedback/FeedbackFormScreen';
7+
import type { FeedbackFormScreenProps } from '../../src/js/feedback/FeedbackFormScreen.types';
8+
9+
const mockCloseScreen = jest.fn();
10+
11+
jest.spyOn(Alert, 'alert');
12+
13+
jest.mock('@sentry/core', () => ({
14+
captureFeedback: jest.fn(),
15+
}));
16+
17+
const defaultProps: FeedbackFormScreenProps = {
18+
closeScreen: mockCloseScreen,
19+
text: {
20+
formTitle: 'Feedback Form',
21+
namePlaceholder: 'Name',
22+
emailPlaceholder: 'Email',
23+
descriptionPlaceholder: 'Description',
24+
submitButton: 'Submit',
25+
cancelButton: 'Cancel',
26+
errorTitle: 'Error',
27+
formError: 'Please fill out all required fields.',
28+
emailError: 'The email address is not valid.',
29+
},
30+
};
31+
32+
describe('FeedbackFormScreen', () => {
33+
afterEach(() => {
34+
jest.clearAllMocks();
35+
});
36+
37+
it('renders correctly', () => {
38+
const { getByPlaceholderText, getByText } = render(<FeedbackFormScreen {...defaultProps} />);
39+
40+
expect(getByText(defaultProps.text.formTitle)).toBeTruthy();
41+
expect(getByPlaceholderText(defaultProps.text.namePlaceholder)).toBeTruthy();
42+
expect(getByPlaceholderText(defaultProps.text.emailPlaceholder)).toBeTruthy();
43+
expect(getByPlaceholderText(defaultProps.text.descriptionPlaceholder)).toBeTruthy();
44+
expect(getByText(defaultProps.text.submitButton)).toBeTruthy();
45+
expect(getByText(defaultProps.text.cancelButton)).toBeTruthy();
46+
});
47+
48+
it('shows an error message if required fields are empty', async () => {
49+
const { getByText } = render(<FeedbackFormScreen {...defaultProps} />);
50+
51+
fireEvent.press(getByText(defaultProps.text.submitButton));
52+
53+
await waitFor(() => {
54+
expect(Alert.alert).toHaveBeenCalledWith(defaultProps.text.errorTitle, defaultProps.text.formError);
55+
});
56+
});
57+
58+
it('shows an error message if the email is not valid', async () => {
59+
const { getByPlaceholderText, getByText } = render(<FeedbackFormScreen {...defaultProps} />);
60+
61+
fireEvent.changeText(getByPlaceholderText(defaultProps.text.namePlaceholder), 'John Doe');
62+
fireEvent.changeText(getByPlaceholderText(defaultProps.text.emailPlaceholder), 'not-an-email');
63+
fireEvent.changeText(getByPlaceholderText(defaultProps.text.descriptionPlaceholder), 'This is a feedback message.');
64+
65+
fireEvent.press(getByText(defaultProps.text.submitButton));
66+
67+
await waitFor(() => {
68+
expect(Alert.alert).toHaveBeenCalledWith(defaultProps.text.errorTitle, defaultProps.text.emailError);
69+
});
70+
});
71+
72+
it('calls captureFeedback when the form is submitted successfully', async () => {
73+
const { getByPlaceholderText, getByText } = render(<FeedbackFormScreen {...defaultProps} />);
74+
75+
fireEvent.changeText(getByPlaceholderText(defaultProps.text.namePlaceholder), 'John Doe');
76+
fireEvent.changeText(getByPlaceholderText(defaultProps.text.emailPlaceholder), '[email protected]');
77+
fireEvent.changeText(getByPlaceholderText(defaultProps.text.descriptionPlaceholder), 'This is a feedback message.');
78+
79+
fireEvent.press(getByText(defaultProps.text.submitButton));
80+
81+
await waitFor(() => {
82+
expect(captureFeedback).toHaveBeenCalledWith({
83+
message: 'This is a feedback message.',
84+
name: 'John Doe',
85+
86+
});
87+
});
88+
});
89+
90+
it('calls closeScreen when the form is submitted successfully', async () => {
91+
const { getByPlaceholderText, getByText } = render(<FeedbackFormScreen {...defaultProps} />);
92+
93+
fireEvent.changeText(getByPlaceholderText(defaultProps.text.namePlaceholder), 'John Doe');
94+
fireEvent.changeText(getByPlaceholderText(defaultProps.text.emailPlaceholder), '[email protected]');
95+
fireEvent.changeText(getByPlaceholderText(defaultProps.text.descriptionPlaceholder), 'This is a feedback message.');
96+
97+
fireEvent.press(getByText(defaultProps.text.submitButton));
98+
99+
await waitFor(() => {
100+
expect(mockCloseScreen).toHaveBeenCalled();
101+
});
102+
});
103+
104+
it('calls closeScreen when the cancel button is pressed', () => {
105+
const { getByText } = render(<FeedbackFormScreen {...defaultProps} />);
106+
107+
fireEvent.press(getByText(defaultProps.text.cancelButton));
108+
109+
expect(mockCloseScreen).toHaveBeenCalled();
110+
});
111+
});

samples/react-native/src/App.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import Animated, {
1616

1717
// Import the Sentry React Native SDK
1818
import * as Sentry from '@sentry/react-native';
19+
import { FeedbackFormScreen } from '@sentry/react-native';
1920

2021
import { SENTRY_INTERNAL_DSN } from './dsn';
2122
import ErrorsScreen from './Screens/ErrorsScreen';
@@ -151,6 +152,27 @@ const ErrorsTabNavigator = Sentry.withProfiler(
151152
component={ErrorsScreen}
152153
options={{ title: 'Errors' }}
153154
/>
155+
<Stack.Screen
156+
name="FeedbackForm"
157+
options={{ presentation: 'modal', headerShown: false }}
158+
>
159+
{(props) => (
160+
<FeedbackFormScreen
161+
{...props}
162+
closeScreen={props.navigation.goBack}
163+
styles={{
164+
submitButton: {
165+
backgroundColor: '#6a1b9a',
166+
paddingVertical: 15,
167+
borderRadius: 5,
168+
alignItems: 'center',
169+
marginBottom: 10,
170+
},
171+
}}
172+
text={{namePlaceholder: 'Fullname'}}
173+
/>
174+
)}
175+
</Stack.Screen>
154176
</Stack.Navigator>
155177
</Provider>
156178
</GestureHandlerRootView>

samples/react-native/src/Screens/ErrorsScreen.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,12 @@ const ErrorsScreen = (_props: Props) => {
220220
}
221221
}}
222222
/>
223+
<Button
224+
title="Feedback form"
225+
onPress={() => {
226+
_props.navigation.navigate('FeedbackForm');
227+
}}
228+
/>
223229
<Button
224230
title="Send user feedback"
225231
onPress={() => {

0 commit comments

Comments
 (0)