diff --git a/app/components/AmountInput/AmountInput.js b/app/components/AmountInput/AmountInput.js new file mode 100644 index 00000000..b9efb05e --- /dev/null +++ b/app/components/AmountInput/AmountInput.js @@ -0,0 +1,164 @@ +import React from 'react' +import PropTypes from 'prop-types' + +class AmountInput extends React.Component { + constructor(props) { + super(props) + this.handleChange = this.handleChange.bind(this) + this.handleBlur = this.handleBlur.bind(this) + this.handleKeyDown = this.handleKeyDown.bind(this) + } + + setRules() { + const { currency } = this.props + switch (currency) { + case 'btc': + this.rules = { + precision: 8, + placeholder: '0.00000000', + pattern: '[0-9.]*' + } + break + case 'bits': + this.rules = { + precision: 2, + placeholder: '0.00', + pattern: '[0-9.]*' + } + break + case 'sats': + this.rules = { + precision: 0, + placeholder: '00000000', + pattern: '[0-9]*' + } + break + default: + this.rules = { + precision: 2, + pattern: '[0-9]*' + } + } + } + + parseNumber(_value) { + let value = _value || '' + if (typeof _value === 'string') { + value = _value.replace(/[^0-9.]/g, '') + } + let integer = null + let fractional = null + if (value * 1.0 < 0) { + value = '0.0' + } + // pearse integer and fractional value so that we can reproduce the same string value afterwards + // [0, 0] === 0.0 + // [0, ''] === 0. + // [0, null] === 0 + if (value.match(/^[0-9]*\.[0-9]*$/)) { + ;[integer, fractional] = value.toString().split(/\./) + if (!fractional) { + fractional = '' + } + } else { + integer = value + } + // Limit fractional precision to the correct number of decimal places. + if (fractional && fractional.length > this.rules.precision) { + fractional = fractional.substring(0, this.rules.precision) + } + + return [integer, fractional] + } + + formatValue(integer, fractional) { + let value + if (fractional && fractional.length > 0) { + value = `${integer}.${fractional}` + } else { + // Empty string means `XYZ.` instead of just plain `XYZ`. + if (fractional === '') { + value = `${integer}.` + } else { + value = `${integer}` + } + } + return value + } + + handleChange(e) { + let { value } = e.target + const [integer, fractional] = this.parseNumber(value) + value = this.formatValue(integer, fractional) + + const { onChangeEvent } = this.props + if (onChangeEvent) { + onChangeEvent(value) + } + } + + handleBlur(e) { + let { value } = e.target + const [integer, fractional] = this.parseNumber(value) + value = this.formatValue(integer, fractional) + + const { onBlurEvent } = this.props + if (onBlurEvent) { + onBlurEvent(value) + } + } + + handleKeyDown(e) { + // Do nothing if the user did select all key combo. + if (e.metaKey && e.key === 'a') { + return + } + + // Do not allow multiple dots. + let { value } = e.target + if (e.key === '.') { + if (value.search(/\./) >= 0) { + e.preventDefault() + } + return + } + + if (e.key.length === 1 && !e.key.match(/^[0-9.]$/)) { + e.preventDefault() + return + } + } + + render() { + let { amount = '', readOnly } = this.props + if (amount === null) { + amount = '' + } + this.setRules() + + return ( + <input + id="amount" + onChange={this.handleChange} + onBlur={this.handleBlur} + onKeyDown={this.handleKeyDown} + readOnly={readOnly} + type="text" + required + value={amount} + pattern={this.rules.pattern} + placeholder={this.rules.placeholder} + /> + ) + } +} + +AmountInput.propTypes = { + amount: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + currency: PropTypes.string.isRequired, + onChangeEvent: PropTypes.func, + onBlurEvent: PropTypes.func, + readOnly: PropTypes.bool +} + +export default AmountInput diff --git a/app/components/AmountInput/AmountInput.scss b/app/components/AmountInput/AmountInput.scss new file mode 100644 index 00000000..e69de29b diff --git a/app/components/AmountInput/index.js b/app/components/AmountInput/index.js new file mode 100644 index 00000000..37d612c0 --- /dev/null +++ b/app/components/AmountInput/index.js @@ -0,0 +1,3 @@ +import AmountInput from './AmountInput' + +export default AmountInput diff --git a/app/components/Contacts/SubmitChannelForm.js b/app/components/Contacts/SubmitChannelForm.js index 207472c4..d3d27e93 100644 --- a/app/components/Contacts/SubmitChannelForm.js +++ b/app/components/Contacts/SubmitChannelForm.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types' import FaAngleDown from 'react-icons/lib/fa/angle-down' +import AmountInput from 'components/AmountInput' import styles from './SubmitChannelForm.scss' class SubmitChannelForm extends React.Component { @@ -16,6 +17,8 @@ class SubmitChannelForm extends React.Component { updateContactCapacity, openChannel, + ticker, + toggleCurrencyProps: { setContactsCurrencyFilters, showCurrencyFilters, @@ -73,14 +76,11 @@ class SubmitChannelForm extends React.Component { <section className={styles.amount}> <div className={styles.input}> - <input - type="number" - min="0" - size="" - placeholder="0.00000000" - value={contactCapacity || ''} - onChange={event => updateContactCapacity(event.target.value)} + <AmountInput id="amount" + amount={contactCapacity} + currency={ticker.currency} + onChangeEvent={updateContactCapacity} /> <div className={styles.currency}> <section @@ -127,6 +127,8 @@ SubmitChannelForm.propTypes = { updateContactCapacity: PropTypes.func.isRequired, openChannel: PropTypes.func.isRequired, + ticker: PropTypes.object.isRequired, + toggleCurrencyProps: PropTypes.object.isRequired } diff --git a/app/components/Form/Pay.js b/app/components/Form/Pay.js index 9ad81b6d..76085c9d 100644 --- a/app/components/Form/Pay.js +++ b/app/components/Form/Pay.js @@ -7,6 +7,7 @@ import link from 'icons/link.svg' import FaAngleDown from 'react-icons/lib/fa/angle-down' import { btc } from 'lib/utils' +import AmountInput from 'components/AmountInput' import styles from './Pay.scss' @@ -132,18 +133,12 @@ class Pay extends Component { <span /> </div> <div className={styles.bottom}> - <input - type="number" - min="0" - ref={input => { - this.amountInput = input - }} - size="" - placeholder="0.00000000" - value={currentAmount || ''} - onChange={event => setPayAmount(event.target.value)} - onBlur={onPayAmountBlur} + <AmountInput id="amount" + amount={currentAmount} + currency={ticker.currency} + onChangeEvent={setPayAmount} + onBlurEvent={onPayAmountBlur} readOnly={isLn} /> <div className={styles.currency}> diff --git a/app/components/Form/Request.js b/app/components/Form/Request.js index ed0128dc..5f61c28a 100644 --- a/app/components/Form/Request.js +++ b/app/components/Form/Request.js @@ -6,6 +6,7 @@ import hand from 'icons/hand.svg' import FaAngleDown from 'react-icons/lib/fa/angle-down' import { btc } from 'lib/utils' +import AmountInput from 'components/AmountInput' import styles from './Request.scss' const Request = ({ @@ -45,12 +46,11 @@ const Request = ({ <span /> </div> <div className={styles.bottom}> - <input - type="number" - value={amount || ''} - onChange={event => setRequestAmount(event.target.value)} + <AmountInput id="amount" - placeholder="0.00000000" + amount={amount} + currency={ticker.currency} + onChangeEvent={setRequestAmount} /> <div className={styles.currency}> <section diff --git a/app/routes/app/containers/AppContainer.js b/app/routes/app/containers/AppContainer.js index b00c4806..dd58dd4e 100644 --- a/app/routes/app/containers/AppContainer.js +++ b/app/routes/app/containers/AppContainer.js @@ -409,6 +409,8 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { openChannel: dispatchProps.openChannel, + ticker: stateProps.ticker, + toggleCurrencyProps: { currentCurrencyFilters: stateProps.currentCurrencyFilters, currencyName: stateProps.currencyName, diff --git a/test/unit/components/AmountInput.spec.js b/test/unit/components/AmountInput.spec.js new file mode 100644 index 00000000..db6d0789 --- /dev/null +++ b/test/unit/components/AmountInput.spec.js @@ -0,0 +1,251 @@ +import React from 'react' +import { configure, mount } from 'enzyme' +import Adapter from 'enzyme-adapter-react-16' + +import AmountInput from 'components/AmountInput' + +configure({ adapter: new Adapter() }) + +let component +let wrapped +let input + +const defaultProps = { + amount: '', + currency: '', + + onChange: () => {} +} + +const mountInput = props => { + wrapped = mount(<AmountInput {...defaultProps} {...props} />) + input = wrapped.find('input') + component = wrapped.instance() +} + +describe('AmountInput', () => { + describe('render', () => { + it('renders amount', () => { + mountInput({ amount: '0.1234' }) + expect(input.props().value).toEqual('0.1234') + }) + }) + + describe('input properties', () => { + it('passes readOnly to input', () => { + mountInput({ readOnly: true }) + expect(input.props().readOnly).toEqual(true) + }) + }) + + describe('parseNumber', () => { + const test = (from, to) => { + expect(component.parseNumber(from)).toEqual(to) + } + beforeEach(() => mountInput()) + + it('splits number into integer and fraction', () => { + test(null, ['', null]) + test('', ['', null]) + test('0', ['0', null]) + test('00', ['00', null]) + test('0.', ['0', '']) + + test('1', ['1', null]) + test('01', ['01', null]) + test('1.', ['1', '']) + + test('0.0', ['0', '0']) + test('0.1', ['0', '1']) + + test('0.01', ['0', '01']) + test('0.10', ['0', '10']) + + test('1.0', ['1', '0']) + test('01.0', ['01', '0']) + }) + + describe('bitcoin', () => { + beforeEach(() => { + let props = { ...defaultProps, currency: 'btc' } + component = mount(<AmountInput {...props} />).instance() + }) + it('has a precision of 8', () => { + test('1.1', ['1', '1']) + test('1.12', ['1', '12']) + test('1.123', ['1', '123']) + test('1.1234', ['1', '1234']) + test('1.12345', ['1', '12345']) + test('1.123456', ['1', '123456']) + test('1.1234567', ['1', '1234567']) + test('1.12345678', ['1', '12345678']) + test('1.123456789', ['1', '12345678']) + test('1.1234567890', ['1', '12345678']) + }) + }) + + describe('bits', () => { + beforeEach(() => { + let props = { ...defaultProps, currency: 'bits' } + component = mount(<AmountInput {...props} />).instance() + }) + it('has a precision of 2', () => { + test('1.1', ['1', '1']) + test('1.12', ['1', '12']) + test('1.123', ['1', '12']) + test('1.1234', ['1', '12']) + }) + }) + + describe('sats', () => { + beforeEach(() => { + let props = { ...defaultProps, currency: 'sats' } + component = mount(<AmountInput {...props} />).instance() + }) + it('has a precision of 0', () => { + test('1.1', ['1', '']) + test('1.12', ['1', '']) + }) + }) + }) + + describe('formatValue', () => { + const component = mount(<AmountInput {...defaultProps} />).instance() + const test = (from, to) => { + expect(component.formatValue(from[0], from[1])).toEqual(to) + } + + it('turns parsed number into a string', () => { + test(['0', null], '0') + test(['00', null], '00') + test(['0', ''], '0.') + + test(['1', null], '1') + test(['01', null], '01') + test(['1', ''], '1.') + + test(['0', '0'], '0.0') + test(['0', '1'], '0.1') + + test(['0', '01'], '0.01') + test(['0', '10'], '0.10') + + test(['1', '0'], '1.0') + test(['01', '0'], '01.0') + }) + }) + + describe('handleChange', () => { + let onChangeEvent + let component + beforeEach(() => { + onChangeEvent = jest.fn() + let props = { ...defaultProps, onChangeEvent } + component = mount(<AmountInput {...props} />).instance() + }) + + it('sends value to parseNumber', () => { + jest.spyOn(component, 'parseNumber') + + component.handleChange({ target: { value: '123.0' } }) + expect(component.parseNumber).toHaveBeenCalledTimes(1) + expect(component.parseNumber).toHaveBeenCalledWith('123.0') + }) + + it('calls formatValue with presult from parseNumber', () => { + jest.spyOn(component, 'formatValue') + + component.handleChange({ target: { value: '123.0' } }) + expect(component.formatValue).toHaveBeenCalledTimes(1) + expect(component.formatValue).toHaveBeenCalledWith('123', '0') + }) + + it('calls props.onChange with formatValue result', () => { + jest.spyOn(component, 'formatValue').mockReturnValue('456') + + component.handleChange({ target: { value: '123.0' } }) + expect(onChangeEvent).toHaveBeenCalledWith('456') + }) + }) + + describe('handleBlur', () => { + let onBlurEvent + let component + beforeEach(() => { + onBlurEvent = jest.fn() + let props = { ...defaultProps, onBlurEvent } + component = mount(<AmountInput {...props} />).instance() + }) + + it('calls props.onBlurEvent with formatValue result', () => { + jest.spyOn(component, 'formatValue').mockReturnValue('456') + + component.handleBlur({ target: { value: '123.0' } }) + expect(onBlurEvent).toHaveBeenCalledWith('456') + }) + }) + + describe('handleKeyDown', () => { + let event + let preventDefault + + beforeEach(() => { + wrapped = mount(<AmountInput {...defaultProps} />) + component = wrapped.instance() + + preventDefault = jest.fn() + }) + + describe('dot', () => { + beforeEach(() => { + wrapped = mount(<AmountInput {...defaultProps} />) + component = wrapped.instance() + preventDefault = jest.fn() + + event = { + key: '.', + target: { value: '123' }, + preventDefault + } + }) + + it('allows adding dot to a number that has no dot', () => { + component.handleKeyDown({ ...event, target: { value: '123' } }) // no dot + expect(preventDefault).not.toHaveBeenCalled() + }) + + it('does not allow multiple dots in the number', () => { + component.handleKeyDown({ ...event, target: { value: '123.' } }) // has a dot + expect(preventDefault).toHaveBeenCalled() + }) + }) + + describe('Non-supported keys', () => { + beforeEach(() => { + wrapped = mount(<AmountInput {...defaultProps} />) + component = wrapped.instance() + preventDefault = jest.fn() + + event = { + key: '.', + target: { value: '123' }, + preventDefault + } + }) + + it('prevents event', () => { + component.handleKeyDown({ ...event, key: 'b' }) + expect(preventDefault).toHaveBeenCalled() + + component.handleKeyDown({ ...event, key: 'B' }) + expect(preventDefault).toHaveBeenCalled() + + component.handleKeyDown({ ...event, key: '-' }) + expect(preventDefault).toHaveBeenCalled() + + component.handleKeyDown({ ...event, key: '?' }) + expect(preventDefault).toHaveBeenCalled() + }) + }) + }) +}) diff --git a/test/unit/components/Form.spec.js b/test/unit/components/Form.spec.js index f4f8a219..ef2cf33a 100644 --- a/test/unit/components/Form.spec.js +++ b/test/unit/components/Form.spec.js @@ -15,10 +15,12 @@ const payFormProps = { invoice: {}, showErrors: {} }, - currency: {}, - crypto: {}, + currency: '', + crypto: '', nodes: [], - ticker: {}, + ticker: { + currency: 'btc' + }, isOnchain: false, isLn: true, diff --git a/test/unit/components/Form/Pay.spec.js b/test/unit/components/Form/Pay.spec.js index 8781090f..ef1503e1 100644 --- a/test/unit/components/Form/Pay.spec.js +++ b/test/unit/components/Form/Pay.spec.js @@ -13,10 +13,12 @@ const defaultProps = { invoice: {}, showErrors: {} }, - currency: {}, - crypto: {}, + currency: '', + crypto: '', nodes: [], - ticker: {}, + ticker: { + currency: 'btc' + }, isOnchain: false, isLn: true, diff --git a/test/unit/components/Form/Request.spec.js b/test/unit/components/Form/Request.spec.js index 1c639701..59da8ee5 100644 --- a/test/unit/components/Form/Request.spec.js +++ b/test/unit/components/Form/Request.spec.js @@ -8,7 +8,9 @@ configure({ adapter: new Adapter() }) const defaultProps = { requestform: {}, - ticker: {}, + ticker: { + currency: 'btc' + }, currentCurrencyFilters: [], showCurrencyFilters: true,