Skip to content

Commit 3fba94e

Browse files
authored
feat: add swap page trending tokens section (#26620)
## **Description** This PR implements the mobile Swap zero-state Trending Tokens experience for Bridge and hardens related Bridge rendering behavior. Key updates: - Added `BridgeTrendingTokensSection` to render Trending tokens only in Swap zero state. - Added filter controls (Sort by / Network / Time) and list chunking with a centered "Load more" action while preserving single-screen scroll behavior. - Refined `BridgeView` content-mode precedence so loading/error/quote/zero states render deterministically. - Preserved quote + confirm visibility during quote refresh (`isLoading && activeQuote`) and only show skeleton when loading without an active quote. - Updated/expanded Bridge tests and removed brittle snapshot dependency in `BridgeView` tests. ## **Changelog** CHANGELOG entry: Added Trending tokens to the mobile Swap zero state with filter controls and improved Bridge quote/loading state handling. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/SWAPS-4038 ## **Manual testing steps** ```gherkin Feature: Swap zero-state trending list on mobile Scenario: Trending list visibility follows zero state Given user is on the Swap screen When no source amount is entered Then Trending tokens are visible below the swap form When user enters a non-zero source amount Then Trending tokens are hidden Scenario: Numpad hidden on initial load Given user opens Swap for the first time When the screen is rendered Then numpad is hidden and swap form is visible Scenario: Quote loading and refresh behavior Given user has entered a non-zero amount When quote is loading with no active quote Then quote skeleton is shown and trending list is hidden When quote is refreshing with an active quote Then quote content and confirm button remain visible Scenario: Single scroll behavior Given user is in zero state with Trending tokens visible When user scrolls Then swap form and trending list scroll together in one vertical scroll area Scenario: Filters update results Given user is in zero state with Trending tokens visible When user changes Sort by, Network, or Time filters Then list content updates to match selected filters And default sort is Price change high to low ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** https://github.com/user-attachments/assets/e55f04c5-6190-4c26-a15a-2e0c00a8b879 ## **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** > Changes Bridge/Swap screen rendering precedence (loading/error/quote/zero) and scroll behavior, which could affect quote visibility and confirm UX during refreshes. Mostly UI/state-driven with good test coverage but touches a core transaction entry flow. > > **Overview** > Adds a **Swap zero-state Trending Tokens** section to `BridgeView`, gated behind the temporary `swapsTrendingTokens` remote feature flag, with filter bottom sheets and incremental “show more” loading triggered by button or near-bottom scroll. > > Refactors `BridgeView` to render deterministically via a `contentMode` state machine: shows a `QuoteDetailsCardSkeleton` only when *loading without an active quote*, preserves quote + confirm UI while refreshing (`isLoading && activeQuote`), and keeps error banners/zero-state separate from quote content. > > Updates styles to support a single unified scroll area (inputs + dynamic content), introduces new `testID`s, and rewrites/expands tests to avoid brittle snapshots and to assert the new loading/error/quote/zero behaviors (including mocking the trending section). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ab8ffe2. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent ec4fbf2 commit 3fba94e

20 files changed

Lines changed: 841 additions & 3903 deletions

app/components/UI/Bridge/Views/BridgeView/BridgeView.styles.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,12 @@ export const createStyles = (params: { theme: Theme }) => {
2323
backgroundColor: theme.colors.background.default,
2424
},
2525
quoteContainer: {
26-
flex: 1,
2726
justifyContent: 'flex-start',
2827
},
2928
destinationAccountSelectorContainer: {
3029
paddingBottom: 12,
3130
},
3231
dynamicContent: {
33-
flex: 1,
3432
justifyContent: 'flex-start',
3533
},
3634
keypadContainerWithDestinationPicker: {
@@ -42,6 +40,10 @@ export const createStyles = (params: { theme: Theme }) => {
4240
},
4341
scrollViewContent: {
4442
flexGrow: 1,
43+
paddingBottom: 16,
44+
},
45+
loadingContainer: {
46+
paddingTop: 8,
4547
},
4648
disclaimerText: {
4749
textAlign: 'center',

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

Lines changed: 224 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData';
2121
import { useRWAToken } from '../../hooks/useRWAToken';
2222
import { strings } from '../../../../../../locales/i18n';
2323
import { isHardwareAccount } from '../../../../../util/address';
24+
import { BridgeViewSelectorsIDs } from './BridgeView.testIds';
2425
import { MOCK_ENTROPY_SOURCE as mockEntropySource } from '../../../../../util/test/keyringControllerTestUtils';
2526
import { RootState } from '../../../../../reducers';
2627
import { mockQuoteWithMetadata } from '../../_mocks_/bridgeQuoteWithMetadata';
@@ -281,6 +282,25 @@ jest.mock('react-native-fade-in-image', () => {
281282
};
282283
});
283284

285+
jest.mock(
286+
'../../components/BridgeTrendingTokensSection/BridgeTrendingTokensSection',
287+
() => {
288+
const React = jest.requireActual('react');
289+
const { View } = jest.requireActual('react-native');
290+
const { BridgeViewSelectorsIDs: BridgeViewTestIds } = jest.requireActual(
291+
'./BridgeView.testIds',
292+
);
293+
294+
return {
295+
__esModule: true,
296+
default: () =>
297+
React.createElement(View, {
298+
testID: BridgeViewTestIds.TRENDING_TOKENS_SECTION,
299+
}),
300+
};
301+
},
302+
);
303+
284304
// Mock BottomSheetDialog so that onCloseDialog synchronously calls onClose,
285305
// allowing keypad close() to work in tests (the real component uses reanimated
286306
// withTiming which never completes in JSDOM).
@@ -331,16 +351,19 @@ describe('BridgeView', () => {
331351
jest.clearAllMocks();
332352
});
333353

334-
it('renders', async () => {
335-
const { toJSON } = renderScreen(
354+
it('renders source and destination token areas', async () => {
355+
const { getByTestId } = renderScreen(
336356
BridgeView,
337357
{
338358
name: Routes.BRIDGE.ROOT,
339359
},
340360
{ state: mockState },
341361
);
342362

343-
expect(toJSON()).toMatchSnapshot();
363+
expect(getByTestId(BridgeViewSelectorsIDs.SOURCE_TOKEN_AREA)).toBeTruthy();
364+
expect(
365+
getByTestId(BridgeViewSelectorsIDs.DESTINATION_TOKEN_AREA),
366+
).toBeTruthy();
344367
});
345368

346369
it('should open BridgeTokenSelector when clicking source token', async () => {
@@ -393,7 +416,12 @@ describe('BridgeView', () => {
393416
{ state: mockState },
394417
);
395418

396-
// Verify keypad is open (opened by useBridgeViewOnFocus on mount)
419+
const sourceInput = getByTestId('source-token-area-input');
420+
await act(async () => {
421+
sourceInput.props.onPressIn();
422+
});
423+
424+
// Verify keypad is open
397425
await waitFor(() => {
398426
expect(getByText('1')).toBeTruthy();
399427
expect(queryByTestId('keypad-delete-button')).toBeTruthy();
@@ -434,6 +462,11 @@ describe('BridgeView', () => {
434462
{ state: mockState },
435463
);
436464

465+
const sourceInput = getByTestId('source-token-area-input');
466+
await act(async () => {
467+
sourceInput.props.onPressIn();
468+
});
469+
437470
// Press number buttons to input
438471
fireEvent.press(getByText('9'));
439472
fireEvent.press(getByText('.'));
@@ -755,21 +788,19 @@ describe('BridgeView', () => {
755788
.mockImplementation(() => mockUseBridgeQuoteData);
756789
});
757790

758-
it('displays keypad when no amount is entered', () => {
759-
const { getByText } = renderScreen(
791+
it('does not display keypad on initial render when no amount is entered', () => {
792+
const { queryByTestId } = renderScreen(
760793
BridgeView,
761794
{
762795
name: Routes.BRIDGE.ROOT,
763796
},
764797
{ state: mockState },
765798
);
766799

767-
// Keypad is visible instead of "Select amount" text
768-
expect(getByText('1')).toBeTruthy();
769-
expect(getByText('5')).toBeTruthy();
800+
expect(queryByTestId('keypad-delete-button')).toBeNull();
770801
});
771802

772-
it('displays keypad when amount is zero', () => {
803+
it('does not display keypad on initial render when amount is zero', () => {
773804
const stateWithZeroAmount = {
774805
...mockState,
775806
bridge: {
@@ -778,20 +809,18 @@ describe('BridgeView', () => {
778809
},
779810
};
780811

781-
const { getByText } = renderScreen(
812+
const { queryByTestId } = renderScreen(
782813
BridgeView,
783814
{
784815
name: Routes.BRIDGE.ROOT,
785816
},
786817
{ state: stateWithZeroAmount },
787818
);
788819

789-
// Keypad is visible instead of "Select amount" text
790-
expect(getByText('1')).toBeTruthy();
791-
expect(getByText('5')).toBeTruthy();
820+
expect(queryByTestId('keypad-delete-button')).toBeNull();
792821
});
793822

794-
it('displays "Fetching quote" when quotes are loading and there is no active quote', () => {
823+
it('shows loading mode with quote skeleton only', () => {
795824
const testState = createBridgeTestState({
796825
bridgeControllerOverrides: {
797826
quotesLastFetched: null,
@@ -806,15 +835,190 @@ describe('BridgeView', () => {
806835
activeQuote: null,
807836
}));
808837

809-
const { getByText } = renderScreen(
838+
const { getByTestId, queryByTestId, queryByText } = renderScreen(
810839
BridgeView,
811840
{
812841
name: Routes.BRIDGE.ROOT,
813842
},
814843
{ state: testState },
815844
);
816845

817-
expect(getByText('Fetching quote')).toBeTruthy();
846+
expect(
847+
getByTestId(BridgeViewSelectorsIDs.QUOTE_DETAILS_SKELETON),
848+
).toBeTruthy();
849+
expect(queryByTestId('banneralert')).toBeNull();
850+
expect(queryByTestId('edit-slippage-button')).toBeNull();
851+
expect(
852+
queryByTestId(BridgeViewSelectorsIDs.TRENDING_TOKENS_SECTION),
853+
).toBeNull();
854+
expect(queryByText('Fetching quote')).toBeNull();
855+
});
856+
857+
it('keeps quote mode content visible while refreshing an existing quote', async () => {
858+
const now = Date.now();
859+
const testState = createBridgeTestState({
860+
bridgeControllerOverrides: {
861+
quotesLoadingStatus: RequestStatus.LOADING,
862+
quotes: [mockQuoteWithMetadata as unknown as QuoteResponse],
863+
quotesLastFetched: now,
864+
},
865+
bridgeReducerOverrides: {
866+
sourceAmount: '1.0',
867+
},
868+
});
869+
870+
jest
871+
.mocked(useBridgeQuoteData as unknown as jest.Mock)
872+
.mockImplementation(() => ({
873+
...mockUseBridgeQuoteData,
874+
isLoading: true,
875+
activeQuote: mockQuoteWithMetadata as unknown as QuoteResponse,
876+
}));
877+
878+
const { getByTestId, queryByTestId } = renderScreen(
879+
BridgeView,
880+
{
881+
name: Routes.BRIDGE.ROOT,
882+
},
883+
{ state: testState },
884+
);
885+
886+
await waitFor(() => {
887+
expect(queryByTestId('edit-slippage-button')).toBeTruthy();
888+
});
889+
890+
expect(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON)).toBeTruthy();
891+
expect(
892+
queryByTestId(BridgeViewSelectorsIDs.QUOTE_DETAILS_SKELETON),
893+
).toBeNull();
894+
});
895+
896+
it('shows error mode with banner and without quote or zero state', async () => {
897+
const testState = createBridgeTestState({
898+
bridgeControllerOverrides: {
899+
quotesLoadingStatus: RequestStatus.FETCHED,
900+
quotes: [],
901+
quotesLastFetched: 12,
902+
},
903+
});
904+
905+
jest
906+
.mocked(useBridgeQuoteData as unknown as jest.Mock)
907+
.mockImplementation(() => ({
908+
...mockUseBridgeQuoteData,
909+
activeQuote: null,
910+
isLoading: false,
911+
quoteFetchError: 'Error fetching quote',
912+
isNoQuotesAvailable: true,
913+
}));
914+
915+
const { queryByTestId } = renderScreen(
916+
BridgeView,
917+
{
918+
name: Routes.BRIDGE.ROOT,
919+
},
920+
{ state: testState },
921+
);
922+
923+
await waitFor(() => {
924+
expect(queryByTestId('banneralert')).toBeTruthy();
925+
});
926+
expect(queryByTestId('edit-slippage-button')).toBeNull();
927+
expect(
928+
queryByTestId(BridgeViewSelectorsIDs.TRENDING_TOKENS_SECTION),
929+
).toBeNull();
930+
});
931+
932+
it('shows quote mode with quote content and confirm button', async () => {
933+
const now = Date.now();
934+
const testState = createBridgeTestState({
935+
bridgeControllerOverrides: {
936+
quotesLoadingStatus: RequestStatus.FETCHED,
937+
quotes: [mockQuoteWithMetadata as unknown as QuoteResponse],
938+
quotesLastFetched: now,
939+
},
940+
bridgeReducerOverrides: {
941+
sourceAmount: '1.0',
942+
},
943+
});
944+
945+
jest
946+
.mocked(useBridgeQuoteData as unknown as jest.Mock)
947+
.mockImplementation(() => ({
948+
...mockUseBridgeQuoteData,
949+
isLoading: false,
950+
activeQuote: mockQuoteWithMetadata as unknown as QuoteResponse,
951+
}));
952+
953+
const { getByTestId, queryByTestId } = renderScreen(
954+
BridgeView,
955+
{
956+
name: Routes.BRIDGE.ROOT,
957+
},
958+
{ state: testState },
959+
);
960+
961+
await waitFor(() => {
962+
expect(queryByTestId('edit-slippage-button')).toBeTruthy();
963+
});
964+
expect(getByTestId(BridgeViewSelectorsIDs.CONFIRM_BUTTON)).toBeTruthy();
965+
expect(
966+
queryByTestId(BridgeViewSelectorsIDs.TRENDING_TOKENS_SECTION),
967+
).toBeNull();
968+
});
969+
970+
it('shows zero mode with trending section and without quote content', () => {
971+
const testState = createBridgeTestState(
972+
{
973+
bridgeControllerOverrides: {
974+
quotesLoadingStatus: RequestStatus.FETCHED,
975+
quotes: [],
976+
quotesLastFetched: 12,
977+
},
978+
bridgeReducerOverrides: {
979+
sourceAmount: undefined,
980+
},
981+
},
982+
{
983+
...mockState,
984+
engine: {
985+
...mockState.engine,
986+
backgroundState: {
987+
...mockState.engine?.backgroundState,
988+
RemoteFeatureFlagController: {
989+
remoteFeatureFlags: {
990+
swapsTrendingTokens: true,
991+
},
992+
cacheTimestamp: 0,
993+
},
994+
},
995+
},
996+
} as DeepPartial<RootState>,
997+
);
998+
999+
jest
1000+
.mocked(useBridgeQuoteData as unknown as jest.Mock)
1001+
.mockImplementation(() => ({
1002+
...mockUseBridgeQuoteData,
1003+
activeQuote: null,
1004+
isLoading: false,
1005+
quoteFetchError: null,
1006+
isNoQuotesAvailable: false,
1007+
destTokenAmount: undefined,
1008+
}));
1009+
1010+
const { getByTestId, queryByTestId } = renderScreen(
1011+
BridgeView,
1012+
{
1013+
name: Routes.BRIDGE.ROOT,
1014+
},
1015+
{ state: testState },
1016+
);
1017+
1018+
expect(
1019+
getByTestId(BridgeViewSelectorsIDs.TRENDING_TOKENS_SECTION),
1020+
).toBeTruthy();
1021+
expect(queryByTestId('edit-slippage-button')).toBeNull();
8181022
});
8191023

8201024
it('navigates to QuoteExpiredModal when quote expires without refresh', async () => {
@@ -932,7 +1136,7 @@ describe('BridgeView', () => {
9321136
});
9331137
});
9341138

935-
it('blurs input when opening QuoteExpiredModal', async () => {
1139+
it('navigates to QuoteExpiredModal when quote expires and leaves quote content hidden', async () => {
9361140
jest
9371141
.mocked(useBridgeQuoteData as unknown as jest.Mock)
9381142
.mockImplementation(() => ({
@@ -943,7 +1147,7 @@ describe('BridgeView', () => {
9431147
activeQuote: undefined, // activeQuote is undefined when quote expires without refresh
9441148
}));
9451149

946-
const { toJSON } = renderScreen(
1150+
const { queryByTestId } = renderScreen(
9471151
BridgeView,
9481152
{
9491153
name: Routes.BRIDGE.ROOT,
@@ -956,8 +1160,7 @@ describe('BridgeView', () => {
9561160
screen: Routes.BRIDGE.MODALS.QUOTE_EXPIRED_MODAL,
9571161
});
9581162
});
959-
960-
expect(toJSON()).toMatchSnapshot();
1163+
expect(queryByTestId('edit-slippage-button')).toBeNull();
9611164
});
9621165

9631166
it('displays hardware wallet not supported banner when using hardware wallet with Solana source', async () => {

app/components/UI/Bridge/Views/BridgeView/BridgeView.testIds.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ export const BridgeViewSelectorsIDs = {
66
CONFIRM_BUTTON: 'bridge-confirm-button',
77
CONFIRM_BUTTON_KEYPAD: 'bridge-confirm-button-keypad',
88
BRIDGE_VIEW_SCROLL: 'bridge-view-scroll',
9+
TRENDING_TOKENS_SECTION: 'bridge-trending-tokens-section',
10+
TRENDING_PRICE_FILTER: 'bridge-trending-price-filter',
11+
TRENDING_NETWORK_FILTER: 'bridge-trending-network-filter',
12+
TRENDING_TIME_FILTER: 'bridge-trending-time-filter',
13+
TRENDING_SHOW_MORE: 'bridge-trending-show-more',
14+
QUOTE_DETAILS_SKELETON: 'bridge-quote-details-skeleton',
915
} as const;
1016

1117
export type BridgeViewSelectorsIDsType = typeof BridgeViewSelectorsIDs;

0 commit comments

Comments
 (0)