Skip to content

Commit c81a7cd

Browse files
authored
chore(predict): add error handling for predict actions (#21590)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** - Add error handling on features calls (place order, claim, withdraw) <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds an offline error UI and propagates robust error handling/logging across Predict UI, hooks, controller, and provider with new validations, toasts, retries, and extensive tests. > > - **UI/UX**: > - Introduce `PredictOffline` component (error state with retry) and integrate into `PredictTabView` to display on errors from `PredictPositionsHeader`/`PredictPositions`. > - `PredictPositions`/`PredictPositionsHeader`: add `onError` callbacks; surface hook errors to parent. > - **Hooks**: > - `usePredictClaim`/`usePredictDeposit`/`usePredictPlaceOrder`: capture exceptions to Sentry, navigate back on failure, show actionable toasts with retry, and reload balances. > - **Controller** (`PredictController`): > - Harden `placeOrder`, `claimWithConfirmation`, `depositWithConfirmation`, `prepareWithdraw` with validation (account/chainId/batchId/transactions), state updates (`lastError`, timestamps), DevLogger logs, and Sentry contexts; clear errors on success. > - **Provider** (`PolymarketProvider`): > - Return structured errors (no throws) for `placeOrder`; add validations and errors for claim/deposit/account state; improve price history/markets error fallback; preview BUY rate-limiting. > - **Localization**: add `predict.error.{title,description,retry}` strings. > - **Tests**: add new suites and expand coverage for error paths, navigation, retries, toasts, validations, and rate limiting across components, hooks, controller, and provider. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ded18c7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 0072bc2 commit c81a7cd

22 files changed

+3192
-470
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { StyleSheet } from 'react-native';
2+
3+
const styleSheet = () =>
4+
StyleSheet.create({
5+
errorState: {
6+
flex: 1,
7+
justifyContent: 'center',
8+
alignItems: 'center',
9+
paddingHorizontal: 24,
10+
},
11+
errorStateIcon: {
12+
marginBottom: 16,
13+
},
14+
errorStateTitle: {
15+
marginBottom: 8,
16+
textAlign: 'center',
17+
},
18+
errorStateDescription: {
19+
textAlign: 'center',
20+
marginBottom: 24,
21+
},
22+
errorStateButton: {
23+
alignSelf: 'center',
24+
width: '100%',
25+
},
26+
});
27+
28+
export default styleSheet;
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import React from 'react';
2+
import { screen, fireEvent } from '@testing-library/react-native';
3+
import PredictOffline from './PredictOffline';
4+
import renderWithProvider from '../../../../../util/test/renderWithProvider';
5+
6+
// Mock dependencies
7+
jest.mock('@metamask/design-system-react-native', () => {
8+
const { View, Text } = jest.requireActual('react-native');
9+
return {
10+
Box: ({
11+
children,
12+
testID,
13+
...props
14+
}: {
15+
children: React.ReactNode;
16+
testID?: string;
17+
[key: string]: unknown;
18+
}) => (
19+
<View testID={testID} {...props}>
20+
{children}
21+
</View>
22+
),
23+
Text: ({
24+
children,
25+
variant,
26+
...props
27+
}: {
28+
children: React.ReactNode;
29+
variant?: string;
30+
[key: string]: unknown;
31+
}) => (
32+
<Text testID={props.testID} {...props}>
33+
{children}
34+
</Text>
35+
),
36+
TextVariant: {
37+
HeadingMd: 'heading-md',
38+
BodyMd: 'body-md',
39+
},
40+
};
41+
});
42+
43+
jest.mock('../../../../../component-library/components/Icons/Icon', () => {
44+
const { View } = jest.requireActual('react-native');
45+
return {
46+
__esModule: true,
47+
default: ({
48+
name,
49+
size,
50+
color,
51+
testID,
52+
...props
53+
}: {
54+
name: string;
55+
size: string;
56+
color: string;
57+
testID?: string;
58+
[key: string]: unknown;
59+
}) => (
60+
<View testID={testID || 'icon'} {...props}>
61+
{name}
62+
</View>
63+
),
64+
IconName: {
65+
Warning: 'warning',
66+
},
67+
IconSize: {
68+
XXL: 'xxl',
69+
},
70+
IconColor: {
71+
Error: 'error',
72+
},
73+
};
74+
});
75+
76+
jest.mock('../../../../../component-library/components/Buttons/Button', () => {
77+
const { TouchableOpacity, Text } = jest.requireActual('react-native');
78+
return {
79+
__esModule: true,
80+
default: ({
81+
onPress,
82+
label,
83+
testID,
84+
...props
85+
}: {
86+
onPress: () => void;
87+
label: string;
88+
testID?: string;
89+
[key: string]: unknown;
90+
}) => (
91+
<TouchableOpacity
92+
testID={testID || 'button'}
93+
onPress={onPress}
94+
{...props}
95+
>
96+
<Text>{label}</Text>
97+
</TouchableOpacity>
98+
),
99+
ButtonSize: {
100+
Lg: 'lg',
101+
},
102+
ButtonVariants: {
103+
Primary: 'primary',
104+
},
105+
};
106+
});
107+
108+
jest.mock('../../../../../component-library/hooks', () => ({
109+
useStyles: jest.fn(() => ({
110+
styles: {
111+
errorState: {},
112+
errorStateIcon: {},
113+
errorStateTitle: {},
114+
errorStateDescription: {},
115+
errorStateButton: {},
116+
},
117+
})),
118+
}));
119+
120+
describe('PredictOffline', () => {
121+
describe('Component Rendering', () => {
122+
it('renders the error state with default message', () => {
123+
renderWithProvider(<PredictOffline />);
124+
125+
expect(
126+
screen.getByText('Unable to connect to predictions'),
127+
).toBeOnTheScreen();
128+
expect(
129+
screen.getByText(
130+
'Prediction markets are temporarily offline. Please check you have a stable connection and try again.',
131+
),
132+
).toBeOnTheScreen();
133+
expect(screen.getByTestId('icon')).toBeOnTheScreen();
134+
});
135+
136+
it('renders with custom test ID', () => {
137+
renderWithProvider(<PredictOffline testID="custom-error-state" />);
138+
139+
expect(screen.getByTestId('custom-error-state')).toBeOnTheScreen();
140+
});
141+
142+
it('renders with default test ID when not provided', () => {
143+
renderWithProvider(<PredictOffline />);
144+
145+
expect(screen.getByTestId('predict-error-state')).toBeOnTheScreen();
146+
});
147+
});
148+
149+
describe('Message Display', () => {
150+
it('displays error description', () => {
151+
renderWithProvider(<PredictOffline />);
152+
153+
expect(
154+
screen.getByText(
155+
'Prediction markets are temporarily offline. Please check you have a stable connection and try again.',
156+
),
157+
).toBeOnTheScreen();
158+
});
159+
160+
it('displays error title', () => {
161+
renderWithProvider(<PredictOffline />);
162+
163+
expect(
164+
screen.getByText('Unable to connect to predictions'),
165+
).toBeOnTheScreen();
166+
});
167+
});
168+
169+
describe('Retry Button', () => {
170+
it('renders retry button when onRetry callback is provided', () => {
171+
const onRetry = jest.fn();
172+
173+
renderWithProvider(<PredictOffline onRetry={onRetry} />);
174+
175+
expect(screen.getByText('Retry')).toBeOnTheScreen();
176+
});
177+
178+
it('calls onRetry callback when retry button is pressed', () => {
179+
const onRetry = jest.fn();
180+
181+
renderWithProvider(<PredictOffline onRetry={onRetry} />);
182+
183+
const retryButton = screen.getByText('Retry');
184+
fireEvent.press(retryButton);
185+
186+
expect(onRetry).toHaveBeenCalledTimes(1);
187+
});
188+
189+
it('does not render retry button when onRetry callback is not provided', () => {
190+
renderWithProvider(<PredictOffline />);
191+
192+
expect(screen.queryByText('Retry')).not.toBeOnTheScreen();
193+
});
194+
});
195+
196+
describe('Icon Display', () => {
197+
it('displays warning icon', () => {
198+
renderWithProvider(<PredictOffline />);
199+
200+
const icon = screen.getByTestId('icon');
201+
202+
expect(icon).toBeOnTheScreen();
203+
});
204+
});
205+
206+
describe('Edge Cases', () => {
207+
it('renders without retry button when onRetry is undefined', () => {
208+
renderWithProvider(<PredictOffline onRetry={undefined} />);
209+
210+
expect(screen.queryByText('Retry')).not.toBeOnTheScreen();
211+
});
212+
});
213+
214+
describe('Integration', () => {
215+
it('renders all elements together with retry callback', () => {
216+
const onRetry = jest.fn();
217+
218+
renderWithProvider(
219+
<PredictOffline onRetry={onRetry} testID="network-error" />,
220+
);
221+
222+
expect(screen.getByTestId('network-error')).toBeOnTheScreen();
223+
expect(
224+
screen.getByText('Unable to connect to predictions'),
225+
).toBeOnTheScreen();
226+
expect(
227+
screen.getByText(
228+
'Prediction markets are temporarily offline. Please check you have a stable connection and try again.',
229+
),
230+
).toBeOnTheScreen();
231+
expect(screen.getByText('Retry')).toBeOnTheScreen();
232+
expect(screen.getByTestId('icon')).toBeOnTheScreen();
233+
});
234+
});
235+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import React from 'react';
2+
import { strings } from '../../../../../../locales/i18n';
3+
import Button, {
4+
ButtonSize,
5+
ButtonVariants,
6+
} from '../../../../../component-library/components/Buttons/Button';
7+
import { useStyles } from '../../../../../component-library/hooks';
8+
import { Box, Text, TextVariant } from '@metamask/design-system-react-native';
9+
import Icon, {
10+
IconName,
11+
IconSize,
12+
IconColor,
13+
} from '../../../../../component-library/components/Icons/Icon';
14+
import styleSheet from './PredictOffline.styles';
15+
16+
interface PredictOfflineProps {
17+
/**
18+
* Optional callback when retry button is pressed
19+
*/
20+
onRetry?: () => void;
21+
/**
22+
* TestID for the component
23+
*/
24+
testID?: string;
25+
}
26+
27+
const PredictOffline: React.FC<PredictOfflineProps> = ({
28+
onRetry,
29+
testID = 'predict-error-state',
30+
}) => {
31+
const { styles } = useStyles(styleSheet, {});
32+
33+
return (
34+
<Box testID={testID} style={styles.errorState}>
35+
<Icon
36+
name={IconName.Warning}
37+
size={IconSize.XXL}
38+
color={IconColor.Error}
39+
style={styles.errorStateIcon}
40+
/>
41+
<Text
42+
variant={TextVariant.HeadingMd}
43+
twClassName="text-default"
44+
style={styles.errorStateTitle}
45+
>
46+
{strings('predict.error.title')}
47+
</Text>
48+
<Text
49+
variant={TextVariant.BodyMd}
50+
twClassName="text-alternative"
51+
style={styles.errorStateDescription}
52+
>
53+
{strings('predict.error.description')}
54+
</Text>
55+
{onRetry && (
56+
<Button
57+
variant={ButtonVariants.Primary}
58+
size={ButtonSize.Lg}
59+
onPress={onRetry}
60+
label={strings('predict.error.retry')}
61+
style={styles.errorStateButton}
62+
/>
63+
)}
64+
</Box>
65+
);
66+
};
67+
68+
export default PredictOffline;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './PredictOffline';

0 commit comments

Comments
 (0)