Skip to content

Commit

Permalink
Autoinject feedback widget (#4483)
Browse files Browse the repository at this point in the history
* Auto-inject feedback form

* Temporarily disable sample rotating indicator

* Revert "Temporarily disable sample rotating indicator"

This reverts commit db407ce.

* Wrap Modal in a View

* Handles Android back button

* Make modal style configurable

* Print an error when the modal is not supported

* Add changelog

* Adds tests

* Get major, minor version with deconstruct declaration

Co-authored-by: LucasZF <[email protected]>

* Remove if condition

Co-authored-by: LucasZF <[email protected]>

* Prettier

* Fix test import

---------

Co-authored-by: LucasZF <[email protected]>
  • Loading branch information
antonis and lucas-zimerman authored Jan 29, 2025
1 parent b7b36d8 commit eda1cb7
Show file tree
Hide file tree
Showing 9 changed files with 184 additions and 2 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
- Send Sentry react-native SDK version in the session replay event (#4450)
- User Feedback Form Component Beta ([#4435](https://github.com/getsentry/sentry-react-native/pull/4435))

To collect user feedback from inside your application add the `FeedbackForm` component.
To collect user feedback from inside your application call `Sentry.showFeedbackForm()` or add the `FeedbackForm` component.

```jsx
import { FeedbackForm } from "@sentry/react-native";
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/js/feedback/FeedbackForm.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ const defaultStyles: FeedbackFormStyles = {
width: 40,
height: 40,
},
modalBackground: {
flex: 1,
justifyContent: 'center',
},
};

export default defaultStyles;
1 change: 1 addition & 0 deletions packages/core/src/js/feedback/FeedbackForm.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ export interface FeedbackFormStyles {
screenshotText?: TextStyle;
titleContainer?: ViewStyle;
sentryLogo?: ImageStyle;
modalBackground?: ViewStyle;
}

/**
Expand Down
99 changes: 99 additions & 0 deletions packages/core/src/js/feedback/FeedbackFormManager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { logger } from '@sentry/core';
import * as React from 'react';
import { Modal, View } from 'react-native';

import { FeedbackForm } from './FeedbackForm';
import defaultStyles from './FeedbackForm.styles';
import type { FeedbackFormStyles } from './FeedbackForm.types';
import { isModalSupported } from './utils';

class FeedbackFormManager {
private static _isVisible = false;
private static _setVisibility: (visible: boolean) => void;

public static initialize(setVisibility: (visible: boolean) => void): void {
this._setVisibility = setVisibility;
}

public static show(): void {
if (this._setVisibility) {
this._isVisible = true;
this._setVisibility(true);
}
}

public static hide(): void {
if (this._setVisibility) {
this._isVisible = false;
this._setVisibility(false);
}
}

public static isFormVisible(): boolean {
return this._isVisible;
}
}

interface FeedbackFormProviderProps {
children: React.ReactNode;
styles?: FeedbackFormStyles;
}

class FeedbackFormProvider extends React.Component<FeedbackFormProviderProps> {
public state = {
isVisible: false,
};

public constructor(props: FeedbackFormProviderProps) {
super(props);
FeedbackFormManager.initialize(this._setVisibilityFunction);
}

/**
* Renders the feedback form modal.
*/
public render(): React.ReactNode {
if (!isModalSupported()) {
logger.error('FeedbackForm Modal is not supported in React Native < 0.71 with Fabric renderer.');
return <>{this.props.children}</>;
}

const { isVisible } = this.state;
const styles: FeedbackFormStyles = { ...defaultStyles, ...this.props.styles };

// Wrapping the `Modal` component in a `View` component is necessary to avoid
// issues like https://github.com/software-mansion/react-native-reanimated/issues/6035
return (
<>
{this.props.children}
{isVisible && (
<View>
<Modal visible={isVisible} transparent animationType="slide" onRequestClose={this._handleClose} testID="feedback-form-modal">
<View style={styles.modalBackground}>
<FeedbackForm
onFormClose={this._handleClose}
onFormSubmitted={this._handleClose}
/>
</View>
</Modal>
</View>
)}
</>
);
}

private _setVisibilityFunction = (visible: boolean): void => {
this.setState({ isVisible: visible });
};

private _handleClose = (): void => {
FeedbackFormManager.hide();
this.setState({ isVisible: false });
};
}

const showFeedbackForm = (): void => {
FeedbackFormManager.show();
};

export { showFeedbackForm, FeedbackFormProvider };
12 changes: 12 additions & 0 deletions packages/core/src/js/feedback/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
import { isFabricEnabled } from '../utils/environment';
import { ReactNativeLibraries } from './../utils/rnlibraries';

/**
* 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);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,4 @@ export type { TimeToDisplayProps } from './tracing';
export { Mask, Unmask } from './replay/CustomMask';

export { FeedbackForm } from './feedback/FeedbackForm';
export { showFeedbackForm } from './feedback/FeedbackFormManager';
5 changes: 4 additions & 1 deletion packages/core/src/js/sdk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import * as React from 'react';

import { ReactNativeClient } from './client';
import { FeedbackFormProvider } from './feedback/FeedbackFormManager';
import { getDevServer } from './integrations/debugsymbolicatorutils';
import { getDefaultIntegrations } from './integrations/default';
import type { ReactNativeClientOptions, ReactNativeOptions, ReactNativeWrapperOptions } from './options';
Expand Down Expand Up @@ -163,7 +164,9 @@ export function wrap<P extends Record<string, unknown>>(
return (
<TouchEventBoundary {...(options?.touchEventBoundaryProps ?? {})}>
<ReactNativeProfiler {...profilerProps}>
<RootComponent {...appProps} />
<FeedbackFormProvider>
<RootComponent {...appProps} />
</FeedbackFormProvider>
</ReactNativeProfiler>
</TouchEventBoundary>
);
Expand Down
56 changes: 56 additions & 0 deletions packages/core/test/feedback/FeedbackFormManager.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { logger } from '@sentry/core';
import { render } from '@testing-library/react-native';
import * as React from 'react';
import { Text } from 'react-native';

import { FeedbackFormProvider, showFeedbackForm } from '../../src/js/feedback/FeedbackFormManager';
import { isModalSupported } from '../../src/js/feedback/utils';

jest.mock('../../src/js/feedback/utils', () => ({
isModalSupported: jest.fn(),
}));

const mockedIsModalSupported = isModalSupported as jest.MockedFunction<typeof isModalSupported>;

beforeEach(() => {
logger.error = jest.fn();
});

describe('FeedbackFormManager', () => {
it('showFeedbackForm displays the form when FeedbackFormProvider is used', () => {
mockedIsModalSupported.mockReturnValue(true);
const { getByText, getByTestId } = render(
<FeedbackFormProvider>
<Text>App Components</Text>
</FeedbackFormProvider>
);

showFeedbackForm();

expect(getByTestId('feedback-form-modal')).toBeTruthy();
expect(getByText('App Components')).toBeTruthy();
});

it('showFeedbackForm does not display the form when Modal is not available', () => {
mockedIsModalSupported.mockReturnValue(false);
const { getByText, queryByTestId } = render(
<FeedbackFormProvider>
<Text>App Components</Text>
</FeedbackFormProvider>
);

showFeedbackForm();

expect(queryByTestId('feedback-form-modal')).toBeNull();
expect(getByText('App Components')).toBeTruthy();
expect(logger.error).toHaveBeenLastCalledWith(
'FeedbackForm Modal is not supported in React Native < 0.71 with Fabric renderer.',
);
});

it('showFeedbackForm does not throw an error when FeedbackFormProvider is not used', () => {
expect(() => {
showFeedbackForm();
}).not.toThrow();
});
});
6 changes: 6 additions & 0 deletions samples/react-native/src/Screens/ErrorsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,12 @@ const ErrorsScreen = (_props: Props) => {
_props.navigation.navigate('FeedbackForm');
}}
/>
<Button
title="Feedback form (auto)"
onPress={() => {
Sentry.showFeedbackForm();
}}
/>
<Button
title="Send user feedback"
onPress={() => {
Expand Down

0 comments on commit eda1cb7

Please sign in to comment.