Skip to content

Commit 91c23b3

Browse files
feat: implement BalanceEmptyState component (#21391)
## **Description** This PR implements the `BalanceEmptyState` component on the homepage when users have a zero balance across all mainnet networks. It is hidden behind the `homepageRedesignV1Key` feature flag which will be used for full page scroll of the homepage. - Shows `BalanceEmptyState` component for accounts with an aggregated balance of zero across mainnets(excludes testnets) - Applies to only `AccountGroupBalance` component. - Replaces the regular balance display with an empty state component when total aggregated balance is zero - Replaces previous `TokenListFooter`(Removed in this PR: #21490) - Hidden behind the `homepageRedesignV1Key` feature flag - Adds new `selectWalletBalanceForEmptyState` to get balance across all networks - Adds e2e tests ## **Changelog** CHANGELOG entry: Updating zero balance display to "Add funds" card ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/DSYS-153 ## **Manual testing steps** ```gherkin Feature: Balance Empty State on Homepage Background: Given the homepageRedesignV1Key feature flag is enabled Scenario: User with zero balance across all aggregated networks sees empty state Given user has a wallet with zero balance across all aggregated networks (Evm and nonEvm) When user navigates to the homepage Then user should see the BalanceEmptyState component with illustration and "Add funds" button And user should not see the regular balance display showing "$0.00" Scenario: User with positive balance on all aggregated networks sees positive balance Given user has a wallet with positive balance across aggregated networks (EVM and non EVM) When user navigates to the homepage Then user should see the normal balance display showing the actual dollar amount And user should not see the BalanceEmptyState component Scenario: User changes to network with empty balance sees zero balance Given user has positive balance on some networks but zero on others When user switches to a network where they have zero balance (EVM and non EVM) Then user should see "$0.00" balance display Scenario: User changes to network with positive balance sees positive balance (including test networks) Given user has zero balance on some networks but positive on others When user switches to a network where they have positive balance (mainnet, Sepolia, Goerli, etc.) Then user should see the normal balance display with actual amount And user should not see the BalanceEmptyState component And this behavior should be consistent across mainnet and test networks Scenario: User taps "Add funds" button from empty state Given user sees the BalanceEmptyState on homepage When user taps the "Add funds" button Then user should be navigated to the buy crypto page (not deposit flow) And appropriate analytics events should be tracked Scenario: Feature flag disabled - no empty state shown Given the homepageRedesignV1Key feature flag is disabled And user has zero balance When user navigates to the homepage Then user should see the regular "$0.00" balance display And user should not see the BalanceEmptyState component Scenario: Privacy mode interaction with empty state Given user has zero balance and sees BalanceEmptyState When user toggles privacy mode Then the BalanceEmptyState should remain visible (not affected by privacy toggle) Scenario: Loading states Given user navigates to homepage When balance data is still loading Then user should see skeleton/loader components And user should not see BalanceEmptyState or balance display until data loads ``` ## **Screenshots/Recordings** ### **Before** Empty balance showed a `0` balance and footer CTA to go to deposit https://github.com/user-attachments/assets/022fa47d-b567-41a8-a1f7-d85ac3164701 Loading balance on first import of an account with zero balance https://github.com/user-attachments/assets/a91cca57-31a2-4317-b741-206884f9a2db ### **After** Empty balance now shows BalanceEmptyState component https://github.com/user-attachments/assets/44b9c855-d9fb-4c96-bcac-72b62a82b862 The flicker between a zero balance(now empty state) still exists and is an architectural issue outside of the scope of this PR. This is being worked on by the @MetaMask/metamask-assets team https://github.com/user-attachments/assets/de262384-ebdf-4bc6-8f94-b5ab39aec928 ## **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 - [ ] 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 recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Show BalanceEmptyState in `AccountGroupBalance` when aggregated mainnet balance is zero (feature-flagged), with new selector and comprehensive tests. > > - **UI (Wallet)**: > - Display `BalanceEmptyState` in `AccountGroupBalance` when aggregated mainnet balance is zero; hide on testnets; keep skeleton while loading; gated by `selectHomepageRedesignV1Enabled`. > - Uses `TEST_NETWORK_IDS` and `selectEvmChainId` to suppress empty state on test networks. > - **Selectors**: > - Add `selectAccountGroupBalanceForEmptyState` to compute selected group balance across mainnet networks only, using CAIP utilities and excluding `TEST_NETWORK_IDS` and `NON_EVM_TESTNET_IDS`. > - **Tests**: > - Unit: update `AccountGroupBalance.test.tsx` to cover empty state rendering; add selector tests for network filtering, no selection, and missing group fallbacks. > - E2E: add `balance-empty-state.spec.ts` validating visibility on mainnet vs testnets, navigation to buy flow, and persistence after restart; extend page object `WalletView` and selectors with `BALANCE_EMPTY_STATE_*` IDs. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2078f33. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 4567eb8 commit 91c23b3

File tree

7 files changed

+609
-6
lines changed

7 files changed

+609
-6
lines changed

app/components/UI/Assets/components/Balance/AccountGroupBalance.test.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,38 @@ jest.mock('../../../../../selectors/assets/balances', () => ({
99
selectBalanceBySelectedAccountGroup: jest.fn(() => null),
1010
// This one is a factory: selectBalanceChangeBySelectedAccountGroup(period) -> (state) => value
1111
selectBalanceChangeBySelectedAccountGroup: jest.fn(() => () => null),
12+
// This selector is used to display the BalanceEmptyState
13+
selectAccountGroupBalanceForEmptyState: jest.fn(() => null),
14+
}));
15+
16+
// Mock homepage redesign feature flag for BalanceEmptyState
17+
jest.mock('../../../../../selectors/featureFlagController/homepage', () => ({
18+
selectHomepageRedesignV1Enabled: jest.fn(() => true),
19+
}));
20+
21+
// This selector is used to determine if the current network is a testnet for BalanceEmptyState display logic
22+
jest.mock('../../../../../selectors/networkController', () => ({
23+
...jest.requireActual('../../../../../selectors/networkController'),
24+
selectEvmChainId: jest.fn(() => '0x1'), // Ethereum mainnet (not a testnet)
25+
selectChainId: jest.fn(() => '0x1'), // BalanceEmptyState also needs this
26+
}));
27+
28+
// Mock navigation hooks used by BalanceEmptyState
29+
jest.mock('@react-navigation/native', () => ({
30+
...jest.requireActual('@react-navigation/native'),
31+
useNavigation: () => ({
32+
navigate: jest.fn(),
33+
goBack: jest.fn(),
34+
reset: jest.fn(),
35+
}),
36+
}));
37+
38+
// Mock metrics hook used by BalanceEmptyState
39+
jest.mock('../../../../../components/hooks/useMetrics', () => ({
40+
useMetrics: () => ({
41+
trackEvent: jest.fn(),
42+
createEventBuilder: jest.fn(() => ({ record: jest.fn() })),
43+
}),
1244
}));
1345

1446
const testState = {
@@ -52,4 +84,36 @@ describe('AccountGroupBalance', () => {
5284
const el = getByTestId(WalletViewSelectorsIDs.TOTAL_BALANCE_TEXT);
5385
expect(el).toBeTruthy();
5486
});
87+
88+
it('renders empty state when account group balance is zero', () => {
89+
const {
90+
selectAccountGroupBalanceForEmptyState,
91+
selectBalanceBySelectedAccountGroup,
92+
} = jest.requireMock('../../../../../selectors/assets/balances');
93+
94+
// Mock the regular balance selector to return data (prevents skeleton loader)
95+
(selectBalanceBySelectedAccountGroup as jest.Mock).mockImplementation(
96+
() => ({
97+
totalBalanceInUserCurrency: 100, // Some non-zero amount for current network
98+
userCurrency: 'usd',
99+
}),
100+
);
101+
102+
// Mock the empty state selector to return zero balance across all mainnet networks
103+
(selectAccountGroupBalanceForEmptyState as jest.Mock).mockImplementation(
104+
() => ({
105+
totalBalanceInUserCurrency: 0, // Zero across all mainnet networks
106+
userCurrency: 'usd',
107+
}),
108+
);
109+
110+
const { getByTestId } = renderWithProvider(<AccountGroupBalance />, {
111+
state: testState,
112+
});
113+
114+
const el = getByTestId(
115+
WalletViewSelectorsIDs.BALANCE_EMPTY_STATE_CONTAINER,
116+
);
117+
expect(el).toBeOnTheScreen();
118+
});
55119
});

