diff --git a/.eslintrc.yaml b/.eslintrc.yaml index f4d9c68bd..b5c000473 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 4bbf6b38b..26e75d1b2 100644 --- a/package.json +++ b/package.json @@ -123,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/components/AttachDictionary.js b/source/components/AttachDictionary.js index 42fbba6f8..733132fcf 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 2dcd46ccc..7507264ff 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,27 @@ let currencyFormat = language => ({ }) export default function CurrencyInput({ - value: valueArg, + value: valueProp = '', debounce: debounceTimeout, 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,19 +65,17 @@ 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` } } - : {})}> + {...(valueLength > 5 ? { style: { width } } : {})}> {isCurrencyPrefixed && '€'} { 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/PercentageField.js b/source/components/PercentageField.js index e0abf0e7d..e85eabf7a 100644 --- a/source/components/PercentageField.js +++ b/source/components/PercentageField.js @@ -4,7 +4,8 @@ import './PercentageField.css' export default function PercentageField({ onChange, value, debounce }) { const [localValue, setLocalValue] = useState(value) const debouncedOnChange = useCallback( - debounce ? debounce(debounce, onChange) : onChange + debounce ? debounce(debounce, onChange) : onChange, + [debounce, onChange] ) return ( diff --git a/source/components/PeriodSwitch.js b/source/components/PeriodSwitch.js index e87d33920..1feb823a1 100644 --- a/source/components/PeriodSwitch.js +++ b/source/components/PeriodSwitch.js @@ -1,5 +1,5 @@ import { findRuleByDottedName } from 'Engine/rules' -import React, { useEffect } from 'react' +import React, { useCallback, useEffect } from 'react' import { Trans } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' import { @@ -15,17 +15,20 @@ export default function PeriodSwitch() { const initialPeriod = useSelector( state => state.simulation?.config?.situation?.période ) + const currentPeriod = situation.période useEffect(() => { !currentPeriod && updatePeriod(initialPeriod || 'année') - }, []) - const currentPeriod = situation.période - const updatePeriod = toPeriod => { - const needConversion = Object.keys(situation).filter(dottedName => { - const rule = findRuleByDottedName(rules, dottedName) - return rule?.période === 'flexible' - }) - dispatch({ type: 'UPDATE_PERIOD', toPeriod, needConversion }) - } + }, [currentPeriod, initialPeriod, updatePeriod]) + const updatePeriod = useCallback( + toPeriod => { + const needConversion = Object.keys(situation).filter(dottedName => { + const rule = findRuleByDottedName(rules, dottedName) + return rule?.période === 'flexible' + }) + dispatch({ type: 'UPDATE_PERIOD', toPeriod, needConversion }) + }, + [dispatch, rules, situation] + ) const periods = ['mois', 'année'] return ( diff --git a/source/components/SearchBar.js b/source/components/SearchBar.js index 31f384035..5294b3919 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() + // 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 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 - ) - } - - 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({ <>