Skip to content

Commit

Permalink
Merge 7934756 into 9282172
Browse files Browse the repository at this point in the history
  • Loading branch information
antonis authored Dec 6, 2024
2 parents 9282172 + 7934756 commit a3ba405
Show file tree
Hide file tree
Showing 8 changed files with 363 additions and 0 deletions.
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,29 @@
});
```

- Adds feedback form ([#4320](https://github.com/getsentry/sentry-react-native/pull/4328))

You can add the form component in your UI and customise it like:
```jsx
import { FeedbackForm } from "@sentry/react-native";
...
<FeedbackForm
{...props}
closeScreen={props.navigation.goBack}
styles={{
submitButton: {
backgroundColor: '#6a1b9a',
paddingVertical: 15,
borderRadius: 5,
alignItems: 'center',
marginBottom: 10,
},
}}
text={{namePlaceholder: 'Fullname'}}
/>
```
Check [the documentation](https://docs.sentry.io/platforms/react-native/user-feedback/) for more configuration options.

### Fixes

- Return `lastEventId` export from `@sentry/core` ([#4315](https://github.com/getsentry/sentry-react-native/pull/4315))
Expand Down
50 changes: 50 additions & 0 deletions packages/core/src/js/feedback/FeedbackForm.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { StyleSheet } from 'react-native';

const defaultStyles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
backgroundColor: '#fff',
},
title: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 20,
textAlign: 'center',
},
input: {
height: 50,
borderColor: '#ccc',
borderWidth: 1,
borderRadius: 5,
paddingHorizontal: 10,
marginBottom: 15,
fontSize: 16,
},
textArea: {
height: 100,
textAlignVertical: 'top',
},
submitButton: {
backgroundColor: '#6a1b9a',
paddingVertical: 15,
borderRadius: 5,
alignItems: 'center',
marginBottom: 10,
},
submitText: {
color: '#fff',
fontSize: 18,
fontWeight: 'bold',
},
cancelButton: {
paddingVertical: 15,
alignItems: 'center',
},
cancelText: {
color: '#6a1b9a',
fontSize: 16,
},
});

export default defaultStyles;
113 changes: 113 additions & 0 deletions packages/core/src/js/feedback/FeedbackForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { captureFeedback } from '@sentry/core';
import type { SendFeedbackParams } from '@sentry/types';
import * as React from 'react';
import type { KeyboardTypeOptions } from 'react-native';
import { Alert, Text, TextInput, TouchableOpacity, View } from 'react-native';

import defaultStyles from './FeedbackForm.styles';
import type { FeedbackFormProps, FeedbackFormState } from './FeedbackForm.types';

const defaultFormTitle = 'Feedback Form';
const defaultNamePlaceholder ='Name';
const defaultEmailPlaceholder = 'Email';
const defaultDescriptionPlaceholder = 'Description (required)';
const defaultSubmitButton = 'Send Feedback';
const defaultCancelButton = 'Cancel';
const defaultErrorTitle = 'Error';
const defaultFormError = 'Please fill out all required fields.';
const defaultEmailError = 'Please enter a valid email address.';

/**
* @beta
* Implements a feedback form screen that sends feedback to Sentry using Sentry.captureFeedback.
*/
export class FeedbackForm extends React.Component<FeedbackFormProps, FeedbackFormState> {
public constructor(props: FeedbackFormProps) {
super(props);
this.state = {
name: '',
email: '',
description: '',
};
}

public handleFeedbackSubmit: () => void = () => {
const { name, email, description } = this.state;
const { closeScreen, text } = this.props;

const trimmedName = name?.trim();
const trimmedEmail = email?.trim();
const trimmedDescription = description?.trim();

if (!trimmedName || !trimmedEmail || !trimmedDescription) {
const errorMessage = text?.formError || defaultFormError;
Alert.alert(text?.errorTitle || defaultErrorTitle, errorMessage);
return;
}

if (!this._isValidEmail(trimmedEmail)) {
const errorMessage = text?.emailError || defaultEmailError;
Alert.alert(text?.errorTitle || defaultErrorTitle, errorMessage);
return;
}

const userFeedback: SendFeedbackParams = {
message: trimmedDescription,
name: trimmedName,
email: trimmedEmail,
};

captureFeedback(userFeedback);
closeScreen();
};

/**
* Renders the feedback form screen.
*/
public render(): React.ReactNode {
const { closeScreen, text, styles } = this.props;
const { name, email, description } = this.state;

return (
<View style={styles?.container || defaultStyles.container}>
<Text style={styles?.title || defaultStyles.title}>{text?.formTitle || defaultFormTitle}</Text>

<TextInput
style={styles?.input || defaultStyles.input}
placeholder={text?.namePlaceholder || defaultNamePlaceholder}
value={name}
onChangeText={(value) => this.setState({ name: value })}
/>

<TextInput
style={styles?.input || defaultStyles.input}
placeholder={text?.emailPlaceholder || defaultEmailPlaceholder}
keyboardType={'email-address' as KeyboardTypeOptions}
value={email}
onChangeText={(value) => this.setState({ email: value })}
/>

<TextInput
style={[styles?.input || defaultStyles.input, styles?.textArea || defaultStyles.textArea]}
placeholder={text?.descriptionPlaceholder || defaultDescriptionPlaceholder}
value={description}
onChangeText={(value) => this.setState({ description: value })}
multiline
/>

<TouchableOpacity style={styles?.submitButton || defaultStyles.submitButton} onPress={this.handleFeedbackSubmit}>
<Text style={styles?.submitText || defaultStyles.submitText}>{text?.submitButton || defaultSubmitButton}</Text>
</TouchableOpacity>

<TouchableOpacity style={styles?.cancelButton || defaultStyles.cancelButton} onPress={closeScreen}>
<Text style={styles?.cancelText || defaultStyles.cancelText}>{text?.cancelButton || defaultCancelButton}</Text>
</TouchableOpacity>
</View>
);
}

private _isValidEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
}
36 changes: 36 additions & 0 deletions packages/core/src/js/feedback/FeedbackForm.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { TextStyle, ViewStyle } from 'react-native';

export interface FeedbackFormProps {
closeScreen: () => void;
text: FeedbackFormText;
styles?: FeedbackFormStyles;
}

export interface FeedbackFormText {
formTitle?: string;
namePlaceholder?: string;
emailPlaceholder?: string;
descriptionPlaceholder?: string;
submitButton?: string;
cancelButton?: string;
errorTitle?: string;
formError?: string;
emailError?: string;
}

export interface FeedbackFormStyles {
container?: ViewStyle;
title?: TextStyle;
input?: TextStyle;
textArea?: ViewStyle;
submitButton?: ViewStyle;
submitText?: TextStyle;
cancelButton?: ViewStyle;
cancelText?: TextStyle;
}

export interface FeedbackFormState {
name: string;
email: string;
description: string;
}
2 changes: 2 additions & 0 deletions packages/core/src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,5 @@ export {
export type { TimeToDisplayProps } from './tracing';

export { Mask, Unmask } from './replay/CustomMask';

export { FeedbackForm } from './feedback/FeedbackForm';
111 changes: 111 additions & 0 deletions packages/core/test/feedback/FeedbackForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { captureFeedback } from '@sentry/core';
import { fireEvent, render, waitFor } from '@testing-library/react-native';
import * as React from 'react';
import { Alert } from 'react-native';

import { FeedbackForm } from '../../src/js/feedback/FeedbackForm';
import type { FeedbackFormProps } from '../../src/js/feedback/FeedbackForm.types';

const mockCloseScreen = jest.fn();

jest.spyOn(Alert, 'alert');

jest.mock('@sentry/core', () => ({
captureFeedback: jest.fn(),
}));

const defaultProps: FeedbackFormProps = {
closeScreen: mockCloseScreen,
text: {
formTitle: 'Feedback Form',
namePlaceholder: 'Name',
emailPlaceholder: 'Email',
descriptionPlaceholder: 'Description',
submitButton: 'Submit',
cancelButton: 'Cancel',
errorTitle: 'Error',
formError: 'Please fill out all required fields.',
emailError: 'The email address is not valid.',
},
};

describe('FeedbackForm', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('renders correctly', () => {
const { getByPlaceholderText, getByText } = render(<FeedbackForm {...defaultProps} />);

expect(getByText(defaultProps.text.formTitle)).toBeTruthy();
expect(getByPlaceholderText(defaultProps.text.namePlaceholder)).toBeTruthy();
expect(getByPlaceholderText(defaultProps.text.emailPlaceholder)).toBeTruthy();
expect(getByPlaceholderText(defaultProps.text.descriptionPlaceholder)).toBeTruthy();
expect(getByText(defaultProps.text.submitButton)).toBeTruthy();
expect(getByText(defaultProps.text.cancelButton)).toBeTruthy();
});

it('shows an error message if required fields are empty', async () => {
const { getByText } = render(<FeedbackForm {...defaultProps} />);

fireEvent.press(getByText(defaultProps.text.submitButton));

await waitFor(() => {
expect(Alert.alert).toHaveBeenCalledWith(defaultProps.text.errorTitle, defaultProps.text.formError);
});
});

it('shows an error message if the email is not valid', async () => {
const { getByPlaceholderText, getByText } = render(<FeedbackForm {...defaultProps} />);

fireEvent.changeText(getByPlaceholderText(defaultProps.text.namePlaceholder), 'John Doe');
fireEvent.changeText(getByPlaceholderText(defaultProps.text.emailPlaceholder), 'not-an-email');
fireEvent.changeText(getByPlaceholderText(defaultProps.text.descriptionPlaceholder), 'This is a feedback message.');

fireEvent.press(getByText(defaultProps.text.submitButton));

await waitFor(() => {
expect(Alert.alert).toHaveBeenCalledWith(defaultProps.text.errorTitle, defaultProps.text.emailError);
});
});

it('calls captureFeedback when the form is submitted successfully', async () => {
const { getByPlaceholderText, getByText } = render(<FeedbackForm {...defaultProps} />);

fireEvent.changeText(getByPlaceholderText(defaultProps.text.namePlaceholder), 'John Doe');
fireEvent.changeText(getByPlaceholderText(defaultProps.text.emailPlaceholder), '[email protected]');
fireEvent.changeText(getByPlaceholderText(defaultProps.text.descriptionPlaceholder), 'This is a feedback message.');

fireEvent.press(getByText(defaultProps.text.submitButton));

await waitFor(() => {
expect(captureFeedback).toHaveBeenCalledWith({
message: 'This is a feedback message.',
name: 'John Doe',
email: '[email protected]',
});
});
});

it('calls closeScreen when the form is submitted successfully', async () => {
const { getByPlaceholderText, getByText } = render(<FeedbackForm {...defaultProps} />);

fireEvent.changeText(getByPlaceholderText(defaultProps.text.namePlaceholder), 'John Doe');
fireEvent.changeText(getByPlaceholderText(defaultProps.text.emailPlaceholder), '[email protected]');
fireEvent.changeText(getByPlaceholderText(defaultProps.text.descriptionPlaceholder), 'This is a feedback message.');

fireEvent.press(getByText(defaultProps.text.submitButton));

await waitFor(() => {
expect(mockCloseScreen).toHaveBeenCalled();
});
});

it('calls closeScreen when the cancel button is pressed', () => {
const { getByText } = render(<FeedbackForm {...defaultProps} />);

fireEvent.press(getByText(defaultProps.text.cancelButton));

expect(mockCloseScreen).toHaveBeenCalled();
});
});
22 changes: 22 additions & 0 deletions samples/react-native/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import Animated, {

// Import the Sentry React Native SDK
import * as Sentry from '@sentry/react-native';
import { FeedbackForm } from '@sentry/react-native';

import { SENTRY_INTERNAL_DSN } from './dsn';
import ErrorsScreen from './Screens/ErrorsScreen';
Expand Down Expand Up @@ -151,6 +152,27 @@ const ErrorsTabNavigator = Sentry.withProfiler(
component={ErrorsScreen}
options={{ title: 'Errors' }}
/>
<Stack.Screen
name="FeedbackForm"
options={{ presentation: 'modal', headerShown: false }}
>
{(props) => (
<FeedbackForm
{...props}
closeScreen={props.navigation.goBack}
styles={{
submitButton: {
backgroundColor: '#6a1b9a',
paddingVertical: 15,
borderRadius: 5,
alignItems: 'center',
marginBottom: 10,
},
}}
text={{namePlaceholder: 'Fullname'}}
/>
)}
</Stack.Screen>
</Stack.Navigator>
</Provider>
</GestureHandlerRootView>
Expand Down
6 changes: 6 additions & 0 deletions samples/react-native/src/Screens/ErrorsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,12 @@ const ErrorsScreen = (_props: Props) => {
}
}}
/>
<Button
title="Feedback form"
onPress={() => {
_props.navigation.navigate('FeedbackForm');
}}
/>
<Button
title="Send user feedback"
onPress={() => {
Expand Down

0 comments on commit a3ba405

Please sign in to comment.