From d110025ef5fae763ad7884d4c7e55bd2c97d8c91 Mon Sep 17 00:00:00 2001 From: devhenryno Date: Sun, 31 May 2026 23:22:17 +0100 Subject: [PATCH] Add helper for formatting buy quantity input with min/max bounds [closes #400] --- src/components/common/TradeDialog.tsx | 38 +++++++- .../TradeDialog.buyQuantity.test.tsx | 90 +++++++++++++++++++ src/constants/fees.ts | 5 ++ src/utils/__tests__/buyQuantity.test.ts | 75 ++++++++++++++++ src/utils/buyQuantity.ts | 64 +++++++++++++ 5 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 src/components/common/__tests__/TradeDialog.buyQuantity.test.tsx create mode 100644 src/utils/__tests__/buyQuantity.test.ts create mode 100644 src/utils/buyQuantity.ts diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx index fdfdd1a..a4bdd52 100644 --- a/src/components/common/TradeDialog.tsx +++ b/src/components/common/TradeDialog.tsx @@ -15,6 +15,7 @@ import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; import PercentageBadge from '@/components/common/PercentageBadge'; import NetworkFeeHint from '@/components/common/NetworkFeeHint'; import { TRADE_FEE_ESTIMATE } from '@/constants/fees'; +import { clampBuyQuantity } from '@/utils/buyQuantity'; import { fetchTradeNetworkFeeEstimate, formatTransactionFeeDisplay, @@ -55,12 +56,36 @@ const TradeDialog: React.FC = ({ const [amountText, setAmountText] = useState('1'); const [networkFeeEstimate, setNetworkFeeEstimate] = useState({ status: 'idle', fee: null }); + const [adjustmentNote, setAdjustmentNote] = useState(null); const amountInputRef = useRef(null); useEffect(() => { - if (open) setAmountText('1'); + if (open) { + setAmountText('1'); + setAdjustmentNote(null); + } }, [open]); + const handleBlur = () => { + if (side !== 'buy') return; + + const trimmed = amountText.trim(); + const res = clampBuyQuantity(trimmed); + + if (res.adjusted) { + setAmountText(res.value.toString()); + if (res.reason === 'below_min') { + setAdjustmentNote(`Quantity adjusted to the minimum of ${res.value}.`); + } else if (res.reason === 'above_max') { + setAdjustmentNote(`Quantity adjusted to the maximum of ${res.value}.`); + } else { + setAdjustmentNote(`Quantity rounded to ${res.value}.`); + } + } else { + setAdjustmentNote(null); + } + }; + const parsedAmount = useMemo(() => { const normalized = amountText.trim(); if (!normalized) return NaN; @@ -176,7 +201,11 @@ const TradeDialog: React.FC = ({ ref={amountInputRef} inputMode="decimal" value={amountText} - onChange={event => setAmountText(event.target.value)} + onChange={event => { + setAmountText(event.target.value); + setAdjustmentNote(null); + }} + onBlur={handleBlur} disabled={isSubmitting} className={cn( 'w-full rounded-xl border bg-white/[0.04] px-3 py-2 text-white outline-none transition-colors', @@ -189,6 +218,11 @@ const TradeDialog: React.FC = ({ data-focus-order="1" data-testid="trade-dialog-amount" /> + {side === 'buy' && adjustmentNote && ( +
+ {adjustmentNote} +
+ )}
{ + function renderDialog( + overrides: Partial> = {} + ) { + return render( + + ); + } + + it('clamps values below minimum to minimum on blur', () => { + renderDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // Input below minimum + fireEvent.change(input, { target: { value: '0' } }); + fireEvent.blur(input); + + expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY.toString()); + expect(screen.getByTestId('buy-qty-adjustment-note')).toHaveTextContent( + `Quantity adjusted to the minimum of ${BUY_QUANTITY_BOUNDS.MIN_QTY}.` + ); + }); + + it('clamps values above maximum to maximum on blur', () => { + renderDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // Input above maximum + fireEvent.change(input, { target: { value: '150' } }); + fireEvent.blur(input); + + expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MAX_QTY.toString()); + expect(screen.getByTestId('buy-qty-adjustment-note')).toHaveTextContent( + `Quantity adjusted to the maximum of ${BUY_QUANTITY_BOUNDS.MAX_QTY}.` + ); + }); + + it('rounds decimal inputs on blur', () => { + renderDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // Decimal input + fireEvent.change(input, { target: { value: '5.6' } }); + fireEvent.blur(input); + + expect(input.value).toBe('6'); + expect(screen.getByTestId('buy-qty-adjustment-note')).toHaveTextContent( + 'Quantity rounded to 6.' + ); + }); + + it('does not clamp or show a note for valid quantities on blur', () => { + renderDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // Valid quantity + fireEvent.change(input, { target: { value: '10' } }); + fireEvent.blur(input); + + expect(input.value).toBe('10'); + expect(screen.queryByTestId('buy-qty-adjustment-note')).not.toBeInTheDocument(); + }); + + it('clears the adjustment note on input change', () => { + renderDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // Trigger adjustment note + fireEvent.change(input, { target: { value: '0' } }); + fireEvent.blur(input); + expect(screen.getByTestId('buy-qty-adjustment-note')).toBeInTheDocument(); + + // Change input + fireEvent.change(input, { target: { value: '5' } }); + expect(screen.queryByTestId('buy-qty-adjustment-note')).not.toBeInTheDocument(); + }); +}); diff --git a/src/constants/fees.ts b/src/constants/fees.ts index bf87e95..35428de 100644 --- a/src/constants/fees.ts +++ b/src/constants/fees.ts @@ -12,6 +12,11 @@ export const KEY_PRICE_BOUNDS = { MAX_PRICE: 100, } as const; +export const BUY_QUANTITY_BOUNDS = { + MIN_QTY: 1, + MAX_QTY: 100, +} as const; + export const TRADE_FEE_ESTIMATE = { DEFAULT_NETWORK_FEE: 0.0001, UNIT: 'ETH', diff --git a/src/utils/__tests__/buyQuantity.test.ts b/src/utils/__tests__/buyQuantity.test.ts new file mode 100644 index 0000000..556f8a0 --- /dev/null +++ b/src/utils/__tests__/buyQuantity.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { clampBuyQuantity } from '../buyQuantity'; +import { BUY_QUANTITY_BOUNDS } from '@/constants/fees'; + +describe('clampBuyQuantity utility', () => { + it('keeps valid integer inputs within bounds unchanged', () => { + const result = clampBuyQuantity(5); + expect(result.value).toBe(5); + expect(result.adjusted).toBe(false); + expect(result.reason).toBe('none'); + }); + + it('keeps border values unchanged', () => { + const minResult = clampBuyQuantity(BUY_QUANTITY_BOUNDS.MIN_QTY); + expect(minResult.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY); + expect(minResult.adjusted).toBe(false); + + const maxResult = clampBuyQuantity(BUY_QUANTITY_BOUNDS.MAX_QTY); + expect(maxResult.value).toBe(BUY_QUANTITY_BOUNDS.MAX_QTY); + expect(maxResult.adjusted).toBe(false); + }); + + it('clamps values below minimum to minimum', () => { + const zeroResult = clampBuyQuantity(0); + expect(zeroResult.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY); + expect(zeroResult.adjusted).toBe(true); + expect(zeroResult.reason).toBe('below_min'); + + const negativeResult = clampBuyQuantity(-10); + expect(negativeResult.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY); + expect(negativeResult.adjusted).toBe(true); + expect(negativeResult.reason).toBe('below_min'); + }); + + it('clamps values above maximum to maximum', () => { + const excessiveResult = clampBuyQuantity(105); + expect(excessiveResult.value).toBe(BUY_QUANTITY_BOUNDS.MAX_QTY); + expect(excessiveResult.adjusted).toBe(true); + expect(excessiveResult.reason).toBe('above_max'); + }); + + it('handles invalid numeric inputs (NaN, empty string) by clamping to minimum', () => { + const nanResult = clampBuyQuantity(NaN); + expect(nanResult.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY); + expect(nanResult.adjusted).toBe(true); + expect(nanResult.reason).toBe('below_min'); + + const emptyResult = clampBuyQuantity(''); + expect(emptyResult.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY); + expect(emptyResult.adjusted).toBe(true); + expect(emptyResult.reason).toBe('below_min'); + }); + + it('rounds fractional inputs to the nearest integer', () => { + const fractionResult = clampBuyQuantity(5.4); + expect(fractionResult.value).toBe(5); + expect(fractionResult.adjusted).toBe(true); + + const fractionUpResult = clampBuyQuantity(5.7); + expect(fractionUpResult.value).toBe(6); + expect(fractionUpResult.adjusted).toBe(true); + }); + + it('allows custom min and max bounds', () => { + const customResult = clampBuyQuantity(25, 5, 20); + expect(customResult.value).toBe(20); + expect(customResult.adjusted).toBe(true); + expect(customResult.reason).toBe('above_max'); + + const customMinResult = clampBuyQuantity(2, 5, 20); + expect(customMinResult.value).toBe(5); + expect(customMinResult.adjusted).toBe(true); + expect(customMinResult.reason).toBe('below_min'); + }); +}); diff --git a/src/utils/buyQuantity.ts b/src/utils/buyQuantity.ts new file mode 100644 index 0000000..80b99b2 --- /dev/null +++ b/src/utils/buyQuantity.ts @@ -0,0 +1,64 @@ +import { BUY_QUANTITY_BOUNDS } from '@/constants/fees'; + +export interface ClampingResult { + value: number; + adjusted: boolean; + original: number; + reason: 'below_min' | 'above_max' | 'none'; +} + +/** + * Clamps a given buy quantity to the defined minimum and maximum bounds. + * Non-numeric inputs are treated as below the minimum. + * Values are rounded to the nearest integer as keys are discrete assets. + * + * @param input The input value to clamp. + * @param min The minimum allowed quantity (defaults to BUY_QUANTITY_BOUNDS.MIN_QTY). + * @param max The maximum allowed quantity (defaults to BUY_QUANTITY_BOUNDS.MAX_QTY). + */ +export function clampBuyQuantity( + input: number | string, + min: number = BUY_QUANTITY_BOUNDS.MIN_QTY, + max: number = BUY_QUANTITY_BOUNDS.MAX_QTY +): ClampingResult { + const parsed = typeof input === 'number' ? input : parseFloat(input); + + if (isNaN(parsed) || !isFinite(parsed)) { + return { + value: min, + adjusted: true, + original: NaN, + reason: 'below_min', + }; + } + + // Quantities must be integers + const rounded = Math.round(parsed); + + if (rounded < min) { + return { + value: min, + adjusted: true, + original: parsed, + reason: 'below_min', + }; + } + + if (rounded > max) { + return { + value: max, + adjusted: true, + original: parsed, + reason: 'above_max', + }; + } + + const adjustedByRounding = rounded !== parsed; + + return { + value: rounded, + adjusted: adjustedByRounding, + original: parsed, + reason: 'none', + }; +}