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) => { } }} /> +