From 7a5beb96f64691def9e9fd58c972e6fee3478429 Mon Sep 17 00:00:00 2001 From: Maxime Quandalle Date: Thu, 12 Sep 2019 17:02:07 +0200 Subject: [PATCH 1/7] =?UTF-8?q?Gestion=20de=20l'=C3=A9tat=20"situation"=20?= =?UTF-8?q?avec=20nos=20propres=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En vue de la suppression de Redux-form, ce commit crée deux nouvelles actions : UPDATE_SITUATION et UPDATE_PERIOD qui permettent de gérer le state de la situation, en retrouvant le même résulat qu'avec l'ancienne implémentation au niveau du `formattedSituationSelector` --- source/actions/actions.js | 1 + source/components/PeriodSwitch.js | 35 +++++++-- source/components/TargetSelection.js | 63 ++++++++-------- .../components/conversation/FormDecorator.js | 73 ++++++++++--------- source/reducers/rootReducer.js | 57 +++++++++++++-- source/selectors/analyseSelectors.js | 10 ++- 6 files changed, 163 insertions(+), 76 deletions(-) diff --git a/source/actions/actions.js b/source/actions/actions.js index ea25b89e75..b386b7db29 100644 --- a/source/actions/actions.js +++ b/source/actions/actions.js @@ -29,6 +29,7 @@ export const validateStepWithValue = ( dottedName, value: any ): Thunk => dispatch => { + dispatch({ type: 'UPDATE_SITUATION', fieldName: dottedName, value }) dispatch(change('conversation', dottedName, value)) dispatch({ type: 'STEP_ACTION', diff --git a/source/components/PeriodSwitch.js b/source/components/PeriodSwitch.js index a5e5f7e07a..0a618dd6b5 100644 --- a/source/components/PeriodSwitch.js +++ b/source/components/PeriodSwitch.js @@ -2,7 +2,7 @@ import { findRuleByDottedName, nestedSituationToPathMap } from 'Engine/rules' import { compose, filter, map, toPairs } from 'ramda' import React, { useEffect } from 'react' import { Trans } from 'react-i18next' -import { connect } from 'react-redux' +import { connect, useDispatch } from 'react-redux' import { batchActions } from 'redux-batched-actions' import { change, Field, reduxForm } from 'redux-form' import { @@ -40,16 +40,20 @@ export default compose( batchPeriodChange, initialPériode }) { + const dispatch = useDispatch() useEffect(() => { !situation.période && updateSituation( initialPériode || 'année', batchPeriodChange, situation, - rules + rules, + updatePeriod ) return }) + const updatePeriod = (toPeriod, needConvertion) => + dispatch({ type: 'UPDATE_PERIOD', toPeriod, needConvertion }) return ( @@ -60,7 +64,13 @@ export default compose( type="radio" value="année" onChange={() => - updateSituation('année', batchPeriodChange, situation, rules) + updateSituation( + 'année', + batchPeriodChange, + situation, + rules, + updatePeriod + ) } /> @@ -74,7 +84,13 @@ export default compose( type="radio" value="mois" onChange={() => - updateSituation('mois', batchPeriodChange, situation, rules) + updateSituation( + 'mois', + batchPeriodChange, + situation, + rules, + updatePeriod + ) } /> @@ -86,11 +102,20 @@ export default compose( ) }) -let updateSituation = (toPeriod, batchPeriodChange, situation, rules) => { +let updateSituation = ( + toPeriod, + batchPeriodChange, + situation, + rules, + updatePeriod +) => { let needConvertion = filter(([dottedName, value]) => { let rule = findRuleByDottedName(rules, dottedName) return value != null && rule?.période === 'flexible' })(toPairs(situation)) + + updatePeriod(toPeriod, needConvertion.map(([fieldName]) => fieldName)) + let actions = [ ...map( ([dottedName, value]) => diff --git a/source/components/TargetSelection.js b/source/components/TargetSelection.js index 93b24f5157..bd73b33a7d 100644 --- a/source/components/TargetSelection.js +++ b/source/components/TargetSelection.js @@ -12,8 +12,7 @@ import { compose, isEmpty, isNil, propEq } from 'ramda' import React, { memo, useEffect, useState } from 'react' import emoji from 'react-easy-emoji' import { useTranslation } from 'react-i18next' -import { connect } from 'react-redux' -import { withRouter } from 'react-router' +import { connect, useDispatch, useSelector } from 'react-redux' import { Link } from 'react-router-dom' import { change, Field, formValueSelector, reduxForm } from 'redux-form' import { @@ -43,8 +42,10 @@ export default compose( state.simulation?.config['objectifs secondaires'] || [] }), dispatch => ({ - setFormValue: (field, name) => - dispatch(change('conversation', field, name)), + setFormValue: (fieldName, value) => { + dispatch({ type: 'UPDATE_SITUATION', fieldName, value }) + dispatch(change('conversation', fieldName, value)) + }, setActiveInput: name => dispatch({ type: 'SET_ACTIVE_TARGET_INPUT', name }) }) @@ -282,15 +283,25 @@ let TargetInputOrValue = ({ setActiveInput, firstStepCompleted }) => { - const { - i18n: { language } - } = useTranslation() + const { i18n } = useTranslation() + const dispatch = useDispatch() + let inputIsActive = activeInput === target.dottedName + const Component = { '€': CurrencyField, '%': DebouncedPercentageField }[ + serialiseUnit(target.unit) + ] return ( {inputIsActive || !target.formule || isEmpty(target.formule) ? ( + dispatch({ + type: 'UPDATE_SITUATION', + fieldName: target.dottedName, + value: evt.target.value + }) + } onBlur={event => event.preventDefault()} component={ { '€': CurrencyField, '%': DebouncedPercentageField }[ @@ -298,7 +309,7 @@ let TargetInputOrValue = ({ ] } {...(inputIsActive ? { autoFocus: true } : {})} - language={language} + language={i18n.language} /> ) : ( ({ - blurValue: analysisWithDefaultsSelector(state)?.cache.inversionFail - }), - dispatch => ({ - setFormValue: (field, name) => dispatch(change('conversation', field, name)) - }) -)(function TargetValue({ - targets, - target, - blurValue, - setFormValue, - activeInput, - setActiveInput -}) { +function TargetValue({ targets, target, activeInput, setActiveInput }) { + const blurValue = useSelector( + state => analysisWithDefaultsSelector(state)?.cache.inversionFail + ) + const dispatch = useDispatch() + const setFormValue = (field, name) => { + dispatch({ type: 'REFACTO_UPDATE_ACTIVE_FIELD', field, name }) + dispatch(change('conversation', field, name)) + } let targetWithValue = targets?.find(propEq('dottedName', target.dottedName)), value = targetWithValue && targetWithValue.nodeValue @@ -356,12 +361,12 @@ const TargetValue = connect( ) -}) +} -const AidesGlimpse = compose( - withRouter, - connect(state => ({ analysis: analysisWithDefaultsSelector(state) })) -)(({ analysis: { targets }, colours }) => { +function AidesGlimpse() { + const targets = useSelector( + state => analysisWithDefaultsSelector(state).targets + ) const aides = targets?.find( t => t.dottedName === 'contrat salarié . aides employeur' ) @@ -379,4 +384,4 @@ const AidesGlimpse = compose( ) -}) +} diff --git a/source/components/conversation/FormDecorator.js b/source/components/conversation/FormDecorator.js index 16fc54cf93..f1abebc47a 100644 --- a/source/components/conversation/FormDecorator.js +++ b/source/components/conversation/FormDecorator.js @@ -1,9 +1,8 @@ import classNames from 'classnames' import Explicable from 'Components/conversation/Explicable' -import { compose } from 'ramda' import React from 'react' -import { connect } from 'react-redux' -import { change, Field } from 'redux-form' +import { useDispatch } from 'react-redux' +import { Field } from 'redux-form' /* This higher order component wraps "Form" components (e.g. Question.js), that represent user inputs, @@ -13,47 +12,53 @@ Read https://medium.com/@dan_abramov/mixins-are-dead-long-live-higher-order-comp to understand those precious higher order components. */ -export var FormDecorator = formType => RenderField => - compose( - connect( - //... this helper directly to the redux state to avoid passing more props - state => ({ - flatRules: state.flatRules - }), - dispatch => ({ - stepAction: (name, step, source) => - dispatch({ type: 'STEP_ACTION', name, step, source }), - setFormValue: (field, value) => - dispatch(change('conversation', field, value)) +export const FormDecorator = formType => RenderField => + function({ fieldName, question, inversion, unit, ...otherProps }) { + const dispatch = useDispatch() + const submit = source => + dispatch({ + type: 'STEP_ACTION', + name: 'fold', + step: fieldName, + source }) - ) - )(function(props) { - let { stepAction, fieldName, inversion, setFormValue, unit } = props, - submit = cause => stepAction('fold', fieldName, cause), - stepProps = { - ...props, - submit, - setFormValue: (value, name = fieldName) => setFormValue(name, value), - ...(unit === '%' - ? { - format: x => (x == null ? null : +(x * 100).toFixed(2)), - normalize: x => (x == null ? null : x / 100) - } - : {}) - } + const setFormValue = (fieldName, value) => { + dispatch({ type: 'UPDATE_SITUATION', fieldName, value }) + dispatch(change('conversation', fieldName, value)) + } + + const stepProps = { + ...otherProps, + submit, + ...(unit === '%' + ? { + format: x => (x == null ? null : +(x * 100).toFixed(2)), + normalize: x => (x == null ? null : x / 100) + } + : {}) + } return (

- {props.question}{' '} - {!inversion && } + {question} {!inversion && }

