diff --git a/CHANGELOG.md b/CHANGELOG.md index 0580a1b851..c97c86296c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ ## Unreleased +### Features + +- User Feedback Form Component Beta ([#4320](https://github.com/getsentry/sentry-react-native/pull/4328)) + + To collect user feedback from inside your application add the `FeedbackForm` component. + + ```jsx + import { FeedbackForm } from "@sentry/react-native"; + ... + + ``` + ### Fixes - Use proper SDK name for Session Replay tags ([#4428](https://github.com/getsentry/sentry-react-native/pull/4428)) 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 0000000000..836f4e1629 --- /dev/null +++ b/packages/core/src/js/feedback/FeedbackForm.styles.ts @@ -0,0 +1,71 @@ +import type { FeedbackFormStyles } from './FeedbackForm.types'; + +const PURPLE = 'rgba(88, 74, 192, 1)'; +const FORGROUND_COLOR = '#2b2233'; +const BACKROUND_COLOR = '#ffffff'; +const BORDER_COLOR = 'rgba(41, 35, 47, 0.13)'; + +const defaultStyles: FeedbackFormStyles = { + container: { + flex: 1, + padding: 20, + backgroundColor: BACKROUND_COLOR, + }, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 20, + textAlign: 'left', + flex: 1, + color: FORGROUND_COLOR, + }, + label: { + marginBottom: 4, + fontSize: 16, + color: FORGROUND_COLOR, + }, + input: { + height: 50, + borderColor: BORDER_COLOR, + borderWidth: 1, + borderRadius: 5, + paddingHorizontal: 10, + marginBottom: 15, + fontSize: 16, + color: FORGROUND_COLOR, + }, + textArea: { + height: 100, + textAlignVertical: 'top', + color: FORGROUND_COLOR, + }, + submitButton: { + backgroundColor: PURPLE, + paddingVertical: 15, + borderRadius: 5, + alignItems: 'center', + marginBottom: 10, + }, + submitText: { + color: BACKROUND_COLOR, + fontSize: 18, + }, + cancelButton: { + paddingVertical: 15, + alignItems: 'center', + }, + cancelText: { + color: FORGROUND_COLOR, + fontSize: 16, + }, + titleContainer: { + flexDirection: 'row', + width: '100%', + }, + sentryLogo: { + width: 40, + height: 40, + }, +}; + +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 0000000000..6e741b655d --- /dev/null +++ b/packages/core/src/js/feedback/FeedbackForm.tsx @@ -0,0 +1,182 @@ +import type { SendFeedbackParams } from '@sentry/core'; +import { captureFeedback, getCurrentScope, lastEventId } from '@sentry/core'; +import * as React from 'react'; +import type { KeyboardTypeOptions } from 'react-native'; +import { + Alert, + Image, + Keyboard, + KeyboardAvoidingView, + SafeAreaView, + ScrollView, + Text, + TextInput, + TouchableOpacity, + TouchableWithoutFeedback, + View +} from 'react-native'; + +import { sentryLogo } from './branding'; +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 static defaultProps: Partial = { + ...defaultConfiguration + } + + public constructor(props: FeedbackFormProps) { + super(props); + + const currentUser = { + useSentryUser: { + email: this.props?.useSentryUser?.email || getCurrentScope()?.getUser()?.email || '', + name: this.props?.useSentryUser?.name || getCurrentScope()?.getUser()?.name || '', + } + } + + this.state = { + isVisible: true, + name: currentUser.useSentryUser.name, + email: currentUser.useSentryUser.email, + description: '', + }; + } + + public handleFeedbackSubmit: () => void = () => { + const { name, email, description } = this.state; + const { onFormClose } = this.props; + const text: FeedbackTextConfiguration = this.props; + + const trimmedName = name?.trim(); + const trimmedEmail = email?.trim(); + const trimmedDescription = description?.trim(); + + if ((this.props.isNameRequired && !trimmedName) || (this.props.isEmailRequired && !trimmedEmail) || !trimmedDescription) { + Alert.alert(text.errorTitle, text.formError); + return; + } + + if (this.props.shouldValidateEmail && (this.props.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 } = this.props; + const config: FeedbackGeneralConfiguration = this.props; + const text: FeedbackTextConfiguration = 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.showBranding && ( + + )} + + + {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 0000000000..9805e166c1 --- /dev/null +++ b/packages/core/src/js/feedback/FeedbackForm.types.ts @@ -0,0 +1,166 @@ +import type { ImageStyle, TextStyle, ViewStyle } from 'react-native'; + +/** + * The props for the feedback form + */ +export interface FeedbackFormProps extends FeedbackGeneralConfiguration, FeedbackTextConfiguration, FeedbackCallbacks { + styles?: FeedbackFormStyles; +} + +/** + * General feedback configuration + */ +export interface FeedbackGeneralConfiguration { + /** + * Show the Sentry branding + * + * @default true + */ + showBranding?: boolean; + + /** + * Should the email field be required? + */ + isEmailRequired?: boolean; + + /** + * Should the email field be validated? + */ + shouldValidateEmail?: 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; +} + +/** + * The styles for the feedback form + */ +export interface FeedbackFormStyles { + container?: ViewStyle; + title?: TextStyle; + label?: TextStyle; + input?: TextStyle; + textArea?: TextStyle; + submitButton?: ViewStyle; + submitText?: TextStyle; + cancelButton?: ViewStyle; + cancelText?: TextStyle; + titleContainer?: ViewStyle; + sentryLogo?: ImageStyle; +} + +/** + * The state of the feedback form + */ +export interface FeedbackFormState { + isVisible: boolean; + name: string; + email: string; + description: string; +} diff --git a/packages/core/src/js/feedback/branding.ts b/packages/core/src/js/feedback/branding.ts new file mode 100644 index 0000000000..e69dd1c79f --- /dev/null +++ b/packages/core/src/js/feedback/branding.ts @@ -0,0 +1,5 @@ +/** + * Base64 encoded PNG image of the Sentry logo (source https://sentry.io/branding/) + */ +export const sentryLogo = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAAC4BAMAAABUVL5sAAAAD1BMVEUAAAA2Llo3LVk4LFg2LVmgcvrUAAAABHRSTlMAgL9ARyeO/QAAA7pJREFUeNrtWltynDAQFBIHIFUcgCQ6AIk5AAjd/0ypxFk3ouUCNJofl/rHy75a09MzI7Q2DQ0NDQ0NDQ0NDQ0N9eF+xPh9MKpwPv6FLss7R9yMImz8j8nowb9IglFDF6N+KCNIdqMFcGjpBbVUXdzHqKsXUhJGNb2g1vz6azRgX8XulUwMtVZjFsWkvFz10s0ooEO+9Vpxj/6rp9eI5VsdE6eJcDCxilqICiZWMHBSMkBVA2vq1SVrVyr6Ea5VMjHqjytTwcAgRVJ0DAy9VoWUTNT3tQysZGIYmGNTKHfK0q6oFkysYWCuHB0DaxX9yHLx5JKrhVC0TGwjWNRMvNAuW6PoR4TCepnqLLuiicEyKJq4g2C6Rc+hsInlgtHCF41OrD65INisObksbKy2/YJg9U08sGBTbRN3YaBQQu2i74/mcQil6vZr5C0dQpGbGKJvdOA1VDVxd5LHooPR5BJPdwql5vaLeq9FQZKJpa1kOoUSKpq45+m+ZK93aUpSwRyuafslUguCIZRZamKoBYQT80QmlqnF38p6bSIDcyg2q5fw/uo8dx0upZMLLdadQ1kgkNDEOOGiYYXjVKGJ8V00rEggcErurxAKCwQTi9RCAW7UFcUm5vPAOFNXFBY9DggQyp76jnIkOyBwaeqtyMScEu7w4JRNLnyQOjyvACcWpR145g6PV1fp9mvE0jMd3tWZXDR3/Ud2cSXZfmHhvNpEoFlgYsjFHX4AJc3kXXSTyEfDTrz94ptE1qvS9ouG1Ud2sQT5PVcHg3FL78FIYUpqxWK1yLzMxNzhHVaLzItMzB0eB/S4CDRHC+AzFTjhAiSSHx9tpgJXqnmhXi7VizM/F5v4V5oVqOIp81PpEW4Xt7PUA0kEe5WZ2PLt7ZopDg8Seue9GpxoU0WrHyFPgYlzmyrKPDxcpFeX3YRS5mGvxybmsC2tPhLJQxPzdsfliwMeLjAx9wcujoFIaEAX/KSYXz0s+9TE/E7LX0yF8lQvitl99sVjSgITl/yk6Lk48JjfGadnanHml8xjMvFTA+eL42CRwDKEZwbm4rBMyAmdH6UEz8HDTPj4d4ie1EJxJCQg56DXaxKOl0iGz0jcdebZluzhbFSA1yEZ2JzbHZKQe3I/EK4CErTHbwn84ZP+8Poxqrd/+I2cXJAw0v9VAkBiI3DhLryZEe6SXNeJk5HcHFu+Aom5wiIn2a7niZiE1WKMUhIOhNFJSQZzh0VG8tPcQufLSQI46sO9vcM0NDQ0NDQ0NDQ0NHxF/AFGJOBYBWrb5gAAAABJRU5ErkJggg=='; diff --git a/packages/core/src/js/feedback/defaults.ts b/packages/core/src/js/feedback/defaults.ts new file mode 100644 index 0000000000..f184c62634 --- /dev/null +++ b/packages/core/src/js/feedback/defaults.ts @@ -0,0 +1,54 @@ +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 + showBranding: true, + isEmailRequired: false, + shouldValidateEmail: true, + isNameRequired: false, + showEmail: true, + showName: true, + + // 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 e7c5411613..53abd065b8 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 0000000000..a97250441b --- /dev/null +++ b/packages/core/test/feedback/FeedbackForm.test.tsx @@ -0,0 +1,168 @@ +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(); +const mockGetUser = jest.fn(() => ({ + email: 'test@example.com', + name: 'Test User', +})); + +jest.spyOn(Alert, 'alert'); + +jest.mock('@sentry/core', () => ({ + captureFeedback: jest.fn(), + getCurrentScope: jest.fn(() => ({ + getUser: mockGetUser, + })), + 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, getByTestId } = render(); + + expect(getByText(defaultProps.formTitle)).toBeTruthy(); + expect(getByTestId('sentry-logo')).toBeTruthy(); // default showBranding is true + 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('does not render the sentry logo when showBranding is false', () => { + const { queryByTestId } = render(); + + expect(queryByTestId('sentry-logo')).toBeNull(); + }); + + 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('ensure getUser is called only after the component is rendered', () => { + // Ensure getUser is not called before render + expect(mockGetUser).not.toHaveBeenCalled(); + + // Render the component + render(); + + // After rendering, check that getUser was called twice (email and name) + expect(mockGetUser).toHaveBeenCalledTimes(2); + }); + + 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 f6d1063736..07afc5a214 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'; @@ -149,6 +150,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 5f2f405677..4788fa407a 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) => { } }} /> +