Skip to content

Commit

Permalink
feat(TokenEnterAmount): add new component to Swap flow (#6247)
Browse files Browse the repository at this point in the history
Final PR for new Enter Amount component. This page implements a 2nd
version of SwapScreen that uses new Enter Amount component. It is hidden
behind the feature flag to mitigate the risks of such a big rework.

`SwapScreenV2.test.tsx` file is an exact copy of the existing
`SwapScreen.test.tsx`. It seems like a huge addition (2k lines) BUT it
is a 99% copy-paste just because the new flow is implemented as a new
component. The only changes in that file are `testID` props and some
translations excerpts. To make life A LOT easier in reviewing this
specific file, I suggest doing the following in VSCode to only see the
changed lines:
- Right click `SwapScreen.test.tsx` and click "Select for Compare"
- Right click `SwapScreenV2.test.tsx` and click "Compare with Selected"

Details of the implementation for clarity:
- The correct functioning of the new component has been ensured by
implementing it to pass all the existing tests from
`SwapScreen.test.tsx`.
- Removed local Redux slice. As a new Enter Amount component is using
`useState` and it would be a mess to keep multiple `useState`s and Redux
slice in sync - all the other fields from the slice were moved to the
corresponding `useState`s.
- The business logic of the component is intact. Only the state
implementation (useState instead of Redux slice) has changed to follow
the existing logic therefore some functions look different but they do
identical things in terms of business logic.
- Code has been order in the following order: 
  1. variables
  2. derived variables (`useMemo`)
  3. `useEffect`s
  4. functions


### Test plan

<video width="400"
src="https://github.com/user-attachments/assets/51811584-1eec-404d-9ec3-b67d5aaab79d"></video>



### Related issues
Relates to RET-1208

### Backwards compatibility
Yes

### Network scalability

If a new NetworkId and/or Network are added in the future, the changes
in this PR will:

- [x] Continue to work without code changes, OR trigger a compilation
error (guaranteeing we find it when a new network is added)
  • Loading branch information
sviderock authored Jan 6, 2025
1 parent 9694bea commit 9815138
Show file tree
Hide file tree
Showing 11 changed files with 3,300 additions and 107 deletions.
6 changes: 4 additions & 2 deletions locales/base/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -2825,6 +2825,8 @@
"tokenEnterAmount": {
"availableBalance": "Available: <0></0>",
"selectToken": "Select token",
"fiatPriceUnavailable": "Price unavailable"
}
"fiatPriceUnavailable": "Price unavailable",
"tokenDescription": "{{tokenName}} on {{tokenNetwork}}"
},
"on": "on"
}
8 changes: 5 additions & 3 deletions src/components/TokenEnterAmount.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,9 @@ describe('TokenEnterAmount', () => {
</Provider>
)

expect(getByTestId('TokenEnterAmount/TokenName')).toHaveTextContent('CELO on Celo Alfajores')
expect(getByTestId('TokenEnterAmount/TokenName')).toHaveTextContent(
'tokenEnterAmount.tokenDescription, {"tokenName":"CELO","tokenNetwork":"Celo Alfajores"}'
)
expect(getByTestId('TokenEnterAmount/SwitchTokens')).toBeTruthy()
expect(getByTestId('TokenEnterAmount/TokenSelect')).toBeTruthy()
expect(getByTestId('TokenEnterAmount/TokenBalance')).toHaveTextContent(
Expand Down Expand Up @@ -403,17 +405,17 @@ describe('TokenEnterAmount', () => {
<Provider store={store}>
<TokenEnterAmount
{...defaultProps}
editable={false}
inputValue="1234.5678"
tokenAmount="1,234.5678"
localAmount="$123.57"
amountType="token"
onInputChange={undefined}
/>
</Provider>
)
const input = getByTestId('TokenEnterAmount/TokenAmountInput')

expect(input.props.editable).toBe(false)
expect(input).toBeDisabled()
})

it('shows unavailable fiat price message when priceUsd is undefined', () => {
Expand Down
227 changes: 138 additions & 89 deletions src/components/TokenEnterAmount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
View,
} from 'react-native'
import { getNumberFormatSettings } from 'react-native-localize'
import SkeletonPlaceholder from 'react-native-skeleton-placeholder'
import TextInput from 'src/components/TextInput'
import TokenDisplay from 'src/components/TokenDisplay'
import TokenIcon, { IconSize } from 'src/components/TokenIcon'
Expand Down Expand Up @@ -105,12 +106,10 @@ export function getDisplayLocalAmount(
* variables and handlers that manage "enter amount" functionality, including rate calculations.
*/
export function useEnterAmount(props: {
token: TokenBalance
token: TokenBalance | undefined
inputRef: React.RefObject<RNTextInput>
onHandleAmountInputChange?(amount: string): void
}) {
const { decimalSeparator } = getNumberFormatSettings()

/**
* This field is formatted for processing purpose. It is a lot easier to process a number formatted
* in a single format, rather than writing different logic for various combinations of decimal
Expand Down Expand Up @@ -139,6 +138,15 @@ export function useEnterAmount(props: {
* - `local.displayAmount` -> `localDisplayAmount`
*/
const processedAmounts = useMemo(() => {
const { decimalSeparator } = getNumberFormatSettings()

if (!props.token) {
return {
token: { bignum: null, displayAmount: '' },
local: { bignum: null, displayAmount: '' },
}
}

if (amountType === 'token') {
const parsedTokenAmount = amount === '' ? null : parseInputAmount(amount)

Expand Down Expand Up @@ -201,9 +209,11 @@ export function useEnterAmount(props: {
displayAmount: getDisplayLocalAmount(parsedLocalAmount, localCurrencySymbol),
},
}
}, [amount, amountType, localCurrencySymbol])
}, [amount, amountType, localCurrencySymbol, usdToLocalRate, props.token])

function handleToggleAmountType() {
if (!props.token) return

const newAmountType = amountType === 'local' ? 'token' : 'local'
setAmountType(newAmountType)
setAmount(
Expand All @@ -218,7 +228,7 @@ export function useEnterAmount(props: {
value = unformatNumberForProcessing(value)
value = value.startsWith('.') ? `0${value}` : value

if (!value) {
if (!value || !props.token) {
setAmount('')
props.onHandleAmountInputChange?.('')
return
Expand All @@ -231,17 +241,30 @@ export function useEnterAmount(props: {
`^(?:\\d+[.]?\\d{0,${props.token.decimals}}|[.]\\d{0,${props.token.decimals}}|[.])$`
)

if (
(amountType === 'token' && value.match(tokenAmountRegex)) ||
(amountType === 'local' && value.match(localAmountRegex))
) {
const isValidTokenAmount = amountType === 'token' && value.match(tokenAmountRegex)
const isValidLocalAmount = amountType === 'local' && value.match(localAmountRegex)
if (isValidTokenAmount || isValidLocalAmount) {
setAmount(value)
props.onHandleAmountInputChange?.(value)
return
}
}

function replaceAmount(value: string) {
if (!props.token) return

if (value === '') {
setAmount('')
return
}

const rawValue = unformatNumberForProcessing(value)
const roundedAmount = new BigNumber(rawValue).decimalPlaces(props.token?.decimals).toString()
setAmount(roundedAmount)
}

function handleSelectPercentageAmount(percentage: number) {
if (!props.token) return
if (percentage <= 0 || percentage > 1) return

const percentageAmount = props.token.balance.multipliedBy(percentage)
Expand All @@ -265,7 +288,7 @@ export function useEnterAmount(props: {
amount,
amountType,
processedAmounts,
replaceAmount: setAmount,
replaceAmount,
handleToggleAmountType,
handleAmountInputChange,
handleSelectPercentageAmount,
Expand All @@ -281,30 +304,31 @@ export default function TokenEnterAmount({
inputRef,
inputStyle,
autoFocus,
editable = true,
testID,
onInputChange,
toggleAmountType,
onOpenTokenPicker,
loading,
}: {
token?: TokenBalance
inputValue: string
tokenAmount: string
localAmount: string
amountType: AmountEnteredIn
inputRef: React.MutableRefObject<RNTextInput | null>
loading?: boolean
inputStyle?: StyleProp<TextStyle>
autoFocus?: boolean
editable?: boolean
testID?: string
onInputChange(value: string): void
onInputChange?(value: string): void
toggleAmountType?(): void
onOpenTokenPicker?(): void
}) {
const { t } = useTranslation()
// the startPosition and inputRef variables exist to ensure TextInput
// displays the start of the value for long values on Android
// https://github.com/facebook/react-native/issues/14845
/**
* startPosition and inputRef variables exist to ensure TextInput displays the start of the value
* for long values on Android: https://github.com/facebook/react-native/issues/14845
*/
const [startPosition, setStartPosition] = useState<number | undefined>(0)
// this should never be null, just adding a default to make TS happy
const localCurrencySymbol = useSelector(getLocalCurrencySymbol) ?? LocalCurrencySymbol.USD
Expand Down Expand Up @@ -359,7 +383,10 @@ export default function TokenEnterAmount({

<View style={styles.tokenNameAndAvailable}>
<Text style={styles.tokenName} testID={`${testID}/TokenName`}>
{token.symbol} on {NETWORK_NAMES[token.networkId]}
{t('tokenEnterAmount.tokenDescription', {
tokenName: token.symbol,
tokenNetwork: NETWORK_NAMES[token.networkId],
})}
</Text>
<Text style={styles.tokenBalance} testID={`${testID}/TokenBalance`}>
<Trans i18nKey="tokenEnterAmount.availableBalance">
Expand All @@ -381,81 +408,95 @@ export default function TokenEnterAmount({
</View>
</Touchable>
{token && (
<View
style={[
styles.rowContainer,
{ borderTopLeftRadius: 0, borderTopRightRadius: 0, borderTopWidth: 0 },
]}
>
<TextInput
forwardedRef={inputRef}
onChangeText={(value) => {
handleSetStartPosition(undefined)
onInputChange(value)
}}
value={formattedInputValue}
placeholderTextColor={Colors.gray3}
placeholder={amountType === 'token' ? placeholder.token : placeholder.local}
keyboardType="decimal-pad"
// Work around for RN issue with Samsung keyboards
// https://github.com/facebook/react-native/issues/22005
autoCapitalize="words"
autoFocus={autoFocus}
// unset lineHeight to allow ellipsis on long inputs on iOS. For
// android, ellipses doesn't work and unsetting line height causes
// height changes when amount is entered
inputStyle={[
styles.primaryAmountText,
inputStyle,
Platform.select({ ios: { lineHeight: undefined } }),
<View>
<View
style={[
styles.rowContainer,
{ borderTopLeftRadius: 0, borderTopRightRadius: 0, borderTopWidth: 0 },
]}
onBlur={() => {
handleSetStartPosition(0)
}}
onFocus={() => {
const withCurrency = amountType === 'local' ? 1 : 0
handleSetStartPosition((inputValue?.length ?? 0) + withCurrency)
}}
onSelectionChange={() => {
handleSetStartPosition(undefined)
}}
selection={
Platform.OS === 'android' && typeof startPosition === 'number'
? { start: startPosition }
: undefined
}
showClearButton={false}
editable={editable}
testID={`${testID}/TokenAmountInput`}
/>

{token.priceUsd ? (
<>
{toggleAmountType && (
<Touchable
onPress={toggleAmountType}
style={styles.swapArrowContainer}
testID={`${testID}/SwitchTokens`}
hitSlop={variables.iconHitslop}
>
<TextInput
forwardedRef={inputRef}
onChangeText={(value) => {
handleSetStartPosition(undefined)
onInputChange?.(value)
}}
value={formattedInputValue}
placeholderTextColor={Colors.gray3}
placeholder={amountType === 'token' ? placeholder.token : placeholder.local}
keyboardType="decimal-pad"
// Work around for RN issue with Samsung keyboards
// https://github.com/facebook/react-native/issues/22005
autoCapitalize="words"
autoFocus={autoFocus}
// unset lineHeight to allow ellipsis on long inputs on iOS. For
// android, ellipses doesn't work and unsetting line height causes
// height changes when amount is entered
inputStyle={[
styles.primaryAmountText,
inputStyle,
Platform.select({ ios: { lineHeight: undefined } }),
]}
onBlur={() => {
handleSetStartPosition(0)
}}
onFocus={() => {
const withCurrency = amountType === 'local' ? 1 : 0
handleSetStartPosition((inputValue?.length ?? 0) + withCurrency)
}}
onSelectionChange={() => {
handleSetStartPosition(undefined)
}}
selection={
Platform.OS === 'android' && typeof startPosition === 'number'
? { start: startPosition }
: undefined
}
showClearButton={false}
editable={!!onInputChange}
testID={`${testID}/TokenAmountInput`}
/>

{token.priceUsd ? (
<>
{toggleAmountType && (
<Touchable
onPress={toggleAmountType}
style={styles.swapArrowContainer}
testID={`${testID}/SwitchTokens`}
hitSlop={variables.iconHitslop}
>
<SwapArrows color={Colors.gray3} size={24} />
</Touchable>
)}

<Text
numberOfLines={1}
style={[styles.secondaryAmountText, { flex: 0, textAlign: 'right' }]}
testID={`${testID}/ExchangeAmount`}
>
<SwapArrows color={Colors.gray3} size={24} />
</Touchable>
)}

<Text
numberOfLines={1}
style={[styles.secondaryAmountText, { flex: 0, textAlign: 'right' }]}
testID={`${testID}/ExchangeAmount`}
>
{amountType === 'token'
? `${APPROX_SYMBOL} ${localAmount ? localAmount : placeholder.local}`
: `${APPROX_SYMBOL} ${tokenAmount ? tokenAmount : placeholder.token}`}
{amountType === 'token'
? `${APPROX_SYMBOL} ${localAmount ? localAmount : placeholder.local}`
: `${APPROX_SYMBOL} ${tokenAmount ? tokenAmount : placeholder.token}`}
</Text>
</>
) : (
<Text style={styles.secondaryAmountText}>
{t('tokenEnterAmount.fiatPriceUnavailable')}
</Text>
</>
) : (
<Text style={styles.secondaryAmountText}>
{t('tokenEnterAmount.fiatPriceUnavailable')}
</Text>
)}
</View>

{loading && (
<View testID={`${testID}/Loader`} style={styles.loader}>
<SkeletonPlaceholder
borderRadius={100} // ensure rounded corners with font scaling
backgroundColor={Colors.gray2}
highlightColor={Colors.white}
>
<View style={{ height: '100%', width: '100%' }} />
</SkeletonPlaceholder>
</View>
)}
</View>
)}
Expand Down Expand Up @@ -511,4 +552,12 @@ const styles = StyleSheet.create({
swapArrowContainer: {
transform: [{ rotate: '90deg' }],
},
loader: {
padding: Spacing.Regular16,
position: 'absolute',
top: 0,
left: 0,
height: '100%',
width: '100%',
},
})
Loading

0 comments on commit 9815138

Please sign in to comment.