diff --git a/packages/core/src/js/feedback/FeedbackForm.styles.ts b/packages/core/src/js/feedback/FeedbackForm.styles.ts index 836f4e1629..9cbbfab7f7 100644 --- a/packages/core/src/js/feedback/FeedbackForm.styles.ts +++ b/packages/core/src/js/feedback/FeedbackForm.styles.ts @@ -39,6 +39,17 @@ const defaultStyles: FeedbackFormStyles = { 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, diff --git a/packages/core/src/js/feedback/FeedbackForm.tsx b/packages/core/src/js/feedback/FeedbackForm.tsx index 438eadbd2c..d8a295fa9b 100644 --- a/packages/core/src/js/feedback/FeedbackForm.tsx +++ b/packages/core/src/js/feedback/FeedbackForm.tsx @@ -68,6 +68,15 @@ export class FeedbackForm extends React.Component 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. */ @@ -167,7 +187,15 @@ export class FeedbackForm extends React.Component this.setState({ description: value })} multiline /> - + {config.enableScreenshot && ( + + + {!this.state.filename && !this.state.attachment + ? text.addScreenshotButtonLabel + : text.removeScreenshotButtonLabel} + + + )} {text.submitButtonLabel} diff --git a/packages/core/src/js/feedback/FeedbackForm.types.ts b/packages/core/src/js/feedback/FeedbackForm.types.ts index 74d87aa709..cffe54447a 100644 --- a/packages/core/src/js/feedback/FeedbackForm.types.ts +++ b/packages/core/src/js/feedback/FeedbackForm.types.ts @@ -44,6 +44,12 @@ export interface FeedbackGeneralConfiguration { */ 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. @@ -113,6 +119,16 @@ export interface FeedbackTextConfiguration { */ 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 */ @@ -148,6 +164,11 @@ export interface FeedbackCallbacks { */ onFormClose?: () => void; + /** + * Callback when a screenshot is added + */ + onAddScreenshot?: (attachFile: (filename: string, data: Uint8Array) => void) => void; + /** * Callback when feedback is successfully submitted * @@ -179,6 +200,8 @@ export interface FeedbackFormStyles { submitText?: TextStyle; cancelButton?: ViewStyle; cancelText?: TextStyle; + screenshotButton?: ViewStyle; + screenshotText?: TextStyle; titleContainer?: ViewStyle; sentryLogo?: ImageStyle; } @@ -191,4 +214,6 @@ export interface FeedbackFormState { name: string; email: string; description: string; + filename?: string; + attachment?: string | Uint8Array; } diff --git a/packages/core/src/js/feedback/defaults.ts b/packages/core/src/js/feedback/defaults.ts index 3c5dbdef6e..a1d8e74b62 100644 --- a/packages/core/src/js/feedback/defaults.ts +++ b/packages/core/src/js/feedback/defaults.ts @@ -16,6 +16,8 @@ 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 = { @@ -31,6 +33,11 @@ export const defaultConfiguration: Partial = { ); } }, + onAddScreenshot: (_: (filename: string, data: Uint8Array) => void) => { + if (__DEV__) { + Alert.alert('Development note', 'onAddScreenshot callback is not implemented.'); + } + }, onSubmitSuccess: () => { // Does nothing by default }, @@ -53,6 +60,7 @@ export const defaultConfiguration: Partial = { isNameRequired: false, showEmail: true, showName: true, + enableScreenshot: false, // FeedbackTextConfiguration cancelButtonLabel: CANCEL_BUTTON_LABEL, @@ -69,5 +77,7 @@ export const defaultConfiguration: Partial = { 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/test/feedback/FeedbackForm.test.tsx b/packages/core/test/feedback/FeedbackForm.test.tsx index b6b6337e5f..33cf9d5811 100644 --- a/packages/core/test/feedback/FeedbackForm.test.tsx +++ b/packages/core/test/feedback/FeedbackForm.test.tsx @@ -7,6 +7,7 @@ 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(); @@ -28,9 +29,11 @@ jest.mock('@sentry/core', () => ({ 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', @@ -84,6 +87,13 @@ const customStyles: FeedbackFormStyles = { color: '#ff0000', fontSize: 10, }, + screenshotButton: { + backgroundColor: '#00ff00', + }, + screenshotText: { + color: '#0000ff', + fontSize: 13, + }, }; describe('FeedbackForm', () => { @@ -107,8 +117,24 @@ describe('FeedbackForm', () => { 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 } = render(); + const { getByPlaceholderText, getByText, getByTestId, queryByText } = render(); expect(getByText(defaultProps.formTitle)).toBeTruthy(); expect(getByTestId('sentry-logo')).toBeTruthy(); // default showBranding is true @@ -118,10 +144,17 @@ describe('FeedbackForm', () => { 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(); @@ -188,7 +221,7 @@ describe('FeedbackForm', () => { message: 'This is a feedback message.', name: 'John Doe', email: 'john.doe@example.com', - }); + }, undefined); }); }); @@ -270,6 +303,16 @@ describe('FeedbackForm', () => { }); }); + 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(); diff --git a/packages/core/test/feedback/__snapshots__/FeedbackForm.test.tsx.snap b/packages/core/test/feedback/__snapshots__/FeedbackForm.test.tsx.snap index ca4942387f..a642bd88da 100644 --- a/packages/core/test/feedback/__snapshots__/FeedbackForm.test.tsx.snap +++ b/packages/core/test/feedback/__snapshots__/FeedbackForm.test.tsx.snap @@ -268,6 +268,320 @@ exports[`FeedbackForm matches the snapshot with custom styles 1`] = ` `; +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 + 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 Label + Name - Email Label + Email - Message Label - (is required label) + Description + (required) - Submit Button Label + Send Bug Report - Cancel Button Label + Cancel @@ -567,7 +1529,7 @@ exports[`FeedbackForm matches the snapshot with custom texts 1`] = ` `; -exports[`FeedbackForm matches the snapshot with default configuration 1`] = ` +exports[`FeedbackForm matches the snapshot with default configuration and screenshot button 1`] = ` + + + Add a screenshot + + NSPrivacyAccessedAPITypeReasons C617.1 + 3B52.1 diff --git a/samples/react-native/package.json b/samples/react-native/package.json index 80ac941a39..231bcf4db3 100644 --- a/samples/react-native/package.json +++ b/samples/react-native/package.json @@ -30,6 +30,8 @@ "react": "18.3.1", "react-native": "0.76.3", "react-native-gesture-handler": "^2.21.1", + "react-native-image-picker": "^7.2.2", + "react-native-quick-base64": "^2.1.2", "react-native-reanimated": "3.16.1", "react-native-safe-area-context": "4.14.0", "react-native-screens": "4.1.0", diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 956c77223f..2df2079c0b 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -37,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'); @@ -139,6 +141,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 ( @@ -157,6 +176,8 @@ const ErrorsTabNavigator = Sentry.withProfiler( {(props) => (