Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(2.4) Feedback form attachments #4338

Open
wants to merge 14 commits into
base: antonis/3859-newCaptureFeedbackAPI-Form
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/core/src/js/feedback/FeedbackForm.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,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,
Expand Down
35 changes: 34 additions & 1 deletion packages/core/src/js/feedback/FeedbackForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
View
} from 'react-native';

import { base64ToUint8Array } from '../utils/base64ToUint8Array';
import { defaultConfiguration } from './defaults';
import defaultStyles from './FeedbackForm.styles';
import type { FeedbackFormProps, FeedbackFormState, FeedbackFormStyles,FeedbackGeneralConfiguration, FeedbackTextConfiguration } from './FeedbackForm.types';
Expand Down Expand Up @@ -64,6 +65,17 @@ export class FeedbackForm extends React.Component<FeedbackFormProps, FeedbackFor
return;
}

const attachement = (this.state?.attachment instanceof Uint8Array)? this.state?.attachment : base64ToUint8Array(this.state?.attachment);

const attachments = this.state.filename && this.state.attachment
? [
{
filename: this.state.filename,
data: attachement,
},
]
: undefined;

const eventId = lastEventId();
const userFeedback: SendFeedbackParams = {
message: trimmedDescription,
Expand All @@ -75,10 +87,21 @@ export class FeedbackForm extends React.Component<FeedbackFormProps, FeedbackFor
onFormClose();
this.setState({ isVisible: false });

captureFeedback(userFeedback);
captureFeedback(userFeedback, attachments ? { attachments } : undefined);
Alert.alert(text.successMessageText);
};

public addRemoveAttachment: () => void = () => {
if (!this.state.filename && !this.state.attachment) {
const { onFileChosen } = { ...defaultConfiguration, ...this.props };
onFileChosen((filename: string, attachement: string | Uint8Array) => {
this.setState({ filename, attachment: attachement });
});
} else {
this.setState({ filename: undefined, attachment: undefined });
}
}

/**
* Renders the feedback form screen.
*/
Expand Down Expand Up @@ -148,6 +171,16 @@ export class FeedbackForm extends React.Component<FeedbackFormProps, FeedbackFor
multiline
/>

{config.enableScreenshot && (
<TouchableOpacity style={styles.screenshotButton} onPress={this.addRemoveAttachment}>
<Text style={styles.screenshotText}>
{!this.state.filename && !this.state.attachment
? text.addScreenshotButtonLabel
: text.removeScreenshotButtonLabel}
</Text>
</TouchableOpacity>
)}

<TouchableOpacity style={styles.submitButton} onPress={this.handleFeedbackSubmit}>
<Text style={styles.submitText}>{text.submitButtonLabel}</Text>
</TouchableOpacity>
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/js/feedback/FeedbackForm.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ export interface FeedbackGeneralConfiguration {
*/
showName?: boolean;

/**
* Should the screen shots field be included?
* Screen shots cannot be marked as required
*/
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.
Expand Down Expand Up @@ -102,6 +108,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
*/
Expand All @@ -126,6 +142,11 @@ export interface FeedbackCallbacks {
* Callback when form is closed and not submitted
*/
onFormClose?: () => void;

/**
* Callback when a file is chosen for attachment
*/
onFileChosen?: (attachFile: (filename: string, base64Attachment: string | Uint8Array) => void) => void;
}

export interface FeedbackFormStyles {
Expand All @@ -138,11 +159,15 @@ export interface FeedbackFormStyles {
submitText?: TextStyle;
cancelButton?: ViewStyle;
cancelText?: TextStyle;
screenshotButton?: ViewStyle;
screenshotText?: TextStyle;
}

export interface FeedbackFormState {
isVisible: boolean;
name: string;
email: string;
description: string;
filename?: string;
attachment?: string | Uint8Array;
}
10 changes: 10 additions & 0 deletions packages/core/src/js/feedback/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

export const defaultConfiguration: Partial<FeedbackFormProps> = {
// FeedbackCallbacks
Expand All @@ -27,13 +29,19 @@ export const defaultConfiguration: Partial<FeedbackFormProps> = {
);
}
},
onFileChosen: (_: (filename: string, base64Attachment: string | Uint8Array) => void) => {
if (__DEV__) {
Alert.alert('Development note', 'onFileChosen callback is not implemented.');
}
},

// FeedbackGeneralConfiguration
isEmailRequired: false,
shouldValidateEmail: true,
isNameRequired: false,
showEmail: true,
showName: true,
enableScreenshot: false,

// FeedbackTextConfiguration
cancelButtonLabel: CANCEL_BUTTON_LABEL,
Expand All @@ -50,4 +58,6 @@ export const defaultConfiguration: Partial<FeedbackFormProps> = {
formError: FORM_ERROR,
emailError: EMAIL_ERROR,
successMessageText: SUCCESS_MESSAGE_TEXT,
addScreenshotButtonLabel: ADD_SCREENSHOT_LABEL,
removeScreenshotButtonLabel: REMOVE_SCREENSHOT_LABEL,
};
29 changes: 29 additions & 0 deletions packages/core/src/js/utils/base64ToUint8Array.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/* eslint-disable no-bitwise */
export const base64ToUint8Array = (base64?: string): Uint8Array | undefined => {
if (!base64) return undefined;

const cleanedBase64 = base64.replace(/^data:.*;base64,/, ''); // Remove any prefix before the base64 string
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const bytes: number[] = [];

let buffer = 0;
let bits = 0;

for (const char of cleanedBase64) {
if (char === '=') break;

const value = chars.indexOf(char); // Validate each character
if (value === -1) return undefined;

buffer = (buffer << 6) | value; // Shift 6 bits to the left and add the value
bits += 6;

if (bits >= 8) {
// Add a byte when we have 8 or more bits
bits -= 8;
bytes.push((buffer >> bits) & 0xff);
}
}

return new Uint8Array(bytes);
};
24 changes: 22 additions & 2 deletions packages/core/test/feedback/FeedbackForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { FeedbackForm } from '../../src/js/feedback/FeedbackForm';
import type { FeedbackFormProps } from '../../src/js/feedback/FeedbackForm.types';

const mockOnFormClose = jest.fn();
const mockOnFileChosen = jest.fn();

jest.spyOn(Alert, 'alert');

Expand All @@ -23,6 +24,8 @@ jest.mock('@sentry/core', () => ({

const defaultProps: FeedbackFormProps = {
onFormClose: mockOnFormClose,
onFileChosen: mockOnFileChosen,
addScreenshotButtonLabel: 'Add Screenshot',
formTitle: 'Feedback Form',
nameLabel: 'Name',
namePlaceholder: 'Name Placeholder',
Expand All @@ -45,7 +48,7 @@ describe('FeedbackForm', () => {
});

it('renders correctly', () => {
const { getByPlaceholderText, getByText } = render(<FeedbackForm {...defaultProps} />);
const { getByPlaceholderText, getByText, queryByText } = render(<FeedbackForm {...defaultProps} />);

expect(getByText(defaultProps.formTitle)).toBeTruthy();
expect(getByText(defaultProps.nameLabel)).toBeTruthy();
Expand All @@ -54,10 +57,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(<FeedbackForm {...defaultProps} enableScreenshot={true} />);

expect(getByText(defaultProps.addScreenshotButtonLabel)).toBeTruthy();
});

it('name and email are prefilled when sentry user is set', () => {
const { getByPlaceholderText } = render(<FeedbackForm {...defaultProps} />);

Expand Down Expand Up @@ -107,7 +117,7 @@ describe('FeedbackForm', () => {
message: 'This is a feedback message.',
name: 'John Doe',
email: '[email protected]',
});
}, undefined);
});
});

Expand Down Expand Up @@ -139,6 +149,16 @@ describe('FeedbackForm', () => {
});
});

