Skip to content

Commit

Permalink
feat(widgets): use default input/output (#3161)
Browse files Browse the repository at this point in the history
* feat: use default input/output on chain switch

* feat(widgets): ErrorGenerator -> PropValidator

* default prop validation

* useDefaults hook

* pr feedback

* fix cosmos

* drop token map changes

* add default inputs to cosmos fixture

* set up different validation layers for widget and swap

* split widget/swap prop types

* cleanup

* pr feedback

* clear defaults when they're no longer valid on the current chain

* remove state checks on validators

* stop using address in cosmos fixture

* pr feedback

* useMemo on useSwapDefaults args

* tell the user what they gave to error'd props

Co-authored-by: Zach Pomerantz <[email protected]>
  • Loading branch information
JFrankfurt and zzmp authored Jan 26, 2022
1 parent 779625a commit ce96873
Show file tree
Hide file tree
Showing 9 changed files with 242 additions and 122 deletions.
48 changes: 0 additions & 48 deletions src/lib/components/Error/ErrorGenerator.tsx

This file was deleted.

22 changes: 22 additions & 0 deletions src/lib/components/Error/WidgetsPropsValidator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { WidgetProps } from 'lib/components/Widget'
import { IntegrationError } from 'lib/errors'
import { PropsWithChildren, useEffect } from 'react'

export default function WidgetsPropsValidator(props: PropsWithChildren<WidgetProps>) {
const { jsonRpcEndpoint, provider } = props

useEffect(() => {
if (!provider && !jsonRpcEndpoint) {
throw new IntegrationError('This widget requires a provider or jsonRpcEndpoint.')
}
}, [provider, jsonRpcEndpoint])

const { width } = props
useEffect(() => {
if (width && width < 300) {
throw new IntegrationError(`Set widget width to at least 300px. (You set it to ${width}.)`)
}
}, [width])

return <>{props.children}</>
}
38 changes: 36 additions & 2 deletions src/lib/components/Swap/Swap.fixture.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { tokens } from '@uniswap/default-token-list'
import { DAI, USDC } from 'constants/tokens'
import { useUpdateAtom } from 'jotai/utils'
import { useEffect } from 'react'
import { useValue } from 'react-cosmos/fixture'
import { useSelect, useValue } from 'react-cosmos/fixture'

import Swap from '.'
import { colorAtom } from './Output'
Expand All @@ -24,7 +25,40 @@ function Fixture() {
}
}, [color, setColor])

return <Swap defaults={{ tokenList: tokens }} />
const optionsToAddressMap: Record<string, string> = {
none: '',
Native: 'NATIVE',
DAI: DAI.address,
USDC: USDC.address,
}
const addressOptions = Object.keys(optionsToAddressMap)
const [defaultInput] = useSelect('defaultInputAddress', {
options: addressOptions,
defaultValue: addressOptions[2],
})
const inputOptions = ['', '0', '100', '-1']
const [defaultInputAmount] = useSelect('defaultInputAmount', {
options: inputOptions,
defaultValue: inputOptions[2],
})
const [defaultOutput] = useSelect('defaultOutputAddress', {
options: addressOptions,
defaultValue: addressOptions[1],
})
const [defaultOutputAmount] = useSelect('defaultOutputAmount', {
options: inputOptions,
defaultValue: inputOptions[0],
})

return (
<Swap
tokenList={tokens}
defaultInputAddress={optionsToAddressMap[defaultInput]}
defaultInputAmount={defaultInputAmount}
defaultOutputAddress={optionsToAddressMap[defaultOutput]}
defaultOutputAmount={defaultOutputAmount}
/>
)
}

export default <Fixture />
76 changes: 76 additions & 0 deletions src/lib/components/Swap/SwapPropValidator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { BigNumber } from '@ethersproject/bignumber'
import { DefaultAddress, SwapProps } from 'lib/components/Swap'
import { IntegrationError } from 'lib/errors'
import { PropsWithChildren, useEffect } from 'react'

import { isAddress } from '../../../utils'

function isAddressOrAddressMap(addressOrMap: DefaultAddress): boolean {
if (typeof addressOrMap === 'object') {
return Object.values(addressOrMap).every((address) => isAddress(address))
}
if (typeof addressOrMap === 'string') {
return typeof isAddress(addressOrMap) === 'string'
}
return false
}

type ValidatorProps = PropsWithChildren<SwapProps>

