diff --git a/CHANGELOG.md b/CHANGELOG.md index a68cb1100c..d8396917cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### Features + +- User Feedback Form Component Beta ([#4435](https://github.com/getsentry/sentry-react-native/pull/4435)) + + To collect user feedback from inside your application call `Sentry.showFeedbackForm()` or add the `FeedbackForm` component. + + ```jsx + import { FeedbackForm } from "@sentry/react-native"; + ... + + ``` + ## 6.7.0 ### Features 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..1e90af07c6 --- /dev/null +++ b/packages/core/src/js/feedback/FeedbackForm.styles.ts @@ -0,0 +1,111 @@ +import type { ViewStyle } from 'react-native'; + +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, + }, + screenshotButton: { + backgroundColor: '#eee', + padding: 15, + borderRadius: 5, + marginBottom: 20, + alignItems: 'center', + }, + screenshotText: { + color: '#333', + fontSize: 16, + }, + 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 const modalWrapper: ViewStyle = { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, +}; + +export const modalBackground: ViewStyle = { + flex: 1, + justifyContent: 'flex-end', +}; + +export const modalSheetContainer: ViewStyle = { + backgroundColor: '#ffffff', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + overflow: 'hidden', + alignSelf: 'stretch', + height: '92%', + shadowColor: '#000', + shadowOffset: { width: 0, height: -3 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 5, +}; + +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..5b8dc28b70 --- /dev/null +++ b/packages/core/src/js/feedback/FeedbackForm.tsx @@ -0,0 +1,217 @@ +import type { SendFeedbackParams } from '@sentry/core'; +import { captureFeedback, getCurrentScope, lastEventId, logger } from '@sentry/core'; +import * as React from 'react'; +import type { KeyboardTypeOptions } from 'react-native'; +import { + Alert, + Image, + Keyboard, + KeyboardAvoidingView, + Platform, + 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'; +import { isValidEmail } from './utils'; + +/** + * @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 { onSubmitSuccess, onSubmitError, onFormSubmitted } = 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) && !isValidEmail(trimmedEmail)) { + Alert.alert(text.errorTitle, text.emailError); + return; + } + + const attachments = this.state.filename && this.state.attachment + ? [ + { + filename: this.state.filename, + data: this.state.attachment, + }, + ] + : undefined; + + const eventId = lastEventId(); + const userFeedback: SendFeedbackParams = { + message: trimmedDescription, + name: trimmedName, + email: trimmedEmail, + associatedEventId: eventId, + }; + + try { + this.setState({ isVisible: false }); + captureFeedback(userFeedback, attachments ? { attachments } : undefined); + onSubmitSuccess({ name: trimmedName, email: trimmedEmail, message: trimmedDescription, attachments: undefined }); + Alert.alert(text.successMessageText); + onFormSubmitted(); + } catch (error) { + const errorString = `Feedback form submission failed: ${error}`; + onSubmitError(new Error(errorString)); + Alert.alert(text.errorTitle, text.genericError); + logger.error(`Feedback form submission failed: ${error}`); + } + }; + + public onScreenshotButtonPress: () => void = () => { + if (!this.state.filename && !this.state.attachment) { + const { onAddScreenshot } = { ...defaultConfiguration, ...this.props }; + onAddScreenshot((filename: string, attachement: Uint8Array) => { + this.setState({ filename, attachment: attachement }); + }); + } else { + this.setState({ filename: undefined, attachment: undefined }); + } + } + + /** + * 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 + /> + {config.enableScreenshot && ( + + + {!this.state.filename && !this.state.attachment + ? text.addScreenshotButtonLabel + : text.removeScreenshotButtonLabel} + + + )} + + {text.submitButtonLabel} + + + + {text.cancelButtonLabel} + + + + + + + ); + } +} 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..cffe54447a --- /dev/null +++ b/packages/core/src/js/feedback/FeedbackForm.types.ts @@ -0,0 +1,219 @@ +import type { FeedbackFormData } from '@sentry/core'; +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; + + /** + * This flag determines whether the "Add Screenshot" button is displayed + * @default false + */ + enableScreenshot?: 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 label for the button that adds a screenshot and renders the image editor + */ + addScreenshotButtonLabel?: string; + + /** + * The label for the button that removes a screenshot and hides the image editor + */ + removeScreenshotButtonLabel?: 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; + + /** + * Message when there is a generic error + */ + genericError?: string; +} + +/** + * The public callbacks available for the feedback integration + */ +export interface FeedbackCallbacks { + /** + * Callback when form is opened + */ + onFormOpen?: () => void; + + /** + * Callback when form is closed and not submitted + */ + onFormClose?: () => void; + + /** + * Callback when a screenshot is added + */ + onAddScreenshot?: (attachFile: (filename: string, data: Uint8Array) => void) => void; + + /** + * Callback when feedback is successfully submitted + * + * After this you'll see a SuccessMessage on the screen for a moment. + */ + onSubmitSuccess?: (data: FeedbackFormData) => void; + + /** + * Callback when feedback is unsuccessfully submitted + */ + onSubmitError?: (error: Error) => void; + + /** + * Callback when the feedback form is submitted successfully, and the SuccessMessage is complete, or dismissed + */ + onFormSubmitted?: () => 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; + screenshotButton?: ViewStyle; + screenshotText?: TextStyle; + titleContainer?: ViewStyle; + sentryLogo?: ImageStyle; +} + +/** + * The state of the feedback form + */ +export interface FeedbackFormState { + isVisible: boolean; + name: string; + email: string; + description: string; + filename?: string; + attachment?: string | Uint8Array; +} diff --git a/packages/core/src/js/feedback/FeedbackFormManager.tsx b/packages/core/src/js/feedback/FeedbackFormManager.tsx new file mode 100644 index 0000000000..b7b9f9fe55 --- /dev/null +++ b/packages/core/src/js/feedback/FeedbackFormManager.tsx @@ -0,0 +1,130 @@ +import { logger } from '@sentry/core'; +import * as React from 'react'; +import { Animated, KeyboardAvoidingView, Modal, Platform, View } from 'react-native'; + +import { FeedbackForm } from './FeedbackForm'; +import { modalBackground, modalSheetContainer, modalWrapper } from './FeedbackForm.styles'; +import type { FeedbackFormStyles } from './FeedbackForm.types'; +import { getFeedbackOptions } from './integration'; +import { isModalSupported } from './utils'; + +class FeedbackFormManager { + private static _isVisible = false; + private static _setVisibility: (visible: boolean) => void; + + public static initialize(setVisibility: (visible: boolean) => void): void { + this._setVisibility = setVisibility; + } + + public static show(): void { + if (this._setVisibility) { + this._isVisible = true; + this._setVisibility(true); + } + } + + public static hide(): void { + if (this._setVisibility) { + this._isVisible = false; + this._setVisibility(false); + } + } + + public static isFormVisible(): boolean { + return this._isVisible; + } +} + +interface FeedbackFormProviderProps { + children: React.ReactNode; + styles?: FeedbackFormStyles; +} + +interface FeedbackFormProviderState { + isVisible: boolean; + backgroundOpacity: Animated.Value; +} + +class FeedbackFormProvider extends React.Component { + public state: FeedbackFormProviderState = { + isVisible: false, + backgroundOpacity: new Animated.Value(0), + }; + + public constructor(props: FeedbackFormProviderProps) { + super(props); + FeedbackFormManager.initialize(this._setVisibilityFunction); + } + + /** + * Animates the background opacity when the modal is shown. + */ + public componentDidUpdate(_prevProps: any, prevState: FeedbackFormProviderState): void { + if (!prevState.isVisible && this.state.isVisible) { + Animated.timing(this.state.backgroundOpacity, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }).start(); + } else if (prevState.isVisible && !this.state.isVisible) { + this.state.backgroundOpacity.setValue(0); + } + } + + /** + * Renders the feedback form modal. + */ + public render(): React.ReactNode { + if (!isModalSupported()) { + logger.error('FeedbackForm Modal is not supported in React Native < 0.71 with Fabric renderer.'); + return <>{this.props.children}; + } + + const { isVisible, backgroundOpacity } = this.state; + + const backgroundColor = backgroundOpacity.interpolate({ + inputRange: [0, 1], + outputRange: ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0.9)'], + }); + + // Wrapping the `Modal` component in a `View` component is necessary to avoid + // issues like https://github.com/software-mansion/react-native-reanimated/issues/6035 + return ( + <> + {this.props.children} + {isVisible && ( + + + + + + + + + + )} + + ); + } + + private _setVisibilityFunction = (visible: boolean): void => { + this.setState({ isVisible: visible }); + }; + + private _handleClose = (): void => { + FeedbackFormManager.hide(); + this.setState({ isVisible: false }); + }; +} + +const showFeedbackForm = (): void => { + FeedbackFormManager.show(); +}; + +export { showFeedbackForm, FeedbackFormProvider }; 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..a1d8e74b62 --- /dev/null +++ b/packages/core/src/js/feedback/defaults.ts @@ -0,0 +1,83 @@ +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!'; +const ADD_SCREENSHOT_LABEL = 'Add a screenshot'; +const REMOVE_SCREENSHOT_LABEL = 'Remove screenshot'; +const GENERIC_ERROR_TEXT = 'Unable to send feedback due to an unexpected error.'; + +export const defaultConfiguration: Partial = { + // FeedbackCallbacks + onFormOpen: () => { + // Does nothing by default + }, + onFormClose: () => { + if (__DEV__) { + Alert.alert( + 'Development note', + 'onFormClose callback is not implemented. By default the form is just unmounted.', + ); + } + }, + onAddScreenshot: (_: (filename: string, data: Uint8Array) => void) => { + if (__DEV__) { + Alert.alert('Development note', 'onAddScreenshot callback is not implemented.'); + } + }, + onSubmitSuccess: () => { + // Does nothing by default + }, + onSubmitError: () => { + // Does nothing by default + }, + onFormSubmitted: () => { + if (__DEV__) { + Alert.alert( + 'Development note', + 'onFormSubmitted callback is not implemented. By default the form is just unmounted.', + ); + } + }, + + // FeedbackGeneralConfiguration + showBranding: true, + isEmailRequired: false, + shouldValidateEmail: true, + isNameRequired: false, + showEmail: true, + showName: true, + enableScreenshot: false, + + // 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, + addScreenshotButtonLabel: ADD_SCREENSHOT_LABEL, + removeScreenshotButtonLabel: REMOVE_SCREENSHOT_LABEL, + genericError: GENERIC_ERROR_TEXT, +}; diff --git a/packages/core/src/js/feedback/integration.ts b/packages/core/src/js/feedback/integration.ts new file mode 100644 index 0000000000..31fb463971 --- /dev/null +++ b/packages/core/src/js/feedback/integration.ts @@ -0,0 +1,22 @@ +import type { Integration } from '@sentry/core'; + +import type { FeedbackFormProps } from './FeedbackForm.types'; + +export const FEEDBACK_FORM_INTEGRATION_NAME = 'MobileFeedback'; + +type FeedbackIntegration = Integration & { + options: Partial; +}; + +let savedOptions: Partial = {}; + +export const feedbackIntegration = (initOptions: FeedbackFormProps = {}): FeedbackIntegration => { + savedOptions = initOptions; + + return { + name: FEEDBACK_FORM_INTEGRATION_NAME, + options: savedOptions, + }; +}; + +export const getFeedbackOptions = (): Partial => savedOptions; diff --git a/packages/core/src/js/feedback/utils.ts b/packages/core/src/js/feedback/utils.ts new file mode 100644 index 0000000000..b27fb3ea8d --- /dev/null +++ b/packages/core/src/js/feedback/utils.ts @@ -0,0 +1,16 @@ +import { isFabricEnabled } from '../utils/environment'; +import { ReactNativeLibraries } from './../utils/rnlibraries'; + +/** + * Modal is not supported in React Native < 0.71 with Fabric renderer. + * ref: https://github.com/facebook/react-native/issues/33652 + */ +export function isModalSupported(): boolean { + const { major, minor } = ReactNativeLibraries.ReactNativeVersion?.version || {}; + return !(isFabricEnabled() && major === 0 && minor < 71); +} + +export const 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/index.ts b/packages/core/src/js/index.ts index e7c5411613..a07e89f92a 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -84,3 +84,6 @@ export { export type { TimeToDisplayProps } from './tracing'; export { Mask, Unmask } from './replay/CustomMask'; + +export { FeedbackForm } from './feedback/FeedbackForm'; +export { showFeedbackForm } from './feedback/FeedbackFormManager'; diff --git a/packages/core/src/js/integrations/exports.ts b/packages/core/src/js/integrations/exports.ts index dfc9e2c3e1..f5fabb397e 100644 --- a/packages/core/src/js/integrations/exports.ts +++ b/packages/core/src/js/integrations/exports.ts @@ -13,6 +13,7 @@ export { viewHierarchyIntegration } from './viewhierarchy'; export { expoContextIntegration } from './expocontext'; export { spotlightIntegration } from './spotlight'; export { mobileReplayIntegration } from '../replay/mobilereplay'; +export { feedbackIntegration } from '../feedback/integration'; export { browserReplayIntegration } from '../replay/browserReplay'; export { appStartIntegration } from '../tracing/integrations/appStart'; export { nativeFramesIntegration, createNativeFramesIntegrations } from '../tracing/integrations/nativeFrames'; diff --git a/packages/core/src/js/sdk.tsx b/packages/core/src/js/sdk.tsx index 3c6fdff90c..606e2cf5ea 100644 --- a/packages/core/src/js/sdk.tsx +++ b/packages/core/src/js/sdk.tsx @@ -8,6 +8,7 @@ import { import * as React from 'react'; import { ReactNativeClient } from './client'; +import { FeedbackFormProvider } from './feedback/FeedbackFormManager'; import { getDevServer } from './integrations/debugsymbolicatorutils'; import { getDefaultIntegrations } from './integrations/default'; import type { ReactNativeClientOptions, ReactNativeOptions, ReactNativeWrapperOptions } from './options'; @@ -163,7 +164,9 @@ export function wrap

