Skip to content

Commit 286b012

Browse files
fix: reduce min/max amount validation flashing in build quote (#29360)
## **Description** This PR fixes `TRAM-3472` on the Unified Buy V2 `BuildQuote` screen. While a user is typing an amount, min/max provider-limit validation was surfacing immediately on each intermediate value. That made the amount input and inline error flash red while the user was still entering a final valid number. 1. **What is the reason for the change?** The visible limit error was tied directly to the live typed amount, so transient values like `1` and `10` could momentarily render a min error before the user finished typing `100`. 2. **What is the improvement/solution?** Debounce the user-visible min/max error before rendering it, while keeping the underlying validation immediate so invalid amounts still block quote fetching and disable Continue right away. ## **Changelog** CHANGELOG entry: Fixed excessive red flashing while entering buy amounts near minimum and maximum provider limits. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TRAM-3472 ## **Manual testing steps** ```gherkin Feature: Buy amount limit validation does not flash while typing Scenario: User types through transient below-min values toward a valid amount Given the user is on the Build Quote screen And the selected provider has a minimum purchase amount When the user types an amount quickly toward a valid final value Then the minimum purchase error should not flash red for the intermediate values And the amount should settle without showing the error if the final value is valid Scenario: User pauses on an invalid amount Given the user is on the Build Quote screen And the selected provider has a minimum or maximum purchase amount When the user stops typing on an out-of-bounds amount Then the corresponding min or max error should appear after the debounce delay Scenario: User corrects an invalid amount back into range Given the user can see a min or max purchase error on the Build Quote screen When the user updates the amount so it is within the provider limits Then the visible limit error should clear immediately And quote fetching should resume for the valid amount ``` ## **Screenshots/Recordings** ### **Before** See ticket recording in `TRAM-3472`. ### **After** https://www.loom.com/share/acff17a2f05c4b1ab576d9664821fda5 <!-- Add updated recording if needed. --> ## **Validation** - `./node_modules/.bin/jest --runInBand --coverage --collectCoverageFrom=app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx` - `./node_modules/.bin/jest --runInBand app/components/UI/Ramp/hooks/useProviderLimits.test.ts` - `./node_modules/.bin/jest --runInBand app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx` - `./node_modules/.bin/eslint app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx` - `./node_modules/.bin/prettier --check app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx` - Coverage on `BuildQuote.tsx`: 97.29% statements, 88.33% branches, 97.05% functions, 98.87% lines. - `yarn lint:tsc` currently reports existing repo-wide type errors outside this diff; no errors reference `BuildQuote.tsx` or `BuildQuote.test.tsx`. ## **Pre-merge author checklist** - [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) - [ ] I've tested on Android - [ ] I've tested with a power user scenario - [ ] I've instrumented key operations with Sentry traces for production performance metrics ## **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] > **Low Risk** > Low risk UI/UX change: only delays rendering of min/max limit error text while keeping the underlying limit validation immediate for quote fetching and the Continue button. > > **Overview** > Reduces *min/max provider limit error flashing* on the `BuildQuote` amount input by debouncing the **user-visible** `amountLimitError` while still using the immediate error to block quote fetching and disable Continue. > > Updates tests to mock `useDebouncedValue` more generically and adds coverage ensuring the limit error text is delayed, clears immediately once valid, and quote-fetch gating remains immediate. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ae546a5. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 6b4a28e commit 286b012

2 files changed

Lines changed: 79 additions & 4 deletions

File tree

app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ jest.mock('../../../../hooks/useFormatters', () => ({
152152
}));
153153

154154
jest.mock('../../../../hooks/useDebouncedValue', () => ({
155-
useDebouncedValue: jest.fn((value: number) => value),
155+
useDebouncedValue: jest.fn((value: unknown) => value),
156156
}));
157157

158158
jest.mock('../../hooks/useBlinkingCursor', () => ({
@@ -358,7 +358,7 @@ describe('BuildQuote', () => {
358358
jest.clearAllMocks();
359359
mockUseParams.mockReturnValue({});
360360
mockUseRampsController.mockReturnValue(buildRampsControllerResult());
361-
mockUseDebouncedValue.mockImplementation((value: number) => value);
361+
mockUseDebouncedValue.mockImplementation((value: unknown) => value);
362362
mockUseRampsQuotes.mockImplementation((options) =>
363363
defaultQuotesHookResult(options),
364364
);
@@ -654,6 +654,75 @@ describe('BuildQuote', () => {
654654
).toBe(true);
655655
});
656656

657+
it('debounces the visible limit error while keeping quote validation immediate', () => {
658+
const providerWithLimits = buildProviderWithLimits({
659+
minAmount: 120,
660+
maxAmount: 1000,
661+
});
662+
let debouncedLimitError: string | null = null;
663+
mockUseDebouncedValue.mockImplementation((value: unknown) =>
664+
typeof value === 'number' ? value : debouncedLimitError,
665+
);
666+
mockUseRampsController.mockReturnValue(
667+
buildRampsControllerResult({
668+
providers: [providerWithLimits, NATIVE_PROVIDER],
669+
selectedProvider: providerWithLimits,
670+
}),
671+
);
672+
673+
const { queryByText, getByTestId, rerender } = renderWithProvider(
674+
<BuildQuote />,
675+
{
676+
state: initialRootState,
677+
},
678+
);
679+
680+
expect(queryByText('Minimum purchase is $120.00')).not.toBeOnTheScreen();
681+
expect(mockUseRampsQuotes).toHaveBeenLastCalledWith(null);
682+
683+
const continueButton = getByTestId(BuildQuoteSelectors.CONTINUE_BUTTON);
684+
expect(
685+
continueButton.props.isDisabled ??
686+
continueButton.props.disabled ??
687+
continueButton.props.accessibilityState?.disabled,
688+
).toBe(true);
689+
690+
debouncedLimitError = 'Minimum purchase is $120.00';
691+
rerender(<BuildQuote />);
692+
693+
expect(queryByText('Minimum purchase is $120.00')).toBeOnTheScreen();
694+
});
695+
696+
it('clears the visible limit error immediately once the amount is no longer invalid', () => {
697+
const providerWithLimits = buildProviderWithLimits({
698+
minAmount: 10,
699+
maxAmount: 80,
700+
});
701+
const debouncedLimitError: string | null = 'Maximum purchase is $80.00';
702+
mockUseDebouncedValue.mockImplementation((value: unknown) =>
703+
typeof value === 'number' ? value : debouncedLimitError,
704+
);
705+
mockUseRampsController.mockReturnValue(
706+
buildRampsControllerResult({
707+
providers: [providerWithLimits, NATIVE_PROVIDER],
708+
selectedProvider: providerWithLimits,
709+
}),
710+
);
711+
712+
const { getByText, queryByText, getByTestId } = renderWithProvider(
713+
<BuildQuote />,
714+
{
715+
state: initialRootState,
716+
},
717+
);
718+
719+
expect(getByText('Maximum purchase is $80.00')).toBeOnTheScreen();
720+
721+
fireEvent.press(getByTestId('keypad-trigger-empty'));
722+
723+
expect(queryByText('Maximum purchase is $80.00')).not.toBeOnTheScreen();
724+
});
725+
657726
it('fetches quotes normally when the amount is within provider limits', () => {
658727
const providerWithLimits = buildProviderWithLimits({
659728
minAmount: 50,
@@ -687,7 +756,9 @@ describe('BuildQuote', () => {
687756
maxAmount: 200,
688757
});
689758
let debouncedAmount = 100;
690-
mockUseDebouncedValue.mockImplementation(() => debouncedAmount);
759+
mockUseDebouncedValue.mockImplementation((value: unknown) =>
760+
typeof value === 'number' ? debouncedAmount : value,
761+
);
691762
mockUseRampsController.mockReturnValue(
692763
buildRampsControllerResult({
693764
providers: [providerWithLimits, NATIVE_PROVIDER],

app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,9 @@ function BuildQuote() {
396396
amount: amountAsNumber,
397397
currency,
398398
});
399+
const debouncedAmountLimitError = useDebouncedValue(amountLimitError);
400+
const displayedAmountLimitError =
401+
amountLimitError === debouncedAmountLimitError ? amountLimitError : null;
399402
const quoteFetchEnabled = !!(
400403
walletAddress &&
401404
selectedPaymentMethod &&
@@ -663,7 +666,8 @@ function BuildQuote() {
663666
return firstError?.error;
664667
}, [hasNoQuotes, quotesResponse?.error]);
665668

666-
const inlineQuoteError = amountLimitError ?? providerQuoteError ?? null;
669+
const inlineQuoteError =
670+
displayedAmountLimitError ?? providerQuoteError ?? null;
667671
const hasGenericNoQuotes = hasNoQuotes && !providerQuoteError;
668672
const amountInputHasError = Boolean(
669673
rampsError || quoteFetchError || inlineQuoteError || hasGenericNoQuotes,

0 commit comments

Comments
 (0)