export default function SwapPropValidator(props: ValidatorProps) {
const { convenienceFee, convenienceFeeRecipient } = props
useEffect(() => {
if (convenienceFee) {
if (convenienceFee > 100 || convenienceFee < 0) {
throw new IntegrationError(`convenienceFee must be between 0 and 100. (You set it to ${convenienceFee})`)
}
if (!convenienceFeeRecipient) {
throw new IntegrationError('convenienceFeeRecipient is required when convenienceFee is set.')
}

if (typeof convenienceFeeRecipient === 'string') {
if (!isAddress(convenienceFeeRecipient)) {
throw new IntegrationError(
`convenienceFeeRecipient must be a valid address. (You set it to ${convenienceFeeRecipient}.)`
)
}
} else if (typeof convenienceFeeRecipient === 'object') {
Object.values(convenienceFeeRecipient).forEach((recipient) => {
if (!isAddress(recipient)) {
const values = Object.values(convenienceFeeRecipient).join(', ')
throw new IntegrationError(
`All values in convenienceFeeRecipient object must be valid addresses. (You used ${values}.)`
)
}
})
}
}
}, [convenienceFee, convenienceFeeRecipient])

const { defaultInputAddress, defaultInputAmount, defaultOutputAddress, defaultOutputAmount } = props
useEffect(() => {
if (defaultOutputAmount && defaultInputAmount) {
throw new IntegrationError('defaultInputAmount and defaultOutputAmount may not both be defined.')
}
if (defaultInputAmount && BigNumber.from(defaultInputAmount).lt(0)) {
throw new IntegrationError(`defaultInputAmount must be a positive number. (You set it to ${defaultInputAmount})`)
}
if (defaultOutputAmount && BigNumber.from(defaultOutputAmount).lt(0)) {
throw new IntegrationError(
`defaultOutputAmount must be a positive number. (You set it to ${defaultOutputAmount})`
)
}
if (defaultInputAddress && !isAddressOrAddressMap(defaultInputAddress) && defaultInputAddress !== 'NATIVE') {
throw new IntegrationError(
`defaultInputAddress(es) must be a valid address or "NATIVE". (You set it to ${defaultInputAddress}`
)
}
if (defaultOutputAddress && !isAddressOrAddressMap(defaultOutputAddress) && defaultOutputAddress !== 'NATIVE') {
throw new IntegrationError(
`defaultOutputAddress(es) must be a valid address or "NATIVE". (You set it to ${defaultOutputAddress}`
)
}
}, [defaultInputAddress, defaultInputAmount, defaultOutputAddress, defaultOutputAmount])

return <>{props.children}</>
}
67 changes: 17 additions & 50 deletions src/lib/components/Swap/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { Trans } from '@lingui/macro'
import { TokenInfo } from '@uniswap/token-lists'
import { nativeOnChain } from 'constants/tokens'
import { useSwapAmount, useSwapCurrency } from 'lib/hooks/swap'
import useSwapDefaults from 'lib/hooks/swap/useSwapDefaults'
import { SwapInfoUpdater } from 'lib/hooks/swap/useSwapInfo'
import useActiveWeb3React from 'lib/hooks/useActiveWeb3React'
import useTokenList, { DEFAULT_TOKEN_LIST } from 'lib/hooks/useTokenList'
import { Field } from 'lib/state/swap'
import { useLayoutEffect, useMemo, useState } from 'react'
import useTokenList from 'lib/hooks/useTokenList'
import { useState } from 'react'

import Header from '../Header'
import { BoundaryProvider } from '../Popover'
Expand All @@ -16,60 +14,29 @@ import Output from './Output'
import ReverseButton from './ReverseButton'
import Settings from './Settings'
import SwapButton from './SwapButton'
import SwapPropValidator from './SwapPropValidator'
import Toolbar from './Toolbar'

interface DefaultTokenAmount {
address?: string | { [chainId: number]: string }
amount?: number
}

interface SwapDefaults {
tokenList: string | TokenInfo[]
input: DefaultTokenAmount
output: DefaultTokenAmount
}

const DEFAULT_INPUT: DefaultTokenAmount = { address: 'ETH' }
const DEFAULT_OUTPUT: DefaultTokenAmount = {}

function useSwapDefaults(defaults: Partial<SwapDefaults> = {}): SwapDefaults {
const tokenList = defaults.tokenList || DEFAULT_TOKEN_LIST
const input: DefaultTokenAmount = defaults.input || DEFAULT_INPUT
const output: DefaultTokenAmount = defaults.output || DEFAULT_OUTPUT
input.amount = input.amount || 0
output.amount = output.amount || 0
// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo(() => ({ tokenList, input, output }), [])
}

export type DefaultAddress = string | { [chainId: number]: string | 'NATIVE' } | 'NATIVE'
export interface SwapProps {
tokenList?: string | TokenInfo[]
defaultInputAddress?: DefaultAddress
defaultInputAmount?: string
defaultOutputAddress?: DefaultAddress
defaultOutputAmount?: string
convenienceFee?: number
convenienceFeeRecipient?: string // TODO: improve typing to require recipient when fee is set
defaults?: Partial<SwapDefaults>
convenienceFeeRecipient?: string | { [chainId: number]: string }
}

