Skip to content

Commit 010ee1b

Browse files
authored
feat: Swaps quotes selector (#26640)
<!-- 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 select quotes functionality in swaps. <!-- 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: add select quotes functionality ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/SWAPS-3642, https://consensyssoftware.atlassian.net/browse/SWAPS-4149, https://consensyssoftware.atlassian.net/browse/SWAPS-4203 ## **Manual testing steps** ```gherkin Ensure AC pass ``` ## **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** - [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. ## **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] > **Medium Risk** > Touches quote-selection and fee-formatting logic and introduces a new navigation flow, which could affect which quote is executed and what costs are displayed if edge cases (expired/refreshing quotes, missing requestId) are mishandled. > > **Overview** > Adds a new **Quote Selector** screen that lists sorted swap/bridge quotes by estimated *total cost* and lets users pick a specific quote; the `rate` row in `QuoteDetailsCard` now navigates to this selector via new `rate-info-button`/`rate-arrow-button` actions. > > Introduces `selectedQuoteRequestId` to the bridge Redux slice and threads it through `selectBridgeQuotes` and `useBridgeQuoteData` so a manually chosen quote becomes the `activeQuote` (with auto-reset when the selection is no longer valid/available). > > Refactors fiat/amount display by adding `useDisplayCurrencyValue` and switching `TokenInputArea` to use it (and the renamed `useFormattedBalanceWithThreshold`), and updates `formatNetworkFee` to correctly handle gasless quotes via `includedTxFees` with a `gasFee.effective` fallback. Includes new route wiring (`Routes.BRIDGE.QUOTE_SELECTOR_VIEW`), i18n strings, and extensive new/updated unit tests covering the selector UI, analytics (`useTrackAllQuotesSortedEvent`), and various BridgeView behaviors (Blockaid banner, approval disclaimer, input constraints, sponsored-network handling). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 701dd24. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent d7de0da commit 010ee1b

29 files changed

Lines changed: 4342 additions & 395 deletions

File tree

app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx

Lines changed: 484 additions & 1 deletion
Large diffs are not rendered by default.

app/components/UI/Bridge/_mocks_/bridgeReducerState.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,5 @@ export const mockBridgeReducerState: BridgeState = {
3737
isDestTokenManuallySet: false,
3838
tokenSelectorNetworkFilter: undefined,
3939
visiblePillChainIds: undefined,
40+
selectedQuoteRequestId: undefined,
4041
};

app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx

Lines changed: 86 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -596,27 +596,32 @@ describe('QuoteDetailsCard', () => {
596596
});
597597
});
598598

