Browse Source

Merge pull request #881 from mrfelton/feat/crypto-fiat-fields

feat: add Fiat/Crypto amount field components
renovate/lint-staged-8.x
JimmyMow 6 years ago
committed by GitHub
parent
commit
a35704c380
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 149
      app/components/UI/CryptoAmountInput.js
  2. 131
      app/components/UI/FiatAmountInput.js
  3. 2
      app/components/UI/index.js
  4. 30
      app/lib/utils/btc.js
  5. 51
      app/lib/utils/crypto.js
  6. 3
      package.json
  7. 36
      stories/components/form.stories.js
  8. 27
      test/unit/utils/crypto.spec.js
  9. 39
      yarn.lock

149
app/components/UI/CryptoAmountInput.js

@ -0,0 +1,149 @@
/* eslint-disable react/no-multi-comp */
import React from 'react'
import PropTypes from 'prop-types'
import { asField } from 'informed'
import * as yup from 'yup'
import { convert } from 'lib/utils/btc'
import { formatValue, parseNumber } from 'lib/utils/crypto'
import Input from 'components/UI/Input'
/**
* @render react
* @name CryptoAmountInput
*/
class CryptoAmountInput extends React.Component {
static propTypes = {
currency: PropTypes.string.isRequired,
required: PropTypes.bool,
onChange: PropTypes.func,
onBlur: PropTypes.func
}
/**
* Reformat the value when the currency unit has changed.
*/
componentDidUpdate(prevProps) {
const { currency, fieldApi } = this.props
// Reformat the value when the currency unit has changed.
if (currency !== prevProps.currency) {
const { fieldApi } = this.props
let value = fieldApi.getValue()
const convertedValue = convert(prevProps.currency, currency, value)
const [integer, fractional] = parseNumber(convertedValue, this.getRules().precision)
value = formatValue(integer, fractional)
fieldApi.setValue(value)
}
// If the value has changed, reformat it if needed.
const valueBefore = prevProps.fieldState.value
const valueAfter = fieldApi.getValue()
if (valueAfter !== valueBefore) {
const [integer, fractional] = parseNumber(valueAfter, this.getRules().precision)
const formattedValue = formatValue(integer, fractional)
if (formattedValue !== valueAfter) {
fieldApi.setValue(formattedValue)
}
}
}
getRules = () => {
const { currency } = this.props
switch (currency) {
case 'btc':
return {
precision: 8,
placeholder: '0.00000000',
pattern: '[0-9]*.?[0-9]{0,8}?'
}
case 'bits':
return {
precision: 2,
placeholder: '0.00',
pattern: '[0-9]*.?[0-9]{0,2}?'
}
case 'sats':
return {
precision: 0,
placeholder: '00000000',
pattern: '[0-9]*'
}
default:
return {
precision: 2,
pattern: '[0-9]*'
}
}
}
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() {
const rules = this.getRules()
return (
<Input
{...this.props}
type="text"
placeholder={rules.placeholder}
pattern={rules.pattern}
onKeyDown={this.handleKeyDown}
/>
)
}
}
const CryptoAmountInputAsField = asField(CryptoAmountInput)
class WrappedCryptoAmountInputAsField extends React.Component {
validate = value => {
const { disabled, required } = this.props
if (disabled) {
return
}
try {
const validator = yup
.number()
.positive()
.min(0)
.typeError('A number is required')
if (required) {
validator.required()
}
validator.validateSync(Number(value))
} catch (error) {
return error.message
}
// Run any additional validation provided by the caller.
const { validate } = this.props
if (validate) {
return validate(value)
}
}
render() {
return <CryptoAmountInputAsField validate={this.validate} {...this.props} />
}
}
export default WrappedCryptoAmountInputAsField

131
app/components/UI/FiatAmountInput.js