export default function Swap({ defaults }: SwapProps) {
const { tokenList } = useSwapDefaults(defaults)
useTokenList(tokenList)
export default function Swap(props: SwapProps) {
useTokenList(props.tokenList)
useSwapDefaults(props)

const { active, account } = useActiveWeb3React()
const [boundary, setBoundary] = useState<HTMLDivElement | null>(null)
const { chainId, active, account } = useActiveWeb3React()

// Switch to on-chain currencies if/when chain changes to prevent chain mismatched currencies.
const [, updateSwapInputCurrency] = useSwapCurrency(Field.INPUT)
const [, updateSwapOutputCurrency] = useSwapCurrency(Field.OUTPUT)
const [, updateSwapInputAmount] = useSwapAmount(Field.INPUT)
useLayoutEffect(() => {
if (chainId) {
updateSwapInputCurrency(nativeOnChain(chainId))
updateSwapOutputCurrency()
updateSwapInputAmount('')
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chainId])

return (
<>
<SwapPropValidator {...props}>
<SwapInfoUpdater />
<Header logo title={<Trans>Swap</Trans>}>
{active && <Wallet disabled={!account} />}
Expand All @@ -85,6 +52,6 @@ export default function Swap({ defaults }: SwapProps) {
</Output>
</BoundaryProvider>
</div>
</>
</SwapPropValidator>
)
}
31 changes: 15 additions & 16 deletions src/lib/components/Widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ import { UNMOUNTING } from 'lib/hooks/useUnmount'
import { Provider as I18nProvider } from 'lib/i18n'
import { MulticallUpdater, store as multicallStore } from 'lib/state/multicall'
import styled, { keyframes, Theme, ThemeProvider } from 'lib/theme'
import { ComponentProps, JSXElementConstructor, PropsWithChildren, StrictMode, useRef } from 'react'
import { PropsWithChildren, StrictMode, useRef } from 'react'
import { Provider as ReduxProvider } from 'react-redux'
import { Provider as EthProvider } from 'widgets-web3-react/types'

import { Provider as DialogProvider } from './Dialog'
import ErrorBoundary, { ErrorHandler } from './Error/ErrorBoundary'
import ErrorGenerator from './Error/ErrorGenerator'
import WidgetPropValidator from './Error/WidgetsPropsValidator'
import Web3Provider from './Web3Provider'

const slideDown = keyframes`
Expand Down Expand Up @@ -79,7 +79,7 @@ function Updaters() {
)
}

export type WidgetProps<T extends JSXElementConstructor<any> | undefined = undefined> = {
export type WidgetProps = {
theme?: Theme
locale?: SupportedLocale
provider?: EthProvider
Expand All @@ -88,10 +88,7 @@ export type WidgetProps<T extends JSXElementConstructor<any> | undefined = undef
dialog?: HTMLElement | null
className?: string
onError?: ErrorHandler
} & (T extends JSXElementConstructor<any>
? ComponentProps<T>
: // eslint-disable-next-line @typescript-eslint/ban-types
{})
}

export default function Widget(props: PropsWithChildren<WidgetProps>) {
const {
Expand All @@ -105,6 +102,7 @@ export default function Widget(props: PropsWithChildren<WidgetProps>) {
className,
onError,
} = props

const wrapper = useRef<HTMLDivElement>(null)
return (
<StrictMode>
Expand All @@ -113,15 +111,16 @@ export default function Widget(props: PropsWithChildren<WidgetProps>) {
<WidgetWrapper width={width} className={className} ref={wrapper}>
<DialogProvider value={dialog || wrapper.current}>
<ErrorBoundary onError={onError}>
<ReduxProvider store={multicallStore}>
<AtomProvider>
<Web3Provider provider={provider} jsonRpcEndpoint={jsonRpcEndpoint}>
<Updaters />
<ErrorGenerator {...props} />
{children}
</Web3Provider>
</AtomProvider>
</ReduxProvider>
<WidgetPropValidator {...props}>
<ReduxProvider store={multicallStore}>
<AtomProvider>
<Web3Provider provider={provider} jsonRpcEndpoint={jsonRpcEndpoint}>
<Updaters />
{children}
</Web3Provider>
</AtomProvider>
</ReduxProvider>
</WidgetPropValidator>
</ErrorBoundary>
</DialogProvider>
</WidgetWrapper>
Expand Down
Loading

0 comments on commit ce96873

Please sign in to comment.