Skip to content

Commit 9e00148

Browse files
authored
Redo MoneyInput (better UX, less error cases) (#175)
* feat(money-input): reduce to hasError and hasWarning BREAKING CHANGE: dropped support for hasAmountError, hasAmountWarning, hasCurrencyError and hasCurrencyWarning. Use hasError and hasWarning instead. BREAKING CHANGE: isTouched only returns true when both fields were touched from now on. * feat(money-field): mark both fields as touched when one of them loses focus We only mark both as blurred when an element which is not part of the MoneyInput gains focus. This is done to ensure the MoneyInput acts as one unit so that errors can be displayed correctly (as they rely on both touched states and would pop up too early if only one of them is checked). This also auto-focuses the amount intput after a currency has been selected. * test(money-input): switch tests to react-testing-library * docs(money-input): fix leftover areAllTouched
1 parent a538458 commit 9e00148

12 files changed

+245
-449
lines changed

examples/form-fields.story.js

+3
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ class ProductForm extends React.Component {
274274
value={this.props.formik.values.inventory}
275275
onChange={this.props.formik.handleChange}
276276
onBlur={this.props.formik.handleBlur}
277+
isDisabled={this.props.formik.isSubmitting}
277278
touched={this.props.formik.touched.inventory}
278279
errors={this.props.formik.errors.inventory}
279280
renderError={key => {
@@ -293,6 +294,7 @@ class ProductForm extends React.Component {
293294
value={this.props.formik.values.price}
294295
onChange={this.props.formik.handleChange}
295296
onBlur={this.props.formik.handleBlur}
297+
isDisabled={this.props.formik.isSubmitting}
296298
currencies={currencies}
297299
touched={this.props.formik.touched.price}
298300
errors={this.props.formik.errors.price}
@@ -312,6 +314,7 @@ class ProductForm extends React.Component {
312314
value={this.props.formik.values.status}
313315
onChange={this.props.formik.handleChange}
314316
onBlur={this.props.formik.handleBlur}
317+
isDisabled={this.props.formik.isSubmitting}
315318
options={[
316319
{ value: 'unpublished', label: 'Unpublished' },
317320
{ value: 'modified', label: 'Modified' },

examples/form-inputs.story.js

+8-9
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,7 @@ class ProductForm extends React.Component {
385385
value={this.props.formik.values.description}
386386
onChange={this.props.formik.handleChange}
387387
onBlur={this.props.formik.handleBlur}
388+
isDisabled={this.props.formik.isSubmitting}
388389
hasError={
389390
this.props.formik.touched.description &&
390391
Boolean(this.props.formik.errors.description)
@@ -435,6 +436,7 @@ class ProductForm extends React.Component {
435436
value={this.props.formik.values.inventory}
436437
onChange={this.props.formik.handleChange}
437438
onBlur={this.props.formik.handleBlur}
439+
isDisabled={this.props.formik.isSubmitting}
438440
hasError={
439441
this.props.formik.touched.inventory &&
440442
Boolean(this.props.formik.errors.inventory)
@@ -465,20 +467,17 @@ class ProductForm extends React.Component {
465467
onBlur={this.props.formik.handleBlur}
466468
currencies={currencies}
467469
isDisabled={this.props.formik.isSubmitting}
468-
hasAmountError={Boolean(
469-
this.props.formik.touched.price &&
470-
this.props.formik.touched.price.amount &&
471-
this.props.formik.errors.price
472-
)}
470+
hasError={
471+
MoneyInput.isTouched(this.props.formik.touched.price) &&
472+
Boolean(this.props.formik.errors.price)
473+
}
473474
/>
474475
{MoneyInput.isTouched(this.props.formik.touched.price) &&
475-
this.props.formik.errors.price &&
476-
this.props.formik.errors.price.missing && (
476+
this.props.formik.errors.price?.missing && (
477477
<ErrorMessage>Missing price</ErrorMessage>
478478
)}
479479
{MoneyInput.isTouched(this.props.formik.touched.price) &&
480-
this.props.formik.errors.price &&
481-
this.props.formik.errors.price.unsupportedHighPrecision && (
480+
this.props.formik.errors.price?.unsupportedHighPrecision && (
482481
<ErrorMessage>
483482
This value is a high precision value. High precision pricing is
484483
not supported for products.

src/components/fields/money-field/money-field.form.story.js

-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ storiesOf('Examples|Forms/Fields', module)
3939
if (MoneyField.isEmpty(values.pricePerTon))
4040
errors.pricePerTon.missing = true;
4141

42-
console.log(values, omitEmpty(errors));
4342
return omitEmpty(errors);
4443
}}
4544
onSubmit={(values, formik) => {

src/components/fields/money-field/money-field.js

+8-19
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import React from 'react';
22
import PropTypes from 'prop-types';
3-
import has from 'lodash.has';
43
import requiredIf from 'react-required-if';
54
import { FormattedMessage } from 'react-intl';
5+
import has from 'lodash.has';
66
import Constraints from '../../constraints';
77
import Spacings from '../../spacings';
88
import FieldLabel from '../../field-label';
@@ -98,21 +98,11 @@ class MoneyField extends React.Component {
9898
});
9999

100100
render() {
101-
// We could determine the errors of amount and currencyCode separately
102-
// and forward hasCurrencyError / hasAmountError depending on the error.
103-
// This would work for example for the known "missing" error.
104-
// Doing so would lead to the correct part of the MoneyField being marked
105-
// with a red border instead of the complete field.
106-
// This is something we can do later / when somebody asks for it.
107-
//
108-
// We do not use MoneyField.isTouched() as we want to ensure both fields have been touched.
109-
// This avoids showing an error when the user just selected a language but didn't add
110-
// an amount yet.
111-
const hasAnyErrors =
112-
this.props.touched &&
113-
this.props.touched.amount &&
114-
this.props.touched.currencyCode &&
115-
hasErrors(this.props.errors);
101+
// MoneyField.isTouched() ensures both fields have been touched.
102+
// This avoids showing an error when the user just selected a language but
103+
// didn't add an amount yet.
104+
const hasError =
105+
MoneyInput.isTouched(this.props.touched) && hasErrors(this.props.errors);
116106
return (
117107
<Constraints.Horizontal constraint={this.props.horizontalConstraint}>
118108
<Spacings.Stack scale="xs">
@@ -148,13 +138,12 @@ class MoneyField extends React.Component {
148138
onBlur={this.props.onBlur}
149139
isDisabled={this.props.isDisabled}
150140
onChange={this.props.onChange}
151-
hasCurrencyError={hasAnyErrors}
152-
hasAmountError={hasAnyErrors}
141+
hasError={hasError}
153142
{...filterDataAttributes(this.props)}
154143
/>
155144
<FieldErrors
156145
errors={this.props.errors}
157-
isVisible={hasAnyErrors}
146+
isVisible={hasError}
158147
renderError={this.props.renderError}
159148
/>
160149
</Spacings.Stack>

src/components/fields/money-field/money-field.spec.js

+3-4
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,7 @@ describe('rendering', () => {
114114
expect(moneyInput).toHaveProp('onBlur', props.onBlur);
115115
expect(moneyInput).toHaveProp('isDisabled', props.isDisabled);
116116
expect(moneyInput).toHaveProp('placeholder', props.placeholder);
117-
expect(moneyInput).toHaveProp('hasAmountError', true);
118-
expect(moneyInput).toHaveProp('hasCurrencyError', true);
117+
expect(moneyInput).toHaveProp('hasError', true);
119118

120119
expect(wrapper).toRender(FieldErrors);
121120
expect(wrapper.find(FieldErrors)).toHaveProp('errors', props.errors);
@@ -145,7 +144,7 @@ describe('rendering', () => {
145144
wrapper = shallow(<MoneyField {...props} />);
146145
});
147146
it('should mark the MoneyInput as erroneous', () => {
148-
expect(wrapper.find(MoneyInput)).toHaveProp('hasAmountError', true);
147+
expect(wrapper.find(MoneyInput)).toHaveProp('hasError', true);
149148
});
150149
it('should render the known error', () => {
151150
expect(wrapper).toRender(FieldErrors);
@@ -164,7 +163,7 @@ describe('rendering', () => {
164163
wrapper = shallow(<MoneyField {...props} />);
165164
});
166165
it('should mark the NumberInput as erroneous', () => {
167-
expect(wrapper.find(MoneyInput)).toHaveProp('hasAmountError', true);
166+
expect(wrapper.find(MoneyInput)).toHaveProp('hasError', true);
168167
});
169168
it('should forward the error', () => {
170169
expect(wrapper.find(FieldErrors)).toHaveProp('errors', props.errors);

src/components/inputs/money-input/README.md

+18-5
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,8 @@ The amount can have an arbitrary precision. When the precision of the amount exc
4141
| `isDisabled` | `bool` | - | - | `false` | Indicates that the field cannot be used. |
4242
| `onBlur` | `func` | - | - | - | Called when the amount field or the currency code dropdown is blurred. |
4343
| `onChange` | `function(event)` | ✳️ | - | - | Called with the event of the input or dropdown when either the currency or the amount have changed. Either `onChange` or `onChangeValue` must be passed. |
44-
| `hasCurrencyError` | `bool` | - | - | - | Indicates if the currency field has an error |
45-
| `hasCurrencyWarning` | `bool` | - | - | - | Indicates if the currency field has a warning |
46-
| `hasAmountError` | `bool` | - | - | - | Indicates if the centAmount field has an error |
47-
| `hasAmountWarning` | `bool` | - | - | - | Indicates if the centAmount field has a warning |
44+
| `hasError` | `bool` | - | - | - | Indicates if the input has an error |
45+
| `hasWarning` | `bool` | - | - | - | Indicates if the input has a warning |
4846
| `horizontalConstraint` | `string` | - | `s`, `m`, `l`, `xl`, `scale` | `scale` | Horizontal size limit of the input fields. |
4947

5048
### Static methods
@@ -93,6 +91,19 @@ MoneyInput.isEmpty(); // -> true
9391
MoneyInput.isEmpty({ amount: '5', currencyCode: 'EUR' }); // -> false
9492
```
9593

94+
#### `MoneyInput.isTouched`
95+
96+
The `isTouched` function will return `true` when all input elements were touched (currency dropdown and amount input).
97+
98+
```js
99+
MoneyInput.isTouched({ amount: true, currencyCode: true }); // -> true
100+
101+
MoneyInput.isTouched({ amount: true }); // -> false
102+
MoneyInput.isTouched({ currencyCode: true }); // -> false
103+
MoneyInput.isTouched({ amount: false, currencyCode: false }); // -> false
104+
MoneyInput.isTouched({}); // -> false
105+
```
106+
96107
#### `MoneyInput.getCurrencyDropdownId`
97108

98109
##### `getCurrencyDropdownId(idPrefix)`
@@ -209,7 +220,9 @@ return (
209220
onBlur={() => setFieldTouched('somePrice')}
210221
isDisabled={isSubmitting}
211222
onChange={value => setFieldValue('somePrice', value)}
212-
hasAmountError={touched.somePrice && Boolean(errors.somePrice)}
223+
hasError={
224+
MoneyInput.isTouched(touched.somePrice) && Boolean(errors.somePrice)
225+
}
213226
horizontalConstraint="l"
214227
/>
215228
{touched.somePrice &&

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

+16-14
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,25 @@ import styles from './money-input.mod.css';
66
import Currency from './currency';
77
import Option from './option';
88
import DropdownChevron from './dropdown-chevron';
9+
import filterDataAttributes from '../../../utils/filter-data-attributes';
910

1011
const getCurrencyDropdownSelectStyles = ({
1112
isDisabled,
12-
hasCurrencyError,
13-
hasCurrencyWarning,
13+
hasError,
14+
hasWarning,
1415
isOpen,
1516
}) => {
1617
if (isDisabled) return styles['currency-disabled'];
17-
if (hasCurrencyError) return styles['currency-error'];
18-
if (hasCurrencyWarning) return styles['currency-warning'];
18+
if (hasError) return styles['currency-error'];
19+
if (hasWarning) return styles['currency-warning'];
1920
if (isOpen) return styles['currency-active'];
2021

2122
return styles['currency-default'];
2223
};
2324

2425
const getCurrencyDropdownOptionsStyles = props => {
25-
if (props.hasCurrencyError) return styles['options-wrapper-error'];
26-
if (props.hasCurrencyWarning) return styles['options-wrapper-warning'];
26+
if (props.hasError) return styles['options-wrapper-error'];
27+
if (props.hasWarning) return styles['options-wrapper-warning'];
2728

2829
return styles['options-wrapper-active'];
2930
};
@@ -34,19 +35,20 @@ const CurrencyDropdown = props => (
3435
<div
3536
className={getCurrencyDropdownSelectStyles({
3637
isDisabled: props.isDisabled,
37-
hasCurrencyError: props.hasCurrencyError,
38-
hasCurrencyWarning: props.hasCurrencyWarning,
38+
hasError: props.hasError,
39+
hasWarning: props.hasWarning,
3940
isOpen,
4041
})}
42+
{...filterDataAttributes(props)}
4143
>
4244
<div className={styles.languagesDropdown} onClick={toggleMenu}>
4345
<Spacings.Inline scale="xs" alignItems="center">
4446
<Currency
4547
id={props.id}
4648
isDropdown={true}
4749
isDisabled={props.isDisabled}
48-
hasError={props.hasCurrencyError}
49-
hasWarning={props.hasCurrencyWarning}
50+
hasError={props.hasError}
51+
hasWarning={props.hasWarning}
5052
currency={props.currencyCode}
5153
/>
5254
{props.currencies.length > 0 && (
@@ -58,8 +60,8 @@ const CurrencyDropdown = props => (
5860
props.currencies.length > 0 && (
5961
<div
6062
className={getCurrencyDropdownOptionsStyles({
61-
hasCurrencyError: props.hasCurrencyError,
62-
hasCurrencyWarning: props.hasCurrencyWarning,
63+
hasError: props.hasError,
64+
hasWarning: props.hasWarning,
6365
})}
6466
>
6567
{props.currencies.map(currencyCode => (
@@ -90,8 +92,8 @@ CurrencyDropdown.propTypes = {
9092
currencies: PropTypes.arrayOf(PropTypes.string).isRequired,
9193
currencyCode: PropTypes.string,
9294
isDisabled: PropTypes.bool,
93-
hasCurrencyError: PropTypes.bool,
94-
hasCurrencyWarning: PropTypes.bool,
95+
hasError: PropTypes.bool,
96+
hasWarning: PropTypes.bool,
9597
onChange: PropTypes.func,
9698
onBlur: PropTypes.func,
9799
name: PropTypes.string,

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

+4-12
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,7 @@ describe('rendering', () => {
105105

106106
describe('error', () => {
107107
beforeEach(() => {
108-
props = createTestProps({
109-
hasCurrencyError: true,
110-
});
108+
props = createTestProps({ hasError: true });
111109
downshiftProps = { isOpen: false, toggleMenu: jest.fn() };
112110
downshiftRenderWrapper = render(props, downshiftProps);
113111
});
@@ -121,9 +119,7 @@ describe('rendering', () => {
121119

122120
describe('warning', () => {
123121
beforeEach(() => {
124-
props = createTestProps({
125-
hasCurrencyWarning: true,
126-
});
122+
props = createTestProps({ hasWarning: true });
127123
downshiftProps = { isOpen: false, toggleMenu: jest.fn() };
128124
downshiftRenderWrapper = render(props, downshiftProps);
129125
});
@@ -185,9 +181,7 @@ describe('rendering', () => {
185181

186182
describe('error', () => {
187183
beforeEach(() => {
188-
props = createTestProps({
189-
hasAmountError: true,
190-
});
184+
props = createTestProps({ hasError: true });
191185
wrapper = shallow(<MoneyInput {...props} />);
192186
centAmountField = wrapper.find('input');
193187
});
@@ -199,9 +193,7 @@ describe('rendering', () => {
199193

200194
describe('warning', () => {
201195
beforeEach(() => {
202-
props = createTestProps({
203-
hasAmountWarning: true,
204-
});
196+
props = createTestProps({ hasWarning: true });
205197
wrapper = shallow(<MoneyInput {...props} />);
206198
centAmountField = wrapper.find('input');
207199
});

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

+6-14
Original file line numberDiff line numberDiff line change
@@ -51,26 +51,18 @@ storiesOf('Examples|Forms/Inputs', module)
5151
value={formik.values.price}
5252
onChange={formik.handleChange}
5353
onBlur={formik.handleBlur}
54-
hasCurrencyError={Boolean(
55-
formik.touched.price &&
56-
formik.touched.price.currencyCode &&
57-
formik.errors.price
58-
)}
59-
hasAmountError={Boolean(
60-
formik.touched.price &&
61-
formik.touched.price.amount &&
62-
formik.errors.price
63-
)}
54+
hasError={
55+
MoneyInput.isTouched(formik.touched.price) &&
56+
Boolean(formik.errors.price)
57+
}
6458
horizontalConstraint="m"
6559
/>
6660
{MoneyInput.isTouched(formik.touched.price) &&
67-
formik.errors.price &&
68-
formik.errors.price.missing && (
61+
formik.errors.price?.missing && (
6962
<ErrorMessage>Missing price</ErrorMessage>
7063
)}
7164
{MoneyInput.isTouched(formik.touched.price) &&
72-
formik.errors.price &&
73-
formik.errors.price.unsupportedHighPrecision && (
65+
formik.errors.price?.unsupportedHighPrecision && (
7466
<ErrorMessage>
7567
This value is a high precision value. High precision pricing
7668
is not supported for products.

0 commit comments

Comments
 (0)