Skip to content

Commit ee41b3b

Browse files
authored
feat: add account selection on money account deposit page (#28415)
<!-- 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** Add account selection on money account deposit page. ## **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: https://consensyssoftware.atlassian.net/browse/CONF-1148 ## **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** https://github.com/user-attachments/assets/eb5c374a-a312-4d6e-b4c5-97a6aeb48d3e ## **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** > Touches confirmation/pay selection logic and introduces `from`-dependent asset/balance lookups, which can affect token selection and displayed balances across deposit flows. > > **Overview** > Adds a configurable label and an `AccountSelectorSkeleton` to `AccountSelector`, and exposes the skeleton via the component index. > > Updates `CustomAmountInfo` to show an account selector for `moneyAccountDeposit` transactions (labeled **From**) and persist the chosen account by calling `updateEditableParams(..., { from })` (withdraw still sets `{ to }`). > > Makes token/pay UI and data **from-address aware**: `PayWithRow` temporarily disables interaction while the `from` address changes and until a new pay token is available, `useAutomaticTransactionPayToken` re-selects a pay token when `txParams.from` changes, and `useAccountTokens`/`useTokenWithBalance` look up assets/balances for the transaction’s `from` account group when present (adding `selectAssetsByAccountGroupId` selector). Tests are expanded accordingly and a new `confirm.label.from` string is added. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 6093f4b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 3cef363 commit ee41b3b

File tree

18 files changed

+685
-35
lines changed

18 files changed

+685
-35
lines changed

app/components/Views/confirmations/components/AccountSelector/AccountSelector.test.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import React from 'react';
22
import { render, fireEvent } from '@testing-library/react-native';
33
import { mockTheme } from '../../../../../util/theme';
4-
import AccountSelector, { ACCOUNT_SELECTOR_TEST_IDS } from './AccountSelector';
4+
import AccountSelector, {
5+
ACCOUNT_SELECTOR_TEST_IDS,
6+
AccountSelectorSkeleton,
7+
} from './AccountSelector';
58

69
jest.mock('../../../../../../locales/i18n', () => ({
710
strings: (key: string) => key,
@@ -31,8 +34,14 @@ jest.mock('@metamask/design-system-react-native', () => {
3134
testID?: string;
3235
twClassName?: string;
3336
}) => <RNText {...props}>{children}</RNText>;
37+
const MockSkeleton = (props: {
38+
height?: number;
39+
width?: number;
40+
twClassName?: string;
41+
}) => <RNText testID="skeleton">{`${props.height}x${props.width}`}</RNText>;
3442
return {
3543
Text: MockText,
44+
Skeleton: MockSkeleton,
3645
TextVariant: { BodyMd: 'BodyMd', HeadingMd: 'HeadingMd' },
3746
TextColor: { TextAlternative: 'TextAlternative' },
3847
};
@@ -257,4 +266,24 @@ describe('AccountSelector', () => {
257266

258267
expect(queryByTestId(ACCOUNT_SELECTOR_TEST_IDS.MODAL)).toBeNull();
259268
});
269+
270+
it('renders custom label when label prop is provided', () => {
271+
const { getByText, queryByText } = render(
272+
<AccountSelector
273+
label="From"
274+
onAccountSelected={mockOnAccountSelected}
275+
/>,
276+
);
277+
278+
expect(getByText('From')).toBeOnTheScreen();
279+
expect(queryByText('confirm.label.to')).toBeNull();
280+
});
281+
});
282+
283+
describe('AccountSelectorSkeleton', () => {
284+
it('renders skeleton with correct testID', () => {
285+
const { getByTestId } = render(<AccountSelectorSkeleton />);
286+
287+
expect(getByTestId('account-selector-skeleton')).toBeOnTheScreen();
288+
});
260289
});

app/components/Views/confirmations/components/AccountSelector/AccountSelector.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import Icon, {
1414
IconSize,
1515
} from '../../../../../component-library/components/Icons/Icon';
1616
import {
17+
Skeleton,
1718
Text,
1819
TextColor,
1920
TextVariant,
@@ -38,11 +39,14 @@ export const ACCOUNT_SELECTOR_TEST_IDS = {
3839
export interface AccountSelectorProps {
3940
selectedAddress?: string;
4041
onAccountSelected: (address: string) => void;
42+
/** Label shown on the left side of the row. Defaults to the "To" i18n string. */
43+
label?: string;
4144
}
4245

4346
const AccountSelector: React.FC<AccountSelectorProps> = ({
4447
selectedAddress,
4548
onAccountSelected,
49+
label = strings('confirm.label.to'),
4650
}) => {
4751
const [isModalVisible, setIsModalVisible] = useState(false);
4852

@@ -122,7 +126,7 @@ const AccountSelector: React.FC<AccountSelectorProps> = ({
122126
testID={ACCOUNT_SELECTOR_TEST_IDS.PILL}
123127
>
124128
<Text variant={TextVariant.BodyMd} color={TextColor.TextAlternative}>
125-
{strings('confirm.label.to')}
129+
{label}
126130
</Text>
127131
<View style={styles.valueContainer}>
128132
{selectedAddress && accountName ? (
@@ -192,4 +196,20 @@ const AccountSelector: React.FC<AccountSelectorProps> = ({
192196
);
193197
};
194198

199+
export function AccountSelectorSkeleton() {
200+
const { styles } = useStyles(stylesheet, {});
201+
202+
return (
203+
<View style={styles.container} testID="account-selector-skeleton">
204+
<View style={styles.row}>
205+
<Skeleton height={18} width={60} />
206+
<View style={styles.valueContainer}>
207+
<Skeleton height={32} width={32} twClassName="rounded-full" />
208+
<Skeleton height={18} width={120} />
209+
</View>
210+
</View>
211+
</View>
212+
);
213+
}
214+
195215
export default AccountSelector;
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { default } from './AccountSelector';
1+
export { default, AccountSelectorSkeleton } from './AccountSelector';

app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import React, { act } from 'react';
22
import { merge, noop } from 'lodash';
33
import renderWithProvider from '../../../../../../util/test/renderWithProvider';
4-
import { CustomAmountInfo, CustomAmountInfoProps } from './custom-amount-info';
4+
import {
5+
CustomAmountInfo,
6+
CustomAmountInfoProps,
7+
CustomAmountInfoSkeleton,
8+
} from './custom-amount-info';
59
import { simpleSendTransactionControllerMock } from '../../../__mocks__/controllers/transaction-controller-mock';
610
import { transactionApprovalControllerMock } from '../../../__mocks__/controllers/approval-controller-mock';
711
import { otherControllersMock } from '../../../__mocks__/controllers/other-controllers-mock';
@@ -55,7 +59,7 @@ jest.mock('../../../../../../util/transaction-controller', () => ({
5559
updateEditableParams: jest.fn(),
5660
}));
5761
jest.mock('../../AccountSelector', () => {
58-
const { TouchableOpacity, Text } = jest.requireActual('react-native');
62+
const { TouchableOpacity, Text, View } = jest.requireActual('react-native');
5963
return {
6064
__esModule: true,
6165
default: ({
@@ -448,4 +452,69 @@ describe('CustomAmountInfo', () => {
448452
to: '0xTestRecipient',
449453
});
450454
});
455+
456+
it('renders AccountSelector for moneyAccountDeposit transactions', () => {
457+
useTransactionMetadataRequestMock.mockReturnValue({
458+
type: TransactionType.moneyAccountDeposit,
459+
txParams: { from: '0x123' },
460+
} as never);
461+
462+
const { getByTestId } = render({
463+
transactionType: TransactionType.moneyAccountDeposit,
464+
});
465+
466+
expect(getByTestId('account-selector')).toBeOnTheScreen();
467+
});
468+
469+
it('renders AccountSelector for moneyAccountDeposit when txParams.from is undefined', () => {
470+
useTransactionMetadataRequestMock.mockReturnValue({
471+
type: TransactionType.moneyAccountDeposit,
472+
txParams: {},
473+
} as never);
474+
475+
const { getByTestId } = render({
476+
transactionType: TransactionType.moneyAccountDeposit,
477+
});
478+
479+
expect(getByTestId('account-selector')).toBeOnTheScreen();
480+
});
481+
482+
it('calls updateEditableParams with from key when deposit account is selected', async () => {
483+
const { updateEditableParams } = jest.requireMock(
484+
'../../../../../../util/transaction-controller',
485+
);
486+
487+
useTransactionMetadataRequestMock.mockReturnValue({
488+
id: 'mock-tx-id',
489+
type: TransactionType.moneyAccountDeposit,
490+
txParams: { from: '0x123' },
491+
} as never);
492+
493+
const { getByTestId } = render({
494+
transactionType: TransactionType.moneyAccountDeposit,
495+
});
496+
497+
await act(async () => {
498+
fireEvent.press(getByTestId('account-selector'));
499+
});
500+
501+
expect(updateEditableParams).toHaveBeenCalledWith('mock-tx-id', {
502+
from: '0xTestRecipient',
503+
});
504+
});
505+
});
506+
507+
describe('CustomAmountInfoSkeleton', () => {
508+
it('renders skeleton without AccountSelectorSkeleton', () => {
509+
const { queryByTestId } = renderWithProvider(<CustomAmountInfoSkeleton />, {
510+
state: merge(
511+
{},
512+
simpleSendTransactionControllerMock,
513+
transactionApprovalControllerMock,
514+
otherControllersMock,
515+
),
516+
});
517+
518+
expect(queryByTestId('account-selector-skeleton')).toBeNull();
519+
});
451520
});

app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,10 @@ import Engine from '../../../../../../core/Engine';
6060
import { ConfirmationFooterSelectorIDs } from '../../../ConfirmationView.testIds';
6161
import { useTransactionPayToken } from '../../../hooks/pay/useTransactionPayToken';
6262
import { getNativeTokenAddress } from '@metamask/assets-controllers';
63+
import { useSelector } from 'react-redux';
6364
import AccountSelector from '../../AccountSelector';
6465
import { updateEditableParams } from '../../../../../../util/transaction-controller';
66+
import { selectSelectedInternalAccountAddress } from '../../../../../../selectors/accountsController';
6567

6668
export interface CustomAmountInfoProps {
6769
children?: ReactNode;
@@ -120,10 +122,23 @@ export const CustomAmountInfo: React.FC<CustomAmountInfoProps> = memo(
120122
const isMoneyAccountWithdraw = hasTransactionType(transactionMeta, [
121123
TransactionType.moneyAccountWithdraw,
122124
]);
125+
const isMoneyAccountDeposit = hasTransactionType(transactionMeta, [
126+
TransactionType.moneyAccountDeposit,
127+
]);
123128
const [selectedRecipientAddress, setSelectedRecipientAddress] = useState<
124129
string | undefined
125130
>(undefined);
126131

132+
const globalSelectedAddress = useSelector(
133+
selectSelectedInternalAccountAddress,
134+
);
135+
const initialFromAddress =
136+
(transactionMeta?.txParams?.from as string | undefined) ??
137+
globalSelectedAddress;
138+
const [selectedFromAddress, setSelectedFromAddress] = useState<
139+
string | undefined
140+
>(initialFromAddress);
141+
127142
const handleRecipientAccountSelected = useCallback(
128143
(address: string) => {
129144
if (transactionId) {
@@ -134,6 +149,16 @@ export const CustomAmountInfo: React.FC<CustomAmountInfoProps> = memo(
134149
[transactionId],
135150
);
136151

152+
const handleFromAccountSelected = useCallback(
153+
(address: string) => {
154+
if (transactionId) {
155+
updateEditableParams(transactionId, { from: address as Hex });
156+
}
157+
setSelectedFromAddress(address);
158+
},
159+
[transactionId],
160+
);
161+
137162
const isRecipientMissing =
138163
isMoneyAccountWithdraw && !selectedRecipientAddress;
139164

@@ -206,6 +231,13 @@ export const CustomAmountInfo: React.FC<CustomAmountInfoProps> = memo(
206231
<Box gap={16}>
207232
{!overrideContent && (
208233
<>
234+
{isMoneyAccountDeposit && (
235+
<AccountSelector
236+
label={strings('confirm.label.from')}
237+
selectedAddress={selectedFromAddress}
238+
onAccountSelected={handleFromAccountSelected}
239+
/>
240+
)}
209241
{isMoneyAccountWithdraw && (
210242
<AccountSelector
211243
selectedAddress={selectedRecipientAddress}

app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.styles.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ const styleSheet = (params: { theme: Theme }) =>
3434
marginLeft: -1,
3535
borderRadius: 99,
3636
},
37+
38+
disabled: {
39+
opacity: 0.5,
40+
},
3741
});
3842

3943
export default styleSheet;

app/components/Views/confirmations/components/rows/pay-with-row/pay-with-row.test.tsx

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ import renderWithProvider from '../../../../../../util/test/renderWithProvider';
1414
import { backgroundState } from '../../../../../../util/test/initial-root-state';
1515
import { isHardwareAccount } from '../../../../../../util/address';
1616
import { useConfirmationMetricEvents } from '../../../hooks/metrics/useConfirmationMetricEvents';
17+
import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest';
1718

19+
jest.mock('../../../hooks/transactions/useTransactionMetadataRequest');
1820
jest.mock('@react-navigation/native', () => ({
1921
...jest.requireActual('@react-navigation/native'),
2022
useNavigation: jest.fn(),
@@ -56,6 +58,9 @@ describe('PayWithRow', () => {
5658
const useTransactionPayRequiredTokensMock = jest.mocked(
5759
useTransactionPayRequiredTokens,
5860
);
61+
const useTransactionMetadataRequestMock = jest.mocked(
62+
useTransactionMetadataRequest,
63+
);
5964
const mockSetConfirmationMetric = jest.fn();
6065

6166
beforeEach(() => {
@@ -241,4 +246,96 @@ describe('PayWithRow', () => {
241246
);
242247
});
243248
});
249+
250+
describe('from address change', () => {
251+
beforeEach(() => {
252+
useTransactionMetadataRequestMock.mockReturnValue({
253+
txParams: { from: '0xFromAddress' },
254+
} as never);
255+
});
256+
257+
it('completes reselecting cycle when from changes and payToken stays set', async () => {
258+
const { rerender, getByText } = render();
259+
260+
useTransactionMetadataRequestMock.mockReturnValue({
261+
txParams: { from: '0xDifferentAddress' },
262+
} as never);
263+
264+
await act(async () => {
265+
rerender(<PayWithRow />);
266+
});
267+
268+
await act(async () => {
269+
fireEvent.press(getByText(`${ADDRESS_MOCK} ${CHAIN_ID_MOCK}`));
270+
});
271+
272+
expect(navigateMock).toHaveBeenCalledWith(
273+
Routes.CONFIRMATION_PAY_WITH_MODAL,
274+
);
275+
});
276+
277+
it('shows skeleton when payToken clears during from address change', async () => {
278+
const { rerender, getByTestId } = render();
279+
280+
useTransactionMetadataRequestMock.mockReturnValue({
281+
txParams: { from: '0xDifferentAddress' },
282+
} as never);
283+
284+
jest.mocked(useTransactionPayToken).mockReturnValue({
285+
payToken: undefined,
286+
setPayToken: jest.fn(),
287+
});
288+
289+
await act(async () => {
290+
rerender(<PayWithRow />);
291+
});
292+
293+
expect(getByTestId('pay-with-row-skeleton')).toBeDefined();
294+
});
295+
296+
it('re-enables row when new payToken arrives after from change', async () => {
297+
const { rerender, getByText, getByTestId } = render();
298+
299+
useTransactionMetadataRequestMock.mockReturnValue({
300+
txParams: { from: '0xDifferentAddress' },
301+
} as never);
302+
303+
jest.mocked(useTransactionPayToken).mockReturnValue({
304+
payToken: undefined,
305+
setPayToken: jest.fn(),
306+
});
307+
308+
await act(async () => {
309+
rerender(<PayWithRow />);
310+
});
311+
312+
expect(getByTestId('pay-with-row-skeleton')).toBeDefined();
313+
314+
jest.mocked(useTransactionPayToken).mockReturnValue({
315+
payToken: {
316+
address: ADDRESS_MOCK,
317+
balanceHuman: '0',
318+
balanceFiat: '$0',
319+
balanceRaw: '0',
320+
balanceUsd: '0',
321+
chainId: CHAIN_ID_MOCK,
322+
decimals: 4,
323+
symbol: 'test',
324+
},
325+
setPayToken: jest.fn(),
326+
});
327+
328+
await act(async () => {
329+
rerender(<PayWithRow />);
330+
});
331+
332+
await act(async () => {
333+
fireEvent.press(getByText(`${ADDRESS_MOCK} ${CHAIN_ID_MOCK}`));
334+
});
335+
336+
expect(navigateMock).toHaveBeenCalledWith(
337+
Routes.CONFIRMATION_PAY_WITH_MODAL,
338+
);
339+
});
340+
});
244341
});

0 commit comments

Comments
 (0)