Mp 2757 token input errors (#235)

* Refactor TokenInput

* add TokenInput test + warning

* change title assigned select

* add unit tests for Card

* remove marketAssets from broadcast store
This commit is contained in:
Bob van der Helm 2023-05-30 09:01:07 +02:00 committed by GitHub
parent a3b436f8dd
commit de89ecb7ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 214 additions and 120 deletions

View File

@ -0,0 +1,51 @@
import { render, screen } from '@testing-library/react'
import Card from 'components/Card'
import { shallow } from 'enzyme'
import Text from 'components/Text'
import Button from 'components/Button'
describe('<Card />', () => {
const defaultProps = {
children: <></>,
}
it('should render', () => {
const { container } = render(<Card {...defaultProps} />)
expect(container).toBeInTheDocument()
})
it('should handle `className` prop correctly', () => {
const testClass = 'test-class'
const { container } = render(<Card {...defaultProps} className={testClass} />)
expect(container.querySelector('section')).toHaveClass(testClass)
})
it('should handle `contentClassName` prop correctly', () => {
const testClass = 'test-class'
const { container } = render(<Card {...defaultProps} contentClassName={testClass} />)
expect(container.querySelector('div')).toHaveClass(testClass)
})
it('should handle `title` prop as string correctly', () => {
const testTitle = 'test-title'
const wrapper = shallow(<Card {...defaultProps} title={testTitle} />)
const textComponent = wrapper.find(Text).at(0)
const text = textComponent.dive().text()
expect(text).toBe(testTitle)
})
it('should handle `title` prop as element correctly', () => {
const testTitle = <p className='test-class'>Test title</p>
const wrapper = shallow(<Card {...defaultProps} title={testTitle} />)
expect(wrapper.find('p.test-class')).toHaveLength(1)
expect(wrapper.find(Text)).toHaveLength(0)
})
it('should handle `id` prop as element correctly', () => {
const testId = 'test-id'
const wrapper = shallow(<Card {...defaultProps} id={testId} />)
expect(wrapper.find(`section#${testId}`).at(0)).toHaveLength(1)
})
})

View File

@ -0,0 +1,74 @@
import { render, fireEvent } from '@testing-library/react'
import BigNumber from 'bignumber.js'
import TokenInput from 'components/TokenInput'
import { ASSETS } from 'constants/assets'
describe('<TokenInput />', () => {
const asset = ASSETS[0]
const defaultProps = {
amount: new BigNumber(1),
asset,
max: new BigNumber(100),
onChangeAsset: jest.fn(),
onChange: jest.fn(),
}
it('should render', () => {
const { container } = render(<TokenInput {...defaultProps} />)
expect(container).toBeInTheDocument()
})
it('should handle `className` prop correctly', () => {
const testClass = 'test-class'
const { getByTestId } = render(<TokenInput {...defaultProps} className={testClass} />)
expect(getByTestId('token-input-component')).toHaveClass(testClass)
})
it('should handle `disabled` prop correctly', () => {
const { getByTestId } = render(<TokenInput {...defaultProps} disabled />)
expect(getByTestId('token-input-component')).toHaveClass('pointer-events-none opacity-50')
})
it('should handle `maxText` prop correctly', () => {
const { getByTestId } = render(<TokenInput {...defaultProps} maxText='Max' />)
expect(getByTestId('token-input-max-button')).toBeInTheDocument()
})
it('should handle `warning` prop correctly', () => {
const { getByTestId } = render(<TokenInput {...defaultProps} warning='Warning' />)
expect(getByTestId('token-input-wrapper')).toHaveClass('border-warning')
})
describe('should render the max button', () => {
it('when `maxText` prop is defined', () => {
const { getByTestId } = render(<TokenInput {...defaultProps} maxText='Max' />)
expect(getByTestId('token-input-max-button')).toBeInTheDocument()
})
it('not when `maxText` prop is undefined', () => {
const { queryByTestId } = render(<TokenInput {...defaultProps} />)
expect(queryByTestId('token-input-max-button')).not.toBeInTheDocument()
})
})
describe('should render <Select />', () => {
it('when `hasSelect` prop is true and balances is defined', () => {
const { getByTestId } = render(<TokenInput {...defaultProps} balances={[]} hasSelect />)
expect(getByTestId('select-component')).toBeInTheDocument()
})
it('not when `hasSelect` prop is true and balances is not defined', () => {
const { queryByTestId } = render(<TokenInput {...defaultProps} hasSelect />)
expect(queryByTestId('select-component')).not.toBeInTheDocument()
})
it('not when `hasSelect` prop is false and balances is defined', () => {
const { queryByTestId } = render(<TokenInput {...defaultProps} balances={[]} />)
expect(queryByTestId('select-component')).not.toBeInTheDocument()
})
})
it('should call onMaxBtnClick when the user clicks on max button', () => {
const { getByTestId } = render(<TokenInput {...defaultProps} maxText='max' />)
const maxBtn = getByTestId('token-input-max-button')
fireEvent.click(maxBtn)
expect(defaultProps.onChange).toBeCalledWith(defaultProps.max)
})
})

View File

@ -85,6 +85,7 @@ export default function FundAccount(props: Props) {
your Osmosis address has no assets.
</Text>
<TokenInputWithSlider
asset={asset}
onChange={onChangeAmount}
onChangeAsset={onChangeAsset}
amount={amount}

View File

@ -33,6 +33,7 @@ interface Props {
iconClassName?: string
hasSubmenu?: boolean
hasFocus?: boolean
dataTestId?: string
}
const Button = React.forwardRef(function Button(
@ -52,6 +53,7 @@ const Button = React.forwardRef(function Button(
iconClassName,
hasSubmenu,
hasFocus,
dataTestId,
}: Props,
ref,
) {
@ -99,6 +101,7 @@ const Button = React.forwardRef(function Button(
return (
<button
data-testid={dataTestId}
className={buttonClassNames}
id={id}
ref={ref as LegacyRef<HTMLButtonElement>}

View File

@ -9,7 +9,6 @@ interface Props {
contentClassName?: string
title?: string | ReactElement
id?: string
onClick?: (e: React.MouseEvent) => void
}
export default function Card(props: Props) {

View File

@ -0,0 +1,3 @@
<svg width="22" height="20" viewBox="0 0 22 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.9998 8.00023V12.0002M10.9998 16.0002H11.0098M9.61507 2.89195L1.39019 17.0986C0.933982 17.8866 0.70588 18.2806 0.739593 18.6039C0.768998 18.886 0.916769 19.1423 1.14613 19.309C1.40908 19.5002 1.86435 19.5002 2.77487 19.5002H19.2246C20.1352 19.5002 20.5904 19.5002 20.8534 19.309C21.0827 19.1423 21.2305 18.886 21.2599 18.6039C21.2936 18.2806 21.0655 17.8866 20.6093 17.0986L12.3844 2.89195C11.9299 2.10679 11.7026 1.71421 11.4061 1.58235C11.1474 1.46734 10.8521 1.46734 10.5935 1.58235C10.2969 1.71421 10.0696 2.10679 9.61507 2.89195Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 722 B

View File

@ -15,6 +15,7 @@ export { default as Copy } from 'components/Icons/Copy.svg'
export { default as Cross } from 'components/Icons/Cross.svg'
export { default as CrossCircled } from 'components/Icons/CrossCircled.svg'
export { default as ExclamationMarkCircled } from 'components/Icons/ExclamationMarkCircled.svg'
export { default as ExclamationMarkTriangle } from 'components/Icons/ExclamationMarkTriangle.svg'
export { default as ExternalLink } from 'components/Icons/ExternalLink.svg'
export { default as Gear } from 'components/Icons/Gear.svg'
export { default as Heart } from 'components/Icons/Heart.svg'

View File

@ -94,9 +94,10 @@ export default function FundWithdrawModalContent(props: Props) {
onChangeAsset={setCurrentAsset}
amount={amount}
max={max}
balances={props.isFunding ? balances : props.account.deposits ?? []}
balances={props.isFunding ? balances : props.account.deposits}
accountId={!props.isFunding ? props.account.id : undefined}
hasSelect
maxText='Max'
/>
<Divider />
<Button

View File

@ -97,6 +97,10 @@ export default function VaultDeposit(props: Props) {
})
}
function getWarningText(asset: Asset) {
return `You don't have ${asset.symbol} balance in your account. Toggle custom amount to deposit.`
}
return (
<div className='flex h-full flex-col justify-between gap-6 p-4'>
<TokenInput
@ -105,6 +109,7 @@ export default function VaultDeposit(props: Props) {
max={primaryMax}
maxText='Balance'
asset={props.primaryAsset}
warning={primaryMax.isZero() ? getWarningText(props.primaryAsset) : undefined}
/>
{!isCustomAmount && <Slider value={percentage} onChange={onChangeSlider} />}
<TokenInput
@ -113,6 +118,7 @@ export default function VaultDeposit(props: Props) {
max={secondaryMax}
maxText='Balance'
asset={props.secondaryAsset}
warning={secondaryMax.isZero() ? getWarningText(props.secondaryAsset) : undefined}
/>
<Divider />
<div className='flex justify-between'>

View File

@ -46,6 +46,7 @@ export default function Option(props: Props) {
return (
<div
data-testid='option-component'
className={classNames(
'grid grid-flow-row grid-cols-5 grid-rows-2 py-3.5 pr-4',
'border-b border-b-white/20 last:border-none',

View File

@ -48,6 +48,7 @@ export default function Select(props: Props) {
return (
<div
data-testid='select-component'
className={classNames(
props.isParent && 'relative',
'flex min-w-fit items-center gap-2',

View File

@ -1,7 +1,6 @@
import BigNumber from 'bignumber.js'
import classNames from 'classnames'
import Image from 'next/image'
import { useCallback, useEffect, useState } from 'react'
import DisplayCurrency from 'components/DisplayCurrency'
import NumberInput from 'components/NumberInput'
@ -12,107 +11,96 @@ import useStore from 'store'
import { BN } from 'utils/helpers'
import { FormattedNumber } from 'components/FormattedNumber'
import Button from 'components/Button'
import { ExclamationMarkTriangle } from 'components/Icons'
interface Props {
amount: BigNumber
onChange: (amount: BigNumber) => void
className?: string
disabled?: boolean
balances?: Coin[] | null
accountId?: string
}
interface SingleProps extends Props {
asset: Asset
max: BigNumber
maxText: string
onChange: (amount: BigNumber) => void
accountId?: string
balances?: Coin[]
className?: string
disabled?: boolean
hasSelect?: boolean
onChangeAsset?: (asset: Asset, max: BigNumber) => void
}
interface SelectProps extends Props {
asset?: Asset
max?: BigNumber
maxText?: string
hasSelect: boolean
onChangeAsset: (asset: Asset, max: BigNumber) => void
warning?: string
onChangeAsset?: (asset: Asset) => void
}
export default function TokenInput(props: SingleProps | SelectProps) {
export default function TokenInput(props: Props) {
const baseCurrency = useStore((s) => s.baseCurrency)
const [asset, setAsset] = useState<Asset>(props.asset ? props.asset : baseCurrency)
const [coin, setCoin] = useState<Coin>({
denom: props.asset ? props.asset.denom : baseCurrency.denom,
amount: '0',
})
// TODO: Refactor the useEffect
useEffect(() => {
props.onChangeAsset && props.onChangeAsset(asset, coin ? BN(coin.amount) : BN(0))
}, [coin, asset])
const updateAsset = useCallback(
(coinDenom: string) => {
const newAsset = ASSETS.find((asset) => asset.denom === coinDenom) ?? baseCurrency
const newCoin = props.balances?.find((coin) => coin.denom === coinDenom)
setAsset(newAsset)
setCoin(newCoin ?? { denom: coinDenom, amount: '0' })
},
[props.balances, baseCurrency],
)
function onMaxBtnClick() {
if (!props.max) return
props.onChange(BN(props.max))
}
function onChangeAsset(denom: string) {
if (!props.onChangeAsset) return
const newAsset = ASSETS.find((asset) => asset.denom === denom) ?? baseCurrency
props.onChangeAsset(newAsset)
}
return (
<div
data-testid='token-input-component'
className={classNames(
'flex w-full flex-col gap-2 transition-opacity',
props.className,
props.disabled && 'pointer-events-none opacity-50',
)}
>
<div className='relative isolate z-40 box-content flex h-11 w-full rounded-sm border border-white/20 bg-white/5'>
<div
data-testid='token-input-wrapper'
className={classNames(
'relative isolate z-40 box-content flex h-11 w-full rounded-sm border bg-white/5',
props.warning ? 'border-warning' : 'border-white/20',
)}
>
{props.hasSelect && props.balances ? (
<Select
options={props.balances}
defaultValue={coin.denom}
onChange={(value) => updateAsset(value)}
defaultValue={props.asset.denom}
onChange={onChangeAsset}
title={props.accountId ? `Account ${props.accountId}` : 'Your Wallet'}
className='border-r border-white/20 bg-white/5'
/>
) : (
<div className='flex min-w-fit items-center gap-2 border-r border-white/20 bg-white/5 p-3'>
<Image src={asset.logo} alt='token' width={20} height={20} />
<Text>{asset.symbol}</Text>
<Image src={props.asset.logo} alt='token' width={20} height={20} />
<Text>{props.asset.symbol}</Text>
</div>
)}
<NumberInput
disabled={props.disabled}
asset={asset}
maxDecimals={asset.decimals}
asset={props.asset}
maxDecimals={props.asset.decimals}
onChange={props.onChange}
amount={props.amount}
max={props.max}
className='border-none p-3'
/>
{props.warning && (
<div className='grid items-center px-2'>
<ExclamationMarkTriangle className='text-warning' />
</div>
)}
</div>
<div className='flex'>
<div className='flex flex-1 items-center'>
{props.max && props.maxText && (
{props.maxText && (
<>
<Text size='xs' className='mr-1 text-white' monospace>
{`${props.maxText}:`}
</Text>
<FormattedNumber
className='mr-1 text-xs text-white/50'
amount={props.max?.toNumber() || 0}
options={{ decimals: asset.decimals }}
amount={props.max.toNumber()}
options={{ decimals: props.asset.decimals }}
/>
<Button
dataTestId='token-input-max-button'
color='tertiary'
className='h-4 bg-white/20 px-1.5 py-0.5 text-2xs'
variant='transparent'
@ -127,7 +115,7 @@ export default function TokenInput(props: SingleProps | SelectProps) {
<DisplayCurrency
isApproximation
className='inline pl-0.5 text-xs text-white/50'
coin={{ denom: asset.denom, amount: props.amount.toString() }}
coin={{ denom: props.asset.denom, amount: props.amount.toString() }}
/>
</div>
</div>

View File

@ -1,91 +1,58 @@
import BigNumber from 'bignumber.js'
import { useCallback, useEffect, useState } from 'react'
import { useState } from 'react'
import Slider from 'components/Slider'
import TokenInput from 'components/TokenInput'
import { ASSETS } from 'constants/assets'
import { BN } from 'utils/helpers'
interface Props {
amount: BigNumber
asset: Asset
max: BigNumber
onChange: (amount: BigNumber) => void
accountId?: string
balances?: Coin[]
className?: string
disabled?: boolean
balances?: Coin[] | null
accountId?: string
}
interface SingleProps extends Props {
max: BigNumber
maxText: string
asset: Asset
hasSelect?: boolean
maxText?: string
onChangeAsset?: (asset: Asset) => void
}
interface SelectProps extends Props {
max?: BigNumber
maxText?: string
asset?: Asset
onChangeAsset: (asset: Asset) => void
hasSelect: boolean
}
export default function TokenInputWithSlider(props: SingleProps | SelectProps) {
export default function TokenInputWithSlider(props: Props) {
const [amount, setAmount] = useState(props.amount)
const [percentage, setPercentage] = useState(0)
const [asset, setAsset] = useState<Asset>(props.asset ? props.asset : ASSETS[0])
const [max, setMax] = useState<BigNumber>(props.max ? props.max : BN(0))
const onSliderChange = useCallback(
(percentage: number) => {
const newAmount = BN(percentage).div(100).times(max)
setPercentage(percentage)
setAmount(newAmount)
props.onChange(newAmount)
},
[props, max],
)
function onChangeSlider(percentage: number) {
const newAmount = BN(percentage).div(100).times(props.max)
setPercentage(percentage)
setAmount(newAmount)
props.onChange(newAmount)
}
const onInputChange = useCallback(
(newAmount: BigNumber) => {
setAmount(newAmount)
setPercentage(BN(newAmount).div(max).times(100).toNumber())
props.onChange(newAmount)
},
[props, max],
)
function onChangeAmount(newAmount: BigNumber) {
setAmount(newAmount)
setPercentage(BN(newAmount).div(props.max).times(100).toNumber())
props.onChange(newAmount)
}
const onAssetChange = useCallback(
(newAsset: Asset, liquidtyAmount: BigNumber) => {
props.onChangeAsset && props.onChangeAsset(newAsset)
setAsset(newAsset)
setMax(liquidtyAmount)
setPercentage(0)
setAmount(BN(0))
},
[props],
)
useEffect(() => {
if (props.max?.isEqualTo(max)) return
setMax(props.max ?? BN(0))
function onChangeAsset(newAsset: Asset) {
if (!props.onChangeAsset) return
setPercentage(0)
setAmount(BN(0))
setAsset(props.asset ?? ASSETS[0])
}, [props.max, props.asset, max])
props.onChangeAsset(newAsset)
}
return (
<div className={props.className}>
<TokenInput
asset={asset}
onChange={(amount) => onInputChange(amount)}
onChangeAsset={(asset: Asset, max: BigNumber) => onAssetChange(asset, max)}
asset={props.asset}
onChange={onChangeAmount}
onChangeAsset={onChangeAsset}
amount={amount}
max={max}
maxText={props.maxText || ''}
className='mb-4'
max={props.max}
maxText={props.maxText}
disabled={props.disabled}
hasSelect={props.hasSelect}
balances={props.balances}
@ -93,7 +60,7 @@ export default function TokenInputWithSlider(props: SingleProps | SelectProps) {
/>
<Slider
value={percentage}
onChange={(value) => onSliderChange(value)}
onChange={(value) => onChangeSlider(value)}
disabled={props.disabled}
/>
</div>

View File

@ -56,7 +56,7 @@ export default function ConnectedButton() {
const disconnectWallet = () => {
disconnect()
terminate()
useStore.setState({ client: undefined, balances: null })
useStore.setState({ client: undefined, balances: [] })
}
useEffect(() => {

View File

@ -4,7 +4,6 @@ import { GetState, SetState } from 'zustand'
import { ENV } from 'constants/env'
import { Store } from 'store'
import { getMarketAssets } from 'utils/assets'
import { getSingleValueFromBroadcastResult } from 'utils/broadcast'
import { formatAmountWithSymbol } from 'utils/formatters'
@ -12,7 +11,6 @@ export default function createBroadcastSlice(
set: SetState<Store>,
get: GetState<Store>,
): BroadcastSlice {
const marketAssets = getMarketAssets()
return {
toast: null,
borrow: async (options: { fee: StdFee; accountId: string; coin: Coin }) => {

View File

@ -4,7 +4,7 @@ import { GetState, SetState } from 'zustand'
export default function createCommonSlice(set: SetState<CommonSlice>, get: GetState<CommonSlice>) {
return {
accounts: null,
balances: null,
balances: [],
creditAccounts: null,
enableAnimations: true,
isOpen: true,

View File

@ -3,7 +3,7 @@ interface CommonSlice {
address?: string
enableAnimations: boolean
isOpen: boolean
balances: Coin[] | null
balances: Coin[]
selectedAccount: string | null
client?: import('@marsprotocol/wallet-connector').WalletClient
status: import('@marsprotocol/wallet-connector').WalletConnectionStatus

View File

@ -100,7 +100,7 @@ module.exports = {
success: '#32D583',
'success-bg': '#6CE9A6',
'vote-against': '#eb9e49',
warning: '#c83333',
warning: '#F79009',
white: '#FFF',
},
fontFamily: {