Skip to content

Commit

Permalink
refactor: improve amount handling
Browse files Browse the repository at this point in the history
  • Loading branch information
glevco committed Nov 29, 2024
1 parent ad6ca5a commit 2a2eee6
Show file tree
Hide file tree
Showing 12 changed files with 81 additions and 201 deletions.
103 changes: 38 additions & 65 deletions src/components/InputNumber.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import React, { useState, useLayoutEffect, useRef, useEffect } from "react";
import PropTypes from "prop-types";
import { useSelector } from 'react-redux';
import hathorLib from "@hathor/wallet-lib";
import { numberUtils } from "@hathor/wallet-lib";

/**
* Component that enhances typing numbers
Expand All @@ -28,43 +28,36 @@ const InputNumber = React.forwardRef(
(
{
defaultValue,
precision,
separator,
locale,
isNFT,
onValueChange,
requirePositive,
...otherProps
},
ref
) => {

const decimalPlaces = useSelector((state) => state.serverInfo.decimalPlaces);
const decimalPrecision = precision ?? decimalPlaces;
if (ref !== null) {
// TODO: We should just convert InputNumber from a React.forwardRef to a normal React.Component,
// but we may do this in a separate PR
throw Error('do not use ref in InputNumber, instead use onValueChange')
}
const decimalPlaces = isNFT ? 0 : useSelector((state) => state.serverInfo.decimalPlaces);

/**
* Formats a string following the pattern 9,999.99. It decomposes rawValue into decimal and fractional parts, mainly to add the thousands separator.
* Formats a number following the pattern 9,999.99
*
* @param {string} rawValue String to be formatted
* @param {number} rawValue Number to be formatted
*
* @return {string} Formatted string
*/
const format = (rawValue = "") => {
const value = String(rawValue)
.replace(/[^\d]/g, "")
.padStart(decimalPrecision + 1, "0");
const decimalPart = Intl.NumberFormat(locale).format(
value.substr(0, value.length - decimalPrecision)
);
const fractionalPart = value.substr(value.length - decimalPrecision);
if (fractionalPart.length === 0) {
return decimalPart;
} else {
return `${decimalPart}${separator}${fractionalPart}`;
const format = (rawValue) => {
if (typeof rawValue !== 'number') {
throw Error(`value must be a number: ${rawValue}`)
}
return numberUtils.prettyValue(rawValue, decimalPlaces)
};

const innerRef = ref || useRef();
const [value, setValue] = useState(format(defaultValue));
const innerRef = useRef();
const [value, setValue] = useState(defaultValue);

/**
* Listen keydown events while this component is focused overriding the default native input behavior.
Expand All @@ -78,7 +71,7 @@ const InputNumber = React.forwardRef(
const isBackspace = evt.key === "Backspace" || evt.key === "Delete";
const isDeleteAll =
isBackspace &&
evt.target.selectionEnd - evt.target.selectionStart >= value.length;
evt.target.selectionEnd - evt.target.selectionStart >= format(value).length;
const isCtrlOrMeta = evt.ctrlKey || evt.metaKey;

// Do not handle keyboard events when ctrlKey or metaKey are present
Expand All @@ -88,36 +81,34 @@ const InputNumber = React.forwardRef(

let newValue = value;
if (isDeleteAll) {
newValue = "";
newValue = 0;
} else if (isNumberChar) {
newValue = value.concat(evt.key);
newValue = value * 10 + Number(evt.key);
} else if (isBackspace) {
newValue = value.slice(0, -1);
newValue = Math.floor(value / 10);
}
newValue = format(newValue);
updateCaretPosition(newValue);
return newValue;
});

/**
* Handle onClick events just to update the caret position.
*
* @param {MouseEvent} evt MouseEvent triggered when the input or its inner content is clicked
*/
const onClick = (evt) => {
updateCaretPosition(format(evt.target.value));
};
const onClick = () => setValue((currentValue) => {
updateCaretPosition(currentValue);
return currentValue;
});

/**
* Put the caret always at the end.
*
* @param {string} value Current input value
* @param {number} value Current input value
*/
const updateCaretPosition = (value) => {
setTimeout(() => {
const { current } = innerRef;
if (current) {
current.selectionStart = value.length;
current.selectionStart = format(value).length;
}
});
};
Expand All @@ -131,11 +122,10 @@ const InputNumber = React.forwardRef(
*/
const onPaste = (evt) =>
setValue(() => {
const paste = format(
(evt.clipboardData || window.clipboardData).getData("text")
);
updateCaretPosition(paste);
return paste;
const paste = (evt.clipboardData || window.clipboardData).getData("text");
const newValue = Number(paste.replace(/\D/g, ''))
updateCaretPosition(newValue);
return newValue;
});

/**
Expand All @@ -159,58 +149,41 @@ const InputNumber = React.forwardRef(
* Call onValueChange every time the value changes, similarly the native onChange callback.
*/
useEffect(() => {
const parsedValue =
Number(value.replace(/[^\d]/g, "")) / Math.pow(10, decimalPrecision);
onValueChange(parsedValue);
if (requirePositive && parsedValue <= 0) {
onValueChange(value);
if (requirePositive && value <= 0) {
innerRef.current.setCustomValidity('Must be a positive number.');
} else {
innerRef.current.setCustomValidity('');
}
}, [value]);

return <input ref={innerRef} value={value} {...otherProps} type="text" />;
return <input ref={innerRef} value={format(value)} {...otherProps} type="text" />;
}
);

InputNumber.propTypes = {
/**
* Same behavior of React input defaultValue
*/
defaultValue: PropTypes.string,
defaultValue: PropTypes.number,
/**
* Number of digits after the separator
* Whether this is a NFT input
*/
precision: PropTypes.number,
/**
* Generally a dot or a comma char
*/
separator: PropTypes.string,
/**
* Locale (e.g.: 'en-US', 'pt-br'). Must be used in conjunction with `separator`
*/
locale: PropTypes.string,
isNFT: PropTypes.bool,
/**
* Similar to onChange, but it receives the parsed value as single parameter
*/
onValueChange: PropTypes.func,
/**
* Same behavior of React input onChange
*/
onChange: PropTypes.func,
/**
* If the input value is required to be a positive number, i.e. > 0
*/
requirePositive: PropTypes.bool,
};

InputNumber.defaultProps = {
defaultValue: "",
precision: null,
separator: ".",
locale: "en-US",
defaultValue: 0,
isNFT: false,
onValueChange: () => {},
onChange: () => {},
requirePositive: false,
};

Expand Down
9 changes: 2 additions & 7 deletions src/components/OutputsWrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import React from 'react';
import { t } from 'ttag';
import $ from 'jquery';
import _ from 'lodash';
import hathorLib from '@hathor/wallet-lib';
import InputNumber from './InputNumber';
import LOCAL_STORE from '../storage';
import { connect } from 'react-redux';
Expand All @@ -30,7 +29,7 @@ class OutputsWrapper extends React.Component {
super(props);

this.address = React.createRef();
this.value = React.createRef();
this.value = null;
this.timelock = React.createRef();
this.timelockCheckbox = React.createRef();
this.uniqueID = _.uniqueId()
Expand All @@ -55,11 +54,7 @@ class OutputsWrapper extends React.Component {
render = () => {
const renderInputNumber = () => {
const classNames = "form-control output-value col-2";
if (this.props.isNFT) {
return <InputNumber key="nft-value" ref={this.value} className={classNames} placeholder="0" precision={0} />;
} else {
return <InputNumber key="value" ref={this.value} placeholder={hathorLib.numberUtils.prettyValue(0, this.props.decimalPlaces)} className={classNames} />;
}
return <InputNumber key={this.props.isNFT ? "nft-value" : "value"} onValueChange={(value) => this.value = value} className={classNames} isNFT={this.props.isNFT} />;
}

return (
Expand Down
9 changes: 4 additions & 5 deletions src/components/SendTokensOne.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,16 +106,15 @@ class SendTokensOne extends React.Component {
let data = {'outputs': [], 'inputs': []};
for (const output of this.outputs) {
const address = output.current.address.current.value.replace(/\s/g, '');
const valueStr = (output.current.value.current.value || "").replace(/,/g, '');
const value = output.current.value;

if (address && valueStr) {
if (address && value) {
// Doing the check here because need to validate before doing parseInt
const tokensValue = this.isNFT() ? parseInt(valueStr) : wallet.decimalToInteger(valueStr, this.props.decimalPlaces);
if (tokensValue > hathorLib.constants.MAX_OUTPUT_VALUE) {
if (value > hathorLib.constants.MAX_OUTPUT_VALUE) {
this.props.updateState({ errorMessage: `Token: ${this.state.selected.symbol}. Output: ${output.current.props.index}. Maximum output value is ${hathorLib.numberUtils.prettyValue(hathorLib.constants.MAX_OUTPUT_VALUE, this.isNFT() ? 0 : this.props.decimalPlaces)}` });
return null;
}
let dataOutput = {'address': address, 'value': parseInt(tokensValue, 10), 'token': this.state.selected.uid};
let dataOutput = {'address': address, 'value': value, 'token': this.state.selected.uid};

const hasTimelock = output.current.timelockCheckbox.current.checked;
if (hasTimelock) {
Expand Down
9 changes: 3 additions & 6 deletions src/components/atomic-swap/ModalAtomicReceive.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import React, { useState, useRef, useEffect } from "react";
import { t } from "ttag";
import InputNumber from "../InputNumber";
import hathorLib, { Address } from "@hathor/wallet-lib";
import { Address } from "@hathor/wallet-lib";
import walletUtils from '../../utils/wallet';
import { getGlobalWallet } from "../../modules/wallet";
import { useSelector } from 'react-redux';
Expand All @@ -18,7 +18,6 @@ export function ModalAtomicReceive ({ sendClickHandler, receivableTokens, manage
const wallet = getGlobalWallet();
const [selectedToken, setSelectedToken] = useState(receivableTokens[0]);
const [address, setAddress] = useState('');
let amountRef = useRef();
const [amount, setAmount] = useState(0);
const [errMessage, setErrMessage] = useState('');
const modalDomId = 'atomicReceiveModal';
Expand Down Expand Up @@ -69,7 +68,7 @@ export function ModalAtomicReceive ({ sendClickHandler, receivableTokens, manage

// On success, clean error message and return user input
setErrMessage('');
sendClickHandler({ selectedToken, address, amount: walletUtils.decimalToInteger(amount, decimalPlaces) });
sendClickHandler({ selectedToken, address, amount });
onClose(`#${modalDomId}`);
}

Expand Down Expand Up @@ -116,9 +115,7 @@ export function ModalAtomicReceive ({ sendClickHandler, receivableTokens, manage
<label htmlFor="amount" className="col-3 my-auto">{t`Amount`}: </label>
<InputNumber key="value"
name="amount"
ref={amountRef}
defaultValue={hathorLib.numberUtils.prettyValue(amount, decimalPlaces)}
placeholder={hathorLib.numberUtils.prettyValue(0, decimalPlaces)}
defaultValue={amount}
onValueChange={value => setAmount(value)}
className="form-control output-value col-3"/>
</div>
Expand Down
14 changes: 5 additions & 9 deletions src/components/atomic-swap/ModalAtomicSend.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import React, { useEffect, useRef, useState } from "react";
import { t } from "ttag";
import InputNumber from "../InputNumber";
import hathorLib, { Address, numberUtils } from "@hathor/wallet-lib";
import { Address, numberUtils } from "@hathor/wallet-lib";
import { translateTxToProposalUtxo } from "../../utils/atomicSwap";
import { TOKEN_DOWNLOAD_STATUS } from "../../sagas/tokens";
import { get } from 'lodash';
Expand Down Expand Up @@ -130,7 +130,6 @@ function UtxoSelection ({ wallet, utxos, token, utxosChanged, setErrMessage }) {
export function ModalAtomicSend ({ sendClickHandler, sendableTokens, tokenBalances, manageDomLifecycle, onClose, wallet }) {
const [selectedToken, setSelectedToken] = useState(sendableTokens.length && sendableTokens[0]);
const [changeAddress, setChangeAddress] = useState('');
let amountRef = useRef();
const [amount, setAmount] = useState(0);
const [errMessage, setErrMessage] = useState('');
const modalDomId = 'atomicSendModal';
Expand Down Expand Up @@ -203,9 +202,8 @@ export function ModalAtomicSend ({ sendClickHandler, sendableTokens, tokenBalanc
}

// Validating available balance
const selectedAmount = walletUtils.decimalToInteger(amount, decimalPlaces);
const availableAmount = selectedTokenBalance.data.available;
if (selectedAmount > availableAmount) {
if (amount > availableAmount) {
setErrMessage(t`Insufficient balance`);
return false;
}
Expand All @@ -214,7 +212,7 @@ export function ModalAtomicSend ({ sendClickHandler, sendableTokens, tokenBalanc
if (showUtxoSelection) {
const validUtxos = utxos.filter(u => u.amount);
const totalAmount = validUtxos.reduce((acc, u) => acc + u.amount, 0);
if (selectedAmount > totalAmount) {
if (amount > totalAmount) {
setErrMessage(t`Insufficient balance on selected inputs`);
return false;
}
Expand All @@ -239,7 +237,7 @@ export function ModalAtomicSend ({ sendClickHandler, sendableTokens, tokenBalanc
sendClickHandler({
selectedToken,
changeAddress: changeAddress,
amount: walletUtils.decimalToInteger(amount, decimalPlaces),
amount,
utxos: selectedUtxos,
});
onClose(`#${modalDomId}`);
Expand Down Expand Up @@ -325,9 +323,7 @@ export function ModalAtomicSend ({ sendClickHandler, sendableTokens, tokenBalanc
<label htmlFor="amount" className="col-3 my-auto">{t`Amount`}: </label>
<InputNumber key="value"
name="amount"
ref={amountRef}
defaultValue={numberUtils.prettyValue(amount, decimalPlaces)}
placeholder={numberUtils.prettyValue(0, decimalPlaces)}
defaultValue={amount}
onValueChange={value => setAmount(value)}
className="form-control output-value col-3"/>
</div>
Expand Down
Loading

0 comments on commit 2a2eee6

Please sign in to comment.