app/components/UI/Assets/components/Balance/AccountGroupBalance.tsx

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import { selectPrivacyMode } from '../../../../../selectors/preferencesControlle
77
import {
88
selectBalanceBySelectedAccountGroup,
99
selectBalanceChangeBySelectedAccountGroup,
10+
selectAccountGroupBalanceForEmptyState,
1011
} from '../../../../../selectors/assets/balances';
12+
import { selectHomepageRedesignV1Enabled } from '../../../../../selectors/featureFlagController/homepage';
13+
import { selectEvmChainId } from '../../../../../selectors/networkController';
14+
import { TEST_NETWORK_IDS } from '../../../../../constants/network';
1115
import SensitiveText, {
1216
SensitiveTextLength,
1317
} from '../../../../../component-library/components/Texts/SensitiveText';
@@ -16,16 +20,24 @@ import { WalletViewSelectorsIDs } from '../../../../../../e2e/selectors/wallet/W
1620
import { Skeleton } from '../../../../../component-library/components/Skeleton';
1721
import { useFormatters } from '../../../../hooks/useFormatters';
1822
import AccountGroupBalanceChange from '../../components/BalanceChange/AccountGroupBalanceChange';
23+
import BalanceEmptyState from '../../../BalanceEmptyState';
1924

2025
const AccountGroupBalance = () => {
2126
const { PreferencesController } = Engine.context;
2227
const styles = createStyles();
2328
const { formatCurrency } = useFormatters();
2429
const privacyMode = useSelector(selectPrivacyMode);
2530
const groupBalance = useSelector(selectBalanceBySelectedAccountGroup);
31+
const accountGroupBalance = useSelector(
32+
selectAccountGroupBalanceForEmptyState,
33+
);
2634
const balanceChange1d = useSelector(
2735
selectBalanceChangeBySelectedAccountGroup('1d'),
2836
);
37+
const isHomepageRedesignV1Enabled = useSelector(
38+
selectHomepageRedesignV1Enabled,
39+
);
40+
const selectedChainId = useSelector(selectEvmChainId);
2941

3042
const togglePrivacy = useCallback(
3143
(value: boolean) => {
@@ -38,10 +50,30 @@ const AccountGroupBalance = () => {
3850
const userCurrency = groupBalance?.userCurrency ?? '';
3951
const displayBalance = formatCurrency(totalBalance, userCurrency);
4052

53+
// Check if account group balance (across all mainnet networks) is zero for empty state
54+
const hasZeroAccountGroupBalance =
55+
accountGroupBalance && accountGroupBalance.totalBalanceInUserCurrency === 0;
56+
57+
// Check if current network is a testnet
58+
const isCurrentNetworkTestnet = TEST_NETWORK_IDS.includes(selectedChainId);
59+
60+
// Show empty state on accounts with an aggregated mainnet balance of zero
61+
const shouldShowEmptyState =
62+
hasZeroAccountGroupBalance &&
63+
isHomepageRedesignV1Enabled &&
64+
!isCurrentNetworkTestnet;
65+
4166
return (
4267
<View style={styles.accountGroupBalance}>
4368
<View>
44-
{groupBalance ? (
69+
{!groupBalance ? (
70+
<View style={styles.skeletonContainer}>
71+
<Skeleton width={100} height={40} />
72+
<Skeleton width={100} height={20} />
73+
</View>
74+
) : shouldShowEmptyState ? (
75+
<BalanceEmptyState testID="account-group-balance-empty-state" />
76+
) : (
4577
<TouchableOpacity
4678
onPress={() => togglePrivacy(!privacyMode)}
4779
testID="balance-container"
@@ -66,11 +98,6 @@ const AccountGroupBalance = () => {
6698
/>
6799
)}
68100
</TouchableOpacity>
69-
) : (
70-
<View style={styles.skeletonContainer}>
71-
<Skeleton width={100} height={40} />
72-
<Skeleton width={100} height={20} />
73-
</View>
74101
)}
75102
</View>
76103
</View>

app/selectors/assets/balances.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ import {
107107
selectBalancePercentChangeByAccountGroup,
108108
selectBalanceBySelectedAccountGroup,
109109
selectBalanceChangeBySelectedAccountGroup,
110+
selectAccountGroupBalanceForEmptyState,
110111
} from './balances';
111112

112113
// Enhanced state factory with realistic data
@@ -484,4 +485,116 @@ describe('assets balance and balance change selectors (mobile)', () => {
484485
expect(selector(state)).toBeNull();
485486
});
486487
});
488+
489+
describe('selectAccountGroupBalanceForEmptyState', () => {
490+
it('excludes testnet chains and includes only mainnet chains in balance calculation', () => {
491+
const mockCalculateBalanceForAllWallets = jest.requireMock(
492+
'@metamask/assets-controllers',
493+
).calculateBalanceForAllWallets;
494+
495+
const state = makeState({
496+
engine: {
497+
backgroundState: {
498+
...makeState().engine.backgroundState,
499+
NetworkController: {
500+
networkConfigurationsByChainId: {
501+
// Mainnet networks - should be included
502+
'0x1': { chainId: '0x1', name: 'Ethereum Mainnet' },
503+
'0x89': { chainId: '0x89', name: 'Polygon Mainnet' },
504+
// Testnet networks - should be excluded
505+
'0xaa36a7': { chainId: '0xaa36a7', name: 'Sepolia' },
506+
'0x5': { chainId: '0x5', name: 'Goerli' },
507+
},
508+
},
509+
MultichainNetworkController: {
510+
multichainNetworkConfigurationsByChainId: {
511+
// Mainnet networks - should be included
512+
'eip155:1': { chainId: '0x1', name: 'Ethereum Mainnet' },
513+
'eip155:137': { chainId: '0x89', name: 'Polygon Mainnet' },
514+
// Testnet networks - should be excluded
515+
'eip155:11155111': { chainId: '0xaa36a7', name: 'Sepolia' },
516+
'eip155:5': { chainId: '0x5', name: 'Goerli' },
517+
// Non-EVM testnet - should be excluded
518+
'solana:103': { chainId: 'solana:103', name: 'Solana Testnet' },
519+
},
520+
},
521+
},
522+
},
523+
}) as unknown as RootState;
524+
525+
// Clear previous calls
526+
mockCalculateBalanceForAllWallets.mockClear();
527+
528+
selectAccountGroupBalanceForEmptyState(state);
529+
530+
// Verify calculateBalanceForAllWallets was called with proper enabledNetworkMap
531+
expect(mockCalculateBalanceForAllWallets).toHaveBeenCalledTimes(1);
532+
const enabledNetworkMap =
533+
mockCalculateBalanceForAllWallets.mock.calls[0][8];
534+
535+
// Should include mainnet networks only
536+
expect(enabledNetworkMap).toEqual({
537+
eip155: {
538+
'0x1': true, // Ethereum mainnet
539+
'0x89': true, // Polygon mainnet
540+
},
541+
// No testnet networks should be present
542+
});
543+
});
544+
545+
it('returns null when no account group is selected', () => {
546+
const state = makeState({
547+
engine: {
548+
backgroundState: {
549+
...makeState().engine.backgroundState,
550+
NetworkController: {
551+
networkConfigurationsByChainId: {
552+
'0x1': { chainId: '0x1', name: 'Ethereum Mainnet' },
553+
},
554+
},
555+
MultichainNetworkController: {
556+
multichainNetworkConfigurationsByChainId: {
557+
'eip155:1': { chainId: '0x1', name: 'Ethereum Mainnet' },
558+
},
559+
},
560+
},
561+
},
562+
}) as unknown as RootState;
563+
state.engine.backgroundState.AccountTreeController.accountTree.selectedAccountGroup =
564+
'';
565+
566+
const result = selectAccountGroupBalanceForEmptyState(state);
567+
expect(result).toBeNull();
568+
});
569+
570+
it('returns zeroed fallback when selected group does not exist', () => {
571+
const state = makeState({
572+
engine: {
573+
backgroundState: {
574+
...makeState().engine.backgroundState,
575+
NetworkController: {
576+
networkConfigurationsByChainId: {
577+
'0x1': { chainId: '0x1', name: 'Ethereum Mainnet' },
578+
},
579+
},
580+
MultichainNetworkController: {
581+
multichainNetworkConfigurationsByChainId: {
582+
'eip155:1': { chainId: '0x1', name: 'Ethereum Mainnet' },
583+
},
584+
},
585+
},
586+
},
587+
}) as unknown as RootState;
588+
state.engine.backgroundState.AccountTreeController.accountTree.selectedAccountGroup =
589+
'keyring:wallet-1/group-999';
590+
591+
const result = selectAccountGroupBalanceForEmptyState(state);
592+
expect(result).toEqual({
593+
walletId: 'keyring:wallet-1',
594+
groupId: 'keyring:wallet-1/group-999',
595+
totalBalanceInUserCurrency: 0,
596+
userCurrency: 'usd',
597+
});
598+
});
599+
});
487600
});

0 commit comments

Comments
 (0)