feat(suite-native): account picker with account types

This commit is contained in:
Martin Vytick Vytrhlík
2025-03-17 18:56:51 +01:00
committed by vytick
parent 31897ee1b2
commit 6af0ae992f
14 changed files with 628 additions and 249 deletions

View File

@@ -28,6 +28,7 @@ module.exports = {
],
setupFiles: [
'<rootDir>/../../node_modules/@shopify/react-native-skia/jestSetup.js',
'<rootDir>/../../node_modules/@shopify/flash-list/jestSetup.js',
'<rootDir>/../../node_modules/react-native-gesture-handler/jestSetup.js',
'<rootDir>/../../suite-native/test-utils/src/everstakeJestSetup.js',
'<rootDir>/../../suite-native/test-utils/src/expoMock.js',

View File

@@ -0,0 +1,63 @@
[
{
"symbol": "btc",
"accountLabel": "BTC Account #1",
"deviceState": "staticSessionId",
"key": "btc1",
"networkType": "bitcoin",
"accountType": "normal",
"addresses": {
"used": [
{ "address": "USED1", "path": "path_USED1", "balance": "10000000" },
{ "address": "USED2", "path": "path_USED2", "balance": "20000000" }
],
"change": [{ "address": "CHANGE1", "path": "path_CHANGE1" }],
"unused": [
{ "address": "UNUSED1", "path": "path_UNUSED1" },
{ "address": "UNUSED2", "path": "path_UNUSED2" }
]
},
"visible": true
},
{
"symbol": "btc",
"accountLabel": "BTC Account #2",
"deviceState": "staticSessionId",
"key": "btc2",
"networkType": "bitcoin",
"accountType": "legacy",
"addresses": {
"used": [],
"change": [],
"unused": []
},
"visible": true
},
{
"symbol": "eth",
"accountLabel": "ETH Account #1",
"deviceState": "staticSessionId",
"key": "eth1",
"networkType": "ethereum",
"accountType": "normal",
"visible": true
},
{
"symbol": "eth",
"accountLabel": "ETH Account #2",
"deviceState": "staticSessionId",
"key": "eth2",
"networkType": "ethereum",
"accountType": "legacy",
"visible": true
},
{
"symbol": "eth",
"accountLabel": "ETH Account #3 HIDDEN",
"deviceState": "staticSessionId",
"key": "eth3",
"networkType": "ethereum",
"accountType": "legacy",
"visible": false
}
]

View File

@@ -0,0 +1,41 @@
import { Text } from '@suite-native/atoms';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
import { ReceiveAccount } from '../../../types';
import { AccountAddress } from '../AccountAddress';
import { AccountListBaseItem } from './AccountListBaseItem';
const labelTextStyle = prepareNativeStyle(utils => ({
color: utils.colors.textSubdued,
flex: 1,
}));
export type AccountListAddressItemProps = {
receiveAccount: ReceiveAccount;
onPress: () => void;
};
export const AccountListAddressItem = ({
receiveAccount,
onPress,
}: AccountListAddressItemProps) => {
const { applyStyle } = useNativeStyles();
const { address } = receiveAccount;
if (!address) {
return null;
}
return (
<AccountListBaseItem
receiveAccount={receiveAccount}
label={<AccountAddress address={address.address} form="full" />}
isAddressDetail={true}
info={
<Text variant="hint" style={applyStyle(labelTextStyle)}>
{address.path}
</Text>
}
onPress={onPress}
/>
);
};

View File

@@ -0,0 +1,153 @@
import { ReactNode } from 'react';
import { Pressable } from 'react-native';
import { BASE_CRYPTO_MAX_DISPLAYED_DECIMALS } from '@suite-common/formatters';
import { Box, HStack, Text, VStack } from '@suite-native/atoms';
import { CryptoAmountFormatter, CryptoToFiatAmountFormatter } from '@suite-native/formatters';
import { CryptoIcon, Icon } from '@suite-native/icons';
import { useTranslate } from '@suite-native/intl';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
import { ReceiveAccount } from '../../../types';
export type AccountListBaseItemProps = {
receiveAccount: ReceiveAccount;
label: ReactNode;
info: ReactNode;
isAddressDetail: boolean;
onPress: () => void;
};
type TextColor = 'textDefault' | 'textSubdued';
export const ACCOUNT_LIST_ITEM_HEIGHT = 68 as const;
const labelTextStyle = prepareNativeStyle<{ textColor: TextColor; flex: number }>(
({ colors }, { textColor, flex }) => ({
color: colors[textColor],
flex,
}),
);
const amountTextStyle = prepareNativeStyle<{ textColor: TextColor }>(
({ colors }, { textColor }) => ({
color: colors[textColor],
textAlign: 'right',
flex: 0,
}),
);
const bottomContentStyle = prepareNativeStyle<{ hasSingleChildren: boolean }>(
(_, { hasSingleChildren }) => ({
alignItems: 'center',
justifyContent: 'space-between',
extend: {
condition: hasSingleChildren,
style: { justifyContent: 'flex-end' },
},
}),
);
const AccountListLabel = ({ label, flex }: { label: ReactNode; flex: number }) => {
const { applyStyle } = useNativeStyles();
return (
<Text
variant="body"
numberOfLines={1}
ellipsizeMode="tail"
style={applyStyle(labelTextStyle, {
textColor: 'textDefault',
flex,
})}
>
{label}
</Text>
);
};
export const AccountListBaseItem = ({
receiveAccount: { account, address },
label,
info,
isAddressDetail,
onPress,
}: AccountListBaseItemProps) => {
const { applyStyle } = useNativeStyles();
const { translate } = useTranslate();
const cryptoValue = isAddressDetail ? address?.balance ?? '0' : account.availableBalance;
const shouldDisplayCaret = !isAddressDetail && !!account.addresses;
const shouldDisplayBalance = !isAddressDetail || address?.balance != null;
return (
<Pressable
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={account.accountLabel}
>
<HStack
alignItems="center"
spacing="sp12"
paddingVertical="sp12"
justifyContent="center"
>
<Box justifyContent="center">
<CryptoIcon symbol={account.symbol} size="extraSmall" />
</Box>
{!info && (
<Box flex={1}>
<AccountListLabel label={label} flex={0} />
</Box>
)}
<VStack flex={info ? 1 : 0} spacing={0}>
<HStack alignItems="center" justifyContent="space-between">
{info && <AccountListLabel label={label} flex={1} />}
{shouldDisplayBalance && (
<CryptoAmountFormatter
value={cryptoValue}
symbol={account.symbol}
variant="body"
style={applyStyle(amountTextStyle, {
textColor: 'textDefault',
})}
accessibilityLabel={translate(
'moduleTrading.accountScreen.balanceCrypto',
)}
isBalance={false}
decimals={BASE_CRYPTO_MAX_DISPLAYED_DECIMALS}
/>
)}
</HStack>
<HStack style={applyStyle(bottomContentStyle, { hasSingleChildren: false })}>
{info}
{shouldDisplayBalance && cryptoValue && (
<CryptoToFiatAmountFormatter
value={cryptoValue}
symbol={account.symbol}
variant="hint"
style={applyStyle(labelTextStyle, {
textColor: 'textDefault',
flex: 1,
})}
accessibilityLabel={translate(
'moduleTrading.accountScreen.balanceFiat',
)}
/>
)}
</HStack>
</VStack>
{shouldDisplayCaret && (
<Box justifyContent="center">
<Icon
name="caretRight"
color="textSecondaryHighlight"
accessibilityHint={translate('moduleTrading.accountScreen.step2Hint')}
/>
</Box>
)}
</HStack>
</Pressable>
);
};

View File

@@ -1,131 +1,33 @@
import { Pressable } from 'react-native';
import { useSelector } from 'react-redux';
import { BASE_CRYPTO_MAX_DISPLAYED_DECIMALS, useFormatters } from '@suite-common/formatters';
import { NetworkSymbol } from '@suite-common/wallet-config';
import { Box, HStack, RoundedIcon, Text, VStack } from '@suite-native/atoms';
import { CryptoAmountFormatter, CryptoToFiatAmountFormatter } from '@suite-native/formatters';
import { Icon } from '@suite-native/icons';
import { useTranslate } from '@suite-native/intl';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
import { AccountsRootState, selectFormattedAccountType } from '@suite-common/wallet-core';
import { Badge } from '@suite-native/atoms';
import { AccountListBaseItem } from './AccountListBaseItem';
import { ReceiveAccount } from '../../../types';
import { AccountAddress } from '../AccountAddress';
export type AccountListItemProps = {
symbol: NetworkSymbol;
receiveAccount: ReceiveAccount;
onPress: () => void;
};
type TextStyle = {
textColor: 'textDefault' | 'textSubdued';
};
export const ACCOUNT_LIST_ITEM_HEIGHT = 68 as const;
const labelTextStyle = prepareNativeStyle<TextStyle>(({ colors }, { textColor }) => ({
color: colors[textColor],
flex: 1,
}));
const amountTextStyle = prepareNativeStyle<TextStyle>(({ colors }, { textColor }) => ({
color: colors[textColor],
textAlign: 'right',
flex: 0,
}));
export const AccountListItem = ({
symbol,
receiveAccount: { account, address },
onPress,
}: AccountListItemProps) => {
const { applyStyle } = useNativeStyles();
const { translate } = useTranslate();
const { DisplaySymbolFormatter } = useFormatters();
const isAddressDetail = !!address;
const cryptoValue = isAddressDetail ? address.balance : account.availableBalance;
const shouldDisplayCaret = !isAddressDetail && !!account.addresses;
const shouldDisplayBalance = !isAddressDetail || address?.balance != null;
const label = isAddressDetail ? (
<AccountAddress address={address.address} form="full" />
) : (
account.accountLabel
export const AccountListItem = ({ receiveAccount, onPress }: AccountListItemProps) => {
const { account } = receiveAccount;
const formattedAccountType = useSelector((state: AccountsRootState) =>
selectFormattedAccountType(state, account.key),
);
const info = isAddressDetail ? (
address.path
) : (
<DisplaySymbolFormatter value={symbol} areAmountUnitsEnabled={false} />
const typeBadge = formattedAccountType && (
<Badge label={formattedAccountType} size="small" elevation="1" />
);
return (
<Pressable
<AccountListBaseItem
receiveAccount={receiveAccount}
label={account.accountLabel}
isAddressDetail={false}
info={typeBadge}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={account.accountLabel}
>
<HStack alignItems="center" spacing="sp12" paddingVertical="sp12">
<Box justifyContent="center">
<RoundedIcon symbol={symbol} />
</Box>
<VStack flex={1} spacing={0}>
<HStack alignItems="center" justifyContent="space-between">
<Text
variant="body"
numberOfLines={1}
ellipsizeMode="tail"
style={applyStyle(labelTextStyle, { textColor: 'textDefault' })}
>
{label}
</Text>
{shouldDisplayBalance && (
<CryptoAmountFormatter
value={cryptoValue}
symbol={account.symbol}
variant="body"
style={applyStyle(amountTextStyle, {
textColor: 'textDefault',
})}
accessibilityLabel={translate(
'moduleTrading.accountScreen.balanceCrypto',
)}
isBalance={false}
decimals={BASE_CRYPTO_MAX_DISPLAYED_DECIMALS}
/>
)}
</HStack>
<HStack alignItems="center" justifyContent="space-between">
<Text
variant="hint"
style={applyStyle(labelTextStyle, { textColor: 'textSubdued' })}
>
{info}
</Text>
{shouldDisplayBalance && cryptoValue && (
<CryptoToFiatAmountFormatter
value={cryptoValue}
symbol={account.symbol}
variant="hint"
style={applyStyle(labelTextStyle, { textColor: 'textSubdued' })}
accessibilityLabel={translate(
'moduleTrading.accountScreen.balanceFiat',
)}
/>
)}
</HStack>
</VStack>
{shouldDisplayCaret && (
<Box justifyContent="center">
<Icon
name="caretCircleRight"
color="textSecondaryHighlight"
accessibilityHint={translate('moduleTrading.accountScreen.step2Hint')}
/>
</Box>
)}
</HStack>
</Pressable>
/>
);
};

View File

@@ -14,10 +14,12 @@ import {
} from '@suite-native/navigation';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
import { ACCOUNT_LIST_ITEM_HEIGHT, AccountListItem } from './AccountListItem';
import { AccountListAddressItem } from './AccountListAddressItem';
import { ACCOUNT_LIST_ITEM_HEIGHT } from './AccountListBaseItem';
import { AccountListItem } from './AccountListItem';
import { AccountsListFooter } from './AccountsListFooter';
import { AddressListEmptyComponent } from './AddressListEmptyComponent';
import { NoAccountsComponent } from './NoAccountsComponent';
import { TradeAccountsListFooter } from './TradeAccountsListFooter';
import {
ReceiveAccountsListMode,
useReceiveAccountsListData,
@@ -35,11 +37,11 @@ type NavigationProp = StackToStackCompositeNavigationProps<
RootStackParamList
>;
type TradeAccountsListProps = {
export type AccountsListProps = {
symbol: NetworkSymbol;
pickerMode: ReceiveAccountsListMode;
onAddAccountTap: () => void;
setPickerMode: (mode: ReceiveAccountsListMode) => void;
onSetPickerMode: (mode: ReceiveAccountsListMode) => void;
};
const DEFAULT_INSET_BOTTOM = 25;
@@ -50,15 +52,15 @@ const contentContainerStyle = prepareNativeStyle<{
paddingBottom: Math.max(insetBottom, utils.spacings.sp16),
}));
const keyExtractor = (item: ReceiveAccount) =>
export const keyExtractor = (item: ReceiveAccount) =>
`${item.account.key}_${item.address?.address ?? 'address_undefined'}`;
export const TradeAccountsList = ({
export const AccountsList = ({
symbol,
pickerMode,
onAddAccountTap,
setPickerMode,
}: TradeAccountsListProps) => {
onSetPickerMode,
}: AccountsListProps) => {
const navigation = useNavigation<NavigationProp>();
const { applyStyle } = useNativeStyles();
const dispatch = useDispatch();
@@ -77,16 +79,19 @@ export const TradeAccountsList = ({
dispatch(setBuySelectedReceiveAccount({ selectedReceiveAccount: receiveAccount }));
const hasAddresses = receiveAccount.account.addresses;
if (receiveAccount.account && hasAddresses) {
setPickerMode('address');
onSetPickerMode('address');
}
if ((hasAddresses && receiveAccount.address) || !hasAddresses) {
navigation.popToTop();
}
};
const renderItem = (item: ReceiveAccount) => (
<AccountListItem receiveAccount={item} onPress={() => onItemSelect(item)} symbol={symbol} />
);
const renderItem = (item: ReceiveAccount) =>
pickerMode === 'account' ? (
<AccountListItem receiveAccount={item} onPress={() => onItemSelect(item)} />
) : (
<AccountListAddressItem receiveAccount={item} onPress={() => onItemSelect(item)} />
);
const {
data: internalData,
@@ -99,17 +104,15 @@ export const TradeAccountsList = ({
keyExtractor,
renderItem,
noSingletonSectionHeader: true,
isLastItemRounded: isDeviceInViewOnlyMode,
isLastItemRounded: isDeviceInViewOnlyMode || pickerMode === 'address',
});
const insetBottom = Math.max(insetsBottom, DEFAULT_INSET_BOTTOM);
const shouldHaveFooter = !isDeviceInViewOnlyMode && pickerMode === 'account';
const footer = shouldHaveFooter ? (
<TradeAccountsListFooter
hasTextualDivider={itemsCount > 0}
onAddAccountTap={onAddAccountTap}
/>
<AccountsListFooter hasTextualDivider={itemsCount > 0} onAddAccountTap={onAddAccountTap} />
) : null;
const filter = '';

View File

@@ -11,15 +11,15 @@ const footerStyle = prepareNativeStyle(utils => ({
borderBottomRightRadius: utils.borders.radii.r16,
}));
export type TradeAccountsListFooterProps = {
export type AccountsListFooterProps = {
hasTextualDivider: boolean;
onAddAccountTap: () => void;
};
export const TradeAccountsListFooter = ({
export const AccountsListFooter = ({
hasTextualDivider,
onAddAccountTap,
}: TradeAccountsListFooterProps) => {
}: AccountsListFooterProps) => {
const { applyStyle } = useNativeStyles();
return (

View File

@@ -0,0 +1,128 @@
import { Account } from '@suite-common/wallet-types';
import { fireEvent, renderWithStoreProviderAsync } from '@suite-native/test-utils';
import { Address } from '@trezor/blockchain-link-types';
import { ReceiveAccount } from '../../../../types';
import { AccountListAddressItem } from '../AccountListAddressItem';
jest.mock('@suite-common/wallet-core', () => {
const fiatRate = { rate: 1e8 };
return {
...jest.requireActual('@suite-common/wallet-core'),
selectFiatRatesByFiatRateKey: () => fiatRate,
};
});
describe('AccountListAddressItem', () => {
const onPressMock = jest.fn();
const renderAccountListAddressItem = (receiveAccount: ReceiveAccount) =>
renderWithStoreProviderAsync(
<AccountListAddressItem receiveAccount={receiveAccount} onPress={onPressMock} />,
);
beforeEach(() => {
jest.resetAllMocks();
});
it('should call onPress callback when pressed', async () => {
const receiveAccount: ReceiveAccount = {
account: {
key: 'btc1',
symbol: 'btc',
accountLabel: 'My BTC account',
availableBalance: '10000000',
} as unknown as Account,
address: {
address: 'BTC_address',
balance: '5000000',
} as unknown as Address,
};
const { getByText } = await renderAccountListAddressItem(receiveAccount);
fireEvent.press(getByText('BTC_address'));
expect(onPressMock).toHaveBeenCalled();
});
it('should not display caret for address addresses', async () => {
const receiveAccount: ReceiveAccount = {
account: {
key: 'btc1',
symbol: 'btc',
accountLabel: 'My BTC account',
availableBalance: '10000000',
} as unknown as Account,
address: {
address: 'BTC_address',
balance: '5000000',
} as unknown as Address,
};
const { getByText, queryByAccessibilityHint } =
await renderAccountListAddressItem(receiveAccount);
expect(getByText('BTC_address')).toBeDefined();
expect(queryByAccessibilityHint('Select to display account addresses')).toBeNull();
});
it('should display address', async () => {
const receiveAccount: ReceiveAccount = {
account: {
key: 'btc1',
symbol: 'btc',
accountLabel: 'My BTC account',
availableBalance: '10000000',
} as unknown as Account,
address: {
address: 'BTC_address',
balance: '5000000',
} as unknown as Address,
};
const { getByText, queryByText, queryByAccessibilityHint, getByLabelText } =
await renderAccountListAddressItem(receiveAccount);
expect(getByText('BTC_address')).toBeDefined();
expect(queryByText('My BTC account')).toBeNull();
expect(queryByAccessibilityHint('Select to display account addresses')).toBeNull();
expect(getByLabelText('Balance in fiat')).toHaveTextContent('$5,000,000.00');
expect(getByLabelText('Balance in crypto')).toHaveTextContent('0.05 BTC');
});
it('should display zero balance', async () => {
const receiveAccount: ReceiveAccount = {
account: {
key: 'btc1',
symbol: 'btc',
accountLabel: 'My BTC account',
availableBalance: '10000000',
} as unknown as Account,
address: {
address: 'BTC_address',
balance: '0',
} as unknown as Address,
};
const { getByLabelText } = await renderAccountListAddressItem(receiveAccount);
expect(getByLabelText('Balance in fiat')).toHaveTextContent('$0.00');
expect(getByLabelText('Balance in crypto')).toHaveTextContent('0 BTC');
});
it('should not display balance when address has no balance', async () => {
const receiveAccount: ReceiveAccount = {
account: {
key: 'btc1',
symbol: 'btc',
accountLabel: 'My BTC account',
availableBalance: '10000000',
} as unknown as Account,
address: {
address: 'BTC_address',
} as unknown as Address,
};
const { queryByLabelText } = await renderAccountListAddressItem(receiveAccount);
expect(queryByLabelText('Balance in fiat')).toBeNull();
expect(queryByLabelText('Balance in crypto')).toBeNull();
});
});

View File

@@ -1,6 +1,5 @@
import { Account } from '@suite-common/wallet-types';
import { fireEvent, renderWithStoreProviderAsync } from '@suite-native/test-utils';
import { Address } from '@trezor/blockchain-link-types';
import { ReceiveAccount } from '../../../../types';
import { AccountListItem } from '../AccountListItem';
@@ -19,7 +18,7 @@ describe('AccountListItem', () => {
const renderAccountListItem = (receiveAccount: ReceiveAccount) =>
renderWithStoreProviderAsync(
<AccountListItem symbol="btc" onPress={onPressMock} receiveAccount={receiveAccount} />,
<AccountListItem onPress={onPressMock} receiveAccount={receiveAccount} />,
);
beforeEach(() => {
@@ -37,12 +36,12 @@ describe('AccountListItem', () => {
};
const { getByText } = await renderAccountListItem(receiveAccount);
fireEvent.press(getByText('BTC'));
fireEvent.press(getByText('My BTC account'));
expect(onPressMock).toHaveBeenCalled();
});
it('should render account name when no address is specified', async () => {
it('should render account name', async () => {
const receiveAccount: ReceiveAccount = {
account: {
key: 'btc1',
@@ -79,64 +78,4 @@ describe('AccountListItem', () => {
expect(getByText('My BTC account')).toBeDefined();
expect(getByAccessibilityHint('Select to display account addresses')).toBeDefined();
});
it('should display address when specified', async () => {
const receiveAccount: ReceiveAccount = {
account: {
key: 'btc1',
symbol: 'btc',
accountLabel: 'My BTC account',
availableBalance: '10000000',
} as unknown as Account,
address: {
address: 'BTC_address',
balance: '5000000',
} as unknown as Address,
};
const { getByText, queryByText, queryByAccessibilityHint, getByLabelText } =
await renderAccountListItem(receiveAccount);
expect(getByText('BTC_address')).toBeDefined();
expect(queryByText('My BTC account')).toBeNull();
expect(queryByAccessibilityHint('Select to display account addresses')).toBeNull();
expect(getByLabelText('Balance in fiat')).toHaveTextContent('$5,000,000.00');
expect(getByLabelText('Balance in crypto')).toHaveTextContent('0.05 BTC');
});
it('should display zero balance', async () => {
const receiveAccount: ReceiveAccount = {
account: {
key: 'btc1',
symbol: 'btc',
accountLabel: 'My BTC account',
availableBalance: '10000000',
} as unknown as Account,
address: {
address: 'BTC_address',
balance: '0',
} as unknown as Address,
};
const { getByLabelText } = await renderAccountListItem(receiveAccount);
expect(getByLabelText('Balance in fiat')).toHaveTextContent('$0.00');
expect(getByLabelText('Balance in crypto')).toHaveTextContent('0 BTC');
});
it('should not display balance when address has no balance', async () => {
const receiveAccount: ReceiveAccount = {
account: {
key: 'btc1',
symbol: 'btc',
accountLabel: 'My BTC account',
availableBalance: '10000000',
} as unknown as Account,
address: {
address: 'BTC_address',
} as unknown as Address,
};
const { queryByLabelText } = await renderAccountListItem(receiveAccount);
expect(queryByLabelText('Balance in fiat')).toBeNull();
expect(queryByLabelText('Balance in crypto')).toBeNull();
});
});

View File

@@ -0,0 +1,180 @@
import { useNavigation } from '@react-navigation/native';
import { Account } from '@suite-common/wallet-types';
import { fireEvent, renderWithStoreProviderAsync } from '@suite-native/test-utils';
import { Address } from '@trezor/blockchain-link-types';
import { StaticSessionId } from '@trezor/connect';
import fixturesAccounts from '../../../../__fixtures__/accounts.json';
import { ReceiveAccount } from '../../../../types';
import { AccountsList, AccountsListProps, keyExtractor } from '../AccountsList';
const accounts = fixturesAccounts as Account[];
const defaultPreloadedState = {
device: {
selectedDevice: {
state: {
staticSessionId: 'staticSessionId' as StaticSessionId,
},
connected: true,
available: true,
remember: true,
},
},
wallet: { accounts },
};
const getStateMockup = (selectedAccount: ReceiveAccount) => ({
...defaultPreloadedState,
wallet: {
accounts: defaultPreloadedState.wallet.accounts,
tradingNew: {
buy: {
selectedReceiveAccount: selectedAccount,
},
},
},
});
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useNavigation: jest.fn(),
}));
describe('AccountsList', () => {
const onSetPickerModeMock = jest.fn();
const popToTop = jest.fn();
const renderComponent = (
props: Partial<AccountsListProps>,
preloadedState = defaultPreloadedState,
) =>
renderWithStoreProviderAsync(
<AccountsList
symbol="btc"
pickerMode="account"
onAddAccountTap={jest.fn()}
onSetPickerMode={jest.fn()}
{...props}
/>,
{ preloadedState },
);
describe('renderItem', () => {
it('should display all accounts for given symbol', async () => {
const { getByText } = await renderComponent({
symbol: 'btc',
pickerMode: 'account',
});
expect(getByText('BTC Account #1')).toBeTruthy();
expect(getByText('BTC Account #2')).toBeTruthy();
});
it('should display addresses when picker mode is set and account is selected', async () => {
const { getByText } = await renderComponent(
{
symbol: 'btc',
pickerMode: 'address',
},
getStateMockup({ account: accounts[0] }),
);
const item = getByText('UNUSED1');
expect(item).toBeTruthy();
});
});
describe('onItemSelect', () => {
it('should call onSetPickerMode when account is selected in account mode', async () => {
(useNavigation as jest.Mock).mockReturnValue({ popToTop });
const { getByText } = await renderComponent({
symbol: 'btc',
pickerMode: 'account',
onSetPickerMode: onSetPickerModeMock,
});
const item = getByText('BTC Account #1');
expect(item).toBeTruthy();
fireEvent.press(item);
expect(onSetPickerModeMock).toHaveBeenCalledWith('address');
expect(popToTop).not.toHaveBeenCalled();
});
it('should popToTop when account is selected in account mode and there are no addresses', async () => {
(useNavigation as jest.Mock).mockReturnValue({ popToTop });
const { getByText } = await renderComponent({
symbol: 'eth',
pickerMode: 'account',
onSetPickerMode: onSetPickerModeMock,
});
const item = getByText('ETH Account #1');
expect(item).toBeTruthy();
fireEvent.press(item);
expect(popToTop).toHaveBeenCalled();
});
it('should handle address selection in address mode', async () => {
(useNavigation as jest.Mock).mockReturnValue({ popToTop });
const { getByText } = await renderComponent(
{
symbol: 'btc',
pickerMode: 'address',
onSetPickerMode: onSetPickerModeMock,
},
getStateMockup({ account: accounts[0] }),
);
const item = getByText('UNUSED1');
expect(item).toBeTruthy();
fireEvent.press(item);
expect(popToTop).toHaveBeenCalled();
});
});
describe('footer', () => {
it('should display footer with "Add new" button in "account" mode', async () => {
const { getByText } = await renderComponent({
symbol: 'btc',
pickerMode: 'account',
});
expect(getByText('Add new')).toBeTruthy();
});
it('should NOT display footer with "Add new" button in "address" mode', async () => {
const { queryByText } = await renderComponent(
{
symbol: 'btc',
pickerMode: 'address',
},
getStateMockup({ account: accounts[0] }),
);
expect(queryByText('Add new')).toBeNull();
});
});
describe('keyExtractor', () => {
it('should use default string for undefined address', () => {
expect(keyExtractor({ account: accounts[0], address: undefined })).toBe(
'btc1_address_undefined',
);
});
it('should use address string for set address', () => {
expect(
keyExtractor({ account: accounts[0], address: { address: 'ADDRESS1' } as Address }),
).toBe('btc1_ADDRESS1');
});
});
});

View File

@@ -1,28 +1,28 @@
import { act, fireEvent, renderWithBasicProvider } from '@suite-native/test-utils';
import { TradeAccountsListFooter, TradeAccountsListFooterProps } from '../TradeAccountsListFooter';
import { AccountsListFooter, AccountsListFooterProps } from '../AccountsListFooter';
describe('TradeAccountsListFooter', () => {
const renderTradeAccountsListFooter = (props: Partial<TradeAccountsListFooterProps>) =>
describe('AccountsListFooter', () => {
const renderAccountsListFooter = (props: Partial<AccountsListFooterProps>) =>
renderWithBasicProvider(
<TradeAccountsListFooter hasTextualDivider onAddAccountTap={jest.fn()} {...props} />,
<AccountsListFooter hasTextualDivider onAddAccountTap={jest.fn()} {...props} />,
);
it('should not render "OR" when hasTextualDivider props is false', () => {
const { queryByText } = renderTradeAccountsListFooter({ hasTextualDivider: false });
const { queryByText } = renderAccountsListFooter({ hasTextualDivider: false });
expect(queryByText('OR')).toBeNull();
});
it('should render "OR" when hasTextualDivider props is true', () => {
const { getByText } = renderTradeAccountsListFooter({ hasTextualDivider: true });
const { getByText } = renderAccountsListFooter({ hasTextualDivider: true });
expect(getByText('OR')).toBeDefined();
});
it('should call onAddAccountTap callback on "Add new" button press', () => {
const onAddAccountTap = jest.fn();
const { getByText } = renderTradeAccountsListFooter({ onAddAccountTap });
const { getByText } = renderAccountsListFooter({ onAddAccountTap });
act(() => {
fireEvent.press(getByText('Add new'));

View File

@@ -3,6 +3,7 @@ import { Account } from '@suite-common/wallet-types';
import { PreloadedState, renderHookWithStoreProviderAsync } from '@suite-native/test-utils';
import { StaticSessionId } from '@trezor/connect';
import accounts from '../../__fixtures__/accounts.json';
import { ReceiveAccountsListMode, useReceiveAccountsListData } from '../useReceiveAccountsListData';
describe('useReceiveAccountsListData', () => {
@@ -14,42 +15,7 @@ describe('useReceiveAccountsListData', () => {
},
},
},
wallet: {
accounts: [
{
symbol: 'btc',
accountLabel: 'BTC Account #1',
deviceState: 'staticSessionId',
key: 'btc1',
addresses: {
used: [{ address: 'USED1' }, { address: 'USED2' }],
change: [{ address: 'CHANGE1' }],
unused: [{ address: 'UNUSED1' }, { address: 'UNUSED2' }],
},
visible: true,
},
{
symbol: 'btc',
accountLabel: 'BTC Account #2',
deviceState: 'staticSessionId',
key: 'btc2',
addresses: {
used: [],
change: [],
unused: [],
},
visible: true,
},
{
symbol: 'eth',
accountLabel: 'ETH Account #1',
deviceState: 'staticSessionId',
addresses: undefined,
key: 'eth1',
visible: true,
},
] as unknown as Account[],
},
wallet: { accounts: accounts as Account[] },
};
const renderUseReceiveAccountsListDataHook = (
@@ -104,7 +70,10 @@ describe('useReceiveAccountsListData', () => {
{
key: '',
label: '',
data: [{ account: expect.objectContaining({ key: 'eth1' }) }],
data: [
{ account: expect.objectContaining({ key: 'eth1' }) },
{ account: expect.objectContaining({ key: 'eth2' }) },
],
},
]);
});
@@ -149,7 +118,7 @@ describe('useReceiveAccountsListData', () => {
data: [
{
account: expect.objectContaining({ key: 'btc1' }),
address: { address: 'UNUSED1' },
address: { address: 'UNUSED1', path: 'path_UNUSED1' },
},
],
},
@@ -159,11 +128,11 @@ describe('useReceiveAccountsListData', () => {
data: [
{
account: expect.objectContaining({ key: 'btc1' }),
address: { address: 'USED1' },
address: { address: 'USED1', balance: '10000000', path: 'path_USED1' },
},
{
account: expect.objectContaining({ key: 'btc1' }),
address: { address: 'USED2' },
address: { address: 'USED2', balance: '20000000', path: 'path_USED2' },
},
],
},

View File

@@ -17,7 +17,7 @@ import {
TradingStackRoutes,
} from '@suite-native/navigation';
import { TradeAccountsList } from '../components/general/AccountSheet/TradeAccountsList';
import { AccountsList } from '../components/general/AccountSheet/AccountsList';
import { ReceiveAccountsListMode } from '../hooks/useReceiveAccountsListData';
import { selectBuySelectedReceiveAccount } from '../tradingSlice';
@@ -56,11 +56,11 @@ export const ReceiveAccountsPickerScreen = () => {
return (
<Screen header={<ScreenHeader content={title} closeActionType="close" />}>
<TradeAccountsList
<AccountsList
symbol={symbol}
pickerMode={pickerMode}
onAddAccountTap={handleAddAccount}
setPickerMode={setPickerMode}
onSetPickerMode={setPickerMode}
/>
<AccountTypeDecisionBottomSheet
coinName={symbol}