Skip to content

Commit 0fd7b49

Browse files
authored
feat(card): add card components (MetaMask#17508)
<!-- 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** <!-- 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? --> This PR introduces three new components and a util function for the card experience: - `CardImage`: renders a scalable SVG representation of the card, automatically adapting to different screen sizes. - `ManageCardListItem`: used in the Card Home UI to display selectable card management options. - `CardAssetItem`: displays a token and its balance, used to represent supported assets on the Card Home screen. - `buildTokenIconUrl`: utility function that generates token icon URLs using the TokenIcons API. ## **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: ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> <img width="402" height="864" alt="Card View" src="https://github.com/user-attachments/assets/c995181c-530f-4aa7-b7d0-3f7a55332ef9" /> ### **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.
1 parent b118f49 commit 0fd7b49

18 files changed

Lines changed: 8197 additions & 154 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { StyleSheet } from 'react-native';
2+
3+
const styleSheet = () =>
4+
StyleSheet.create({
5+
balances: {
6+
flex: 1,
7+
justifyContent: 'center',
8+
marginLeft: 20,
9+
},
10+
assetName: {
11+
flexDirection: 'row',
12+
},
13+
allowanceStatusContainer: {
14+
flexDirection: 'row',
15+
alignItems: 'center',
16+
alignContent: 'center',
17+
},
18+
ethLogo: {
19+
width: 32,
20+
height: 32,
21+
borderRadius: 16,
22+
overflow: 'hidden',
23+
},
24+
badge: {
25+
marginTop: 12,
26+
},
27+
});
28+
29+
export default styleSheet;
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import React from 'react';
2+
import { fireEvent, render } from '@testing-library/react-native';
3+
import CardAssetItem from './CardAssetItem';
4+
import { renderScreen } from '../../../../../util/test/renderWithProvider';
5+
import { backgroundState } from '../../../../../util/test/initial-root-state';
6+
import { TokenI } from '../../../Tokens/types';
7+
import { AllowanceState, CardTokenAllowance } from '../../types';
8+
import { ethers } from 'ethers';
9+
10+
// Mock dependencies
11+
jest.mock('../../hooks/useAssetBalance');
12+
jest.mock('../../../../../util/networks');
13+
jest.mock('../../../../../util/networks/customNetworks');
14+
jest.mock(
15+
'../../../Tokens/TokenList/TokenListItem/CustomNetworkNativeImgMapping',
16+
);
17+
jest.mock('../../../../Base/RemoteImage', () => 'RemoteImage');
18+
19+
import { useAssetBalance } from '../../hooks/useAssetBalance';
20+
import {
21+
isTestNet,
22+
getDefaultNetworkByChainId,
23+
getTestNetImageByChainId,
24+
} from '../../../../../util/networks';
25+
26+
const mockUseAssetBalance = useAssetBalance as jest.MockedFunction<
27+
typeof useAssetBalance
28+
>;
29+
const mockIsTestNet = isTestNet as jest.MockedFunction<typeof isTestNet>;
30+
const mockGetDefaultNetworkByChainId =
31+
getDefaultNetworkByChainId as jest.MockedFunction<
32+
typeof getDefaultNetworkByChainId
33+
>;
34+
const mockGetTestNetImageByChainId =
35+
getTestNetImageByChainId as jest.MockedFunction<
36+
typeof getTestNetImageByChainId
37+
>;
38+
39+
function renderWithProvider(
40+
component: React.ComponentType | (() => React.ReactElement | null),
41+
) {
42+
return renderScreen(
43+
component,
44+
{
45+
name: 'CardAssetItem',
46+
},
47+
{
48+
state: {
49+
engine: {
50+
backgroundState,
51+
},
52+
},
53+
},
54+
);
55+
}
56+
57+
describe('CardAssetItem Component', () => {
58+
const mockOnPress = jest.fn();
59+
60+
const mockAsset: TokenI = {
61+
name: 'Ethereum',
62+
symbol: 'ETH',
63+
address: '0x0000000000000000000000000000000000000000',
64+
decimals: 18,
65+
image: 'https://example.com/eth.png',
66+
isNative: true,
67+
ticker: 'ETH',
68+
aggregators: [],
69+
balance: '1000000000000000000',
70+
logo: undefined,
71+
isETH: true,
72+
};
73+
74+
const mockAssetKey: CardTokenAllowance = {
75+
chainId: '0x1',
76+
address: '0x0000000000000000000000000000000000000000',
77+
isStaked: false,
78+
allowanceState: AllowanceState.NotActivated,
79+
allowance: ethers.BigNumber.from('1000000000000000000'),
80+
decimals: 18,
81+
symbol: 'ETH',
82+
name: 'Ethereum',
83+
};
84+
85+
const mockAssetBalance = {
86+
asset: mockAsset,
87+
mainBalance: '1.5 ETH',
88+
secondaryBalance: '$3,000.00',
89+
balanceFiat: '$3,000.00',
90+
};
91+
92+
beforeEach(() => {
93+
jest.clearAllMocks();
94+
mockUseAssetBalance.mockReturnValue(mockAssetBalance);
95+
mockIsTestNet.mockReturnValue(false);
96+
mockGetDefaultNetworkByChainId.mockReturnValue(undefined);
97+
});
98+
99+
it('renders with required props and matches snapshot', () => {
100+
const { toJSON } = renderWithProvider(() => (
101+
<CardAssetItem assetKey={mockAssetKey} privacyMode={false} />
102+
));
103+
104+
expect(toJSON()).toMatchSnapshot();
105+
});
106+
107+
it('renders with all props and matches snapshot', () => {
108+
const { toJSON } = renderWithProvider(() => (
109+
<CardAssetItem
110+
assetKey={mockAssetKey}
111+
privacyMode={false}
112+
disabled
113+
onPress={mockOnPress}
114+
/>
115+
));
116+
117+
expect(toJSON()).toMatchSnapshot();
118+
});
119+
120+
it('renders with privacy mode enabled and matches snapshot', () => {
121+
const { toJSON } = renderWithProvider(() => (
122+
<CardAssetItem
123+
assetKey={mockAssetKey}
124+
privacyMode
125+
onPress={mockOnPress}
126+
/>
127+
));
128+
129+
expect(toJSON()).toMatchSnapshot();
130+
});
131+
132+
it('renders non-native token and matches snapshot', () => {
133+
const nonNativeAsset = {
134+
...mockAsset,
135+
name: 'USD Coin',
136+
symbol: 'USDC',
137+
isNative: false,
138+
address: '0xa0b86a33e6c8e2c3c5b5f7ae5f7c5b5f7ae5f7c5b5f',
139+
};
140+
mockUseAssetBalance.mockReturnValue({
141+
...mockAssetBalance,
142+
asset: nonNativeAsset,
143+
});
144+
145+
const { toJSON } = renderWithProvider(() => (
146+
<CardAssetItem
147+
assetKey={{
148+
...mockAssetKey,
149+
address: '0xa0b86a33e6c8e2c3c5b5f7ae5f7c5b5f7ae5f7c5b5f',
150+
}}
151+
privacyMode={false}
152+
/>
153+
));
154+
155+
expect(toJSON()).toMatchSnapshot();
156+
});
157+
158+
it('calls onPress when pressed', () => {
159+
const { getByTestId } = renderWithProvider(() => (
160+
<CardAssetItem
161+
assetKey={mockAssetKey}
162+
privacyMode={false}
163+
onPress={mockOnPress}
164+
/>
165+
));
166+
167+
const assetElement = getByTestId('asset-ETH');
168+
fireEvent.press(assetElement);
169+
170+
expect(mockOnPress).toHaveBeenCalledTimes(1);
171+
expect(mockOnPress).toHaveBeenCalledWith(mockAsset);
172+
});
173+
174+
it('returns null when chainId is missing', () => {
175+
const assetKeyWithoutChainId = {
176+
...mockAssetKey,
177+
chainId: undefined as string | undefined,
178+
};
179+
180+
const { toJSON } = render(
181+
<CardAssetItem assetKey={assetKeyWithoutChainId} privacyMode={false} />,
182+
);
183+
184+
expect(toJSON()).toBeNull();
185+
});
186+
187+
it('returns null when asset is undefined', () => {
188+
mockUseAssetBalance.mockReturnValue({
189+
...mockAssetBalance,
190+
asset: undefined,
191+
});
192+
193+
const { toJSON } = render(
194+
<CardAssetItem assetKey={mockAssetKey} privacyMode={false} />,
195+
);
196+
197+
expect(toJSON()).toBeNull();
198+
});
199+
200+
it('renders with disabled state', () => {
201+
const { toJSON } = renderWithProvider(() => (
202+
<CardAssetItem assetKey={mockAssetKey} privacyMode={false} disabled />
203+
));
204+
205+
expect(toJSON()).toMatchSnapshot();
206+
});
207+
208+
it('handles test network correctly', () => {
209+
mockIsTestNet.mockReturnValue(true);
210+
mockGetTestNetImageByChainId.mockReturnValue({
211+
uri: 'https://example.com/testnet.png',
212+
});
213+
214+
const { toJSON } = renderWithProvider(() => (
215+
<CardAssetItem assetKey={mockAssetKey} privacyMode={false} />
216+
));
217+
218+
expect(toJSON()).toMatchSnapshot();
219+
});
220+
221+
it('displays asset name when available', () => {
222+
const { getByText } = renderWithProvider(() => (
223+
<CardAssetItem assetKey={mockAssetKey} privacyMode={false} />
224+
));
225+
226+
expect(getByText('Ethereum')).toBeTruthy();
227+
});
228+
229+
it('displays asset symbol when name is not available', () => {
230+
const assetWithoutName = {
231+
...mockAsset,
232+
name: '',
233+
};
234+
mockUseAssetBalance.mockReturnValue({
235+
...mockAssetBalance,
236+
asset: assetWithoutName,
237+
});
238+
239+
const { getByText } = renderWithProvider(() => (
240+
<CardAssetItem assetKey={mockAssetKey} privacyMode={false} />
241+
));
242+
243+
expect(getByText('ETH')).toBeTruthy();
244+
});
245+
});

0 commit comments

Comments
 (0)