Skip to content

Commit a3ba405

Browse files
authored
Merge 7934756 into 9282172
2 parents 9282172 + 7934756 commit a3ba405

File tree

8 files changed

+363
-0
lines changed

8 files changed

+363
-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 { FeedbackForm } from "@sentry/react-native";
43+
...
44+
<FeedbackForm
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: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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 './FeedbackForm.styles';
8+
import type { FeedbackFormProps, FeedbackFormState } from './FeedbackForm.types';
9+
10+
const defaultFormTitle = 'Feedback Form';
11+
const defaultNamePlaceholder ='Name';
12+
const defaultEmailPlaceholder = 'Email';
13+
const defaultDescriptionPlaceholder = 'Description (required)';
14+
const defaultSubmitButton = 'Send Feedback';
15+
const defaultCancelButton = 'Cancel';
16+
const defaultErrorTitle = 'Error';
17+
const defaultFormError = 'Please fill out all required fields.';
18+
const defaultEmailError = 'Please enter a valid email address.';
19+
20+
/**
21+
* @beta
22+
* Implements a feedback form screen that sends feedback to Sentry using Sentry.captureFeedback.
23+
*/
24+
export class FeedbackForm extends React.Component<FeedbackFormProps, FeedbackFormState> {
25+
public constructor(props: FeedbackFormProps) {
26+
super(props);
27+
this.state = {
28+
name: '',
29+
email: '',
30+
description: '',
31+
};
32+
}
33+
34+
public handleFeedbackSubmit: () => void = () => {
35+
const { name, email, description } = this.state;
36+
const { closeScreen, text } = this.props;
37+
38+
const trimmedName = name?.trim();
39+
const trimmedEmail = email?.trim();
40+
const trimmedDescription = description?.trim();
41+
42+
if (!trimmedName || !trimmedEmail || !trimmedDescription) {
43+
const errorMessage = text?.formError || defaultFormError;
44+
Alert.alert(text?.errorTitle || defaultErrorTitle, errorMessage);
45+
return;
46+
}
47+
48+
if (!this._isValidEmail(trimmedEmail)) {
49+
const errorMessage = text?.emailError || defaultEmailError;
50+
Alert.alert(text?.errorTitle || defaultErrorTitle, errorMessage);
51+
return;
52+
}
53+
54+
const userFeedback: SendFeedbackParams = {
55+
message: trimmedDescription,
56+
name: trimmedName,
57+
email: trimmedEmail,
58+
};
59+
60+
captureFeedback(userFeedback);
61+
closeScreen();
62+
};
63+
64+
/**
65+
* Renders the feedback form screen.
66+
*/
67+
public render(): React.ReactNode {
68+
const { closeScreen, text, styles } = this.props;
69+
const { name, email, description } = this.state;
70+
71+
return (
72+
<View style={styles?.container || defaultStyles.container}>
73+
<Text style={styles?.title || defaultStyles.title}>{text?.formTitle || defaultFormTitle}</Text>
74+
75+
<TextInput
76+
style={styles?.input || defaultStyles.input}
77+
placeholder={text?.namePlaceholder || defaultNamePlaceholder}
78+
value={name}
79+
onChangeText={(value) => this.setState({ name: value })}
80+
/>
81+
82+
<TextInput
83+
style={styles?.input || defaultStyles.input}
84+
placeholder={text?.emailPlaceholder || defaultEmailPlaceholder}
85+
keyboardType={'email-address' as KeyboardTypeOptions}
86+
value={email}
87+
onChangeText={(value) => this.setState({ email: value })}
88+
/>
89+
90+
<TextInput
91+
style={[styles?.input || defaultStyles.input, styles?.textArea || defaultStyles.textArea]}
92+
placeholder={text?.descriptionPlaceholder || defaultDescriptionPlaceholder}
93+
value={description}
94+
onChangeText={(value) => this.setState({ description: value })}
95+
multiline
96+
/>
97+
98+
<TouchableOpacity style={styles?.submitButton || defaultStyles.submitButton} onPress={this.handleFeedbackSubmit}>
99+
<Text style={styles?.submitText || defaultStyles.submitText}>{text?.submitButton || defaultSubmitButton}</Text>
100+
</TouchableOpacity>
101+
102+
<TouchableOpacity style={styles?.cancelButton || defaultStyles.cancelButton} onPress={closeScreen}>
103+
<Text style={styles?.cancelText || defaultStyles.cancelText}>{text?.cancelButton || defaultCancelButton}</Text>
104+
</TouchableOpacity>
105+
</View>
106+
);
107+
}
108+
109+
private _isValidEmail = (email: string): boolean => {
110+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
111+
return emailRegex.test(email);
112+
};
113+
}
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 FeedbackFormProps {
4+
closeScreen: () => void;
5+
text: FeedbackFormText;
6+
styles?: FeedbackFormStyles;
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 FeedbackFormStyles {
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 FeedbackFormState {
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 { FeedbackForm } from './feedback/FeedbackForm';
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 { FeedbackForm } from '../../src/js/feedback/FeedbackForm';
7+
import type { FeedbackFormProps } from '../../src/js/feedback/FeedbackForm.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: FeedbackFormProps = {
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('FeedbackForm', () => {
33+
afterEach(() => {
34+
jest.clearAllMocks();
35+
});
36+
37+
it('renders correctly', () => {
38+
const { getByPlaceholderText, getByText } = render(<FeedbackForm {...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(<FeedbackForm {...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(<FeedbackForm {...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(<FeedbackForm {...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(<FeedbackForm {...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(<FeedbackForm {...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 { FeedbackForm } 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+
<FeedbackForm
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)