Skip to content

Commit

Permalink
feat: add validation & error handling ux improvements
Browse files Browse the repository at this point in the history
  • Loading branch information
denviljclarke committed Nov 22, 2024
1 parent 249dd38 commit 5ee62d7
Show file tree
Hide file tree
Showing 5 changed files with 125 additions and 44 deletions.
2 changes: 1 addition & 1 deletion src/config/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const STALE_BLOCK_TIME = 25000 // 25 seconds
export const EXCHANGE_RATE_STALE_TIME = 5000 // 5 second
export const BALANCE_STALE_TIME = 5000 // 5 seconds
export const STATUS_POLLER_DELAY = 5000 // 5 seconds
export const SWAP_QUOTE_REFETCH_INTERVAL = 5000 // 5 seconds
export const SWAP_QUOTE_REFETCH_INTERVAL = 7000 // 5 seconds
export const SIGN_OPERATION_TIMEOUT = 90000 // 90 seconds

export const STALE_TOKEN_PRICE_TIME = 900000 // 15 minutes
Expand Down
108 changes: 74 additions & 34 deletions src/features/swap/SwapForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useConnectModal } from '@rainbow-me/rainbowkit'
import { Form, Formik, useFormikContext } from 'formik'
import { ReactNode, SVGProps, useEffect, useMemo } from 'react'
import { ReactNode, useCallback, useEffect, useMemo } from 'react'
import { toast } from 'react-toastify'
import { Spinner } from 'src/components/animation/Spinner'
import { Button3D } from 'src/components/buttons/3DButton'
Expand All @@ -26,6 +26,7 @@ import { useFormValidator } from 'src/features/swap/useFormValidator'
import { useSwapQuote } from 'src/features/swap/useSwapQuote'
import { FloatingBox } from 'src/layout/FloatingBox'
import { fromWei, fromWeiRounded, toSignificant } from 'src/utils/amount'
import { debounce } from 'src/utils/debounce'
import { logger } from 'src/utils/logger'
import { escapeRegExp, inputRegex } from 'src/utils/string'
import { useAccount, useNetwork, useSwitchNetwork } from 'wagmi'
Expand Down Expand Up @@ -69,11 +70,15 @@ function SwapForm() {
}
const validateForm = useFormValidator(balances)

const debouncedValidate = debounce(async (values: SwapFormValues) => {
return await validateForm(values)
}, 750)

return (
<Formik<SwapFormValues>
initialValues={initialValues}
onSubmit={onSubmit}
validate={validateForm}
validate={debouncedValidate}
validateOnChange={true}
validateOnBlur={false}
>
Expand All @@ -96,7 +101,7 @@ function SwapFormInputs({ balances }: { balances: AccountBalances }) {
return chain ? getTokenOptionsByChainId(chain?.id) : getTokenOptionsByChainId(Celo.chainId)
}, [chain])

const { values, setFieldValue } = useFormikContext<SwapFormValues>()
const { values, setFieldValue, setFieldError } = useFormikContext<SwapFormValues>()

const swappableTokenOptions = useMemo(() => {
return getSwappableTokenOptions(values.fromTokenId, chain ? chain?.id : Celo.chainId)
Expand All @@ -110,7 +115,7 @@ function SwapFormInputs({ balances }: { balances: AccountBalances }) {
if (values.direction === 'in' && quote) {
setFieldValue('quote', quote)
}
}, [quote, setFieldValue, values.direction])
}, [quote, setFieldError, setFieldValue, values.direction])

