Skip to content

Commit 52d2ff9

Browse files
authored
fix: don't pin selected asset if it doesn't match search (#25395)
<!-- 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** Prevents pinning of selected asset when it doesn't match the search query <!-- 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: Fixed a bug where the currently selected swap asset would be pinned to the top of the asset picker list even when it didn't match the search query ## **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] > **Low Risk** > Low risk UI/filtering change limited to token list search/pinning logic, with added unit tests for the new matcher helper. > > **Overview** > Fixes Bridge token selector filtering so the currently selected token is **only pinned** to the top when it matches both the active chain filter and the current search query. > > Introduces a shared `tokenMatchesQuery` helper (name/symbol/address, case-insensitive) and uses it to filter balance tokens and gate selected-token pinning; adds unit tests covering empty queries, field matches, and edge cases like missing `name`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 14b9ae2. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 1d4bd68 commit 52d2ff9

3 files changed

Lines changed: 120 additions & 30 deletions

File tree

app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.tsx

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ import { useTokensWithBalances } from '../../hooks/useTokensWithBalances';
5454
import { useTokenSelection } from '../../hooks/useTokenSelection';
5555
import { createStyles } from './BridgeTokenSelector.styles';
5656
import Engine from '../../../../../core/Engine';
57-
import { tokenToIncludeAsset } from '../../utils/tokenUtils';
57+
import { tokenToIncludeAsset, tokenMatchesQuery } from '../../utils/tokenUtils';
5858

5959
export interface BridgeTokenSelectorRouteParams {
6060
type: TokenSelectorType;
@@ -161,35 +161,30 @@ export const BridgeTokenSelector: React.FC = () => {
161161
const { tokensWithBalance, balancesByAssetId } = useBalancesByAssetId({
162162
chainIds: chainIdsToFetch,
163163
});
164-
const filteredTokensWithBalance = useMemo(() => {
165-
const filteredTokens = tokensWithBalance.filter(
166-
(token) => token.balance && parseFloat(token.balance) > 0,
167-
);
168-
169-
if (!searchString.trim()) {
170-
return filteredTokens;
171-
}
172-
173-
const searchLower = searchString.toLowerCase();
174-
return filteredTokens.filter(
175-
(token) =>
176-
token.name?.toLowerCase().includes(searchLower) ||
177-
token.symbol.toLowerCase().includes(searchLower) ||
178-
token.address.toLowerCase().includes(searchLower),
179-
);
180-
}, [tokensWithBalance, searchString]);
164+
const searchQuery = searchString.trim();
165+
166+
const filteredTokensWithBalance = useMemo(
167+
() =>
168+
tokensWithBalance.filter(
169+
(token) =>
170+
token.balance &&
171+
parseFloat(token.balance) > 0 &&
172+
tokenMatchesQuery(token, searchQuery),
173+
),
174+
[tokensWithBalance, searchQuery],
175+
);
181176

182177
// Create includeAssets array from tokens with balance to be sent to API
183178
// Selected token is prepended to pin it to the top of the list
184179
// Stringified to avoid triggering the useEffect when only balances change
185180
const includeAssets = useMemo(() => {
186-
// Only include selected token if its network matches the current filter
187-
const isSelectedTokenOnFilteredNetwork =
181+
// Only include selected token if it matches current network and search filters
182+
const shouldPinSelectedToken =
188183
selectedToken &&
189-
chainIdsToFetch.includes(formatChainIdToCaip(selectedToken.chainId));
184+
chainIdsToFetch.includes(formatChainIdToCaip(selectedToken.chainId)) &&
185+
tokenMatchesQuery(selectedToken, searchQuery);
190186

191-
// Convert selected token first (will be pinned to top of list)
192-
const selectedAsset = isSelectedTokenOnFilteredNetwork
187+
const selectedAsset = shouldPinSelectedToken
193188
? tokenToIncludeAsset(selectedToken)
194189
: null;
195190

@@ -201,12 +196,10 @@ export const BridgeTokenSelector: React.FC = () => {
201196
asset !== null && asset.assetId !== selectedAsset?.assetId,
202197
);
203198

204-
const assets = selectedAsset
205-
? [selectedAsset, ...balanceAssets]
206-
: balanceAssets;
207-
208-
return JSON.stringify(assets);
209-
}, [filteredTokensWithBalance, selectedToken, chainIdsToFetch]);
199+
return JSON.stringify(
200+
selectedAsset ? [selectedAsset, ...balanceAssets] : balanceAssets,
201+
);
202+
}, [filteredTokensWithBalance, selectedToken, chainIdsToFetch, searchQuery]);
210203

211204
// Fetch popular tokens
212205
const { popularTokens, isLoading: isPopularTokensLoading } = usePopularTokens(

app/components/UI/Bridge/utils/tokenUtils.test.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { constants } from 'ethers';
2-
import { getNativeSourceToken, getDefaultDestToken } from './tokenUtils';
2+
import {
3+
getNativeSourceToken,
4+
getDefaultDestToken,
5+
tokenMatchesQuery,
6+
} from './tokenUtils';
7+
import { BridgeToken } from '../types';
38
import {
49
getNativeAssetForChainId,
510
isNonEvmChainId,
@@ -226,4 +231,79 @@ describe('tokenUtils', () => {
226231
expect(result?.chainId).not.toBe(originalToken.chainId);
227232
});
228233
});
234+
235+
describe('tokenMatchesQuery', () => {
236+
const createToken = (
237+
overrides: Partial<BridgeToken> = {},
238+
): BridgeToken => ({
239+
address: '0x1234567890abcdef',
240+
symbol: 'TEST',
241+
name: 'Test Token',
242+
decimals: 18,
243+
chainId: '0x1',
244+
...overrides,
245+
});
246+
247+
it('returns true when query is empty', () => {
248+
const token = createToken();
249+
250+
expect(tokenMatchesQuery(token, '')).toBe(true);
251+
});
252+
253+
it('matches token by name (case-insensitive)', () => {
254+
const token = createToken({ name: 'Ethereum' });
255+
256+
expect(tokenMatchesQuery(token, 'ethereum')).toBe(true);
257+
expect(tokenMatchesQuery(token, 'ETHEREUM')).toBe(true);
258+
expect(tokenMatchesQuery(token, 'Ether')).toBe(true);
259+
});
260+
261+
it('matches token by symbol (case-insensitive)', () => {
262+
const token = createToken({ symbol: 'ETH' });
263+
264+
expect(tokenMatchesQuery(token, 'eth')).toBe(true);
265+
expect(tokenMatchesQuery(token, 'ETH')).toBe(true);
266+
expect(tokenMatchesQuery(token, 'Et')).toBe(true);
267+
});
268+
269+
it('matches token by address (case-insensitive)', () => {
270+
const token = createToken({ address: '0xAbCdEf1234567890' });
271+
272+
expect(tokenMatchesQuery(token, '0xabcdef')).toBe(true);
273+
expect(tokenMatchesQuery(token, '0xABCDEF')).toBe(true);
274+
expect(tokenMatchesQuery(token, 'abcdef1234')).toBe(true);
275+
});
276+
277+
it('returns false when query does not match any field', () => {
278+
const token = createToken({
279+
name: 'Ethereum',
280+
symbol: 'ETH',
281+
address: '0x1234',
282+
});
283+
284+
expect(tokenMatchesQuery(token, 'bitcoin')).toBe(false);
285+
expect(tokenMatchesQuery(token, 'BTC')).toBe(false);
286+
expect(tokenMatchesQuery(token, '0x9999')).toBe(false);
287+
});
288+
289+
it('handles token with undefined name', () => {
290+
const token = createToken({ name: undefined });
291+
292+
// Query that would only match name returns false
293+
expect(tokenMatchesQuery(token, 'token')).toBe(false);
294+
// Query matching symbol still works
295+
expect(tokenMatchesQuery(token, token.symbol)).toBe(true);
296+
});
297+
298+
it('handles partial matches', () => {
299+
const token = createToken({
300+
name: 'Wrapped Bitcoin',
301+
symbol: 'WBTC',
302+
});
303+
304+
expect(tokenMatchesQuery(token, 'wrap')).toBe(true);
305+
expect(tokenMatchesQuery(token, 'bit')).toBe(true);
306+
expect(tokenMatchesQuery(token, 'btc')).toBe(true);
307+
});
308+
});
229309
});

app/components/UI/Bridge/utils/tokenUtils.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,23 @@ export const getDefaultDestToken = (
6262
return undefined;
6363
};
6464

65+
/**
66+
* Checks if a token matches a search query by name, symbol, or address.
67+
* Returns true if no query is provided.
68+
*/
69+
export const tokenMatchesQuery = (
70+
token: BridgeToken,
71+
query: string,
72+
): boolean => {
73+
if (!query) return true;
74+
const lowerQuery = query.toLowerCase();
75+
return (
76+
token.name?.toLowerCase().includes(lowerQuery) ||
77+
token.symbol.toLowerCase().includes(lowerQuery) ||
78+
token.address.toLowerCase().includes(lowerQuery)
79+
);
80+
};
81+
6582
/**
6683
* Converts a BridgeToken to IncludeAsset format for the API.
6784
* Returns null if the token cannot be converted (invalid assetId).

0 commit comments

Comments
 (0)