it('calls onFileChosen when the screenshot button is pressed', async () => {
const { getByText } = render(<FeedbackForm {...defaultProps} enableScreenshot={true} />);

fireEvent.press(getByText(defaultProps.addScreenshotButtonLabel));

await waitFor(() => {
expect(mockOnFileChosen).toHaveBeenCalled();
});
});

it('calls onFormClose when the cancel button is pressed', () => {
const { getByText } = render(<FeedbackForm {...defaultProps} />);

Expand Down
1 change: 1 addition & 0 deletions samples/react-native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"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-reanimated": "3.16.1",
"react-native-safe-area-context": "4.14.0",
"react-native-screens": "4.1.0",
Expand Down
20 changes: 20 additions & 0 deletions samples/react-native/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import HeavyNavigationScreen from './Screens/HeavyNavigationScreen';
import WebviewScreen from './Screens/WebviewScreen';
import { isTurboModuleEnabled } from '@sentry/react-native/dist/js/utils/environment';

import { launchImageLibrary } from 'react-native-image-picker';

if (typeof setImmediate === 'undefined') {
require('setimmediate');
}
Expand Down Expand Up @@ -141,6 +143,22 @@ const Stack = isMobileOs
: createStackNavigator();
const Tab = createBottomTabNavigator();

const handleChooseFile = (attachFile: (filename: string, base64Attachment: string | 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;
if (filename && base64String) {
attachFile(filename, base64String);
}
}
});
};

const ErrorsTabNavigator = Sentry.withProfiler(
() => {
return (
Expand All @@ -159,6 +177,8 @@ const ErrorsTabNavigator = Sentry.withProfiler(
{(props) => (
<FeedbackForm
{...props}
enableScreenshot={true}
onFileChosen={handleChooseFile}
onFormClose={props.navigation.goBack}
styles={{
submitButton: {
Expand Down
11 changes: 11 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -22673,6 +22673,16 @@ __metadata:
languageName: node
linkType: hard

"react-native-image-picker@npm:^7.2.2":
version: 7.2.2
resolution: "react-native-image-picker@npm:7.2.2"
peerDependencies:
react: "*"
react-native: "*"
checksum: 34289e29a28c3f8d869db46fdf5bfdeec8b37221ee4dcd9a63698b106f0097d11f4c40f3a2789c23a728dc94e37e02a0cf61add80aa04ffe60a6cf82115cddea
languageName: node
linkType: hard

"react-native-is-edge-to-edge@npm:^1.1.6":
version: 1.1.6
resolution: "react-native-is-edge-to-edge@npm:1.1.6"
Expand Down Expand Up @@ -24415,6 +24425,7 @@ __metadata:
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-reanimated: 3.16.1
react-native-safe-area-context: 4.14.0
react-native-screens: 4.1.0
Expand Down
Loading