Merge pull request #551 from mquandalle/format-currency-input

Formatage des prix dans les champs de saisie
pull/559/head
Johan Girod 2019-06-03 14:25:08 +02:00 committed by GitHub
commit fe8615c4cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 114 additions and 56 deletions

View File

@ -31,7 +31,7 @@ if (fr) {
describe('Simulation saving test', function() {
it('should save the current simulation', function() {
salaryInput('Salaire net').type('5471')
salaryInput('Salaire net').type('471')
cy.wait(1000)
cy.contains('CDD').click()
cy.contains('passer').click()
@ -40,7 +40,7 @@ if (fr) {
cy.wait(1100)
cy.visit('/sécurité-sociale/salarié')
cy.contains('Retrouver ma simulation').click()
salaryInput('Salaire net').should('have.value', '5471')
salaryInput('Salaire net').should('have.value', '471')
})
})
}

View File

@ -41,6 +41,7 @@
"react-helmet": "6.0.0-beta",
"react-highlight-words": "^0.11.0",
"react-i18next": "^10.0.1",
"react-number-format": "^4.0.8",
"react-redux": "^7.0.3",
"react-router": "^4.3.1",
"react-router-dom": "^4.3.1",

View File

@ -1,51 +1,61 @@
import classnames from 'classnames'
import { omit } from 'ramda'
import React, { Component } from 'react'
import NumberFormat from 'react-number-format'
import { debounce } from '../../utils'
import './CurrencyInput.css'
let isCurrencyPrefixed = language =>
!!Intl.NumberFormat(language, {
let currencyFormat = language => ({
isCurrencyPrefixed: !!Intl.NumberFormat(language, {
style: 'currency',
currency: 'EUR'
})
.format(12)
.match(/€.*12/)
.match(/€.*12/),
thousandSeparator: Intl.NumberFormat(language)
.format(1000)
.charAt(1),
decimalSeparator: Intl.NumberFormat(language)
.format(0.1)
.charAt(1)
})
class CurrencyInput extends Component {
state = {
value: this.props.storeValue
value: this.props.value,
initialValue: this.props.value
}
onChange = this.props.debounce
? debounce(this.props.debounce, this.props.onChange)
: this.props.onChange
input = React.createRef()
handleNextChange = false
value = undefined
handleChange = event => {
let value = event.target.value
value = value
.replace(/,/g, '.')
.replace(/[^\d.]/g, '')
.replace(/\.(.*)\.(.*)/g, '$1.$2')
this.setState({ value })
if (value.endsWith('.')) {
// Only trigger the `onChange` event if the value has changed -- and not
// only its formating, we don't want to call it when a dot is added in `12.`
// for instance
if (!this.handleNextChange) {
return
}
event.target.value = value
if (event.persist) {
event.persist()
this.handleNextChange = false
event.persist()
event.target = {
...event.target,
value: this.value
}
this.onChange(event)
}
componentDidUpdate(prevProps) {
if (
prevProps.storeValue !== this.props.storeValue &&
this.props.storeValue !== this.state.value
) {
this.setState({ value: this.props.storeValue })
// See https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html#alternative-1-reset-uncontrolled-component-with-an-id-prop
static getDerivedStateFromProps({ value }, { initialValue }) {
if (value !== initialValue) {
return { value, initialValue: value }
}
return null
}
render() {
@ -54,22 +64,40 @@ class CurrencyInput extends Component {
this.props
)
const {
isCurrencyPrefixed,
thousandSeparator,
decimalSeparator
} = currencyFormat(this.props.language)
// We display negative numbers iff this was the provided value (but we allow the user to enter them)
const valueHasChanged = this.state.value !== this.state.initialValue
return (
<div
className={classnames(
this.props.className,
'currencyInput__container'
)}>
{isCurrencyPrefixed(this.props.language) && '€'}
<input
{isCurrencyPrefixed && '€'}
<NumberFormat
{...forwardedProps}
thousandSeparator={thousandSeparator}
decimalSeparator={decimalSeparator}
allowNegative={!valueHasChanged}
className="currencyInput__input"
inputMode="numeric"
onValueChange={({ value }) => {
this.setState({ value })
this.value = value.toString().replace(/^\-/, '')
this.handleNextChange = true
}}
onChange={this.handleChange}
ref={this.input}
value={this.state.value}
value={(this.state.value || '')
.toString()
.replace('.', decimalSeparator)}
/>
{!isCurrencyPrefixed(this.props.language) && <>&nbsp;</>}
{!isCurrencyPrefixed && <>&nbsp;</>}
</div>
)
}

View File

@ -1,10 +1,10 @@
import { expect } from 'chai'
import { shallow } from 'enzyme'
import { shallow, mount } from 'enzyme'
import React from 'react'
import { match, spy, useFakeTimers } from 'sinon'
import CurrencyInput from './CurrencyInput'
let getInput = component => shallow(component).find('input')
let getInput = component => mount(component).find('input')
describe('CurrencyInput', () => {
it('should render an input', () => {
expect(getInput(<CurrencyInput />)).to.have.length(1)
@ -13,20 +13,36 @@ describe('CurrencyInput', () => {
it('should accept both . and , as decimal separator', () => {
let onChange = spy()
const input = getInput(<CurrencyInput onChange={onChange} />)
input.simulate('change', { target: { value: '12.1' } })
input.simulate('change', { target: { value: '12.1', focus: () => {} } })
expect(onChange).to.have.been.calledWith(
match.hasNested('target.value', '12.1')
)
input.simulate('change', { target: { value: '12,1' } })
input.simulate('change', { target: { value: '12,1', focus: () => {} } })
expect(onChange).to.have.been.calledWith(
match.hasNested('target.value', '12.1')
)
})
it('should separate thousand groups', () => {
const input1 = getInput(<CurrencyInput value={1000} language="fr" />)
const input2 = getInput(<CurrencyInput value={1000} language="en" />)
const input3 = getInput(<CurrencyInput value={1000.5} language="en" />)
const input4 = getInput(<CurrencyInput value={1000000} language="en" />)
expect(input1.instance().value).to.equal('1 000')
expect(input2.instance().value).to.equal('1,000')
expect(input3.instance().value).to.equal('1,000.5')
expect(input4.instance().value).to.equal('1,000,000')
})
it('should handle decimal separator', () => {
const input = getInput(<CurrencyInput value={0.5} language="fr" />)
expect(input.instance().value).to.equal('0,5')
})
it('should not accept negative number', () => {
let onChange = spy()
const input = getInput(<CurrencyInput onChange={onChange} />)
input.simulate('change', { target: { value: '-12' } })
input.simulate('change', { target: { value: '-12', focus: () => {} } })
expect(onChange).to.have.been.calledWith(
match.hasNested('target.value', '12')
)
@ -35,19 +51,21 @@ describe('CurrencyInput', () => {
it('should not accept anything else than number', () => {
let onChange = spy()
const input = getInput(<CurrencyInput onChange={onChange} />)
input.simulate('change', { target: { value: '*1/2abc3' } })
input.simulate('change', { target: { value: '*1/2abc3', focus: () => {} } })
expect(onChange).to.have.been.calledWith(
match.hasNested('target.value', '123')
)
})
it('should pass other props to the input', () => {
const input = getInput(<CurrencyInput autoFocus />)
expect(input.prop('autoFocus')).to.be.true
})
it('should not call onChange while the decimal part is being written', () => {
let onChange = spy()
const input = getInput(<CurrencyInput onChange={onChange} />)
input.simulate('change', { target: { value: '111,' } })
const input = getInput(<CurrencyInput value="111" onChange={onChange} />)
input.simulate('change', { target: { value: '111,', focus: () => {} } })
expect(onChange).not.to.have.been.called
})
@ -74,10 +92,10 @@ describe('CurrencyInput', () => {
const input = getInput(
<CurrencyInput onChange={onChange} debounce={1000} />
)
input.simulate('change', { target: { value: '1' } })
input.simulate('change', { target: { value: '1', focus: () => {} } })
expect(onChange).not.to.have.been.called
clock.tick(500)
input.simulate('change', { target: { value: '12' } })
input.simulate('change', { target: { value: '12', focus: () => {} } })
clock.tick(600)
expect(onChange).not.to.have.been.called
clock.tick(400)
@ -87,25 +105,23 @@ describe('CurrencyInput', () => {
clock.restore()
})
it('should initialize with value of the storeValue prop', () => {
const input = getInput(<CurrencyInput storeValue={1} />)
expect(input.prop('value')).to.eq(1)
it('should initialize with value of the value prop', () => {
const wrapper = shallow(<CurrencyInput value={1} />)
expect(wrapper.state('value')).to.eq(1)
})
it('should update its value if the storeValue prop changes', () => {
const wrapper = shallow(<CurrencyInput storeValue={1} />)
wrapper.setProps({ storeValue: 2 })
it('should update its value if the value prop changes', () => {
const wrapper = shallow(<CurrencyInput value={1} />)
wrapper.setProps({ value: 2 })
expect(wrapper.state('value')).to.equal(2)
})
it('should not update state if the storeValue is the same as the current input value', () => {
const wrapper = shallow(
<CurrencyInput storeValue={1000} onChange={() => {}} />
)
it('should not call onChange the value is the same as the current input value', () => {
let onChange = spy()
const wrapper = mount(<CurrencyInput value={1000} onChange={() => {}} />)
const input = wrapper.find('input')
input.simulate('change', { target: { value: '2000' } })
const state1 = wrapper.state()
wrapper.setProps({ storeValue: '2000' })
const state2 = wrapper.state()
expect(state1).to.equal(state2)
input.simulate('change', { target: { value: '2000', focus: () => {} } })
wrapper.setProps({ value: '2000' })
expect(onChange).not.to.have.been.called
})
})

View File

@ -250,7 +250,7 @@ let CurrencyField = withColours(props => {
}}
debounce={600}
className="targetInput"
storeValue={props.input.value}
value={props.input.value}
{...props.input}
{...props}
/>
@ -274,6 +274,7 @@ let TargetInputOrValue = withLanguage(
<Field
name={target.dottedName}
component={CurrencyField}
onBlur={event => event.preventDefault()}
{...(inputIsActive ? { autoFocus: true } : {})}
language={language}
/>

View File

@ -35,6 +35,11 @@ export default withLanguage(
this.timeoutId = null
}, 250)
}
componentWillUnmount() {
if (this.timeoutId) {
clearTimeout(this.timeoutId)
}
}
format = value => {
return value == null
? ''

View File

@ -7695,6 +7695,13 @@ react-lifecycles-compat@^3.0.4:
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
react-number-format@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/react-number-format/-/react-number-format-4.0.8.tgz#f0a0dfbeded9a746f4d8b309926cf55d7effebb2"
integrity sha512-A7Gi4BSkdgnyY1DO98lwFvWujcyxZCOfNP6tkiOKkwMY6oFP+JTQhd/vQ9dXXs6TpyXfl1eBJdueokM7o98YTQ==
dependencies:
prop-types "^15.7.2"
react-redux@^7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.0.3.tgz#983c5a6de81cb1e696bd1c090ba826545f9170f1"