@ -0,0 +1,131 @@
/* eslint-disable react/no-multi-comp */
import React from 'react'
import PropTypes from 'prop-types'
import { asField } from 'informed'
import * as yup from 'yup'
import { convert } from 'lib/utils/btc'
import { formatValue, parseNumber } from 'lib/utils/crypto'
import Input from 'components/UI/Input'
/**
* @render react
* @name FiatAmountInput
*/
class FiatAmountInput extends React.Component {
static propTypes = {
currency: PropTypes.string.isRequired,
currentTicker: PropTypes.object.isRequired,
required: PropTypes.bool,
onChange: PropTypes.func,
onBlur: PropTypes.func
}
componentDidUpdate(prevProps) {
const { currency, currentTicker, fieldApi } = this.props
// Reformat the value when the currency unit has changed.
if (currency !== prevProps.currency) {
const { fieldApi } = this.props
let value = fieldApi.getValue()
const lastPriceInOrigCurrency = currentTicker[prevProps.currency].last
const lastPriceInNewCurrency = currentTicker[currency].last
// Convert to BTC.
const btcValue = convert('fiat', 'btc', value, lastPriceInOrigCurrency)
// Convert to new currency.
const newFiatValue = convert('btc', 'fiat', btcValue, lastPriceInNewCurrency)
const [integer, fractional] = parseNumber(newFiatValue, this.getRules().precision)
value = formatValue(integer, fractional)
fieldApi.setValue(value)
}
// If the value has changed, reformat it if needed.
const valueBefore = prevProps.fieldState.value
const valueAfter = fieldApi.getValue()
if (valueAfter !== valueBefore) {
const [integer, fractional] = parseNumber(valueAfter, this.getRules().precision)
const formattedValue = formatValue(integer, fractional)
if (formattedValue !== valueAfter) {
fieldApi.setValue(formattedValue)
}
}
}
getRules() {
return {
precision: 2,
placeholder: '0.00',
pattern: '[0-9]*.?[0-9]{0,2}?'
}
}
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() {
const rules = this.getRules()
return (
<Input
{...this.props}
type="text"
placeholder={rules.placeholder}
pattern={rules.pattern}
onKeyDown={this.handleKeyDown}
/>
)
}
}
const FiatAmountInputAsField = asField(FiatAmountInput)
class WrappedFiatAmountInputAsField extends React.Component {
validate = value => {
const { disabled, required } = this.props
if (disabled) {
return
}
try {
const validator = yup
.number()
.positive()
.min(0)
.typeError('A number is required')
if (required) {
validator.required()
}
validator.validateSync(Number(value))
} catch (error) {
return error.message
}
// Run any additional validation provided by the caller.
const { validate } = this.props
if (validate) {
return validate(value)
}
}
render() {
return <FiatAmountInputAsField validate={this.validate} {...this.props} />
}
}
export default WrappedFiatAmountInputAsField

2
app/components/UI/index.js

@ -3,7 +3,9 @@ export BackgroundLight from './BackgroundLight'
export BackgroundLightest from './BackgroundLightest'
export Bar from './Bar'
export Button from './Button'
export CryptoAmountInput from './CryptoAmountInput'
export Dropdown from './Dropdown'
export FiatAmountInput from './FiatAmountInput'
export FormFieldMessage from './FormFieldMessage'
export GlobalStyle from './GlobalStyle'
export Heading from './Heading'

30
app/lib/utils/btc.js

