diff --git a/cypress/integration/mon-entreprise/simulateur-salarié.js b/cypress/integration/mon-entreprise/simulateur-salarié.js
index 001b90ad2..260b63b67 100644
--- a/cypress/integration/mon-entreprise/simulateur-salarié.js
+++ b/cypress/integration/mon-entreprise/simulateur-salarié.js
@@ -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')
})
})
}
diff --git a/package.json b/package.json
index a2115b07a..44ce83d27 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/source/components/CurrencyInput/CurrencyInput.js b/source/components/CurrencyInput/CurrencyInput.js
index 2f0374287..898f50d7b 100644
--- a/source/components/CurrencyInput/CurrencyInput.js
+++ b/source/components/CurrencyInput/CurrencyInput.js
@@ -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 (
- {isCurrencyPrefixed(this.props.language) && '€'}
- {
+ 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) && <> €>}
+ {!isCurrencyPrefixed && <> €>}
)
}
diff --git a/source/components/CurrencyInput/CurrencyInput.test.js b/source/components/CurrencyInput/CurrencyInput.test.js
index f1e0f6eb4..cd492910e 100644
--- a/source/components/CurrencyInput/CurrencyInput.test.js
+++ b/source/components/CurrencyInput/CurrencyInput.test.js
@@ -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()).to.have.length(1)
@@ -13,20 +13,36 @@ describe('CurrencyInput', () => {
it('should accept both . and , as decimal separator', () => {
let onChange = spy()
const input = getInput()
- 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()
+ const input2 = getInput()
+ const input3 = getInput()
+ const input4 = getInput()
+ 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()
+ expect(input.instance().value).to.equal('0,5')
+ })
+
it('should not accept negative number', () => {
let onChange = spy()
const input = getInput()
- 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()
- 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()
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()
- input.simulate('change', { target: { value: '111,' } })
+ const input = getInput()
+ input.simulate('change', { target: { value: '111,', focus: () => {} } })
expect(onChange).not.to.have.been.called
})
@@ -74,10 +92,10 @@ describe('CurrencyInput', () => {
const input = getInput(
)
- 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()
- expect(input.prop('value')).to.eq(1)
+ it('should initialize with value of the value prop', () => {
+ const wrapper = shallow()
+ expect(wrapper.state('value')).to.eq(1)
})
- it('should update its value if the storeValue prop changes', () => {
- const wrapper = shallow()
- wrapper.setProps({ storeValue: 2 })
+ it('should update its value if the value prop changes', () => {
+ const wrapper = shallow()
+ 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(
- {}} />
- )
+
+ it('should not call onChange the value is the same as the current input value', () => {
+ let onChange = spy()
+ const wrapper = mount( {}} />)
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
})
})
diff --git a/source/components/TargetSelection.js b/source/components/TargetSelection.js
index 384f21b9e..a857f66e3 100644
--- a/source/components/TargetSelection.js
+++ b/source/components/TargetSelection.js
@@ -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(
event.preventDefault()}
{...(inputIsActive ? { autoFocus: true } : {})}
language={language}
/>
diff --git a/source/components/ui/AnimatedTargetValue.js b/source/components/ui/AnimatedTargetValue.js
index 34083f1d3..956034586 100644
--- a/source/components/ui/AnimatedTargetValue.js
+++ b/source/components/ui/AnimatedTargetValue.js
@@ -35,6 +35,11 @@ export default withLanguage(
this.timeoutId = null
}, 250)
}
+ componentWillUnmount() {
+ if (this.timeoutId) {
+ clearTimeout(this.timeoutId)
+ }
+ }
format = value => {
return value == null
? ''
diff --git a/yarn.lock b/yarn.lock
index 605ad709a..06f803d69 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"