Use TextMask on CurrencyInput
This commit is contained in:
parent
70e82776e0
commit
a5b3d5d198
14 changed files with 566 additions and 53 deletions
|
@ -8,6 +8,8 @@ import {HoudiniField} from "../../lib/houdini_form";
|
||||||
import ReactInput from "./form/ReactInput";
|
import ReactInput from "./form/ReactInput";
|
||||||
import ReactSelect from './form/ReactSelect';
|
import ReactSelect from './form/ReactSelect';
|
||||||
import ReactTextarea from "./form/ReactTextarea";
|
import ReactTextarea from "./form/ReactTextarea";
|
||||||
|
import ReactMaskedInput from "./form/ReactMaskedInput";
|
||||||
|
import createNumberMask from "../../lib/createNumberMask";
|
||||||
|
|
||||||
|
|
||||||
export const BasicField = observer((props:{field:Field, placeholder?:string, label?:string, wrapperClassName?:string, inputClassNames?:string}) =>{
|
export const BasicField = observer((props:{field:Field, placeholder?:string, label?:string, wrapperClassName?:string, inputClassNames?:string}) =>{
|
||||||
|
@ -44,17 +46,26 @@ export const TextareaField = observer((props:{field:Field, placeholder?:string,
|
||||||
</LabeledFieldComponent>
|
</LabeledFieldComponent>
|
||||||
})
|
})
|
||||||
|
|
||||||
export const CurrencyField = observer((props:{field:Field,placeholder?:string, label?:string, currencySymbol?:string, wrapperClassName?:string, inputClassNames?:string}) => {
|
export const CurrencyField = observer((props:{field:Field,placeholder?:string, label?:string, currencySymbol?:string, wrapperClassName?:string, inputClassNames?:string, mustBeNegative?:boolean, allowNegative?:boolean}) => {
|
||||||
let field = props.field as HoudiniField
|
let field = props.field as HoudiniField
|
||||||
let currencySymbolId = props.field.id + "_____currency_symbol"
|
let currencySymbol = props.mustBeNegative ? "-$" : "$"
|
||||||
|
let allowNegative = props.allowNegative || !props.mustBeNegative
|
||||||
return <LabeledFieldComponent
|
return <LabeledFieldComponent
|
||||||
inputId={props.field.id} labelText={field.label} inError={field.hasError} error={field.error}
|
inputId={props.field.id} labelText={field.label} inError={field.hasError} error={field.error}
|
||||||
inStickyError={field.hasServerError} stickyError={field.serverError}
|
inStickyError={field.hasServerError} stickyError={field.serverError}
|
||||||
className={props.wrapperClassName} >
|
className={props.wrapperClassName} >
|
||||||
<div className="input-group">
|
|
||||||
<span className="input-group-addon" id={currencySymbolId}>{props.currencySymbol}</span>
|
<ReactMaskedInput field={field} label={props.label} placeholder={props.placeholder}
|
||||||
<ReactInput field={field} label={props.label} placeholder={props.placeholder} className={`form-control ${props.inputClassNames}`} aria-describedby={currencySymbolId}/>
|
className={`form-control ${props.inputClassNames}`} guide={true}
|
||||||
</div>
|
mask={createNumberMask({allowDecimal:true,
|
||||||
|
requireDecimal:true,
|
||||||
|
prefix:currencySymbol,
|
||||||
|
allowNegative:allowNegative,
|
||||||
|
fixedDecimalScale:true
|
||||||
|
})}
|
||||||
|
showMask={true} placeholderChar={'0'}
|
||||||
|
/>
|
||||||
|
|
||||||
</LabeledFieldComponent>
|
</LabeledFieldComponent>
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
// License: LGPL-3.0-or-later
|
||||||
|
import * as React from 'react';
|
||||||
|
import 'jest';
|
||||||
|
import ReactMaskedInput from './ReactMaskedInput'
|
||||||
|
|
||||||
|
describe('ReactMaskedInput', () => {
|
||||||
|
test('your test here', () => {
|
||||||
|
expect(false).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
81
javascripts/src/components/common/form/ReactMaskedInput.tsx
Normal file
81
javascripts/src/components/common/form/ReactMaskedInput.tsx
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
// License: LGPL-3.0-or-later
|
||||||
|
import * as React from 'react';
|
||||||
|
import { observer } from 'mobx-react';
|
||||||
|
import {ReactInputProps} from "./react_input_props";
|
||||||
|
import {InputHTMLAttributes} from "react";
|
||||||
|
import {action, observable} from "mobx";
|
||||||
|
import {Field} from "mobx-react-form";
|
||||||
|
import {castToNullIfUndef} from "../../../lib/utils";
|
||||||
|
import MaskedInput, {maskArray} from "react-text-mask";
|
||||||
|
|
||||||
|
type InputTypes = ReactInputProps &
|
||||||
|
InputHTMLAttributes<HTMLInputElement> & {
|
||||||
|
mask?: maskArray | ((value: string) => maskArray);
|
||||||
|
|
||||||
|
guide?: boolean;
|
||||||
|
|
||||||
|
placeholderChar?: string;
|
||||||
|
|
||||||
|
keepCharPositions?: boolean;
|
||||||
|
|
||||||
|
pipe?: (
|
||||||
|
conformedValue: string,
|
||||||
|
config: any
|
||||||
|
) => false | string | { value: string; indexesOfPipedChars: number[] };
|
||||||
|
|
||||||
|
showMask?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReactMaskedInput extends React.Component<InputTypes, {}> {
|
||||||
|
|
||||||
|
constructor(props:InputTypes){
|
||||||
|
super(props)
|
||||||
|
}
|
||||||
|
|
||||||
|
@observable
|
||||||
|
field:Field
|
||||||
|
|
||||||
|
|
||||||
|
@action.bound
|
||||||
|
componentWillMount(){
|
||||||
|
|
||||||
|
this.field = this.props.field
|
||||||
|
|
||||||
|
|
||||||
|
this.updateProps()
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount(){
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: Readonly<InputTypes>, prevState: Readonly<{}>): void {
|
||||||
|
this.updateProps()
|
||||||
|
}
|
||||||
|
|
||||||
|
@action.bound
|
||||||
|
updateProps() {
|
||||||
|
this.field.set('label', castToNullIfUndef(this.props.label))
|
||||||
|
this.field.set('placeholder', castToNullIfUndef(this.props.placeholder))
|
||||||
|
}
|
||||||
|
|
||||||
|
///Removes the properties we don't want to put into the input element
|
||||||
|
@action.bound
|
||||||
|
winnowProps(): InputTypes {
|
||||||
|
let ourProps = {...this.props}
|
||||||
|
delete ourProps.field
|
||||||
|
delete ourProps.value
|
||||||
|
return ourProps
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <MaskedInput {...this.winnowProps()} {...this.field.bind()}/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(ReactMaskedInput)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -7,11 +7,3 @@ exports[`ReactInput gets removed properly 1`] = `
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`ReactInput no children passed in gets removed properly 1`] = `
|
|
||||||
<form>
|
|
||||||
<button
|
|
||||||
onClick={[Function]}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
// License: LGPL-3.0-or-later
|
||||||
import {Field} from "mobx-react-form";
|
import {Field} from "mobx-react-form";
|
||||||
|
|
||||||
export interface ReactInputProps
|
export interface ReactInputProps
|
||||||
|
|
|
@ -19,12 +19,14 @@ import {TwoColumnFields} from "../common/layout";
|
||||||
import {Validations} from "../../lib/vjf_rules";
|
import {Validations} from "../../lib/vjf_rules";
|
||||||
import _ = require("lodash");
|
import _ = require("lodash");
|
||||||
import {Dedication, parseDedication, serializeDedication} from '../../lib/dedication';
|
import {Dedication, parseDedication, serializeDedication} from '../../lib/dedication';
|
||||||
|
import blacklist = require("validator/lib/blacklist");
|
||||||
|
import {createFieldDefinition} from "../../lib/mobx_utils";
|
||||||
|
|
||||||
|
|
||||||
interface Charge {
|
interface Charge {
|
||||||
status: string
|
status: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface RecurringDonation {
|
interface RecurringDonation {
|
||||||
interval?: number
|
interval?: number
|
||||||
time_unit?: string
|
time_unit?: string
|
||||||
|
@ -79,7 +81,6 @@ export interface FundraiserInfo {
|
||||||
|
|
||||||
class EditPaymentPanelForm extends HoudiniForm {
|
class EditPaymentPanelForm extends HoudiniForm {
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -160,9 +161,7 @@ class EditPaymentPane extends React.Component<EditPaymentPaneProps & InjectedInt
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createDefinition<TInputType>(fieldDef:FieldDefinition<TInputType>) : FieldDefinition<TInputType> {
|
|
||||||
return fieldDef;
|
|
||||||
}
|
|
||||||
|
|
||||||
@action.bound
|
@action.bound
|
||||||
loadFormFromData() {
|
loadFormFromData() {
|
||||||
|
@ -171,18 +170,24 @@ class EditPaymentPane extends React.Component<EditPaymentPaneProps & InjectedInt
|
||||||
let params: {[name:string]:FieldDefinition} = {
|
let params: {[name:string]:FieldDefinition} = {
|
||||||
'event': {name: 'event', label: 'Event', value: eventId},
|
'event': {name: 'event', label: 'Event', value: eventId},
|
||||||
'campaign': {name: 'campaign', label: 'Campaign', value: campaignId},
|
'campaign': {name: 'campaign', label: 'Campaign', value: campaignId},
|
||||||
'gross_amount': {name: 'gross_amount', label: 'Gross Amount', value: centsToDollars(this.props.data.gross_amount)},
|
'gross_amount': createFieldDefinition({name: 'gross_amount', label: 'Gross Amount', value: this.props.data.gross_amount,
|
||||||
'fee_total': {name: 'fee_total', label: 'Fees', value: centsToDollars(this.props.data.fee_total)},
|
input: (amount:number) => centsToDollars(amount),
|
||||||
'date': this.createDefinition({name: 'date', label: 'Date',
|
output: (dollarString:string) => parseFloat(blacklist(dollarString, '$,'))
|
||||||
|
}),
|
||||||
|
'fee_total': createFieldDefinition({name: 'fee_total', label: 'Fees', value: this.props.data.fee_total,
|
||||||
|
input: (amount:number) => centsToDollars(amount),
|
||||||
|
output: (dollarString:string) => parseFloat(blacklist(dollarString, '$,'))
|
||||||
|
}),
|
||||||
|
'date': createFieldDefinition({name: 'date', label: 'Date',
|
||||||
value: this.props.data.date,
|
value: this.props.data.date,
|
||||||
input: (isoTime:string) => this.nonprofitTimezonedDates.readable_date(isoTime),
|
input: (isoTime:string) => this.nonprofitTimezonedDates.readable_date(isoTime),
|
||||||
output:(date:string) => this.nonprofitTimezonedDates.readable_date_time_to_iso(date)}),
|
output:(date:string) => this.nonprofitTimezonedDates.readable_date_time_to_iso(date)}),
|
||||||
'dedication': {name: 'dedication', label: 'Dedication', fields: [
|
'dedication': {name: 'dedication', label: 'Dedication', fields: [
|
||||||
this.createDefinition({name:'type', label: 'Dedication Type', value: this.dedication.type}),
|
createFieldDefinition({name:'type', label: 'Dedication Type', value: this.dedication.type}),
|
||||||
this.createDefinition({name: 'supporter_id', type: 'hidden', value: this.dedication.supporter_id}),
|
createFieldDefinition({name: 'supporter_id', type: 'hidden', value: this.dedication.supporter_id}),
|
||||||
this.createDefinition({name:'name', label:'Person dedicated for', value: this.dedication.name}),
|
createFieldDefinition({name:'name', label:'Person dedicated for', value: this.dedication.name}),
|
||||||
this.createDefinition({name: 'contact', type: 'hidden', value: this.dedication.contact}),
|
createFieldDefinition({name: 'contact', type: 'hidden', value: this.dedication.contact}),
|
||||||
this.createDefinition({name: 'note', value: this.dedication.note})
|
createFieldDefinition({name: 'note', value: this.dedication.note})
|
||||||
]},
|
]},
|
||||||
'designation': {name: 'designation', label: 'Designation', value: this.props.data.donation.designation},
|
'designation': {name: 'designation', label: 'Designation', value: this.props.data.donation.designation},
|
||||||
'comment': {name: 'comment', label: 'Note', value: this.props.data.donation.comment}
|
'comment': {name: 'comment', label: 'Note', value: this.props.data.donation.comment}
|
||||||
|
@ -201,7 +206,7 @@ class EditPaymentPane extends React.Component<EditPaymentPaneProps & InjectedInt
|
||||||
|
|
||||||
return new EditPaymentPanelForm({fields: _.values(params)}, {
|
return new EditPaymentPanelForm({fields: _.values(params)}, {
|
||||||
hooks: {
|
hooks: {
|
||||||
onSubmit: async (e: Field) => {
|
onSuccess: async (e: Field) => {
|
||||||
await this.updateDonation()
|
await this.updateDonation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -346,7 +351,7 @@ class EditPaymentPane extends React.Component<EditPaymentPaneProps & InjectedInt
|
||||||
|
|
||||||
<TwoColumnFields>
|
<TwoColumnFields>
|
||||||
<CurrencyField field={this.form.$('gross_amount')} label={"Gross Amount"} currencySymbol={"$"}/>
|
<CurrencyField field={this.form.$('gross_amount')} label={"Gross Amount"} currencySymbol={"$"}/>
|
||||||
<CurrencyField field={this.form.$('fee_total')} label={"Processing Fees"}/>
|
<CurrencyField field={this.form.$('fee_total')} label={"Processing Fees"} mustBeNegative={true}/>
|
||||||
|
|
||||||
</TwoColumnFields>
|
</TwoColumnFields>
|
||||||
|
|
||||||
|
|
236
javascripts/src/lib/createNumberMask.spec.ts
Normal file
236
javascripts/src/lib/createNumberMask.spec.ts
Normal file
|
@ -0,0 +1,236 @@
|
||||||
|
//from: https://github.com/text-mask/text-mask/pull/760/commits/f66c81b62c4894b7da43862bee5943f659dc7537
|
||||||
|
// under: Unlicense
|
||||||
|
import createNumberMask from './createNumberMask'
|
||||||
|
describe('createNumberMask', () => {
|
||||||
|
it('can returns a configured currency mask', () => {
|
||||||
|
let numberMask = createNumberMask()
|
||||||
|
|
||||||
|
expect(typeof numberMask).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('takes a prefix', () => {
|
||||||
|
let numberMask = createNumberMask({prefix: '$'})
|
||||||
|
|
||||||
|
expect(numberMask('12')).toEqual(['$', /\d/, /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('takes a suffix', () => {
|
||||||
|
let numberMask = createNumberMask({suffix: ' $', prefix: ''})
|
||||||
|
|
||||||
|
expect(numberMask('12')).toEqual([/\d/, /\d/, ' ', '$'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works when the prefix contains numbers', () => {
|
||||||
|
let numberMask = createNumberMask({prefix: 'm2 '})
|
||||||
|
|
||||||
|
expect(numberMask('m2 1')).toEqual(['m', '2', ' ', /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works when the suffix contains numbers', () => {
|
||||||
|
let numberMask = createNumberMask({prefix: '', suffix: ' m2'})
|
||||||
|
|
||||||
|
expect(numberMask('1 m2')).toEqual([/\d/, ' ', 'm', '2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works when there is a decimal and the suffix contains numbers', () => {
|
||||||
|
let numberMask = createNumberMask({prefix: '', suffix: ' m2', allowDecimal: true})
|
||||||
|
|
||||||
|
expect(numberMask('1.2 m2')).toEqual([/\d/, '[]', '.', '[]', /\d/, ' ', 'm', '2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can be configured to add a thousands separator or not', () => {
|
||||||
|
let numberMaskWithoutThousandsSeparator = createNumberMask({includeThousandsSeparator: false})
|
||||||
|
expect(numberMaskWithoutThousandsSeparator('1000')).toEqual(['$', /\d/, /\d/, /\d/, /\d/])
|
||||||
|
|
||||||
|
let numberMaskWithThousandsSeparator = createNumberMask()
|
||||||
|
expect(numberMaskWithThousandsSeparator('1000')).toEqual(['$', /\d/, ',', /\d/, /\d/, /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can be configured with a custom character for the thousands separator', () => {
|
||||||
|
let numberMask = createNumberMask({thousandsSeparatorSymbol: '.'})
|
||||||
|
|
||||||
|
expect(numberMask('1000')).toEqual(['$', /\d/, '.', /\d/, /\d/, /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can be configured to accept a fraction and returns the fraction separator with caret traps', () => {
|
||||||
|
let numberMask = createNumberMask({allowDecimal: true})
|
||||||
|
|
||||||
|
expect(numberMask('1000.')).toEqual(['$', /\d/, ',', /\d/, /\d/, /\d/, '[]', '.', '[]'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects fractions by default', () => {
|
||||||
|
let numberMask = createNumberMask()
|
||||||
|
|
||||||
|
expect(numberMask('1000.')).toEqual(['$', /\d/, ',', /\d/, /\d/, /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can be configured with a custom character for the fraction separator', () => {
|
||||||
|
let numberMask = createNumberMask({
|
||||||
|
allowDecimal: true,
|
||||||
|
decimalSymbol: ',',
|
||||||
|
thousandsSeparatorSymbol: '.'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(numberMask('1000,')).toEqual(['$', /\d/, '.', /\d/, /\d/, /\d/, '[]', ',', '[]'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can limit the length of the fraction', () => {
|
||||||
|
let numberMask = createNumberMask({allowDecimal: true, decimalLimit: 2})
|
||||||
|
|
||||||
|
expect(numberMask('1000.3823')).toEqual(['$', /\d/, ',', /\d/, /\d/, /\d/, '[]', '.', '[]', /\d/, /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can require a fraction', () => {
|
||||||
|
let numberMask = createNumberMask({requireDecimal: true})
|
||||||
|
|
||||||
|
expect(numberMask('1000')).toEqual(['$', /\d/, ',', /\d/, /\d/, /\d/, '[]', '.', '[]'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts negative integers', function() {
|
||||||
|
let numberMask = createNumberMask({allowNegative: true})
|
||||||
|
expect(numberMask('-$12')).toEqual([/-/, '$', /\d/, /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores multiple minus signs', function() {
|
||||||
|
let numberMask = createNumberMask({allowNegative: true})
|
||||||
|
expect(numberMask('--$12')).toEqual([/-/, '$', /\d/, /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds a digit placeholder if the input is nothing but a minus sign in order to attract the caret', () => {
|
||||||
|
let numberMask = createNumberMask({allowNegative: true})
|
||||||
|
expect(numberMask('-')).toEqual([/-/, '$', /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts with dot should be considered as decimal input', () => {
|
||||||
|
let numberMask = createNumberMask({prefix: '$', allowDecimal: true})
|
||||||
|
expect(numberMask('.')).toEqual(['$', '0', '.', /\d/])
|
||||||
|
|
||||||
|
numberMask = createNumberMask({prefix: '#', allowDecimal: true})
|
||||||
|
expect(numberMask('.')).toEqual(['#', '0', '.', /\d/])
|
||||||
|
|
||||||
|
numberMask = createNumberMask({prefix: '', allowDecimal: true})
|
||||||
|
expect(numberMask('.')).toEqual(['0', '.', /\d/])
|
||||||
|
|
||||||
|
numberMask = createNumberMask({allowDecimal: false})
|
||||||
|
expect(numberMask('.')).toEqual(['$'])
|
||||||
|
|
||||||
|
numberMask = createNumberMask({prefix: '', suffix: '$', allowDecimal: true})
|
||||||
|
expect(numberMask('.')).toEqual(['0', '.', /\d/, '$'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can allow leading zeroes', function() {
|
||||||
|
let numberMask = createNumberMask({allowLeadingZeroes: true})
|
||||||
|
expect(numberMask('012')).toEqual(['$', /\d/, /\d/, /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works with large numbers when leading zeroes is false', function() {
|
||||||
|
let numberMask = createNumberMask({allowLeadingZeroes: false})
|
||||||
|
expect(numberMask('111111111111111111111111')).toEqual([
|
||||||
|
'$', /\d/, /\d/, /\d/, ',', /\d/, /\d/, /\d/, ',', /\d/, /\d/, /\d/, ',', /\d/, /\d/, /\d/, ',',
|
||||||
|
/\d/, /\d/, /\d/, ',', /\d/, /\d/, /\d/, ',', /\d/, /\d/, /\d/, ',', /\d/, /\d/, /\d/
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('integer limiting', () => {
|
||||||
|
it('can limit the length of the integer part', () => {
|
||||||
|
let numberMask = createNumberMask({integerLimit: 3})
|
||||||
|
expect(numberMask('1999')).toEqual(['$', /\d/, /\d/, /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works when there is a prefix', () => {
|
||||||
|
let numberMask = createNumberMask({integerLimit: 3, prefix: '$'})
|
||||||
|
expect(numberMask('$1999')).toEqual(['$', /\d/, /\d/, /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works when there is a thousands separator', () => {
|
||||||
|
expect(createNumberMask({integerLimit: 4, prefix: ''})('1,9995'))
|
||||||
|
.toEqual([/\d/, ',', /\d/, /\d/, /\d/])
|
||||||
|
|
||||||
|
expect(createNumberMask({integerLimit: 7, prefix: ''})('1,000,0001'))
|
||||||
|
.toEqual([/\d/, ',', /\d/, /\d/, /\d/, ',', /\d/, /\d/, /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works when there is a decimal and a prefix', () => {
|
||||||
|
let numberMask = createNumberMask({integerLimit: 3, allowDecimal: true})
|
||||||
|
expect(numberMask('$199.34')).toEqual(['$', /\d/, /\d/, /\d/, '[]', '.', '[]', /\d/, /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works when there is a decimal and no prefix', () => {
|
||||||
|
let numberMask = createNumberMask({integerLimit: 3, allowDecimal: true, prefix: ''})
|
||||||
|
expect(numberMask('199.34')).toEqual([/\d/, /\d/, /\d/, '[]', '.', '[]', /\d/, /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works when thousandsSeparatorSymbol is a period', () => {
|
||||||
|
let numberMask = createNumberMask({
|
||||||
|
prefix: '',
|
||||||
|
thousandsSeparatorSymbol: '.',
|
||||||
|
decimalSymbol: ',',
|
||||||
|
allowDecimal: true,
|
||||||
|
requireDecimal: true,
|
||||||
|
integerLimit: 5,
|
||||||
|
decimalLimit: 3,
|
||||||
|
})
|
||||||
|
expect(numberMask('1234567890,12345678'))
|
||||||
|
.toEqual([/\d/, /\d/, '.', /\d/, /\d/, /\d/, '[]', ',', '[]', /\d/, /\d/, /\d/])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('numberMask default behavior', () => {
|
||||||
|
let numberMask:ReturnType<typeof createNumberMask> = null
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
numberMask = createNumberMask()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a mask that has the same number of digits as the given number', () => {
|
||||||
|
expect(numberMask('20382')).toEqual(['$', /\d/, /\d/, ',', /\d/, /\d/, /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the dollar symbol as the default prefix', () => {
|
||||||
|
expect(numberMask('1')).toEqual(['$', /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds no suffix by default', () => {
|
||||||
|
expect(numberMask('1')).toEqual(['$', /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a mask that appends the currency symbol', () => {
|
||||||
|
expect(numberMask('1')).toEqual(['$', /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds adds a comma after a thousand', () => {
|
||||||
|
expect(numberMask('1000')).toEqual(['$', /\d/, ',', /\d/, /\d/, /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds as many commas as needed', () => {
|
||||||
|
expect(numberMask('23984209342084'))
|
||||||
|
.toEqual(
|
||||||
|
['$', /\d/, /\d/, ',', /\d/, /\d/, /\d/, ',', /\d/, /\d/, /\d/, ',', /\d/, /\d/, /\d/, ',', /\d/, /\d/, /\d/]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts any string and strips out any non-digit characters', () => {
|
||||||
|
expect(numberMask('h4x0r sp43k')).toEqual(['$', /\d/, ',', /\d/, /\d/, /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not allow leading zeroes', function() {
|
||||||
|
let numberMask = createNumberMask()
|
||||||
|
expect(numberMask('012')).toEqual(['$', /\d/, /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows one leading zero followed by a fraction', function() {
|
||||||
|
let numberMask = createNumberMask({allowDecimal: true})
|
||||||
|
expect(numberMask('0.12')).toEqual(['$', /\d/, '[]', '.', '[]', /\d/, /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores fixedDecimalScale when requireDecimal:false', () => {
|
||||||
|
let numberMask = createNumberMask({allowDecimal: true, fixedDecimalScale:true})
|
||||||
|
expect(numberMask('0.1')).toEqual(['$', /\d/, '[]', '.', '[]', /\d/])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fixedDecimalScale expands decimal', () => {
|
||||||
|
let numberMask = createNumberMask({requireDecimal: true, fixedDecimalScale:true})
|
||||||
|
expect(numberMask('0.1')).toEqual(['$', /\d/, '[]', '.', '[]', /\d/, /\d/])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
184
javascripts/src/lib/createNumberMask.ts
Normal file
184
javascripts/src/lib/createNumberMask.ts
Normal file
|
@ -0,0 +1,184 @@
|
||||||
|
//from: https://github.com/text-mask/text-mask/pull/760/commits/f66c81b62c4894b7da43862bee5943f659dc7537
|
||||||
|
// under: Unlicense
|
||||||
|
const dollarSign = '$'
|
||||||
|
const emptyString = ''
|
||||||
|
const comma = ','
|
||||||
|
const period = '.'
|
||||||
|
const minus = '-'
|
||||||
|
const minusRegExp = /-/
|
||||||
|
const nonDigitsRegExp = /\D+/g
|
||||||
|
const number = 'number'
|
||||||
|
const digitRegExp = /\d/
|
||||||
|
const caretTrap = '[]'
|
||||||
|
|
||||||
|
|
||||||
|
interface NumberMaskProps {
|
||||||
|
prefix?: string
|
||||||
|
suffix?: string
|
||||||
|
includeThousandsSeparator?: boolean
|
||||||
|
thousandsSeparatorSymbol?: string
|
||||||
|
allowDecimal?:boolean
|
||||||
|
decimalSymbol?: string
|
||||||
|
decimalLimit?: number
|
||||||
|
integerLimit?: number
|
||||||
|
requireDecimal?: boolean
|
||||||
|
allowNegative?: boolean
|
||||||
|
allowLeadingZeroes?: boolean
|
||||||
|
fixedDecimalScale?:boolean
|
||||||
|
alwaysNegative?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function createNumberMask({
|
||||||
|
prefix = dollarSign,
|
||||||
|
suffix = emptyString,
|
||||||
|
includeThousandsSeparator = true,
|
||||||
|
thousandsSeparatorSymbol = comma,
|
||||||
|
allowDecimal = false,
|
||||||
|
decimalSymbol = period,
|
||||||
|
decimalLimit = 2,
|
||||||
|
requireDecimal = false,
|
||||||
|
allowNegative = false,
|
||||||
|
allowLeadingZeroes = false,
|
||||||
|
fixedDecimalScale = false,
|
||||||
|
integerLimit = null,
|
||||||
|
alwaysNegative = false
|
||||||
|
}:NumberMaskProps = {}) {
|
||||||
|
const prefixLength = prefix && prefix.length || 0
|
||||||
|
const suffixLength = suffix && suffix.length || 0
|
||||||
|
const thousandsSeparatorSymbolLength = thousandsSeparatorSymbol && thousandsSeparatorSymbol.length || 0
|
||||||
|
|
||||||
|
function numberMask(rawValue = emptyString) {
|
||||||
|
const rawValueLength = rawValue.length
|
||||||
|
|
||||||
|
if (
|
||||||
|
rawValue === emptyString ||
|
||||||
|
(rawValue[0] === prefix[0] && rawValueLength === 1)
|
||||||
|
) {
|
||||||
|
return stringMaskArray(prefix.split(emptyString)).concat([digitRegExp]).concat(suffix.split(emptyString))
|
||||||
|
} else if (
|
||||||
|
rawValue === decimalSymbol &&
|
||||||
|
allowDecimal
|
||||||
|
) {
|
||||||
|
return stringMaskArray(prefix.split(emptyString)).concat(['0', decimalSymbol, digitRegExp]).concat(suffix.split(emptyString))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const isNegative = ((rawValue[0] === minus) && allowNegative)
|
||||||
|
//If negative remove "-" sign
|
||||||
|
if (isNegative) {
|
||||||
|
rawValue = rawValue.toString().substr(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexOfLastDecimal = rawValue.lastIndexOf(decimalSymbol)
|
||||||
|
const hasDecimal = indexOfLastDecimal !== -1
|
||||||
|
|
||||||
|
let integer
|
||||||
|
let fraction
|
||||||
|
let mask
|
||||||
|
|
||||||
|
// remove the suffix
|
||||||
|
if (rawValue.slice(suffixLength * -1) === suffix) {
|
||||||
|
rawValue = rawValue.slice(0, suffixLength * -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDecimal && (allowDecimal || requireDecimal)) {
|
||||||
|
integer = rawValue.slice(rawValue.slice(0, prefixLength) === prefix ? prefixLength : 0, indexOfLastDecimal)
|
||||||
|
|
||||||
|
fraction = rawValue.slice(indexOfLastDecimal + 1, rawValueLength)
|
||||||
|
fraction = convertToMask(fraction.replace(nonDigitsRegExp, emptyString))
|
||||||
|
} else {
|
||||||
|
if (rawValue.slice(0, prefixLength) === prefix) {
|
||||||
|
integer = rawValue.slice(prefixLength)
|
||||||
|
} else {
|
||||||
|
integer = rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (integerLimit && typeof integerLimit === number) {
|
||||||
|
const thousandsSeparatorRegex = thousandsSeparatorSymbol === '.' ? '[.]' : `${thousandsSeparatorSymbol}`
|
||||||
|
const numberOfThousandSeparators = (integer.match(new RegExp(thousandsSeparatorRegex, 'g')) || []).length
|
||||||
|
|
||||||
|
integer = integer.slice(0, integerLimit + (numberOfThousandSeparators * thousandsSeparatorSymbolLength))
|
||||||
|
}
|
||||||
|
|
||||||
|
integer = integer.replace(nonDigitsRegExp, emptyString)
|
||||||
|
|
||||||
|
if (!allowLeadingZeroes) {
|
||||||
|
integer = integer.replace(/^0+(0$|[^0])/, '$1')
|
||||||
|
}
|
||||||
|
|
||||||
|
integer = (includeThousandsSeparator) ? addThousandsSeparator(integer, thousandsSeparatorSymbol) : integer
|
||||||
|
|
||||||
|
mask = convertToMask(integer)
|
||||||
|
|
||||||
|
if ((hasDecimal && allowDecimal) || requireDecimal === true) {
|
||||||
|
if (rawValue[indexOfLastDecimal - 1] !== decimalSymbol) {
|
||||||
|
mask.push(caretTrap)
|
||||||
|
}
|
||||||
|
|
||||||
|
mask.push(decimalSymbol, caretTrap)
|
||||||
|
|
||||||
|
if (fraction) {
|
||||||
|
if (typeof decimalLimit === number) {
|
||||||
|
fraction = fraction.slice(0, decimalLimit)
|
||||||
|
}
|
||||||
|
mask = mask.concat(fraction)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireDecimal === true) {
|
||||||
|
if (fixedDecimalScale === true) {
|
||||||
|
const decimalLimitRemaining = fraction ? decimalLimit - fraction.length : decimalLimit
|
||||||
|
for (var i = 0; i < decimalLimitRemaining; i++) {
|
||||||
|
mask.push(digitRegExp)
|
||||||
|
}
|
||||||
|
} else if (rawValue[indexOfLastDecimal - 1] === decimalSymbol) {
|
||||||
|
mask.push(digitRegExp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefixLength > 0) {
|
||||||
|
let res:(string|RegExp)[] = prefix.split(emptyString)
|
||||||
|
mask = res.concat(mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNegative) {
|
||||||
|
// If user is entering a negative number, add a mask placeholder spot to attract the caret to it.
|
||||||
|
if (mask.length === prefixLength) {
|
||||||
|
mask.push(digitRegExp)
|
||||||
|
}
|
||||||
|
|
||||||
|
mask = stringMaskArray([minusRegExp]).concat(mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suffix.length > 0) {
|
||||||
|
mask = mask.concat(suffix.split(emptyString))
|
||||||
|
}
|
||||||
|
|
||||||
|
return mask
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return numberMask
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringMaskArray(i:(string|(string|RegExp)[])):(string|RegExp)[] {
|
||||||
|
if (typeof i === 'string'){
|
||||||
|
return [i]
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToMask(strNumber:string):(string|RegExp)[] {
|
||||||
|
return strNumber
|
||||||
|
.split(emptyString)
|
||||||
|
.map((char) => digitRegExp.test(char) ? digitRegExp : char)
|
||||||
|
}
|
||||||
|
|
||||||
|
// http://stackoverflow.com/a/10899795/604296
|
||||||
|
function addThousandsSeparator(n:string, thousandsSeparatorSymbol:string) {
|
||||||
|
return n.replace(/\B(?=(\d{3})+(?!\d))/g, thousandsSeparatorSymbol)
|
||||||
|
}
|
5
javascripts/src/lib/mobx_utils.ts
Normal file
5
javascripts/src/lib/mobx_utils.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import {FieldDefinition} from "mobx-react-form";
|
||||||
|
|
||||||
|
export function createFieldDefinition<TInOut>(fieldDef:FieldDefinition<TInOut>) : FieldDefinition<TInOut> {
|
||||||
|
return fieldDef;
|
||||||
|
}
|
|
@ -55,7 +55,7 @@ export class Validations {
|
||||||
{
|
{
|
||||||
return ({field, validator}:ValidationInput) => {
|
return ({field, validator}:ValidationInput) => {
|
||||||
return [
|
return [
|
||||||
parseFloat(field.value) >= value,
|
parseFloat(field.get('value')) >= value,
|
||||||
`${field.label} must be at least ${value}`
|
`${field.label} must be at least ${value}`
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -64,7 +64,7 @@ export class Validations {
|
||||||
static isLessThanOrEqualTo(value:number, flip:boolean=false) : ({field, validator}:ValidationInput) => StringBoolTuple
|
static isLessThanOrEqualTo(value:number, flip:boolean=false) : ({field, validator}:ValidationInput) => StringBoolTuple
|
||||||
{
|
{
|
||||||
return ({field, validator}:ValidationInput) => {
|
return ({field, validator}:ValidationInput) => {
|
||||||
let float = parseFloat(field.value)
|
let float = field.get('value')
|
||||||
return [
|
return [
|
||||||
(flip ? -1 * float : float) <= value,
|
(flip ? -1 * float : float) <= value,
|
||||||
`${field.label} must be no more than ${value}`
|
`${field.label} must be no more than ${value}`
|
||||||
|
|
9
package-lock.json
generated
9
package-lock.json
generated
|
@ -199,6 +199,15 @@
|
||||||
"@types/react": "*"
|
"@types/react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@types/react-text-mask": {
|
||||||
|
"version": "5.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-text-mask/-/react-text-mask-5.4.2.tgz",
|
||||||
|
"integrity": "sha512-R4h07wAeOPh6xc6E9qYPMgYpeuUJ1w3rs3oWwp9oWIVPtnAGxPGDuCPEs2+ynlP5syeM7heZeZeM8saAHRgENA==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@types/react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@types/sinon": {
|
"@types/sinon": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-4.3.3.tgz",
|
||||||
|
|
|
@ -30,6 +30,7 @@
|
||||||
"@types/react-dom": "^16.0.5",
|
"@types/react-dom": "^16.0.5",
|
||||||
"@types/react-intl": "^2.3.7",
|
"@types/react-intl": "^2.3.7",
|
||||||
"@types/react-test-renderer": "^16.0.1",
|
"@types/react-test-renderer": "^16.0.1",
|
||||||
|
"@types/react-text-mask": "^5.4.2",
|
||||||
"@types/sinon": "^4.3.3",
|
"@types/sinon": "^4.3.3",
|
||||||
"@types/validator": "^9.4.1",
|
"@types/validator": "^9.4.1",
|
||||||
"babel-core": "^6.26.0",
|
"babel-core": "^6.26.0",
|
||||||
|
|
20
types/react-text-mask/index.d.ts
vendored
20
types/react-text-mask/index.d.ts
vendored
|
@ -1,20 +0,0 @@
|
||||||
// License: LGPL-3.0-or-later
|
|
||||||
import {Component} from 'react'
|
|
||||||
|
|
||||||
|
|
||||||
export interface MaskedInputProps
|
|
||||||
{
|
|
||||||
mask: Array<any>|Function|Boolean | {mask: Array<any> | Function, pipe: Function}
|
|
||||||
|
|
||||||
guide?: Boolean
|
|
||||||
value?: String| Number,
|
|
||||||
pipe?: Function,
|
|
||||||
placeholderChar?: String,
|
|
||||||
keepCharPositions?: Boolean,
|
|
||||||
showMask?: Boolean,
|
|
||||||
[additionalProps: string] : any
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MaskedInput extends Component<MaskedInputProps, {}> {
|
|
||||||
|
|
||||||
}
|
|
2
types/text-mask/index.d.ts
vendored
2
types/text-mask/index.d.ts
vendored
|
@ -1,2 +0,0 @@
|
||||||
// License: LGPL-3.0-or-later
|
|
||||||
declare module "text-mask"
|
|
Loading…
Reference in a new issue