Skip to content
Permalink
Browse files

Merge pull request #114 from input-output-hk/chore/ddw-665-rewrite-nu…

…meric-input-component

[DDW-665] Rewrite numeric input logic
  • Loading branch information...
MarcusHurney committed Aug 14, 2019
2 parents 3a2afc3 + 2764bbf commit 387e7cf24139662ddda38e3b63aeb7318b4a45ea
Showing with 419 additions and 636 deletions.
  1. +1 −0 .eslintrc
  2. +1 −0 .travis.yaml
  3. +5 −0 CHANGELOG.md
  4. +48 −17 README.md
  5. +21 −154 __tests__/NumericInput.behavior.test.js
  6. +306 −360 source/components/NumericInput.js
  7. +5 −0 source/utils/types.js
  8. +32 −105 stories/NumericInput.stories.js
@@ -49,6 +49,7 @@
"no-underscore-dangle": 0,
"no-console": 0,
"no-mixed-operators": 0,
"no-restricted-globals": 0,
"prefer-template": 0,
"no-unused-vars": 1,
"no-trailing-spaces": 1,
@@ -0,0 +1 @@
language: node_js
@@ -10,6 +10,11 @@ vNext
- (BREAKING) `none` is no longer a valid option for `$bubble-border-color` and it should instead be `transparent`
- (BREAKING) Any library that directly calls the `arrow` mixin directly has to be updated to take into account the change in parameter to use `width` and `height` instead of a single `size`. If you use Bubble or any component that instead of a direct use of the mixin, no change is needed.

### Breaking Changes

