Created
August 17, 2017 22:27
-
-
Save a-laughlin/72e77c506c372f95e4721cf2ac30d7ae to your computer and use it in GitHub Desktop.
User Goals HOC
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// HOC designed around enabling components to consistently work with user's mental state, rather than just program state. | |
// storing this here for reference... | |
import React from 'react'; // so jsx parsing works | |
import {withProps,withReducer,mapProps,setPropTypes,branch} from 'recompose'; | |
import {ifElse} from 'ramda'; | |
import { | |
spread, rest, pick, isString, compose, pipe, uniq, startCase, get, map, values, flatten, sortBy, | |
filter, identity, keys, without, omit, transform as transformFP | |
} from 'lodash/fp'; | |
// reference | |
// https://medium.com/@jamsesso/a-simple-react-finite-state-machine-design-pattern-9078e343bdd7 | |
// https://www.google.com/search?num=20&q=human+action+cycle+%22state+machine%22&oq=human+action+cycle+%22state+machine%22 | |
// organize app's components around goals user can achieve | |
// organize components within a goal to ensure each step of human action cycle has feedback fitting a great user experience | |
// track state of each goal's achievement (for both user feedback and granular data) | |
// | |
// constraints | |
// defines each goal as a finite state machine | |
// states must conform to standard goal achievement syntax for consistency | |
// | |
// design considerations | |
// invert control where possible for external event handler fn composition | |
// describes states declaratively (abstracts away component switch via statesMap object) | |
// accepts custom dispatch fn | |
// base HOC should work like any other HOC | |
// compose in additional states beyond view-only, for action executing, cancelling, etc | |
// ? normalize all dispatches as async? | |
// ? maybe... compose states together via Object.assign(baseStates,otherStates) ... unsure if describing them completely as strings will work | |
// ? how to structure state component map? | |
// | |
// assumptions | |
// assumes any component can contribute to 1-n goals | |
// assumes user can be actively attempting 1-n goals | |
// assumes any goal can be nested... though 1-n parent goals since child goals may or may not contribute to both... define children goals on parent. | |
// assumes user tracks goals as a series of directed acyclical graphs | |
// | |
// implementation considerations | |
// the functionality is very similar to routing, where the path reflects path in nested goals... may be able to use router for underlying state | |
export const UserGoalsHOC = ()=>{ | |
const DefaultComponent = (props)=>React.createElement('div',props); | |
const activeGoalStates = { | |
activePath:[], | |
root:{ | |
status:'formingGoal', | |
data:{}, | |
children:{ | |
findContent:{ | |
status:'formingGoal', | |
children:{ | |
navigate:{ | |
status:'formingGoal', | |
}, | |
search:{}, | |
}, | |
}, | |
} | |
} | |
}; | |
// state:{} | |
// navigate:{}, | |
// search:{}, | |
// goalHistory:[], | |
// stateTree={} | |
// onEntry, onExit, Component, exitHandlers: | |
// construct the exit handlers from each state to the possible other states | |
// formingGoal:{nextStateDispatchers:{evaluatingPossiblePlansVisually}} | |
// | |
const DefaultStateComponents = { | |
formingGoal:DefaultComponent, // content + style affordances | |
evaluatingPossiblePlansVisually:DefaultComponent, // IA (semantic mapping) + Design (concept mapping) + Design (affordances) + Constraints (Component does not exist) | |
evaluatingThisComponentAsExecutionPlan:DefaultComponent, // css affordances: hover with mouse (cursor change), tap with finger, titles, sometimes components | |
executingPlan:DefaultComponent // e.g., progress - like mouseDown or held tap on element | |
evaluatingPlanExecution:DefaultComponent // progress - feedback when | |
// attemptingToAchieveGoal:DefaultComponent, // css affordances: hover/click, cursor change, titles, sometimes components | |
// attemptingToChangeGoal:DefaultComponent, // css affordances: hover/click, cursor change, titles, sometimes components | |
evaluatingAttemptProgress:DefaultComponent, // component feedback | |
evaluatingAttemptSuccess:DefaultComponent, // component feedback | |
evaluatingAttemptError:DefaultComponent, // component feedback | |
// goalCancel:DefaultComponent, // cancel would be handled by a parent component since this component is just a way to achieve a goal, not the goal itself | |
// if a user switches goals, they'll use a different component | |
}; | |
const DISPATCH_NAME = 'dispatch'; | |
const dispatchers = { | |
formingGoal:{ | |
evaluatingPossiblePlansVisually:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'evaluatingPossiblePlansVisually'}}), | |
previousState:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'previousState'}}), | |
}, | |
// should entry state differs if user is using this component to achieve some other goal? | |
// cases - user knows what they want to do, user does not know - | |
// component shouldn't know if it's being used... parents should know about children states, | |
// children should not know bout parents, so they can be composed | |
// needs a way for parent to indicate cancel, for cleanup. Maybe. Might be able to do this as part of the tree mgmt. | |
evaluatingPossiblePlansVisually:{ | |
evaluatingThisComponentAsExecutionPlan:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'evaluatingThisComponentAsExecutionPlan'}}), | |
previousState:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'previousState'}}), | |
}, | |
evaluatingThisComponentAsExecutionPlan:{ | |
executingPlan:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'executingPlan'}}), | |
previousState:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'previousState'}}), | |
}, | |
// evaluating HOC | |
// executing HOC | |
executingPlan:{ // mousedown on download | |
evaluatingAttemptAction:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'evaluatingPlanExecution'}}), | |
previousState:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'previousState'}}), | |
}, | |
evaluatingPlanExecution:{ // e.g., starting download | |
evaluatingPlanExecutionProgress:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'evaluatingPlanExecutionProgress'}}), | |
evaluatingPlanExecutionSuccess:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'evaluatingPlanExecutionSuccess'}}), | |
evaluatingPlanExecutionError:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'evaluatingPlanExecutionError'}}), | |
previousState:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'previousState'}}), | |
}, | |
evaluatingPlanExecutionProgress:{ | |
abort:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'attemptingAbort'}}), | |
evalSuccess:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'evaluatingPlanExecutionSuccess'}}), | |
evalError:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'evaluatingPlanExecutionError'}}), | |
evalPrevious:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'previousState'}}), | |
}, | |
evaluatingPlanExecutionSuccess:{ | |
complete:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'evaluatingPossiblePlansVisually'}}), | |
reAttemptGoal:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'executingPlan'}}), | |
reEvaluatePlan:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'evaluatingThisComponentAsExecutionPlan'}}), | |
}, | |
evaluatingPlanExecutionError:{ | |
abandon:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'evaluatingAbandonAttempt'}}), | |
reAttemptGoal:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'executingPlan'}}), | |
reEvaluatePlan:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'evaluatingThisComponentAsExecutionPlan'}}), | |
evalUnexpectedError:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'evaluatingThisComponentAsExecutionPlan'}}), | |
}, | |
evaluatingUnexpectedError:{to:{ | |
contact:'evaluatingUnexpectedErrorInstructions', | |
abandon:'evaluatingAbandonAttempt', | |
reEvaluatePlan:'', | |
reAttemptGoal:'', | |
}}, | |
// abortable HOC | |
attemptingAbort:{to:{evalAbortAttempt:'evaluatingAbortAttempt'}}, | |
evaluatingAbortAttempt:{to:{progress:'evaluatingAbortProgress', success:'evaluatingAbortSuccess', error:'evaluatingAbortError'}}, | |
evaluatingAbortProgress:{to:{ success:'evaluatingAbortSuccess', error:'evaluatingAbortError'}, | |
evaluatingAbortSuccess:{to:{reAttemptGoal:'executingPlan',reEvaluatePlan:'evaluatingThisComponentAsExecutionPlan'}}, | |
evaluatingAbortError:{to:{reAttemptGoal:'executingPlan',reEvaluatePlan:'evaluatingThisComponentAsExecutionPlan'}}, | |
// abandonable HOC | |
attemptingAbandon:{}, | |
evaluatingAbandonAttempt:{}, | |
evaluatingAbandonProgress:{}, | |
evaluatingAbandonSuccess:{}, | |
evaluatingAbandonError:{}, | |
changingGoal:{}, | |
reAttemptingGoal:{ // retrying | |
evaluatingAttemptAction:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'evaluatingPlanExecution'}}), | |
previousState:(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:'previousState'}}), | |
}, | |
}; | |
keys(DefaultStateComponents).reduce((acc,key)=>{ | |
acc[key] = (props)=>(props.dispatch({type:'userState',payload:key})); | |
return acc; | |
},{}); | |
return ( | |
stateComponents={}, | |
dispatchFn=(props)=>(e)=>props[DISPATCH_NAME]({type:'userGoalStateChange',payload:{to:props.goalPath}}), | |
handlers={ | |
attemptGoal:(props)=>(e)=>{ | |
return Promise.resolve() | |
.then(props.userEvaluatingProgress) | |
.then(props.userEvaluatingSuccess) | |
.catch(props.userEvaluatingError) | |
}, | |
cancelGoalAttempt:(props)=>(e)=>{ | |
return Promise.resolve() | |
.then(props.dispatchers.determiningGoal) | |
}, | |
}, | |
goalStateTreeKey='', | |
stateTree={} | |
)=>(DeterminingGoalComponent)=>{ | |
const tree = {}; | |
const states = Object.assign({},DefaultStateComponents,stateComponents); | |
const dispatchers = keys(stateComponents).reduce((acc,key)=>{ | |
acc[key] = (props)=>(props.dispatch({type:'userState',payload:key})); | |
return acc; | |
},{}); | |
const componentChooser = withReducer('userState','dispatch',(state,{type,payload})=>payload,'determiningGoal'); | |
return componentChooser((props)=>{ | |
return React.createElement(stateComponents[props.userState], {...props, ...dispatchers,...handlers}); | |
}) | |
} | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment