commit
92389f890a
|
@ -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",
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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 {
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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' : ''}
|
||||
/>
|
||||
|
|
|
@ -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">✓</span>
|
||||
<i className="fa fa-check" aria-hidden="true" />
|
||||
</button>
|
||||
<span
|
||||
className="keyIcon"
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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> */
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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]
|
||||
|
|
Loading…
Reference in New Issue