From 1bb4e347625545eaa9028780a6f952b92b5e32cd Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 7 Apr 2025 13:01:22 +0300 Subject: [PATCH 01/10] feat(feedback): Report a Bug button (#4378) * Update the client implementation to use the new capture feedback js api * Updates SDK API * Adds new feedback button in the sample * Adds changelog * Removes unused mock * Update CHANGELOG.md Co-authored-by: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> * Directly use captureFeedback from sentry/core * Use import from core * Fixes imports order lint issue * Fixes build issue * Adds captureFeedback tests from sentry-javascript * Update CHANGELOG.md * Only deprecate client captureUserFeedback * Add simple form UI * Adds basic form functionality * Update imports * Update imports * Remove useState hook to avoid multiple react instances issues * Move types and styles in different files * Removes attachment button to be added back separately along with the implementation * Add basic field validation * Adds changelog * Updates changelog * Updates changelog * Trim whitespaces from the submitted feedback * Adds tests * Renames FeedbackFormScreen to FeedbackForm * Add beta label * Extract default text to constants * Moves constant to a separate file and aligns naming with JS * Adds input text labels * Close screen before sending the feedback to minimise wait time Co-authored-by: LucasZF * Rename file for consistency * Flatten configuration hierarchy and clean up * Align required values with JS * Use Sentry user email and name when set * Simplifies email validation * Show success alert message * Aligns naming with JS and unmounts the form by default * Use the minimum config without props in the changelog * Adds development not for unimplemented function * Show email and name conditionally * Adds sentry branding (png logo) * Adds sentry logo resource * Add assets in module exports * Revert "Add assets in module exports" This reverts commit 529247542a10e501e18b76e53e2933c7772d2d06. * Revert "Adds sentry logo resource" This reverts commit d6e9229430c70808d2f30279cc0f63e7daab0a82. * Revert "Adds sentry branding (png logo)" This reverts commit 8c5675351db5dce4f3b0981d9005e748fc66da89. * Add last event id * Mock lastEventId * Adds beta note in the changelog * Autoinject feedback form * Updates changelog * Align colors with JS * Update CHANGELOG.md Co-authored-by: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> * Update CHANGELOG.md Co-authored-by: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> * Update CHANGELOG.md Co-authored-by: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> * Use regular fonts for both buttons * Handle keyboard properly * Adds an option on whether the email should be validated * Merge properties only once * Loads current user data on form construction * Remove unneeded extra padding * Fix background color issue * Adds feedback button * Updates the changelog * Fixes changelog typo * Updates styles background color Co-authored-by: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> * Use defaultProps * Correct defaultProps * Adds test to verify when getUser is called * Use smaller image Co-authored-by: LucasZF * Add margin next to the icon * Adds bottom spacing in the ErrorScreen so that the feedback button does not hide the scrollview buttons * (2.2) feat: Add Feedback Form UI Branding logo (#4357) * Adds sentry branding logo as a base64 encoded png --------- Co-authored-by: LucasZF * Autoinject feedback form (#4370) * Align changelog entry * Update changelog * Disable bouncing * Add modal ui appearance * Update snapshot tests * Fix bottom margin * Fix sheet height * Remove extra modal border * Do not expose modal styles * Animate background color * Avoid keyboard in modal * Update changelog * Fix changelog * Updates comment * Extract FeedbackButtonProps * Add public function description to satisfy lint check * Adds tests * Fix tests * Include in the feedback integration * Fix circular dependency * Remove unneeded line Co-authored-by: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> * Place widget button below the feedback widget shadow * Expose showFeedbackButton/hideFeedbackButton methods * Add dummy integration for tracking usage * Adds button border * Fixes tests * Rename FeedbackButtonProps in tests for clarity * Add missing function call in test Co-authored-by: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> * Adds missing semicolon in test * Adds feedback button in expo app --------- Co-authored-by: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com> Co-authored-by: LucasZF --- CHANGELOG.md | 6 + .../core/src/js/feedback/FeedbackButton.tsx | 48 ++++ .../src/js/feedback/FeedbackWidget.styles.ts | 33 ++- .../src/js/feedback/FeedbackWidget.types.ts | 31 +++ .../src/js/feedback/FeedbackWidgetManager.tsx | 56 +++- packages/core/src/js/feedback/defaults.ts | 8 +- packages/core/src/js/feedback/icons.ts | 2 + packages/core/src/js/feedback/integration.ts | 23 +- packages/core/src/js/feedback/lazy.ts | 13 + packages/core/src/js/index.ts | 3 +- .../test/feedback/FeedbackButton.test.tsx | 56 ++++ .../feedback/FeedbackWidgetManager.test.tsx | 74 +++++- .../FeedbackButton.test.tsx.snap | 250 ++++++++++++++++++ samples/expo/app/(tabs)/index.tsx | 6 + samples/expo/app/_layout.tsx | 7 + samples/react-native/src/App.tsx | 7 + .../react-native/src/Screens/ErrorsScreen.tsx | 13 + 17 files changed, 617 insertions(+), 19 deletions(-) create mode 100644 packages/core/src/js/feedback/FeedbackButton.tsx create mode 100644 packages/core/src/js/feedback/icons.ts create mode 100644 packages/core/test/feedback/FeedbackButton.test.tsx create mode 100644 packages/core/test/feedback/__snapshots__/FeedbackButton.test.tsx.snap diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e874efe86..18c705b51c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### Features + +- Adds the `FeedbackButton` component that shows the Feedback Widget ([#4378](https://github.com/getsentry/sentry-react-native/pull/4378)) + ## 6.11.0-beta.0 ### Features diff --git a/packages/core/src/js/feedback/FeedbackButton.tsx b/packages/core/src/js/feedback/FeedbackButton.tsx new file mode 100644 index 0000000000..04a51cc0ca --- /dev/null +++ b/packages/core/src/js/feedback/FeedbackButton.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { Image, Text, TouchableOpacity } from 'react-native'; + +import { defaultButtonConfiguration } from './defaults'; +import { defaultButtonStyles } from './FeedbackWidget.styles'; +import type { FeedbackButtonProps, FeedbackButtonStyles, FeedbackButtonTextConfiguration } from './FeedbackWidget.types'; +import { feedbackIcon } from './icons'; +import { lazyLoadFeedbackIntegration } from './lazy'; + +const showFeedbackWidget = (): void => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { showFeedbackWidget } = require('./FeedbackWidgetManager'); + showFeedbackWidget(); +}; + +/** + * @beta + * Implements a feedback button that opens the FeedbackForm. + */ +export class FeedbackButton extends React.Component { + public constructor(props: FeedbackButtonProps) { + super(props); + lazyLoadFeedbackIntegration(); + } + + /** + * Renders the feedback button. + */ + public render(): React.ReactNode { + const text: FeedbackButtonTextConfiguration = { ...defaultButtonConfiguration, ...this.props }; + const styles: FeedbackButtonStyles = { + triggerButton: { ...defaultButtonStyles.triggerButton, ...this.props.styles?.triggerButton }, + triggerText: { ...defaultButtonStyles.triggerText, ...this.props.styles?.triggerText }, + triggerIcon: { ...defaultButtonStyles.triggerIcon, ...this.props.styles?.triggerIcon }, + }; + + return ( + + + {text.triggerLabel} + + ); + } +} diff --git a/packages/core/src/js/feedback/FeedbackWidget.styles.ts b/packages/core/src/js/feedback/FeedbackWidget.styles.ts index aebdb181e3..1bd58be9f4 100644 --- a/packages/core/src/js/feedback/FeedbackWidget.styles.ts +++ b/packages/core/src/js/feedback/FeedbackWidget.styles.ts @@ -1,6 +1,6 @@ import type { ViewStyle } from 'react-native'; -import type { FeedbackWidgetStyles } from './FeedbackWidget.types'; +import type { FeedbackButtonStyles, FeedbackWidgetStyles } from './FeedbackWidget.types'; const PURPLE = 'rgba(88, 74, 192, 1)'; const FOREGROUND_COLOR = '#2b2233'; @@ -99,6 +99,37 @@ const defaultStyles: FeedbackWidgetStyles = { }, }; +export const defaultButtonStyles: FeedbackButtonStyles = { + triggerButton: { + position: 'absolute', + bottom: 30, + right: 30, + backgroundColor: BACKGROUND_COLOR, + padding: 15, + borderRadius: 40, + justifyContent: 'center', + alignItems: 'center', + elevation: 5, + shadowColor: BORDER_COLOR, + shadowOffset: { width: 1, height: 2 }, + shadowOpacity: 0.5, + shadowRadius: 3, + flexDirection: 'row', + borderWidth: 1, + borderColor: BORDER_COLOR, + }, + triggerText: { + color: FOREGROUND_COLOR, + fontSize: 18, + }, + triggerIcon: { + width: 24, + height: 24, + padding: 2, + marginEnd: 6, + }, +}; + export const modalWrapper: ViewStyle = { position: 'absolute', top: 0, diff --git a/packages/core/src/js/feedback/FeedbackWidget.types.ts b/packages/core/src/js/feedback/FeedbackWidget.types.ts index af08c2ffc3..68b09f7c4f 100644 --- a/packages/core/src/js/feedback/FeedbackWidget.types.ts +++ b/packages/core/src/js/feedback/FeedbackWidget.types.ts @@ -154,6 +154,21 @@ export interface FeedbackTextConfiguration { genericError?: string; } +/** + * The FeedbackButton text labels that can be customized + */ +export interface FeedbackButtonTextConfiguration { + /** + * The label for the Feedback widget button that opens the dialog + */ + triggerLabel?: string; + + /** + * The aria label for the Feedback widget button that opens the dialog + */ + triggerAriaLabel?: string; +} + /** * The public callbacks available for the feedback integration */ @@ -247,6 +262,22 @@ export interface FeedbackWidgetStyles { sentryLogo?: ImageStyle; } +/** + * The props for the feedback button + */ +export interface FeedbackButtonProps extends FeedbackButtonTextConfiguration { + styles?: FeedbackButtonStyles; +} + +/** + * The styles for the feedback button + */ +export interface FeedbackButtonStyles { + triggerButton?: ViewStyle; + triggerText?: TextStyle; + triggerIcon?: ImageStyle; +} + /** * The state of the feedback form */ diff --git a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx index 856298382e..61b93a3a25 100644 --- a/packages/core/src/js/feedback/FeedbackWidgetManager.tsx +++ b/packages/core/src/js/feedback/FeedbackWidgetManager.tsx @@ -4,20 +4,25 @@ import type { NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import { Animated, Dimensions, Easing, Modal, PanResponder, Platform, ScrollView, View } from 'react-native'; import { notWeb } from '../utils/environment'; +import { FeedbackButton } from './FeedbackButton'; import { FeedbackWidget } from './FeedbackWidget'; import { modalSheetContainer, modalWrapper, topSpacer } from './FeedbackWidget.styles'; import type { FeedbackWidgetStyles } from './FeedbackWidget.types'; -import { getFeedbackOptions } from './integration'; -import { lazyLoadAutoInjectFeedbackIntegration } from './lazy'; +import { getFeedbackButtonOptions, getFeedbackOptions } from './integration'; +import { lazyLoadAutoInjectFeedbackButtonIntegration,lazyLoadAutoInjectFeedbackIntegration } from './lazy'; import { isModalSupported } from './utils'; const PULL_DOWN_CLOSE_THRESHOLD = 200; const SLIDE_ANIMATION_DURATION = 200; const BACKGROUND_ANIMATION_DURATION = 200; -class FeedbackWidgetManager { - private static _isVisible = false; - private static _setVisibility: (visible: boolean) => void; +abstract class FeedbackManager { + protected static _isVisible = false; + protected static _setVisibility: (visible: boolean) => void; + + protected static get _feedbackComponentName(): string { + throw new Error('Subclasses must override feedbackComponentName'); + } public static initialize(setVisibility: (visible: boolean) => void): void { this._setVisibility = setVisibility; @@ -38,7 +43,7 @@ class FeedbackWidgetManager { } else { // This message should be always shown otherwise it's not possible to use the widget. // eslint-disable-next-line no-console - console.warn('[Sentry] FeedbackWidget requires `Sentry.wrap(RootComponent)` to be called before `showFeedbackWidget()`.'); + console.warn(`[Sentry] ${this._feedbackComponentName} requires 'Sentry.wrap(RootComponent)' to be called before 'show${this._feedbackComponentName}()'.`); } } @@ -49,7 +54,7 @@ class FeedbackWidgetManager { } else { // This message should be always shown otherwise it's not possible to use the widget. // eslint-disable-next-line no-console - console.warn('[Sentry] FeedbackWidget requires `Sentry.wrap(RootComponent)` before interacting with the widget.'); + console.warn(`[Sentry] ${this._feedbackComponentName} requires 'Sentry.wrap(RootComponent)' before interacting with the widget.`); } } @@ -58,12 +63,25 @@ class FeedbackWidgetManager { } } +class FeedbackWidgetManager extends FeedbackManager { + protected static get _feedbackComponentName(): string { + return 'FeedbackWidget'; + } +} + +class FeedbackButtonManager extends FeedbackManager { + protected static get _feedbackComponentName(): string { + return 'FeedbackButton'; + } +} + interface FeedbackWidgetProviderProps { children: React.ReactNode; styles?: FeedbackWidgetStyles; } interface FeedbackWidgetProviderState { + isButtonVisible: boolean; isVisible: boolean; backgroundOpacity: Animated.Value; panY: Animated.Value; @@ -72,6 +90,7 @@ interface FeedbackWidgetProviderState { class FeedbackWidgetProvider extends React.Component { public state: FeedbackWidgetProviderState = { + isButtonVisible: false, isVisible: false, backgroundOpacity: new Animated.Value(0), panY: new Animated.Value(Dimensions.get('screen').height), @@ -112,6 +131,7 @@ class FeedbackWidgetProvider extends React.Component{this.props.children}; } - const { isVisible, backgroundOpacity } = this.state; + const { isButtonVisible, isVisible, backgroundOpacity } = this.state; const backgroundColor = backgroundOpacity.interpolate({ inputRange: [0, 1], @@ -162,6 +182,7 @@ class FeedbackWidgetProvider extends React.Component {this.props.children} + {isButtonVisible && } {isVisible && @@ -219,6 +240,10 @@ class FeedbackWidgetProvider extends React.Component { + this.setState({ isButtonVisible: visible }); + }; + private _handleClose = (): void => { FeedbackWidgetManager.hide(); }; @@ -233,4 +258,17 @@ const resetFeedbackWidgetManager = (): void => { FeedbackWidgetManager.reset(); }; -export { showFeedbackWidget, FeedbackWidgetProvider, resetFeedbackWidgetManager }; +const showFeedbackButton = (): void => { + lazyLoadAutoInjectFeedbackButtonIntegration(); + FeedbackButtonManager.show(); +}; + +const hideFeedbackButton = (): void => { + FeedbackButtonManager.hide(); +}; + +const resetFeedbackButtonManager = (): void => { + FeedbackButtonManager.reset(); +}; + +export { showFeedbackButton, hideFeedbackButton, showFeedbackWidget, FeedbackWidgetProvider, resetFeedbackButtonManager, resetFeedbackWidgetManager }; diff --git a/packages/core/src/js/feedback/defaults.ts b/packages/core/src/js/feedback/defaults.ts index 90d9534874..0e60a49fb2 100644 --- a/packages/core/src/js/feedback/defaults.ts +++ b/packages/core/src/js/feedback/defaults.ts @@ -1,4 +1,4 @@ -import type { FeedbackWidgetProps } from './FeedbackWidget.types'; +import type { FeedbackButtonProps, FeedbackWidgetProps } from './FeedbackWidget.types'; import { feedbackAlertDialog } from './utils'; const FORM_TITLE = 'Report a Bug'; @@ -11,6 +11,7 @@ const MESSAGE_LABEL = 'Description'; const IS_REQUIRED_LABEL = '(required)'; const SUBMIT_BUTTON_LABEL = 'Send Bug Report'; const CANCEL_BUTTON_LABEL = 'Cancel'; +const TRIGGER_LABEL = 'Report a Bug'; const ERROR_TITLE = 'Error'; const FORM_ERROR = 'Please fill out all required fields.'; const EMAIL_ERROR = 'Please enter a valid email address.'; @@ -80,3 +81,8 @@ export const defaultConfiguration: Partial = { removeScreenshotButtonLabel: REMOVE_SCREENSHOT_LABEL, genericError: GENERIC_ERROR_TEXT, }; + +export const defaultButtonConfiguration: Partial = { + triggerLabel: TRIGGER_LABEL, + triggerAriaLabel: '', +}; diff --git a/packages/core/src/js/feedback/icons.ts b/packages/core/src/js/feedback/icons.ts new file mode 100644 index 0000000000..3404bf6347 --- /dev/null +++ b/packages/core/src/js/feedback/icons.ts @@ -0,0 +1,2 @@ +export const feedbackIcon = + 'data:image/png;base64,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'; diff --git a/packages/core/src/js/feedback/integration.ts b/packages/core/src/js/feedback/integration.ts index 96280cf967..bb0854d867 100644 --- a/packages/core/src/js/feedback/integration.ts +++ b/packages/core/src/js/feedback/integration.ts @@ -1,17 +1,23 @@ import { type Integration, getClient } from '@sentry/core'; -import type { FeedbackWidgetProps } from './FeedbackWidget.types'; +import type { FeedbackButtonProps, FeedbackWidgetProps } from './FeedbackWidget.types'; export const MOBILE_FEEDBACK_INTEGRATION_NAME = 'MobileFeedback'; type FeedbackIntegration = Integration & { options: Partial; + buttonOptions: Partial; }; -export const feedbackIntegration = (initOptions: FeedbackWidgetProps = {}): FeedbackIntegration => { +export const feedbackIntegration = ( + initOptions: FeedbackWidgetProps & { buttonOptions?: FeedbackButtonProps } = {}, +): FeedbackIntegration => { + const { buttonOptions, ...widgetOptions } = initOptions; + return { name: MOBILE_FEEDBACK_INTEGRATION_NAME, - options: initOptions, + options: widgetOptions, + buttonOptions: buttonOptions || {}, }; }; @@ -25,3 +31,14 @@ export const getFeedbackOptions = (): Partial => { return integration.options; }; + +export const getFeedbackButtonOptions = (): Partial => { + const integration = getClient()?.getIntegrationByName>( + MOBILE_FEEDBACK_INTEGRATION_NAME, + ); + if (!integration) { + return {}; + } + + return integration.buttonOptions; +}; diff --git a/packages/core/src/js/feedback/lazy.ts b/packages/core/src/js/feedback/lazy.ts index ba02365c2f..bf60820779 100644 --- a/packages/core/src/js/feedback/lazy.ts +++ b/packages/core/src/js/feedback/lazy.ts @@ -25,3 +25,16 @@ export function lazyLoadAutoInjectFeedbackIntegration(): void { getClient()?.addIntegration({ name: AUTO_INJECT_FEEDBACK_INTEGRATION_NAME }); } } + +export const AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME = 'AutoInjectMobileFeedbackButton'; + +/** + * Lazy loads the auto inject feedback button integration if it is not already loaded. + */ +export function lazyLoadAutoInjectFeedbackButtonIntegration(): void { + const integration = getClient()?.getIntegrationByName(AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME); + if (!integration) { + // Lazy load the integration to track usage + getClient()?.addIntegration({ name: AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME }); + } +} diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 3aa2bdb71d..aef7733170 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -87,7 +87,8 @@ export type { TimeToDisplayProps } from './tracing'; export { Mask, Unmask } from './replay/CustomMask'; +export { FeedbackButton } from './feedback/FeedbackButton'; export { FeedbackWidget } from './feedback/FeedbackWidget'; -export { showFeedbackWidget } from './feedback/FeedbackWidgetManager'; +export { showFeedbackWidget, showFeedbackButton, hideFeedbackButton } from './feedback/FeedbackWidgetManager'; export { getDataFromUri } from './wrapper'; diff --git a/packages/core/test/feedback/FeedbackButton.test.tsx b/packages/core/test/feedback/FeedbackButton.test.tsx new file mode 100644 index 0000000000..579bccc7ca --- /dev/null +++ b/packages/core/test/feedback/FeedbackButton.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import * as React from 'react'; + +import { FeedbackButton } from '../../src/js/feedback/FeedbackButton'; +import type { FeedbackButtonProps, FeedbackButtonStyles } from '../../src/js/feedback/FeedbackWidget.types'; +import { showFeedbackWidget } from '../../src/js/feedback/FeedbackWidgetManager'; + +jest.mock('../../src/js/feedback/FeedbackWidgetManager', () => ({ + ...jest.requireActual('../../src/js/feedback/FeedbackWidgetManager'), + showFeedbackWidget: jest.fn(), +})); + +const customTextProps: FeedbackButtonProps = { + triggerLabel: 'Give Feedback', +}; + +export const customStyles: FeedbackButtonStyles = { + triggerButton: { + backgroundColor: '#ffffff', + }, + triggerText: { + color: '#ff0000', + }, +}; + +describe('FeedbackButton', () => { + 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('shows the feedback widget when pressed', async () => { + const { getByText } = render(); + + fireEvent.press(getByText(customTextProps.triggerLabel)); + + await waitFor(() => { + expect(showFeedbackWidget).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/test/feedback/FeedbackWidgetManager.test.tsx b/packages/core/test/feedback/FeedbackWidgetManager.test.tsx index ee317d2650..92894320cb 100644 --- a/packages/core/test/feedback/FeedbackWidgetManager.test.tsx +++ b/packages/core/test/feedback/FeedbackWidgetManager.test.tsx @@ -4,9 +4,9 @@ import * as React from 'react'; import { Text } from 'react-native'; import { defaultConfiguration } from '../../src/js/feedback/defaults'; -import { FeedbackWidgetProvider, resetFeedbackWidgetManager, showFeedbackWidget } from '../../src/js/feedback/FeedbackWidgetManager'; +import { FeedbackWidgetProvider, hideFeedbackButton,resetFeedbackButtonManager, resetFeedbackWidgetManager, showFeedbackButton, showFeedbackWidget } from '../../src/js/feedback/FeedbackWidgetManager'; import { feedbackIntegration } from '../../src/js/feedback/integration'; -import { AUTO_INJECT_FEEDBACK_INTEGRATION_NAME } from '../../src/js/feedback/lazy'; +import { AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME,AUTO_INJECT_FEEDBACK_INTEGRATION_NAME } from '../../src/js/feedback/lazy'; import { isModalSupported } from '../../src/js/feedback/utils'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; @@ -114,7 +114,7 @@ describe('FeedbackWidgetManager', () => { showFeedbackWidget(); - expect(consoleWarnSpy).toHaveBeenLastCalledWith('[Sentry] FeedbackWidget requires `Sentry.wrap(RootComponent)` to be called before `showFeedbackWidget()`.'); + expect(consoleWarnSpy).toHaveBeenLastCalledWith(`[Sentry] FeedbackWidget requires 'Sentry.wrap(RootComponent)' to be called before 'showFeedbackWidget()'.`); }); it('showFeedbackWidget does not warn about missing feedback provider when FeedbackWidgetProvider is used', () => { @@ -130,7 +130,7 @@ describe('FeedbackWidgetManager', () => { expect(consoleWarnSpy).not.toHaveBeenCalled(); }); - + it('showFeedbackWidget adds the feedbackIntegration to the client', () => { mockedIsModalSupported.mockReturnValue(true); @@ -139,3 +139,69 @@ describe('FeedbackWidgetManager', () => { expect(getClient().getIntegrationByName(AUTO_INJECT_FEEDBACK_INTEGRATION_NAME)).toBeDefined(); }); }); + +describe('FeedbackButtonManager', () => { + + beforeEach(() => { + const client = new TestClient(getDefaultTestClientOptions()); + setCurrentClient(client); + client.init(); + consoleWarnSpy.mockReset(); + resetFeedbackButtonManager(); + }); + + it('showFeedbackButton displays the button when FeedbackWidgetProvider is used', () => { + const { getByText } = render( + + App Components + + ); + + showFeedbackButton(); + + expect(getByText('Report a Bug')).toBeTruthy(); + }); + + it('hideFeedbackButton hides the button', () => { + const { queryByText } = render( + + App Components + + ); + + showFeedbackButton(); + hideFeedbackButton(); + + expect(queryByText('Report a Bug')).toBeNull(); + }); + + it('showFeedbackButton does not throw an error when FeedbackWidgetProvider is not used', () => { + expect(() => { + showFeedbackButton(); + }).not.toThrow(); + }); + + it('showFeedbackButton warns about missing feedback provider', () => { + showFeedbackButton(); + + expect(consoleWarnSpy).toHaveBeenLastCalledWith(`[Sentry] FeedbackButton requires 'Sentry.wrap(RootComponent)' to be called before 'showFeedbackButton()'.`); + }); + + it('showFeedbackButton does not warn about missing feedback provider when FeedbackWidgetProvider is used', () => { + render( + + App Components + + ); + + showFeedbackButton(); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('showFeedbackButton adds the feedbackIntegration to the client', () => { + showFeedbackButton(); + + expect(getClient().getIntegrationByName(AUTO_INJECT_FEEDBACK_BUTTON_INTEGRATION_NAME)).toBeDefined(); + }); +}); diff --git a/packages/core/test/feedback/__snapshots__/FeedbackButton.test.tsx.snap b/packages/core/test/feedback/__snapshots__/FeedbackButton.test.tsx.snap new file mode 100644 index 0000000000..178a48b2d3 --- /dev/null +++ b/packages/core/test/feedback/__snapshots__/FeedbackButton.test.tsx.snap @@ -0,0 +1,250 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FeedbackButton matches the snapshot with custom styles 1`] = ` + + + + Report a Bug + + +`; + +exports[`FeedbackButton matches the snapshot with custom texts 1`] = ` + + + + Give Feedback + + +`; + +exports[`FeedbackButton matches the snapshot with default configuration 1`] = ` + + + + Report a Bug + + +`; diff --git a/samples/expo/app/(tabs)/index.tsx b/samples/expo/app/(tabs)/index.tsx index 947b22e471..f8d030eb91 100644 --- a/samples/expo/app/(tabs)/index.tsx +++ b/samples/expo/app/(tabs)/index.tsx @@ -64,6 +64,12 @@ export default function TabOneScreen() { Sentry.showFeedbackWidget(); }} /> +