Skip to content

Commit ecebfb0

Browse files
authored
Merge pull request #626 from facultyai/number-input
Number input
2 parents b60d188 + 12e4e92 commit ecebfb0

File tree

3 files changed

+173
-86
lines changed

3 files changed

+173
-86
lines changed

src/components/input/Input.js

Lines changed: 137 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,37 @@
1-
import React, {useEffect, useState} from 'react';
1+
import React, {forwardRef, useEffect, useRef, useState} from 'react';
22
import PropTypes from 'prop-types';
3-
import {omit} from 'ramda';
3+
import {isNil, omit} from 'ramda';
44
import isNumeric from 'fast-isnumeric';
55
import classNames from 'classnames';
66

77
const convert = val => (isNumeric(val) ? +val : NaN);
8+
const isEquivalent = (v1, v2) => v1 === v2 || (isNaN(v1) && isNaN(v2));
89

9-
/**
10-
* A basic HTML input control for entering text, numbers, or passwords, with
11-
* Bootstrap styles automatically applied. This component is much like its
12-
* counterpart in dash_core_components, but with a few additions such as the
13-
* `valid` and `invalid` props for providing user feedback.
14-
*
15-
* Note that checkbox and radio types are supported through
16-
* the Checklist and RadioItems component. Dates, times, and file uploads
17-
* are supported through separate components in other libraries.
18-
*/
19-
const Input = props => {
10+
const BaseInput = forwardRef((props, ref) => {
2011
const {
21-
value,
22-
className,
2312
debounce,
2413
n_blur,
2514
n_submit,
26-
valid,
27-
invalid,
28-
bs_size,
29-
plaintext,
3015
loading_state,
3116
setProps,
17+
onEvent,
18+
onChange,
19+
valid,
20+
invalid,
3221
...otherProps
3322
} = props;
3423

35-
const [valueState, setValueState] = useState(value || '');
36-
37-
useEffect(() => {
38-
// "" == 0 in JavaScript, which means we need to check separately if a
39-
// cleared input is being set to 0
40-
if (value != valueState || (valueState === '' && value === 0)) {
41-
if (value !== null && value !== undefined) {
42-
setValueState(value);
43-
} else {
44-
setValueState('');
45-
}
46-
}
47-
}, [value]);
48-
49-
const parseValue = value => {
50-
if (props.type === 'number') {
51-
const convertedValue = convert(value);
52-
if (isNaN(convertedValue)) {
53-
return null;
54-
} else return convertedValue;
55-
} else return value;
56-
};
57-
58-
const onChange = e => {
59-
setValueState(e.target.value);
60-
if (!debounce && setProps) {
61-
setProps({value: parseValue(e.target.value)});
62-
}
63-
};
64-
6524
const onBlur = () => {
6625
if (setProps) {
6726
const payload = {
6827
n_blur: n_blur + 1,
6928
n_blur_timestamp: Date.now()
7029
};
7130
if (debounce) {
72-
payload.value = parseValue(valueState);
31+
onEvent(payload);
32+
} else {
33+
setProps(payload);
7334
}
74-
setProps(payload);
7535
}
7636
};
7737

@@ -82,48 +42,154 @@ const Input = props => {
8242
n_submit_timestamp: Date.now()
8343
};
8444
if (debounce) {
85-
payload.value = parseValue(valueState);
45+
onEvent(payload);
46+
} else {
47+
setProps(payload);
8648
}
87-
setProps(payload);
8849
}
8950
};
9051

91-
const formControlClass = plaintext
92-
? 'form-control-plaintext'
93-
: 'form-control';
94-
95-
const classes = classNames(
96-
className,
97-
invalid && 'is-invalid',
98-
valid && 'is-valid',
99-
bs_size ? `form-control-${bs_size}` : false,
100-
formControlClass
101-
);
10252
return (
10353
<input
54+
ref={ref}
10455
onChange={onChange}
10556
onBlur={onBlur}
10657
onKeyPress={onKeyPress}
107-
className={classes}
108-
value={valueState}
10958
{...omit(
11059
[
11160
'n_blur_timestamp',
11261
'n_submit_timestamp',
113-
'selectionDirection',
114-
'selectionEnd',
115-
'selectionStart',
11662
'persistence',
11763
'persistence_type',
11864
'persisted_props'
11965
],
12066
otherProps
12167
)}
68+
valid={valid ? 'true' : undefined}
69+
invalid={invalid ? 'true' : undefined}
12270
data-dash-is-loading={
12371
(loading_state && loading_state.is_loading) || undefined
12472
}
12573
/>
12674
);
75+
});
76+
77+
const NumberInput = forwardRef((props, inputRef) => {
78+
const {setProps, debounce, value, ...otherProps} = props;
79+
80+
const onChange = () => {
81+
if (!debounce) {
82+
onEvent();
83+
}
84+
};
85+
86+
useEffect(() => {
87+
const inputValue = inputRef.current.value;
88+
const inputValueAsNumber = inputRef.current.checkValidity()
89+
? convert(inputValue)
90+
: NaN;
91+
const valueAsNumber = convert(value);
92+
93+
if (!isEquivalent(valueAsNumber, inputValueAsNumber)) {
94+
inputRef.current.value = isNil(valueAsNumber) ? valueAsNumber : value;
95+
}
96+
}, [value]);
97+
98+
const onEvent = (payload = {}) => {
99+
const inputValue = inputRef.current.value;
100+
const inputValueAsNumber = inputRef.current.checkValidity()
101+
? convert(inputValue)
102+
: NaN;
103+
const valueAsNumber = convert(value);
104+
105+
if (!isEquivalent(valueAsNumber, inputValueAsNumber)) {
106+
setProps({...payload, value: inputValueAsNumber});
107+
} else if (Object.keys(payload).length) {
108+
setProps(payload);
109+
}
110+
};
111+
112+
return (
113+
<BaseInput
114+
ref={inputRef}
115+
debounce={debounce}
116+
onEvent={onEvent}
117+
onChange={onChange}
118+
setProps={setProps}
119+
{...otherProps}
120+
/>
121+
);
122+
});
123+
124+
const NonNumberInput = forwardRef((props, inputRef) => {
125+
const {value, debounce, setProps, ...otherProps} = props;
126+
const [valueState, setValueState] = useState(value || '');
127+
128+
const onChange = () => {
129+
if (!debounce) {
130+
onEvent();
131+
} else {
132+
setValueState(inputRef.current.value);
133+
}
134+
};
135+
136+
useEffect(() => {
137+
if (value !== null && value !== undefined) {
138+
setValueState(value);
139+
} else {
140+
setValueState('');
141+
}
142+
}, [value]);
143+
144+
const onEvent = (payload = {}) => {
145+
payload.value = inputRef.current.value;
146+
setProps(payload);
147+
};
148+
149+
return (
150+
<BaseInput
151+
ref={inputRef}
152+
value={valueState}
153+
debounce={debounce}
154+
onEvent={onEvent}
155+
onChange={onChange}
156+
setProps={setProps}
157+
{...otherProps}
158+
/>
159+
);
160+
});
161+
162+
/**
163+
* A basic HTML input control for entering text, numbers, or passwords, with
164+
* Bootstrap styles automatically applied. This component is much like its
165+
* counterpart in dash_core_components, but with a few additions such as the
166+
* `valid` and `invalid` props for providing user feedback.
167+
*
168+
* Note that checkbox and radio types are supported through
169+
* the Checklist and RadioItems component. Dates, times, and file uploads
170+
* are supported through separate components in other libraries.
171+
*/
172+
const Input = props => {
173+
const {plaintext, className, bs_size, ...otherProps} = props;
174+
const inputRef = useRef(null);
175+
176+
const formControlClass = plaintext
177+
? 'form-control-plaintext'
178+
: 'form-control';
179+
180+
const classes = classNames(
181+
className,
182+
props.invalid && 'is-invalid',
183+
props.valid && 'is-valid',
184+
bs_size ? `form-control-${bs_size}` : false,
185+
formControlClass
186+
);
187+
188+
if (props.type === 'number') {
189+
return <NumberInput ref={inputRef} {...otherProps} className={classes} />;
190+
}
191+
192+
return <NonNumberInput ref={inputRef} {...otherProps} className={classes} />;
127193
};
128194

