diff --git a/packages/app-transfer/src/Transfer.tsx b/packages/app-transfer/src/Transfer.tsx index 00119be467..652ce3e7af 100644 --- a/packages/app-transfer/src/Transfer.tsx +++ b/packages/app-transfer/src/Transfer.tsx @@ -2,16 +2,17 @@ // This software may be modified and distributed under the terms // of the ISC license. See the LICENSE file for details. -import { I18nProps } from '@polkadot/ui-app/types'; +import { BitLength, I18nProps } from '@polkadot/ui-app/types'; import { RxFees } from '@polkadot/ui-react-rx/ApiObservable/types'; import { QueueProps } from '@polkadot/ui-signer/types'; import { Fees } from './types'; import BN from 'bn.js'; import React from 'react'; +import { BitLengthOption } from '@polkadot/ui-app/constants'; import AddressSummary from '@polkadot/ui-app/AddressSummary'; import InputAddress from '@polkadot/ui-app/InputAddress'; -import Input from '@polkadot/ui-app/Input'; +import InputNumber from '@polkadot/ui-app/InputNumber'; import withMulti from '@polkadot/ui-react-rx/with/multi'; import withObservable from '@polkadot/ui-react-rx/with/observable'; import { QueueConsumer } from '@polkadot/ui-signer/Context'; @@ -31,6 +32,8 @@ type State = { txfees: Fees }; +const DEFAULT_BITLENGTH = BitLengthOption.CHAIN_SPEC as BitLength; + const ZERO = new BN(0); class Transfer extends React.PureComponent { @@ -79,15 +82,13 @@ class Transfer extends React.PureComponent {
{this.renderAddress(from)}
- { ); } - private onChangeAmount = (amount: string) => { - this.setState({ amount: new BN(amount || 0) }); + private onChangeAmount = (amount: BN) => { + this.setState({ amount }); } private onChangeFrom = (from: Uint8Array) => { diff --git a/packages/ui-app/src/Input.tsx b/packages/ui-app/src/Input.tsx index c4333a14f1..2e2aef84e5 100644 --- a/packages/ui-app/src/Input.tsx +++ b/packages/ui-app/src/Input.tsx @@ -6,7 +6,6 @@ import { BareProps } from './types'; import React from 'react'; import SUIInput from 'semantic-ui-react/dist/commonjs/elements/Input/Input'; - import isUndefined from '@polkadot/util/is/undefined'; import Labelled from './Labelled'; @@ -25,10 +24,12 @@ type Props = BareProps & { isHidden?: boolean, label?: any, // node? max?: any, + maxLength?: number, min?: any, name?: string, onChange: (value: string) => void, onKeyDown?: (event: React.KeyboardEvent) => void, + onKeyUp?: (event: React.KeyboardEvent) => void, placeholder?: string, tabIndex?: number, type?: Input$Type, @@ -40,6 +41,39 @@ type State = { name: string; }; +// note: KeyboardEvent.keyCode and KeyboardEvent.which are deprecated +const KEYS = { + A: 'a', + ALT: 'Alt', + ARROW_LEFT: 'ArrowLeft', + ARROW_RIGHT: 'ArrowRight', + BACKSPACE: 'Backspace', + C: 'c', + CMD: 'Meta', + CTRL: 'Control', + ENTER: 'Enter', + ESCAPE: 'Escape', + TAB: 'Tab', + V: 'v', + X: 'x', + ZERO: '0' +}; + +const KEYS_PRE: Array = [KEYS.ALT, KEYS.CMD, KEYS.CTRL]; + +// reference: degrade key to keyCode for cross-browser compatibility https://www.w3schools.com/jsref/event_key_keycode.asp +const isCopy = (key: string, isPreKeyDown: boolean): boolean => + isPreKeyDown && key === KEYS.C; + +const isCut = (key: string, isPreKeyDown: boolean): boolean => + isPreKeyDown && key === KEYS.X; + +const isPaste = (key: string, isPreKeyDown: boolean): boolean => + isPreKeyDown && key === KEYS.V; + +const isSelectAll = (key: string, isPreKeyDown: boolean): boolean => + isPreKeyDown && key === KEYS.A; + let counter = 0; export default class Input extends React.PureComponent { @@ -48,7 +82,7 @@ export default class Input extends React.PureComponent { }; render () { - const { autoFocus = false, children, className, defaultValue, icon, isEditable = false, isAction = false, isDisabled = false, isError = false, isHidden = false, label, max, min, name, placeholder, style, tabIndex, type = 'text', value, withLabel } = this.props; + const { autoFocus = false, children, className, defaultValue, icon, isEditable = false, isAction = false, isDisabled = false, isError = false, isHidden = false, label, max, maxLength, min, name, placeholder, style, tabIndex, type = 'text', value, withLabel } = this.props; return ( { hidden={isHidden} max={max} min={min} + maxLength={maxLength} name={name || this.state.name} onChange={this.onChange} onKeyDown={this.onKeyDown} + onKeyUp={this.onKeyUp} placeholder={placeholder} tabIndex={tabIndex} type={type} @@ -98,8 +134,9 @@ export default class Input extends React.PureComponent { onChange = (event: React.SyntheticEvent): void => { const { onChange } = this.props; + const { value } = event.target as HTMLInputElement; - onChange((event.target as HTMLInputElement).value); + onChange(value); } onKeyDown = (event: React.KeyboardEvent): void => { @@ -109,4 +146,21 @@ export default class Input extends React.PureComponent { onKeyDown(event); } } + + onKeyUp = (event: React.KeyboardEvent): void => { + const { onKeyUp } = this.props; + + if (onKeyUp) { + onKeyUp(event); + } + } } + +export { + isCopy, + isCut, + isPaste, + isSelectAll, + KEYS, + KEYS_PRE +}; diff --git a/packages/ui-app/src/InputNumber.spec.js b/packages/ui-app/src/InputNumber.spec.js new file mode 100644 index 0000000000..a406b6a9c9 --- /dev/null +++ b/packages/ui-app/src/InputNumber.spec.js @@ -0,0 +1,102 @@ +// Copyright 2017-2018 @polkadot/ui-app authors & contributors +// This software may be modified and distributed under the terms +// of the ISC license. See the LICENSE file for details. + +import BN from 'bn.js'; +import React from 'react'; + +import { mount } from '../../../test/enzyme'; +import { BitLengthOption } from './constants'; +import Input from './Input'; +import { InputNumber, maxConservativeLength } from './InputNumber'; + +const mockT = (key, options) => (key); + +describe('InputNumber', () => { + const DEFAULT_BITLENGTH = BitLengthOption.CHAIN_SPEC; + const max128BN = new BN(2).pow(new BN(128)).subn(1); + const aboveMax128BN = max128BN.addn(1); + const belowMax128BN = max128BN.subn(1); + + let wrapper, divInputNumber, defaultValue; + + beforeEach(() => { + defaultValue = { value: '0' }; + wrapper = mount( + , {} + ); + divInputNumber = wrapper.find('.ui--InputNumber'); + }); + + it('creates the element', () => { + expect(wrapper).toHaveLength(1); + }); + + it('should display InputNumber wrapper and Input component', () => { + expect(divInputNumber).toHaveLength(1); + expect(wrapper.find(Input)).toHaveLength(1); + }); + + describe('maxLength', () => { + it('returns max length for use on number input fields that is the length of the max BN value minus a conservativeness factor for 128 bit', () => { + const maxValueLength = wrapper.instance().maxValue(DEFAULT_BITLENGTH).toString().length; + expect(maxConservativeLength(maxValueLength)).toBe(38); + }); + }); + + describe('maxValue', () => { + it('returns max BN value for given bitlength or uses 128 bit bitlength fallback', () => { + expect(wrapper.instance().maxValue(128)).toEqual(max128BN); + }); + }); + + describe('isNonInteger', () => { + describe('invalid inputs', () => { + it('detects decimal value', () => { + expect(wrapper.instance().isNonInteger('12.1')).toBe(true); + }); + + it('detects non-integer value', () => { + expect(wrapper.instance().isNonInteger('12.1copypastedvalue')).toBe(true); + }); + }); + + describe('valid inputs', () => { + it('detects integers', () => { + expect(wrapper.instance().isNonInteger('12')).toBe(false); + }); + + it('detects integer value with single digit', () => { + expect(wrapper.instance().isNonInteger('0')).toBe(false); + }); + }); + }); + + describe('isValidBitLength', () => { + it('returns whether value in BN is valid for given bitlength or uses 128 bit bitlength fallback', () => { + expect(wrapper.instance().isValidBitLength(aboveMax128BN, DEFAULT_BITLENGTH)).toBe(false); + expect(wrapper.instance().isValidBitLength(max128BN, DEFAULT_BITLENGTH)).toBe(true); + }); + }); + + describe('isValidNumber', () => { + describe('checks extrinsic balance', () => { + it('throws an error if input value for comparison is not a string', () => { + const invalidInputValueType = 340282366920938463463374607431768211456; + + expect(() => { wrapper.instance().isValidNumber(invalidInputValueType, '0', DEFAULT_BITLENGTH); }).toThrow(); + }); + }); + + it('should not be valid when user enters positive value greater than or equal to the 128 bit max for latest chain', () => { + expect(wrapper.instance().isValidNumber(max128BN.toString(10), DEFAULT_BITLENGTH)).toBe(false); + }); + + it('should be valid when user enters positive value less than the 128 bit max for latest chain', () => { + expect(wrapper.instance().isValidNumber(belowMax128BN.toString(10), DEFAULT_BITLENGTH)).toBe(true); + }); + }); +}); diff --git a/packages/ui-app/src/InputNumber.tsx b/packages/ui-app/src/InputNumber.tsx new file mode 100644 index 0000000000..2c977990c6 --- /dev/null +++ b/packages/ui-app/src/InputNumber.tsx @@ -0,0 +1,215 @@ +// Copyright 2017-2018 @polkadot/ui-app authors & contributors +// This software may be modified and distributed under the terms +// of the ISC license. See the LICENSE file for details. + +import { BareProps, BitLength, I18nProps } from './types'; + +import BN from 'bn.js'; +import React from 'react'; +import isString from '@polkadot/util/is/string'; + +import classes from './util/classes'; +import { BitLengthOption } from './constants'; +import Input, { KEYS, KEYS_PRE, isCopy, isCut, isPaste, isSelectAll } from './Input'; +import translate from './translate'; + +type Props = BareProps & I18nProps & { + bitLength?: BitLength, + defaultValue?: string, + isError?: boolean, + label?: any, + maxLength?: number, + onChange: (value: BN) => void, + placeholder?: string, + withLabel?: boolean +}; + +type State = { + isPreKeyDown: boolean, + isValid: boolean, + previousValue: string +}; + +const DEFAULT_BITLENGTH = BitLengthOption.NORMAL_NUMBERS as BitLength; +const KEYS_ALLOWED: Array = [KEYS.ARROW_LEFT, KEYS.ARROW_RIGHT, KEYS.BACKSPACE, KEYS.ENTER, KEYS.ESCAPE, KEYS.TAB]; + +function maxConservativeLength (maxValueLength: number): number { + const conservativenessFactor = 1; + + return maxValueLength - conservativenessFactor; +} + +class InputNumber extends React.PureComponent { + state: State = { + isPreKeyDown: false, + isValid: false, + previousValue: '0' + }; + + render () { + const { bitLength = DEFAULT_BITLENGTH, className, defaultValue, maxLength, style, t } = this.props; + const { isValid, previousValue } = this.state; + const revertedValue = !isValid ? previousValue : undefined; + const maxValueLength = this.maxValue(bitLength).toString().length; + + return ( +
+ +
+ ); + } + + private maxValue = (bitLength?: number): BN => + new BN(2).pow(new BN(bitLength || DEFAULT_BITLENGTH)).subn(1) + + // check if string value is non-integer even if above MAX_SAFE_INTEGER. isNaN(Number(value)) is faster for values of length 1 + private isNonInteger = (value: string): boolean => + value && value.length && value.split('').find(value => '0123456789'.indexOf(value) === -1) ? true : false + + private isValidBitLength = (value: BN, bitLength?: number): boolean => + value.bitLength() <= (bitLength || DEFAULT_BITLENGTH) + + private isValidKey = (event: React.KeyboardEvent, isPreKeyDown: boolean): boolean => { + const { value: previousValue } = event.target as HTMLInputElement; + // prevents entry of zero if initial digit is zero + const isDuplicateZero = previousValue[0] === '0' && event.key === KEYS.ZERO; + + if (isDuplicateZero) { + return false; + } + + // prevent use of shift key + if (event.shiftKey) { + return false; + } + + // allow cut/copy/paste combinations but not non-numeric letters (i.e. a, c, x, v) individually + if ( + (isSelectAll(event.key, isPreKeyDown)) || + (isCut(event.key, isPreKeyDown)) || + (isCopy(event.key, isPreKeyDown)) || + (isPaste(event.key, isPreKeyDown)) + ) { + return true; + } + + if (isNaN(Number(event.key)) && !KEYS_ALLOWED.includes(event.key)) { + return false; + } + + return true; + } + + private isValidNumber = (input: string, bitLength?: number): boolean => { + const { t } = this.props; + bitLength = bitLength || DEFAULT_BITLENGTH; + + // failsafe as expects only positive integers as permitted by onKeyDown from input of type text + if (!isString(input)) { + throw Error(t('inputnumber.error.string.required', { + defaultValue: 'Number input value must be valid type' + })); + } + + // remove spaces even though not possible as user restricted from entering spacebar key in onKeyDown + input = input.toLowerCase().split(' ').join(''); + + if (this.isNonInteger(input)) { + return false; + } + + // max is (2 ** 128 - 1) for bitLength of 128 bit + const maxBN = this.maxValue(bitLength); + const inputBN = new BN(input); + + if (!inputBN.lt(maxBN) || !this.isValidBitLength(inputBN, bitLength)) { + return false; + } + + return true; + } + + private onChange = (value: string): void => { + const { bitLength, onChange } = this.props; + + try { + const valueBN = new BN(value || 0); + const isValid = this.isValidNumber(value, bitLength); + + this.setState({ isValid }); + + if (!onChange || !isValid) { + return; + } + + onChange(valueBN); + } catch (error) { + console.error(error); + } + } + + private onKeyDown = (event: React.KeyboardEvent): void => { + const { isPreKeyDown } = this.state; + const { value: previousValue } = event.target as HTMLInputElement; + + // store previous input field in state incase user pastes invalid value and we need to revert the input value + this.setState({ previousValue }); + + if (KEYS_PRE.includes(event.key)) { + this.setState({ isPreKeyDown: true }); + } + + // restrict input of certain keys + const isValid = this.isValidKey(event, isPreKeyDown); + + if (!isValid) { + event.preventDefault(); + } + } + + private onKeyUp = (event: React.KeyboardEvent): void => { + const { value: newValue } = event.target as HTMLInputElement; + const isNewValueZero = new BN(newValue).isZero(); + + if (KEYS_PRE.includes(event.key)) { + this.setState({ isPreKeyDown: false }); + } + + /* if new value equates to '0' in BN when but it's length is >=1 (i.e. '012', '00', etc) + * then replace the input value with just '0'. + * otherwise remove the preceding zeros from the new value (i.e. '0123' -> '123') + * note: edge case glitch occurs if existing value is '0' and you 'hold down' and keep + * pasting a value of '00' after it, then sometimes when you let go the + * remaining value shown as '000' or '00000' in the UI, but it's still ok because + * the actual BN if the user submitted would still be '0', and if they then press any key + * the UI input value resets to '0' + */ + if (isNewValueZero && newValue.length >= 1) { + (event.target as HTMLInputElement).value = '0'; + } else { + (event.target as HTMLInputElement).value = newValue.replace(/^0+/, ''); + } + } +} + +export { + InputNumber, + maxConservativeLength +}; + +export default translate(InputNumber); diff --git a/packages/ui-app/src/Params/Param/Balance.spec.js b/packages/ui-app/src/Params/Param/Balance.spec.js new file mode 100644 index 0000000000..7d98bd6c88 --- /dev/null +++ b/packages/ui-app/src/Params/Param/Balance.spec.js @@ -0,0 +1,33 @@ +// Copyright 2017-2018 @polkadot/ui-app authors & contributors +// This software may be modified and distributed under the terms +// of the ISC license. See the LICENSE file for details. + +import React from 'react'; + +import { shallow } from '../../../../../test/enzyme'; +import InputNumber from '../../InputNumber'; +import { Balance } from './Balance'; + +const mockT = (key, options) => (key); + +describe('Balance', () => { + let wrapper, defaultValue; + + beforeEach(() => { + defaultValue = { value: '0' }; + wrapper = shallow( + , {} + ); + }); + + it('creates the element', () => { + expect(wrapper).toHaveLength(1); + }); + + it('should display InputNumber component', () => { + expect(wrapper.find(InputNumber)).toHaveLength(1); + }); +}); diff --git a/packages/ui-app/src/Params/Param/Balance.tsx b/packages/ui-app/src/Params/Param/Balance.tsx new file mode 100644 index 0000000000..4360bf3238 --- /dev/null +++ b/packages/ui-app/src/Params/Param/Balance.tsx @@ -0,0 +1,60 @@ +// Copyright 2017-2018 @polkadot/ui-app authors & contributors +// This software may be modified and distributed under the terms +// of the ISC license. See the LICENSE file for details. + +import { BitLength } from '../../types'; +import { Props as BareProps } from '../types'; + +import BN from 'bn.js'; +import React from 'react'; + +import { BitLengthOption } from '../../constants'; +import InputNumber from '../../InputNumber'; +import Bare from './Bare'; + +type Props = BareProps; + +const DEFAULT_BITLENGTH = BitLengthOption.CHAIN_SPEC as BitLength; + +class Balance extends React.PureComponent { + render () { + const { className, defaultValue: { value }, isError, label, style, withLabel } = this.props; + const defaultValue = new BN(value as BN || '0').toString(10); + + return ( + + + + ); + } + + onChange = (value: BN): void => { + const { isError, onChange } = this.props; + + if (!onChange) { + return; + } + + onChange({ + isValid: !isError, + value + }); + } +} + +export { + Balance +}; + +export default Balance; diff --git a/packages/ui-app/src/Params/Param/findComponent.ts b/packages/ui-app/src/Params/Param/findComponent.ts index d76b98533a..7a87557ab0 100644 --- a/packages/ui-app/src/Params/Param/findComponent.ts +++ b/packages/ui-app/src/Params/Param/findComponent.ts @@ -7,6 +7,7 @@ import { Props, ComponentMap } from '../types'; import Account from './Account'; import Amount from './Amount'; +import Balance from './Balance'; import Bool from './Bool'; import Bytes from './Bytes'; import Code from './Code'; @@ -22,7 +23,7 @@ import VoteThreshold from './VoteThreshold'; const components: ComponentMap = { 'AccountId': Account, 'AccountIndex': Amount, - 'Balance': Amount, + 'Balance': Balance, 'BlockNumber': Amount, 'bool': Bool, 'Bytes': Bytes, diff --git a/packages/ui-app/src/constants.ts b/packages/ui-app/src/constants.ts new file mode 100644 index 0000000000..e3b7f140c3 --- /dev/null +++ b/packages/ui-app/src/constants.ts @@ -0,0 +1,8 @@ +// Copyright 2017-2018 @polkadot/ui-app authors & contributors +// This software may be modified and distributed under the terms +// of the ISC license. See the LICENSE file for details. + +export enum BitLengthOption { + CHAIN_SPEC = 128, + NORMAL_NUMBERS = 32 +} diff --git a/packages/ui-app/src/types.d.ts b/packages/ui-app/src/types.d.ts index 24edc955ec..6fd7bef95e 100644 --- a/packages/ui-app/src/types.d.ts +++ b/packages/ui-app/src/types.d.ts @@ -25,3 +25,5 @@ export type BaseContext = { } } }; + +export type BitLength = 8 | 16 | 32 | 64 | 128 | 256;