diff --git a/CHANGELOG.md b/CHANGELOG.md
index 25710580e..3493f9e1d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,8 +35,19 @@
});
```
+- Adds feedback form (beta) ([#4320](https://github.com/getsentry/sentry-react-native/pull/4328))
+
+ You can add the form component in your UI like:
+ ```jsx
+ import { FeedbackForm } from "@sentry/react-native";
+ ...
+
+ ```
+ Check [the documentation](https://docs.sentry.io/platforms/react-native/user-feedback/) for more configuration options.
+
- Export `Span` type from `@sentry/types` ([#4345](https://github.com/getsentry/sentry-react-native/pull/4345))
+
### Fixes
- Return `lastEventId` export from `@sentry/core` ([#4315](https://github.com/getsentry/sentry-react-native/pull/4315))
diff --git a/packages/core/src/js/feedback/FeedbackForm.styles.ts b/packages/core/src/js/feedback/FeedbackForm.styles.ts
new file mode 100644
index 000000000..d54fb9cd8
--- /dev/null
+++ b/packages/core/src/js/feedback/FeedbackForm.styles.ts
@@ -0,0 +1,54 @@
+import type { FeedbackFormStyles } from './FeedbackForm.types';
+
+const defaultStyles: FeedbackFormStyles = {
+ container: {
+ flex: 1,
+ padding: 20,
+ backgroundColor: '#fff',
+ },
+ title: {
+ fontSize: 24,
+ fontWeight: 'bold',
+ marginBottom: 20,
+ textAlign: 'center',
+ },
+ label: {
+ marginBottom: 4,
+ fontSize: 16,
+ },
+ 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;
diff --git a/packages/core/src/js/feedback/FeedbackForm.tsx b/packages/core/src/js/feedback/FeedbackForm.tsx
new file mode 100644
index 000000000..b79fe7935
--- /dev/null
+++ b/packages/core/src/js/feedback/FeedbackForm.tsx
@@ -0,0 +1,143 @@
+import { captureFeedback, lastEventId } 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 { defaultConfiguration } from './defaults';
+import defaultStyles from './FeedbackForm.styles';
+import type { FeedbackFormProps, FeedbackFormState, FeedbackFormStyles,FeedbackGeneralConfiguration, FeedbackTextConfiguration } from './FeedbackForm.types';
+
+/**
+ * @beta
+ * Implements a feedback form screen that sends feedback to Sentry using Sentry.captureFeedback.
+ */
+export class FeedbackForm extends React.Component {
+ public constructor(props: FeedbackFormProps) {
+ super(props);
+
+ const config: FeedbackGeneralConfiguration = { ...defaultConfiguration, ...props };
+ this.state = {
+ isVisible: true,
+ name: config.useSentryUser.name,
+ email: config.useSentryUser.email,
+ description: '',
+ };
+ }
+
+ public handleFeedbackSubmit: () => void = () => {
+ const { name, email, description } = this.state;
+ const { onFormClose } = { ...defaultConfiguration, ...this.props };
+ const config: FeedbackGeneralConfiguration = { ...defaultConfiguration, ...this.props };
+ const text: FeedbackTextConfiguration = { ...defaultConfiguration, ...this.props };
+
+ const trimmedName = name?.trim();
+ const trimmedEmail = email?.trim();
+ const trimmedDescription = description?.trim();
+
+ if ((config.isNameRequired && !trimmedName) || (config.isEmailRequired && !trimmedEmail) || !trimmedDescription) {
+ Alert.alert(text.errorTitle, text.formError);
+ return;
+ }
+
+ if ((config.isEmailRequired || trimmedEmail.length > 0) && !this._isValidEmail(trimmedEmail)) {
+ Alert.alert(text.errorTitle, text.emailError);
+ return;
+ }
+
+ const eventId = lastEventId();
+ const userFeedback: SendFeedbackParams = {
+ message: trimmedDescription,
+ name: trimmedName,
+ email: trimmedEmail,
+ associatedEventId: eventId,
+ };
+
+ onFormClose();
+ this.setState({ isVisible: false });
+
+ captureFeedback(userFeedback);
+ Alert.alert(text.successMessageText);
+ };
+
+ /**
+ * Renders the feedback form screen.
+ */
+ public render(): React.ReactNode {
+ const { name, email, description } = this.state;
+ const { onFormClose } = { ...defaultConfiguration, ...this.props };
+ const config: FeedbackGeneralConfiguration = { ...defaultConfiguration, ...this.props };
+ const text: FeedbackTextConfiguration = { ...defaultConfiguration, ...this.props };
+ const styles: FeedbackFormStyles = { ...defaultStyles, ...this.props.styles };
+ const onCancel = (): void => {
+ onFormClose();
+ this.setState({ isVisible: false });
+ }
+
+ if (!this.state.isVisible) {
+ return null;
+ }
+
+ return (
+
+ {text.formTitle}
+
+ {config.showName && (
+ <>
+
+ {text.nameLabel}
+ {config.isNameRequired && ` ${text.isRequiredLabel}`}
+
+ this.setState({ name: value })}
+ />
+ >
+ )}
+
+ {config.showEmail && (
+ <>
+
+ {text.emailLabel}
+ {config.isEmailRequired && ` ${text.isRequiredLabel}`}
+
+ this.setState({ email: value })}
+ />
+ >
+ )}
+
+
+ {text.messageLabel}
+ {` ${text.isRequiredLabel}`}
+
+ this.setState({ description: value })}
+ multiline
+ />
+
+
+ {text.submitButtonLabel}
+
+
+
+ {text.cancelButtonLabel}
+
+
+ );
+ }
+
+ private _isValidEmail = (email: string): boolean => {
+ const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
+ return emailRegex.test(email);
+ };
+}
diff --git a/packages/core/src/js/feedback/FeedbackForm.types.ts b/packages/core/src/js/feedback/FeedbackForm.types.ts
new file mode 100644
index 000000000..aac7a1e1b
--- /dev/null
+++ b/packages/core/src/js/feedback/FeedbackForm.types.ts
@@ -0,0 +1,143 @@
+import type { TextStyle, ViewStyle } from 'react-native';
+
+export interface FeedbackFormProps extends FeedbackGeneralConfiguration, FeedbackTextConfiguration, FeedbackCallbacks {
+ styles?: FeedbackFormStyles;
+}
+
+/**
+ * General feedback configuration
+ */
+export interface FeedbackGeneralConfiguration {
+ /**
+ * Should the email field be required?
+ */
+ isEmailRequired?: boolean;
+
+ /**
+ * Should the name field be required?
+ */
+ isNameRequired?: boolean;
+
+ /**
+ * Should the email input field be visible? Note: email will still be collected if set via `Sentry.setUser()`
+ */
+ showEmail?: boolean;
+
+ /**
+ * Should the name input field be visible? Note: name will still be collected if set via `Sentry.setUser()`
+ */
+ showName?: boolean;
+
+ /**
+ * Fill in email/name input fields with Sentry user context if it exists.
+ * The value of the email/name keys represent the properties of your user context.
+ */
+ useSentryUser?: {
+ email: string;
+ name: string;
+ };
+}
+
+/**
+ * All of the different text labels that can be customized
+ */
+export interface FeedbackTextConfiguration {
+ /**
+ * The label for the Feedback form cancel button that closes dialog
+ */
+ cancelButtonLabel?: string;
+
+ /**
+ * The label for the Feedback form submit button that sends feedback
+ */
+ submitButtonLabel?: string;
+
+ /**
+ * The title of the Feedback form
+ */
+ formTitle?: string;
+
+ /**
+ * Label for the email input
+ */
+ emailLabel?: string;
+
+ /**
+ * Placeholder text for Feedback email input
+ */
+ emailPlaceholder?: string;
+
+ /**
+ * Label for the message input
+ */
+ messageLabel?: string;
+
+ /**
+ * Placeholder text for Feedback message input
+ */
+ messagePlaceholder?: string;
+
+ /**
+ * Label for the name input
+ */
+ nameLabel?: string;
+
+ /**
+ * Message after feedback was sent successfully
+ */
+ successMessageText?: string;
+
+ /**
+ * Placeholder text for Feedback name input
+ */
+ namePlaceholder?: string;
+
+ /**
+ * Text which indicates that a field is required
+ */
+ isRequiredLabel?: string;
+
+ /**
+ * The title of the error dialog
+ */
+ errorTitle?: string;
+
+ /**
+ * The error message when the form is invalid
+ */
+ formError?: string;
+
+ /**
+ * The error message when the email is invalid
+ */
+ emailError?: string;
+}
+
+/**
+ * The public callbacks available for the feedback integration
+ */
+export interface FeedbackCallbacks {
+ /**
+ * Callback when form is closed and not submitted
+ */
+ onFormClose?: () => void;
+}
+
+export interface FeedbackFormStyles {
+ container?: ViewStyle;
+ title?: TextStyle;
+ label?: TextStyle;
+ input?: TextStyle;
+ textArea?: TextStyle;
+ submitButton?: ViewStyle;
+ submitText?: TextStyle;
+ cancelButton?: ViewStyle;
+ cancelText?: TextStyle;
+}
+
+export interface FeedbackFormState {
+ isVisible: boolean;
+ name: string;
+ email: string;
+ description: string;
+}
diff --git a/packages/core/src/js/feedback/defaults.ts b/packages/core/src/js/feedback/defaults.ts
new file mode 100644
index 000000000..5b0360ec4
--- /dev/null
+++ b/packages/core/src/js/feedback/defaults.ts
@@ -0,0 +1,57 @@
+import { getCurrentScope } from '@sentry/core';
+import { Alert } from 'react-native';
+
+import type { FeedbackFormProps } from './FeedbackForm.types';
+
+const FORM_TITLE = 'Report a Bug';
+const NAME_PLACEHOLDER = 'Your Name';
+const NAME_LABEL = 'Name';
+const EMAIL_PLACEHOLDER = 'your.email@example.org';
+const EMAIL_LABEL = 'Email';
+const MESSAGE_PLACEHOLDER = "What's the bug? What did you expect?";
+const MESSAGE_LABEL = 'Description';
+const IS_REQUIRED_LABEL = '(required)';
+const SUBMIT_BUTTON_LABEL = 'Send Bug Report';
+const CANCEL_BUTTON_LABEL = 'Cancel';
+const ERROR_TITLE = 'Error';
+const FORM_ERROR = 'Please fill out all required fields.';
+const EMAIL_ERROR = 'Please enter a valid email address.';
+const SUCCESS_MESSAGE_TEXT = 'Thank you for your report!';
+
+export const defaultConfiguration: Partial = {
+ // FeedbackCallbacks
+ onFormClose: () => {
+ if (__DEV__) {
+ Alert.alert(
+ 'Development note',
+ 'onFormClose callback is not implemented. By default the form is just unmounted.',
+ );
+ }
+ },
+
+ // FeedbackGeneralConfiguration
+ isEmailRequired: false,
+ isNameRequired: false,
+ showEmail: true,
+ showName: true,
+ useSentryUser: {
+ email: getCurrentScope().getUser().email || '',
+ name: getCurrentScope().getUser().name || '',
+ },
+
+ // FeedbackTextConfiguration
+ cancelButtonLabel: CANCEL_BUTTON_LABEL,
+ emailLabel: EMAIL_LABEL,
+ emailPlaceholder: EMAIL_PLACEHOLDER,
+ formTitle: FORM_TITLE,
+ isRequiredLabel: IS_REQUIRED_LABEL,
+ messageLabel: MESSAGE_LABEL,
+ messagePlaceholder: MESSAGE_PLACEHOLDER,
+ nameLabel: NAME_LABEL,
+ namePlaceholder: NAME_PLACEHOLDER,
+ submitButtonLabel: SUBMIT_BUTTON_LABEL,
+ errorTitle: ERROR_TITLE,
+ formError: FORM_ERROR,
+ emailError: EMAIL_ERROR,
+ successMessageText: SUCCESS_MESSAGE_TEXT,
+};
diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts
index af1c1d985..97775503f 100644
--- a/packages/core/src/js/index.ts
+++ b/packages/core/src/js/index.ts
@@ -84,3 +84,5 @@ export {
export type { TimeToDisplayProps } from './tracing';
export { Mask, Unmask } from './replay/CustomMask';
+
+export { FeedbackForm } from './feedback/FeedbackForm';
diff --git a/packages/core/test/feedback/FeedbackForm.test.tsx b/packages/core/test/feedback/FeedbackForm.test.tsx
new file mode 100644
index 000000000..272072f41
--- /dev/null
+++ b/packages/core/test/feedback/FeedbackForm.test.tsx
@@ -0,0 +1,149 @@
+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 mockOnFormClose = jest.fn();
+
+jest.spyOn(Alert, 'alert');
+
+jest.mock('@sentry/core', () => ({
+ captureFeedback: jest.fn(),
+ getCurrentScope: jest.fn(() => ({
+ getUser: jest.fn(() => ({
+ email: 'test@example.com',
+ name: 'Test User',
+ })),
+ })),
+ lastEventId: jest.fn(),
+}));
+
+const defaultProps: FeedbackFormProps = {
+ onFormClose: mockOnFormClose,
+ formTitle: 'Feedback Form',
+ nameLabel: 'Name',
+ namePlaceholder: 'Name Placeholder',
+ emailLabel: 'Email',
+ emailPlaceholder: 'Email Placeholder',
+ messageLabel: 'Description',
+ messagePlaceholder: 'Description Placeholder',
+ submitButtonLabel: 'Submit',
+ cancelButtonLabel: 'Cancel',
+ isRequiredLabel: '(required)',
+ errorTitle: 'Error',
+ formError: 'Please fill out all required fields.',
+ emailError: 'The email address is not valid.',
+ successMessageText: 'Feedback success',
+};
+
+describe('FeedbackForm', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders correctly', () => {
+ const { getByPlaceholderText, getByText } = render();
+
+ expect(getByText(defaultProps.formTitle)).toBeTruthy();
+ expect(getByText(defaultProps.nameLabel)).toBeTruthy();
+ expect(getByPlaceholderText(defaultProps.namePlaceholder)).toBeTruthy();
+ expect(getByText(defaultProps.emailLabel)).toBeTruthy();
+ expect(getByPlaceholderText(defaultProps.emailPlaceholder)).toBeTruthy();
+ expect(getByText(`${defaultProps.messageLabel } ${ defaultProps.isRequiredLabel}`)).toBeTruthy();
+ expect(getByPlaceholderText(defaultProps.messagePlaceholder)).toBeTruthy();
+ expect(getByText(defaultProps.submitButtonLabel)).toBeTruthy();
+ expect(getByText(defaultProps.cancelButtonLabel)).toBeTruthy();
+ });
+
+ it('name and email are prefilled when sentry user is set', () => {
+ const { getByPlaceholderText } = render();
+
+ const nameInput = getByPlaceholderText(defaultProps.namePlaceholder);
+ const emailInput = getByPlaceholderText(defaultProps.emailPlaceholder);
+
+ expect(nameInput.props.value).toBe('Test User');
+ expect(emailInput.props.value).toBe('test@example.com');
+ });
+
+ it('shows an error message if required fields are empty', async () => {
+ const { getByText } = render();
+
+ fireEvent.press(getByText(defaultProps.submitButtonLabel));
+
+ await waitFor(() => {
+ expect(Alert.alert).toHaveBeenCalledWith(defaultProps.errorTitle, defaultProps.formError);
+ });
+ });
+
+ it('shows an error message if the email is not valid and the email is required', async () => {
+ const withEmailProps = {...defaultProps, ...{isEmailRequired: true}};
+ const { getByPlaceholderText, getByText } = render();
+
+ fireEvent.changeText(getByPlaceholderText(defaultProps.namePlaceholder), 'John Doe');
+ fireEvent.changeText(getByPlaceholderText(defaultProps.emailPlaceholder), 'not-an-email');
+ fireEvent.changeText(getByPlaceholderText(defaultProps.messagePlaceholder), 'This is a feedback message.');
+
+ fireEvent.press(getByText(defaultProps.submitButtonLabel));
+
+ await waitFor(() => {
+ expect(Alert.alert).toHaveBeenCalledWith(defaultProps.errorTitle, defaultProps.emailError);
+ });
+ });
+
+ it('calls captureFeedback when the form is submitted successfully', async () => {
+ const { getByPlaceholderText, getByText } = render();
+
+ fireEvent.changeText(getByPlaceholderText(defaultProps.namePlaceholder), 'John Doe');
+ fireEvent.changeText(getByPlaceholderText(defaultProps.emailPlaceholder), 'john.doe@example.com');
+ fireEvent.changeText(getByPlaceholderText(defaultProps.messagePlaceholder), 'This is a feedback message.');
+
+ fireEvent.press(getByText(defaultProps.submitButtonLabel));
+
+ await waitFor(() => {
+ expect(captureFeedback).toHaveBeenCalledWith({
+ message: 'This is a feedback message.',
+ name: 'John Doe',
+ email: 'john.doe@example.com',
+ });
+ });
+ });
+
+ it('shows success message when the form is submitted successfully', async () => {
+ const { getByPlaceholderText, getByText } = render();
+
+ fireEvent.changeText(getByPlaceholderText(defaultProps.namePlaceholder), 'John Doe');
+ fireEvent.changeText(getByPlaceholderText(defaultProps.emailPlaceholder), 'john.doe@example.com');
+ fireEvent.changeText(getByPlaceholderText(defaultProps.messagePlaceholder), 'This is a feedback message.');
+
+ fireEvent.press(getByText(defaultProps.submitButtonLabel));
+
+ await waitFor(() => {
+ expect(Alert.alert).toHaveBeenCalledWith(defaultProps.successMessageText);
+ });
+ });
+
+ it('calls onFormClose when the form is submitted successfully', async () => {
+ const { getByPlaceholderText, getByText } = render();
+
+ fireEvent.changeText(getByPlaceholderText(defaultProps.namePlaceholder), 'John Doe');
+ fireEvent.changeText(getByPlaceholderText(defaultProps.emailPlaceholder), 'john.doe@example.com');
+ fireEvent.changeText(getByPlaceholderText(defaultProps.messagePlaceholder), 'This is a feedback message.');
+
+ fireEvent.press(getByText(defaultProps.submitButtonLabel));
+
+ await waitFor(() => {
+ expect(mockOnFormClose).toHaveBeenCalled();
+ });
+ });
+
+ it('calls onFormClose when the cancel button is pressed', () => {
+ const { getByText } = render();
+
+ fireEvent.press(getByText(defaultProps.cancelButtonLabel));
+
+ expect(mockOnFormClose).toHaveBeenCalled();
+ });
+});
diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx
index 04348fa5c..0e3f0285a 100644
--- a/samples/react-native/src/App.tsx
+++ b/samples/react-native/src/App.tsx
@@ -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';
@@ -151,6 +152,27 @@ const ErrorsTabNavigator = Sentry.withProfiler(
component={ErrorsScreen}
options={{ title: 'Errors' }}
/>
+
+ {(props) => (
+
+ )}
+
diff --git a/samples/react-native/src/Screens/ErrorsScreen.tsx b/samples/react-native/src/Screens/ErrorsScreen.tsx
index 5f2f40567..4788fa407 100644
--- a/samples/react-native/src/Screens/ErrorsScreen.tsx
+++ b/samples/react-native/src/Screens/ErrorsScreen.tsx
@@ -220,6 +220,12 @@ const ErrorsScreen = (_props: Props) => {
}
}}
/>
+