Skip to content

Commit 9991a94

Browse files
authored
fix(rewards): musd calculator (#30160)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** https://consensyssoftware.atlassian.net/browse/RWDS-1294 https://consensyssoftware.atlassian.net/browse/RWDS-1295 https://consensyssoftware.atlassian.net/browse/RWDS-1297 - Updated the mUSD calculator screen container to align better with other Rewards views and avoid unnecessary nested keyboard handling. - Moved keyboard handling into MusdCalculatorTab with KeyboardAwareScrollView so the amount input stays visible while editing. - Simplified calculator state: raw string for the text input, numeric amount for slider and earnings calculations. - Normalized amount input to strip decorations, limit to two decimal places, and cap values at 10,000. - Reused the shared formatUsd utility instead of local fiat formatter/BigNumber wiring. - Refactored slider gesture handling to separate tap and pan behavior, fixing resets when tapping or long-pressing the slider. - Removed unnecessary input focus/end-edit/submit handlers. - Removed unnecessary useMemo usage for simple labels and earnings calculations. - Added regression tests for keyboard-aware behavior, input normalization, amount capping, and slider reset cases. <!-- 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** https://github.com/user-attachments/assets/0a80bef1-8dfa-41db-a41e-8a1432a005b8 <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [x] 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). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] 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. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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] > **Medium Risk** > Moderate risk because it refactors slider gesture handling and amount parsing/formatting, which can affect user-entered values and earnings calculations across iOS/Android keyboard behavior. > > **Overview** > Improves the mUSD calculator UX by **moving keyboard avoidance into the tab** via `KeyboardAwareScrollView` and simplifying the view container (removing the outer `KeyboardAvoidingView` and adjusting padding). > > Refactors `MusdCalculatorTab` to keep **raw numeric input state**, cap input at `MAX_AMOUNT`, and limit decimals to 2, while switching all USD display to the shared `formatUsd` helper. > > Reworks slider interactions to use **simultaneous tap + pan gestures** (instead of `Pressable` press handling) to prevent unintended slider resets, and adds/updates tests to cover keyboard-aware props, input normalization/capping, and gesture regression cases. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c3d9209. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent ec66ea6 commit 9991a94

4 files changed

Lines changed: 242 additions & 150 deletions

File tree

app/components/UI/Rewards/Views/MusdCalculatorView.test.tsx

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,6 @@ describe('MusdCalculatorView', () => {
6666
expect(getByTestId('musd-calculator-tab')).toBeOnTheScreen();
6767
});
6868

69-
it('wraps the calculator in a keyboard avoiding view', () => {
70-
const { getByTestId } = render(<MusdCalculatorView />);
71-
expect(
72-
getByTestId('musd-calculator-keyboard-avoiding-view'),
73-
).toBeOnTheScreen();
74-
});
75-
7669
it('tracks REWARDS_PAGE_VIEWED on mount with page_type musd_calculator', () => {
7770
render(<MusdCalculatorView />);
7871

app/components/UI/Rewards/Views/MusdCalculatorView.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import React from 'react';
22
import { HeaderStandard } from '@metamask/design-system-react-native';
33
import { useNavigation } from '@react-navigation/native';
4-
import { KeyboardAvoidingView, Platform } from 'react-native';
54
import { SafeAreaView } from 'react-native-safe-area-context';
65
import { useTailwind } from '@metamask/design-system-twrnc-preset';
76
import ErrorBoundary from '../../../Views/ErrorBoundary';
@@ -19,21 +18,14 @@ const MusdCalculatorView: React.FC = () => {
1918
<ErrorBoundary navigation={navigation} view="MusdCalculatorView">
2019
<SafeAreaView
2120
edges={{ top: 'additive' }}
22-
style={tw.style('flex-1 bg-default')}
21+
style={tw.style('flex-1 bg-default pb-4')}
2322
>
2423
<HeaderStandard
2524
title={strings('rewards.musd.page_title')}
2625
onBack={() => navigation.goBack()}
2726
backButtonProps={{ testID: 'header-back-button' }}
2827
/>
29-
<KeyboardAvoidingView
30-
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
31-
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
32-
style={tw.style('flex-1')}
33-
testID="musd-calculator-keyboard-avoiding-view"
34-
>
35-
<MusdCalculatorTab />
36-
</KeyboardAvoidingView>
28+
<MusdCalculatorTab />
3729
</SafeAreaView>
3830
</ErrorBoundary>
3931
);

app/components/UI/Rewards/components/Tabs/MusdCalculatorTab/MusdCalculatorTab.test.tsx

Lines changed: 125 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,27 @@ import { amountToPercent } from '../../../utils/musdCalculatorSlider';
99
const mockPanGestureHandlers: {
1010
onBegin?: (event: { x: number }) => void;
1111
onUpdate?: (event: { x: number }) => void;
12+
onEnd?: (event: { x: number }) => void;
1213
onFinalize?: (event: { x: number }) => void;
1314
} = {};
15+
const mockTapGestureHandlers: {
16+
onEnd?: (event: { x: number }) => void;
17+
} = {};
1418

1519
jest.mock('react-native-gesture-handler', () => ({
1620
GestureHandlerRootView: jest.requireActual('react-native').View,
1721
GestureDetector: ({ children }: { children: React.ReactNode }) => children,
1822
Gesture: {
23+
Simultaneous: jest.fn((...gestures: unknown[]) => gestures),
24+
Tap: jest.fn(() => ({
25+
onEnd: jest.fn(function (
26+
this: unknown,
27+
handler: (event: { x: number }) => void,
28+
) {
29+
mockTapGestureHandlers.onEnd = handler;
30+
return this;
31+
}),
32+
})),
1933
Pan: jest.fn(() => ({
2034
minDistance: jest.fn().mockReturnThis(),
2135
onBegin: jest.fn(function (
@@ -32,6 +46,13 @@ jest.mock('react-native-gesture-handler', () => ({
3246
mockPanGestureHandlers.onUpdate = handler;
3347
return this;
3448
}),
49+
onEnd: jest.fn(function (
50+
this: unknown,
51+
handler: (event: { x: number }) => void,
52+
) {
53+
mockPanGestureHandlers.onEnd = handler;
54+
return this;
55+
}),
3556
onFinalize: jest.fn(function (
3657
this: unknown,
3758
handler: (event: { x: number }) => void,
@@ -60,12 +81,18 @@ jest.mock('../../../../../../../locales/i18n', () => ({
6081
strings: jest.fn((key: string) => key),
6182
}));
6283

63-
jest.mock('../../../../SimulationDetails/FiatDisplay/useFiatFormatter', () =>
64-
jest.fn(
65-
() => (value: { toNumber: () => number }) =>
66-
`$${value.toNumber().toLocaleString('en-US')}`,
67-
),
68-
);
84+
jest.mock('react-native-keyboard-aware-scroll-view', () => {
85+
const ReactActual = jest.requireActual('react');
86+
const { ScrollView } = jest.requireActual('react-native');
87+
return {
88+
KeyboardAwareScrollView: ({
89+
children,
90+
...props
91+
}: {
92+
children?: React.ReactNode;
93+
}) => ReactActual.createElement(ScrollView, props, children),
94+
};
95+
});
6996

7097
jest.mock('../../../../../../core/DeeplinkManager', () => ({
7198
handleDeeplink: jest.fn(),
@@ -90,7 +117,9 @@ describe('MusdCalculatorTab', () => {
90117
jest.clearAllMocks();
91118
mockPanGestureHandlers.onBegin = undefined;
92119
mockPanGestureHandlers.onUpdate = undefined;
120+
mockPanGestureHandlers.onEnd = undefined;
93121
mockPanGestureHandlers.onFinalize = undefined;
122+
mockTapGestureHandlers.onEnd = undefined;
94123
jest.mocked(useAnalytics).mockReturnValue(
95124
createMockUseAnalyticsHook({
96125
trackEvent: mockTrackEvent,
@@ -114,6 +143,20 @@ describe('MusdCalculatorTab', () => {
114143
expect(getByTestId('musd-slider-track')).toBeOnTheScreen();
115144
});
116145

146+
it('uses a keyboard-aware scroll container for the amount input', () => {
147+
const { getByTestId } = render(<MusdCalculatorTab />);
148+
const scrollView = getByTestId(
149+
'musd-calculator-keyboard-aware-scroll-view',
150+
);
151+
152+
expect(scrollView).toHaveProp('keyboardShouldPersistTaps', 'handled');
153+
expect(scrollView).toHaveProp('keyboardDismissMode', 'none');
154+
expect(scrollView).toHaveProp('enableOnAndroid', true);
155+
expect(scrollView).toHaveProp('enableAutomaticScroll', true);
156+
expect(scrollView).toHaveProp('enableResetScrollToCoords', false);
157+
expect(scrollView).toHaveProp('extraScrollHeight', 20);
158+
});
159+
117160
it('calls handleDeeplink and tracks buy_musd event when Buy button is pressed', () => {
118161
const { handleDeeplink } = jest.requireMock(
119162
'../../../../../../core/DeeplinkManager',
@@ -159,14 +202,14 @@ describe('MusdCalculatorTab', () => {
159202
});
160203

161204
const locationX = (amountToPercent(5000) / 100) * 300;
162-
fireEvent(track, 'pressIn', {
163-
nativeEvent: { locationX },
205+
await act(async () => {
206+
mockTapGestureHandlers.onEnd?.({ x: locationX });
164207
});
165208

166209
await waitFor(() => {
167210
expect(getByTestId('musd-slider-amount-display')).toHaveProp(
168211
'value',
169-
'$5,000',
212+
'5000',
170213
);
171214
expect(
172215
getByText(/rewards\.musd\.earnings_per_day_suffix/),
@@ -183,52 +226,42 @@ describe('MusdCalculatorTab', () => {
183226
const { getByTestId, getByText } = render(<MusdCalculatorTab />);
184227
const amountInput = getByTestId('musd-slider-amount-display');
185228

186-
fireEvent(amountInput, 'focus');
187229
fireEvent.changeText(amountInput, '5000');
188230

189231
await waitFor(() => {
190232
expect(amountInput).toHaveProp('value', '5000');
191233
expect(getByText(/\$150/)).toBeOnTheScreen();
192234
});
193-
194-
fireEvent(amountInput, 'endEditing');
195-
196-
await waitFor(() => {
197-
expect(amountInput).toHaveProp('value', '$5,000');
198-
});
199235
});
200236

201-
it('accepts amounts over the slider maximum', async () => {
237+
it('caps typed amounts at the slider maximum', async () => {
202238
const { getByTestId, getByText } = render(<MusdCalculatorTab />);
203239
const amountInput = getByTestId('musd-slider-amount-display');
204240

205-
fireEvent(amountInput, 'focus');
206241
fireEvent.changeText(amountInput, '12000');
207242

208243
await waitFor(() => {
209-
expect(amountInput).toHaveProp('value', '12000');
210-
expect(getByText(/\$360/)).toBeOnTheScreen();
244+
expect(amountInput).toHaveProp('value', '10000');
245+
expect(getByText(/\$300/)).toBeOnTheScreen();
211246
});
212247
});
213248

214-
it('normalizes decorated decimal input while editing', async () => {
249+
it('normalizes decorated decimal input to two decimal places while editing', async () => {
215250
const { getByTestId, getByText } = render(<MusdCalculatorTab />);
216251
const amountInput = getByTestId('musd-slider-amount-display');
217252

218-
fireEvent(amountInput, 'focus');
219253
fireEvent.changeText(amountInput, '$1,234.56.78');
220254

221255
await waitFor(() => {
222-
expect(amountInput).toHaveProp('value', '1234.5678');
223-
expect(getByText(/\$37\.037/)).toBeOnTheScreen();
256+
expect(amountInput).toHaveProp('value', '1234.56');
257+
expect(getByText(/\$37\.04/)).toBeOnTheScreen();
224258
});
225259
});
226260

227261
it('treats invalid numeric input as zero', async () => {
228262
const { getByTestId, getAllByText } = render(<MusdCalculatorTab />);
229263
const amountInput = getByTestId('musd-slider-amount-display');
230264

231-
fireEvent(amountInput, 'focus');
232265
fireEvent.changeText(amountInput, '.');
233266

234267
await waitFor(() => {
@@ -240,13 +273,13 @@ describe('MusdCalculatorTab', () => {
240273
it('ignores slider presses before the track is measured', () => {
241274
const { getByTestId } = render(<MusdCalculatorTab />);
242275

243-
fireEvent(getByTestId('musd-slider-track'), 'pressIn', {
244-
nativeEvent: { locationX: 200 },
276+
act(() => {
277+
mockTapGestureHandlers.onEnd?.({ x: 200 });
245278
});
246279

247280
expect(getByTestId('musd-slider-amount-display')).toHaveProp(
248281
'value',
249-
'$1,000',
282+
'1000',
250283
);
251284
});
252285

@@ -261,17 +294,79 @@ describe('MusdCalculatorTab', () => {
261294
act(() => {
262295
mockPanGestureHandlers.onBegin?.({ x: 150 });
263296
mockPanGestureHandlers.onUpdate?.({ x: 300 });
297+
mockPanGestureHandlers.onEnd?.({ x: 300 });
264298
mockPanGestureHandlers.onFinalize?.({ x: 300 });
265299
});
266300

267301
await waitFor(() => {
268302
expect(getByTestId('musd-slider-amount-display')).toHaveProp(
269303
'value',
270-
'$10,000',
304+
'10000',
271305
);
272306
});
273307
});
274308

309+
it('does not reset the slider when a press finalizes without a pan', async () => {
310+
const { getByTestId } = render(<MusdCalculatorTab />);
311+
const track = getByTestId('musd-slider-track');
312+
313+
fireEvent(track, 'layout', {
314+
nativeEvent: { layout: { width: 300, height: 32, x: 0, y: 0 } },
315+
});
316+
317+
const locationX = (amountToPercent(5000) / 100) * 300;
318+
await act(async () => {
319+
mockTapGestureHandlers.onEnd?.({ x: locationX });
320+
});
321+
322+
await waitFor(() => {
323+
expect(getByTestId('musd-slider-amount-display')).toHaveProp(
324+
'value',
325+
'5000',
326+
);
327+
});
328+
329+
act(() => {
330+
mockPanGestureHandlers.onFinalize?.({ x: 0 });
331+
});
332+
333+
expect(getByTestId('musd-slider-amount-display')).toHaveProp(
334+
'value',
335+
'5000',
336+
);
337+
});
338+
339+
it('does not reset when tapping the same slider position twice', async () => {
340+
const { getByTestId } = render(<MusdCalculatorTab />);
341+
const track = getByTestId('musd-slider-track');
342+
343+
fireEvent(track, 'layout', {
344+
nativeEvent: { layout: { width: 300, height: 32, x: 0, y: 0 } },
345+
});
346+
347+
const locationX = (amountToPercent(5000) / 100) * 300;
348+
349+
await act(async () => {
350+
mockTapGestureHandlers.onEnd?.({ x: locationX });
351+
});
352+
353+
await waitFor(() => {
354+
expect(getByTestId('musd-slider-amount-display')).toHaveProp(
355+
'value',
356+
'5000',
357+
);
358+
});
359+
360+
await act(async () => {
361+
mockTapGestureHandlers.onEnd?.({ x: locationX });
362+
});
363+
364+
expect(getByTestId('musd-slider-amount-display')).toHaveProp(
365+
'value',
366+
'5000',
367+
);
368+
});
369+
275370
it('ignores pan gesture handlers before the track is measured', () => {
276371
const { getByTestId } = render(<MusdCalculatorTab />);
277372

@@ -282,7 +377,7 @@ describe('MusdCalculatorTab', () => {
282377

283378
expect(getByTestId('musd-slider-amount-display')).toHaveProp(
284379
'value',
285-
'$1,000',
380+
'1000',
286381
);
287382
});
288383
});

0 commit comments

Comments
 (0)