=> 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..9c2826981d
--- /dev/null
+++ b/packages/core/src/js/feedback/utils.ts
@@ -0,0 +1,46 @@
+import { Alert } from 'react-native';
+
+import { isFabricEnabled, isWeb } from '../utils/environment';
+import { RN_GLOBAL_OBJ } from '../utils/worldwide';
+import { ReactNativeLibraries } from './../utils/rnlibraries';
+
+declare global {
+ // Declaring atob function to be used in web environment
+ function atob(encodedString: string): string;
+}
+
+/**
+ * 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);
+};
+
+/**
+ * Converts base64 string to Uint8Array on the web
+ * @param base64 base64 string
+ * @returns Uint8Array data
+ */
+export const base64ToUint8Array = (base64: string): Uint8Array => {
+ if (typeof atob !== 'function' || !isWeb()) {
+ throw new Error('atob is not available in this environment.');
+ }
+
+ const binaryString = atob(base64);
+ return new Uint8Array([...binaryString].map(char => char.charCodeAt(0)));
+};
+
+export const feedbackAlertDialog = (title: string, message: string): void => {
+ if (isWeb() && typeof RN_GLOBAL_OBJ.alert !== 'undefined') {
+ RN_GLOBAL_OBJ.alert(`${title}\n${message}`);
+ } else {
+ Alert.alert(title, message);
+ }
+};
diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts
index e7c5411613..4508684156 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 { FeedbackWidget } from './feedback/FeedbackWidget';
+export { showFeedbackWidget } from './feedback/FeedbackWidgetManager';
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..5edba50b48 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 { FeedbackWidgetProvider } from './feedback/FeedbackWidgetManager';
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/src/js/utils/worldwide.ts b/packages/core/src/js/utils/worldwide.ts
index c1a4ae5dbb..03327bac36 100644
--- a/packages/core/src/js/utils/worldwide.ts
+++ b/packages/core/src/js/utils/worldwide.ts
@@ -25,6 +25,7 @@ export interface ReactNativeInternalGlobal extends InternalGlobal {
__BUNDLE_START_TIME__?: number;
nativePerformanceNow?: () => number;
TextEncoder?: TextEncoder;
+ alert?: (message: string) => void;
}
type TextEncoder = {
diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts
index 4db45f0855..9b37a9b87b 100644
--- a/packages/core/src/js/wrapper.ts
+++ b/packages/core/src/js/wrapper.ts
@@ -120,6 +120,8 @@ interface SentryNativeWrapper {
crashedLastRun(): Promise;
getNewScreenTimeToDisplay(): Promise;
+
+ getDataFromUri(uri: string): Promise;
}
const EOL = utf8ToBytes('\n');
@@ -702,6 +704,19 @@ export const NATIVE: SentryNativeWrapper = {
return RNSentry.getNewScreenTimeToDisplay();
},
+ async getDataFromUri(uri: string): Promise {
+ if (!this.enableNative || !this._isModuleLoaded(RNSentry)) {
+ return null;
+ }
+ try {
+ const data: number[] = await RNSentry.getDataFromUri(uri);
+ return new Uint8Array(data);
+ } catch (error) {
+ logger.error('Error:', error);
+ return null;
+ }
+ },
+
/**
* Gets the event from envelopeItem and applies the level filter to the selected event.
* @param data An envelope item containing the event.
diff --git a/packages/core/test/feedback/FeedbackWidget.test.tsx b/packages/core/test/feedback/FeedbackWidget.test.tsx
new file mode 100644
index 0000000000..fb5a394fa3
--- /dev/null
+++ b/packages/core/test/feedback/FeedbackWidget.test.tsx
@@ -0,0 +1,404 @@
+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 { FeedbackWidget } from '../../src/js/feedback/FeedbackWidget';
+import type { FeedbackWidgetProps, FeedbackWidgetStyles, ImagePicker } from '../../src/js/feedback/FeedbackWidget.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: FeedbackWidgetProps = {
+ 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: FeedbackWidgetStyles = {
+ 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('FeedbackWidget', () => {
+ 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 and no image picker library is integrated', async () => {
+ const { getByText } = render();
+
+ fireEvent.press(getByText(defaultProps.addScreenshotButtonLabel));
+
+ await waitFor(() => {
+ expect(mockOnAddScreenshot).toHaveBeenCalled();
+ });
+ });
+
+ it('calls launchImageLibraryAsync when the expo-image-picker library is integrated', async () => {
+ const mockLaunchImageLibrary = jest.fn().mockResolvedValue({
+ assets: [{ fileName: "mock-image.jpg", uri: "file:///mock/path/image.jpg" }],
+ });
+ const mockImagePicker: jest.Mocked = {
+ launchImageLibraryAsync: mockLaunchImageLibrary,
+ };
+
+ const { getByText } = render();
+
+ fireEvent.press(getByText(defaultProps.addScreenshotButtonLabel));
+
+ await waitFor(() => {
+ expect(mockLaunchImageLibrary).toHaveBeenCalled();
+ });
+ });
+
+ it('calls launchImageLibrary when the react-native-image-picker library is integrated', async () => {
+ const mockLaunchImageLibrary = jest.fn().mockResolvedValue({
+ assets: [{ fileName: "mock-image.jpg", uri: "file:///mock/path/image.jpg" }],
+ });
+ const mockImagePicker: jest.Mocked = {
+ launchImageLibrary: mockLaunchImageLibrary,
+ };
+
+ const { getByText } = render();
+
+ fireEvent.press(getByText(defaultProps.addScreenshotButtonLabel));
+
+ await waitFor(() => {
+ expect(mockLaunchImageLibrary).toHaveBeenCalled();
+ });
+ });
+
+ it('calls onFormClose when the cancel button is pressed', () => {
+ const { getByText } = render();
+
+ fireEvent.press(getByText(defaultProps.cancelButtonLabel));
+
+ expect(mockOnFormClose).toHaveBeenCalled();
+ });
+
+ it('onUnmount the input is saved and restored when the form reopens', async () => {
+ const { getByPlaceholderText, unmount } = 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.');
+
+ unmount();
+ const { queryByPlaceholderText } = render();
+
+ expect(queryByPlaceholderText(defaultProps.namePlaceholder).props.value).toBe('John Doe');
+ expect(queryByPlaceholderText(defaultProps.emailPlaceholder).props.value).toBe('john.doe@example.com');
+ expect(queryByPlaceholderText(defaultProps.messagePlaceholder).props.value).toBe('This is a feedback message.');
+ });
+
+ it('onCancel the input is saved and restored when the form reopens', async () => {
+ const { getByPlaceholderText, getByText, unmount } = 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.cancelButtonLabel));
+ unmount();
+ const { queryByPlaceholderText } = render();
+
+ expect(queryByPlaceholderText(defaultProps.namePlaceholder).props.value).toBe('John Doe');
+ expect(queryByPlaceholderText(defaultProps.emailPlaceholder).props.value).toBe('john.doe@example.com');
+ expect(queryByPlaceholderText(defaultProps.messagePlaceholder).props.value).toBe('This is a feedback message.');
+ });
+
+ it('onSubmit the saved input is cleared and not restored when the form reopens', async () => {
+ const { getByPlaceholderText, getByText, unmount } = 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));
+ unmount();
+ const { queryByPlaceholderText } = render();
+
+ expect(queryByPlaceholderText(defaultProps.namePlaceholder).props.value).toBe('Test User');
+ expect(queryByPlaceholderText(defaultProps.emailPlaceholder).props.value).toBe('test@example.com');
+ expect(queryByPlaceholderText(defaultProps.messagePlaceholder).props.value).toBe('');
+ });
+});
diff --git a/packages/core/test/feedback/FeedbackWidgetManager.test.tsx b/packages/core/test/feedback/FeedbackWidgetManager.test.tsx
new file mode 100644
index 0000000000..2d9bfcc926
--- /dev/null
+++ b/packages/core/test/feedback/FeedbackWidgetManager.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 { FeedbackWidgetProvider, showFeedbackWidget } from '../../src/js/feedback/FeedbackWidgetManager';
+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('FeedbackWidgetManager', () => {
+ it('showFeedbackWidget displays the form when FeedbackWidgetProvider is used', () => {
+ mockedIsModalSupported.mockReturnValue(true);
+ const { getByText, getByTestId } = render(
+
+ App Components
+
+ );
+
+ showFeedbackWidget();
+
+ expect(getByTestId('feedback-form-modal')).toBeTruthy();
+ expect(getByText('App Components')).toBeTruthy();
+ });
+
+ it('showFeedbackWidget does not display the form when Modal is not available', () => {
+ mockedIsModalSupported.mockReturnValue(false);
+ const { getByText, queryByTestId } = render(
+
+ App Components
+
+ );
+
+ showFeedbackWidget();
+
+ expect(queryByTestId('feedback-form-modal')).toBeNull();
+ expect(getByText('App Components')).toBeTruthy();
+ expect(logger.error).toHaveBeenLastCalledWith(
+ 'FeedbackWidget Modal is not supported in React Native < 0.71 with Fabric renderer.',
+ );
+ });
+
+ it('showFeedbackWidget does not throw an error when FeedbackWidgetProvider is not used', () => {
+ expect(() => {
+ showFeedbackWidget();
+ }).not.toThrow();
+ });
+
+ it('showFeedbackWidget 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',
+ });
+
+ showFeedbackWidget();
+
+ expect(getByPlaceholderText('Custom Message Placeholder')).toBeTruthy();
+ expect(getByText('Custom Submit Button')).toBeTruthy();
+ });
+
+ it('showFeedbackWidget 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',
+ }),
+
+ showFeedbackWidget();
+
+ 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__/FeedbackWidget.test.tsx.snap b/packages/core/test/feedback/__snapshots__/FeedbackWidget.test.tsx.snap
new file mode 100644
index 0000000000..9f71d72ceb
--- /dev/null
+++ b/packages/core/test/feedback/__snapshots__/FeedbackWidget.test.tsx.snap
@@ -0,0 +1,1700 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`FeedbackWidget matches the snapshot with custom styles 1`] = `
+
+
+
+ Report a Bug
+
+
+
+
+ Name
+
+
+
+ Email
+
+
+
+ Description
+ (required)
+
+
+
+
+ Send Bug Report
+
+
+
+
+ Cancel
+
+
+
+`;
+
+exports[`FeedbackWidget 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[`FeedbackWidget 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[`FeedbackWidget 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[`FeedbackWidget matches the snapshot with default configuration 1`] = `
+
+
+
+ Report a Bug
+
+
+
+
+ Name
+
+
+
+ Email
+
+
+
+ Description
+ (required)
+
+
+
+
+ Send Bug Report
+
+
+
+
+ Cancel
+
+
+
+`;
+
+exports[`FeedbackWidget 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/packages/core/test/mockWrapper.ts b/packages/core/test/mockWrapper.ts
index 82b2b9194c..fe6a611394 100644
--- a/packages/core/test/mockWrapper.ts
+++ b/packages/core/test/mockWrapper.ts
@@ -59,6 +59,7 @@ const NATIVE: MockInterface = {
crashedLastRun: jest.fn(),
getNewScreenTimeToDisplay: jest.fn().mockResolvedValue(42),
+ getDataFromUri: jest.fn(),
};
NATIVE.isNativeAvailable.mockReturnValue(true);
diff --git a/samples/expo/app.json b/samples/expo/app.json
index 694ba5382f..1f1c89980d 100644
--- a/samples/expo/app.json
+++ b/samples/expo/app.json
@@ -54,6 +54,12 @@
}
}
],
+ [
+ "expo-image-picker",
+ {
+ "photosPermission": "The app accesses your photos to let you share them with your friends."
+ }
+ ],
[
"expo-router",
{
diff --git a/samples/expo/app/(tabs)/index.tsx b/samples/expo/app/(tabs)/index.tsx
index c6c5cc0b5a..947b22e471 100644
--- a/samples/expo/app/(tabs)/index.tsx
+++ b/samples/expo/app/(tabs)/index.tsx
@@ -58,6 +58,12 @@ export default function TabOneScreen() {
Sentry.nativeCrash();
}}
/>
+