@ -74,6 +74,25 @@ export function satoshisToFiat(satoshis, price) {
return btcToFiat(satoshisToBtc(satoshis), price)
}
////////////////////////////
// fiat to things /////
//////////////////////////
//////////////////////////
export function fiatToBtc(fiat, price) {
if (fiat === undefined || fiat === null || fiat === '' || !price) return null
return Number(fiat / price)
}
export function fiatToBits(fiat, price) {
return btcToBits(fiatToBtc(fiat, price))
}
export function fiatToSatoshis(fiat, price) {
return btcToSatoshis(fiatToBtc(fiat, price))
return btcToFiat(satoshisToBtc(satoshis), price)
}
export function renderCurrency(currency) {
switch (currency) {
case 'btc':
@ -125,6 +144,17 @@ export function convert(from, to, amount, price) {
return amount
}
break
case 'fiat':
switch (to) {
case 'btc':
return fiatToBtc(amount, price)
case 'bits':
return fiatToBits(amount, price)
case 'sats':
return fiatToSatoshis(amount, price)
case 'fiat':
return amount
}
default:
return ''
}

51
app/lib/utils/crypto.js

@ -1,6 +1,57 @@
import bitcoin from 'bitcoinjs-lib'
import bech32 from 'lib/utils/bech32'
/**
* Turns parsed number into a string.
*/
export const 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
}
/**
* Splits number into integer and fraction.
*/
export const parseNumber = (_value, precision) => {
let value = String(_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 > precision) {
fractional = fractional.substring(0, precision)
}
return [integer, fractional]
}
/**
* Test to see if a string is a valid on-chain address.
* @param {String} input string to check.

3
package.json

@ -352,7 +352,8 @@
"styled-reset": "^1.6.0",
"tildify": "^1.2.0",
"untildify": "^3.0.3",
"validator": "^10.8.0"
"validator": "^10.8.0",
"yup": "^0.26.6"
},
"main": "internals/webpack/webpack.config.base.js",
"directories": {

36
stories/components/form.stories.js

@ -4,6 +4,8 @@ import { action } from '@storybook/addon-actions'
import { Box } from 'rebass'
import { Form } from 'informed'
import {
CryptoAmountInput,
FiatAmountInput,
Page,
MainContent,
Input,
@ -45,6 +47,40 @@ storiesOf('Components.Form', module)
<TextArea field="fieldName" placeholder="Type here" />
</Form>
))
.add('CryptoAmountInput', () => (
<Form>
<Box my={4}>
<Box>
<Label htmlFor="cryptoBtc">BTC</Label>
</Box>
<CryptoAmountInput field="cryptoBtc" currency="btc" width={150} />
</Box>
<Box my={4}>
<Box>
<Label htmlFor="cryptoBits">Bits</Label>
</Box>
<CryptoAmountInput field="cryptoBits" currency="bits" width={150} />
</Box>
<Box my={4}>
<Box>
<Label htmlFor="cryptoSats">Sats</Label>
</Box>
<CryptoAmountInput field="cryptoSats" currency="sats" width={150} />
</Box>
</Form>
))
.add('FiatAmountInput', () => (
<Form>
<Box my={4}>
<Box>
<Label htmlFor="fiat">USD</Label>
</Box>
<FiatAmountInput field="fiat" currency="usd" width={150} />
</Box>
</Form>
))
.add('Lightning Invoice Textarea', () => (
<React.Fragment>
<Box my={4}>

27
test/unit/utils/crypto.spec.js

@ -1,5 +1,5 @@
/* eslint-disable max-len */
import { isLn, isOnchain } from 'lib/utils/crypto'
import { formatValue, isLn, isOnchain } from 'lib/utils/crypto'
const VALID_BITCOIN_MAINNET_LN =
'lnbc10u1pduey89pp57gt0mqvh9gv4m5kkxmy9a0a46ha5jlzr3mcfcz2fx8tzu63vpjksdq8w3jhxaqcqzystfg0drarrx89nvpegwykvfr4fypvwz2d9ktcr6tj5s08f0nn8gdjnv74y9amksk3rjw7englhjrsev70k77vwf603qh2pr4tnqeue6qp5n92gy'
@ -98,3 +98,28 @@ describe('Crypto.isOnchain', () => {
})
})
})
describe('formatValue', () => {
const test = (from, to) => {
expect(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')
})
})

39
yarn.lock

