diff --git a/.eslintrc.yaml b/.eslintrc.yaml index f4d9c68bd0..b5c0004734 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -1,7 +1,4 @@ rules: - linebreak-style: - - 2 - - unix quotes: - 1 # While https://github.com/eslint/eslint/issues/9662#issuecomment-353958854 we don't enforce this - single @@ -14,10 +11,13 @@ rules: react/jsx-no-target-blank: 0 react/no-unescaped-entities: 0 react/display-name: 1 + react-hooks/rules-of-hooks: error + react-hooks/exhaustive-deps: warn parser: babel-eslint plugins: - react + - react-hooks - flowtype env: browser: true diff --git a/package.json b/package.json index 54d0aaad3c..26e75d1b27 100644 --- a/package.json +++ b/package.json @@ -57,8 +57,6 @@ "react-virtualized-select": "^3.1.3", "reduce-reducers": "^0.1.2", "redux": "^3.7.2", - "redux-batched-actions": "^0.4.1", - "redux-form": "^8.2.0", "redux-thunk": "^2.3.0", "regenerator-runtime": "^0.13.3", "reselect": "^4.0.0", @@ -125,6 +123,7 @@ "eslint-config-prettier": "^4.0.0", "eslint-plugin-flowtype": "^3.2.1", "eslint-plugin-react": "^7.12.4", + "eslint-plugin-react-hooks": "^2.0.1", "express": "^4.16.3", "file-loader": "^1.1.11", "flow-bin": "^0.92.0", diff --git a/source/Provider.js b/source/Provider.js index 681260b33f..37b95fa1be 100644 --- a/source/Provider.js +++ b/source/Provider.js @@ -9,7 +9,6 @@ import { Provider as ReduxProvider } from 'react-redux' import { Router } from 'react-router-dom' import reducers from 'Reducers/rootReducer' import { applyMiddleware, compose, createStore } from 'redux' -import { enableBatching } from 'redux-batched-actions' import thunk from 'redux-thunk' import { getIframeOption, inIframe } from './utils' @@ -54,11 +53,7 @@ export default class Provider extends PureComponent { if (this.props.initialStore) this.props.initialStore.lang = this.props.language } - this.store = createStore( - enableBatching(reducers), - this.props.initialStore, - storeEnhancer - ) + this.store = createStore(reducers, this.props.initialStore, storeEnhancer) this.props.onStoreCreated && this.props.onStoreCreated(this.store) // Remove loader diff --git a/source/actions/actions.js b/source/actions/actions.js index ea25b89e75..1fe6bf54a0 100644 --- a/source/actions/actions.js +++ b/source/actions/actions.js @@ -7,8 +7,6 @@ import type { SetSimulationConfigAction, SetSituationBranchAction } from 'Types/ActionsTypes' -// $FlowFixMe -import { change, reset } from 'redux-form' import { deletePersistedSimulation } from '../storage/persistSimulation' import type { Thunk } from 'Types/ActionsTypes' @@ -18,18 +16,19 @@ export const resetSimulation = () => (dispatch: any => void): void => { type: 'RESET_SIMULATION' }: ResetSimulationAction) ) - dispatch(reset('conversation')) } + export const goToQuestion = (question: string): StepAction => ({ type: 'STEP_ACTION', name: 'unfold', step: question }) + export const validateStepWithValue = ( dottedName, value: any ): Thunk => dispatch => { - dispatch(change('conversation', dottedName, value)) + dispatch(updateSituation(dottedName, value)) dispatch({ type: 'STEP_ACTION', name: 'fold', @@ -62,6 +61,17 @@ export const deletePreviousSimulation = () => ( deletePersistedSimulation() } +export const updateSituation = (fieldName, value) => ({ + type: 'UPDATE_SITUATION', + fieldName, + value +}) + +export const updatePeriod = toPeriod => ({ + type: 'UPDATE_PERIOD', + toPeriod +}) + // $FlowFixMe export function setExample(name, situation, dottedName) { return { type: 'SET_EXAMPLE', name, situation, dottedName } diff --git a/source/components/AttachDictionary.js b/source/components/AttachDictionary.js index 42fbba6f8e..733132fcf9 100644 --- a/source/components/AttachDictionary.js +++ b/source/components/AttachDictionary.js @@ -8,6 +8,7 @@ import Overlay from './Overlay' // Il suffit à la section d'appeler une fonction fournie en lui donnant du JSX export let AttachDictionary = dictionary => Decorated => function withDictionary(props) { + // eslint-disable-next-line react-hooks/rules-of-hooks const [{ explanation, term }, setState] = useState({ term: null, explanation: null diff --git a/source/components/CurrencyInput/CurrencyInput.js b/source/components/CurrencyInput/CurrencyInput.js index 2dcd46ccc2..593c27a87a 100644 --- a/source/components/CurrencyInput/CurrencyInput.js +++ b/source/components/CurrencyInput/CurrencyInput.js @@ -1,5 +1,5 @@ import classnames from 'classnames' -import React, { useEffect, useRef, useState } from 'react' +import React, { useRef, useState } from 'react' import NumberFormat from 'react-number-format' import { debounce } from '../../utils' import './CurrencyInput.css' @@ -22,23 +22,28 @@ let currencyFormat = language => ({ }) export default function CurrencyInput({ - value: valueArg, + value: valueProp = '', debounce: debounceTimeout, + currencySymbol = '€', onChange, language, className, ...forwardedProps }) { - const [currentValue, setCurrentValue] = useState(valueArg) - const [initialValue] = useState(valueArg) - // When the component is rendered with a new "value" argument, we update our local state - useEffect(() => { - setCurrentValue(valueArg) - }, [valueArg]) - const nextValue = useRef(null) + const [initialValue, setInitialValue] = useState(valueProp) + const [currentValue, setCurrentValue] = useState(valueProp) const onChangeDebounced = useRef( debounceTimeout ? debounce(debounceTimeout, onChange) : onChange ) + // We need some mutable reference because the component doesn't provide + // the DOM `event` in its custom `onValueChange` handler + const nextValue = useRef(null) + + // When the component is rendered with a new "value" prop, we reset our local state + if (valueProp !== initialValue) { + setCurrentValue(valueProp) + setInitialValue(valueProp) + } const handleChange = event => { // Only trigger the `onChange` event if the value has changed -- and not @@ -61,20 +66,18 @@ export default function CurrencyInput({ thousandSeparator, decimalSeparator } = currencyFormat(language) - - // We display negative numbers iff this was the provided value (but we allow the user to enter them) + // We display negative numbers iff this was the provided value (but we disallow the user to enter them) const valueHasChanged = currentValue !== initialValue // Autogrow the input - const valueLength = (currentValue || '').toString().length + const valueLength = currentValue.toString().length + const width = `${5 + (valueLength - 5) * 0.75}em` return (
5 - ? { style: { width: `${5 + (valueLength - 5) * 0.75}em` } } - : {})}> - {isCurrencyPrefixed && '€'} + {...(valueLength > 5 ? { style: { width } } : {})}> + {!currentValue && isCurrencyPrefixed && currencySymbol} { setCurrentValue(value) - nextValue.current = value.toString().replace(/^\-/, '') + nextValue.current = value.toString().replace(/^-/, '') }} onChange={handleChange} - value={(currentValue || '').toString().replace('.', decimalSeparator)} + value={currentValue.toString().replace('.', decimalSeparator)} /> {!isCurrencyPrefixed && <> €}
diff --git a/source/components/CurrencyInput/CurrencyInput.test.js b/source/components/CurrencyInput/CurrencyInput.test.js index 8e6a331e35..a0469a8ed7 100644 --- a/source/components/CurrencyInput/CurrencyInput.test.js +++ b/source/components/CurrencyInput/CurrencyInput.test.js @@ -24,10 +24,18 @@ describe('CurrencyInput', () => { }) it('should separate thousand groups', () => { - const input1 = getInput() - const input2 = getInput() - const input3 = getInput() - const input4 = getInput() + 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') @@ -90,7 +98,7 @@ describe('CurrencyInput', () => { const clock = useFakeTimers() let onChange = spy() const input = getInput( - + ) input.simulate('change', { target: { value: '1', focus: () => {} } }) expect(onChange).not.to.have.been.called @@ -106,12 +114,12 @@ describe('CurrencyInput', () => { }) it('should initialize with value of the value prop', () => { - const input = getInput() + const input = getInput() expect(input.instance().value).to.equal('1') }) it('should update its value if the value prop changes', () => { - const component = mount() + const component = mount() component.setProps({ value: 2 }) expect(component.find('input').instance().value).to.equal('2') }) diff --git a/source/components/PercentageField.js b/source/components/PercentageField.js index 27f99e69ad..e85eabf7a9 100644 --- a/source/components/PercentageField.js +++ b/source/components/PercentageField.js @@ -1,23 +1,22 @@ -import React, { useState } from 'react' +import React, { useCallback, useState } from 'react' import './PercentageField.css' -export default function PercentageField({ input, debounce }) { - const [localValue, setLocalValue] = useState(input?.value) - - const debouncedOnChange = debounce - ? debounce(debounce, input.onChange) - : input.onChange - - const onChange = value => { - setLocalValue(value) - debouncedOnChange(value) - } +export default function PercentageField({ onChange, value, debounce }) { + const [localValue, setLocalValue] = useState(value) + const debouncedOnChange = useCallback( + debounce ? debounce(debounce, onChange) : onChange, + [debounce, onChange] + ) return (
onChange(e.target.value)} + onChange={e => { + const value = e.target.value + setLocalValue(value) + debouncedOnChange(value) + }} type="range" value={localValue} name="volume" diff --git a/source/components/PeriodSwitch.js b/source/components/PeriodSwitch.js index a5e5f7e07a..34bba977d8 100644 --- a/source/components/PeriodSwitch.js +++ b/source/components/PeriodSwitch.js @@ -1,116 +1,41 @@ -import { findRuleByDottedName, nestedSituationToPathMap } from 'Engine/rules' -import { compose, filter, map, toPairs } from 'ramda' -import React, { useEffect } from 'react' +import { updatePeriod } from 'Actions/actions' +import React from 'react' import { Trans } from 'react-i18next' -import { connect } from 'react-redux' -import { batchActions } from 'redux-batched-actions' -import { change, Field, reduxForm } from 'redux-form' -import { - flatRulesSelector, - situationSelector, - situationsWithDefaultsSelector -} from 'Selectors/analyseSelectors' +import { useDispatch, useSelector } from 'react-redux' +import { situationSelector } from 'Selectors/analyseSelectors' import './PeriodSwitch.css' -export default compose( - reduxForm({ - form: 'conversation', - destroyOnUnmount: false - }), - connect( - state => { - let situation = situationsWithDefaultsSelector(state) - if (Array.isArray(situation)) { - situation = situation[0] - } - - return { - rules: flatRulesSelector(state), - situation: nestedSituationToPathMap(situationSelector(state)), - initialPériode: situation.période - } - }, - dispatch => ({ - batchPeriodChange: actions => dispatch(batchActions(actions)) - }) +export default function PeriodSwitch() { + const dispatch = useDispatch() + const situation = useSelector(situationSelector) + const defaultPeriod = useSelector( + state => state.simulation?.config?.situation?.période || 'année' ) -)(function PeriodSwitch({ - situation, - rules, - batchPeriodChange, - initialPériode -}) { - useEffect(() => { - !situation.période && - updateSituation( - initialPériode || 'année', - batchPeriodChange, - situation, - rules - ) - return - }) + const currentPeriod = situation.période + let periods = ['année', 'mois'] + + if (!currentPeriod) { + dispatch(updatePeriod(defaultPeriod)) + } + return ( - - + {periods.map(period => ( + + ))} ) -}) - -let updateSituation = (toPeriod, batchPeriodChange, situation, rules) => { - let needConvertion = filter(([dottedName, value]) => { - let rule = findRuleByDottedName(rules, dottedName) - return value != null && rule?.période === 'flexible' - })(toPairs(situation)) - let actions = [ - ...map( - ([dottedName, value]) => - change( - 'conversation', - dottedName, - Math.round( - situation.période === 'mois' && toPeriod === 'année' - ? value * 12 - : situation.période === 'année' && toPeriod === 'mois' - ? value / 12 - : (function() { - throw new Error('Oups, changement de période invalide') - })() - ) + '' - ), - needConvertion - ), - change('conversation', 'période', toPeriod) - ] - - batchPeriodChange(actions) } diff --git a/source/components/SalaryExplanation.js b/source/components/SalaryExplanation.js index c6b955ea03..f452a4a18b 100644 --- a/source/components/SalaryExplanation.js +++ b/source/components/SalaryExplanation.js @@ -6,7 +6,7 @@ import React, { useRef } from 'react' import emoji from 'react-easy-emoji' import { Trans } from 'react-i18next' import { connect } from 'react-redux' -import { formValueSelector } from 'redux-form' +import { usePeriod } from 'Selectors/analyseSelectors' import * as Animate from 'Ui/animate' class ErrorBoundary extends React.Component { @@ -89,20 +89,21 @@ export default compose( ) }) -const PaySlipSection = connect(state => ({ - period: formValueSelector('conversation')(state, 'période') -}))(({ period }) => ( -
-

- - {period === 'mois' - ? 'Fiche de paie mensuelle' - : 'Détail annuel des cotisations'} - -

- -
-)) +function PaySlipSection() { + const period = usePeriod() + return ( +
+

+ + {period === 'mois' + ? 'Fiche de paie mensuelle' + : 'Détail annuel des cotisations'} + +

+ +
+ ) +} const DistributionSection = () => (
diff --git a/source/components/SearchBar.js b/source/components/SearchBar.js index 31f384035e..5294b39198 100644 --- a/source/components/SearchBar.js +++ b/source/components/SearchBar.js @@ -2,7 +2,7 @@ import withSitePaths from 'Components/utils/withSitePaths' import { encodeRuleName } from 'Engine/rules.js' import Fuse from 'fuse.js' import { compose, pick, sortBy } from 'ramda' -import React, { useRef, useState } from 'react' +import React, { useMemo, useRef, useState } from 'react' import Highlighter from 'react-highlight-words' import { useTranslation } from 'react-i18next' import { Link, Redirect } from 'react-router-dom' @@ -19,28 +19,23 @@ function SearchBar({ const [inputValue, setInputValue] = useState(null) const [selectedOption, setSelectedOption] = useState(null) const inputElementRef = useRef() - const fuse = useRef() - const { i18n } = useTranslation() - - const options = { - keys: [ - { name: 'name', weight: 0.3 }, - { name: 'title', weight: 0.3 }, - { name: 'espace', weight: 0.2 }, - { name: 'description', weight: 0.2 } - ] - } - if (!fuse.current) { - // This operation is expensive, we don't want to do it everytime we re-render, so we cache its result in a reference - fuse.current = new Fuse( - rules.map(pick(['title', 'espace', 'description', 'name', 'dottedName'])), - options + // This operation is expensive, we don't want to do it everytime we re-render, so we cache its result + const fuse = useMemo(() => { + const list = rules.map( + pick(['title', 'espace', 'description', 'name', 'dottedName']) ) - } + const options = { + keys: [ + { name: 'name', weight: 0.3 }, + { name: 'title', weight: 0.3 }, + { name: 'espace', weight: 0.2 }, + { name: 'description', weight: 0.2 } + ] + } + return new Fuse(list, options) + }, [rules]) + const { i18n } = useTranslation() - const handleChange = selectedOption => { - setSelectedOption(selectedOption) - } const renderOption = ({ title, dottedName }) => ( @@ -49,7 +44,7 @@ function SearchBar({ ) - const filterOptions = (options, filter) => fuse.current.search(filter) + const filterOptions = (options, filter) => fuse.search(filter) if (selectedOption != null) { finallyCallback && finallyCallback() @@ -68,8 +63,8 @@ function SearchBar({ <> { - e.persist() - debouncedOnChange(e) + defaultValue={value} + onChange={evt => { + debouncedSetFormValue(evt.target.value) }} className={classnames({ suffixed })} id={'step-' + dottedName} @@ -76,10 +72,8 @@ export default compose( )} )} - +
- - {inputError && {error}} ) }) diff --git a/source/components/conversation/InputSuggestions.js b/source/components/conversation/InputSuggestions.js index 3e547ec127..ad0a3de81e 100644 --- a/source/components/conversation/InputSuggestions.js +++ b/source/components/conversation/InputSuggestions.js @@ -1,21 +1,16 @@ -import { compose, toPairs } from 'ramda' +import { toPairs } from 'ramda' import React, { useState } from 'react' import { useTranslation } from 'react-i18next' -import { connect } from 'react-redux' -import { formValueSelector } from 'redux-form' +import { usePeriod } from 'Selectors/analyseSelectors' -export default compose( - connect(state => ({ - period: formValueSelector('conversation')(state, 'période') - })) -)(function InputSuggestions({ +export default function InputSuggestions({ suggestions, onSecondClick, onFirstClick, - rulePeriod, - period + rulePeriod }) { const [suggestion, setSuggestion] = useState(null) + const period = usePeriod() const { t } = useTranslation() if (!suggestions) return null @@ -45,4 +40,4 @@ export default compose( })} ) -}) +} diff --git a/source/components/conversation/Question.js b/source/components/conversation/Question.js index 28e51e6e48..f396cee386 100644 --- a/source/components/conversation/Question.js +++ b/source/components/conversation/Question.js @@ -1,7 +1,7 @@ import classnames from 'classnames' import withColours from 'Components/utils/withColours' import { compose, is } from 'ramda' -import React from 'react' +import React, { useCallback, useState } from 'react' import { Trans } from 'react-i18next' import Explicable from './Explicable' import { FormDecorator } from './FormDecorator' @@ -29,46 +29,40 @@ import SendButton from './SendButton' export default compose( FormDecorator('question'), withColours -)(function Question(props) { - let { - choices, - submit, - colours, - meta: { pristine } - } = props +)(function Question({ + choices, + submit, + colours, + name, + setFormValue, + value: currentValue +}) { + const [touched, setTouched] = useState(false) + const onChange = useCallback( + value => { + setFormValue(value) + setTouched(true) + }, + [setFormValue] + ) const renderBinaryQuestion = () => { - let { - input, // vient de redux-form - submit, - choices, - setFormValue, - colours - } = props - return (
{choices.map(({ value, label }) => ( ))}
) } const renderChildren = choices => { - let { - input, // vient de redux-form - submit, - setFormValue, - colours - } = props, - { name } = input, - // seront stockées ainsi dans le state : - // [parent object path]: dotted name relative to parent - relativeDottedName = radioDottedName => - radioDottedName.split(name + ' . ')[1] + // seront stockées ainsi dans le state : + // [parent object path]: dotted name relative to parent + const relativeDottedName = radioDottedName => + radioDottedName.split(name + ' . ')[1] return (
    @@ -78,11 +72,11 @@ export default compose( {...{ value: 'non', label: 'Aucun', - input, + currentValue, submit, colours, dottedName: null, - setFormValue + onChange }} /> @@ -101,10 +95,10 @@ export default compose( value: relativeDottedName(dottedName), label: title, dottedName, - input, + currentValue, submit, colours, - setFormValue + onChange }} /> @@ -123,7 +117,7 @@ export default compose( {choiceElements} ( const RadioLabelContent = compose(withColours)(function RadioLabelContent({ value, label, - input, + currentValue, + onChange, submit }) { let labelStyle = value === '_' ? { fontWeight: 'bold' } : null, - selected = value === input.value + selected = value === currentValue const click = value => () => { - if (input.value == value) submit('dblClick') + if (currentValue == value) submit('dblClick') } return ( @@ -161,10 +156,10 @@ const RadioLabelContent = compose(withColours)(function RadioLabelContent({ {label} onChange(evt.target.value)} + checked={value === currentValue ? 'checked' : ''} /> ) diff --git a/source/components/conversation/RhetoricalQuestion.js b/source/components/conversation/RhetoricalQuestion.js index 0db826014a..fe8f10f051 100644 --- a/source/components/conversation/RhetoricalQuestion.js +++ b/source/components/conversation/RhetoricalQuestion.js @@ -2,7 +2,7 @@ import FormDecorator from 'Components/conversation/FormDecorator' import React from 'react' export default FormDecorator('rhetorical-question')( - function RhetoricalQuestion({ input, submit, possibleChoice }) { + function RhetoricalQuestion({ value: currentValue, submit, possibleChoice }) { if (!possibleChoice) return null // No action possible, don't render an answer let { text, value } = possibleChoice @@ -10,7 +10,12 @@ export default FormDecorator('rhetorical-question')( return ( diff --git a/source/components/conversation/SendButton.js b/source/components/conversation/SendButton.js index 5514da3a5e..10a0f9374b 100644 --- a/source/components/conversation/SendButton.js +++ b/source/components/conversation/SendButton.js @@ -1,28 +1,29 @@ -import React, { useEffect } from 'react' +import React, { useCallback, useEffect } from 'react' import { Trans } from 'react-i18next' export default function SendButton({ disabled, submit }) { + const getAction = useCallback(cause => (!disabled ? submit(cause) : null), [ + disabled, + submit + ]) useEffect(() => { + const handleKeyDown = ({ key }) => { + if (key !== 'Enter') return + getAction('enter') + } + window.addEventListener('keydown', handleKeyDown) return () => { window.removeEventListener('keydown', handleKeyDown) } - }, []) - - const getAction = () => { - return cause => (!disabled ? submit(cause) : null) - } + }, [getAction]) - const handleKeyDown = ({ key }) => { - if (key !== 'Enter') return - getAction()('enter') - } return (