Skip to content

Commit aebfab9

Browse files
LucianBuzzoepicfaace
authored andcommittedFeb 26, 2019
Improve handling of decimal points and trailing zeroes in numbers (#1183)
Connects to #674 #958 Change-type: patch Signed-off-by: Lucian <lucian.buzzo@gmail.com>
1 parent 07decc5 commit aebfab9

File tree

6 files changed

+238
-26
lines changed

6 files changed

+238
-26
lines changed
 

‎src/components/fields/NumberField.js

+83-8
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,89 @@ import React from "react";
33
import * as types from "../../types";
44
import { asNumber } from "../../utils";
55

6-
function NumberField(props) {
7-
const { StringField } = props.registry.fields;
8-
return (
9-
<StringField
10-
{...props}
11-
onChange={value => props.onChange(asNumber(value))}
12-
/>
13-
);
6+
// Matches a string that ends in a . character, optionally followed by a sequence of
7+
// digits followed by any number of 0 characters up until the end of the line.
8+
// Ensuring that there is at least one prefixed character is important so that
9+
// you don't incorrectly match against "0".
10+
const trailingCharMatcherWithPrefix = /\.([0-9]*0)*$/;
11+
12+
// This is used for trimming the trailing 0 and . characters without affecting
13+
// the rest of the string. Its possible to use one RegEx with groups for this
14+
// functionality, but it is fairly complex compared to simply defining two
15+
// different matchers.
16+
const trailingCharMatcher = /[0.]0*$/;
17+
18+
/**
19+
* The NumberField class has some special handling for dealing with trailing
20+
* decimal points and/or zeroes. This logic is designed to allow trailing values
21+
* to be visible in the input element, but not be represented in the
22+
* corresponding form data.
23+
*
24+
* The algorithm is as follows:
25+
*
26+
* 1. When the input value changes the value is cached in the component state
27+
*
28+
* 2. The value is then normalized, removing trailing decimal points and zeros,
29+
* then passed to the "onChange" callback
30+
*
31+
* 3. When the component is rendered, the formData value is checked against the
32+
* value cached in the state. If it matches the cached value, the cached
33+
* value is passed to the input instead of the formData value
34+
*/
35+
class NumberField extends React.Component {
36+
constructor(props) {
37+
super(props);
38+
39+
this.state = {
40+
lastValue: props.value,
41+
};
42+
}
43+
44+
handleChange = value => {
45+
// Cache the original value in component state
46+
this.setState({ lastValue: value });
47+
48+
// Normalize decimals that don't start with a zero character in advance so
49+
// that the rest of the normalization logic is simpler
50+
if (`${value}`.charAt(0) === ".") {
51+
value = `0${value}`;
52+
}
53+
54+
// Check that the value is a string (this can happen if the widget used is a
55+
// <select>, due to an enum declaration etc) then, if the value ends in a
56+
// trailing decimal point or multiple zeroes, strip the trailing values
57+
let processed =
58+
typeof value === "string" && value.match(trailingCharMatcherWithPrefix)
59+
? asNumber(value.replace(trailingCharMatcher, ""))
60+
: asNumber(value);
61+
62+
this.props.onChange(processed);
63+
};
64+
65+
render() {
66+
const { StringField } = this.props.registry.fields;
67+
const { formData, ...props } = this.props;
68+
const { lastValue } = this.state;
69+
70+
let value = formData;
71+
72+
if (typeof lastValue === "string" && value) {
73+
// Construct a regular expression that checks for a string that consists
74+
// of the formData value suffixed with zero or one '.' characters and zero
75+
// or more '0' characters
76+
const re = new RegExp(`${value}`.replace(".", "\\.") + "\\.?0*$");
77+
78+
// If the cached "lastValue" is a match, use that instead of the formData
79+
// value to prevent the input value from changing in the UI
80+
if (lastValue.match(re)) {
81+
value = lastValue;
82+
}
83+
}
84+
85+
return (
86+
<StringField {...props} formData={value} onChange={this.handleChange} />
87+
);
88+
}
1489
}
1590

1691
if (process.env.NODE_ENV !== "production") {

‎src/components/widgets/BaseInput.js

+26-2
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,32 @@ function BaseInput(props) {
2323
...inputProps
2424
} = props;
2525

26-
inputProps.type = options.inputType || inputProps.type || "text";
26+
// If options.inputType is set use that as the input type
27+
if (options.inputType) {
28+
inputProps.type = options.inputType;
29+
} else if (!inputProps.type) {
30+
// If the schema is of type number or integer, set the input type to number
31+
if (schema.type === "number") {
32+
inputProps.type = "number";
33+
// Setting step to 'any' fixes a bug in Safari where decimals are not
34+
// allowed in number inputs
35+
inputProps.step = "any";
36+
} else if (schema.type === "integer") {
37+
inputProps.type = "number";
38+
// Since this is integer, you always want to step up or down in multiples
39+
// of 1
40+
inputProps.step = "1";
41+
} else {
42+
inputProps.type = "text";
43+
}
44+
}
45+
46+
// If multipleOf is defined, use this as the step value. This mainly improves
47+
// the experience for keyboard users (who can use the up/down KB arrows).
48+
if (schema.multipleOf) {
49+
inputProps.step = schema.multipleOf;
50+
}
51+
2752
const _onChange = ({ target: { value } }) => {
2853
return props.onChange(value === "" ? options.emptyValue : value);
2954
};
@@ -44,7 +69,6 @@ function BaseInput(props) {
4469
}
4570

4671
BaseInput.defaultProps = {
47-
type: "text",
4872
required: false,
4973
disabled: false,
5074
readonly: false,

‎test/ArrayField_test.js

+4-4
Original file line numberDiff line numberDiff line change
@@ -1102,7 +1102,7 @@ describe("ArrayField", () => {
11021102
"fieldset .field-string input[type=text]"
11031103
);
11041104
const numInput = node.querySelector(
1105-
"fieldset .field-number input[type=text]"
1105+
"fieldset .field-number input[type=number]"
11061106
);
11071107
expect(strInput.id).eql("root_0");
11081108
expect(numInput.id).eql("root_1");
@@ -1114,7 +1114,7 @@ describe("ArrayField", () => {
11141114
"fieldset .field-string input[type=text]"
11151115
);
11161116
const numInput = node.querySelector(
1117-
"fieldset .field-number input[type=text]"
1117+
"fieldset .field-number input[type=number]"
11181118
);
11191119
expect(strInput.required).eql(true);
11201120
expect(numInput.required).eql(true);
@@ -1129,7 +1129,7 @@ describe("ArrayField", () => {
11291129
"fieldset .field-string input[type=text]"
11301130
);
11311131
const numInput = node.querySelector(
1132-
"fieldset .field-number input[type=text]"
1132+
"fieldset .field-number input[type=number]"
11331133
);
11341134
expect(strInput.value).eql("foo");
11351135
expect(numInput.value).eql("42");
@@ -1141,7 +1141,7 @@ describe("ArrayField", () => {
11411141
"fieldset .field-string input[type=text]"
11421142
);
11431143
const numInput = node.querySelector(
1144-
"fieldset .field-number input[type=text]"
1144+
"fieldset .field-number input[type=number]"
11451145
);
11461146

11471147
Simulate.change(strInput, { target: { value: "bar" } });

‎test/Form_test.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -1693,7 +1693,7 @@ describe("Form", () => {
16931693
liveValidate: true,
16941694
});
16951695

1696-
Simulate.change(node.querySelector("input[type=text]"), {
1696+
Simulate.change(node.querySelector("input[type=number]"), {
16971697
target: { value: "not a number" },
16981698
});
16991699

@@ -1711,7 +1711,7 @@ describe("Form", () => {
17111711
formData: { branch: 2 },
17121712
});
17131713

1714-
Simulate.change(node.querySelector("input[type=text]"), {
1714+
Simulate.change(node.querySelector("input[type=number]"), {
17151715
target: { value: "not a number" },
17161716
});
17171717

‎test/NumberField_test.js

+121-8
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { expect } from "chai";
33
import { Simulate } from "react-addons-test-utils";
44
import sinon from "sinon";
55

6-
import { createFormComponent, createSandbox } from "./test_utils";
6+
import { createFormComponent, createSandbox, setProps } from "./test_utils";
77

88
describe("NumberField", () => {
99
let sandbox;
@@ -17,15 +17,15 @@ describe("NumberField", () => {
1717
});
1818

1919
describe("TextWidget", () => {
20-
it("should render a string field", () => {
20+
it("should render a number input", () => {
2121
const { node } = createFormComponent({
2222
schema: {
2323
type: "number",
2424
},
2525
});
2626

2727
expect(
28-
node.querySelectorAll(".field input[type=text]")
28+
node.querySelectorAll(".field input[type=number]")
2929
).to.have.length.of(1);
3030
});
3131

@@ -129,18 +129,107 @@ describe("NumberField", () => {
129129
expect(node.querySelector(".field input").value).eql("2");
130130
});
131131

132-
it("should not cast the input as a number if it ends with a dot", () => {
132+
describe("when inputting a number that ends with a dot and/or zero it should normalize it, without changing the input value", () => {
133133
const { comp, node } = createFormComponent({
134134
schema: {
135135
type: "number",
136136
},
137137
});
138138

139-
Simulate.change(node.querySelector("input"), {
140-
target: { value: "2." },
139+
const $input = node.querySelector("input");
140+
141+
const tests = [
142+
{
143+
input: "2.",
144+
output: 2,
145+
},
146+
{
147+
input: "2.0",
148+
output: 2,
149+
},
150+
{
151+
input: "2.3",
152+
output: 2.3,
153+
},
154+
{
155+
input: "2.30",
156+
output: 2.3,
157+
},
158+
{
159+
input: "2.300",
160+
output: 2.3,
161+
},
162+
{
163+
input: "2.3001",
164+
output: 2.3001,
165+
},
166+
{
167+
input: "2.03",
168+
output: 2.03,
169+
},
170+
{
171+
input: "2.003",
172+
output: 2.003,
173+
},
174+
{
175+
input: "2.00300",
176+
output: 2.003,
177+
},
178+
{
179+
input: "200300",
180+
output: 200300,
181+
},
182+
];
183+
184+
tests.forEach(test => {
185+
it(`should work with an input value of ${test.input}`, () => {
186+
Simulate.change($input, {
187+
target: { value: test.input },
188+
});
189+
190+
expect(comp.state.formData).eql(test.output);
191+
expect($input.value).eql(test.input);
192+
});
193+
});
194+
});
195+
196+
it("should normalize values beginning with a decimal point", () => {
197+
const { comp, node } = createFormComponent({
198+
schema: {
199+
type: "number",
200+
},
141201
});
142202

143-
expect(comp.state.formData).eql("2.");
203+
const $input = node.querySelector("input");
204+
205+
Simulate.change($input, {
206+
target: { value: ".00" },
207+
});
208+
209+
expect(comp.state.formData).eql(0);
210+
expect($input.value).eql("0");
211+
});
212+
213+
it("should update input values correctly when formData prop changes", () => {
214+
const schema = {
215+
type: "number",
216+
};
217+
218+
const { comp, node } = createFormComponent({
219+
schema,
220+
formData: 2.03,
221+
});
222+
223+
const $input = node.querySelector("input");
224+
225+
expect($input.value).eql("2.03");
226+
227+
setProps(comp, {
228+
schema,
229+
formData: 203,
230+
});
231+
232+
expect($input.value).eql("203");
144233
});
145234

146235
it("should render the widget with the expected id", () => {
@@ -150,7 +239,7 @@ describe("NumberField", () => {
150239
},
151240
});
152241

153-
expect(node.querySelector("input[type=text]").id).eql("root");
242+
expect(node.querySelector("input[type=number]").id).eql("root");
154243
});
155244

156245
it("should render with trailing zeroes", () => {
@@ -181,6 +270,19 @@ describe("NumberField", () => {
181270
expect(node.querySelector(".field input").value).eql("2.000");
182271
});
183272

273+
it("should allow a zero to be input", () => {
274+
const { node } = createFormComponent({
275+
schema: {
276+
type: "number",
277+
},
278+
});
279+
280+
Simulate.change(node.querySelector("input"), {
281+
target: { value: "0" },
282+
});
283+
expect(node.querySelector(".field input").value).eql("0");
284+
});
285+
184286
it("should render customized StringField", () => {
185287
const CustomStringField = () => <div id="custom" />;
186288

@@ -195,6 +297,17 @@ describe("NumberField", () => {
195297

196298
expect(node.querySelector("#custom")).to.exist;
197299
});
300+
301+
it("should use step to represent the multipleOf keyword", () => {
302+
const { node } = createFormComponent({
303+
schema: {
304+
type: "number",
305+
multipleOf: 5,
306+
},
307+
});
308+
309+
expect(node.querySelector("input").step).to.eql("5");
310+
});
198311
});
199312

200313
describe("SelectWidget", () => {

‎test/uiSchema_test.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -1933,7 +1933,7 @@ describe("uiSchema", () => {
19331933

19341934
it("should disable a number text widget", () => {
19351935
shouldBeDisabled(
1936-
"input[type=text]",
1936+
"input[type=number]",
19371937
{
19381938
type: "number",
19391939
},
@@ -2226,7 +2226,7 @@ describe("uiSchema", () => {
22262226

22272227
it("should mark as readonly a number text widget", () => {
22282228
shouldBeReadonly(
2229-
"input[type=text]",
2229+
"input[type=number]",
22302230
{
22312231
type: "number",
22322232
},

0 commit comments

Comments
 (0)
Please sign in to comment.