>( return ( - + + + ); diff --git a/packages/core/test/feedback/FeedbackForm.test.tsx b/packages/core/test/feedback/FeedbackForm.test.tsx new file mode 100644 index 0000000000..33cf9d5811 --- /dev/null +++ b/packages/core/test/feedback/FeedbackForm.test.tsx @@ -0,0 +1,323 @@ +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, FeedbackFormStyles } from '../../src/js/feedback/FeedbackForm.types'; + +const mockOnFormClose = jest.fn(); +const mockOnAddScreenshot = jest.fn(); +const mockOnSubmitSuccess = jest.fn(); +const mockOnFormSubmitted = jest.fn(); +const mockOnSubmitError = jest.fn(); +const mockGetUser = jest.fn(() => ({ + email: 'test@example.com', + name: 'Test User', +})); + +jest.spyOn(Alert, 'alert'); + +jest.mock('@sentry/core', () => ({ + ...jest.requireActual('@sentry/core'), + captureFeedback: jest.fn(), + getCurrentScope: jest.fn(() => ({ + getUser: mockGetUser, + })), + lastEventId: jest.fn(), +})); + +const defaultProps: FeedbackFormProps = { + onFormClose: mockOnFormClose, + onAddScreenshot: mockOnAddScreenshot, + onSubmitSuccess: mockOnSubmitSuccess, + onFormSubmitted: mockOnFormSubmitted, + onSubmitError: mockOnSubmitError, + addScreenshotButtonLabel: 'Add Screenshot', + formTitle: 'Feedback Form', + nameLabel: 'Name Label', + namePlaceholder: 'Name Placeholder', + emailLabel: 'Email Label', + emailPlaceholder: 'Email Placeholder', + messageLabel: 'Message Label', + messagePlaceholder: 'Message Placeholder', + submitButtonLabel: 'Submit Button Label', + cancelButtonLabel: 'Cancel Button Label', + isRequiredLabel: '(is required label)', + errorTitle: 'Error', + formError: 'Please fill out all required fields.', + emailError: 'The email address is not valid.', + successMessageText: 'Feedback success', + genericError: 'Generic error', +}; + +const customStyles: FeedbackFormStyles = { + container: { + backgroundColor: '#ffffff', + }, + title: { + fontSize: 20, + color: '#ff0000', + }, + label: { + fontSize: 15, + color: '#00ff00', + }, + input: { + height: 50, + borderColor: '#0000ff', + fontSize: 13, + color: '#000000', + }, + textArea: { + height: 50, + color: '#00ff00', + }, + submitButton: { + backgroundColor: '#ffff00', + }, + submitText: { + color: '#ff0000', + fontSize: 12, + }, + cancelButton: { + paddingVertical: 10, + }, + cancelText: { + color: '#ff0000', + fontSize: 10, + }, + screenshotButton: { + backgroundColor: '#00ff00', + }, + screenshotText: { + color: '#0000ff', + fontSize: 13, + }, +}; + +describe('FeedbackForm', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('matches the snapshot with default configuration', () => { + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches the snapshot with custom texts', () => { + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches the snapshot with custom styles', () => { + const customStyleProps = {styles: customStyles}; + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches the snapshot with default configuration and screenshot button', () => { + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches the snapshot with custom texts and screenshot button', () => { + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('matches the snapshot with custom styles and screenshot button', () => { + const customStyleProps = {styles: customStyles}; + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders correctly', () => { + const { getByPlaceholderText, getByText, getByTestId, queryByText } = 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(queryByText(defaultProps.addScreenshotButtonLabel)).toBeNull(); // default false + expect(getByText(defaultProps.submitButtonLabel)).toBeTruthy(); + expect(getByText(defaultProps.cancelButtonLabel)).toBeTruthy(); + }); + + it('renders attachment button when the enableScreenshot is true', () => { + const { getByText } = render(); + + expect(getByText(defaultProps.addScreenshotButtonLabel)).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', + }, undefined); + }); + }); + + 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('shows an error message when there is a an error in captureFeedback', async () => { + (captureFeedback as jest.Mock).mockImplementationOnce(() => { + throw new Error('Test error'); + }); + + 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.errorTitle, defaultProps.genericError); + }); + }); + + it('calls onSubmitError when there is an error', async () => { + (captureFeedback as jest.Mock).mockImplementationOnce(() => { + throw new Error('Test error'); + }); + + 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(mockOnSubmitError).toHaveBeenCalled(); + }); + }); + + it('calls onSubmitSuccess 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(mockOnSubmitSuccess).toHaveBeenCalled(); + }); + }); + + it('calls onFormSubmitted 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(mockOnFormSubmitted).toHaveBeenCalled(); + }); + }); + + it('calls onAddScreenshot when the screenshot button is pressed', async () => { + const { getByText } = render(); + + fireEvent.press(getByText(defaultProps.addScreenshotButtonLabel)); + + await waitFor(() => { + expect(mockOnAddScreenshot).toHaveBeenCalled(); + }); + }); + + it('calls onFormClose when the cancel button is pressed', () => { + const { getByText } = render(); + + fireEvent.press(getByText(defaultProps.cancelButtonLabel)); + + expect(mockOnFormClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/test/feedback/FeedbackFormManager.test.tsx b/packages/core/test/feedback/FeedbackFormManager.test.tsx new file mode 100644 index 0000000000..9fe53af4c6 --- /dev/null +++ b/packages/core/test/feedback/FeedbackFormManager.test.tsx @@ -0,0 +1,96 @@ +import { logger } from '@sentry/core'; +import { render } from '@testing-library/react-native'; +import * as React from 'react'; +import { Text } from 'react-native'; + +import { defaultConfiguration } from '../../src/js/feedback/defaults'; +import { FeedbackFormProvider, showFeedbackForm } from '../../src/js/feedback/FeedbackFormManager'; +import { feedbackIntegration } from '../../src/js/feedback/integration'; +import { isModalSupported } from '../../src/js/feedback/utils'; + +jest.mock('../../src/js/feedback/utils', () => ({ + isModalSupported: jest.fn(), +})); + +const mockedIsModalSupported = isModalSupported as jest.MockedFunction; + +beforeEach(() => { + logger.error = jest.fn(); +}); + +describe('FeedbackFormManager', () => { + it('showFeedbackForm displays the form when FeedbackFormProvider is used', () => { + mockedIsModalSupported.mockReturnValue(true); + const { getByText, getByTestId } = render( + + App Components + + ); + + showFeedbackForm(); + + expect(getByTestId('feedback-form-modal')).toBeTruthy(); + expect(getByText('App Components')).toBeTruthy(); + }); + + it('showFeedbackForm does not display the form when Modal is not available', () => { + mockedIsModalSupported.mockReturnValue(false); + const { getByText, queryByTestId } = render( + + App Components + + ); + + showFeedbackForm(); + + expect(queryByTestId('feedback-form-modal')).toBeNull(); + expect(getByText('App Components')).toBeTruthy(); + expect(logger.error).toHaveBeenLastCalledWith( + 'FeedbackForm Modal is not supported in React Native < 0.71 with Fabric renderer.', + ); + }); + + it('showFeedbackForm does not throw an error when FeedbackFormProvider is not used', () => { + expect(() => { + showFeedbackForm(); + }).not.toThrow(); + }); + + it('showFeedbackForm displays the form with the feedbackIntegration options', () => { + mockedIsModalSupported.mockReturnValue(true); + const { getByPlaceholderText, getByText } = render( + + App Components + + ); + + feedbackIntegration({ + messagePlaceholder: 'Custom Message Placeholder', + submitButtonLabel: 'Custom Submit Button', + }); + + showFeedbackForm(); + + expect(getByPlaceholderText('Custom Message Placeholder')).toBeTruthy(); + expect(getByText('Custom Submit Button')).toBeTruthy(); + }); + + it('showFeedbackForm displays the form with the feedbackIntegration options merged with the defaults', () => { + mockedIsModalSupported.mockReturnValue(true); + const { getByPlaceholderText, getByText, queryByText } = render( + + App Components + + ); + + feedbackIntegration({ + submitButtonLabel: 'Custom Submit Button', + }), + + showFeedbackForm(); + + expect(queryByText(defaultConfiguration.submitButtonLabel)).toBeFalsy(); // overridden value + expect(getByText('Custom Submit Button')).toBeTruthy(); // overridden value + expect(getByPlaceholderText(defaultConfiguration.messagePlaceholder)).toBeTruthy(); // default configuration value + }); +}); diff --git a/packages/core/test/feedback/__snapshots__/FeedbackForm.test.tsx.snap b/packages/core/test/feedback/__snapshots__/FeedbackForm.test.tsx.snap new file mode 100644 index 0000000000..a3c2857ac9 --- /dev/null +++ b/packages/core/test/feedback/__snapshots__/FeedbackForm.test.tsx.snap @@ -0,0 +1,1891 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FeedbackForm matches the snapshot with custom styles 1`] = ` + + + + + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + Send Bug Report + + + + + Cancel + + + + + + + +`; + +exports[`FeedbackForm matches the snapshot with custom styles and screenshot button 1`] = ` + + + + + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + Add a screenshot + + + + + Send Bug Report + + + + + Cancel + + + + + + + +`; + +exports[`FeedbackForm matches the snapshot with custom texts 1`] = ` + + + + + + + + Feedback Form + + + + + Name Label + + + + Email Label + + + + Message Label + (is required label) + + + + + Submit Button Label + + + + + Cancel Button Label + + + + + + + +`; + +exports[`FeedbackForm matches the snapshot with custom texts and screenshot button 1`] = ` + + + + + + + + Feedback Form + + + + + Name Label + + + + Email Label + + + + Message Label + (is required label) + + + + + Add Screenshot + + + + + Submit Button Label + + + + + Cancel Button Label + + + + + + + +`; + +exports[`FeedbackForm matches the snapshot with default configuration 1`] = ` + + + + + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + Send Bug Report + + + + + Cancel + + + + + + + +`; + +exports[`FeedbackForm matches the snapshot with default configuration and screenshot button 1`] = ` + + + + + + + + Report a Bug + + + + + Name + + + + Email + + + + Description + (required) + + + + + Add a screenshot + + + + + Send Bug Report + + + + + Cancel + + + + + + + +`; diff --git a/samples/react-native/ios/sentryreactnativesample/PrivacyInfo.xcprivacy b/samples/react-native/ios/sentryreactnativesample/PrivacyInfo.xcprivacy index e9e7208ef3..2c388f904c 100644 --- a/samples/react-native/ios/sentryreactnativesample/PrivacyInfo.xcprivacy +++ b/samples/react-native/ios/sentryreactnativesample/PrivacyInfo.xcprivacy @@ -10,6 +10,7 @@ NSPrivacyAccessedAPITypeReasons C617.1 + 3B52.1 diff --git a/samples/react-native/package.json b/samples/react-native/package.json index 97930828ab..dfa65242b8 100644 --- a/samples/react-native/package.json +++ b/samples/react-native/package.json @@ -30,6 +30,8 @@ "react": "18.3.1", "react-native": "0.77.0", "react-native-gesture-handler": "^2.22.1", + "react-native-image-picker": "^7.2.2", + "react-native-quick-base64": "^2.1.2", "react-native-reanimated": "3.16.7", "react-native-safe-area-context": "5.2.0", "react-native-screens": "4.6.0", diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index f6d1063736..a9e36e7fd5 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'; @@ -36,6 +37,8 @@ import { ErrorEvent } from '@sentry/core'; import HeavyNavigationScreen from './Screens/HeavyNavigationScreen'; import WebviewScreen from './Screens/WebviewScreen'; import { isTurboModuleEnabled } from '@sentry/react-native/dist/js/utils/environment'; +import { toByteArray } from 'react-native-quick-base64'; +import { launchImageLibrary } from 'react-native-image-picker'; if (typeof setImmediate === 'undefined') { require('setimmediate'); @@ -103,6 +106,18 @@ Sentry.init({ ? false : true, }), + Sentry.feedbackIntegration({ + styles:{ + submitButton: { + backgroundColor: '#6a1b9a', + paddingVertical: 15, + borderRadius: 5, + alignItems: 'center', + marginBottom: 10, + }, + }, + namePlaceholder: 'Fullname', + }), ); return integrations.filter(i => i.name !== 'Dedupe'); }, @@ -138,6 +153,23 @@ const Stack = isMobileOs : createStackNavigator(); const Tab = createBottomTabNavigator(); +const handleChooseImage = (attachFile: (filename: string, data: Uint8Array) => void): void => { + launchImageLibrary({ mediaType: 'photo', includeBase64: true }, (response) => { + if (response.didCancel) { + console.log('User cancelled image picker'); + } else if (response.errorCode) { + console.log('ImagePicker Error: ', response.errorMessage); + } else if (response.assets && response.assets.length > 0) { + const filename = response.assets[0].fileName; + const base64String = response.assets[0].base64; + const screenShotUint8Array = toByteArray(base64String); + if (filename && screenShotUint8Array) { + attachFile(filename, screenShotUint8Array); + } + } + }); +}; + const ErrorsTabNavigator = Sentry.withProfiler( () => { return ( @@ -149,6 +181,30 @@ 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..cc2810fd1d 100644 --- a/samples/react-native/src/Screens/ErrorsScreen.tsx +++ b/samples/react-native/src/Screens/ErrorsScreen.tsx @@ -220,6 +220,18 @@ const ErrorsScreen = (_props: Props) => { } }} /> +