Merge pull request #151 from betagouv/recalcul-immédiat

Recalcul immédiat
pull/165/head v0.3.1
Mael 2018-01-30 18:19:30 +01:00 committed by GitHub
commit 92389f890a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 183 additions and 87 deletions

View File

@ -29,6 +29,7 @@
"nearley": "^2.9.2",
"npm": "^5.3.0",
"ramda": "^0.25.0",
"rc-progress": "^2.2.5",
"react": "^16.2.0",
"react-addons-css-transition-group": "^15.6.2",
"react-color": "^2.13.8",

View File

@ -2,8 +2,8 @@
// The state keeps track of which of them have been submitted
// The user can also come back to one of his answers and edit it
export const STEP_ACTION = 'STEP_ACTION'
export function stepAction(name, step) {
return { type: STEP_ACTION, name, step }
export function stepAction(name, step, source) {
return { type: STEP_ACTION, name, step, source }
}
export const START_CONVERSATION = 'START_CONVERSATION'

View File

@ -1,6 +1,6 @@
#sim .tip {
font-style: italic;
margin-bottom: 1em;
margin-bottom: -0.3em;
text-align: center;
}
#sim .tip p {
@ -12,10 +12,3 @@
border-width: 1px;
border-style: solid;
}
progress::-webkit-progress-bar {
background: white;
}
progress::-webkit-progress-value {
}

View File

@ -2,13 +2,14 @@ import React, { Component } from 'react'
import { connect } from 'react-redux'
import { withRouter } from 'react-router'
import './ProgressTip.css'
import { Line } from 'rc-progress'
@withRouter
@connect(state => ({
done: state.done,
foldedSteps: state.foldedSteps,
nextSteps: state.nextSteps,
textColourOnWhite: state.themeColours.textColourOnWhite
colour: state.themeColours.colour
}))
export default class ProgressTip extends Component {
state = {
@ -21,7 +22,7 @@ export default class ProgressTip extends Component {
})
}
render() {
let { done, nextSteps, foldedSteps, textColourOnWhite } = this.props,
let { done, nextSteps, foldedSteps, colour } = this.props,
nbQuestions = nextSteps.length
if (!done) return null
return (
@ -35,10 +36,14 @@ export default class ProgressTip extends Component {
{nbQuestions === 1
? 'Une dernière question !'
: `Il reste moins de ${nbQuestions} questions`}
<ProgressBar
foldedSteps={foldedSteps}
nbQuestions={nbQuestions}
colour={textColourOnWhite}
<Line
percent={
100 * foldedSteps.length / (foldedSteps.length + nbQuestions)
}
strokeWidth="1"
strokeColor={colour}
trailColor="white"
strokeLinecap="butt"
/>
</p>
)}
@ -46,13 +51,3 @@ export default class ProgressTip extends Component {
)
}
}
let ProgressBar = ({ foldedSteps, nbQuestions, colour }) => (
<progress
value={foldedSteps.length}
max={foldedSteps.length + nbQuestions}
style={{
borderColor: colour
}}
/>
)

View File

@ -25,7 +25,7 @@ export var FormDecorator = formType => RenderField =>
formValueSelector('conversation')(state, 'inversions.' + dottedName)
}),
dispatch => ({
stepAction: (name, step) => dispatch(stepAction(name, step)),
stepAction: (name, step, source) => dispatch(stepAction(name, step, source)),
setFormValue: (field, value) =>
dispatch(change('conversation', field, value))
})
@ -75,11 +75,11 @@ export var FormDecorator = formType => RenderField =>
props passées à ce dernier, car React 15.2 n'aime pas les attributes inconnus
des balises html, <input> dans notre cas.
*/
let submit = () => setTimeout(() => stepAction('fold', fieldName), 1),
//TODO hack, enables redux-form/CHANGE to update the form state before the traverse functions are run
let submit = (cause) => setTimeout(() => stepAction('fold', fieldName, cause), 1),
stepProps = {
...this.props.step,
inverted,
//TODO hack, enables redux-form/CHANGE to update the form state before the traverse functions are run
submit,
setFormValue: (value, name = fieldName) => setFormValue(name, value)
}
@ -121,7 +121,7 @@ export var FormDecorator = formType => RenderField =>
<IgnoreStepButton
action={() => {
setFormValue(fieldName, '' + defaultValue)
submit()
submit('ignore')
}}
/>
)}
@ -159,7 +159,7 @@ export var FormDecorator = formType => RenderField =>
</span>
<button
className="edit"
onClick={() => stepAction('unfold', dottedName)}
onClick={() => stepAction('unfold', dottedName, 'unfold')}
style={{ color: themeColours.textColourOnWhite }}
>
<i className="fa fa-pencil" aria-hidden="true" />

