Skip to content

Commit

Permalink
feat(send): add new enter amount screen (#4370)
Browse files Browse the repository at this point in the history
### Description

Adds new enter amount screen for multichain sends. Also 
- refactors assets screen and selectors to re-use getting tokens with
balances and zero balance tokens.
- adds a new state to send to track last sent tokenId, which will be the
default token on the enter amount screen
- removes lastUsedCurrency and associated actions and reducers which was
not used anywhere

### Test plan

Unit tests, manual



https://github.com/valora-inc/wallet/assets/5062591/bcb10854-7938-440b-a16a-d7302abd378b



### Related issues

- Fixes ACT-941

### Backwards compatibility

Yes

---------

Co-authored-by: Tom McGuire <[email protected]>
  • Loading branch information
satish-ravi and MuckT authored Oct 25, 2023
1 parent a1f9c33 commit c2d3642
Show file tree
Hide file tree
Showing 21 changed files with 743 additions and 64 deletions.
5 changes: 5 additions & 0 deletions locales/base/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1859,5 +1859,10 @@
"add": "Buy tokens using one of our trusted providers",
"withdraw": "Transfer tokens to a bank account, mobile money, gift card etc."
}
},
"sendEnterAmountScreen": {
"title": "Enter Amount",
"selectToken": "Select a Token",
"networkFee": "{{networkName}} Network Fee"
}
}
2 changes: 2 additions & 0 deletions src/analytics/Properties.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,8 @@ interface SendEventsProperties {
underlyingTokenSymbol: string
underlyingAmount: string | null
amountInUsd: string | null
tokenId: string | null
networkId: string | null
}
[SendEvents.send_confirm_back]: undefined
[SendEvents.send_confirm_send]:
Expand Down
6 changes: 6 additions & 0 deletions src/navigator/Navigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ import { store } from 'src/redux/store'
import Send from 'src/send/Send'
import SendAmount from 'src/send/SendAmount'
import SendConfirmation, { sendConfirmationScreenNavOptions } from 'src/send/SendConfirmation'
import SendEnterAmount from 'src/send/SendEnterAmount'
import ValidateRecipientAccount, {
validateRecipientAccountScreenNavOptions,
} from 'src/send/ValidateRecipientAccount'
Expand Down Expand Up @@ -254,6 +255,11 @@ const sendScreens = (Navigator: typeof Stack) => (
component={ReclaimPaymentConfirmationScreen}
options={headerWithBackButton}
/>
<Navigator.Screen
name={Screens.SendEnterAmount}
component={SendEnterAmount}
options={noHeader}
/>
</>
)