- + + setFormValue(name, value) + } + onChange={(evt, value) => { + dispatch({ type: 'UPDATE_SITUATION', fieldName, value }) + }} + {...stepProps} + />
) - }) + } diff --git a/source/reducers/rootReducer.js b/source/reducers/rootReducer.js index d589d9cdf9..cd373fcd03 100644 --- a/source/reducers/rootReducer.js +++ b/source/reducers/rootReducer.js @@ -3,8 +3,10 @@ import { compose, defaultTo, + identity, isNil, lensPath, + omit, over, set, uniq, @@ -99,15 +101,58 @@ function conversationSteps( return state } -function simulation(state = null, { type, config, url, id }) { +function updatePeriod(situation, { toPeriod, needConvertion }) { + const currentPeriod = situation['période'] || 'mois' + if (currentPeriod === toPeriod) { + return situation + } + if (!['mois', 'année'].includes(toPeriod)) { + throw new Error('Oups, changement de période invalide') + } + + const updatedSituation = Object.entries(situation) + .filter(([fieldName]) => needConvertion.includes(fieldName)) + .map(([fieldName, value]) => [ + fieldName, + currentPeriod === 'mois' && toPeriod === 'année' ? value * 12 : value / 12 + ]) + + return { + ...situation, + ...Object.fromEntries(updatedSituation), + période: toPeriod + } +} + +function simulation( + state = null, + { type, config, url, id, fieldName, value, toPeriod, needConvertion } +) { if (type === 'SET_SIMULATION') { - return { config, url, hiddenControls: [] } + return { config, url, hiddenControls: [], situation: {} } } - if (type === 'HIDE_CONTROL' && state !== null) { - return { ...state, hiddenControls: [...state.hiddenControls, id] } + if (state === null) { + return state } - if (type === 'RESET_SIMULATION' && state !== null) { - return { ...state, hiddenControls: [] } + switch (type) { + case 'HIDE_CONTROL': + return { ...state, hiddenControls: [...state.hiddenControls, id] } + case 'RESET_SIMULATION': + return { ...state, hiddenControls: [], situation: {} } + case 'UPDATE_SITUATION': + const { config, situation } = state + const removePreviousTarget = config.objectifs.includes(fieldName) + ? omit(config.objectifs) + : identity + return { + ...state, + situation: { ...removePreviousTarget(situation), [fieldName]: value } + } + case 'UPDATE_PERIOD': + return { + ...state, + situation: updatePeriod(state.situation, { toPeriod, needConvertion }) + } } return state } diff --git a/source/selectors/analyseSelectors.js b/source/selectors/analyseSelectors.js index 0c91498e1a..90fbdf928d 100644 --- a/source/selectors/analyseSelectors.js +++ b/source/selectors/analyseSelectors.js @@ -87,8 +87,14 @@ export let situationSelector = createDeepEqualSelector( ) export let formattedSituationSelector = createSelector( - [situationSelector], - situation => nestedSituationToPathMap(situation) + [situationSelector, state => state.simulation?.situation], + (reduxFormsituation, situation) => { + const formatedReduxFormSituation = nestedSituationToPathMap( + reduxFormsituation + ) + console.log(formatedReduxFormSituation, situation) + return formatedReduxFormSituation + } ) export let noUserInputSelector = createSelector( From 3fbd94bc656d2d646a18bcd69c82242637226958 Mon Sep 17 00:00:00 2001 From: Maxime Quandalle Date: Fri, 13 Sep 2019 12:42:19 +0200 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=94=A5=20Suppression=20de=20redux-for?= =?UTF-8?q?m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supprime aussi redux-batched-action. Le code résultant est plus concis (alors que l'on supprime une dépendance !), et plus clair car il y a moins d'indirections pour se conformer aux API de redux-form. --- package.json | 2 - source/Provider.js | 7 +- source/actions/actions.js | 14 +- source/components/PercentageField.js | 24 ++- source/components/PeriodSwitch.js | 160 ++++------------ source/components/SalaryExplanation.js | 31 ++-- source/components/TargetSelection.js | 172 +++++------------- source/components/Targets.js | 6 +- .../components/conversation/Conversation.js | 48 ++--- .../components/conversation/FormDecorator.js | 41 ++--- source/components/conversation/Input.js | 40 ++-- .../conversation/InputSuggestions.js | 17 +- source/components/conversation/Question.js | 68 +++---- .../conversation/RhetoricalQuestion.js | 9 +- .../conversation/select/SelectG\303\251o.js" | 6 +- .../conversation/select/SelectTauxRisque.js | 38 ++-- source/engine/rules.js | 2 - source/reducers/rootReducer.js | 44 +++-- source/reducers/storageReducer.js | 12 +- source/selectors/analyseSelectors.js | 40 ++-- source/selectors/storageSelectors.js | 33 ++-- .../middlewares/trackSimulatorActions.js | 29 ++- source/storage/persistSimulation.js | 2 +- source/types/State.js | 5 - test/ficheDePaieSelector.test.js | 14 +- yarn.lock | 39 +--- 26 files changed, 317 insertions(+), 586 deletions(-) diff --git a/package.json b/package.json index 54d0aaad3c..4bbf6b38ba 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", 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 b386b7db29..6f330e6237 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,19 +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({ type: 'UPDATE_SITUATION', fieldName: dottedName, value }) - dispatch(change('conversation', dottedName, value)) + dispatch(updateSituation(dottedName, value)) dispatch({ type: 'STEP_ACTION', name: 'fold', @@ -63,6 +61,12 @@ export const deletePreviousSimulation = () => ( deletePersistedSimulation() } +export const updateSituation = (fieldName, value) => ({ + type: 'UPDATE_SITUATION', + fieldName, + value +}) + // $FlowFixMe export function setExample(name, situation, dottedName) { return { type: 'SET_EXAMPLE', name, situation, dottedName } diff --git a/source/components/PercentageField.js b/source/components/PercentageField.js index 27f99e69ad..e0abf0e7dc 100644 --- a/source/components/PercentageField.js +++ b/source/components/PercentageField.js @@ -1,23 +1,21 @@ -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 + ) 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 0a618dd6b5..e87d33920c 100644 --- a/source/components/PeriodSwitch.js +++ b/source/components/PeriodSwitch.js @@ -1,141 +1,51 @@ -import { findRuleByDottedName, nestedSituationToPathMap } from 'Engine/rules' -import { compose, filter, map, toPairs } from 'ramda' +import { findRuleByDottedName } from 'Engine/rules' import React, { useEffect } from 'react' import { Trans } from 'react-i18next' -import { connect, useDispatch } from 'react-redux' -import { batchActions } from 'redux-batched-actions' -import { change, Field, reduxForm } from 'redux-form' +import { useDispatch, useSelector } from 'react-redux' import { flatRulesSelector, - situationSelector, - situationsWithDefaultsSelector + 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)) - }) - ) -)(function PeriodSwitch({ - situation, - rules, - batchPeriodChange, - initialPériode -}) { +export default function PeriodSwitch() { const dispatch = useDispatch() + const rules = useSelector(flatRulesSelector) + const situation = useSelector(situationSelector) + const initialPeriod = useSelector( + state => state.simulation?.config?.situation?.période + ) useEffect(() => { - !situation.période && - updateSituation( - initialPériode || 'année', - batchPeriodChange, - situation, - rules, - updatePeriod - ) - return - }) - const updatePeriod = (toPeriod, needConvertion) => - dispatch({ type: 'UPDATE_PERIOD', toPeriod, needConvertion }) + !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 }) + } + const periods = ['mois', 'année'] + return ( - - + {periods.map(period => ( + + ))} ) -}) - -let updateSituation = ( - toPeriod, - batchPeriodChange, - situation, - rules, - updatePeriod -) => { - let needConvertion = filter(([dottedName, value]) => { - let rule = findRuleByDottedName(rules, dottedName) - return value != null && rule?.période === 'flexible' - })(toPairs(situation)) - - updatePeriod(toPeriod, needConvertion.map(([fieldName]) => fieldName)) - - 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/TargetSelection.js b/source/components/TargetSelection.js index bd73b33a7d..d76ccc9563 100644 --- a/source/components/TargetSelection.js +++ b/source/components/TargetSelection.js @@ -1,3 +1,4 @@ +import { updateSituation } from 'Actions/actions' import classNames from 'classnames' import { T } from 'Components' import InputSuggestions from 'Components/conversation/InputSuggestions' @@ -8,16 +9,17 @@ import withColours from 'Components/utils/withColours' import withSitePaths from 'Components/utils/withSitePaths' import { encodeRuleName } from 'Engine/rules' import { serialiseUnit } from 'Engine/units' -import { compose, isEmpty, isNil, propEq } from 'ramda' +import { compose, isEmpty, isNil } from 'ramda' import React, { memo, useEffect, useState } from 'react' import emoji from 'react-easy-emoji' import { useTranslation } from 'react-i18next' -import { connect, useDispatch, useSelector } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { Link } from 'react-router-dom' -import { change, Field, formValueSelector, reduxForm } from 'redux-form' import { analysisWithDefaultsSelector, - flatRulesSelector + useSituation, + useSituationValue, + useTarget } from 'Selectors/analyseSelectors' import Animate from 'Ui/animate' import AnimatedTargetValue from 'Ui/AnimatedTargetValue' @@ -26,42 +28,18 @@ import './TargetSelection.css' export default compose( withColours, - reduxForm({ - form: 'conversation', - destroyOnUnmount: false - }), - connect( - state => ({ - getTargetValue: dottedName => - formValueSelector('conversation')(state, dottedName), - analysis: analysisWithDefaultsSelector(state), - flatRules: flatRulesSelector(state), - activeInput: state.activeTargetInput, - objectifs: state.simulation?.config.objectifs || [], - secondaryObjectives: - state.simulation?.config['objectifs secondaires'] || [] - }), - dispatch => ({ - setFormValue: (fieldName, value) => { - dispatch({ type: 'UPDATE_SITUATION', fieldName, value }) - dispatch(change('conversation', fieldName, value)) - }, - setActiveInput: name => - dispatch({ type: 'SET_ACTIVE_TARGET_INPUT', name }) - }) - ), memo -)(function TargetSelection({ - secondaryObjectives, - analysis, - getTargetValue, - setFormValue, - colours, - activeInput, - setActiveInput, - objectifs -}) { +)(function TargetSelection({ colours }) { const [initialRender, setInitialRender] = useState(true) + const analysis = useSelector(analysisWithDefaultsSelector) + const objectifs = useSelector( + state => state.simulation?.config.objectifs || [] + ) + const secondaryObjectives = useSelector( + state => state.simulation?.config['objectifs secondaires'] || [] + ) + const situation = useSituation() + const dispatch = useDispatch() useEffect(() => { let targets = getTargets() @@ -72,15 +50,17 @@ export default compose( (!target.formule || isEmpty(target.formule)) && (!isNil(target.defaultValue) || !isNil(target.explanation?.defaultValue)) && - !getTargetValue(target.dottedName) + !situation[target.dottedName] ) .forEach(target => { - setFormValue( - target.dottedName, - !isNil(target.defaultValue) - ? target.defaultValue - : target.explanation?.defaultValue + dispatch( + updateSituation( + target.dottedName, + !isNil(target.defaultValue) + ? target.defaultValue + : target.explanation?.defaultValue + ) ) }) @@ -128,9 +108,6 @@ export default compose( }}> groupTargets.includes(dottedName) ), @@ -145,13 +122,7 @@ export default compose( ) }) -let Targets = ({ - activeInput, - setActiveInput, - setFormValue, - targets, - initialRender -}) => ( +let Targets = ({ targets, initialRender }) => (
    {targets @@ -167,11 +138,7 @@ let Targets = ({ key={target.dottedName} initialRender={initialRender} {...{ - target, - setFormValue, - activeInput, - setActiveInput, - targets + target }} /> ))} @@ -179,18 +146,12 @@ let Targets = ({
) -const Target = ({ - target, - activeInput, +const Target = ({ target, initialRender }) => { + const activeInput = useSelector(state => state.activeTargetInput) + const dispatch = useDispatch() - targets, - setActiveInput, - setFormValue, - initialRender -}) => { const isSmallTarget = !target.question || !target.formule || isEmpty(target.formule) - return (
  • @@ -229,7 +187,7 @@ const Target = ({ { - setFormValue(target.dottedName, '' + value) + dispatch(updateSituation(target.dottedName, '' + value)) }} rulePeriod={target.période} colouredBackground={true} @@ -257,7 +215,7 @@ let Header = withSitePaths(({ target, sitePaths }) => { ) }) -let CurrencyField = withColours(props => { +let DebouncedCurrencyField = withColours(props => { return ( { }} debounce={600} className="targetInput" - value={props.input.value} - {...props.input} {...props} /> ) @@ -276,76 +232,51 @@ let DebouncedPercentageField = props => ( ) -let TargetInputOrValue = ({ - target, - targets, - activeInput, - setActiveInput, - firstStepCompleted -}) => { +let TargetInputOrValue = ({ target, activeInput }) => { const { i18n } = useTranslation() const dispatch = useDispatch() + const situationValue = useSituationValue(target.dottedName) let inputIsActive = activeInput === target.dottedName - const Component = { '€': CurrencyField, '%': DebouncedPercentageField }[ - serialiseUnit(target.unit) - ] + const Component = { + '€': DebouncedCurrencyField, + '%': DebouncedPercentageField + }[serialiseUnit(target.unit)] return ( {inputIsActive || !target.formule || isEmpty(target.formule) ? ( - - dispatch({ - type: 'UPDATE_SITUATION', - fieldName: target.dottedName, - value: evt.target.value - }) + dispatch(updateSituation(target.dottedName, evt.target.value)) } onBlur={event => event.preventDefault()} - component={ - { '€': CurrencyField, '%': DebouncedPercentageField }[ - serialiseUnit(target.unit) - ] - } {...(inputIsActive ? { autoFocus: true } : {})} language={i18n.language} /> ) : ( - + )} {target.dottedName.includes('rémunération . total') && } ) } -function TargetValue({ targets, target, activeInput, setActiveInput }) { +function TargetValue({ target }) { const blurValue = useSelector( state => analysisWithDefaultsSelector(state)?.cache.inversionFail ) + const targetWithValue = useTarget(target.dottedName) const dispatch = useDispatch() - const setFormValue = (field, name) => { - dispatch({ type: 'REFACTO_UPDATE_ACTIVE_FIELD', field, name }) - dispatch(change('conversation', field, name)) - } - let targetWithValue = targets?.find(propEq('dottedName', target.dottedName)), - value = targetWithValue && targetWithValue.nodeValue + const value = targetWithValue?.nodeValue const showField = value => () => { if (!target.question) return if (value != null && !Number.isNaN(value)) - setFormValue(target.dottedName, Math.round(value) + '') + dispatch(updateSituation(target.dottedName, Math.round(value) + '')) - if (activeInput) setFormValue(activeInput, '') - setActiveInput(target.dottedName) + dispatch({ type: 'SET_ACTIVE_TARGET_INPUT', name: target.dottedName }) } return ( @@ -364,13 +295,8 @@ function TargetValue({ targets, target, activeInput, setActiveInput }) { } function AidesGlimpse() { - const targets = useSelector( - state => analysisWithDefaultsSelector(state).targets - ) - const aides = targets?.find( - t => t.dottedName === 'contrat salarié . aides employeur' - ) - if (!aides || !aides.nodeValue) return null + const aides = useTarget('contrat salarié . aides employeur') + if (!aides?.nodeValue) return null return (
    diff --git a/source/components/Targets.js b/source/components/Targets.js index 6d9afc334d..8089a150f4 100644 --- a/source/components/Targets.js +++ b/source/components/Targets.js @@ -3,16 +3,16 @@ import withSitePaths from 'Components/utils/withSitePaths' import { compose } from 'ramda' import React from 'react' import emoji from 'react-easy-emoji' -import { connect } from 'react-redux' +import { useSelector } from 'react-redux' import { Link } from 'react-router-dom' import { analysisWithDefaultsSelector } from 'Selectors/analyseSelectors' import './Targets.css' export default compose( - connect(state => ({ analysis: analysisWithDefaultsSelector(state) })), withColours, withSitePaths -)(function Targets({ analysis, colours, sitePaths }) { +)(function Targets({ colours, sitePaths }) { + const analysis = useSelector(analysisWithDefaultsSelector) let { nodeValue, unité: unit, dottedName } = analysis.targets[0] return (
    diff --git a/source/components/conversation/Conversation.js b/source/components/conversation/Conversation.js index a9fbb1d838..f2f000b3a5 100644 --- a/source/components/conversation/Conversation.js +++ b/source/components/conversation/Conversation.js @@ -3,11 +3,9 @@ import { T } from 'Components' import QuickLinks from 'Components/QuickLinks' import { getInputComponent } from 'Engine/generateQuestions' import { findRuleByDottedName } from 'Engine/rules' -import { compose } from 'ramda' import React from 'react' import emoji from 'react-easy-emoji' -import { connect } from 'react-redux' -import { reduxForm } from 'redux-form' +import { useDispatch, useSelector } from 'react-redux' import { currentQuestionSelector, flatRulesSelector, @@ -17,40 +15,30 @@ import * as Animate from 'Ui/animate' import Aide from './Aide' import './conversation.css' -export default compose( - reduxForm({ - form: 'conversation', - destroyOnUnmount: false - }), - connect( - state => ({ - flatRules: flatRulesSelector(state), - currentQuestion: currentQuestionSelector(state), - previousAnswers: state.conversationSteps.foldedSteps, - nextSteps: nextStepsSelector(state) - }), - { validateStepWithValue, goToQuestion } +export default function Conversation({ customEndMessages }) { + const dispatch = useDispatch() + const flatRules = useSelector(flatRulesSelector) + const currentQuestion = useSelector(currentQuestionSelector) + const previousAnswers = useSelector( + state => state.conversationSteps.foldedSteps ) -)(function Conversation({ - nextSteps, - previousAnswers, - currentQuestion, - customEndMessages, - flatRules, - goToQuestion, - validateStepWithValue -}) { + const nextSteps = useSelector(nextStepsSelector) + const setDefault = () => - validateStepWithValue( - currentQuestion, - findRuleByDottedName(flatRules, currentQuestion).defaultValue + dispatch( + validateStepWithValue( + currentQuestion, + findRuleByDottedName(flatRules, currentQuestion).defaultValue + ) ) - const goToPrevious = () => goToQuestion(previousAnswers.slice(-1)[0]) + const goToPrevious = () => + dispatch(goToQuestion(previousAnswers.slice(-1)[0])) const handleKeyDown = ({ key }) => { if (['Escape'].includes(key)) { setDefault() } } + return nextSteps.length ? ( <> @@ -98,4 +86,4 @@ export default compose(

    ) -}) +} diff --git a/source/components/conversation/FormDecorator.js b/source/components/conversation/FormDecorator.js index f1abebc47a..86dab9eab1 100644 --- a/source/components/conversation/FormDecorator.js +++ b/source/components/conversation/FormDecorator.js @@ -1,8 +1,9 @@ +import { updateSituation } from 'Actions/actions' import classNames from 'classnames' import Explicable from 'Components/conversation/Explicable' import React from 'react' -import { useDispatch } from 'react-redux' -import { Field } from 'redux-form' +import { useDispatch, useSelector } from 'react-redux' +import { situationSelector } from 'Selectors/analyseSelectors' /* This higher order component wraps "Form" components (e.g. Question.js), that represent user inputs, @@ -15,6 +16,8 @@ to understand those precious higher order components. export const FormDecorator = formType => RenderField => function({ fieldName, question, inversion, unit, ...otherProps }) { const dispatch = useDispatch() + const situation = useSelector(situationSelector) + const submit = source => dispatch({ type: 'STEP_ACTION', @@ -22,21 +25,13 @@ export const FormDecorator = formType => RenderField => step: fieldName, source }) - const setFormValue = (fieldName, value) => { - dispatch({ type: 'UPDATE_SITUATION', fieldName, value }) - dispatch(change('conversation', fieldName, value)) + const setFormValue = value => { + dispatch(updateSituation(fieldName, normalize(value))) } - const stepProps = { - ...otherProps, - submit, - ...(unit === '%' - ? { - format: x => (x == null ? null : +(x * 100).toFixed(2)), - normalize: x => (x == null ? null : x / 100) - } - : {}) - } + const format = x => (unit === '%' && x ? +(x * 100).toFixed(2) : x) + const normalize = x => (unit === '%' ? x / 100 : x) + const value = format(situation[fieldName]) return (
    @@ -47,16 +42,14 @@ export const FormDecorator = formType => RenderField =>
    - - setFormValue(name, value) - } - onChange={(evt, value) => { - dispatch({ type: 'UPDATE_SITUATION', fieldName, value }) - }} - {...stepProps} + value={value} + setFormValue={setFormValue} + submit={submit} + format={format} + unit={unit} + {...otherProps} />
    diff --git a/source/components/conversation/Input.js b/source/components/conversation/Input.js index 45e1cd2cb9..8e32f1cd89 100644 --- a/source/components/conversation/Input.js +++ b/source/components/conversation/Input.js @@ -1,9 +1,9 @@ import classnames from 'classnames' -import { React, T } from 'Components' +import { T } from 'Components' import withColours from 'Components/utils/withColours' import { compose } from 'ramda' -import { connect } from 'react-redux' -import { formValueSelector } from 'redux-form' +import React, { useCallback, useState } from 'react' +import { usePeriod } from 'Selectors/analyseSelectors' import { debounce } from '../../utils' import { FormDecorator } from './FormDecorator' import InputSuggestions from './InputSuggestions' @@ -11,33 +11,30 @@ import SendButton from './SendButton' export default compose( FormDecorator('input'), - withColours, - connect(state => ({ - period: formValueSelector('conversation')(state, 'période') - })) + withColours )(function Input({ - input, suggestions, setFormValue, submit, rulePeriod, dottedName, - meta: { dirty, error }, + value, + format, colours, - period, unit }) { - const debouncedOnChange = debounce(750, input.onChange) - let suffixed = unit != null, - inputError = dirty && error, - submitDisabled = !dirty || inputError + const period = usePeriod() + const debouncedSetFormValue = useCallback(debounce(750, setFormValue), []) + const suffixed = unit != null return ( <>
    setFormValue('' + value)} + onFirstClick={value => { + setFormValue(format(value)) + }} onSecondClick={() => submit('suggestion')} rulePeriod={rulePeriod} /> @@ -46,12 +43,11 @@ export default compose(
    { - 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..b025504e51 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,37 @@ 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) + }) 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 +69,11 @@ export default compose( {...{ value: 'non', label: 'Aucun', - input, + currentValue, submit, colours, dottedName: null, - setFormValue + onChange }} /> @@ -101,10 +92,10 @@ export default compose( value: relativeDottedName(dottedName), label: title, dottedName, - input, + currentValue, submit, colours, - setFormValue + onChange }} /> @@ -123,7 +114,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 +153,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/select/SelectG\303\251o.js" "b/source/components/conversation/select/SelectG\303\251o.js" index a7fb0697b0..6d625d0efe 100644 --- "a/source/components/conversation/select/SelectG\303\251o.js" +++ "b/source/components/conversation/select/SelectG\303\251o.js" @@ -35,14 +35,14 @@ let getOptions = input => }) export default FormDecorator('select')(function Select({ - input: { onChange }, + setFormValue, submit }) { let submitOnChange = option => { tauxVersementTransport(option.code) .then(({ taux }) => { // serialize to not mix our data schema and the API response's - onChange( + setFormValue( JSON.stringify({ ...option, ...(taux != undefined @@ -59,7 +59,7 @@ export default FormDecorator('select')(function Select({ console.log( 'Erreur dans la récupération du taux de versement transport à partir du code commune', error - ) || onChange(JSON.stringify({ option })) + ) || setFormValue(JSON.stringify({ option })) submit() // eslint-disable-line no-console }) } diff --git a/source/components/conversation/select/SelectTauxRisque.js b/source/components/conversation/select/SelectTauxRisque.js index 3cb8994129..076cc5e3cd 100644 --- a/source/components/conversation/select/SelectTauxRisque.js +++ b/source/components/conversation/select/SelectTauxRisque.js @@ -5,26 +5,22 @@ import { FormDecorator } from '../FormDecorator' import './Select.css' import SelectOption from './SelectOption.js' -function ReactSelectWrapper(props) { - let { - value, - onBlur, - onChange, - submit, - options, - submitOnChange = option => { - option.text = +option['Taux net'].replace(',', '.') / 100 - onChange(option.text) - submit() - }, - selectValue = value && value['Code risque'] - // but ReactSelect obviously needs a unique identifier - } = props - +function ReactSelectWrapper({ + value, + onBlur, + setFormValue, + submit, + options, + submitOnChange = option => { + option.text = +option['Taux net'].replace(',', '.') / 100 + setFormValue(option.text) + submit() + }, + selectValue = value?.['Code risque'] +}) { if (!options) return null return ( - // For redux-form integration, checkout https://github.com/erikras/redux-form/issues/82#issuecomment-143164199 { fetch( @@ -63,9 +59,7 @@ function Select({ input, submit }) { return (
      - +
      ) -} - -export default FormDecorator('select')(Select) +}) diff --git a/source/engine/rules.js b/source/engine/rules.js index 193590e1d0..46e505c6a0 100644 --- a/source/engine/rules.js +++ b/source/engine/rules.js @@ -173,8 +173,6 @@ export let findRuleByNamespace = (allRules, ns) => export let queryRule = rule => query => path(query.split(' . '))(rule) -// Redux-form stores the form values as a nested object -// This helper makes a dottedName => value Map export let nestedSituationToPathMap = situation => { if (situation == undefined) return {} let rec = (o, currentPath) => diff --git a/source/reducers/rootReducer.js b/source/reducers/rootReducer.js index cd373fcd03..3320856ee8 100644 --- a/source/reducers/rootReducer.js +++ b/source/reducers/rootReducer.js @@ -14,8 +14,6 @@ import { } from 'ramda' import reduceReducers from 'reduce-reducers' import { combineReducers } from 'redux' -// $FlowFixMe -import { reducer as formReducer } from 'redux-form' import i18n from '../i18n' import inFranceAppReducer from './inFranceAppReducer' import storageReducer from './storageReducer' @@ -101,8 +99,15 @@ function conversationSteps( return state } -function updatePeriod(situation, { toPeriod, needConvertion }) { - const currentPeriod = situation['période'] || 'mois' +function updateSituation(situation, { fieldName, value, objectifs }) { + const removePreviousTarget = objectifs.includes(fieldName) + ? omit(objectifs) + : identity + return { ...removePreviousTarget(situation), [fieldName]: value } +} + +function updatePeriod(situation, { toPeriod, needConversion }) { + const currentPeriod = situation['période'] if (currentPeriod === toPeriod) { return situation } @@ -111,7 +116,7 @@ function updatePeriod(situation, { toPeriod, needConvertion }) { } const updatedSituation = Object.entries(situation) - .filter(([fieldName]) => needConvertion.includes(fieldName)) + .filter(([fieldName]) => needConversion.includes(fieldName)) .map(([fieldName, value]) => [ fieldName, currentPeriod === 'mois' && toPeriod === 'année' ? value * 12 : value / 12 @@ -124,34 +129,35 @@ function updatePeriod(situation, { toPeriod, needConvertion }) { } } -function simulation( - state = null, - { type, config, url, id, fieldName, value, toPeriod, needConvertion } -) { - if (type === 'SET_SIMULATION') { +function simulation(state = null, action) { + if (action.type === 'SET_SIMULATION') { + const { config, url } = action return { config, url, hiddenControls: [], situation: {} } } if (state === null) { return state } - switch (type) { + switch (action.type) { case 'HIDE_CONTROL': - return { ...state, hiddenControls: [...state.hiddenControls, id] } + return { ...state, hiddenControls: [...state.hiddenControls, action.id] } case 'RESET_SIMULATION': return { ...state, hiddenControls: [], situation: {} } case 'UPDATE_SITUATION': - const { config, situation } = state - const removePreviousTarget = config.objectifs.includes(fieldName) - ? omit(config.objectifs) - : identity return { ...state, - situation: { ...removePreviousTarget(situation), [fieldName]: value } + situation: updateSituation(state.situation, { + fieldName: action.fieldName, + value: action.value, + objectifs: state.config.objectifs + }) } case 'UPDATE_PERIOD': return { ...state, - situation: updatePeriod(state.situation, { toPeriod, needConvertion }) + situation: updatePeriod(state.situation, { + toPeriod: action.toPeriod, + needConversion: action.needConversion + }) } } return state @@ -194,8 +200,6 @@ export default reduceReducers( storageReducer, combineReducers({ sessionId: defaultTo(Math.floor(Math.random() * 1000000000000) + ''), - // this is handled by redux-form, pas touche ! - form: formReducer, conversationSteps, lang, simulation, diff --git a/source/reducers/storageReducer.js b/source/reducers/storageReducer.js index fbaf901008..be0240f83b 100644 --- a/source/reducers/storageReducer.js +++ b/source/reducers/storageReducer.js @@ -2,28 +2,20 @@ import type { State } from 'Types/State' import type { Action } from 'Types/ActionsTypes' -import { - createStateFromSavedSimulation, - currentSimulationSelector -} from 'Selectors/storageSelectors' +import { createStateFromSavedSimulation } from 'Selectors/storageSelectors' export default (state: State, action: Action): State => { switch (action.type) { case 'LOAD_PREVIOUS_SIMULATION': return { ...state, - ...createStateFromSavedSimulation(state.previousSimulation) + ...createStateFromSavedSimulation(state) } case 'DELETE_PREVIOUS_SIMULATION': return { ...state, previousSimulation: null } - case 'RESET_SIMULATION': - return { - ...state, - previousSimulation: currentSimulationSelector(state) - } default: return state } diff --git a/source/selectors/analyseSelectors.js b/source/selectors/analyseSelectors.js index 90fbdf928d..37fd56fc13 100644 --- a/source/selectors/analyseSelectors.js +++ b/source/selectors/analyseSelectors.js @@ -6,7 +6,6 @@ import { collectDefaults, disambiguateExampleSituation, findRuleByDottedName, - nestedSituationToPathMap, rules as baseRulesEn, rulesFr as baseRulesFr } from 'Engine/rules' @@ -33,7 +32,7 @@ import { takeWhile, zipWith } from 'ramda' -import { getFormValues } from 'redux-form' +import { useSelector } from 'react-redux' import { createSelector, createSelectorCreator, defaultMemoize } from 'reselect' import { mapOrApply } from '../utils' // create a "selector creator" that uses deep equal instead of === @@ -81,30 +80,29 @@ export let targetNamesSelector = state => { return [...targetNames, ...secondaryTargetNames] } -export let situationSelector = createDeepEqualSelector( - getFormValues('conversation'), - x => x -) +export let situationSelector = state => state.simulation?.situation || {} -export let formattedSituationSelector = createSelector( - [situationSelector, state => state.simulation?.situation], - (reduxFormsituation, situation) => { - const formatedReduxFormSituation = nestedSituationToPathMap( - reduxFormsituation - ) - console.log(formatedReduxFormSituation, situation) - return formatedReduxFormSituation - } -) +export const useSituation = () => useSelector(situationSelector) + +export const useSituationValue = fieldName => useSituation()?.[fieldName] + +export const usePeriod = () => useSituationValue('période') + +export const useTarget = dottedName => { + const targets = useSelector( + state => analysisWithDefaultsSelector(state).targets + ) + return targets?.find(t => t.dottedName === dottedName) +} export let noUserInputSelector = createSelector( - [formattedSituationSelector], + [situationSelector], situation => !situation || isEmpty(dissoc('période', situation)) ) export let firstStepCompletedSelector = createSelector( [ - formattedSituationSelector, + situationSelector, targetNamesSelector, parsedRulesSelector, state => state.simulation?.config?.bloquant @@ -156,7 +154,7 @@ const createSituationBrancheSelector = situationSelector => ) export let situationBranchesSelector = createSituationBrancheSelector( - formattedSituationSelector + situationSelector ) export let situationBranchNameSelector = createSelector( [branchesSelector, state => state.situationBranch], @@ -165,7 +163,7 @@ export let situationBranchNameSelector = createSelector( ) export let validatedSituationSelector = createSelector( - [formattedSituationSelector, validatedStepsSelector], + [situationSelector, validatedStepsSelector], (situation, validatedSteps) => pick(validatedSteps, situation) ) export let validatedSituationBranchesSelector = createSituationBrancheSelector( @@ -289,7 +287,7 @@ export let nextStepsSelector = createSelector( currentMissingVariablesByTargetSelector, state => state.simulation?.config.questions, state => state.conversationSteps.foldedSteps, - formattedSituationSelector + situationSelector ], ( mv, diff --git a/source/selectors/storageSelectors.js b/source/selectors/storageSelectors.js index 0f1863b71e..d8936fff68 100644 --- a/source/selectors/storageSelectors.js +++ b/source/selectors/storageSelectors.js @@ -1,28 +1,23 @@ /* @flow */ -import type { Situation } from 'Types/Situation.js' import type { SavedSimulation, State } from 'Types/State.js' -const situationSelector: State => Situation = state => - state.form.conversation?.values - -export const currentSimulationSelector: State => SavedSimulation = state => ({ - situation: situationSelector(state), - activeTargetInput: state.activeTargetInput, - foldedSteps: state.conversationSteps.foldedSteps -}) +export const currentSimulationSelector: State => SavedSimulation = state => { + return { + situation: state.simulation.situation, + activeTargetInput: state.activeTargetInput, + foldedSteps: state.conversationSteps.foldedSteps + } +} -export const createStateFromSavedSimulation: ( - ?SavedSimulation -) => ?$Supertype = simulation => - simulation && { - activeTargetInput: simulation.activeTargetInput, - form: { - conversation: { - values: simulation.situation - } +export const createStateFromSavedSimulation = state => + state.previousSimulation && { + activeTargetInput: state.previousSimulation.activeTargetInput, + simulation: { + ...state.simulation, + situation: state.previousSimulation.situation || {} }, conversationSteps: { - foldedSteps: simulation.foldedSteps + foldedSteps: state.previousSimulation.foldedSteps }, previousSimulation: null } diff --git a/source/sites/mon-entreprise.fr/middlewares/trackSimulatorActions.js b/source/sites/mon-entreprise.fr/middlewares/trackSimulatorActions.js index f569b2dd8c..98c5e7db23 100644 --- a/source/sites/mon-entreprise.fr/middlewares/trackSimulatorActions.js +++ b/source/sites/mon-entreprise.fr/middlewares/trackSimulatorActions.js @@ -1,26 +1,12 @@ /* @flow */ - -// $FlowFixMe -import { actionTypes } from 'redux-form' import { currentQuestionSelector, - formattedSituationSelector + situationSelector } from 'Selectors/analyseSelectors' -import { debounce } from '../../../utils' import type { Tracker } from 'Components/utils/withTracker' export default (tracker: Tracker) => { - const debouncedUserInputTracking = debounce(1000, action => - tracker.push([ - 'trackEvent', - 'Simulator', - 'input', - action.meta.field, - action.payload - ]) - ) - // $FlowFixMe return ({ getState }) => next => action => { next(action) @@ -31,7 +17,7 @@ export default (tracker: Tracker) => { 'Simulator::answer', action.source, action.step, - formattedSituationSelector(newState)[action.step] + situationSelector(newState)[action.step] ]) if (!currentQuestionSelector(newState)) { @@ -55,8 +41,15 @@ export default (tracker: Tracker) => { ]) } - if (action.type === actionTypes.CHANGE) { - debouncedUserInputTracking(action) + if (action.type === 'UPDATE_SITUATION' || action.type === 'UPDATE_PERIOD') { + tracker.push([ + 'trackEvent', + 'Simulator', + 'update situation', + ...(action.type === 'UPDATE_PERIOD' + ? ['période', action.toPeriod] + : [action.fieldName, action.value]) + ]) } if (action.type === 'START_CONVERSATION') { tracker.push([ diff --git a/source/storage/persistSimulation.js b/source/storage/persistSimulation.js index 30f3fea24d..61b0259bc7 100644 --- a/source/storage/persistSimulation.js +++ b/source/storage/persistSimulation.js @@ -7,7 +7,7 @@ import { deserialize, serialize } from './serializeSimulation' import type { State, SavedSimulation } from '../types/State' import type { Action } from 'Types/ActionsTypes' -const VERSION = 2 +const VERSION = 3 const LOCAL_STORAGE_KEY = 'embauche.gouv.fr::persisted-simulation::v' + VERSION diff --git a/source/types/State.js b/source/types/State.js index 71fa6b8820..91df7e3d2f 100644 --- a/source/types/State.js +++ b/source/types/State.js @@ -18,11 +18,6 @@ export type FlatRules = { } } export type State = { - form: { - conversation: { - values: Situation - } - }, previousSimulation: ?SavedSimulation, conversationSteps: { foldedSteps: Array, diff --git a/test/ficheDePaieSelector.test.js b/test/ficheDePaieSelector.test.js index 83f37adb4b..c96c6c4b72 100644 --- a/test/ficheDePaieSelector.test.js +++ b/test/ficheDePaieSelector.test.js @@ -11,16 +11,12 @@ import { } from 'Selectors/ficheDePaieSelectors' let state = { - form: { - conversation: { - values: { - 'contrat salarié': { rémunération: { 'brut de base': '2300' } }, - entreprise: { effectif: '50' } - } - } - }, simulation: { - config: salariéConfig + config: salariéConfig, + situation: { + 'contrat salarié . rémunération . brut de base': '2300', + 'entreprise . effectif': '50' + } }, conversationSteps: { foldedSteps: [] diff --git a/yarn.lock b/yarn.lock index 459b0b342d..6824ff4c69 100644 --- a/yarn.lock +++ b/yarn.lock @@ -751,7 +751,7 @@ dependencies: regenerator-runtime "^0.13.2" -"@babel/runtime@^7.2.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4": +"@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4": version "7.5.5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.5.5.tgz#74fba56d35efbeca444091c7850ccd494fd2f132" integrity sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ== @@ -3497,11 +3497,6 @@ es-to-primitive@^1.2.0: is-date-object "^1.0.1" is-symbol "^1.0.2" -es6-error@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" - integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg== - es6-promise@^4.0.3: version "4.2.8" resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" @@ -4686,7 +4681,7 @@ hoist-non-react-statics@^2.2.2: resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" integrity sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw== -hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.2.1, hoist-non-react-statics@^3.3.0: +hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz#b09178f0122184fb95acf525daaecb4d8f45958b" integrity sha512-0XsbTXxgiaCDYDIWFcwkmerZPSwywfUqYmwT4jzewKTQSWoE6FCMoUVOeBJWK3E/CrWbxRG3m5GzY4lnIwGRBA== @@ -4934,11 +4929,6 @@ ignore@^4.0.6: resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== -immutable@3.8.2: - version "3.8.2" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" - integrity sha1-wkOZUUVbs5kT2vKBN28VMOEErfM= - import-cwd@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" @@ -5799,7 +5789,7 @@ locate-path@^3.0.0: p-locate "^3.0.0" path-exists "^3.0.0" -lodash-es@^4.17.15, lodash-es@^4.2.1: +lodash-es@^4.2.1: version "4.17.15" resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78" integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ== @@ -8180,29 +8170,6 @@ reduce-reducers@^0.1.2: resolved "https://registry.yarnpkg.com/reduce-reducers/-/reduce-reducers-0.1.5.tgz#ff77ca8068ff41007319b8b4b91533c7e0e54576" integrity sha512-uoVmQnZQ+BtKKDKpBdbBri5SLNyIK9ULZGOA504++VbHcwouWE+fJDIo8AuESPF9/EYSkI0v05LDEQK6stCbTA== -redux-batched-actions@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/redux-batched-actions/-/redux-batched-actions-0.4.1.tgz#a8de8cef50a1db4f009d5222820c836515597e22" - integrity sha512-r6tLDyBP3U9cXNLEHs0n1mX5TQfmk6xE0Y9uinYZ5HOyAWDgIJxYqRRkU/bC6XrJ4nS7tasNbxaHJHVmf9UdkA== - -redux-form@^8.2.0: - version "8.2.6" - resolved "https://registry.yarnpkg.com/redux-form/-/redux-form-8.2.6.tgz#6840bbe9ed5b2aaef9dd82e6db3e5efcfddd69b1" - integrity sha512-krmF7wl1C753BYpEpWIVJ5NM4lUJZFZc5GFUVgblT+jprB99VVBDyBcgrZM3gWWLOcncFyNsHcKNQQcFg8Uanw== - dependencies: - "@babel/runtime" "^7.2.0" - es6-error "^4.1.1" - hoist-non-react-statics "^3.2.1" - invariant "^2.2.4" - is-promise "^2.1.0" - lodash "^4.17.15" - lodash-es "^4.17.15" - prop-types "^15.6.1" - react-is "^16.7.0" - react-lifecycles-compat "^3.0.4" - optionalDependencies: - immutable "3.8.2" - redux-thunk@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" From dbbb67ee7fddde04d0f47358dd2cc13c3094da37 Mon Sep 17 00:00:00 2001 From: Maxime Quandalle Date: Sun, 15 Sep 2019 22:51:13 +0200 Subject: [PATCH 3/7] Ajout du lint pour les hooks React MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comme recommandé dans la documentation des hooks React, ajout des deux linters suivants : react-hooks/rules-of-hooks et react-hooks/exhaustive-deps Mise à jour des composants, en particulier les useEffect pour y spécifier toutes les dépendances. --- .eslintrc.yaml | 6 +-- package.json | 1 + source/components/AttachDictionary.js | 1 + .../components/CurrencyInput/CurrencyInput.js | 36 ++++++++-------- source/components/PercentageField.js | 3 +- source/components/PeriodSwitch.js | 23 +++++----- source/components/SearchBar.js | 43 ++++++++----------- source/components/SearchButton.js | 15 +++---- source/components/TargetSelection.js | 24 ++++------- source/components/conversation/Question.js | 11 +++-- source/components/conversation/SendButton.js | 23 +++++----- source/components/ui/AnimatedTargetValue.js | 2 +- source/components/ui/animate.js | 3 ++ source/engine/uniroot.js | 2 +- source/sites/mon-entreprise.fr/App.js | 2 +- .../layout/Navigation/SideBar.js | 27 ++++++------ .../pages/Dev/IntegrationTest.js | 1 + .../pages/Iframes/IframeFooter.js | 8 ++-- yarn.lock | 5 +++ 19 files changed, 122 insertions(+), 114 deletions(-) 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 4bbf6b38ba..26e75d1b27 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 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..7507264ff2 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 e0abf0e7dc..e85eabf7a9 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 e87d33920c..1feb823a1d 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 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({ <> selon le contexte Fixes #558 --- .../components/CurrencyInput/CurrencyInput.js | 6 +- .../CurrencyInput/CurrencyInput.test.js | 22 ++- source/components/PeriodSwitch.js | 5 +- source/components/TargetSelection.css | 34 ++-- source/components/TargetSelection.js | 162 ++++++++++-------- source/components/ui/AnimatedTargetValue.js | 21 +-- source/components/utils/withColours.js | 2 +- 7 files changed, 143 insertions(+), 109 deletions(-) diff --git a/source/components/CurrencyInput/CurrencyInput.js b/source/components/CurrencyInput/CurrencyInput.js index 7507264ff2..593c27a87a 100644 --- a/source/components/CurrencyInput/CurrencyInput.js +++ b/source/components/CurrencyInput/CurrencyInput.js @@ -24,6 +24,7 @@ let currencyFormat = language => ({ export default function CurrencyInput({ value: valueProp = '', debounce: debounceTimeout, + currencySymbol = '€', onChange, language, className, @@ -76,7 +77,7 @@ export default function CurrencyInput({
      5 ? { style: { width } } : {})}> - {isCurrencyPrefixed && '€'} + {!currentValue && isCurrencyPrefixed && currencySymbol} { setCurrentValue(value) nextValue.current = value.toString().replace(/^-/, '') 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/PeriodSwitch.js b/source/components/PeriodSwitch.js index 1feb823a1d..d2c4950ef3 100644 --- a/source/components/PeriodSwitch.js +++ b/source/components/PeriodSwitch.js @@ -29,7 +29,10 @@ export default function PeriodSwitch() { }, [dispatch, rules, situation] ) - const periods = ['mois', 'année'] + let periods = ['mois', 'année'] + if (initialPeriod === 'année') { + periods.reverse() + } return ( diff --git a/source/components/TargetSelection.css b/source/components/TargetSelection.css index 4da72c2d54..63783d9ebc 100644 --- a/source/components/TargetSelection.css +++ b/source/components/TargetSelection.css @@ -106,28 +106,19 @@ text-decoration: none; } -#targetSelection .editable:not(.attractClick) { - border: 2px solid rgba(0, 0, 0, 0); - border-bottom: 1px dashed #ffffff91; - min-width: 2.5em; - display: inline-block; -} #targetSelection .targetInputOrValue > :not(.targetInput):not(.attractClick) { margin: 0.2rem 0.6rem; } -#targetSelection .attractClick.editable::before { - content: '€'; +#targetSelection input { + margin: 2.7px 0; } -#targetSelection .attractClick, #targetSelection .targetInput { width: 5.5em; max-width: 7.5em; - display: inline-block; text-align: right; background: rgba(255, 255, 255, 0.2); - cursor: text; padding: 0; padding: 0.2rem 0.6rem; border-radius: 0.3rem; @@ -135,6 +126,27 @@ font-size: inherit; } +#targetSelection .editableTarget { + max-width: 7.5em; + display: inline-block; + text-align: right; + padding: 0 2px; + font-size: inherit; +} + +#targetSelection .targetInputBottomBorder { + margin: 0; + padding: 0; + height: 0; + overflow: hidden; + position: relative; + top: -6px; +} + +#targetSelection .editableTarget + .targetInputBottomBorder { + border-bottom: 1px dashed #ffffff91; +} + #targetSelection .unit { margin-left: 0.4em; font-size: 110%; diff --git a/source/components/TargetSelection.js b/source/components/TargetSelection.js index 1dae7bf025..e781539d17 100644 --- a/source/components/TargetSelection.js +++ b/source/components/TargetSelection.js @@ -1,16 +1,15 @@ import { updateSituation } from 'Actions/actions' -import classNames from 'classnames' import { T } from 'Components' import InputSuggestions from 'Components/conversation/InputSuggestions' import PercentageField from 'Components/PercentageField' import PeriodSwitch from 'Components/PeriodSwitch' import RuleLink from 'Components/RuleLink' -import withColours from 'Components/utils/withColours' +import { ThemeColoursContext } from 'Components/utils/withColours' import withSitePaths from 'Components/utils/withSitePaths' import { encodeRuleName } from 'Engine/rules' import { serialiseUnit } from 'Engine/units' -import { compose, isEmpty, isNil } from 'ramda' -import React, { memo, useEffect, useState } from 'react' +import { isEmpty, isNil } from 'ramda' +import React, { useEffect, useState, useContext } from 'react' import emoji from 'react-easy-emoji' import { useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' @@ -26,10 +25,7 @@ import AnimatedTargetValue from 'Ui/AnimatedTargetValue' import CurrencyInput from './CurrencyInput/CurrencyInput' import './TargetSelection.css' -export default compose( - withColours, - memo -)(function TargetSelection({ colours }) { +export default function TargetSelection() { const [initialRender, setInitialRender] = useState(true) const analysis = useSelector(analysisWithDefaultsSelector) const objectifs = useSelector( @@ -40,6 +36,7 @@ export default compose( ) const situation = useSituation() const dispatch = useDispatch() + const colours = useContext(ThemeColoursContext) const targets = analysis?.targets.filter( @@ -50,6 +47,7 @@ export default compose( useEffect(() => { // Initialize defaultValue for target that can't be computed + // TODO: this logic shouldn't be here targets .filter( target => @@ -114,7 +112,7 @@ export default compose( )}
      ) -}) +} let Targets = ({ targets, initialRender }) => (
      @@ -144,6 +142,7 @@ const Target = ({ target, initialRender }) => { const activeInput = useSelector(state => state.activeTargetInput) const dispatch = useDispatch() + const isActiveInput = activeInput === target.dottedName const isSmallTarget = !target.question || !target.formule || isEmpty(target.formule) return ( @@ -156,8 +155,7 @@ const Target = ({ target, initialRender }) => {
      {isSmallTarget && ( @@ -172,11 +170,12 @@ const Target = ({ target, initialRender }) => {
      - {activeInput === target.dottedName && ( + {isActiveInput && ( { ) }) -let DebouncedCurrencyField = withColours(props => { - return ( - - ) -}) -let DebouncedPercentageField = props => ( - -) +export const formatCurrency = (value, language) => { + return value == null + ? '' + : Intl.NumberFormat(language, { + style: 'currency', + currency: 'EUR', + maximumFractionDigits: 0, + minimumFractionDigits: 0 + }) + .format(value) + .replace(/^€/, '€ ') +} -let TargetInputOrValue = ({ target, activeInput }) => { +let clickableField = Input => + function WrappedClickableField({ value, ...otherProps }) { + const colors = useContext(ThemeColoursContext) + const { language } = useTranslation().i18n + return ( + <> + + + + {formatCurrency(value, language)} + + + ) + } + +let unitToComponent = { + '€': clickableField(CurrencyInput), + '%': clickableField(PercentageField) +} + +let TargetInputOrValue = ({ target, isActiveInput, isSmallTarget }) => { const { i18n } = useTranslation() const dispatch = useDispatch() const situationValue = useSituationValue(target.dottedName) - - let inputIsActive = activeInput === target.dottedName - const Component = { - '€': DebouncedCurrencyField, - '%': DebouncedPercentageField - }[serialiseUnit(target.unit)] + const targetWithValue = useTarget(target.dottedName) + const value = targetWithValue?.nodeValue?.toFixed(0) + const inversionFail = useSelector( + state => analysisWithDefaultsSelector(state)?.cache.inversionFail + ) + const blurValue = inversionFail && !isActiveInput && value + const Component = unitToComponent[serialiseUnit(target.unit)] return ( - - {inputIsActive || !target.formule || isEmpty(target.formule) ? ( + + {target.question ? ( dispatch(updateSituation(target.dottedName, evt.target.value)) } onBlur={event => event.preventDefault()} - {...(inputIsActive ? { autoFocus: true } : {})} + // We use onMouseDown instead of onClick because that's when the browser moves the cursor + onMouseDown={() => { + if (isSmallTarget) return + dispatch({ + type: 'SET_ACTIVE_TARGET_INPUT', + name: target.dottedName + }) + // TODO: This shouldn't be necessary: we don't need to recalculate the situation + // when the user just focus another field. Removing this line is almost working + // however there is a weird bug in the selection of the next question. + if (value) { + dispatch(updateSituation(target.dottedName, '' + value)) + } + }} + {...(isActiveInput ? { autoFocus: true } : {})} language={i18n.language} /> ) : ( - + + {Number.isNaN(value) ? '—' : formatCurrency(value, i18n.language)} + )} {target.dottedName.includes('rémunération . total') && } ) } -function TargetValue({ target }) { - const blurValue = useSelector( - state => analysisWithDefaultsSelector(state)?.cache.inversionFail - ) - const targetWithValue = useTarget(target.dottedName) - const dispatch = useDispatch() - - const value = targetWithValue?.nodeValue - const showField = value => () => { - if (!target.question) return - if (value != null && !Number.isNaN(value)) - dispatch(updateSituation(target.dottedName, Math.round(value) + '')) - - dispatch({ type: 'SET_ACTIVE_TARGET_INPUT', name: target.dottedName }) - } - - return ( -
      - -
      - ) -} - function AidesGlimpse() { const aides = useTarget('contrat salarié . aides employeur') if (!aides?.nodeValue) return null @@ -297,7 +311,9 @@ function AidesGlimpse() { -{' '} - + + {formatCurrency(aides.nodeValue)} + {' '} d'aides {emoji(aides.icons)} diff --git a/source/components/ui/AnimatedTargetValue.js b/source/components/ui/AnimatedTargetValue.js index 542b065a92..0efd1eae5a 100644 --- a/source/components/ui/AnimatedTargetValue.js +++ b/source/components/ui/AnimatedTargetValue.js @@ -3,12 +3,13 @@ import React, { useEffect, useState } from 'react' import ReactCSSTransitionGroup from 'react-addons-css-transition-group' import { useTranslation } from 'react-i18next' import './AnimatedTargetValue.css' +import { formatCurrency } from 'Components/TargetSelection' type Props = { value: ?number } -export default function AnimatedTargetValue({ value }: Props) { +export default function AnimatedTargetValue({ value, children }: Props) { const [difference, setDifference] = useState(0) const [previousValue, setPreviousValue] = useState() useEffect(() => { @@ -20,18 +21,7 @@ export default function AnimatedTargetValue({ value }: Props) { }, [previousValue, value]) const { i18n } = useTranslation() - const format = value => { - return value == null - ? '' - : Intl.NumberFormat(i18n.language, { - style: 'currency', - currency: 'EUR', - maximumFractionDigits: 0, - minimumFractionDigits: 0 - }).format(value) - } - - const formattedDifference = format(difference) + const formattedDifference = formatCurrency(difference, i18n.language) const shouldDisplayDifference = Math.abs(difference) > 1 && value != null && !Number.isNaN(value) return ( @@ -40,12 +30,13 @@ export default function AnimatedTargetValue({ value }: Props) { {shouldDisplayDifference && ( 0 ? 'chartreuse' : 'red' + color: difference > 0 ? 'chartreuse' : 'red', + pointerEvents: 'none' }}> {(difference > 0 ? '+' : '') + formattedDifference} )}{' '} - {Number.isNaN(value) ? '—' : format(value)} + {children}
      ) diff --git a/source/components/utils/withColours.js b/source/components/utils/withColours.js index 17822c1a40..9ef5b5f232 100644 --- a/source/components/utils/withColours.js +++ b/source/components/utils/withColours.js @@ -88,7 +88,7 @@ const generateTheme = (themeColour?: ?string): ThemeColours => { } } -const ThemeColoursContext: React$Context = createContext( +export const ThemeColoursContext: React$Context = createContext( generateTheme() ) From 0ddc2d046970aeec9f278e42b0e1f0a349915197 Mon Sep 17 00:00:00 2001 From: Maxime Quandalle Date: Tue, 17 Sep 2019 20:10:27 +0200 Subject: [PATCH 5/7] =?UTF-8?q?Ajout=20des=20r=C3=A8gles=20dans=20le=20sta?= =?UTF-8?q?te=20Redux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Déplace la logique de changement de période d'un component vers un reducer --- source/components/PeriodSwitch.js | 33 +++++----------- source/reducers/rootReducer.js | 57 ++++++++++++++++----------- source/selectors/analyseSelectors.js | 20 +++------- source/sites/mon-entreprise.fr/App.js | 7 +++- test/conversation.test.js | 20 +++++----- test/ficheDePaieSelector.test.js | 3 +- test/real-rules.test.js | 1 + 7 files changed, 67 insertions(+), 74 deletions(-) diff --git a/source/components/PeriodSwitch.js b/source/components/PeriodSwitch.js index d2c4950ef3..c7f6670596 100644 --- a/source/components/PeriodSwitch.js +++ b/source/components/PeriodSwitch.js @@ -1,36 +1,23 @@ -import { findRuleByDottedName } from 'Engine/rules' -import React, { useCallback, useEffect } from 'react' +import React from 'react' import { Trans } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' -import { - flatRulesSelector, - situationSelector -} from 'Selectors/analyseSelectors' +import { situationSelector } from 'Selectors/analyseSelectors' import './PeriodSwitch.css' export default function PeriodSwitch() { const dispatch = useDispatch() - const rules = useSelector(flatRulesSelector) const situation = useSelector(situationSelector) - const initialPeriod = useSelector( - state => state.simulation?.config?.situation?.période + const defaultPeriod = useSelector( + state => state.simulation?.config?.situation?.période || 'année' ) const currentPeriod = situation.période - useEffect(() => { - !currentPeriod && updatePeriod(initialPeriod || 'année') - }, [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] - ) let periods = ['mois', 'année'] - if (initialPeriod === 'année') { + const updatePeriod = toPeriod => dispatch({ type: 'UPDATE_PERIOD', toPeriod }) + + if (!currentPeriod) { + updatePeriod(defaultPeriod) + } + if (defaultPeriod === 'année') { periods.reverse() } diff --git a/source/reducers/rootReducer.js b/source/reducers/rootReducer.js index 3320856ee8..a5cc2cd370 100644 --- a/source/reducers/rootReducer.js +++ b/source/reducers/rootReducer.js @@ -17,6 +17,7 @@ import { combineReducers } from 'redux' import i18n from '../i18n' import inFranceAppReducer from './inFranceAppReducer' import storageReducer from './storageReducer' +import { findRuleByDottedName } from 'Engine/rules' import type { Action } from 'Types/ActionsTypes' function explainedVariable(state = null, { type, variableName = null }) { @@ -99,14 +100,14 @@ function conversationSteps( return state } -function updateSituation(situation, { fieldName, value, objectifs }) { - const removePreviousTarget = objectifs.includes(fieldName) - ? omit(objectifs) +function updateSituation(situation, { fieldName, value, config }) { + const removePreviousTarget = config.objectifs.includes(fieldName) + ? omit(config.objectifs) : identity return { ...removePreviousTarget(situation), [fieldName]: value } } -function updatePeriod(situation, { toPeriod, needConversion }) { +function updatePeriod(situation, { toPeriod, rules }) { const currentPeriod = situation['période'] if (currentPeriod === toPeriod) { return situation @@ -115,6 +116,11 @@ function updatePeriod(situation, { toPeriod, needConversion }) { throw new Error('Oups, changement de période invalide') } + const needConversion = Object.keys(situation).filter(dottedName => { + const rule = findRuleByDottedName(rules, dottedName) + return rule?.période === 'flexible' + }) + const updatedSituation = Object.entries(situation) .filter(([fieldName]) => needConversion.includes(fieldName)) .map(([fieldName, value]) => [ @@ -148,7 +154,7 @@ function simulation(state = null, action) { situation: updateSituation(state.situation, { fieldName: action.fieldName, value: action.value, - objectifs: state.config.objectifs + config: state.config }) } case 'UPDATE_PERIOD': @@ -156,7 +162,7 @@ function simulation(state = null, action) { ...state, situation: updatePeriod(state.situation, { toPeriod: action.toPeriod, - needConversion: action.needConversion + rules: action.rules }) } } @@ -195,19 +201,26 @@ const existingCompanyReducer = (state, action) => { } return newState } -export default reduceReducers( - existingCompanyReducer, - storageReducer, - combineReducers({ - sessionId: defaultTo(Math.floor(Math.random() * 1000000000000) + ''), - conversationSteps, - lang, - simulation, - explainedVariable, - previousSimulation: defaultTo(null), - currentExample, - situationBranch, - activeTargetInput, - inFranceApp: inFranceAppReducer - }) -) +export default (state, action) => { + // Enrich the action + if (action.type === 'UPDATE_PERIOD') { + action.rules = state.rules + } + return reduceReducers( + existingCompanyReducer, + storageReducer, + combineReducers({ + sessionId: defaultTo(Math.floor(Math.random() * 1000000000000) + ''), + conversationSteps, + lang, + rules: defaultTo(null), + simulation, + explainedVariable, + previousSimulation: defaultTo(null), + currentExample, + situationBranch, + activeTargetInput, + inFranceApp: inFranceAppReducer + }) + )(state, action) +} diff --git a/source/selectors/analyseSelectors.js b/source/selectors/analyseSelectors.js index 37fd56fc13..df197b4ee9 100644 --- a/source/selectors/analyseSelectors.js +++ b/source/selectors/analyseSelectors.js @@ -5,9 +5,7 @@ import { import { collectDefaults, disambiguateExampleSituation, - findRuleByDottedName, - rules as baseRulesEn, - rulesFr as baseRulesFr + findRuleByDottedName } from 'Engine/rules' import { analyse, analyseMany, parseAll } from 'Engine/traverse' import { @@ -38,18 +36,10 @@ import { mapOrApply } from '../utils' // create a "selector creator" that uses deep equal instead of === const createDeepEqualSelector = createSelectorCreator(defaultMemoize, equals) -/* - * - * We must here compute parsedRules, flatRules, analyse which contains both targets and cache objects - * - * - * */ - -export let flatRulesSelector = createSelector( - state => state.lang, - (state, props) => props && props.rules, - (lang, rules) => rules || (lang === 'en' ? baseRulesEn : baseRulesFr) -) +// We must here compute parsedRules, flatRules, analyse which contains both targets and cache objects +export let flatRulesSelector = (state, props) => { + return props?.rules || state?.rules +} export let parsedRulesSelector = createSelector( [flatRulesSelector], diff --git a/source/sites/mon-entreprise.fr/App.js b/source/sites/mon-entreprise.fr/App.js index 5feb02dfa9..179046680f 100644 --- a/source/sites/mon-entreprise.fr/App.js +++ b/source/sites/mon-entreprise.fr/App.js @@ -38,6 +38,7 @@ import Landing from './pages/Landing/Landing.js' import SocialSecurity from './pages/SocialSecurity' import ÉconomieCollaborative from './pages/ÉconomieCollaborative' import { constructLocalizedSitePath } from './sitePaths' +import { rules as baseRulesEn, rulesFr as baseRulesFr } from 'Engine/rules' if (process.env.NODE_ENV === 'production') { Raven.config( @@ -60,6 +61,7 @@ function InFranceRoute({ basename, language }) { setToSessionStorage('lang', language) }, [language]) const paths = constructLocalizedSitePath(language) + const rules = language === 'en' ? baseRulesEn : baseRulesFr return ( { - persistEverything()(store) + persistEverything({ except: ['rules'] })(store) persistSimulation(store) }} initialStore={{ ...retrievePersistedState(), - previousSimulation: retrievePersistedSimulation() + previousSimulation: retrievePersistedSimulation(), + rules }}> diff --git a/test/conversation.test.js b/test/conversation.test.js index a0c76c1ae7..cd585b9fa6 100644 --- a/test/conversation.test.js +++ b/test/conversation.test.js @@ -9,7 +9,7 @@ import { } from '../source/selectors/analyseSelectors' let baseState = { conversationSteps: { foldedSteps: [] }, - form: { conversation: { values: {} } } + simulation: { situation: {} } } describe('conversation', function() { @@ -48,14 +48,11 @@ describe('conversation', function() { rules = rawRules.map(enrichRule) let step1 = merge(baseState, { + rules, simulation: { config: { objectifs: ['startHere'] } } }) let step2 = reducers( - assocPath( - ['form', 'conversation', 'values'], - { top: { aa: '1' } }, - step1 - ), + assocPath(['simulation', 'situation'], { 'top . aa': '1' }, step1), { type: 'STEP_ACTION', name: 'fold', @@ -65,8 +62,8 @@ describe('conversation', function() { let step3 = reducers( assocPath( - ['form', 'conversation', 'values'], - { top: { bb: '1', aa: '1' } }, + ['simulation', 'situation'], + { 'top . aa': '1', 'top . bb': '1' }, step2 ), { @@ -86,7 +83,7 @@ describe('conversation', function() { step: 'top . bb' }) - expect(currentQuestionSelector(lastStep, { rules })).to.equal('top . bb') + expect(currentQuestionSelector(lastStep)).to.equal('top . bb') expect(lastStep.conversationSteps).to.have.property('foldedSteps') expect(lastStep.conversationSteps.foldedSteps).to.have.lengthOf(0) }) @@ -129,12 +126,13 @@ describe('conversation', function() { rules = rawRules.map(enrichRule) let step1 = merge(baseState, { + rules, simulation: { config: { objectifs: ['net'] } } }) - expect(currentQuestionSelector(step1, { rules })).to.equal('brut') + expect(currentQuestionSelector(step1)).to.equal('brut') let step2 = reducers( - assocPath(['form', 'conversation', 'values', 'brut'], '2300', step1), + assocPath(['simulation', 'situation', 'brut'], '2300', step1), { type: 'STEP_ACTION', name: 'fold', diff --git a/test/ficheDePaieSelector.test.js b/test/ficheDePaieSelector.test.js index c96c6c4b72..b3d445c912 100644 --- a/test/ficheDePaieSelector.test.js +++ b/test/ficheDePaieSelector.test.js @@ -3,7 +3,7 @@ import { expect } from 'chai' // $FlowFixMe import salariéConfig from 'Components/simulationConfigs/salarié.yaml' -import { getRuleFromAnalysis } from 'Engine/rules' +import { getRuleFromAnalysis, rules } from 'Engine/rules' import { analysisWithDefaultsSelector } from 'Selectors/analyseSelectors' import { analysisToCotisationsSelector, @@ -11,6 +11,7 @@ import { } from 'Selectors/ficheDePaieSelectors' let state = { + rules, simulation: { config: salariéConfig, situation: { diff --git a/test/real-rules.test.js b/test/real-rules.test.js index 715bd2f1b2..8274ad3772 100644 --- a/test/real-rules.test.js +++ b/test/real-rules.test.js @@ -10,6 +10,7 @@ let runExamples = (examples, rule) => examples.map(ex => { let runExample = exampleAnalysisSelector( { + rules, currentExample: { situation: ex.situation, dottedName: rule.dottedName From 7a965e6d9937da8ef40115a983c5c097c11ff399 Mon Sep 17 00:00:00 2001 From: Maxime Quandalle Date: Wed, 18 Sep 2019 15:40:45 +0200 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=90=9B=20Correction=20formatage=20de?= =?UTF-8?q?=20l'AnimatedValue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suppression de l'AnimatedValue pour l'objectif courant Simplification du code de TargetSelection Corrections CSS --- source/components/PeriodSwitch.js | 5 +- source/components/TargetSelection.css | 8 +- source/components/TargetSelection.js | 96 +++++++++------------ source/components/ui/AnimatedTargetValue.js | 37 ++++---- 4 files changed, 65 insertions(+), 81 deletions(-) diff --git a/source/components/PeriodSwitch.js b/source/components/PeriodSwitch.js index c7f6670596..e0225cc412 100644 --- a/source/components/PeriodSwitch.js +++ b/source/components/PeriodSwitch.js @@ -11,15 +11,12 @@ export default function PeriodSwitch() { state => state.simulation?.config?.situation?.période || 'année' ) const currentPeriod = situation.période - let periods = ['mois', 'année'] + let periods = ['année', 'mois'] const updatePeriod = toPeriod => dispatch({ type: 'UPDATE_PERIOD', toPeriod }) if (!currentPeriod) { updatePeriod(defaultPeriod) } - if (defaultPeriod === 'année') { - periods.reverse() - } return ( diff --git a/source/components/TargetSelection.css b/source/components/TargetSelection.css index 63783d9ebc..f9ef880718 100644 --- a/source/components/TargetSelection.css +++ b/source/components/TargetSelection.css @@ -106,8 +106,8 @@ text-decoration: none; } -#targetSelection .targetInputOrValue > :not(.targetInput):not(.attractClick) { - margin: 0.2rem 0.6rem; +#targetSelection .targetInputOrValue > :not(.targetInput) { + margin: 0 0.6rem; } #targetSelection input { @@ -136,11 +136,9 @@ #targetSelection .targetInputBottomBorder { margin: 0; - padding: 0; + padding: 0 2px; height: 0; overflow: hidden; - position: relative; - top: -6px; } #targetSelection .editableTarget + .targetInputBottomBorder { diff --git a/source/components/TargetSelection.js b/source/components/TargetSelection.js index e781539d17..7f7f9c0ffa 100644 --- a/source/components/TargetSelection.js +++ b/source/components/TargetSelection.js @@ -1,13 +1,11 @@ import { updateSituation } from 'Actions/actions' import { T } from 'Components' import InputSuggestions from 'Components/conversation/InputSuggestions' -import PercentageField from 'Components/PercentageField' import PeriodSwitch from 'Components/PeriodSwitch' import RuleLink from 'Components/RuleLink' import { ThemeColoursContext } from 'Components/utils/withColours' import withSitePaths from 'Components/utils/withSitePaths' import { encodeRuleName } from 'Engine/rules' -import { serialiseUnit } from 'Engine/units' import { isEmpty, isNil } from 'ramda' import React, { useEffect, useState, useContext } from 'react' import emoji from 'react-easy-emoji' @@ -221,36 +219,9 @@ export const formatCurrency = (value, language) => { .replace(/^€/, '€ ') } -let clickableField = Input => - function WrappedClickableField({ value, ...otherProps }) { - const colors = useContext(ThemeColoursContext) - const { language } = useTranslation().i18n - return ( - <> - - - - {formatCurrency(value, language)} - - - ) - } - -let unitToComponent = { - '€': clickableField(CurrencyInput), - '%': clickableField(PercentageField) -} - let TargetInputOrValue = ({ target, isActiveInput, isSmallTarget }) => { const { i18n } = useTranslation() + const colors = useContext(ThemeColoursContext) const dispatch = useDispatch() const situationValue = useSituationValue(target.dottedName) const targetWithValue = useTarget(target.dottedName) @@ -259,39 +230,50 @@ let TargetInputOrValue = ({ target, isActiveInput, isSmallTarget }) => { state => analysisWithDefaultsSelector(state)?.cache.inversionFail ) const blurValue = inversionFail && !isActiveInput && value - const Component = unitToComponent[serialiseUnit(target.unit)] + return ( {target.question ? ( - - dispatch(updateSituation(target.dottedName, evt.target.value)) - } - onBlur={event => event.preventDefault()} - // We use onMouseDown instead of onClick because that's when the browser moves the cursor - onMouseDown={() => { - if (isSmallTarget) return - dispatch({ - type: 'SET_ACTIVE_TARGET_INPUT', - name: target.dottedName - }) - // TODO: This shouldn't be necessary: we don't need to recalculate the situation - // when the user just focus another field. Removing this line is almost working - // however there is a weird bug in the selection of the next question. - if (value) { - dispatch(updateSituation(target.dottedName, '' + value)) + <> + {!isActiveInput && } + + dispatch(updateSituation(target.dottedName, evt.target.value)) } - }} - {...(isActiveInput ? { autoFocus: true } : {})} - language={i18n.language} - /> + onBlur={event => event.preventDefault()} + // We use onMouseDown instead of onClick because that's when the browser moves the cursor + onMouseDown={() => { + if (isSmallTarget) return + dispatch({ + type: 'SET_ACTIVE_TARGET_INPUT', + name: target.dottedName + }) + // TODO: This shouldn't be necessary: we don't need to recalculate the situation + // when the user just focus another field. Removing this line is almost working + // however there is a weird bug in the selection of the next question. + if (value) { + dispatch(updateSituation(target.dottedName, '' + value)) + } + }} + {...(isActiveInput ? { autoFocus: true } : {})} + language={i18n.language} + /> + + {formatCurrency(value, i18n.language)} + + ) : ( {Number.isNaN(value) ? '—' : formatCurrency(value, i18n.language)} diff --git a/source/components/ui/AnimatedTargetValue.js b/source/components/ui/AnimatedTargetValue.js index 0efd1eae5a..8a90d9a640 100644 --- a/source/components/ui/AnimatedTargetValue.js +++ b/source/components/ui/AnimatedTargetValue.js @@ -1,29 +1,36 @@ /* @flow */ -import React, { useEffect, useState } from 'react' +import React, { useRef } from 'react' import ReactCSSTransitionGroup from 'react-addons-css-transition-group' import { useTranslation } from 'react-i18next' import './AnimatedTargetValue.css' -import { formatCurrency } from 'Components/TargetSelection' type Props = { value: ?number } +function formatDifference(difference, language) { + const prefix = difference > 0 ? '+' : '' + const formatedValue = Intl.NumberFormat(language, { + style: 'currency', + currency: 'EUR', + maximumFractionDigits: 0, + minimumFractionDigits: 0 + }).format(difference) + return prefix + formatedValue +} + export default function AnimatedTargetValue({ value, children }: Props) { - const [difference, setDifference] = useState(0) - const [previousValue, setPreviousValue] = useState() - useEffect(() => { - if (previousValue === value || Number.isNaN(value)) { - return - } - setDifference((value || 0) - (previousValue || 0)) - setPreviousValue(value) - }, [previousValue, value]) - const { i18n } = useTranslation() + const previousValue = useRef() + const { language } = useTranslation().i18n - const formattedDifference = formatCurrency(difference, i18n.language) + const difference = + previousValue.current === value || Number.isNaN(value) + ? null + : (value || 0) - (previousValue.current || 0) + previousValue.current = value const shouldDisplayDifference = - Math.abs(difference) > 1 && value != null && !Number.isNaN(value) + difference !== null && Math.abs(difference) > 1 + return ( <> @@ -33,7 +40,7 @@ export default function AnimatedTargetValue({ value, children }: Props) { color: difference > 0 ? 'chartreuse' : 'red', pointerEvents: 'none' }}> - {(difference > 0 ? '+' : '') + formattedDifference} + {formatDifference(difference, language)} )}{' '} {children} From ffd65ae613b70874ea3448909ca3459d42c501a0 Mon Sep 17 00:00:00 2001 From: Maxime Quandalle Date: Mon, 23 Sep 2019 09:44:22 +0200 Subject: [PATCH 7/7] =?UTF-8?q?Ajout=20d'un=20param=C3=A8tre=20explicite?= =?UTF-8?q?=20"rules"=20au=20reducer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- source/actions/actions.js | 5 +++++ source/components/PeriodSwitch.js | 6 +++--- source/reducers/rootReducer.js | 25 +++++++++++-------------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/source/actions/actions.js b/source/actions/actions.js index 6f330e6237..1fe6bf54a0 100644 --- a/source/actions/actions.js +++ b/source/actions/actions.js @@ -67,6 +67,11 @@ export const updateSituation = (fieldName, value) => ({ 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/PeriodSwitch.js b/source/components/PeriodSwitch.js index e0225cc412..34bba977d8 100644 --- a/source/components/PeriodSwitch.js +++ b/source/components/PeriodSwitch.js @@ -1,3 +1,4 @@ +import { updatePeriod } from 'Actions/actions' import React from 'react' import { Trans } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' @@ -12,10 +13,9 @@ export default function PeriodSwitch() { ) const currentPeriod = situation.période let periods = ['année', 'mois'] - const updatePeriod = toPeriod => dispatch({ type: 'UPDATE_PERIOD', toPeriod }) if (!currentPeriod) { - updatePeriod(defaultPeriod) + dispatch(updatePeriod(defaultPeriod)) } return ( @@ -27,7 +27,7 @@ export default function PeriodSwitch() { name="période" type="radio" value={period} - onChange={() => updatePeriod(period)} + onChange={() => dispatch(updatePeriod(period))} checked={currentPeriod === period} /> diff --git a/source/reducers/rootReducer.js b/source/reducers/rootReducer.js index a5cc2cd370..de2612ee31 100644 --- a/source/reducers/rootReducer.js +++ b/source/reducers/rootReducer.js @@ -135,7 +135,7 @@ function updatePeriod(situation, { toPeriod, rules }) { } } -function simulation(state = null, action) { +function simulation(state = null, action, rules) { if (action.type === 'SET_SIMULATION') { const { config, url } = action return { config, url, hiddenControls: [], situation: {} } @@ -162,7 +162,7 @@ function simulation(state = null, action) { ...state, situation: updatePeriod(state.situation, { toPeriod: action.toPeriod, - rules: action.rules + rules: rules }) } } @@ -201,26 +201,23 @@ const existingCompanyReducer = (state, action) => { } return newState } -export default (state, action) => { - // Enrich the action - if (action.type === 'UPDATE_PERIOD') { - action.rules = state.rules - } - return reduceReducers( - existingCompanyReducer, - storageReducer, + +export default reduceReducers( + existingCompanyReducer, + storageReducer, + (state, action) => combineReducers({ sessionId: defaultTo(Math.floor(Math.random() * 1000000000000) + ''), conversationSteps, lang, rules: defaultTo(null), - simulation, explainedVariable, + // We need to access the `rules` in the simulation reducer + simulation: (a, b) => simulation(a, b, state.rules), previousSimulation: defaultTo(null), currentExample, situationBranch, activeTargetInput, inFranceApp: inFranceAppReducer - }) - )(state, action) -} + })(state, action) +)