@ -711,6 +711,13 @@
core-js "^2.5.7"
regenerator-runtime "^0.12.0"
"@babel/runtime@7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.0.0.tgz#adeb78fedfc855aa05bc041640f3f6f98e85424c"
integrity sha512-7hGhzlcmg01CvH1EHdSPVXYX1aJ8KCEyz6I9xYIi/asDtzBPMyMhVibhM/K6g/5qnKBwjZtp10bNZIEFTRW1MA==
dependencies:
regenerator-runtime "^0.12.0"
"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2":
version "7.1.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.1.2.tgz#81c89935f4647706fc54541145e6b4ecfef4b8e3"
@ -7543,6 +7550,11 @@ flush-write-stream@^1.0.0:
inherits "^2.0.1"
readable-stream "^2.0.4"
fn-name@~2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/fn-name/-/fn-name-2.0.1.tgz#5214d7537a4d06a4a301c0cc262feb84188002e7"
integrity sha1-UhTXU3pNBqSjAcDMJi/rhBiAAuc=
follow-redirects@^1.0.0, follow-redirects@^1.3.0:
version "1.5.9"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.9.tgz#c9ed9d748b814a39535716e531b9196a845d89c6"
@ -12845,6 +12857,11 @@ prop-types@15.x, prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.4, pr
loose-envify "^1.3.1"
object-assign "^4.1.1"
property-expr@^1.5.0:
version "1.5.1"
resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-1.5.1.tgz#22e8706894a0c8e28d58735804f6ba3a3673314f"
integrity sha512-CGuc0VUTGthpJXL36ydB6jnbyOf/rAHFvmVrJlH+Rg0DqqLFQGAP6hIaxD/G0OAmBJPhXDHuEJigrp0e0wFV6g==
property-information@^4.0.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/property-information/-/property-information-4.2.0.tgz#f0e66e07cbd6fed31d96844d958d153ad3eb486e"
@ -15532,6 +15549,11 @@ symbol.prototype.description@^1.0.0:
dependencies:
has-symbols "^1.0.0"
synchronous-promise@^2.0.5:
version "2.0.6"
resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.6.tgz#de76e0ea2b3558c1e673942e47e714a930fa64aa"
integrity sha512-TyOuWLwkmtPL49LHCX1caIwHjRzcVd62+GF6h8W/jHOeZUFHpnd2XJDVuUlaTaLPH1nuu2M69mfHr5XbQJnf/g==
table@^4.0.2:
version "4.0.3"
resolved "https://registry.yarnpkg.com/table/-/table-4.0.3.tgz#00b5e2b602f1794b9acaf9ca908a76386a7813bc"
@ -15805,6 +15827,11 @@ toposort@^1.0.0:
resolved "https://registry.yarnpkg.com/toposort/-/toposort-1.0.7.tgz#2e68442d9f64ec720b8cc89e6443ac6caa950029"
integrity sha1-LmhELZ9k7HILjMieZEOsbKqVACk=
toposort@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330"
integrity sha1-riF2gXXRVZ1IvvNUILL0li8JwzA=
tough-cookie@>=2.3.3, tough-cookie@^2.3.4, tough-cookie@^2.4.3, tough-cookie@~2.4.3:
version "2.4.3"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"
@ -17160,6 +17187,18 @@ yauzl@2.4.1:
dependencies:
fd-slicer "~1.0.1"
yup@^0.26.6:
version "0.26.6"
resolved "https://registry.yarnpkg.com/yup/-/yup-0.26.6.tgz#07e216a1424861f17958fef1d4775c64ef985724"
integrity sha512-Lfj8pAtQ/cDu/wsCuXt2ArQ0uUO/9nfr+EwlD9oQrWIErtjURjdSXYTS1ycN7T/Ok+IUTy23Tdo6Wo0f/wMMBw==
dependencies:
"@babel/runtime" "7.0.0"
fn-name "~2.0.1"
lodash "^4.17.10"
property-expr "^1.5.0"
synchronous-promise "^2.0.5"
toposort "^2.0.2"
zip-stream@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-1.2.0.tgz#a8bc45f4c1b49699c6b90198baacaacdbcd4ba04"

Loading…
Cancel
Save