CurrencyInput component and value formatting as strings

This commit is contained in:
Vesa Luusua 2017-03-20 20:07:51 +02:00
parent 47db19acec
commit 0de0ff723d
6 changed files with 558 additions and 0 deletions

View file

@ -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 (
<IntlProvider locale={locale}><CurrencyInput {...rest} input={{ onChange }} /></IntlProvider>
);
};
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,
},
};

View file

@ -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 (
<input
{...allowedInputProps(this.props)}
value={this.state.value}
onChange={this.onInputChange}
onBlur={this.onInputBlur}
onFocus={this.onInputFocus}
type="text"
placeholder={placeholderText}
/>
);
}
}
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);

View file

@ -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,

View file

@ -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,

106
src/util/currency.js Normal file
View file

@ -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}`);
}
};

185
src/util/currency.test.js Normal file
View file

@ -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(<Decimal>)', () => {
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'
);
});
});
});