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"