diff --git a/packages/core/src/js/feedback/FeedbackForm.tsx b/packages/core/src/js/feedback/FeedbackForm.tsx index 6e741b655..438eadbd2 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 { @@ -20,6 +20,7 @@ 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 @@ -50,7 +51,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(); @@ -62,7 +63,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; } @@ -75,11 +76,18 @@ 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 9805e166c..74d87aa70 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 { ImageStyle, TextStyle, ViewStyle } from 'react-native'; /** @@ -126,16 +127,43 @@ export interface FeedbackTextConfiguration { * 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 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; } /** diff --git a/packages/core/src/js/feedback/defaults.ts b/packages/core/src/js/feedback/defaults.ts index f184c6263..3c5dbdef6 100644 --- a/packages/core/src/js/feedback/defaults.ts +++ b/packages/core/src/js/feedback/defaults.ts @@ -16,9 +16,13 @@ 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 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 +31,20 @@ export const defaultConfiguration: Partial = { ); } }, + 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, @@ -51,4 +69,5 @@ export const defaultConfiguration: Partial = { formError: FORM_ERROR, emailError: EMAIL_ERROR, successMessageText: SUCCESS_MESSAGE_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..4d4efff42 --- /dev/null +++ b/packages/core/src/js/feedback/utils.ts @@ -0,0 +1,4 @@ +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 a97250441..f5f6b657f 100644 --- a/packages/core/test/feedback/FeedbackForm.test.tsx +++ b/packages/core/test/feedback/FeedbackForm.test.tsx @@ -7,6 +7,9 @@ import { FeedbackForm } from '../../src/js/feedback/FeedbackForm'; import type { FeedbackFormProps } from '../../src/js/feedback/FeedbackForm.types'; 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,6 +18,7 @@ const mockGetUser = jest.fn(() => ({ jest.spyOn(Alert, 'alert'); jest.mock('@sentry/core', () => ({ + ...jest.requireActual('@sentry/core'), captureFeedback: jest.fn(), getCurrentScope: jest.fn(() => ({ getUser: mockGetUser, @@ -24,6 +28,9 @@ jest.mock('@sentry/core', () => ({ const defaultProps: FeedbackFormProps = { onFormClose: mockOnFormClose, + onSubmitSuccess: mockOnSubmitSuccess, + onFormSubmitted: mockOnFormSubmitted, + onSubmitError: mockOnSubmitError, formTitle: 'Feedback Form', nameLabel: 'Name', namePlaceholder: 'Name Placeholder', @@ -38,6 +45,7 @@ const defaultProps: FeedbackFormProps = { formError: 'Please fill out all required fields.', emailError: 'The email address is not valid.', successMessageText: 'Feedback success', + genericError: 'Generic error', }; describe('FeedbackForm', () => { @@ -144,7 +152,57 @@ describe('FeedbackForm', () => { }); }); - it('calls onFormClose when the form is submitted successfully', async () => { + 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'); @@ -154,7 +212,7 @@ describe('FeedbackForm', () => { fireEvent.press(getByText(defaultProps.submitButtonLabel)); await waitFor(() => { - expect(mockOnFormClose).toHaveBeenCalled(); + expect(mockOnFormSubmitted).toHaveBeenCalled(); }); }); diff --git a/samples/react-native/src/App.tsx b/samples/react-native/src/App.tsx index 07afc5a21..956c77223 100644 --- a/samples/react-native/src/App.tsx +++ b/samples/react-native/src/App.tsx @@ -158,6 +158,7 @@ const ErrorsTabNavigator = Sentry.withProfiler(