Skip to content

Commit 6f58ca8

Browse files
authored
feat: add MoneyInput value formatting (#221)
* feat: add MoneyInput value formatting * test: add locale data to tests * refactor(money-input): rename functions * test(money-input): add thousand-separator formatting tests
1 parent 3936fe3 commit 6f58ca8

File tree

3 files changed

+220
-75
lines changed

3 files changed

+220
-75
lines changed

src/components/inputs/money-input/money-input.js

+65-31
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
33
import invariant from 'tiny-invariant';
44
import has from 'lodash.has';
55
import Select from 'react-select';
6+
import { injectIntl } from 'react-intl';
67
import ClearIndicator from '../../internals/clear-indicator';
78
import DropdownIndicator from '../../internals/dropdown-indicator';
89
import isNumberish from '../../../utils/is-numberish';
@@ -117,10 +118,29 @@ const createCurrencySelectStyles = ({
117118
// }
118119
// which equals 0.0123456 €
119120

120-
// Parses the value returned from <input type="number" />'s onChange function
121-
// The input will only call us with parseable values, or an empty string
122-
// The function will return NaN for the empty string.
123-
export const parseAmount = amount => parseFloat(amount, 10);
121+
// Parsing
122+
// Since most users are not careful about how they enter values, we will parse
123+
// both `.` and `,` as decimal separators.
124+
// When a value is `1.000,00` we parse it as `1000`.
125+
// When a value is `1,000.00` we also parse it as `1000`.
126+
//
127+
// This means the highest amount always wins. We do this by comparing the last
128+
// position of `.` and `,`. Whatever occurs later is used as the decimal
129+
// separator.
130+
export const parseRawAmountToNumber = rawAmount => {
131+
const lastDot = String(rawAmount).lastIndexOf('.');
132+
const lastComma = String(rawAmount).lastIndexOf(',');
133+
134+
const separator = lastComma > lastDot ? ',' : '.';
135+
const throwaway = separator === '.' ? ',' : '\\.';
136+
137+
// The raw amount with only one sparator
138+
const normalizedAmount = String(rawAmount)
139+
.replace(new RegExp(`${throwaway}`, 'g'), '')
140+
.replace(separator, '.');
141+
142+
return parseFloat(normalizedAmount, 10);
143+
};
124144

125145
// Turns the user input into a value the MoneyInput can pass up through onChange
126146
// In case the number of fraction digits contained in "amount" exceeds the
@@ -133,15 +153,15 @@ export const parseAmount = amount => parseFloat(amount, 10);
133153
// - no currency was selected
134154
//
135155
// This function expects the "amount" to be a trimmed value.
136-
export const createMoneyValue = (currencyCode, amount) => {
156+
export const createMoneyValue = (currencyCode, rawAmount) => {
137157
if (!currencyCode) return null;
138158

139159
const currency = currencies[currencyCode];
140160
if (!currency) return null;
141161

142-
if (amount.length === 0 || !isNumberish(amount)) return null;
162+
if (rawAmount.length === 0 || !isNumberish(rawAmount)) return null;
143163

144-
const amountAsNumber = parseAmount(amount);
164+
const amountAsNumber = parseRawAmountToNumber(rawAmount);
145165
if (isNaN(amountAsNumber)) return null;
146166

147167
const centAmount = amountAsNumber * 10 ** currency.fractionDigits;
@@ -175,27 +195,30 @@ export const createMoneyValue = (currencyCode, amount) => {
175195
};
176196
};
177197

178-
const formatAmount = (amount, currencyCode) => {
198+
const getAmountAsNumberFromMoneyValue = moneyValue =>
199+
moneyValue.type === 'highPrecision'
200+
? moneyValue.preciseAmount / 10 ** moneyValue.fractionDigits
201+
: moneyValue.centAmount /
202+
10 ** currencies[moneyValue.currencyCode].fractionDigits;
203+
204+
// gets called with a string and should return a formatted string
205+
const formatAmount = (rawAmount, currencyCode, locale) => {
179206
// fallback in case the user didn't enter an amount yet (or it's invalid)
180-
const moneyValue = createMoneyValue(currencyCode, amount) || {
207+
const moneyValue = createMoneyValue(currencyCode, rawAmount) || {
181208
currencyCode,
182209
centAmount: NaN,
183210
};
184-
// format highPrecision so that . gets replaced by, and vice versa.
185-
if (moneyValue.type === 'highPrecision') {
186-
return (moneyValue.preciseAmount / 10 ** moneyValue.fractionDigits).toFixed(
187-
moneyValue.fractionDigits
188-
);
189-
}
190-
return (moneyValue.centAmount / 10 ** moneyValue.fractionDigits).toFixed(
191-
moneyValue.fractionDigits
192-
);
193-
};
194211

195-
const getAmountAsNumberFromMoneyValue = money =>
196-
money.type === 'highPrecision'
197-
? money.preciseAmount / 10 ** money.fractionDigits
198-
: money.centAmount / 10 ** currencies[money.currencyCode].fractionDigits;
212+
const amount = getAmountAsNumberFromMoneyValue(moneyValue);
213+
214+
return isNaN(amount)
215+
? ''
216+
: amount.toLocaleString(locale, {
217+
minimumFractionDigits: moneyValue.preciseAmount
218+
? moneyValue.fractionDigits
219+
: currencies[moneyValue.currencyCode].fractionDigits,
220+
});
221+
};
199222

200223
const getAmountStyles = props => {
201224
if (props.isDisabled) return styles['amount-disabled'];
@@ -210,7 +233,7 @@ const getAmountInputName = name => (name ? `${name}.amount` : undefined);
210233
const getCurrencyDropdownName = name =>
211234
name ? `${name}.currencyCode` : undefined;
212235

213-
export default class MoneyInput extends React.Component {
236+
class MoneyInput extends React.Component {
214237
static displayName = 'MoneyInput';
215238

216239
static getAmountInputId = getAmountInputName;
@@ -223,9 +246,14 @@ export default class MoneyInput extends React.Component {
223246
typeof value.amount === 'string' ? value.amount.trim() : ''
224247
);
225248

226-
static parseMoneyValue = moneyValue => {
249+
static parseMoneyValue = (moneyValue, locale) => {
227250
if (!moneyValue) return { currencyCode: '', amount: '' };
228251

252+
invariant(
253+
typeof locale === 'string',
254+
'MoneyInput.parseMoneyValue: A locale must be passed as the second argument'
255+
);
256+
229257
invariant(
230258
typeof moneyValue === 'object',
231259
'MoneyInput.parseMoneyValue: Value must be passed as an object or be undefined'
@@ -251,7 +279,8 @@ export default class MoneyInput extends React.Component {
251279

252280
const amount = formatAmount(
253281
String(getAmountAsNumberFromMoneyValue(moneyValue)),
254-
moneyValue.currencyCode
282+
moneyValue.currencyCode,
283+
locale
255284
);
256285

257286
return { amount, currencyCode: moneyValue.currencyCode };
@@ -288,6 +317,9 @@ export default class MoneyInput extends React.Component {
288317
onChange: PropTypes.func.isRequired,
289318
hasError: PropTypes.bool,
290319
hasWarning: PropTypes.bool,
320+
intl: PropTypes.shape({
321+
locale: PropTypes.string.isRequired,
322+
}).isRequired,
291323

292324
horizontalConstraint: PropTypes.oneOf(['s', 'm', 'l', 'xl', 'scale']),
293325
};
@@ -313,7 +345,8 @@ export default class MoneyInput extends React.Component {
313345
// be lost
314346
const formattedAmount = formatAmount(
315347
this.props.value.amount.trim(),
316-
currencyCode
348+
currencyCode,
349+
this.props.intl.locale
317350
);
318351
// The user could be changing the currency before entering any amount,
319352
// or while the amount is invalid. In these cases, we don't attempt to
@@ -339,9 +372,7 @@ export default class MoneyInput extends React.Component {
339372
persist: () => {},
340373
target: {
341374
name: getAmountInputName(this.props.name),
342-
value: isNaN(formattedAmount)
343-
? this.props.value.amount
344-
: formattedAmount,
375+
value: nextAmount,
345376
},
346377
};
347378

@@ -366,7 +397,8 @@ export default class MoneyInput extends React.Component {
366397
if (amount.length > 0 && currencies[this.props.value.currencyCode]) {
367398
const formattedAmount = formatAmount(
368399
amount,
369-
this.props.value.currencyCode
400+
this.props.value.currencyCode,
401+
this.props.intl.locale
370402
);
371403

372404
// When the user entered a value with centPrecision, we can format
@@ -464,3 +496,5 @@ export default class MoneyInput extends React.Component {
464496
);
465497
}
466498
}
499+
500+
export default injectIntl(MoneyInput);

0 commit comments

Comments
 (0)