599-
it('handles quote info navigation', () => {
600-
const { getByLabelText, getByText, getByTestId } = renderScreen(
599+
it('navigates to quote selector when rate info button is pressed', () => {
600+
const { getByTestId } = renderScreen(
601601
QuoteDetailsCardTestScreen,
602602
{ name: Routes.BRIDGE.ROOT },
603603
{ state: testState },
604604
);
605605

606-
const quoteTooltip = getByLabelText('Rate tooltip');
607-
fireEvent.press(quoteTooltip);
606+
fireEvent.press(getByTestId('rate-info-button'));
608607

609-
expect(mockNavigate).toHaveBeenCalledWith('RootModalFlow', {
610-
params: {
611-
title: strings('bridge.quote_info_title'),
612-
tooltip: strings('bridge.quote_info_content'),
613-
footerText: undefined,
614-
buttonText: undefined,
615-
},
616-
screen: 'tooltipModal',
617-
});
618-
expect(getByText('Price impact')).toBeTruthy();
619-
expect(getByTestId('price-impact-info-button')).toBeTruthy();
608+
expect(mockNavigate).toHaveBeenCalledWith(
609+
Routes.BRIDGE.QUOTE_SELECTOR_VIEW,
610+
);
611+
});
612+
613+
it('navigates to quote selector when rate arrow button is pressed', () => {
614+
const { getByTestId } = renderScreen(
615+
QuoteDetailsCardTestScreen,
616+
{ name: Routes.BRIDGE.ROOT },
617+
{ state: testState },
618+
);
619+
620+
fireEvent.press(getByTestId('rate-arrow-button'));
621+
622+
expect(mockNavigate).toHaveBeenCalledWith(
623+
Routes.BRIDGE.QUOTE_SELECTOR_VIEW,
624+
);
620625
});
621626

622627
it('renders price impact info button for low price impact values', () => {
@@ -688,6 +693,72 @@ describe('QuoteDetailsCard', () => {
688693
expect(getByTestId('price-impact-info-button')).toBeTruthy();
689694
});
690695

696+
describe('minimum received row', () => {
697+
it('displays minimum received row when minToTokenAmount is present', () => {
698+
const mockModule = jest.requireMock('../../hooks/useBridgeQuoteData');
699+
mockModule.useBridgeQuoteData.mockImplementationOnce(() => ({
700+
quoteFetchError: null,
701+
activeQuote: {
702+
...mockQuotes[0],
703+
minToTokenAmount: {
704+
amount: '23.50',
705+
usd: null,
706+
valueInCurrency: null,
707+
},
708+
},
709+
destTokenAmount: '24.44',
710+
isLoading: false,
711+
formattedQuoteData: {
712+
networkFee: '0.01',
713+
estimatedTime: '1 min',
714+
rate: '1 ETH = 24.4 USDC',
715+
priceImpact: '-0.06%',
716+
slippage: '0.5%',
717+
},
718+
shouldShowPriceImpactWarning: false,
719+
}));
720+
721+
const { getByText } = renderScreen(
722+
QuoteDetailsCardTestScreen,
723+
{ name: Routes.BRIDGE.ROOT },
724+
{ state: testState },
725+
);
726+
727+
expect(getByText(strings('bridge.minimum_received'))).toBeOnTheScreen();
728+
// formatMinimumReceived formats "23.50" followed by the dest token symbol "ETH"
729+
expect(getByText(/23\.5 ETH/)).toBeOnTheScreen();
730+
});
731+
732+
it('does not display minimum received row when minToTokenAmount is absent', () => {
733+
const mockModule = jest.requireMock('../../hooks/useBridgeQuoteData');
734+
mockModule.useBridgeQuoteData.mockImplementationOnce(() => ({
735+
quoteFetchError: null,
736+
activeQuote: {
737+
...mockQuotes[0],
738+
minToTokenAmount: undefined,
739+
},
740+
destTokenAmount: '24.44',
741+
isLoading: false,
742+
formattedQuoteData: {
743+
networkFee: '0.01',
744+
estimatedTime: '1 min',
745+
rate: '1 ETH = 24.4 USDC',
746+
priceImpact: '-0.06%',
747+
slippage: '0.5%',
748+
},
749+
shouldShowPriceImpactWarning: false,
750+
}));
751+
752+
const { queryByText } = renderScreen(
753+
QuoteDetailsCardTestScreen,
754+
{ name: Routes.BRIDGE.ROOT },
755+
{ state: testState },
756+
);
757+
758+
expect(queryByText(strings('bridge.minimum_received'))).toBeNull();
759+
});
760+
});
761+
691762
describe('rewards functionality', () => {
692763
const { useRewards } = jest.requireMock('../../hooks/useRewards');
693764

app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx

Lines changed: 47 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useMemo } from 'react';
2-
import { TouchableOpacity, Platform, UIManager } from 'react-native';
2+
import { TouchableOpacity, Platform, UIManager, Pressable } from 'react-native';
33
import { useNavigation } from '@react-navigation/native';
44
import { strings } from '../../../../../../locales/i18n';
55
import { useTheme } from '../../../../../util/theme';
@@ -43,6 +43,7 @@ import TagColored, {
4343
import { useShouldRenderGasSponsoredBanner } from '../../hooks/useShouldRenderGasSponsoredBanner';
4444
import { isGaslessQuote } from '../../utils/isGaslessQuote';
4545
import { QuoteDetailsCardProps } from './QuoteDetailsCard.types';
46+
import { useTailwind } from '@metamask/design-system-twrnc-preset';
4647
import { getPriceImpactViewData } from '../../utils/getPriceImpactViewData';
4748
import {
4849
TextVariant as TextVariantLegacy,
@@ -63,6 +64,7 @@ const QuoteDetailsCard: React.FC<QuoteDetailsCardProps> = ({
6364
hasInsufficientBalance,
6465
location,
6566
}) => {
67+
const tw = useTailwind();
6668
const theme = useTheme();
6769
const navigation = useNavigation();
6870
const styles = createStyles(theme);
@@ -109,6 +111,10 @@ const QuoteDetailsCard: React.FC<QuoteDetailsCardProps> = ({
109111
});
110112
};
111113

114+
const handleRatePress = () => {
115+
navigation.navigate(Routes.BRIDGE.QUOTE_SELECTOR_VIEW);
116+
};
117+
112118
const handlePriceImpactPress = () => {
113119
navigation.navigate(Routes.BRIDGE.MODALS.ROOT, {
114120
screen: Routes.BRIDGE.MODALS.PRICE_IMPACT_MODAL,
@@ -131,6 +137,7 @@ const QuoteDetailsCard: React.FC<QuoteDetailsCardProps> = ({
131137
[formattedQuoteData?.priceImpact],
132138
);
133139

140+
// Early return for invalid states
134141
if (
135142
!sourceToken?.chainId ||
136143
!destToken?.chainId ||
@@ -143,44 +150,45 @@ const QuoteDetailsCard: React.FC<QuoteDetailsCardProps> = ({
143150
return (
144151
<Box>
145152
<Box style={styles.container}>
146-
<KeyValueRow
147-
field={{
148-
label: (
149-
<Box
150-
flexDirection={BoxFlexDirection.Row}
151-
alignItems={BoxAlignItems.Center}
152-
gap={1}
153-
>
154-
<Text
155-
variant={TextVariant.BodyMd}
156-
color={TextColor.TextAlternative}
157-
>
158-
{strings('bridge.rate')}
159-
</Text>
160-
<QuoteCountdownTimer />
161-
</Box>
162-
),
163-
tooltip: {
164-
title: strings('bridge.quote_info_title'),
165-
content: strings('bridge.quote_info_content'),
166-
size: TooltipSizes.Sm,
167-
iconName: IconNameLegacy.Info,
168-
},
169-
}}
170-
value={{
171-
label: (
172-
<Text
173-
variant={TextVariant.BodyMd}
174-
color={TextColor.TextAlternative}
175-
numberOfLines={1}
176-
adjustsFontSizeToFit
177-
minimumFontScale={0.8}
178-
>
179-
{formattedQuoteData.rate}
180-
</Text>
181-
),
182-
}}
183-
/>
153+
<Box
154+
flexDirection={BoxFlexDirection.Row}
155+
alignItems={BoxAlignItems.Center}
156+
gap={1}
157+
>
158+
<Text variant={TextVariant.BodyMd} color={TextColor.TextAlternative}>
159+
{strings('bridge.rate')}
160+
</Text>
161+
<QuoteCountdownTimer />
162+
<TouchableOpacity onPress={handleRatePress} testID="rate-info-button">
163+
<Icon
164+
name={IconName.Info}
165+
size={IconSize.Sm}
166+
color={IconColor.IconAlternative}
167+
/>
168+
</TouchableOpacity>
169+
<Box twClassName="flex-1 min-w-0">
170+
<Text
171+
variant={TextVariant.BodyMd}
172+
color={TextColor.TextAlternative}
173+
style={tw`text-right`}
174+
numberOfLines={1}
175+
ellipsizeMode="tail"
176+
>
177+
{formattedQuoteData?.rate}
178+
</Text>
179+
</Box>
180+
<Pressable
181+
style={tw`shrink-0`}
182+
onPress={handleRatePress}
183+
testID="rate-arrow-button"
184+
>
185+
<Icon
186+
name={IconName.ArrowRight}
187+
size={IconSize.Sm}
188+
color={IconColor.IconAlternative}
189+
/>
190+
</Pressable>
191+
</Box>
184192
{shouldShowGasSponsored ? (
185193
<KeyValueRow
186194
field={{

0 commit comments

Comments
 (0)