- `NumericInput` component was completely rewritten to be more flexible and straight forward.
[PR 114](https://github.com/input-output-hk/react-polymorph/pull/114)

### Chores

- Updates contributors in package.json [PR 117](https://github.com/input-output-hk/react-polymorph/pull/117)
@@ -170,46 +170,77 @@ const MyNumericInput = () => (
skin={InputSkin} // but the same skin!
label="Amount"
placeholder="0.000000"
maxBeforeDot={5}
maxAfterDot={6}
maxValue={30000}
minValue={0.000001}
numberLocaleOptions={{ maximumFractionDigits: 6 }}
/>
);
```


This is a simple example that shows how you can make/use specialized versions
of basic components by composition - a core idea of `react-polymorph`!
_Side Note: this shows how you can make/use specialized versions of basic components by composition
(reusing the `InputSkin` with a specialized logic component) - a core idea of react-polymorph!_

##### NumericInput Props:
##### Expected Behavior & Limitations:

Since there is no web standard on how to build numeric input components, here is the specification we
came up with that serves our purposes in the best way:

- Only numeric inputs that are representable by Javascript numbers are valid. This is guarded by `Number.MIN_SAFE_INTEGER`
(-9007199254740991) and `Number.MAX_SAFE_INTEGER` (9007199254740991) but since also fractions need to
represented, the calculation for the maximum integer part goes like this:
`Number.MAX_SAFE_INTEGER / 10 ** (maximumFractionDigits + 1)` (which basically means that one integer digit is lost for
each supported fraction digit). For `maximumFractionDigits == 3` this results in
`9007199254740991 / 10 ** 4 == 900719925474.099` being the biggest number that can be entered.
- Only numeric digits `[0-9]` and dots `.` can be entered.
- When invalid characters are pasted as input, nothing happens
- When a second dot is entered it replaces the existing one and updates the fraction part accordingly
- Commas cannot be deleted but the cursor should jump over them when DEL or BACKSPACE keys are used
- The fraction dot can only be deleted if `minimumFractionDigits` is not defined or
if the resulting number does not exceed the numeric limits!
- It's possible to replace the whole number or parts of it (even the dot) by inserting another number.
- If the fraction dot is deleted but the resulting number is too big the cursor jumps over the dot without deletion
- If you insert a digit but the resulting number would exceed the numeric limit, nothing happens

##### Props:

```js
type NumericInputProps = {
autoFocus?: boolean,
className?: string,
context: ThemeContextProp,
disabled?: boolean,
enforceMax: boolean,
label?: string | Element<any>,
enforceMin: boolean,
error?: string,
label?: string | Element<any>,
numberLocaleOptions?: Number$LocaleOptions,
onBlur?: Function,
onChange?: Function,
onFocus?: Function,
maxAfterDot?: number,
maxBeforeDot?: number,
maxValue?: number,
minValue?: number,
readOnly?: boolean,
placeholder?: string,
setError?: Function,
readOnly?: boolean,
skin?: ComponentType<any>,
theme: ?Object,
themeId: string,
themeOverrides: Object,
value: string
useDynamicDigitCalculation: boolean,
value: ?number,
};
```
###### `numberLocaleOptions`
`Number.toLocaleString()` is used internally to localize the given number value. This method takes options
explained in greater detail in the
[MDN web docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toLocaleString)
The most important parts are `maximumFractionDigits` (defaults to 3 as per web standard) and `minimumFractionDigits`
which dictate the handling of fraction digits.
###### `useDynamicDigitCalculation`
This is an optional mode that "sacrifices" simple, clear UX in favor of being able to enter bigger numbers.
Basically it works the same way but it dynamically calculates how large the integer part of the number can
be based on the actual fraction digits entered. The less fraction digits, the more integer digits are possible
and vice versa.
---
#### Textarea
@@ -4,180 +4,47 @@ import { NumericInput } from '../source/components/NumericInput';
import { mountInSimpleTheme } from './helpers/theming';

describe('NumericInput onChange simulations', () => {
test('onChange updates state with valid amount', () => {
const wrapper = mountInSimpleTheme(
<NumericInput />
);

const component = wrapper.find('NumericInput').instance();
test('valid input triggers onChange listener', () => {
const onChangeMock = jest.fn();
const wrapper = mountInSimpleTheme(<NumericInput onChange={onChangeMock} />);
const input = wrapper.find('input');

// valid input value
input.simulate('change', { target: { value: '19.00' } });
expect(component.state.oldValue).toBe('19.00');
input.simulate('change', { nativeEvent: { target: { value: '19.00' } } });
expect(onChangeMock.mock.calls[0][0]).toBe(19.00);
});

test('onChange creates error via invalid amount: value > maxValue', () => {
const wrapper = mountInSimpleTheme(
<NumericInput maxValue={1000} />
);

const component = wrapper.find('NumericInput').instance();
test('handles en-US localized input values', () => {
const onChangeMock = jest.fn();
const wrapper = mountInSimpleTheme(<NumericInput onChange={onChangeMock} />);
const input = wrapper.find('input');

// invalid input value: value > maxValue
// state and className should reflect error
input.simulate('change', { target: { value: '1001.00' } });
expect(component.state.error).toBeTruthy();
expect(input.instance().className).toBe('input errored');
});

test('onChange creates error via invalid amount: value < minValue', () => {
const wrapper = mountInSimpleTheme(
<NumericInput minValue={500} />
);

const component = wrapper.find('NumericInput').instance();
const input = wrapper.find('input');

// invalid input value: value < minValue
// state and className should reflect error
input.simulate('change', { target: { value: '499.99' } });
expect(component.state.error).toBeTruthy();
expect(input.instance().className).toBe('input errored');
});

test('onChange is passed invalid amount, maxBeforeDot is enforced correctly', () => {
const wrapper = mountInSimpleTheme(
<NumericInput maxBeforeDot={3} />
);

const component = wrapper.find('NumericInput').instance();
const input = wrapper.find('input');

// input value is valid: value has 3 integer places
input.simulate('change', { target: { value: '432.99' } });
expect(component.state.oldValue).toBe('432.99');

// input value is invalid: value has 4 integer places
// 6 should be dropped from the 1's place
input.simulate('change', { target: { value: '9876.99' } });
expect(component.state.oldValue).toBe('987.99');
input.simulate('change', { nativeEvent: { target: { value: '9,999,999.00' } } });
expect(onChangeMock.mock.calls[0][0]).toBe(9999999.00);
});

test('onChange is passed invalid amount, maxAfterDot is enforced correctly', () => {
test('invalid input does not trigger onChange listener', () => {
const onChangeMock = jest.fn();
const wrapper = mountInSimpleTheme(
<NumericInput maxAfterDot={4} />
<NumericInput onChange={onChangeMock} />
);

const component = wrapper.find('NumericInput').instance();
const input = wrapper.find('input');

// simulate onChange with 4 decimal places (valid)
input.simulate('change', { target: { value: '65.7821' } });
expect(component.state.oldValue).toBe('65.7821');

// simulate onChange with 5 decimal places (invalid)
input.simulate('change', { target: { value: '85.98543' } });
// 3 should be dropped from the 5th decimal place
expect(component.state.oldValue).toBe('85.9854');
input.simulate('change', { nativeEvent: { target: { value: 'A.00' } } });
expect(onChangeMock.mock.calls.length).toBe(0);
});

test('integers only - onChange is passed invalid amount, maxAfterDot is enforced correctly', () => {
test('enforces given minimumFractionDigits', () => {
const wrapper = mountInSimpleTheme(
<NumericInput maxAfterDot={0} />
<NumericInput numberLocaleOptions={{ minimumFractionDigits: 6 }} value={0} />
);

const component = wrapper.find('NumericInput').instance();
const input = wrapper.find('input');

// simulate onChange with only an integer (valid)
input.simulate('change', { target: { value: '1234' } });
expect(component.state.oldValue).toBe('1234');

// simulate onChange with floating point number (invalid)
input.simulate('change', { target: { value: '5678.985' } });
// should drop decimal & all numbers after decimal: '.985'
expect(component.state.oldValue).toBe('5678');
expect(input.getDOMNode().value).toBe('0.000000');
});

test('onChange simulates amount exceeding maxValue, enforceMax is enforced', () => {
test('enforces given maximumFractionDigits', () => {
const wrapper = mountInSimpleTheme(
<NumericInput
enforceMax
maxValue={24999}
maxAfterDot={2}
/>
<NumericInput numberLocaleOptions={{ maximumFractionDigits: 2 }} value={0.123} />
);

const component = wrapper.find('NumericInput').instance();
const input = wrapper.find('input');

// valid input value: there should be no error in state or className
input.simulate('change', { target: { value: '24500.99' } });
expect(component.state.oldValue).toBe('24500.99');
expect(component.state.error).toBe('');
expect(input.instance().className).toBe('input');

// invalid input value: value exceeds maxValue
// integers should be adjusted to maxValue
// state and className should reflect error
input.simulate('change', { target: { value: '25000.00' } });
expect(component.state.oldValue).toBe('24999.00');
expect(component.state.error).toBeTruthy();
expect(input.instance().className).toBe('input errored');

// invalid input value: decimal value exceeds maxValue
// decimals should be adjusted to maxValue
// state and className should reflect error
input.simulate('change', { target: { value: '24999.99' } });
expect(component.state.oldValue).toBe('24999.00');
expect(component.state.error).toBeTruthy();
expect(input.instance().className).toBe('input errored');

// valid input value: should reset state and className
input.simulate('change', { target: { value: '50.00' } });
expect(component.state.error).toBe('');
expect(input.instance().className).toBe('input');
expect(input.getDOMNode().value).toBe('0.12');
});

test('onChange simulates amount less than minValue, enforceMin is enforced', () => {
const wrapper = mountInSimpleTheme(
<NumericInput
enforceMin
minValue={99.99}
maxAfterDot={2}
/>
);

const component = wrapper.find('NumericInput').instance();
const input = wrapper.find('input');

// simulate onChange with valid amount
// should be no error in state or className
input.simulate('change', { target: { value: '100.00' } });
expect(component.state.oldValue).toBe('100.00');
expect(component.state.error).toBe('');

// input value is invalid: value < minValue
// integers should be adjusted to minValue
// state and className should reflect error
input.simulate('change', { target: { value: '85.99' } });
expect(component.state.oldValue).toBe('99.99');
expect(component.state.error).toBeTruthy();
expect(input.instance().className).toBe('input errored');

// input value is invalid: decimal value is less than minValue
// decimals should be adjusted to minValue
// state and className should reflect error
input.simulate('change', { target: { value: '99.98' } });
expect(component.state.oldValue).toBe('99.99');
expect(component.state.error).toBeTruthy();
expect(input.instance().className).toBe('input errored');

// valid input value: should reset error in state and className
input.simulate('change', { target: { value: '150.00' } });
expect(component.state.error).toBe('');
expect(input.instance().className).toBe('input');
});
});

0 comments on commit 387e7cf

Please sign in to comment.
You can’t perform that action at this time.