View File

@ -8,7 +8,7 @@ import SendButton from './SendButton'
@FormDecorator('input')
export default class Input extends Component {
state = {
hoverSuggestion: null
lastValue: ''
}
render() {
let {
@ -20,7 +20,6 @@ export default class Input extends Component {
answerSuffix = valueType.suffix,
suffixed = answerSuffix != null,
inputError = dirty && error,
{ hoverSuggestion } = this.state,
submitDisabled = !dirty || inputError
return (
@ -33,7 +32,6 @@ export default class Input extends Component {
}}
type="text"
{...input}
value={hoverSuggestion != null ? hoverSuggestion : input.value}
className={classnames({ suffixed })}
id={'step-' + dottedName}
{...attributes}
@ -42,9 +40,6 @@ export default class Input extends Component {
? { border: '2px dashed #ddd' }
: { border: `1px solid ${themeColours.textColourOnWhite}` }
}
onKeyDown={({ key }) =>
key == 'Enter' && (submitDisabled ? input.onBlur() : submit())
}
/>
{suffixed && (
<label
@ -104,7 +99,7 @@ export default class Input extends Component {
)
}
renderSuggestions(themeColours) {
let { setFormValue, submit, suggestions, inverted } = this.props.stepProps
let { setFormValue, suggestions, inverted } = this.props.stepProps
if (!suggestions || inverted) return null
return (
@ -114,16 +109,24 @@ export default class Input extends Component {
{toPairs(suggestions).map(([text, value]) => (
<li
key={value}
onClick={e =>
setFormValue('' + value) && submit() && e.preventDefault()
onClick={() => {
this.setState({ lastValue: null })
setFormValue('' + value)
if (this.state.suggestion !== value)
this.setState({ suggestion: value })
else this.props.stepProps.submit('suggestion')
}}
onMouseOver={() => {
this.setState({ lastValue: this.props.input.value })
setFormValue('' + value)
}}
onMouseOut={() =>
this.state.lastValue != null &&
setFormValue('' + this.state.lastValue)
}
onMouseOver={() => this.setState({ hoverSuggestion: value })}
onMouseOut={() => this.setState({ hoverSuggestion: null })}
style={{ color: themeColours.textColourOnWhite }}
>
<a href="#" title="cliquer pour valider">
{text}
</a>
<span title="cliquez pour insérer cette suggestion">{text}</span>
</li>
))}
</ul>

View File

@ -4,7 +4,7 @@ import { answer, answered } from './userAnswerButtonStyle'
import HoverDecorator from '../HoverDecorator'
import Explicable from './Explicable'
import { pipe, split, reverse, reduce, is } from 'ramda'
import SendButton from './SendButton'
/* Ceci est une saisie de type "radio" : l'utilisateur choisit une réponse dans une liste, ou une liste de listes.
Les données @choices sont un arbre de type:
- nom: motif CDD # La racine, unique, qui formera la Question. Ses enfants sont les choix possibles
@ -21,26 +21,37 @@ import { pipe, split, reverse, reduce, is } from 'ramda'
*/
let dottedNameToObject = pipe(
split(' . '),
reverse,
reduce((memo, next) => ({ [next]: memo }), 'oui')
)
// FormDecorator permet de factoriser du code partagé par les différents types de saisie,
// dont Question est un example
@FormDecorator('question')
export default class Question extends Component {
render() {
let { stepProps: { choices } } = this.props
if (is(Array)(choices)) return this.renderBinaryQuestion()
else return this.renderChildren(choices)
let {
stepProps: { choices, submit },
themeColours,
meta: { pristine }
} = this.props
let choiceElements = is(Array)(choices)
? this.renderBinaryQuestion()
: this.renderChildren(choices)
return (
<>
{choiceElements}
<SendButton
{...{
disabled: pristine,
themeColours,
error: false,
submit
}}
/>
</>
)
}
renderBinaryQuestion() {
let {
input, // vient de redux-form
stepProps: { submit, choices },
stepProps: { submit, choices, setFormValue },
themeColours
} = this.props
@ -49,7 +60,7 @@ export default class Question extends Component {
{choices.map(({ value, label }) => (
<RadioLabel
key={value}
{...{ value, label, input, submit, themeColours }}
{...{ value, label, input, submit, themeColours, setFormValue }}
/>
))}
</ul>
@ -62,7 +73,7 @@ export default class Question extends Component {
themeColours
} = this.props,
{ name } = input,
{ submit } = stepProps,
{ submit, setFormValue } = stepProps,
// seront stockées ainsi dans le state :
// [parent object path]: dotted name relative to parent
relativeDottedName = radioDottedName =>
@ -79,7 +90,8 @@ export default class Question extends Component {
input,
submit,
themeColours,
dottedName: null
dottedName: null,
setFormValue
}}
/>
</li>
@ -101,7 +113,8 @@ export default class Question extends Component {
dottedName,
input,
submit,
themeColours
themeColours,
setFormValue
}}
/>
</li>
@ -120,8 +133,11 @@ let RadioLabel = props => (
@HoverDecorator
class RadioLabelContent extends Component {
click = value => () => {
if (this.props.input.value == value) this.props.submit('dblClick')
}
render() {
let { value, label, input, submit, hover, themeColours } = this.props,
let { value, label, input, hover, themeColours } = this.props,
// value = when(is(Object), prop('value'))(choice),
labelStyle = Object.assign(
value === input.value || hover
@ -136,7 +152,7 @@ class RadioLabelContent extends Component {
<input
type="radio"
{...input}
onClick={submit}
onClick={this.click(value)}
value={value}
checked={value === input.value ? 'checked' : ''}
/>

View File

@ -5,7 +5,20 @@ import HoverDecorator from 'Components/HoverDecorator'
export default class SendButton extends Component {
getAction() {
let { disabled, submit } = this.props
return () => (!disabled ? submit() : null)
return (cause) => (!disabled ? submit(cause) : null)
}
componentDidMount() {
// removeEventListener will need the exact same function instance
this.boundHandleKeyDown = this.handleKeyDown.bind(this)
window.addEventListener('keydown', this.boundHandleKeyDown)
}
componentWillUnmount() {
window.removeEventListener('keydown', this.boundHandleKeyDown)
}
handleKeyDown({ key }) {
if (key !== 'Enter') return
this.getAction()('enter')
}
render() {
let { disabled, themeColours, hover } = this.props
@ -18,10 +31,10 @@ export default class SendButton extends Component {
color: themeColours.textColour,
background: themeColours.colour
}}
onClick={this.getAction()}
onClick={(event) => this.getAction()('accept')}
>
<span className="text">valider</span>
<span className="icon">&#10003;</span>
<i className="fa fa-check" aria-hidden="true" />
</button>
<span
className="keyIcon"

View File

@ -214,6 +214,10 @@
.step.question .variantLeaf.aucun label {
font-weight: bold;
}
.step.question .sendWrapper {
float: right;
}
.step label.radio,
/* A resume of what's been answered */ .resume {
text-align: center;
@ -315,7 +319,7 @@
font-style: italic;
float: right;
clear: right;
font-size: 70%;
font-size: 75%;
color: #222;
}
.step .inputSuggestions ul {
@ -326,11 +330,12 @@
}
.step .inputSuggestions li {
}
.step .inputSuggestions a {
.step .inputSuggestions span {
padding: 0.1em 0.9em;
font-weight: 600;
cursor: pointer;
}
.step .inputSuggestions a:hover {
.step .inputSuggestions span:hover {
text-decoration: none;
}
@ -370,11 +375,11 @@
}
.step .send {
padding: 0 0.1em 0 0.5em;
padding: 0.1em 0.4em 0em 1em;
background: none;
cursor: pointer;
border: 1px solid;
border-radius: 0.2em;
border-radius: 0.4em;
line-height: 0em;
}
@ -382,19 +387,24 @@
opacity: 0.2;
}
.step .send .icon {
margin-left: 0.3em;
font-size: 135%;
vertical-align: middle;
.step .send i {
margin: 0 0.3em;
font-size: 160%;
}
.step .send .text {
text-transform: uppercase;
font-size: 115%;
font-size: 135%;
line-height: 2em;
}
.answer {
display: flex;
justify-content: flex-end;
align-items: center;
}
.foldedQuestion .answer {
float: right;
}

View File

@ -2,7 +2,6 @@ import React from 'react'
import { Link } from 'react-router-dom'
import { encodeRuleName } from 'Engine/rules'
import classNames from 'classnames'
import { capitalise0 } from '../../utils'
let fmt = new Intl.NumberFormat('fr-FR').format
export let humanFigure = decimalDigits => value =>
fmt(value.toFixed(decimalDigits))
@ -45,7 +44,7 @@ let RuleValue = ({ unsatisfied, irrelevant, conversationStarted, ruleValue }) =>
? ['irrelevant', "Vous n'êtes pas concerné"]
: unsatisfied
? ['unsatisfied', 'En attente de vos réponses...']
: ['figure', humanFigure(2)(ruleValue) + ' €']
: ['figure', humanFigure(0)(ruleValue) + ' €']
{
/*<p><i className="fa fa-lightbulb-o" aria-hidden="true"></i><em>Pourquoi ?</em></p> */

View File

@ -0,0 +1,37 @@
// Thank you, github.com/ryanseddon/redux-debounced
export default () => {
let timers = {}
let time = 500
let middleware = () => dispatch => action => {
let { type } = action
let key = type
let shouldDebounce = key === '@@redux-form/CHANGE'
if (key === '@@redux-form/UPDATE_SYNC_ERRORS') {
dispatch(action)
return clearTimeout(timers['@@redux-form/CHANGE'])
}
if (!shouldDebounce) return dispatch(action)
if (timers[key]) {
clearTimeout(timers[key])
}
dispatch(action)
return new Promise(resolve => {
timers[key] = setTimeout(() => {
resolve(dispatch({ type: 'USER_INPUT_UPDATE' }))
}, time)
})
}
middleware._timers = timers
return middleware
}

View File

@ -1,10 +1,11 @@
import React from 'react'
import { render } from 'react-dom'
import { compose, createStore } from 'redux'
import { compose, createStore, applyMiddleware } from 'redux'
import App from './containers/App'
import reducers from './reducers'
import DevTools from './DevTools'
import { AppContainer } from 'react-hot-loader'
import debounceFormChangeActions from './debounceFormChangeActions'
import computeThemeColours from './components/themeColours'
import { getIframeOption, getUrl } from './utils'
@ -13,7 +14,15 @@ let initialStore = {
themeColours: computeThemeColours(getIframeOption('couleur'))
}
let store = createStore(reducers, initialStore, compose(DevTools.instrument()))
let createStoreWithMiddleware = applyMiddleware(debounceFormChangeActions())(
createStore
)
let store = createStoreWithMiddleware(
reducers,
initialStore,
compose(DevTools.instrument())
)
let anchor = document.querySelector('#js')
render(<App store={store} />, anchor)

View File

@ -1,4 +1,4 @@
import { head, isEmpty, pathOr, reject, contains, without, concat } from 'ramda'
import { head, isEmpty, pathOr, reject, contains, without, concat, length } from 'ramda'
import { combineReducers } from 'redux'
import reduceReducers from 'reduce-reducers'
import { reducer as formReducer, formValueSelector } from 'redux-form'
@ -50,10 +50,17 @@ export let reduceSteps = (tracker, flatRules, answerSource) => (
// Optimization - don't parse on each analysis
if (!state.parsedRules) state.parsedRules = parseAll(flatRules)
if (![START_CONVERSATION, STEP_ACTION].includes(action.type)) return state
if (
![START_CONVERSATION, STEP_ACTION, 'USER_INPUT_UPDATE'].includes(
action.type
)
)
return state
let targetNames =
action.type == START_CONVERSATION ? action.targetNames : state.targetNames
action.type == START_CONVERSATION
? action.targetNames
: state.targetNames || []
let sim =
targetNames.length === 1 ? findRuleByName(flatRules, targetNames[0]) : {},
@ -66,9 +73,14 @@ export let reduceSteps = (tracker, flatRules, answerSource) => (
situationWithDefaults = assume(intermediateSituation, rulesDefaults)
let analysis = analyseMany(state.parsedRules, targetNames)(
situationWithDefaults(state)
),
nextWithDefaults = getNextSteps(situationWithDefaults(state), analysis),
situationWithDefaults(state)
)
if (action.type === 'USER_INPUT_UPDATE') {
return { ...state, analysis, situationGate: situationWithDefaults(state) }
}
let nextWithDefaults = getNextSteps(situationWithDefaults(state), analysis),
assumptionsMade = !isEmpty(rulesDefaults),
done = nextWithDefaults.length == 0
@ -104,10 +116,18 @@ export let reduceSteps = (tracker, flatRules, answerSource) => (
if (action.type == STEP_ACTION && action.name == 'fold') {
tracker.push([
'trackEvent',
'answer',
'answer:'+action.source,
action.step + ': ' + situationWithDefaults(state)(action.step)
])
if (!newState.currentQuestion) {
tracker.push([
'trackEvent',
'done',
'after'+length(newState.foldedSteps)+'questions'
])
}
return {
...newState,
foldedSteps: [...state.foldedSteps, state.currentQuestion]