Skip to content

Commit 896144b

Browse files
authored
refactor: move TokenIcon component to global scope (MetaMask#23373)
<!-- 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** Migrate TokenIcon component from legacy swaps dir to global scope as it's widely used acrossed the codebase. Changed JS file type to TS and added missing types. Fixed a type bug. Migrated tests from Enzyme to React Testing Library. <!-- 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: https://consensyssoftware.atlassian.net/browse/SWAPS-3477 ## **Manual testing steps** ``` This PR does not introduce any business logic changes. ``` ## **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] > Moves `TokenIcon` to `Base`, converts it to TypeScript with safer image-source logic, and updates all usages and tests accordingly. > > - **Core Component**: > - Migrate `UI/Swaps/components/TokenIcon` to `Base/TokenIcon` and convert to TypeScript (`index.tsx`). > - Add strong typings (`ThemeColors`, props interfaces) and refine image source resolution (handles `ETH`, `SOL`, keyed `image-icons`, URL; returns `undefined` instead of `null`). > - Adjust imports to local `Base` modules and direct `eth-logo-new.png` import. > - **Usage Updates**: > - Replace imports with `Base/TokenIcon` across Bridge (`TokenButton`, `TokenInsightsSheet`, `TokenSelectorItem`, `TransactionAsset`), Network logos (`NetworkAssetLogo`, `NetworkMainAssetLogo`), Swaps (`QuotesView`, `TokenImportModal`, `TokenSelectButton`, `TokenSelectModal`), and Confirmations (`Views/confirmations/.../token-icon`). > - **Tests**: > - Add new RTL tests for `Base/TokenIcon` (`index.test.tsx`), mocking images and theme. > - Update related tests to mock new path; remove old Enzyme test and snapshots for Swaps `TokenIcon`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 26026a7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 9fd5255 commit 896144b

18 files changed

Lines changed: 189 additions & 229 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import React from 'react';
2+
import { render } from '@testing-library/react-native';
3+
import TokenIcon from '.';
4+
5+
// Mock RemoteImage component
6+
jest.mock('../RemoteImage', () => ({
7+
__esModule: true,
8+
default: ({ testID }: { testID?: string }) => {
9+
const { View } = jest.requireActual('react-native');
10+
return <View testID={testID || 'remote-image'} />;
11+
},
12+
}));
13+
14+
// Mock useTheme hook
15+
jest.mock('../../../util/theme', () => ({
16+
useTheme: jest.fn(() => ({
17+
colors: {
18+
background: {
19+
default: '#FFFFFF',
20+
alternative: '#F2F4F6',
21+
},
22+
text: {
23+
default: '#24292E',
24+
},
25+
},
26+
})),
27+
}));
28+
29+
// Mock image imports
30+
jest.mock('../../../images/image-icons', () => ({
31+
SOLANA: { uri: 'solana.png' },
32+
MATIC: { uri: 'matic.png' },
33+
}));
34+
35+
jest.mock('../../../images/eth-logo-new.png', () => ({
36+
uri: 'eth-logo.png',
37+
}));
38+
39+
describe('TokenIcon', () => {
40+
beforeEach(() => {
41+
jest.clearAllMocks();
42+
});
43+
44+
describe('Rendering', () => {
45+
it('renders RemoteImage for ETH symbol', () => {
46+
const { getByTestId } = render(
47+
<TokenIcon symbol="ETH" testID="token-icon" />,
48+
);
49+
50+
expect(getByTestId('token-icon')).toBeOnTheScreen();
51+
});
52+
53+
it('renders RemoteImage for SOL symbol', () => {
54+
const { getByTestId } = render(
55+
<TokenIcon symbol="SOL" testID="token-icon" />,
56+
);
57+
58+
expect(getByTestId('token-icon')).toBeOnTheScreen();
59+
});
60+
61+
it('renders RemoteImage when icon URL is provided', () => {
62+
const { getByTestId } = render(
63+
<TokenIcon
64+
symbol="DAI"
65+
icon="https://example.com/dai.png"
66+
testID="token-icon"
67+
/>,
68+
);
69+
70+
expect(getByTestId('token-icon')).toBeOnTheScreen();
71+
});
72+
73+
it('renders first letter of symbol when no image source available', () => {
74+
const { getByText } = render(<TokenIcon symbol="DAI" />);
75+
76+
expect(getByText('D')).toBeOnTheScreen();
77+
});
78+
79+
it('renders empty icon when no symbol provided', () => {
80+
const { queryByText } = render(<TokenIcon />);
81+
82+
expect(queryByText(/[A-Z]/)).toBeNull();
83+
});
84+
});
85+
86+
describe('Size Variants', () => {
87+
it('renders with medium size', () => {
88+
const { getByTestId } = render(
89+
<TokenIcon symbol="ETH" medium testID="token-icon" />,
90+
);
91+
92+
expect(getByTestId('token-icon')).toBeOnTheScreen();
93+
});
94+
95+
it('renders with big size', () => {
96+
const { getByTestId } = render(
97+
<TokenIcon symbol="ETH" big testID="token-icon" />,
98+
);
99+
100+
expect(getByTestId('token-icon')).toBeOnTheScreen();
101+
});
102+
103+
it('renders with biggest size', () => {
104+
const { getByTestId } = render(
105+
<TokenIcon symbol="ETH" biggest testID="token-icon" />,
106+
);
107+
108+
expect(getByTestId('token-icon')).toBeOnTheScreen();
109+
});
110+
});
111+
112+
describe('Symbol Display', () => {
113+
it('displays uppercase first letter of symbol', () => {
114+
const { getByText } = render(<TokenIcon symbol="dai" />);
115+
116+
expect(getByText('D')).toBeOnTheScreen();
117+
});
118+
119+
it('displays single character symbol as uppercase', () => {
120+
const { getByText } = render(<TokenIcon symbol="X" />);
121+
122+
expect(getByText('X')).toBeOnTheScreen();
123+
});
124+
});
125+
});

app/components/UI/Swaps/components/TokenIcon.js renamed to app/components/Base/TokenIcon/index.tsx

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1-
import React, { useCallback, useState } from 'react';
1+
import React, { PropsWithChildren, useCallback, useState } from 'react';
22
import PropTypes from 'prop-types';
3-
import { StyleSheet, View } from 'react-native';
4-
5-
import RemoteImage from '../../../Base/RemoteImage';
6-
import Text from '../../../Base/Text';
7-
import { useTheme } from '../../../../util/theme';
8-
import imageIcons from '../../../../images/image-icons';
9-
10-
/* eslint-disable import/no-commonjs */
11-
const ethLogo = require('../../../../images/eth-logo-new.png');
12-
/* eslint-enable import/no-commonjs */
3+
import {
4+
ImageSourcePropType,
5+
StyleSheet,
6+
TextStyle,
7+
View,
8+
ViewStyle,
9+
} from 'react-native';
10+
11+
import RemoteImage from '../RemoteImage';
12+
import Text from '../Text';
13+
import { useTheme } from '../../../util/theme';
14+
import imageIcons from '../../../images/image-icons';
15+
import ethLogo from '../../../images/eth-logo-new.png';
16+
import { ThemeColors } from '@metamask/design-tokens';
1317

1418
const REGULAR_SIZE = 24;
1519
const REGULAR_RADIUS = 12;
@@ -20,7 +24,7 @@ const BIG_RADIUS = 25;
2024
const BIGGEST_SIZE = 70;
2125
const BIGGEST_RADIUS = 35;
2226

23-
const createStyles = (colors) =>
27+
const createStyles = (colors: ThemeColors) =>
2428
StyleSheet.create({
2529
icon: {
2630
width: REGULAR_SIZE,
@@ -61,9 +65,23 @@ const createStyles = (colors) =>
6165
fontSize: 26,
6266
color: colors.text.default,
6367
},
68+
tokenSymbolBiggest: {},
6469
});
6570

66-
const EmptyIcon = ({ medium, big, biggest, style, ...props }) => {
71+
interface EmptyIconProps {
72+
medium?: boolean;
73+
big?: boolean;
74+
biggest?: boolean;
75+
style?: ViewStyle;
76+
}
77+
78+
const EmptyIcon = ({
79+
medium,
80+
big,
81+
biggest,
82+
style,
83+
...props
84+
}: PropsWithChildren<EmptyIconProps>) => {
6785
const { colors } = useTheme();
6886
const styles = createStyles(colors);
6987

@@ -90,6 +108,13 @@ EmptyIcon.propTypes = {
90108
testID: PropTypes.string,
91109
};
92110

111+
interface TokenIconProps extends EmptyIconProps {
112+
symbol?: string;
113+
icon?: string;
114+
emptyIconTextStyle?: TextStyle;
115+
testID?: string;
116+
}
117+
93118
function TokenIcon({
94119
symbol,
95120
icon,
@@ -99,7 +124,7 @@ function TokenIcon({
99124
style,
100125
emptyIconTextStyle,
101126
testID,
102-
}) {
127+
}: TokenIconProps) {
103128
const [showFallback, setShowFallback] = useState(false);
104129
const { colors } = useTheme();
105130
const styles = createStyles(colors);
@@ -113,16 +138,21 @@ function TokenIcon({
113138
return imageIcons.SOLANA;
114139
}
115140

116-
if (Object.keys(imageIcons).includes(symbol)) {
117-
return imageIcons[symbol];
141+
if (symbol && Object.keys(imageIcons).includes(symbol)) {
142+
const imageIcon = imageIcons[symbol as keyof typeof imageIcons];
143+
// Skip SVG components (functions) and strings, only return valid image sources
144+
if (typeof imageIcon !== 'function' && typeof imageIcon !== 'string') {
145+
return imageIcon as ImageSourcePropType;
146+
}
118147
}
119148

120149
if (icon) {
121150
return { uri: icon };
122151
}
123152

124-
return null;
153+
return undefined;
125154
}, [symbol, icon]);
155+
126156
const source = getSource();
127157

128158
if (source && !showFallback) {

app/components/UI/Bridge/components/TokenButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import BadgeWrapper, {
1717
import Badge, {
1818
BadgeVariant,
1919
} from '../../../../component-library/components/Badges/Badge';
20-
import TokenIcon from '../../Swaps/components/TokenIcon';
20+
import TokenIcon from '../../../Base/TokenIcon';
2121

2222
interface TokenProps {
2323
symbol?: string;

app/components/UI/Bridge/components/TokenInsightsSheet/TokenInsightsSheet.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
AlignItems as BoxAlignItems,
2929
FlexDirection as BoxFlexDirection,
3030
} from '../../../Box/box.types';
31-
import TokenIcon from '../../../Swaps/components/TokenIcon';
31+
import TokenIcon from '../../../../Base/TokenIcon';
3232
import { BridgeToken } from '../../types';
3333
import i18n, { strings } from '../../../../../../locales/i18n';
3434
import ClipboardManager from '../../../../../core/ClipboardManager';

app/components/UI/Bridge/components/TokenSelectorItem.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import Text, {
1818
TextVariant,
1919
TextColor,
2020
} from '../../../../component-library/components/Texts/Text';
21-
import TokenIcon from '../../Swaps/components/TokenIcon';
21+
import TokenIcon from '../../../Base/TokenIcon';
2222
import { Box } from '../../Box/Box';
2323
import { AlignItems, FlexDirection } from '../../Box/box.types';
2424
import { useStyles } from '../../../../component-library/hooks';

app/components/UI/Bridge/components/TransactionDetails/TransactionAsset.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
NETWORK_TO_SHORT_NETWORK_NAME_MAP,
2525
} from '../../../../../constants/bridge';
2626
import { StyleSheet } from 'react-native';
27-
import TokenIcon from '../../../Swaps/components/TokenIcon';
27+
import TokenIcon from '../../../../Base/TokenIcon';
2828
import { BridgeToken } from '../../types';
2929
import { TransactionType } from '@metamask/transaction-controller';
3030
import { isNativeAddress } from '@metamask/bridge-controller';

app/components/UI/NetworkAssetLogo/index.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import React from 'react';
22
import { render } from '@testing-library/react-native';
33
import NetworkAssetLogo from '.';
4-
import TokenIcon from '../Swaps/components/TokenIcon';
4+
import TokenIcon from '../../Base/TokenIcon';
55
import { ChainId } from '@metamask/controller-utils';
66

77
// Mock the TokenIcon component
8-
jest.mock('../Swaps/components/TokenIcon', () => jest.fn(() => null));
8+
jest.mock('../../Base/TokenIcon', () => jest.fn(() => null));
99

1010
describe('NetworkAssetLogo Component', () => {
1111
it('matches the snapshot for non-mainnet', () => {

app/components/UI/NetworkAssetLogo/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
22
import { ChainId } from '@metamask/controller-utils';
3-
import TokenIcon from '../Swaps/components/TokenIcon';
3+
import TokenIcon from '../../Base/TokenIcon';
44

55
interface NetworkAssetLogoProps {
66
big: boolean;

app/components/UI/NetworkMainAssetLogo/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React from 'react';
22
import PropTypes from 'prop-types';
33
import { ChainId } from '@metamask/controller-utils';
44
import { connect } from 'react-redux';
5-
import TokenIcon from '../Swaps/components/TokenIcon';
5+
import TokenIcon from '../../Base/TokenIcon';
66
import {
77
selectChainId,
88
selectEvmTicker,

app/components/UI/NetworkMainAssetLogo/index.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import { render } from '@testing-library/react-native';
77
import NetworkMainAssetLogo from '.';
88
import { backgroundState } from '../../../util/test/initial-root-state';
99

10-
jest.mock('../Swaps/components/TokenIcon', () => {
11-
const originalModule = jest.requireActual('../Swaps/components/TokenIcon');
10+
jest.mock('../../Base/TokenIcon', () => {
11+
const originalModule = jest.requireActual('../../Base/TokenIcon');
1212
return {
1313
...originalModule,
1414
__esModule: true,

0 commit comments

Comments
 (0)