129195
Input.propTypes = {
@@ -480,7 +546,8 @@ Input.defaultProps = {
480546
n_submit_timestamp: -1,
481547
debounce: false,
482548
persisted_props: ['value'],
483-
persistence_type: 'local'
549+
persistence_type: 'local',
550+
step: 'any'
484551
};
485552

486553
export default Input;

src/components/input/__tests__/Input.test.js

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ describe('Input', () => {
2929
});
3030

3131
test('passes HTML attributes on to underlying input', () => {
32-
const {container: {firstChild: input}} = render(
32+
const {
33+
container: {firstChild: input}
34+
} = render(
3335
<Input
3436
autoComplete="username"
3537
disabled
@@ -91,13 +93,11 @@ describe('Input', () => {
9193
expect(mockSetProps.mock.calls[0][0]).toEqual({
9294
value: 'some-input-value'
9395
});
94-
expect(inputElement).toHaveValue('some-input-value');
9596
});
9697

9798
test('dispatches update for each typed character', () => {
9899
userEvent.type(inputElement, 'abc');
99100

100-
expect(inputElement).toHaveValue('abc');
101101
expect(mockSetProps.mock.calls).toHaveLength(3);
102102

103103
const [call1, call2, call3] = mockSetProps.mock.calls;
@@ -194,7 +194,9 @@ describe('Input', () => {
194194

195195
beforeEach(() => {
196196
mockSetProps = jest.fn();
197-
const {container} = render(<Input setProps={mockSetProps} type="number" />);
197+
const {container} = render(
198+
<Input setProps={mockSetProps} type="number" />
199+
);
198200
inputElement = container.firstChild;
199201
});
200202

@@ -232,11 +234,10 @@ describe('Input', () => {
232234
userEvent.type(inputElement, '-1e4');
233235

234236
expect(inputElement).toHaveValue(-10000);
235-
expect(mockSetProps.mock.calls).toHaveLength(3);
237+
expect(mockSetProps.mock.calls).toHaveLength(2);
236238

237-
const [call1, call2, call3] = mockSetProps.mock.calls;
239+
const [call1, call3] = mockSetProps.mock.calls;
238240
expect(call1).toEqual([{value: -1}]);
239-
expect(call2).toEqual([{value: null}]);
240241
expect(call3).toEqual([{value: -10000}]);
241242
});
242243

@@ -246,5 +247,30 @@ describe('Input', () => {
246247
expect(inputElement).not.toHaveValue();
247248
expect(mockSetProps.mock.calls).toHaveLength(0);
248249
});
250+
251+
test('passes value on to the underlying HTML input', () => {
252+
const {
253+
container: {firstChild: input},
254+
rerender
255+
} = render(<Input type="number" value={10} />);
256+
257+
expect(input).toHaveValue(10);
258+
259+
rerender(<Input type="number" value={12} />);
260+
expect(input).toHaveValue(12);
261+
});
262+
263+
test('returns NaN once for invalid number', () => {
264+
const {
265+
container: {firstChild: input}
266+
} = render(
267+
<Input type="number" min={0} value={0} setProps={mockSetProps} />
268+
);
269+
270+
userEvent.type(input, '{backspace}-100');
271+
272+
expect(mockSetProps.mock.calls).toHaveLength(1);
273+
expect(mockSetProps.mock.calls[0][0]).toEqual({value: NaN});
274+
});
249275
});
250276
});

src/index.js

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,7 @@ export {default as Col} from './components/layout/Col';
1919
export {default as Collapse} from './components/Collapse';
2020
export {default as Container} from './components/layout/Container';
2121
export {default as DropdownMenu} from './components/dropdownmenu/DropdownMenu';
22-
export {
23-
default as DropdownMenuItem
24-
} from './components/dropdownmenu/DropdownMenuItem';
22+
export {default as DropdownMenuItem} from './components/dropdownmenu/DropdownMenuItem';
2523
export {default as Fade} from './components/Fade';
2624
export {default as Form} from './components/form/Form';
2725
export {default as FormFeedback} from './components/form/FormFeedback';
@@ -35,12 +33,8 @@ export {default as Jumbotron} from './components/Jumbotron';
3533
export {default as Label} from './components/Label';
3634
export {default as ListGroup} from './components/listgroup/ListGroup';
3735
export {default as ListGroupItem} from './components/listgroup/ListGroupItem';
38-
export {
39-
default as ListGroupItemHeading
40-
} from './components/listgroup/ListGroupItemHeading';
41-
export {
42-
default as ListGroupItemText
43-
} from './components/listgroup/ListGroupItemText';
36+
export {default as ListGroupItemHeading} from './components/listgroup/ListGroupItemHeading';
37+
export {default as ListGroupItemText} from './components/listgroup/ListGroupItemText';
4438
export {default as Modal} from './components/modal/Modal';
4539
export {default as ModalBody} from './components/modal/ModalBody';
4640
export {default as ModalFooter} from './components/modal/ModalFooter';

0 commit comments

Comments
 (0)