From 432611699cac402af17a598020263437193ae245 Mon Sep 17 00:00:00 2001 From: Sawyer Hollenshead Date: Wed, 7 Mar 2018 11:43:27 -0500 Subject: [PATCH] Add Currency mask formatting onBlur (#251) * Add Currency mask formatting onBlur * Fix tests * Use a more realistic monthly income * Debounce calling onBlur until value has been updated * Add en-US locale string * Ensure onBlur; Accept empty strings * Fallback to empty string so field doesn't start uncontrolled * Add test for pre-masked values * Add test for null and undefined value test * Correct Mask comment * Remove unnecesary object.assign --- .../TextField/CurrencyField.example.jsx | 1 + .../core/src/components/TextField/Mask.jsx | 149 ++++++++++++++++++ .../src/components/TextField/Mask.test.jsx | 118 ++++++++++++++ .../src/components/TextField/TextField.jsx | 7 +- .../__snapshots__/TextField.test.jsx.snap | 18 ++- .../src/components/__tests__/index.test.js | 2 +- 6 files changed, 284 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/components/TextField/Mask.jsx create mode 100644 packages/core/src/components/TextField/Mask.test.jsx diff --git a/packages/core/src/components/TextField/CurrencyField.example.jsx b/packages/core/src/components/TextField/CurrencyField.example.jsx index 24bcc092fd..2cd5657da8 100644 --- a/packages/core/src/components/TextField/CurrencyField.example.jsx +++ b/packages/core/src/components/TextField/CurrencyField.example.jsx @@ -8,6 +8,7 @@ ReactDOM.render( label="Monthly income" mask="currency" name="currency_example" + value="2500" />, document.getElementById('js-example') ); diff --git a/packages/core/src/components/TextField/Mask.jsx b/packages/core/src/components/TextField/Mask.jsx new file mode 100644 index 0000000000..c9ee9b3dd4 --- /dev/null +++ b/packages/core/src/components/TextField/Mask.jsx @@ -0,0 +1,149 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +/** + * A Mask component renders a controlled input field. When the + * field is blurred, it applies formatting to improve the readability + * of the value. + */ +export class Mask extends React.PureComponent { + constructor(props) { + super(props); + this.field = React.Children.only(this.props.children); + this.state = { + value: this.maskedValue(this.initialValue()) + }; + } + + componentDidUpdate() { + if (this.debouncedOnBlurEvent) { + this.field.props.onBlur(this.debouncedOnBlurEvent); + this.debouncedOnBlurEvent = null; + } + } + + /** + * @param {String} value + * @returns {Number} + */ + toNumber(value) { + if (typeof value !== 'string') return value; + + // 0 = number, 1 = decimals + const parts = value.split('.'); + const digitsRegex = /\d/g; + const a = parts[0].match(digitsRegex).join(''); + const b = parts.length >= 2 && parts[1].match(digitsRegex).join(''); + + return b ? parseFloat(`${a}.${b}`) : parseInt(a); + } + + /** + * Format a string using fixed-point notation, similar to Number.prototype.toFixed + * though a decimal is only fixed if the string included a decimal already + * @param {String} value - A stringified number (i.e. "1234") + * @param {Number} digits - The number of digits to appear after the decimal point + * @returns {String} + */ + stringWithFixedDigits(value, digits = 2) { + const decimalRegex = /\.[\d]+$/; + + // Check for existing decimal + const decimal = value.match(decimalRegex); + + if (decimal) { + const fixedDecimal = parseFloat(decimal) + .toFixed(digits) + .match(decimalRegex)[0]; + + return value.replace(decimal, fixedDecimal); + } + + return value; + } + + /** + * Returns the value with additional masking characters + * @param {String} value + * @returns {String} + */ + maskedValue(value = '') { + if (value && typeof value === 'string') { + value = value.trim(); + + if (this.props.mask === 'currency') { + // Format number with commas. If the number includes a decimal, + // ensure it includes two decimal points + value = this.toNumber(value); + value = this.stringWithFixedDigits(value.toLocaleString('en-US')); + } + } + + return value; + } + + /** + * To avoid a jarring experience for screen readers, we only + * add/remove characters after the field has been blurred, + * rather than when the user is typing in the field + * @param {Object} evt + */ + handleBlur(evt) { + const value = this.maskedValue(evt.target.value); + + // We only debounce the onBlur when we know for sure that + // this component will re-render (AKA when the value changes) + // and when an onBlur callback is present + const debounce = + value !== this.state.value && + typeof this.field.props.onBlur === 'function'; + + if (debounce) { + // We need to retain a reference to the event after the callback + // has been called. We pass this onto the consuming app's onBlur + // only after the value has been manipulated – this way, the + // value returned by event.target.value is the value after masking + evt.persist(); + this.debouncedOnBlurEvent = evt; + } + + this.setState({ + value + }); + + if (!debounce && typeof this.field.props.onBlur === 'function') { + // If we didn't debounce the onBlur event, then we need to + // call the onBlur callback from here + this.field.props.onBlur(evt); + } + } + + handleChange(evt) { + this.setState({ value: evt.target.value }); + + if (typeof this.field.props.onChange === 'function') { + this.field.props.onChange(evt); + } + } + + initialValue() { + return this.field.props.value || this.field.props.defaultValue; + } + + render() { + return React.cloneElement(this.field, { + defaultValue: undefined, + onBlur: evt => this.handleBlur(evt), + onChange: evt => this.handleChange(evt), + value: this.state.value + }); + } +} + +Mask.propTypes = { + /** Pass the input as the child */ + children: PropTypes.node.isRequired, + mask: PropTypes.string.isRequired +}; + +export default Mask; diff --git a/packages/core/src/components/TextField/Mask.test.jsx b/packages/core/src/components/TextField/Mask.test.jsx new file mode 100644 index 0000000000..892c27e0b0 --- /dev/null +++ b/packages/core/src/components/TextField/Mask.test.jsx @@ -0,0 +1,118 @@ +import { mount, shallow } from 'enzyme'; +import Mask from './Mask'; +import React from 'react'; + +function render(customProps = {}, inputProps = {}, deep = false) { + const component = ( + + + + ); + + return { + props: customProps, + wrapper: deep ? mount(component) : shallow(component) + }; +} + +describe('Mask', function() { + it('calls onBlur when the value is the same', () => { + const onBlur = jest.fn(); + const wrapper = render( + { mask: 'currency' }, + { value: '123', onBlur: onBlur } + ).wrapper; + + wrapper.simulate('blur', { target: { value: '123' }, persist: jest.fn() }); + + expect(onBlur.mock.calls.length).toBe(1); + }); + + it('calls onBlur when the value changes', () => { + const onBlur = jest.fn(); + const wrapper = render( + { mask: 'currency' }, + { value: '123', onBlur: onBlur }, + true + ).wrapper; + + wrapper + .find('input') + .simulate('blur', { target: { value: '1234' }, persist: jest.fn() }); + + expect(onBlur.mock.calls.length).toBe(1); + }); + + it('calls onChange', () => { + const onChange = jest.fn(); + const wrapper = render( + { mask: 'currency' }, + { value: '123', onChange: onChange } + ).wrapper; + + wrapper.simulate('change', { target: { value: '123' } }); + + expect(onChange.mock.calls.length).toBe(1); + }); + + describe('Currency', () => { + it('renders a blank controlled field when value is empty', () => { + const data = render({ mask: 'currency' }, { value: '' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe(''); + }); + + it('renders a blank controlled field when value is null', () => { + const data = render({ mask: 'currency' }, { value: null }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe(''); + }); + + it('renders a blank controlled field when value is undefined', () => { + const data = render({ mask: 'currency' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe(''); + }); + + it('accepts already masked value', () => { + const data = render({ mask: 'currency' }, { value: '1,234.50' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe('1,234.50'); + }); + + it('adds commas to value with decimal ending in 0', () => { + const data = render({ mask: 'currency' }, { value: '12345678.90' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe('12,345,678.90'); + }); + + it('adds commas to value with decimal ending in non-zero number', () => { + const data = render({ mask: 'currency' }, { value: '1234.95' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe('1,234.95'); + }); + + it('adds commas to value with no decimal', () => { + const data = render({ mask: 'currency' }, { value: '1234' }); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe('1,234'); + }); + + it('adds commas to defaultValue and replaces with value prop', () => { + const data = render( + { mask: 'currency' }, + { defaultValue: '12345678.90' } + ); + const input = data.wrapper.find('input'); + + expect(input.prop('value')).toBe('12,345,678.90'); + }); + }); +}); diff --git a/packages/core/src/components/TextField/TextField.jsx b/packages/core/src/components/TextField/TextField.jsx index 1a65843711..11d8a27367 100644 --- a/packages/core/src/components/TextField/TextField.jsx +++ b/packages/core/src/components/TextField/TextField.jsx @@ -1,4 +1,5 @@ import FormLabel from '../FormLabel/FormLabel'; +import Mask from './Mask'; import PropTypes from 'prop-types'; import React from 'react'; import classNames from 'classnames'; @@ -32,8 +33,8 @@ export class TextField extends React.PureComponent { return maskName ? (
- {this.renderMask()} - {field} + {this.renderMaskOverlay()} + {field}
) : ( field @@ -43,7 +44,7 @@ export class TextField extends React.PureComponent { /** * UI overlayed on top of a field to support certain masks */ - renderMask() { + renderMaskOverlay() { if (this.props.mask) { const content = { currency: '$' diff --git a/packages/core/src/components/TextField/__snapshots__/TextField.test.jsx.snap b/packages/core/src/components/TextField/__snapshots__/TextField.test.jsx.snap index 3fe41017e1..b78c8291e8 100644 --- a/packages/core/src/components/TextField/__snapshots__/TextField.test.jsx.snap +++ b/packages/core/src/components/TextField/__snapshots__/TextField.test.jsx.snap @@ -18,13 +18,17 @@ exports[`TextField masked renders currency mask 1`] = ` > $ - + + + `; diff --git a/packages/core/src/components/__tests__/index.test.js b/packages/core/src/components/__tests__/index.test.js index c40824e910..55d0fec88b 100644 --- a/packages/core/src/components/__tests__/index.test.js +++ b/packages/core/src/components/__tests__/index.test.js @@ -34,7 +34,7 @@ function getDirectories(paths) { return paths.filter(filePath => fs.lstatSync(filePath).isDirectory()); } -const ignoredComponents = ['Step', 'SubStep', 'StepLink', 'ReviewLink']; +const ignoredComponents = ['Mask', 'ReviewLink', 'Step', 'SubStep', 'StepLink']; describe('Components index', () => { it("exports all components except ones we don't want to expose", () => {