useEffect(() => {
if (chain && isConnected && !isSwappable(values.fromTokenId, values.toTokenId, chain?.id)) {
Expand All @@ -121,12 +126,17 @@ function SwapFormInputs({ balances }: { balances: AccountBalances }) {
}
}, [setFieldValue, chain, values, swappableTokenOptions, isConnected])

const roundedBalance = fromWeiRounded(balances[fromTokenId], Tokens[fromTokenId].decimals)
const roundedBalance = fromWeiRounded(
balances[fromTokenId as keyof AccountBalances],
Tokens[fromTokenId as TokenId].decimals
)
const isRoundedBalanceGreaterThanZero = Boolean(Number.parseInt(roundedBalance) > 0)
const onClickUseMax = () => {
const maxAmount = fromWei(balances[fromTokenId], Tokens[fromTokenId].decimals)
const maxAmount = fromWei(
balances[fromTokenId as keyof AccountBalances],
Tokens[fromTokenId as TokenId].decimals
)
setFieldValue('amount', maxAmount)

setFieldValue('direction', 'in')
if (fromTokenId === TokenId.CELO) {
toast.warn('Consider keeping some CELO for transaction fees')
Expand Down Expand Up @@ -283,12 +293,12 @@ function SubmitButton() {
const { switchNetworkAsync } = useSwitchNetwork()
const { openConnectModal } = useConnectModal()
const dispatch = useAppDispatch()
const { errors, touched } = useFormikContext<SwapFormValues>()
const { errors, touched, values, isValidating } = useFormikContext<SwapFormValues>()

const isAccountReady = address && isConnected
const isOnCelo = chains.some((chn) => chn.id === chain?.id)

const switchToNetwork = async () => {
const switchToNetwork = useCallback(async () => {
try {
if (!switchNetworkAsync) throw new Error('switchNetworkAsync undefined')
logger.debug('Resetting and switching to Celo')
Expand All @@ -301,40 +311,70 @@ function SubmitButton() {
logger.error('Error updating network', error)
toast.error('Could not switch network, does wallet support switching?')
}
}
}, [switchNetworkAsync, dispatch])

const error =
touched.amount && (errors.amount || errors.fromTokenId || errors.toTokenId || errors.slippage)
let text

if (error) {
text = error
} else if (!isAccountReady) {
text = 'Connect Wallet'
} else if (!isOnCelo) {
text = 'Switch to Celo Network'
} else {
text = 'Continue'
}
const amountWasModified = touched.amount || values.amount
const quoteLikelyStillLoading = useMemo(
() =>
values.amount && values.quote && values.quote === '0' && errors.amount === 'Amount Required',
[values.amount, values.quote, errors.amount]
)

const hasError = useMemo(
() =>
amountWasModified &&
!quoteLikelyStillLoading &&
(errors.amount || errors.quote || errors.fromTokenId || errors.toTokenId || errors.slippage),
[amountWasModified, quoteLikelyStillLoading, errors]
)

const type = isAccountReady ? 'submit' : 'button'
let onClick
const buttonText = useMemo(() => {
if (isValidating && (!errors.amount || !quoteLikelyStillLoading))
return (
<div
className="flex items-center justify-center
gap-2 h-6"
>
<span>Loading...</span>
</div>
)
if (hasError) {
const isLongError = typeof hasError === 'string' && hasError.length > 50
return isLongError ? 'Error' : hasError
}
if (!isAccountReady) return 'Connect Wallet'
if (!isOnCelo) return 'Switch to Celo Network'
return 'Continue'
}, [hasError, isValidating, errors.amount, quoteLikelyStillLoading, isAccountReady, isOnCelo])

const onClick = useMemo(() => {
if (!isAccountReady) return openConnectModal
if (!isOnCelo) return switchToNetwork
return undefined
}, [isAccountReady, isOnCelo, openConnectModal, switchToNetwork])

const buttonType = useMemo(
() => (isAccountReady && !hasError ? 'submit' : 'button'),
[isAccountReady, hasError]
)

if (!isAccountReady) {
onClick = openConnectModal
} else if (!isOnCelo) {
onClick = switchToNetwork
}
const isErrorState = useMemo(
() => hasError && (!isValidating || (!quoteLikelyStillLoading && !errors.amount)),
[hasError, isValidating, quoteLikelyStillLoading, errors.amount]
)

const showLongError = typeof error === 'string' && error?.length > 50
const showLongError = useMemo(
() => typeof hasError === 'string' && hasError?.length > 50,
[hasError]
)

return (
<div className="flex flex-col w-full items-center justify-center">
{showLongError ? (
<div className="bg-[#E14F4F] rounded-md text-white p-4 mb-6">{error}</div>
<div className="bg-[#E14F4F] rounded-md text-white p-4 mb-6">{hasError}</div>
) : null}
<Button3D fullWidth onClick={onClick} type={type} error={error ? true : false}>
{showLongError ? 'Error' : text}
<Button3D fullWidth onClick={onClick} type={buttonType} error={isErrorState}>
{buttonText}
</Button3D>
</div>
)
Expand Down
13 changes: 13 additions & 0 deletions src/features/swap/useFormValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,24 @@ export function useFormValidator(balances: AccountBalances) {
const tokenId = values.fromTokenId
const tokenBalance = balances[tokenId]
const weiAmount = toWei(parsedAmount, Tokens[values.fromTokenId].decimals)

if (weiAmount.gt(tokenBalance) && !areAmountsNearlyEqual(weiAmount, tokenBalance)) {
return { amount: 'Amount exceeds balance' }
}

const { exceeds, errorMsg } = await checkTradingLimits(values, chainId)
if (exceeds) return { amount: errorMsg }

if (values.amount && values.quote === '0')
return {
quote:
'Trading temporarily paused. ' +
`Unable to determine accurate ${Tokens[values.fromTokenId].symbol} to ${
Tokens[values.toTokenId].symbol
} exchange rate at this time. ` +
'Please try again in a few minutes.',
}

return {}
})().catch((error) => {
logger.error(error)
Expand Down
30 changes: 21 additions & 9 deletions src/features/swap/useSwapQuote.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { ethers } from 'ethers'
import { BigNumber, ethers } from 'ethers'
import { useEffect } from 'react'
import { toast } from 'react-toastify'
import { SWAP_QUOTE_REFETCH_INTERVAL } from 'src/config/consts'
import { TokenId, Tokens, getTokenAddress } from 'src/config/tokens'
import { getMentoSdk } from 'src/features/sdk'
Expand All @@ -13,14 +12,22 @@ import {
} from 'src/features/swap/utils'
import { fromWei } from 'src/utils/amount'
import { useDebounce } from 'src/utils/debounce'
import { logger } from 'src/utils/logger'
import { useChainId } from 'wagmi'

export interface SwapQuote {
amountWei: BigNumber
quoteWei: BigNumber
quote: string
rate: string
}

export function useSwapQuote(
amount: string | number,
direction: SwapDirection,
fromTokenId: TokenId,
toTokenId: TokenId
toTokenId: TokenId,
onError?: (error: Error) => void,
onSuccess?: (data: SwapQuote) => void
) {
const chainId = useChainId()

Expand Down Expand Up @@ -62,23 +69,28 @@ export function useSwapQuote(
{
staleTime: SWAP_QUOTE_REFETCH_INTERVAL,
refetchInterval: SWAP_QUOTE_REFETCH_INTERVAL,
onError: (error) => {
onError?.(error as Error)
},
onSuccess,
}
)

useEffect(() => {
if (error) {
toast.error('Unable to fetch swap out amount')
logger.error(error)
// toast.error('Unable to fetch swap out amount')
// logger.error(error)
onError?.(error as Error)
}
}, [error])
}, [error, onError])

return {
isLoading,
isError,
amountWei: data?.amountWei || '0',
quoteWei: data?.quoteWei || '0',
quote: data?.quote || '0',
rate: data?.rate,
refetch,
isLoading,
isError,
}
}
16 changes: 16 additions & 0 deletions src/utils/debounce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,19 @@ export function useDebounce<T>(value: T, delayMs = 500): T {

return debouncedValue
}

export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => Promise<ReturnType<T>> {
let timeout: NodeJS.Timeout | null = null

return (...args: Parameters<T>) => {
return new Promise((resolve) => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
resolve(func(...args))
}, wait)
})
}
}

0 comments on commit 5ee62d7

Please sign in to comment.