Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 63 additions & 50 deletions src/components/transfer/amount-input.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { AmountInput } from './amount-input';
import { Amount } from '@/types/amount';

const meta: Meta<typeof AmountInput> = {
title: 'Transfer/AmountInput',
Expand All @@ -11,75 +12,75 @@ const meta: Meta<typeof AmountInput> = {
export default meta;
type Story = StoryObj<typeof AmountInput>;

// Helper to create Amount objects for stories
const createAmount = (value: string, decimals: number, symbol: string) =>
Amount.fromFormatted(value, decimals, symbol);

export const Default: Story = {
args: {
label: '转账金额',
symbol: 'USDT',
balance: '1,234.56',
balance: createAmount('1234.56', 6, 'USDT'),
},
};

export const WithValue: Story = {
args: {
label: '转账金额',
symbol: 'ETH',
value: '0.5',
balance: '2.5',
value: createAmount('0.5', 18, 'ETH'),
balance: createAmount('2.5', 18, 'ETH'),
fiatValue: '900.00',
},
};

export const WithError: Story = {
args: {
label: '转账金额',
symbol: 'USDT',
value: '5000',
balance: '1000',
max: '1000',
value: createAmount('5000', 6, 'USDT'),
balance: createAmount('1000', 6, 'USDT'),
max: createAmount('1000', 6, 'USDT'),
},
};

export const CustomError: Story = {
args: {
label: '转账金额',
symbol: 'USDT',
value: '10',
value: createAmount('10', 6, 'USDT'),
balance: createAmount('1000', 6, 'USDT'),
error: '最小转账金额为 20 USDT',
},
};

export const Disabled: Story = {
args: {
label: '转账金额',
symbol: 'USDT',
value: '100',
value: createAmount('100', 6, 'USDT'),
balance: createAmount('1000', 6, 'USDT'),
disabled: true,
},
};

export const Controlled: Story = {
render: () => {
const [value, setValue] = useState('');
const balance = '1000';
const [value, setValue] = useState<Amount | null>(null);
const balance = createAmount('1000', 6, 'USDT');
const rate = 1; // USDT to USD
const fiatValue = value ? (parseFloat(value) * rate).toFixed(2) : undefined;

const inputProps: Record<string, unknown> = {
label: '转账金额',
symbol: 'USDT',
value,
onChange: setValue,
balance,
max: balance,
};
if (fiatValue !== undefined) {
inputProps.fiatValue = fiatValue;
}
const fiatValue = value ? (value.toNumber() * rate).toFixed(2) : undefined;

return (
<div className="space-y-4">
<AmountInput {...inputProps} />
<p className="text-muted-foreground text-sm">输入值: {value || '(空)'}</p>
<AmountInput
label="转账金额"
value={value}
onChange={setValue}
balance={balance}
fiatValue={fiatValue ?? undefined}
/>
<p className="text-muted-foreground text-sm">
输入值: {value?.toFormatted() ?? '(空)'}
</p>
<p className="text-muted-foreground text-sm">
原始值: {value?.toRawString() ?? '(空)'}
</p>
</div>
);
},
Expand All @@ -88,42 +89,54 @@ export const Controlled: Story = {
export const DifferentTokens: Story = {
render: () => (
<div className="space-y-6">
<AmountInput label="发送" symbol="ETH" value="0.5" balance="2.5" fiatValue="900" />
<AmountInput label="发送" symbol="BTC" value="0.01" balance="0.1" fiatValue="500" />
<AmountInput label="发送" symbol="TRX" value="1000" balance="10000" fiatValue="80" />
<AmountInput
label="发送"
value={createAmount('0.5', 18, 'ETH')}
balance={createAmount('2.5', 18, 'ETH')}
fiatValue="900"
/>
<AmountInput
label="发送"
value={createAmount('0.01', 8, 'BTC')}
balance={createAmount('0.1', 8, 'BTC')}
fiatValue="500"
/>
<AmountInput
label="发送"
value={createAmount('1000', 6, 'TRX')}
balance={createAmount('10000', 6, 'TRX')}
fiatValue="80"
/>
</div>
),
};

export const TransferForm: Story = {
render: () => {
const [amount, setAmount] = useState('');
const balance = '1000';
const numAmount = parseFloat(amount) || 0;
const isValid = numAmount > 0 && numAmount <= parseFloat(balance);
const fiatValue = amount ? (parseFloat(amount) || 0).toFixed(2) : undefined;
const [amount, setAmount] = useState<Amount | null>(null);
const balance = createAmount('1000', 6, 'USDT');
const isValid = amount && amount.isPositive() && amount.lte(balance);
const fiatValue = amount ? amount.toNumber().toFixed(2) : undefined;

const formInputProps: Record<string, unknown> = {
label: '转账金额',
symbol: 'USDT',
value: amount,
onChange: setAmount,
balance,
max: balance,
const handleQuickSelect = (val: string) => {
setAmount(createAmount(val, 6, 'USDT'));
};
if (fiatValue !== undefined) {
formInputProps.fiatValue = fiatValue;
}

return (
<div className="space-y-6 p-4">
<h3 className="text-lg font-medium">转账 USDT</h3>
<AmountInput {...formInputProps} />
<AmountInput
label="转账金额"
value={amount}
onChange={setAmount}
balance={balance}
fiatValue={fiatValue ?? undefined}
/>
<div className="flex gap-2 text-sm">
{['100', '500', '1000'].map((val) => (
<button
key={val}
onClick={() => setAmount(val)}
onClick={() => handleQuickSelect(val)}
className="bg-muted hover:bg-muted/80 rounded-lg px-4 py-2 transition-colors"
>
{val}
Expand Down
104 changes: 81 additions & 23 deletions src/components/transfer/amount-input.test.tsx
Original file line number Diff line number Diff line change
@@ -1,95 +1,153 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AmountInput, formatInputValue } from './amount-input'
import { AmountInput, sanitizeInput, limitDecimals } from './amount-input'
import { TestI18nProvider } from '@/test/i18n-mock'
import { Amount } from '@/types/amount'

const renderWithI18n = (ui: React.ReactElement) => render(<TestI18nProvider>{ui}</TestI18nProvider>)

describe('formatInputValue', () => {
describe('sanitizeInput', () => {
it('removes non-numeric characters', () => {
expect(formatInputValue('abc123')).toBe('123')
expect(sanitizeInput('abc123')).toBe('123')
})

it('allows decimal point', () => {
expect(formatInputValue('123.45')).toBe('123.45')
expect(sanitizeInput('123.45')).toBe('123.45')
})

it('keeps only one decimal point', () => {
expect(formatInputValue('123.45.67')).toBe('123.4567')
expect(sanitizeInput('123.45.67')).toBe('123.4567')
})
})

describe('limitDecimals', () => {
it('limits decimal places to specified number', () => {
expect(limitDecimals('1.123456789012', 8)).toBe('1.12345678')
})

it('limits decimal places to 8', () => {
expect(formatInputValue('1.123456789012')).toBe('1.12345678')
it('allows fewer decimals than limit', () => {
expect(limitDecimals('1.12', 8)).toBe('1.12')
})

it('handles no decimals', () => {
expect(limitDecimals('100', 8)).toBe('100')
})
})

describe('AmountInput', () => {
const createBalance = (value: string, decimals = 18, symbol = 'ETH') =>
Amount.fromFormatted(value, decimals, symbol)

it('renders with label', () => {
renderWithI18n(<AmountInput label="转账金额" />)
renderWithI18n(<AmountInput label="转账金额" decimals={18} />)
expect(screen.getByText('转账金额')).toBeInTheDocument()
})

it('renders with symbol', () => {
renderWithI18n(<AmountInput symbol="USDT" />)
renderWithI18n(<AmountInput symbol="USDT" decimals={18} />)
expect(screen.getByText('USDT')).toBeInTheDocument()
})

it('shows balance when provided', () => {
renderWithI18n(<AmountInput label="金额" symbol="ETH" balance="2.5" />)
const balance = createBalance('2.5')
renderWithI18n(<AmountInput label="金额" balance={balance} />)
expect(screen.getByText(/余额: 2.5 ETH/)).toBeInTheDocument()
})

it('calls onChange when typing', async () => {
it('calls onChange with Amount when typing valid number', async () => {
const handleChange = vi.fn()
renderWithI18n(<AmountInput onChange={handleChange} />)
renderWithI18n(<AmountInput onChange={handleChange} decimals={18} />)

const input = screen.getByPlaceholderText('0')
await userEvent.type(input, '100')

expect(handleChange).toHaveBeenCalled()
// Last call should have Amount with value 100
const lastCall = handleChange.mock.calls[handleChange.mock.calls.length - 1]![0] as Amount
expect(lastCall?.toFormatted()).toBe('100')
})

it('calls onChange with null for incomplete input', async () => {
const handleChange = vi.fn()
renderWithI18n(<AmountInput onChange={handleChange} decimals={18} />)

const input = screen.getByPlaceholderText('0')
await userEvent.type(input, '.')

// Single dot is not a valid amount
const lastCall = handleChange.mock.calls[handleChange.mock.calls.length - 1]![0]
expect(lastCall).toBeNull()
})

it('shows MAX button when balance is provided', () => {
renderWithI18n(<AmountInput balance="1000" />)
const balance = createBalance('1000')
renderWithI18n(<AmountInput balance={balance} />)
expect(screen.getByRole('button', { name: 'MAX' })).toBeInTheDocument()
})

it('sets max value when MAX button clicked', async () => {
const handleChange = vi.fn()
renderWithI18n(<AmountInput balance="1000" max="1000" onChange={handleChange} />)
const balance = createBalance('1000')
renderWithI18n(<AmountInput balance={balance} onChange={handleChange} />)

await userEvent.click(screen.getByRole('button', { name: 'MAX' }))
expect(handleChange).toHaveBeenCalledWith('1000')

const lastCall = handleChange.mock.calls[handleChange.mock.calls.length - 1]![0] as Amount
expect(lastCall.toFormatted()).toBe('1000')
})

it('shows fiat value when provided', () => {
renderWithI18n(<AmountInput value="100" fiatValue="100.00" />)
it('shows fiat value when provided and input has value', async () => {
renderWithI18n(<AmountInput decimals={18} fiatValue="100.00" />)

// First type something to show fiat value
const input = screen.getByPlaceholderText('0')
await userEvent.type(input, '100')

expect(screen.getByText(/≈ \$100.00/)).toBeInTheDocument()
})

it('shows custom fiat symbol', () => {
renderWithI18n(<AmountInput value="100" fiatValue="800" fiatSymbol="¥" />)
it('shows custom fiat symbol', async () => {
renderWithI18n(<AmountInput decimals={18} fiatValue="800" fiatSymbol="¥" />)

const input = screen.getByPlaceholderText('0')
await userEvent.type(input, '100')

expect(screen.getByText(/≈ ¥800/)).toBeInTheDocument()
})

it('shows error when value exceeds max', () => {
renderWithI18n(<AmountInput value="5000" max="1000" />)
const value = Amount.fromFormatted('5000', 18, 'ETH')
const max = Amount.fromFormatted('1000', 18, 'ETH')
renderWithI18n(<AmountInput value={value} max={max} />)
expect(screen.getByText('超出可用余额')).toBeInTheDocument()
})

it('shows custom error message', () => {
renderWithI18n(<AmountInput error="最小金额为 10" />)
renderWithI18n(<AmountInput error="最小金额为 10" decimals={18} />)
expect(screen.getByText('最小金额为 10')).toBeInTheDocument()
})

it('disables input when disabled', () => {
renderWithI18n(<AmountInput disabled />)
renderWithI18n(<AmountInput disabled decimals={18} />)
expect(screen.getByPlaceholderText('0')).toBeDisabled()
})

it('hides MAX button when disabled', () => {
renderWithI18n(<AmountInput balance="1000" disabled />)
const balance = createBalance('1000')
renderWithI18n(<AmountInput balance={balance} disabled />)
expect(screen.queryByRole('button', { name: 'MAX' })).not.toBeInTheDocument()
})

it('derives decimals from balance', async () => {
const handleChange = vi.fn()
const balance = Amount.fromFormatted('100', 6, 'USDT') // 6 decimals like USDT
renderWithI18n(<AmountInput balance={balance} onChange={handleChange} />)

const input = screen.getByPlaceholderText('0')
await userEvent.type(input, '1.1234567890') // More than 6 decimals

// Should be limited to 6 decimals
expect(input).toHaveValue('1.123456')
})
})
Loading