diff --git a/src/components/CurrencyInput/CurrencyInput.example.js b/src/components/CurrencyInput/CurrencyInput.example.js new file mode 100644 index 00000000..c9e3fd1b --- /dev/null +++ b/src/components/CurrencyInput/CurrencyInput.example.js @@ -0,0 +1,53 @@ +/* eslint-disable import/prefer-default-export */ +import React, { PropTypes } from 'react'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import en from 'react-intl/locale-data/en'; +import fi from 'react-intl/locale-data/fi'; +import { currencyDefaultConfig } from '../../util/currency'; +import CurrencyInput from './CurrencyInput'; + +const defaultConfig = { + ...currencyDefaultConfig, + currency: 'USD', +}; + +// eslint-disable-next-line no-console +const onChange = price => console.log('CurrencyInput - value:', price); + +// Different locales need to be initialized before their currency formatting is in use +const CurrencyInputWithIntl = ({ locale, ...rest }) => { + if (locale === 'en-US') { + addLocaleData([...en]); + } else { + addLocaleData([...fi]); + } + return ( + + ); +}; + +const { object, string } = PropTypes; + +CurrencyInputWithIntl.propTypes = { + currencyConfig: object.isRequired, + locale: string.isRequired, +}; + +// Empty field with en-US locale +export const EmptyWithEnUS = { + component: CurrencyInputWithIntl, + props: { + currencyConfig: defaultConfig, + locale: 'en-US', + }, +}; + +// Default value with fi-FI locale +export const defaultValueWithFiFI = { + component: CurrencyInputWithIntl, + props: { + currencyConfig: { ...defaultConfig, currency: 'EUR' }, + locale: 'fi-FI', + defaultValue: 9999.99, + }, +}; diff --git a/src/components/CurrencyInput/CurrencyInput.js b/src/components/CurrencyInput/CurrencyInput.js new file mode 100644 index 00000000..845eb393 --- /dev/null +++ b/src/components/CurrencyInput/CurrencyInput.js @@ -0,0 +1,210 @@ +/** + * CurrencyInput renders an input field that format it's value according to currency formatting rules + * onFocus: renders given value in unformatted manner: "9999,99" + * onBlur: formats the given input: "9 999,99 €" + */ +import React, { Component, PropTypes } from 'react'; +import { intlShape, injectIntl } from 'react-intl'; +import { types } from 'sharetribe-sdk'; +import { + convertUnitToSubUnit, + currencyDefaultConfig, + ensureDotSeparator, + ensureSeparator, + truncateToSubUnitPrecision, +} from '../../util/currency'; + +const allowedInputProps = allProps => { + // Strip away props that are not passed to input element (or are overwritten) + // eslint-disable-next-line no-unused-vars + const { currencyConfig, defaultValue, intl, input, meta, ...inputProps } = allProps; + return inputProps || {}; +}; + +// Convert unformatted value (e.g. 10,00) to Money (or null) +const getPrice = (unformattedValue, currency) => { + const isEmptyString = unformattedValue === ''; + return isEmptyString + ? null + : new types.Money(convertUnitToSubUnit(unformattedValue, currency), currency); +}; + +class CurrencyInput extends Component { + constructor(props) { + super(props); + const { currencyConfig, defaultValue, intl } = props; + + // We need to handle number format - some locales use dots and some points as decimal separator + // TODO Figure out if this could be digged from React-Intl directly somehow + const testSubUnitFormat = intl.formatNumber('1.1', currencyConfig); + const usesPoint = testSubUnitFormat.indexOf(',') >= 0; + + try { + // whatever is passed as a default value, will be converted to currency string + // Unformatted value is digits + localized sub unit separator ("9,99") + const unformattedValue = defaultValue + ? truncateToSubUnitPrecision( + ensureSeparator(defaultValue.toString(), usesPoint), + currencyConfig.currency, + usesPoint + ) + : ''; + // Formatted value fully localized currency string ("$1,000.99") + const formattedValue = defaultValue + ? intl.formatNumber(ensureDotSeparator(unformattedValue), currencyConfig) + : ''; + + this.state = { + formattedValue, + unformattedValue, + value: formattedValue, + usesPoint, + }; + } catch (e) { + // Print error, if default value isn't supported (see specs: truncateToSubUnitPrecision). + // eslint-disable-next-line no-console + console.error('CurrencyInput:', e); + throw e; + } + + this.onInputChange = this.onInputChange.bind(this); + this.onInputBlur = this.onInputBlur.bind(this); + this.onInputFocus = this.onInputFocus.bind(this); + this.updateValues = this.updateValues.bind(this); + } + + shouldComponentUpdate(nextProps, nextState) { + return !(nextProps === this.props && nextState === this.state); + } + + onInputChange(event) { + event.preventDefault(); + event.stopPropagation(); + // Update value strings on state + const { unformattedValue } = this.updateValues(event); + // Notify parent component about current price change + const price = getPrice( + ensureDotSeparator(unformattedValue), + this.props.currencyConfig.currency + ); + this.props.input.onChange(price); + } + + onInputBlur(event) { + event.preventDefault(); + event.stopPropagation(); + const onBlur = this.props.input.onBlur; + const currency = this.props.currencyConfig.currency; + this.setState(prevState => { + if (onBlur) { + // If parent component has provided onBlur function, call it with current price. + const price = getPrice(ensureDotSeparator(prevState.unformattedValue), currency); + onBlur(price); + } + return { + value: prevState.formattedValue, + }; + }); + } + + onInputFocus(event) { + event.preventDefault(); + event.stopPropagation(); + const onFocus = this.props.input.onFocus; + const currency = this.props.currencyConfig.currency; + this.setState(prevState => { + if (onFocus) { + // If parent component has provided onFocus function, call it with current price. + const price = getPrice(ensureDotSeparator(prevState.unformattedValue), currency); + onFocus(price); + } + return { + value: prevState.unformattedValue, + }; + }); + } + + updateValues(event) { + try { + const { currencyConfig, intl } = this.props; + const targetValue = event.target.value; + const isEmptyString = targetValue === ''; + const valueOrZero = isEmptyString ? '0' : targetValue; + + // truncate decimals to subunit precision: 10000.999 => 10000.99 + const truncatedValueString = truncateToSubUnitPrecision( + valueOrZero, + currencyConfig.currency, + this.state.usesPoint + ); + const unformattedValue = !isEmptyString ? truncatedValueString : ''; + const formattedValue = !isEmptyString + ? intl.formatNumber(ensureDotSeparator(truncatedValueString), currencyConfig) + : ''; + + this.setState({ + formattedValue, + value: unformattedValue, + unformattedValue, + }); + + return { formattedValue, value: unformattedValue, unformattedValue }; + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + + // If an error occurs while filling input field, use previous values + // This ensures that string like '12.3r' doesn't end up to a state. + const { formattedValue, unformattedValue, value } = this.state; + return { formattedValue, unformattedValue, value }; + } + } + + render() { + const { currencyConfig, defaultValue, placeholder, intl } = this.props; + const placeholderText = placeholder || intl.formatNumber(defaultValue, currencyConfig); + return ( + + ); + } +} + +CurrencyInput.defaultProps = { + currencyConfig: currencyDefaultConfig, + defaultValue: 0, + input: null, + placeholder: null, +}; + +const { bool, func, instanceOf, oneOfType, number, shape, string } = PropTypes; + +CurrencyInput.propTypes = { + currencyConfig: shape({ + style: string.isRequired, + currency: string.isRequired, + currencyDisplay: string, + useGrouping: bool, + minimumFractionDigits: number, + maximumFractionDigits: number, + }).isRequired, + defaultValue: number, + intl: intlShape.isRequired, + input: shape({ + value: oneOfType([string, instanceOf(types.Money)]), + onBlur: func, + onChange: func.isRequired, + onFocus: func, + }).isRequired, + + placeholder: string, +}; + +export default injectIntl(CurrencyInput); diff --git a/src/components/index.js b/src/components/index.js index 50ba6519..9dfe723b 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -1,5 +1,6 @@ import AddImages from './AddImages/AddImages'; import BookingInfo from './BookingInfo/BookingInfo'; +import CurrencyInput from './CurrencyInput/CurrencyInput'; import Discussion from './Discussion/Discussion'; import FilterPanel from './FilterPanel/FilterPanel'; import HeroSection from './HeroSection/HeroSection'; @@ -22,6 +23,7 @@ import SearchResultsPanel from './SearchResultsPanel/SearchResultsPanel'; export { AddImages, BookingInfo, + CurrencyInput, Discussion, FilterPanel, HeroSection, diff --git a/src/examples.js b/src/examples.js index 9669a425..6788a687 100644 --- a/src/examples.js +++ b/src/examples.js @@ -1,6 +1,7 @@ // components import * as AddImages from './components/AddImages/AddImages.example'; import * as BookingInfo from './components/BookingInfo/BookingInfo.example'; +import * as CurrencyInput from './components/CurrencyInput/CurrencyInput.example'; import * as ListingCard from './components/ListingCard/ListingCard.example'; import * as Map from './components/Map/Map.example'; import * as NamedLink from './components/NamedLink/NamedLink.example'; @@ -23,6 +24,7 @@ export { BookingInfo, ChangeAccountPasswordForm, ChangePasswordForm, + CurrencyInput, EditListingForm, HeroSearchForm, ListingCard, diff --git a/src/util/currency.js b/src/util/currency.js new file mode 100644 index 00000000..a654e9d3 --- /dev/null +++ b/src/util/currency.js @@ -0,0 +1,106 @@ +import { trimEnd } from 'lodash'; +import Decimal from 'decimal.js'; + +// This works for USD and EUR +const SUB_UNITS_DIVISOR = 100; + +// Default config for currency formatting (for React-Intl). +export const currencyDefaultConfig = { + style: 'currency', + currency: 'USD', + currencyDisplay: 'symbol', + useGrouping: true, + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}; + +// Ensures that the given string uses only dots or points +// ensureSeparator('9999999,99', false) // => '9999999.99' +export const ensureSeparator = (str, usePoint = false) => { + if (typeof str !== 'string') { + throw new Error('Parameter must be a string'); + } + return usePoint ? str.split('.').join(',') : str.split(',').join('.'); +}; + +// Ensures that the given string uses only dots (e.g. JavaScript floats use dots) +export const ensureDotSeparator = str => { + return ensureSeparator(str, false); +}; + +// Convert string to Decimal object (from Decimal.js math library) +export const convertToDecimal = str => { + const dotFormattedStr = ensureDotSeparator(str); + try { + return new Decimal(dotFormattedStr); + } catch (e) { + throw e; + } +}; + +// Converts Decimal object to string +export const convertDecimalToString = (decimalValue, usePoint = false) => { + try { + const d = new Decimal(decimalValue); + return ensureSeparator(d.toString(), usePoint); + } catch (e) { + throw e; + } +}; + +// Limits value to sub-unit precision: "1.4567" -> "1.45" +// Useful in input fields so this doesn't use rounding. +export const truncateToSubUnitPrecision = (inputString, currency, usePoint = false) => { + if (!(currency === 'USD' || currency === 'EUR')) { + // Other currencies sub unit support need to be changed case by case + throw new Error('Currency must be either USD or EUR'); + } + + // '10,' should be passed through, but that format is not supported as valid number + const trimmed = trimEnd(inputString, usePoint ? ',' : '.'); + // create another instance and check if value is convertable + const value = convertToDecimal(trimmed, usePoint); + + if (value.isNegative()) { + throw new Error('Parameter must be a positive number'); + } + + // Amount is always counted in subunits + // E.g. $10 => 1000¢ + const amount = value.times(SUB_UNITS_DIVISOR); + + // Amount must be integer + // We don't deal with subunit fragments like 1000.345¢ + if (amount.isInteger()) { + // accepted strings: '9', '9,' '9.' '9,99' + const decimalCount2 = value.toFixed(2); + const decimalPrecisionMax2 = decimalCount2.length >= inputString.length + ? inputString + : value.toFixed(2); + return ensureSeparator(decimalPrecisionMax2, usePoint); + } else { + // truncate strings ('9.999' => '9.99') + const truncated = amount.truncated().dividedBy(SUB_UNITS_DIVISOR); + return convertDecimalToString(truncated, usePoint); + } +}; + +// Converts given value to sub unit value and returns it as a number +export const convertUnitToSubUnit = (value, currency, usePoint = false) => { + if (!(currency === 'USD' || currency === 'EUR')) { + throw new Error('Currency must be either USD or EUR'); + } + + if (!(typeof value === 'string' || typeof value === 'number')) { + throw new Error('Value must be either number or string'); + } + + const val = typeof value === 'string' ? convertToDecimal(value, usePoint) : new Decimal(value); + const amount = val.times(SUB_UNITS_DIVISOR); + + if (amount.isInteger()) { + return amount.toNumber(); + } else { + throw new Error(`value must divisible by ${SUB_UNITS_DIVISOR}`); + } +}; diff --git a/src/util/currency.test.js b/src/util/currency.test.js new file mode 100644 index 00000000..d05e59d2 --- /dev/null +++ b/src/util/currency.test.js @@ -0,0 +1,185 @@ +import Decimal from 'decimal.js'; +import { + convertDecimalToString, + convertToDecimal, + convertUnitToSubUnit, + ensureSeparator, + truncateToSubUnitPrecision, +} from './currency'; + +describe('currency utils', () => { + describe('ensureSeparator(str)', () => { + it('changes points in string to dots ', () => { + expect(ensureSeparator('0')).toEqual('0'); + expect(ensureSeparator('0.0')).toEqual('0.0'); + expect(ensureSeparator('0.')).toEqual('0.'); + expect(ensureSeparator('0,')).toEqual('0.'); + expect(ensureSeparator('0,0')).toEqual('0.0'); + expect(ensureSeparator('0,1234')).toEqual('0.1234'); + expect(ensureSeparator('asdf')).toEqual('asdf'); + }); + + it('changes dots in string to points ', () => { + expect(ensureSeparator('0', true)).toEqual('0'); + expect(ensureSeparator('0.0', true)).toEqual('0,0'); + expect(ensureSeparator('0.', true)).toEqual('0,'); + expect(ensureSeparator('0,', true)).toEqual('0,'); + expect(ensureSeparator('0,0', true)).toEqual('0,0'); + expect(ensureSeparator('0,1234', true)).toEqual('0,1234'); + expect(ensureSeparator('asdf', true)).toEqual('asdf'); + }); + + it('Throws exceptions if parameter is not a string', () => { + expect(() => ensureSeparator(0)).toThrowError('Parameter must be a string'); + expect(() => ensureSeparator(true)).toThrowError('Parameter must be a string'); + expect(() => ensureSeparator([])).toThrowError('Parameter must be a string'); + expect(() => ensureSeparator({})).toThrowError('Parameter must be a string'); + expect(() => ensureSeparator(0, true)).toThrowError('Parameter must be a string'); + expect(() => ensureSeparator(true, true)).toThrowError('Parameter must be a string'); + expect(() => ensureSeparator([], true)).toThrowError('Parameter must be a string'); + expect(() => ensureSeparator({}, true)).toThrowError('Parameter must be a string'); + }); + }); + + describe('convertToDecimal(valueStr)', () => { + it('converts numbers in string format to Decimal', () => { + expect(convertToDecimal('0')).toEqual(new Decimal('0')); + expect(convertToDecimal('-0')).toEqual(new Decimal('-0')); + expect(convertToDecimal('1.23')).toEqual(new Decimal('1.23')); + expect(convertToDecimal('999999999.99999999')).toEqual(new Decimal('999999999.99999999')); + expect(convertToDecimal('-999999999.99999999')).toEqual(new Decimal('-999999999.99999999')); + }); + + it('Throws exceptions if string formatted parameter can not be converted to Decimal', () => { + expect(() => convertToDecimal('asdf')).toThrowError('[DecimalError] Invalid argument: asdf'); + expect(() => convertToDecimal('123asdf')).toThrowError( + '[DecimalError] Invalid argument: 123asdf' + ); + }); + + it('Throws exceptions if parameter is not a string', () => { + expect(() => convertToDecimal(true)).toThrowError('Parameter must be a string'); + expect(() => convertToDecimal([])).toThrowError('Parameter must be a string'); + expect(() => convertToDecimal(undefined)).toThrowError('Parameter must be a string'); + expect(() => convertToDecimal(null)).toThrowError('Parameter must be a string'); + }); + }); + + describe('convertDecimalToString()', () => { + it('converts Decimals to string', () => { + expect(convertDecimalToString(new Decimal('0'))).toEqual('0'); + expect(convertDecimalToString(new Decimal('-0'))).toEqual('0'); + expect(convertDecimalToString(new Decimal('1.23'))).toEqual('1.23'); + expect(convertDecimalToString(new Decimal('999999999.99999999'))).toEqual( + '999999999.99999999' + ); + expect(convertDecimalToString(new Decimal('-999999999.99999999'))).toEqual( + '-999999999.99999999' + ); + }); + + it('converts numbers in string format to string', () => { + expect(convertDecimalToString('0')).toEqual('0'); + expect(convertDecimalToString('-0')).toEqual('0'); + expect(convertDecimalToString('1.23')).toEqual('1.23'); + expect(convertDecimalToString('999999999.99999999')).toEqual('999999999.99999999'); + expect(convertDecimalToString('-999999999.99999999')).toEqual('-999999999.99999999'); + }); + + it('Throws exceptions if string formatted parameter is not a number', () => { + expect(() => convertDecimalToString('asdf')).toThrowError( + '[DecimalError] Invalid argument: asdf' + ); + expect(() => convertDecimalToString('123asdf')).toThrowError( + '[DecimalError] Invalid argument: 123asdf' + ); + }); + }); + + describe('truncateToSubUnitPrecision(valueStr, currency)', () => { + it('positive values', () => { + expect(truncateToSubUnitPrecision('0', 'USD')).toEqual('0'); + expect(truncateToSubUnitPrecision('1', 'EUR')).toEqual('1'); + expect(truncateToSubUnitPrecision('10', 'USD')).toEqual('10'); + expect(truncateToSubUnitPrecision('10000', 'EUR')).toEqual('10000'); + expect(truncateToSubUnitPrecision('10000.00', 'USD')).toEqual('10000.00'); + expect(truncateToSubUnitPrecision('99.9', 'EUR')).toEqual('99.9'); + expect(truncateToSubUnitPrecision('99.99', 'USD')).toEqual('99.99'); + }); + + it('truncate excess amount of decimal values', () => { + expect(truncateToSubUnitPrecision('99.999999', 'USD')).toEqual('99.99'); + expect(truncateToSubUnitPrecision('1.111', 'USD')).toEqual('1.11'); + expect(truncateToSubUnitPrecision('1.110000', 'USD')).toEqual('1.11'); + }); + + it('negative values to throw errors', () => { + expect(() => truncateToSubUnitPrecision('-0', 'USD')).toThrowError( + 'Parameter must be a positive number' + ); + expect(() => truncateToSubUnitPrecision('-1', 'USD')).toThrowError( + 'Parameter must be a positive number' + ); + expect(() => truncateToSubUnitPrecision('-10000', 'USD')).toThrowError( + 'Parameter must be a positive number' + ); + expect(() => truncateToSubUnitPrecision('-99.99', 'USD')).toThrowError( + 'Parameter must be a positive number' + ); + expect(() => truncateToSubUnitPrecision('-99.99999', 'USD')).toThrowError( + 'Parameter must be a positive number' + ); + expect(() => truncateToSubUnitPrecision('-1.111', 'USD')).toThrowError( + 'Parameter must be a positive number' + ); + }); + + it('text input to throw errors', () => { + expect(() => truncateToSubUnitPrecision('asdf', 'USD')).toThrowError(); + expect(() => truncateToSubUnitPrecision('100asdf', 'USD')).toThrowError(); + expect(() => truncateToSubUnitPrecision('asdf100', 'USD')).toThrowError(); + expect(() => truncateToSubUnitPrecision('@', 'USD')).toThrowError(); + }); + + it('wrong currency to throw errors', () => { + expect(() => truncateToSubUnitPrecision('asdf', 'JPN')).toThrowError(); + expect(() => truncateToSubUnitPrecision('100asdf', 'JPN')).toThrowError(); + expect(() => truncateToSubUnitPrecision('asdf100', 'JPN')).toThrowError(); + expect(() => truncateToSubUnitPrecision('@', 'JPN')).toThrowError(); + }); + }); + + describe('convertUnitToSubUnit(value, currency)', () => { + it('numbers as value', () => { + expect(convertUnitToSubUnit(0, 'USD')).toEqual(0); + expect(convertUnitToSubUnit(10, 'USD')).toEqual(1000); + expect(convertUnitToSubUnit(1, 'EUR')).toEqual(100); + }); + + it('strings as value', () => { + expect(convertUnitToSubUnit('0', 'USD')).toEqual(0); + expect(convertUnitToSubUnit('10', 'USD')).toEqual(1000); + expect(convertUnitToSubUnit('1', 'EUR')).toEqual(100); + expect(convertUnitToSubUnit('0.10', 'USD')).toEqual(10); + expect(convertUnitToSubUnit('10,99', 'USD')).toEqual(1099); + }); + + it('wrong type', () => { + expect(() => convertUnitToSubUnit({}, 'USD')).toThrowError( + 'Value must be either number or string' + ); + expect(() => convertUnitToSubUnit([], 'USD')).toThrowError( + 'Value must be either number or string' + ); + expect(() => convertUnitToSubUnit(null, 'EUR')).toThrowError( + 'Value must be either number or string' + ); + }); + + it('wrong currency', () => { + expect(() => convertUnitToSubUnit(1, 'JPN')).toThrowError( + 'Currency must be either USD or EUR' + ); + }); + }); +});