Merge pull request #671 from betagouv/remove-redux-form

Suppression de redux-form
pull/696/head
Maxime Quandalle 2019-09-23 12:47:32 +02:00 committed by GitHub
commit f3e79f4251
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 651 additions and 812 deletions

View File

@ -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

View File

@ -57,8 +57,6 @@
"react-virtualized-select": "^3.1.3",
"reduce-reducers": "^0.1.2",
"redux": "^3.7.2",
"redux-batched-actions": "^0.4.1",
"redux-form": "^8.2.0",
"redux-thunk": "^2.3.0",
"regenerator-runtime": "^0.13.3",
"reselect": "^4.0.0",
@ -125,6 +123,7 @@
"eslint-config-prettier": "^4.0.0",
"eslint-plugin-flowtype": "^3.2.1",
"eslint-plugin-react": "^7.12.4",
"eslint-plugin-react-hooks": "^2.0.1",
"express": "^4.16.3",
"file-loader": "^1.1.11",
"flow-bin": "^0.92.0",

View File

@ -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

View File

@ -7,8 +7,6 @@ import type {
SetSimulationConfigAction,
SetSituationBranchAction
} from 'Types/ActionsTypes'
// $FlowFixMe
import { change, reset } from 'redux-form'
import { deletePersistedSimulation } from '../storage/persistSimulation'
import type { Thunk } from 'Types/ActionsTypes'
@ -18,18 +16,19 @@ export const resetSimulation = () => (dispatch: any => void): void => {
type: 'RESET_SIMULATION'
}: ResetSimulationAction)
)
dispatch(reset('conversation'))
}
export const goToQuestion = (question: string): StepAction => ({
type: 'STEP_ACTION',
name: 'unfold',
step: question
})
export const validateStepWithValue = (
dottedName,
value: any
): Thunk<StepAction> => dispatch => {
dispatch(change('conversation', dottedName, value))
dispatch(updateSituation(dottedName, value))
dispatch({
type: 'STEP_ACTION',
name: 'fold',
@ -62,6 +61,17 @@ export const deletePreviousSimulation = () => (
deletePersistedSimulation()
}
export const updateSituation = (fieldName, value) => ({
type: 'UPDATE_SITUATION',
fieldName,
value
})
export const updatePeriod = toPeriod => ({
type: 'UPDATE_PERIOD',
toPeriod
})
// $FlowFixMe
export function setExample(name, situation, dottedName) {
return { type: 'SET_EXAMPLE', name, situation, dottedName }

View File

@ -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

View File

@ -1,5 +1,5 @@
import classnames from 'classnames'
import React, { useEffect, useRef, useState } from 'react'
import React, { useRef, useState } from 'react'
import NumberFormat from 'react-number-format'
import { debounce } from '../../utils'
import './CurrencyInput.css'
@ -22,23 +22,28 @@ let currencyFormat = language => ({
})
export default function CurrencyInput({
value: valueArg,
value: valueProp = '',
debounce: debounceTimeout,
currencySymbol = '€',
onChange,
language,
className,
...forwardedProps
}) {
const [currentValue, setCurrentValue] = useState(valueArg)
const [initialValue] = useState(valueArg)
// When the component is rendered with a new "value" argument, we update our local state
useEffect(() => {
setCurrentValue(valueArg)
}, [valueArg])
const nextValue = useRef(null)
const [initialValue, setInitialValue] = useState(valueProp)
const [currentValue, setCurrentValue] = useState(valueProp)
const onChangeDebounced = useRef(
debounceTimeout ? debounce(debounceTimeout, onChange) : onChange
)
// We need some mutable reference because the <NumberFormat /> component doesn't provide
// the DOM `event` in its custom `onValueChange` handler
const nextValue = useRef(null)
// When the component is rendered with a new "value" prop, we reset our local state
if (valueProp !== initialValue) {
setCurrentValue(valueProp)
setInitialValue(valueProp)
}
const handleChange = event => {
// Only trigger the `onChange` event if the value has changed -- and not
@ -61,20 +66,18 @@ export default function CurrencyInput({
thousandSeparator,
decimalSeparator
} = currencyFormat(language)
// We display negative numbers iff this was the provided value (but we allow the user to enter them)
// We display negative numbers iff this was the provided value (but we disallow the user to enter them)
const valueHasChanged = currentValue !== initialValue
// Autogrow the input
const valueLength = (currentValue || '').toString().length
const valueLength = currentValue.toString().length
const width = `${5 + (valueLength - 5) * 0.75}em`
return (
<div
className={classnames(className, 'currencyInput__container')}
{...(valueLength > 5
? { style: { width: `${5 + (valueLength - 5) * 0.75}em` } }
: {})}>
{isCurrencyPrefixed && '€'}
{...(valueLength > 5 ? { style: { width } } : {})}>
{!currentValue && isCurrencyPrefixed && currencySymbol}
<NumberFormat
{...forwardedProps}
thousandSeparator={thousandSeparator}
@ -82,12 +85,15 @@ export default function CurrencyInput({
allowNegative={!valueHasChanged}
className="currencyInput__input"
inputMode="numeric"
prefix={
isCurrencyPrefixed && currencySymbol ? `${currencySymbol} ` : ''
}
onValueChange={({ value }) => {
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 && <>&nbsp;</>}
</div>

View File

@ -24,10 +24,18 @@ describe('CurrencyInput', () => {
})
it('should separate thousand groups', () => {
const input1 = getInput(<CurrencyInput value={1000} language="fr" />)
const input2 = getInput(<CurrencyInput value={1000} language="en" />)
const input3 = getInput(<CurrencyInput value={1000.5} language="en" />)
const input4 = getInput(<CurrencyInput value={1000000} language="en" />)
const input1 = getInput(
<CurrencyInput value={1000} language="fr" currencySymbol={''} />
)
const input2 = getInput(
<CurrencyInput value={1000} language="en" currencySymbol={''} />
)
const input3 = getInput(
<CurrencyInput value={1000.5} language="en" currencySymbol={''} />
)
const input4 = getInput(
<CurrencyInput value={1000000} language="en" currencySymbol={''} />
)
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(
<CurrencyInput onChange={onChange} debounce={1000} />
<CurrencyInput onChange={onChange} debounce={1000} currencySymbol={''} />
)
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(<CurrencyInput value={1} />)
const input = getInput(<CurrencyInput value={1} language="fr" />)
expect(input.instance().value).to.equal('1')
})
it('should update its value if the value prop changes', () => {
const component = mount(<CurrencyInput value={1} />)
const component = mount(<CurrencyInput value={1} language="fr" />)
component.setProps({ value: 2 })
expect(component.find('input').instance().value).to.equal('2')
})

View File

@ -1,23 +1,22 @@
import React, { useState } from 'react'
import React, { useCallback, useState } from 'react'
import './PercentageField.css'
export default function PercentageField({ input, debounce }) {
const [localValue, setLocalValue] = useState(input?.value)
const debouncedOnChange = debounce
? debounce(debounce, input.onChange)
: input.onChange
const onChange = value => {
setLocalValue(value)
debouncedOnChange(value)
}
export default function PercentageField({ onChange, value, debounce }) {
const [localValue, setLocalValue] = useState(value)
const debouncedOnChange = useCallback(
debounce ? debounce(debounce, onChange) : onChange,
[debounce, onChange]
)
return (
<div>
<input
className="range"
onChange={e => onChange(e.target.value)}
onChange={e => {
const value = e.target.value
setLocalValue(value)
debouncedOnChange(value)
}}
type="range"
value={localValue}
name="volume"

View File

@ -1,116 +1,41 @@
import { findRuleByDottedName, nestedSituationToPathMap } from 'Engine/rules'
import { compose, filter, map, toPairs } from 'ramda'
import React, { useEffect } from 'react'
import { updatePeriod } from 'Actions/actions'
import React from 'react'
import { Trans } from 'react-i18next'
import { connect } from 'react-redux'
import { batchActions } from 'redux-batched-actions'
import { change, Field, reduxForm } from 'redux-form'
import {
flatRulesSelector,
situationSelector,
situationsWithDefaultsSelector
} from 'Selectors/analyseSelectors'
import { useDispatch, useSelector } from 'react-redux'
import { situationSelector } from 'Selectors/analyseSelectors'
import './PeriodSwitch.css'
export default compose(
reduxForm({
form: 'conversation',
destroyOnUnmount: false
}),
connect(
state => {
let situation = situationsWithDefaultsSelector(state)
if (Array.isArray(situation)) {
situation = situation[0]
}
return {
rules: flatRulesSelector(state),
situation: nestedSituationToPathMap(situationSelector(state)),
initialPériode: situation.période
}
},
dispatch => ({
batchPeriodChange: actions => dispatch(batchActions(actions))
})
export default function PeriodSwitch() {
const dispatch = useDispatch()
const situation = useSelector(situationSelector)
const defaultPeriod = useSelector(
state => state.simulation?.config?.situation?.période || 'année'
)
)(function PeriodSwitch({
situation,
rules,
batchPeriodChange,
initialPériode
}) {
useEffect(() => {
!situation.période &&
updateSituation(
initialPériode || 'année',
batchPeriodChange,
situation,
rules
)
return
})
const currentPeriod = situation.période
let periods = ['année', 'mois']
if (!currentPeriod) {
dispatch(updatePeriod(defaultPeriod))
}
return (
<span id="PeriodSwitch">
<span className="base ui__ small toggle">
<label>
<Field
name="période"
component="input"
type="radio"
value="année"
onChange={() =>
updateSituation('année', batchPeriodChange, situation, rules)
}
/>
<span>
<Trans>année</Trans>
</span>
</label>
<label>
<Field
name="période"
component="input"
type="radio"
value="mois"
onChange={() =>
updateSituation('mois', batchPeriodChange, situation, rules)
}
/>
<span>
<Trans>mois</Trans>
</span>
</label>
{periods.map(period => (
<label key={period}>
<input
name="période"
type="radio"
value={period}
onChange={() => dispatch(updatePeriod(period))}
checked={currentPeriod === period}
/>
<span>
<Trans>{period}</Trans>
</span>
</label>
))}
</span>
</span>
)
})
let updateSituation = (toPeriod, batchPeriodChange, situation, rules) => {
let needConvertion = filter(([dottedName, value]) => {
let rule = findRuleByDottedName(rules, dottedName)
return value != null && rule?.période === 'flexible'
})(toPairs(situation))
let actions = [
...map(
([dottedName, value]) =>
change(
'conversation',
dottedName,
Math.round(
situation.période === 'mois' && toPeriod === 'année'
? value * 12
: situation.période === 'année' && toPeriod === 'mois'
? value / 12
: (function() {
throw new Error('Oups, changement de période invalide')
})()
) + ''
),
needConvertion
),
change('conversation', 'période', toPeriod)
]
batchPeriodChange(actions)
}

View File

@ -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 }) => (
<section>
<h2>
<Trans>
{period === 'mois'
? 'Fiche de paie mensuelle'
: 'Détail annuel des cotisations'}
</Trans>
</h2>
<PaySlip />
</section>
))
function PaySlipSection() {
const period = usePeriod()
return (
<section>
<h2>
<Trans>
{period === 'mois'
? 'Fiche de paie mensuelle'
: 'Détail annuel des cotisations'}
</Trans>
</h2>
<PaySlip />
</section>
)
}
const DistributionSection = () => (
<section>

View File

@ -2,7 +2,7 @@ import withSitePaths from 'Components/utils/withSitePaths'
import { encodeRuleName } from 'Engine/rules.js'
import Fuse from 'fuse.js'
import { compose, pick, sortBy } from 'ramda'
import React, { useRef, useState } from 'react'
import React, { useMemo, useRef, useState } from 'react'
import Highlighter from 'react-highlight-words'
import { useTranslation } from 'react-i18next'
import { Link, Redirect } from 'react-router-dom'
@ -19,28 +19,23 @@ function SearchBar({
const [inputValue, setInputValue] = useState(null)
const [selectedOption, setSelectedOption] = useState(null)
const inputElementRef = useRef()
const fuse = useRef()
// This operation is expensive, we don't want to do it everytime we re-render, so we cache its result
const fuse = useMemo(() => {
const list = rules.map(
pick(['title', 'espace', 'description', 'name', 'dottedName'])
)
const options = {
keys: [
{ name: 'name', weight: 0.3 },
{ name: 'title', weight: 0.3 },
{ name: 'espace', weight: 0.2 },
{ name: 'description', weight: 0.2 }
]
}
return new Fuse(list, options)
}, [rules])
const { i18n } = useTranslation()
const options = {
keys: [
{ name: 'name', weight: 0.3 },
{ name: 'title', weight: 0.3 },
{ name: 'espace', weight: 0.2 },
{ name: 'description', weight: 0.2 }
]
}
if (!fuse.current) {
// This operation is expensive, we don't want to do it everytime we re-render, so we cache its result in a reference
fuse.current = new Fuse(
rules.map(pick(['title', 'espace', 'description', 'name', 'dottedName'])),
options
)
}
const handleChange = selectedOption => {
setSelectedOption(selectedOption)
}
const renderOption = ({ title, dottedName }) => (
<span>
<Highlighter searchWords={[inputValue]} textToHighlight={title} />
@ -49,7 +44,7 @@ function SearchBar({
</span>
</span>
)
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({
<>
<Select
value={selectedOption && selectedOption.dottedName}
onChange={handleChange}
onInputChange={inputValue => setInputValue(inputValue)}
onChange={setSelectedOption}
onInputChange={setInputValue}
valueKey="dottedName"
labelKey="title"
options={rules}

View File

@ -14,6 +14,13 @@ export default compose(
)(function SearchButton({ flatRules, invisibleButton }) {
const [visible, setVisible] = useState(false)
useEffect(() => {
const handleKeyDown = e => {
if (!(e.ctrlKey && e.key === 'k')) return
setVisible(true)
e.preventDefault()
e.stopPropagation()
return false
}
window.addEventListener('keydown', handleKeyDown)
return () => {
@ -21,14 +28,6 @@ export default compose(
}
}, [])
const handleKeyDown = e => {
if (!(e.ctrlKey && e.key === 'k')) return
setVisible(true)
e.preventDefault()
e.stopPropagation()
return false
}
const close = () => setVisible(false)
return visible ? (

View File

@ -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 .targetInputOrValue > :not(.targetInput) {
margin: 0 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,25 @@
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 2px;
height: 0;
overflow: hidden;
}
#targetSelection .editableTarget + .targetInputBottomBorder {
border-bottom: 1px dashed #ffffff91;
}
#targetSelection .unit {
margin-left: 0.4em;
font-size: 110%;

View File

@ -1,104 +1,75 @@
import classNames from 'classnames'
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 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, propEq } 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 { connect } from 'react-redux'
import { withRouter } from 'react-router'
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'
import CurrencyInput from './CurrencyInput/CurrencyInput'
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: (field, name) =>
dispatch(change('conversation', field, name)),
setActiveInput: name =>
dispatch({ type: 'SET_ACTIVE_TARGET_INPUT', name })
})
),
memo
)(function TargetSelection({
secondaryObjectives,
analysis,
getTargetValue,
setFormValue,
colours,
activeInput,
setActiveInput,
objectifs
}) {
export default function TargetSelection() {
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()
const colours = useContext(ThemeColoursContext)
const targets =
analysis?.targets.filter(
t =>
!secondaryObjectives.includes(t.dottedName) &&
t.dottedName !== 'contrat salarié . aides employeur'
) || []
useEffect(() => {
let targets = getTargets()
// Initialize defaultValue for target that can't be computed
// TODO: this logic shouldn't be here
targets
.filter(
target =>
(!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
)
)
})
if (initialRender) {
setInitialRender(false)
}
setInitialRender(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const getTargets = () => {
if (!analysis) return []
return analysis.targets.filter(
t =>
!secondaryObjectives.includes(t.dottedName) &&
t.dottedName !== 'contrat salarié . aides employeur'
)
}
let targets = getTargets()
return (
<div id="targetSelection">
{(typeof objectifs[0] === 'string' ? [{ objectifs }] : objectifs).map(
@ -127,9 +98,6 @@ export default compose(
}}>
<Targets
{...{
activeInput,
setActiveInput,
setFormValue,
targets: targets.filter(({ dottedName }) =>
groupTargets.includes(dottedName)
),
@ -142,15 +110,9 @@ export default compose(
)}
</div>
)
})
}
let Targets = ({
activeInput,
setActiveInput,
setFormValue,
targets,
initialRender
}) => (
let Targets = ({ targets, initialRender }) => (
<div>
<ul className="targets">
{targets
@ -166,11 +128,7 @@ let Targets = ({
key={target.dottedName}
initialRender={initialRender}
{...{
target,
setFormValue,
activeInput,
setActiveInput,
targets
target
}}
/>
))}
@ -178,18 +136,13 @@ let Targets = ({
</div>
)
const Target = ({
target,
activeInput,
const Target = ({ target, initialRender }) => {
const activeInput = useSelector(state => state.activeTargetInput)
const dispatch = useDispatch()
targets,
setActiveInput,
setFormValue,
initialRender
}) => {
const isActiveInput = activeInput === target.dottedName
const isSmallTarget =
!target.question || !target.formule || isEmpty(target.formule)
return (
<li
key={target.name}
@ -200,8 +153,7 @@ const Target = ({
<Header
{...{
target,
isActiveInput: activeInput === target.dottedName
isActiveInput
}}
/>
{isSmallTarget && (
@ -216,19 +168,17 @@ const Target = ({
<TargetInputOrValue
{...{
target,
targets,
activeInput,
setActiveInput,
setFormValue
isActiveInput,
isSmallTarget
}}
/>
</div>
{activeInput === target.dottedName && (
{isActiveInput && (
<Animate.fromTop>
<InputSuggestions
suggestions={target.suggestions}
onFirstClick={value => {
setFormValue(target.dottedName, '' + value)
dispatch(updateSituation(target.dottedName, '' + value))
}}
rulePeriod={target.période}
colouredBackground={true}
@ -256,127 +206,100 @@ let Header = withSitePaths(({ target, sitePaths }) => {
)
})
let CurrencyField = withColours(props => {
return (
<CurrencyInput
style={{
color: props.colours.textColour,
borderColor: props.colours.textColour
}}
debounce={600}
className="targetInput"
value={props.input.value}
{...props.input}
{...props}
/>
)
})
let DebouncedPercentageField = props => (
<PercentageField debounce={600} {...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, isActiveInput, isSmallTarget }) => {
const { i18n } = useTranslation()
const colors = useContext(ThemeColoursContext)
const dispatch = useDispatch()
const situationValue = useSituationValue(target.dottedName)
const targetWithValue = useTarget(target.dottedName)
const value = targetWithValue?.nodeValue?.toFixed(0)
const inversionFail = useSelector(
state => analysisWithDefaultsSelector(state)?.cache.inversionFail
)
const blurValue = inversionFail && !isActiveInput && value
let TargetInputOrValue = ({
target,
targets,
activeInput,
setActiveInput,
firstStepCompleted
}) => {
const {
i18n: { language }
} = useTranslation()
let inputIsActive = activeInput === target.dottedName
return (
<span className="targetInputOrValue">
{inputIsActive || !target.formule || isEmpty(target.formule) ? (
<Field
name={target.dottedName}
onBlur={event => event.preventDefault()}
component={
{ '€': CurrencyField, '%': DebouncedPercentageField }[
serialiseUnit(target.unit)
]
}
{...(inputIsActive ? { autoFocus: true } : {})}
language={language}
/>
<span
className="targetInputOrValue"
style={blurValue ? { filter: 'blur(3px)' } : {}}>
{target.question ? (
<>
{!isActiveInput && <AnimatedTargetValue value={value} />}
<CurrencyInput
style={{
color: colors.textColour,
borderColor: colors.textColour
}}
debounce={600}
name={target.dottedName}
value={situationValue || value}
className={
isActiveInput || isNil(value) ? 'targetInput' : 'editableTarget'
}
onChange={evt =>
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 ? { autoFocus: true } : {})}
language={i18n.language}
/>
<span className="targetInputBottomBorder">
{formatCurrency(value, i18n.language)}
</span>
</>
) : (
<TargetValue
{...{
targets,
target,
activeInput,
setActiveInput,
firstStepCompleted
}}
/>
<span>
{Number.isNaN(value) ? '—' : formatCurrency(value, i18n.language)}
</span>
)}
{target.dottedName.includes('rémunération . total') && <AidesGlimpse />}
</span>
)
}
const TargetValue = connect(
state => ({
blurValue: analysisWithDefaultsSelector(state)?.cache.inversionFail
}),
dispatch => ({
setFormValue: (field, name) => dispatch(change('conversation', field, name))
})
)(function TargetValue({
targets,
target,
blurValue,
setFormValue,
activeInput,
setActiveInput
}) {
let targetWithValue = targets?.find(propEq('dottedName', target.dottedName)),
value = targetWithValue && targetWithValue.nodeValue
const showField = value => () => {
if (!target.question) return
if (value != null && !Number.isNaN(value))
setFormValue(target.dottedName, Math.round(value) + '')
if (activeInput) setFormValue(activeInput, '')
setActiveInput(target.dottedName)
}
return (
<div
className={classNames({
editable: target.question,
attractClick: target.question && isNil(target.nodeValue)
})}
style={blurValue ? { filter: 'blur(3px)' } : {}}
{...(target.question ? { tabIndex: 0 } : {})}
onClick={showField(value)}
onFocus={showField(value)}>
<AnimatedTargetValue value={value} />
</div>
)
})
const AidesGlimpse = compose(
withRouter,
connect(state => ({ analysis: analysisWithDefaultsSelector(state) }))
)(({ analysis: { targets }, colours }) => {
const aides = targets?.find(
t => t.dottedName === 'contrat salarié . aides employeur'
)
if (!aides || !aides.nodeValue) return null
function AidesGlimpse() {
const aides = useTarget('contrat salarié . aides employeur')
if (!aides?.nodeValue) return null
return (
<Animate.appear>
<div className="aidesGlimpse">
<RuleLink {...aides}>
-{' '}
<strong>
<AnimatedTargetValue value={aides.nodeValue} />
<AnimatedTargetValue value={aides.nodeValue}>
<span>{formatCurrency(aides.nodeValue)}</span>
</AnimatedTargetValue>
</strong>{' '}
<T>d'aides</T> {emoji(aides.icons)}
</RuleLink>
</div>
</Animate.appear>
)
})
}

View File

@ -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 (
<div id="targets">

View File

@ -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 ? (
<>
<Aide />
@ -98,4 +86,4 @@ export default compose(
</p>
</div>
)
})
}

View File

@ -1,9 +1,9 @@
import { updateSituation } from 'Actions/actions'
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, useSelector } from 'react-redux'
import { situationSelector } from 'Selectors/analyseSelectors'
/*
This higher order component wraps "Form" components (e.g. Question.js), that represent user inputs,
@ -13,47 +13,45 @@ 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 situation = useSelector(situationSelector)
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 = value => {
dispatch(updateSituation(fieldName, normalize(value)))
}
const format = x => (unit === '%' && x ? +(x * 100).toFixed(2) : x)
const normalize = x => (unit === '%' ? x / 100 : x)
const value = format(situation[fieldName])
return (
<div className={classNames('step', formType)}>
<div className="unfoldedHeader">
<h3>
{props.question}{' '}
{!inversion && <Explicable dottedName={fieldName} />}
{question} {!inversion && <Explicable dottedName={fieldName} />}
</h3>
</div>
<fieldset>
<Field component={RenderField} name={fieldName} {...stepProps} />
<RenderField
name={fieldName}
value={value}
setFormValue={setFormValue}
submit={submit}
format={format}
unit={unit}
{...otherProps}
/>
</fieldset>
</div>
)
})
}

View File

@ -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 (
<>
<div css="width: 100%">
<InputSuggestions
suggestions={suggestions}
onFirstClick={value => setFormValue('' + value)}
onFirstClick={value => {
setFormValue(format(value))
}}
onSecondClick={() => submit('suggestion')}
rulePeriod={rulePeriod}
/>
@ -46,12 +43,11 @@ export default compose(
<div className="answer">
<input
type="text"
key={input.value}
key={value}
autoFocus
defaultValue={input.value}
onChange={e => {
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(
)}
</label>
)}
<SendButton {...{ disabled: submitDisabled, error, submit }} />
<SendButton {...{ disabled: value === undefined, submit }} />
</div>
{inputError && <span className="step-input-error">{error}</span>}
</>
)
})

View File

@ -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(
})}
</div>
)
})
}

View File

@ -1,7 +1,7 @@
import classnames from 'classnames'
import withColours from 'Components/utils/withColours'
import { compose, is } from 'ramda'
import React from 'react'
import React, { useCallback, useState } from 'react'
import { Trans } from 'react-i18next'
import Explicable from './Explicable'
import { FormDecorator } from './FormDecorator'
@ -29,46 +29,40 @@ import SendButton from './SendButton'
export default compose(
FormDecorator('question'),
withColours
)(function Question(props) {
let {
choices,
submit,
colours,
meta: { pristine }
} = props
)(function Question({
choices,
submit,
colours,
name,
setFormValue,
value: currentValue
}) {
const [touched, setTouched] = useState(false)
const onChange = useCallback(
value => {
setFormValue(value)
setTouched(true)
},
[setFormValue]
)
const renderBinaryQuestion = () => {
let {
input, // vient de redux-form
submit,
choices,
setFormValue,
colours
} = props
return (
<div className="binaryQuestionList">
{choices.map(({ value, label }) => (
<RadioLabel
key={value}
{...{ value, label, input, submit, colours, setFormValue }}
{...{ value, label, currentValue, submit, colours, onChange }}
/>
))}
</div>
)
}
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 (
<ul css="width: 100%">
@ -78,11 +72,11 @@ export default compose(
{...{
value: 'non',
label: 'Aucun',
input,
currentValue,
submit,
colours,
dottedName: null,
setFormValue
onChange
}}
/>
</li>
@ -101,10 +95,10 @@ export default compose(
value: relativeDottedName(dottedName),
label: title,
dottedName,
input,
currentValue,
submit,
colours,
setFormValue
onChange
}}
/>
</li>
@ -123,7 +117,7 @@ export default compose(
{choiceElements}
<SendButton
{...{
disabled: pristine,
disabled: !touched,
colours,
error: false,
submit
@ -143,14 +137,15 @@ let RadioLabel = props => (
const RadioLabelContent = compose(withColours)(function RadioLabelContent({
value,
label,
input,
currentValue,
onChange,
submit
}) {
let labelStyle = value === '_' ? { fontWeight: 'bold' } : null,
selected = value === input.value
selected = value === currentValue
const click = value => () => {
if (input.value == value) submit('dblClick')
if (currentValue == value) submit('dblClick')
}
return (
@ -161,10 +156,10 @@ const RadioLabelContent = compose(withColours)(function RadioLabelContent({
<Trans i18nKey={`radio_${label}`}>{label}</Trans>
<input
type="radio"
{...input}
onClick={click(value)}
value={value}
checked={value === input.value ? 'checked' : ''}
onChange={evt => onChange(evt.target.value)}
checked={value === currentValue ? 'checked' : ''}
/>
</label>
)

View File

@ -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 (
<span className="answer">
<label key={value} className="radio userAnswerButton">
<input type="radio" {...input} onClick={submit} value={value} />
<input
type="radio"
checked={value === currentValue}
onClick={submit}
value={value}
/>
{text}
</label>
</span>

View File

@ -1,28 +1,29 @@
import React, { useEffect } from 'react'
import React, { useCallback, useEffect } from 'react'
import { Trans } from 'react-i18next'
export default function SendButton({ disabled, submit }) {
const getAction = useCallback(cause => (!disabled ? submit(cause) : null), [
disabled,
submit
])
useEffect(() => {
const handleKeyDown = ({ key }) => {
if (key !== 'Enter') return
getAction('enter')
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [])
}, [getAction])
const getAction = () => {
return cause => (!disabled ? submit(cause) : null)
}
const handleKeyDown = ({ key }) => {
if (key !== 'Enter') return
getAction()('enter')
}
return (
<button
className="ui__ button plain"
css="margin-left: 1.2rem"
disabled={disabled}
onClick={() => getAction()('accept')}>
onClick={() => getAction('accept')}>
<span className="text">
<Trans>Suivant</Trans>
</span>

View File

@ -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
})
}

View File

@ -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
<ReactSelect
options={options}
onChange={submitOnChange}
@ -40,7 +36,7 @@ function ReactSelectWrapper(props) {
)
}
function Select({ input, submit }) {
export default FormDecorator('select')(function Select(props) {
const [options, setOptions] = useState(null)
useEffect(() => {
fetch(
@ -63,9 +59,7 @@ function Select({ input, submit }) {
return (
<div className="select-answer">
<ReactSelectWrapper {...input} options={options} submit={submit} />
<ReactSelectWrapper {...props} options={options} />
</div>
)
}
export default FormDecorator('select')(Select)
})

View File

@ -1,5 +1,5 @@
/* @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'
@ -8,44 +8,42 @@ type Props = {
value: ?number
}
export default function AnimatedTargetValue({ value }: 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)
}, [value])
const { i18n } = useTranslation()
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
}
const format = value => {
return value == null
? ''
: Intl.NumberFormat(i18n.language, {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
minimumFractionDigits: 0
}).format(value)
}
export default function AnimatedTargetValue({ value, children }: Props) {
const previousValue = useRef()
const { language } = useTranslation().i18n
const formattedDifference = format(difference)
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 (
<>
<span className="Rule-value">
{shouldDisplayDifference && (
<Evaporate
style={{
color: difference > 0 ? 'chartreuse' : 'red'
color: difference > 0 ? 'chartreuse' : 'red',
pointerEvents: 'none'
}}>
{(difference > 0 ? '+' : '') + formattedDifference}
{formatDifference(difference, language)}
</Evaporate>
)}{' '}
<span>{Number.isNaN(value) ? '—' : format(value)}</span>
{children}
</span>
</>
)

View File

@ -131,7 +131,10 @@ export function appear({
delay = 0,
style
}) {
// TODO: We should rename this function Appear
// eslint-disable-next-line react-hooks/rules-of-hooks
const [show, setShow] = useState(unless)
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
window.setTimeout(() => setShow(true), 0)
}, [])

View File

@ -88,7 +88,7 @@ const generateTheme = (themeColour?: ?string): ThemeColours => {
}
}
const ThemeColoursContext: React$Context<ThemeColours> = createContext(
export const ThemeColoursContext: React$Context<ThemeColours> = createContext(
generateTheme()
)

View File

@ -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) =>

View File

@ -79,7 +79,7 @@ export default function uniroot(
if (
p < 0.75 * cb * q - Math.abs(tol_act * q) / 2 &&
p < Math.abs(prev_step * q / 2)
p < Math.abs((prev_step * q) / 2)
) {
// If (b + p / q) falls in [b,c] and isn't too large it is accepted
new_step = p / q

View File

@ -3,8 +3,10 @@
import {
compose,
defaultTo,
identity,
isNil,
lensPath,
omit,
over,
set,
uniq,
@ -12,11 +14,10 @@ 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'
import { findRuleByDottedName } from 'Engine/rules'
import type { Action } from 'Types/ActionsTypes'
function explainedVariable(state = null, { type, variableName = null }) {
@ -99,15 +100,71 @@ function conversationSteps(
return state
}
function simulation(state = null, { type, config, url, id }) {
if (type === 'SET_SIMULATION') {
return { config, url, hiddenControls: [] }
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, rules }) {
const currentPeriod = situation['période']
if (currentPeriod === toPeriod) {
return situation
}
if (type === 'HIDE_CONTROL' && state !== null) {
return { ...state, hiddenControls: [...state.hiddenControls, id] }
if (!['mois', 'année'].includes(toPeriod)) {
throw new Error('Oups, changement de période invalide')
}
if (type === 'RESET_SIMULATION' && state !== null) {
return { ...state, hiddenControls: [] }
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]) => [
fieldName,
currentPeriod === 'mois' && toPeriod === 'année' ? value * 12 : value / 12
])
return {
...situation,
...Object.fromEntries(updatedSituation),
période: toPeriod
}
}
function simulation(state = null, action, rules) {
if (action.type === 'SET_SIMULATION') {
const { config, url } = action
return { config, url, hiddenControls: [], situation: {} }
}
if (state === null) {
return state
}
switch (action.type) {
case 'HIDE_CONTROL':
return { ...state, hiddenControls: [...state.hiddenControls, action.id] }
case 'RESET_SIMULATION':
return { ...state, hiddenControls: [], situation: {} }
case 'UPDATE_SITUATION':
return {
...state,
situation: updateSituation(state.situation, {
fieldName: action.fieldName,
value: action.value,
config: state.config
})
}
case 'UPDATE_PERIOD':
return {
...state,
situation: updatePeriod(state.situation, {
toPeriod: action.toPeriod,
rules: rules
})
}
}
return state
}
@ -144,21 +201,23 @@ const existingCompanyReducer = (state, action) => {
}
return newState
}
export default reduceReducers(
existingCompanyReducer,
storageReducer,
combineReducers({
sessionId: defaultTo(Math.floor(Math.random() * 1000000000000) + ''),
// this is handled by redux-form, pas touche !
form: formReducer,
conversationSteps,
lang,
simulation,
explainedVariable,
previousSimulation: defaultTo(null),
currentExample,
situationBranch,
activeTargetInput,
inFranceApp: inFranceAppReducer
})
(state, action) =>
combineReducers({
sessionId: defaultTo(Math.floor(Math.random() * 1000000000000) + ''),
conversationSteps,
lang,
rules: defaultTo(null),
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)
)

View File

@ -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
}

View File

@ -5,10 +5,7 @@ import {
import {
collectDefaults,
disambiguateExampleSituation,
findRuleByDottedName,
nestedSituationToPathMap,
rules as baseRulesEn,
rulesFr as baseRulesFr
findRuleByDottedName
} from 'Engine/rules'
import { analyse, analyseMany, parseAll } from 'Engine/traverse'
import {
@ -33,24 +30,16 @@ 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 ===
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],
@ -81,24 +70,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],
situation => nestedSituationToPathMap(situation)
)
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
@ -150,7 +144,7 @@ const createSituationBrancheSelector = situationSelector =>
)
export let situationBranchesSelector = createSituationBrancheSelector(
formattedSituationSelector
situationSelector
)
export let situationBranchNameSelector = createSelector(
[branchesSelector, state => state.situationBranch],
@ -159,7 +153,7 @@ export let situationBranchNameSelector = createSelector(
)
export let validatedSituationSelector = createSelector(
[formattedSituationSelector, validatedStepsSelector],
[situationSelector, validatedStepsSelector],
(situation, validatedSteps) => pick(validatedSteps, situation)
)
export let validatedSituationBranchesSelector = createSituationBrancheSelector(
@ -283,7 +277,7 @@ export let nextStepsSelector = createSelector(
currentMissingVariablesByTargetSelector,
state => state.simulation?.config.questions,
state => state.conversationSteps.foldedSteps,
formattedSituationSelector
situationSelector
],
(
mv,

View File

@ -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 => {
return {
situation: state.simulation.situation,
activeTargetInput: state.activeTargetInput,
foldedSteps: state.conversationSteps.foldedSteps
}
}
export const currentSimulationSelector: State => SavedSimulation = state => ({
situation: situationSelector(state),
activeTargetInput: state.activeTargetInput,
foldedSteps: state.conversationSteps.foldedSteps
})
export const createStateFromSavedSimulation: (
?SavedSimulation
) => ?$Supertype<State> = 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
}

View File

@ -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(
@ -58,8 +59,9 @@ const middlewares = [
function InFranceRoute({ basename, language }) {
useEffect(() => {
setToSessionStorage('lang', language)
}, [])
}, [language])
const paths = constructLocalizedSitePath(language)
const rules = language === 'en' ? baseRulesEn : baseRulesFr
return (
<Provider
basename={basename}
@ -68,12 +70,13 @@ function InFranceRoute({ basename, language }) {
sitePaths={paths}
reduxMiddlewares={middlewares}
onStoreCreated={store => {
persistEverything()(store)
persistEverything({ except: ['rules'] })(store)
persistSimulation(store)
}}
initialStore={{
...retrievePersistedState(),
previousSimulation: retrievePersistedSimulation()
previousSimulation: retrievePersistedSimulation(),
rules
}}>
<RouterSwitch />
</Provider>

View File

@ -3,7 +3,7 @@
import classnames from 'classnames'
import withTracker from 'Components/utils/withTracker'
import { compose } from 'ramda'
import React, { useEffect, useRef, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { withRouter } from 'react-router'
import backSvg from './back.svg'
import mobileMenuSvg from './mobile-menu.svg'
@ -19,10 +19,6 @@ type Props = OwnProps & {
tracker: Tracker,
location: Location
}
type State = {
opened: boolean,
sticky: boolean
}
const bigScreen = window.matchMedia('(min-width: 1500px)')
const isParent = (parentNode, children) => {
@ -35,13 +31,19 @@ const isParent = (parentNode, children) => {
return isParent(parentNode, children.parentNode)
}
function SideBar({ location, tracker, children }) {
function SideBar({ location, tracker, children }: Props) {
const [opened, setOpened] = useState(false)
const [sticky, setSticky] = useState(bigScreen.matches)
const [previousLocation, setPreviousLocation] = useState(location)
const ref = useRef()
useEffect(() => {
const handleClick = event => {
if (!sticky && !isParent(ref.current, event.target) && opened) {
handleClose()
}
}
window.addEventListener('click', handleClick)
bigScreen.addListener(handleMediaQueryChange)
@ -49,27 +51,22 @@ function SideBar({ location, tracker, children }) {
window.removeEventListener('click', handleClick)
bigScreen.removeListener(handleMediaQueryChange)
}
}, [opened, sticky])
}, [handleClose, opened, sticky])
useEffect(() => {
if (!sticky && previousLocation !== location) {
setOpened(false)
}
setPreviousLocation(location)
})
}, [sticky, previousLocation, location])
const handleClick = event => {
if (!sticky && !isParent(ref.current, event.target) && opened) {
handleClose()
}
}
const handleMediaQueryChange = () => {
setSticky(bigScreen.matches)
}
const handleClose = () => {
const handleClose = useCallback(() => {
tracker.push(['trackEvent', 'Sidebar', 'close'])
setOpened(false)
}
}, [tracker])
const handleOpen = () => {
tracker.push(['trackEvent', 'Sidebar', 'open'])
setOpened(true)

View File

@ -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([

View File

@ -23,6 +23,7 @@ export default function IntegrationTest() {
script.dataset.couleur = colour
domNode.current.innerHTML = ''
domNode.current.appendChild(script)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [version])
return (
<>

View File

@ -1,7 +1,7 @@
import LangSwitcher from 'Components/LangSwitcher'
import marianneSvg from 'Images/marianne.svg'
import urssafSvg from 'Images/urssaf.svg'
import React, { useEffect } from 'react'
import React, { useEffect, useState } from 'react'
import emoji from 'react-easy-emoji'
import { Trans } from 'react-i18next'
import { Link } from 'react-router-dom'
@ -9,8 +9,10 @@ import screenfull from 'screenfull'
import { isIE } from '../../../../utils'
export default function IframeFooter() {
const [isFullscreen, setIsFullscreen] = useState(screenfull.isFullscreen)
useEffect(() => {
screenfull.enabled && screenfull.onchange(() => this.forceUpdate())
screenfull.enabled &&
screenfull.onchange(() => setIsFullscreen(screenfull.isFullscreen))
}, [])
return (
@ -44,7 +46,7 @@ export default function IframeFooter() {
justifyContent: 'space-between'
}}>
<LangSwitcher className="ui__ button simple" />
{screenfull.enabled && !screenfull.isFullscreen && !isIE() && (
{screenfull.enabled && !isFullscreen && !isIE() && (
<button
className="ui__ button small"
onClick={() => {

View File

@ -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

View File

@ -18,11 +18,6 @@ export type FlatRules = {
}
}
export type State = {
form: {
conversation: {
values: Situation
}
},
previousSimulation: ?SavedSimulation,
conversationSteps: {
foldedSteps: Array<string>,

View File

@ -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',

View File

@ -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,16 +11,13 @@ import {
} from 'Selectors/ficheDePaieSelectors'
let state = {
form: {
conversation: {
values: {
'contrat salarié': { rémunération: { 'brut de base': '2300' } },
entreprise: { effectif: '50' }
}
}
},
rules,
simulation: {
config: salariéConfig
config: salariéConfig,
situation: {
'contrat salarié . rémunération . brut de base': '2300',
'entreprise . effectif': '50'
}
},
conversationSteps: {
foldedSteps: []

View File

@ -10,6 +10,7 @@ let runExamples = (examples, rule) =>
examples.map(ex => {
let runExample = exampleAnalysisSelector(
{
rules,
currentExample: {
situation: ex.situation,
dottedName: rule.dottedName

View File

@ -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"
@ -3558,6 +3553,11 @@ eslint-plugin-flowtype@^3.2.1:
dependencies:
lodash "^4.17.15"
eslint-plugin-react-hooks@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-2.0.1.tgz#e898ec26a0a335af6f7b0ad1f0bedda7143ed756"
integrity sha512-xir+3KHKo86AasxlCV8AHRtIZPHljqCRRUYgASkbatmt0fad4+5GgC7zkT7o/06hdKM6MIwp8giHVXqBPaarHQ==
eslint-plugin-react@^7.12.4:
version "7.14.3"
resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.14.3.tgz#911030dd7e98ba49e1b2208599571846a66bdf13"
@ -4686,7 +4686,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 +4934,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 +5794,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 +8175,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"