Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
100 changes: 100 additions & 0 deletions packages/core/src/js/feedback/FeedbackFormManager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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 { getFeedbackOptions } from './integration';
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 {...getFeedbackOptions()}
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 };
31 changes: 31 additions & 0 deletions packages/core/src/js/feedback/integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Integration } from '@sentry/core';

import { defaultConfiguration } from './defaults';
import defaultStyles from './FeedbackForm.styles';
import type { FeedbackFormProps } from './FeedbackForm.types';

export const FEEDBACK_FORM_INTEGRATION_NAME = 'Feedback Form';

type FeedbackIntegration = Integration & {
options: Partial<FeedbackFormProps>;
};

let savedOptions: Partial<FeedbackFormProps> = {};

export const feedbackIntegration = (initOptions: FeedbackFormProps = {}): FeedbackIntegration => {
savedOptions = {
...defaultConfiguration,
...initOptions,
styles: {
...defaultStyles,
...initOptions.styles,
},
};

return {
name: FEEDBACK_FORM_INTEGRATION_NAME,
options: savedOptions,
};
};

export const getFeedbackOptions = (): Partial<FeedbackFormProps> => savedOptions;
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';
1 change: 1 addition & 0 deletions packages/core/src/js/integrations/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
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
73 changes: 73 additions & 0 deletions packages/core/test/feedback/FeedbackFormManager.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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 { feedbackIntegration } from '../../src/js/feedback/integration';

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

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

describe('FeedbackFormManager', () => {
it('showFeedbackForm displays the form when FeedbackFormProvider is used', () => {
require('../../src/js/feedback/utils').isModalSupported.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', () => {
require('../../src/js/feedback/utils').isModalSupported.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();
});

it('showFeedbackForm displays the form with the feedbackIntegration options', () => {
require('../../src/js/feedback/utils').isModalSupported.mockReturnValue(true);
const { getByPlaceholderText, getByText } = render(
<FeedbackFormProvider>
<Text>App Components</Text>
</FeedbackFormProvider>
);

feedbackIntegration({
messagePlaceholder: 'Custom Message Placeholder',
submitButtonLabel: 'Custom Submit Button',
});

showFeedbackForm();

expect(getByPlaceholderText('Custom Message Placeholder')).toBeTruthy();
expect(getByText('Custom Submit Button')).toBeTruthy();
});
});
12 changes: 12 additions & 0 deletions samples/react-native/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,18 @@ Sentry.init({
? false
: true,
}),
Sentry.feedbackIntegration({
styles:{
submitButton: {
backgroundColor: '#6a1b9a',
paddingVertical: 15,
borderRadius: 5,
alignItems: 'center',
marginBottom: 10,
},
},
namePlaceholder: 'Fullname',
}),
);
return integrations.filter(i => i.name !== 'Dedupe');
},
Expand Down
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
Loading