Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add validation & error handling ux improvements
Browse files Browse the repository at this point in the history
refactor: cleanup

chore: cleanup
denviljclarke committed Nov 22, 2024
1 parent 050f73e commit 19fc48b
Showing 5 changed files with 105 additions and 36 deletions.
2 changes: 1 addition & 1 deletion src/config/consts.ts
Original file line number Diff line number Diff line change
@@ -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
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, SVGProps, useCallback, useEffect, useMemo } from 'react'
import { toast } from 'react-toastify'
import { Spinner } from 'src/components/animation/Spinner'
import { Button3D } from 'src/components/buttons/3DButton'
@@ -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'
@@ -72,11 +73,15 @@ function SwapForm() {
const storedFormValues = useAppSelector((s) => s.swap.formValues) // Get stored form values
const initialFormValues = storedFormValues || initialValues // Use stored values if they exist

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

return (
<Formik<SwapFormValues>
initialValues={initialFormValues}
onSubmit={onSubmit}
validate={validateForm}
validate={debouncedValidate}
validateOnChange={true}
validateOnBlur={false}
>
@@ -99,7 +104,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)
@@ -113,7 +118,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)) {
@@ -124,12 +129,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')
@@ -286,12 +296,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')
@@ -304,40 +314,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(
() => Boolean(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>
)
13 changes: 13 additions & 0 deletions src/features/swap/useFormValidator.ts
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion src/features/swap/useSwapQuote.ts
Original file line number Diff line number Diff line change
@@ -24,7 +24,7 @@ export function useSwapQuote(
) {
const chainId = useChainId()

const debouncedAmount = useDebounce(amount, 350)
const debouncedAmount = useDebounce(amount, 0)

const { isLoading, isError, error, data, refetch } = useQuery(
['useSwapQuote', debouncedAmount, fromTokenId, toTokenId, direction],
16 changes: 16 additions & 0 deletions src/utils/debounce.ts
Original file line number Diff line number Diff line change
@@ -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 19fc48b

Please sign in to comment.