Expand Down
1 change: 1 addition & 0 deletions src/navigator/Screens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export enum Screens {
SendAmount = 'SendAmount',
SendConfirmation = 'SendConfirmation',
SendConfirmationModal = 'SendConfirmationModal',
SendEnterAmount = 'SendEnterAmount',
Settings = 'Settings',
SetUpKeylessBackup = 'SetUpKeylessBackup',
SignInWithEmail = 'SignInWithEmail',
Expand Down
7 changes: 7 additions & 0 deletions src/navigator/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,13 @@ export type StackParamList = {
}
[Screens.SendConfirmation]: SendConfirmationParams
[Screens.SendConfirmationModal]: SendConfirmationParams
[Screens.SendEnterAmount]: {
recipient: Recipient
isFromScan: boolean
origin: SendOrigin
forceTokenId?: boolean
defaultTokenIdOverride?: string
}
[Screens.Settings]: { promptConfirmRemovalModal?: boolean } | undefined
[Screens.SetUpKeylessBackup]: undefined
[Screens.SignInWithEmail]: {
Expand Down
4 changes: 4 additions & 0 deletions src/redux/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1393,4 +1393,8 @@ export const migrations = {
}),
},
}),
163: (state: any) => ({
...state,
send: { ..._.omit(state.send, 'lastUsedCurrency'), lastUsedTokenId: undefined },
}),
}
4 changes: 2 additions & 2 deletions src/redux/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ describe('store state', () => {
{
"_persist": {
"rehydrated": true,
"version": 162,
"version": 163,
},
"account": {
"acceptedTerms": false,
Expand Down Expand Up @@ -312,7 +312,7 @@ describe('store state', () => {
"inviteRewardWeeklyLimit": 20,
"inviteRewardsVersion": "none",
"isSending": false,
"lastUsedCurrency": "cUSD",
"lastUsedTokenId": undefined,
"recentPayments": [],
"recentRecipients": [],
"showSendToAddressWarning": true,
Expand Down
2 changes: 1 addition & 1 deletion src/redux/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const persistConfig: PersistConfig<RootState> = {
key: 'root',
// default is -1, increment as we make migrations
// See https://github.com/valora-inc/wallet/tree/main/WALLET.md#redux-state-migration
version: 162,
version: 163,
keyPrefix: `reduxStore-`, // the redux-persist default is `persist:` which doesn't work with some file systems.
storage: FSStorage(),
blacklist: ['networkInfo', 'alert', 'imports', 'keylessBackup'],
Expand Down
2 changes: 1 addition & 1 deletion src/send/Send.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ describe('Send', () => {
)

fireEvent.press(getAllByTestId('RecipientItem')[0])
expect(navigate).toHaveBeenCalledWith(Screens.SendAmount, {
expect(navigate).toHaveBeenCalledWith(Screens.SendEnterAmount, {
isFromScan: false,
origin: SendOrigin.AppSendFlow,
recipient: expect.objectContaining(mockRecipient),
Expand Down
4 changes: 1 addition & 3 deletions src/send/Send.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,7 @@ function Send({ route }: Props) {
}

if (getFeatureGate(StatsigFeatureGates.USE_NEW_SEND_FLOW)) {
// TODO (ACT-945): Use New Send Flow
// Currently navigates to old send flow without bottom sheet
navigate(Screens.SendAmount, {
navigate(Screens.SendEnterAmount, {
isFromScan: false,
defaultTokenIdOverride,
forceTokenId,
Expand Down
2 changes: 2 additions & 0 deletions src/send/SendAmount/useTransactionCallbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ function useTransactionCallbacks({
underlyingTokenSymbol: tokenInfo?.symbol ?? '',
underlyingAmount: tokenAmount.toString(),
amountInUsd: usdAmount?.toString() ?? null,
tokenId: tokenInfo?.tokenId ?? null,
networkId: tokenInfo?.networkId ?? null,
}
}, [
origin,
Expand Down
238 changes: 238 additions & 0 deletions src/send/SendEnterAmount.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { fireEvent, render, waitFor } from '@testing-library/react-native'
import BigNumber from 'bignumber.js'
import React from 'react'
import { Provider } from 'react-redux'
import { SendEvents } from 'src/analytics/Events'
import ValoraAnalytics from 'src/analytics/ValoraAnalytics'
import { SendOrigin } from 'src/analytics/types'
import { useMaxSendAmount } from 'src/fees/hooks'
import { RecipientType } from 'src/recipients/recipient'
import SendEnterAmount from 'src/send/SendEnterAmount'
import { getSupportedNetworkIdsForSend } from 'src/tokens/utils'
import { NetworkId } from 'src/transactions/types'
import MockedNavigator from 'test/MockedNavigator'
import { createMockStore } from 'test/utils'
import {
mockCeloTokenId,
mockEthTokenId,
mockPoofAddress,
mockPoofTokenId,
mockTokenBalances,
} from 'test/values'

jest.mock('src/tokens/utils', () => ({
...jest.requireActual('src/tokens/utils'),
getSupportedNetworkIdsForSend: jest.fn(),
}))

jest.mock('src/fees/hooks')

const mockStore = {
tokens: {
tokenBalances: {
...mockTokenBalances,
[mockEthTokenId]: {
tokenId: mockEthTokenId,
balance: '0',
priceUsd: '5',
networkId: NetworkId['ethereum-sepolia'],
showZeroBalance: true,
isNative: true,
symbol: 'ETH',
priceFetchedAt: Date.now(),
name: 'Ether',
},
},
},
}

const params = {
origin: SendOrigin.AppSendFlow,
recipient: {
recipientType: RecipientType.Address,
address: '0x123',
},
isFromScan: false,
}

describe('SendEnterAmount', () => {
beforeEach(() => {
jest
.mocked(getSupportedNetworkIdsForSend)
.mockReturnValue([NetworkId['celo-alfajores'], NetworkId['ethereum-sepolia']])
jest.clearAllMocks()
})

it('renders components with picker using token with highest balance if no override or last used token exists', () => {
const store = createMockStore(mockStore)

const { getByTestId, getByText } = render(
<Provider store={store}>
<MockedNavigator component={SendEnterAmount} params={params} />
</Provider>
)

expect(getByTestId('SendEnterAmount/Input')).toBeTruthy()
expect(getByTestId('SendEnterAmount/LocalAmount')).toBeTruthy()
expect(getByTestId('SendEnterAmount/LocalAmount')).toHaveTextContent('₱0.00')
expect(getByTestId('SendEnterAmount/Max')).toBeTruthy()
expect(getByTestId('SendEnterAmount/TokenSelect')).toBeTruthy()
expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('POOF')
expect(
getByText('sendEnterAmountScreen.networkFee, {"networkName":"Celo Alfajores"}')
).toBeTruthy()
})

it('renders components with picker using last used token', () => {
const store = createMockStore({ ...mockStore, send: { lastUsedTokenId: mockEthTokenId } })

const { getByTestId, getByText } = render(
<Provider store={store}>
<MockedNavigator component={SendEnterAmount} params={params} />
</Provider>
)

expect(getByTestId('SendEnterAmount/Input')).toBeTruthy()
expect(getByTestId('SendEnterAmount/LocalAmount')).toBeTruthy()
expect(getByTestId('SendEnterAmount/LocalAmount')).toHaveTextContent('₱0.00')
expect(getByTestId('SendEnterAmount/Max')).toBeTruthy()
expect(getByTestId('SendEnterAmount/TokenSelect')).toBeTruthy()
expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('ETH')
expect(
getByText('sendEnterAmountScreen.networkFee, {"networkName":"Ethereum Sepolia"}')
).toBeTruthy()
})

it('renders components with picker using token override', () => {
const store = createMockStore({ ...mockStore, send: { lastUsedTokenId: mockEthTokenId } })

const { getByTestId, getByText } = render(
<Provider store={store}>
<MockedNavigator
component={SendEnterAmount}
params={{ ...params, defaultTokenIdOverride: mockCeloTokenId }}
/>
</Provider>
)

expect(getByTestId('SendEnterAmount/Input')).toBeTruthy()
expect(getByTestId('SendEnterAmount/LocalAmount')).toBeTruthy()
expect(getByTestId('SendEnterAmount/LocalAmount')).toHaveTextContent('₱0.00')
expect(getByTestId('SendEnterAmount/Max')).toBeTruthy()
expect(getByTestId('SendEnterAmount/TokenSelect')).toBeTruthy()
expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('CELO')
expect(
getByText('sendEnterAmountScreen.networkFee, {"networkName":"Celo Alfajores"}')
).toBeTruthy()
})

it('renders components with picker using token with highest balance if default override is not supported for sends', () => {
jest.mocked(getSupportedNetworkIdsForSend).mockReturnValue([NetworkId['celo-alfajores']])
const store = createMockStore(mockStore)

const { getByTestId, getByText } = render(
<Provider store={store}>
<MockedNavigator
component={SendEnterAmount}
params={{ ...params, defaultTokenIdOverride: mockEthTokenId }}
/>
</Provider>
)

expect(getByTestId('SendEnterAmount/Input')).toBeTruthy()
expect(getByTestId('SendEnterAmount/LocalAmount')).toBeTruthy()
expect(getByTestId('SendEnterAmount/LocalAmount')).toHaveTextContent('₱0.00')
expect(getByTestId('SendEnterAmount/Max')).toBeTruthy()
expect(getByTestId('SendEnterAmount/TokenSelect')).toBeTruthy()
expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('POOF')
expect(
getByText('sendEnterAmountScreen.networkFee, {"networkName":"Celo Alfajores"}')
).toBeTruthy()
})

it('entering amount updates local amount', () => {
const store = createMockStore(mockStore)

const { getByTestId } = render(
<Provider store={store}>
<MockedNavigator component={SendEnterAmount} params={params} />
</Provider>
)

fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '10')
expect(getByTestId('SendEnterAmount/LocalAmount')).toHaveTextContent('₱1.33')
})

it('only allows numeric input', () => {
const store = createMockStore(mockStore)

const { getByTestId } = render(
<Provider store={store}>
<MockedNavigator component={SendEnterAmount} params={params} />
</Provider>
)

fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '10.5')
expect(getByTestId('SendEnterAmount/Input').props.value).toBe('10.5')
fireEvent.changeText(getByTestId('SendEnterAmount/Input'), '10.5.1')
expect(getByTestId('SendEnterAmount/Input').props.value).toBe('10.5')
fireEvent.changeText(getByTestId('SendEnterAmount/Input'), 'abc')
expect(getByTestId('SendEnterAmount/Input').props.value).toBe('10.5')
})

it('selecting new token updates token and network info', async () => {
const store = createMockStore(mockStore)

const { getByTestId, getByText } = render(
<Provider store={store}>
<MockedNavigator component={SendEnterAmount} params={params} />
</Provider>
)

expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('POOF')
expect(
getByText('sendEnterAmountScreen.networkFee, {"networkName":"Celo Alfajores"}')
).toBeTruthy()
fireEvent.press(getByTestId('SendEnterAmount/TokenSelect'))
await waitFor(() => expect(getByText('Ether')).toBeTruthy())
fireEvent.press(getByText('Ether'))
expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('ETH')
expect(
getByText('sendEnterAmountScreen.networkFee, {"networkName":"Ethereum Sepolia"}')
).toBeTruthy()
expect(ValoraAnalytics.track).toHaveBeenCalledTimes(2)
expect(ValoraAnalytics.track).toHaveBeenCalledWith(SendEvents.token_dropdown_opened, {
currentNetworkId: NetworkId['celo-alfajores'],
currentTokenAddress: mockPoofAddress,
currentTokenId: mockPoofTokenId,
})
expect(ValoraAnalytics.track).toHaveBeenCalledWith(SendEvents.token_selected, {
networkId: NetworkId['ethereum-sepolia'],
tokenAddress: undefined,
tokenId: mockEthTokenId,
origin: 'Send',
})
// TODO(ACT-958): assert fees
})

it('pressing max fills in max available amount', () => {
jest.mocked(useMaxSendAmount).mockReturnValue(new BigNumber(5))
const store = createMockStore(mockStore)

const { getByTestId } = render(
<Provider store={store}>
<MockedNavigator component={SendEnterAmount} params={params} />
</Provider>
)

fireEvent.press(getByTestId('SendEnterAmount/Max'))
expect(getByTestId('SendEnterAmount/Input').props.value).toBe('5')
expect(getByTestId('SendEnterAmount/LocalAmount')).toHaveTextContent('₱0.67')
expect(ValoraAnalytics.track).toHaveBeenCalledTimes(1)
expect(ValoraAnalytics.track).toHaveBeenCalledWith(SendEvents.max_pressed, {
networkId: NetworkId['celo-alfajores'],
tokenAddress: mockPoofAddress,
tokenId: mockPoofTokenId,
})
})
})
Loading

0 comments on commit c2d3642

Please sign in to comment.