diff --git a/packages/core/src/js/feedback/FeedbackForm.tsx b/packages/core/src/js/feedback/FeedbackForm.tsx index 6bd340402..7dca05113 100644 --- a/packages/core/src/js/feedback/FeedbackForm.tsx +++ b/packages/core/src/js/feedback/FeedbackForm.tsx @@ -1,5 +1,5 @@ import type { SendFeedbackParams } from '@sentry/core'; -import { captureFeedback, getCurrentScope, lastEventId } from '@sentry/core'; +import { captureFeedback, getCurrentScope, lastEventId, logger } from '@sentry/core'; import * as React from 'react'; import type { KeyboardTypeOptions } from 'react-native'; import { @@ -18,6 +18,7 @@ import { import { defaultConfiguration } from './defaults'; import defaultStyles from './FeedbackForm.styles'; import type { FeedbackFormProps, FeedbackFormState, FeedbackFormStyles,FeedbackGeneralConfiguration, FeedbackTextConfiguration } from './FeedbackForm.types'; +import { checkInternetConnection, isValidEmail } from './utils'; /** * @beta @@ -48,7 +49,7 @@ export class FeedbackForm extends React.Component void = () => { const { name, email, description } = this.state; - const { onFormClose } = this.props; + const { onSubmitSuccess, onSubmitError, onFormSubmitted } = this.props; const text: FeedbackTextConfiguration = this.props; const trimmedName = name?.trim(); @@ -60,7 +61,7 @@ export class FeedbackForm extends React.Component 0) && !this._isValidEmail(trimmedEmail)) { + if (this.props.shouldValidateEmail && (this.props.isEmailRequired || trimmedEmail.length > 0) && !isValidEmail(trimmedEmail)) { Alert.alert(text.errorTitle, text.emailError); return; } @@ -73,11 +74,22 @@ export class FeedbackForm extends React.Component { // onConnected + this.setState({ isVisible: false }); + captureFeedback(userFeedback); + onSubmitSuccess({ name: trimmedName, email: trimmedEmail, message: trimmedDescription, attachments: undefined }); + Alert.alert(text.successMessageText); + onFormSubmitted(); + }, () => { // onDisconnected + Alert.alert(text.errorTitle, text.networkError); + logger.error(`Feedback form submission failed: ${text.networkError}`); + }, () => { // onError + const errorString = `Feedback form submission failed: ${text.genericError}`; + onSubmitError(new Error(errorString)); + Alert.alert(text.errorTitle, text.genericError); + logger.error(errorString); + }); }; /** @@ -163,9 +175,4 @@ export class FeedbackForm extends React.Component ); } - - private _isValidEmail = (email: string): boolean => { - const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ - return emailRegex.test(email); - }; } diff --git a/packages/core/src/js/feedback/FeedbackForm.types.ts b/packages/core/src/js/feedback/FeedbackForm.types.ts index 84078d8a6..f8cb97363 100644 --- a/packages/core/src/js/feedback/FeedbackForm.types.ts +++ b/packages/core/src/js/feedback/FeedbackForm.types.ts @@ -1,3 +1,4 @@ +import type { FeedbackFormData } from '@sentry/core'; import type { TextStyle, ViewStyle } from 'react-native'; export interface FeedbackFormProps extends FeedbackGeneralConfiguration, FeedbackTextConfiguration, FeedbackCallbacks { @@ -116,16 +117,48 @@ export interface FeedbackTextConfiguration { * The error message when the email is invalid */ emailError?: string; + + /** + * Message when there is a network error + */ + networkError?: string; + + /** + * Message when there is a network 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 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; } export interface FeedbackFormStyles { diff --git a/packages/core/src/js/feedback/defaults.ts b/packages/core/src/js/feedback/defaults.ts index ae8a3e957..982d311d4 100644 --- a/packages/core/src/js/feedback/defaults.ts +++ b/packages/core/src/js/feedback/defaults.ts @@ -16,9 +16,14 @@ 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 CONNECTIONS_ERROR_TEXT = 'Unable to send Feedback due to network issues.'; +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( @@ -27,6 +32,15 @@ export const defaultConfiguration: Partial = { ); } }, + onSubmitSuccess: () => { + // Does nothing by default + }, + onSubmitError: () => { + // Does nothing by default + }, + onFormSubmitted: () => { + // Does nothing by default + }, // FeedbackGeneralConfiguration isEmailRequired: false, @@ -50,4 +64,6 @@ export const defaultConfiguration: Partial = { formError: FORM_ERROR, emailError: EMAIL_ERROR, successMessageText: SUCCESS_MESSAGE_TEXT, + networkError: CONNECTIONS_ERROR_TEXT, + genericError: GENERIC_ERROR_TEXT, }; diff --git a/packages/core/src/js/feedback/utils.ts b/packages/core/src/js/feedback/utils.ts new file mode 100644 index 000000000..fb46b1f86 --- /dev/null +++ b/packages/core/src/js/feedback/utils.ts @@ -0,0 +1,21 @@ +export const checkInternetConnection = async ( + onConnected: () => void, + onDisconnected: () => void, + onError: () => void, +): Promise => { + try { + const response = await fetch('https://sentry.io', { method: 'HEAD' }); + if (response.ok) { + onConnected(); + } else { + onDisconnected(); + } + } catch (error) { + onError(); + } +}; + +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/test/feedback/FeedbackForm.test.tsx b/packages/core/test/feedback/FeedbackForm.test.tsx index bf8d963a5..aaf747a34 100644 --- a/packages/core/test/feedback/FeedbackForm.test.tsx +++ b/packages/core/test/feedback/FeedbackForm.test.tsx @@ -5,8 +5,12 @@ import { Alert } from 'react-native'; import { FeedbackForm } from '../../src/js/feedback/FeedbackForm'; import type { FeedbackFormProps } from '../../src/js/feedback/FeedbackForm.types'; +import { checkInternetConnection } from '../../src/js/feedback/utils'; const mockOnFormClose = 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', @@ -15,15 +19,23 @@ const mockGetUser = jest.fn(() => ({ jest.spyOn(Alert, 'alert'); jest.mock('@sentry/core', () => ({ + ...jest.requireActual('@sentry/core'), captureFeedback: jest.fn(), getCurrentScope: jest.fn(() => ({ getUser: mockGetUser, })), lastEventId: jest.fn(), })); +jest.mock('../../src/js/feedback/utils', () => ({ + ...jest.requireActual('../../src/js/feedback/utils'), + checkInternetConnection: jest.fn(), +})); const defaultProps: FeedbackFormProps = { onFormClose: mockOnFormClose, + onSubmitSuccess: mockOnSubmitSuccess, + onFormSubmitted: mockOnFormSubmitted, + onSubmitError: mockOnSubmitError, formTitle: 'Feedback Form', nameLabel: 'Name', namePlaceholder: 'Name Placeholder', @@ -38,9 +50,16 @@ const defaultProps: FeedbackFormProps = { formError: 'Please fill out all required fields.', emailError: 'The email address is not valid.', successMessageText: 'Feedback success', + networkError: 'Network error', + genericError: 'Generic error', }; describe('FeedbackForm', () => { + beforeEach(() => { + (checkInternetConnection as jest.Mock).mockImplementation((onConnected, _onDisconnected, _onError) => { + onConnected(); + }); + }); afterEach(() => { jest.clearAllMocks(); }); @@ -137,7 +156,75 @@ describe('FeedbackForm', () => { }); }); - it('calls onFormClose when the form is submitted successfully', async () => { + it('shows an error message when there is no network connection', async () => { + (checkInternetConnection as jest.Mock).mockImplementationOnce((_onConnected, onDisconnected, _onError) => { + onDisconnected(); + }); + + 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.networkError); + }); + }); + + it('shows an error message when there is a generic connection', async () => { + (checkInternetConnection as jest.Mock).mockImplementationOnce((_onConnected, _onDisconnected, onError) => { + onError(); + }); + + 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 () => { + (checkInternetConnection as jest.Mock).mockImplementationOnce((_onConnected, _onDisconnected, onError) => { + onError(); + }); + + 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'); @@ -147,7 +234,7 @@ describe('FeedbackForm', () => { fireEvent.press(getByText(defaultProps.submitButtonLabel)); await waitFor(() => { - expect(mockOnFormClose).toHaveBeenCalled(); + expect(mockOnFormSubmitted).toHaveBeenCalled(); }); });