diff --git a/source/reducers/reduceSteps.js b/source/reducers/reduceSteps.js new file mode 100644 index 000000000..2182d62bb --- /dev/null +++ b/source/reducers/reduceSteps.js @@ -0,0 +1,146 @@ +import { path, head, reject, concat, without } from 'ramda' + +import { rules, collectDefaults, rulesFr } from 'Engine/rules' +import { + getNextSteps, + collectMissingVariablesByTarget +} from 'Engine/generateQuestions' +import { analyseMany, parseAll } from 'Engine/traverse' + +export default (tracker, flatRules, answerSource) => (state, action) => { + state.flatRules = flatRules + // Optimization - don't parse on each analysis + if (!state.parsedRules) { + state.parsedRules = parseAll(flatRules) + } + + // TODO + if (action.type == 'CHANGE_LANG') { + if (action.lang == 'fr') { + flatRules = rulesFr + } else flatRules = rules + return { + ...state, + flatRules + } + } + + if ( + ![ + 'SET_CONVERSATION_TARGETS', + 'STEP_ACTION', + 'USER_INPUT_UPDATE', + 'START_CONVERSATION', + 'SET_ACTIVE_TARGET_INPUT' + ].includes(action.type) + ) + return state + + if ( + path(['form', 'conversation', 'syncErrors'], state) || + !answerSource(state)(state.activeTargetInput) + ) + return state + + let targetNames = reject( + name => state.activeTargetInput && state.activeTargetInput.includes(name) + )(state.targetNames) + + // Most rules have default values + let rulesDefaults = collectDefaults(flatRules), + situationWithDefaults = assume(answerSource, rulesDefaults) + + let analysis = analyseMany(state.parsedRules, targetNames)( + situationWithDefaults(state) + ) + + if (action.type === 'USER_INPUT_UPDATE') { + return { ...state, analysis, situationGate: situationWithDefaults(state) } + } + + let nextStepsAnalysis = analyseMany(state.parsedRules, targetNames)( + answerSource(state) + ), + missingVariablesByTarget = collectMissingVariablesByTarget( + nextStepsAnalysis.targets + ), + nextSteps = getNextSteps(missingVariablesByTarget), + currentQuestion = head(nextSteps) + + let newState = { + ...state, + analysis, + situationGate: situationWithDefaults(state), + explainedVariable: null, + nextSteps, + currentQuestion, + foldedSteps: + action.type === 'SET_CONVERSATION_TARGETS' && action.reset + ? [] + : state.foldedSteps + } + + if (['START_CONVERSATION', 'SET_ACTIVE_TARGET_INPUT'].includes(action.type)) + return { + ...newState, + missingVariablesByTarget: { + initial: missingVariablesByTarget, + current: missingVariablesByTarget + } + } + + if (action.type == 'STEP_ACTION' && action.name == 'fold') { + tracker.push([ + 'trackEvent', + 'answer:' + action.source, + action.step + ': ' + situationWithDefaults(state)(action.step) + ]) + + if (!newState.currentQuestion) { + tracker.push([ + 'trackEvent', + 'done', + 'after' + length(newState.foldedSteps) + 'questions' + ]) + } + let foldedSteps = [...state.foldedSteps, state.currentQuestion] + + return { + ...newState, + foldedSteps, + missingVariablesByTarget: { + ...state.missingVariablesByTarget, + current: missingVariablesByTarget + } + } + } + if (action.type == 'STEP_ACTION' && action.name == 'unfold') { + tracker.push(['trackEvent', 'unfold', action.step]) + + // We are possibly "refolding" a previously open question + let previous = state.currentQuestion, + // we fold it back into foldedSteps if it had been answered + answered = previous && answerSource(state)(previous) != undefined, + rawFoldedSteps = answered + ? concat(state.foldedSteps, [previous]) + : state.foldedSteps, + foldedSteps = without([action.step], rawFoldedSteps) + + return { + ...newState, + foldedSteps, + currentQuestion: action.step, + missingVariablesByTarget: { + ...state.missingVariablesByTarget, + current: missingVariablesByTarget + } + } + } +} + +// assume "wraps" a given situation function with one that overrides its values with +// the given assumptions +export let assume = (evaluator, assumptions) => state => name => { + let userInput = evaluator(state)(name) + return userInput != null ? userInput : assumptions[name] +} diff --git a/source/reducers/reducers.js b/source/reducers/reducers.js new file mode 100644 index 000000000..349fe3474 --- /dev/null +++ b/source/reducers/reducers.js @@ -0,0 +1,90 @@ +import { combineReducers } from 'redux' +import reduceReducers from 'reduce-reducers' +import { reducer as formReducer, formValueSelector } from 'redux-form' +import reduceSteps from './reduceSteps' +import computeThemeColours from 'Components/themeColours' +import { formatInputs } from 'Engine/rules' + +import ReactPiwik from 'Components/Tracker' + +import { popularTargetNames } from 'Components/TargetSelection' + +function themeColours(state = computeThemeColours(), { type, colour }) { + if (type == 'CHANGE_THEME_COLOUR') return computeThemeColours(colour) + else return state +} + +function explainedVariable(state = null, { type, variableName = null }) { + switch (type) { + case 'EXPLAIN_VARIABLE': + return variableName + default: + return state + } +} + +function currentExample(state = null, { type, situation, name }) { + switch (type) { + case 'SET_EXAMPLE': + return name != null ? { name, situation } : null + default: + return state + } +} + +function conversationStarted(state = false, { type }) { + switch (type) { + case 'START_CONVERSATION': + return true + default: + return state + } +} +function activeTargetInput(state = null, { type, name }) { + switch (type) { + case 'SET_ACTIVE_TARGET_INPUT': + return name + default: + return state + } +} + +export default initialRules => + reduceReducers( + combineReducers({ + sessionId: (id = Math.floor(Math.random() * 1000000000000) + '') => id, + // this is handled by redux-form, pas touche ! + form: formReducer, + + /* Have forms been filled or ignored ? + false means the user is reconsidering its previous input */ + foldedSteps: (steps = []) => steps, + currentQuestion: (state = null) => state, + nextSteps: (state = []) => state, + missingVariablesByTarget: (state = {}) => state, + + parsedRules: (state = null) => state, + flatRules: (state = null) => state, + analysis: (state = null) => state, + + targetNames: (state = popularTargetNames) => state, + + situationGate: (state = name => null) => state, + + iframe: (state = false) => state, + + themeColours, + + explainedVariable, + + currentExample, + conversationStarted, + activeTargetInput + }), + // cross-cutting concerns because here `state` is the whole state tree + reduceSteps( + ReactPiwik, + initialRules, + formatInputs(initialRules, formValueSelector) + ) + )