Last active
November 13, 2018 11:41
-
-
Save isocroft/ec3345550880006cf22e73306fd417e4 to your computer and use it in GitHub Desktop.
This is a simple demonstration (proof-of-concept) of using state-charts to design a model for managing transient (temporary UI state) and non-transient data (permanent Data state) in an application
This file contains 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
import { Component } from 'react' | |
import axios from 'axios' | |
import { updateAppBehavior } from './transition_factory_redux.js' | |
import { transitionFunction, getState, subscribe } from 'setup_transition_function.js' | |
const asyncFetchAction = (Component, action) => { | |
const CancelToken = axios.CancelToken; | |
return (dispatch, getState, actionGuard, actionType) => { | |
const source = CancelToken.source(); | |
const storeDispatch = function(dataOrError){ | |
setTimeout(() => { | |
dispatch(action); | |
},0); | |
}; | |
const promise = null; | |
action.type = actionType; | |
Component.setCancelTokenCallback(function(msg){ | |
source.cancel(msg); | |
}); | |
if( actionGuard(action) ){ | |
promise = axios.get("https://jsonplaceholder.typicode.com/"+action.text, { | |
cancelToken: source.token | |
}).then((data) => { | |
action.data = data instanceof Array ? data : JSON.parse(data); // ["number-1", "number-2", "number-3"]; | |
return Component.asyncTaskDone(); | |
}).then( | |
storeDispatch | |
).catch((thrownError) => { | |
action.type = ""; | |
throw Component.asyncTaskDone(thrownError); | |
}).catch( | |
storeDispatch | |
); | |
} | |
return promise; | |
} | |
}; | |
class App extends Component { | |
constructor(props){ | |
super(props) | |
subscribe(this.hasScreenData.bind(this)); | |
this.requestCancelCallback = null; | |
} | |
asyncTaskDone( thrownError ){ | |
const _behavior = transitionFunction( | |
thrownError ? "AJAX_RESPONSE_ERROR" : "AJAX_RESPONSE_SUCCESS", | |
this.state, | |
null, | |
thrownError | |
); | |
this.updateUI(_behavior); // update UI to show "sucess/error" | |
return thrownError ? thrownError : true; | |
} | |
buttonClick( event ){ | |
const _behavior = transitionFunction( | |
(event.target.name === 'search' ? 'QUERY_BUTTON_CLICK' : 'AJAX_ABORT_BUTTON_CLICK'), | |
this.state, | |
(event.target.name === 'search' ? asyncFetchAction(this, { | |
text:event.target.form.elements['query'].value | |
}) : null) | |
); | |
if(event.target.name === 'cancel'){ | |
this.runCancelTokenCallback('User canceled request'); | |
} | |
this.updateUI(_behavior); // update UI to show "loading/abort" | |
return true; | |
} | |
runCancelTokenCallback(msg){ | |
if(typeof this.requestCancelCallback == 'function'){ | |
this.requestCancelCallback(msg); | |
} | |
} | |
setCancelTokenCallback(callback){ | |
this.requestCancelCallback = callback; | |
} | |
updateUI(stateObject){ | |
return updateAppBehavior(stateObject); // this call always calls `render()` cos `setState()` gets called | |
} | |
hasScreenData(){ | |
const _behavior = transitionFunction( | |
'RENDER_START', | |
this.state, | |
null | |
); | |
this.updateUI(_behavior); | |
return true; | |
} | |
renderInput(p, s){ | |
return (s.parallel.form === 'loading' | |
? <input type="text" name="query" value={p.text} readonly="readonly"> | |
: <input type="text" name="query" value={p.text}> | |
); | |
} | |
renderSearchButton(p, s){ | |
return (s.parallel.form === 'loading' | |
? <button type="button" name="search" onClick={this.buttonClick.bind(this)} disabled="disabled">Searching...</button> | |
: <button type="button" name="search" onClick={this.buttonClick.bind(this)}>Search</button>); | |
} | |
renderSearchButton(p, s){ | |
return (s.current === 'idle' | |
? <button type="button" name="cancel" onClick={this.buttonClick.bind(this)} disabled="disabled">Cancel</button> | |
: (s.current === 'canceled' | |
? <button type="button" name="cancel" onClick={this.buttonClick.bind(this)}>Canceling...</button> | |
: <button type="button" name="cancel" onClick={this.buttonClick.bind(this)}>Cancel</button>) | |
); | |
} | |
renderList(p, s){ | |
return (p.search_items.length | |
? <ul> | |
p.search_items.map((item) => | |
<li>item</li> | |
); | |
</ul> | |
: <p>No data yet!</p> | |
); | |
} | |
renderErrorMessage(p, s){ | |
let message = `Error Loading Search Results: ${s.error}`; | |
return ( | |
<p> | |
<span>{message}</span> | |
</p> | |
); | |
} | |
renderLoadingMessage(p, s){ | |
let message = `Loading Search Results...`; | |
return ( | |
<p> | |
<span>{message}</span> | |
</p> | |
); | |
} | |
renderResult(p, s){ | |
return ( | |
s.parallel.form === 'loading' | |
? this.renderLoadingMessage(p, s) | |
: s.sub == 'error' ? this.renderErrorMessage(p, s) : this.renderList(p, s) | |
); | |
} | |
render(){ | |
let _props = getState(); | |
<div> | |
<form name="search" onSubmit={ (e) => e.preventDefault() }> | |
{this.renderInput(_props, this.state)} | |
{this.renderSearchButton(_props, this.state)} | |
{this.renderCancelButton(_props, this.state)} | |
</form> | |
{this.renderResult(_props, this.state)} | |
</div> | |
} | |
} | |
export { App } |
This file contains 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
import { ReactDOM } from 'react-dom' | |
import { App } from 'app.js' | |
ReactDOM.render(<App />, document.body); |
This file contains 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
import { transitionFunctionFactory } from './transition_factory_redux.js' | |
import { createStore, applyMiddleware } from 'redux' | |
const thunkMiddleware = ({ dispatch, getState }) => next => action => { | |
if (typeof action === 'function') { | |
return action(dispatch, getState); | |
} | |
return next(action); | |
}; | |
const loggerMiddleware = ({ getState }) => next => action => { | |
console.log("PREV: ", getState()); | |
next(action); | |
console.log("NEXT: ", getState()); | |
}; | |
// create the [ actionDispatcher ] - redux store | |
const store = createStore((state, action) => { | |
switch(action.type){ | |
case "MAKE_ENTRY": | |
return Object.assign({}, state, { | |
search_items:action.data | |
}); | |
break; | |
default: | |
return state; | |
} | |
}, | |
{text:"",search_items:[]}, | |
applyMiddleware(thunkMiddleware, loggerMiddleware) | |
); | |
/** | |
state transition diagram ( maps to the statechart graph below ) | |
------------------> [ canceled ] | |
| | |
[ idle ] -----------> [ searching ] ----------> [ searched { success / error } ] --- | |
^ | | |
|--------------------------------------------------------------------------------- | |
*/ | |
const statechart_transition_graph = { | |
"idle":{ | |
QUERY_BUTTON_CLICK:{ | |
current:"searching", // user action: buttonClick( event ) | |
parallel:{form:"loading"} // | |
} | |
}, | |
"searching":{ | |
AJAX_RESPONSE_SUCCESS:"searched.success", // promise callback: ajaxTaskDoneSideEffect( eventName ) | |
AJAX_RESPONSE_ERROR:"searched.error", // promise callback: ajaxTaskDoneSideEffect( eventName ) | |
AJAX_ABORT_BUTTON_CLICK:"canceled" // user action: buttonClick( event ) | |
}, | |
"searched":{ | |
RENDER_START:{ | |
current:"idle", // render ui: hasScreenData( void ) | |
parallel:{form:"ready"} // | |
} | |
}, | |
"canceled":{ | |
RENDER_START:{ | |
current:"idle", // hasScreenData( void ) | |
parallel:{form:"ready"} // | |
} | |
}, | |
"form":{ | |
} | |
}; | |
// these are the rules that guide each transition | |
const statechart_transition_ruleset = { | |
"searching":{ | |
"_guard":function(action){ | |
return action.text.length > 0 | |
}, // there is a guard for this transition | |
"_action":"MAKE_ENTRY" // calls redux reducer | |
}, | |
"searched":{ | |
"_guard":false, // there is no guard for this transition | |
"_action":null, // doesn't call redux reducer | |
"_history":"searched.$history" // | |
}, | |
"idle":{ | |
"_guard":false, | |
"_action":null | |
}, | |
"canceled":{ | |
"_guard":false, | |
"_action":null | |
} | |
}; | |
const dispatch = store.dispatch.bind(store); | |
const subscribe = store.subscribe.bind(store); | |
const getState = store.getState.bind(store); | |
const transitionFunction = transitionFunctionFactory( | |
dispatch, | |
statechart_transition_graph, | |
statechart_transition_ruleset, | |
{current:'idle', sub:null, parallel:{form:'ready'}} | |
); | |
export { transitionFunction, subscribe, getState } |
This file contains 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
// create a higher-order function to be called on every transition call in response to UI events (clicks, ajax responses, promise resolutions e.t.c) | |
const transitionFunctionFactory = (actionDispatcher, transitionGraph, transitionRuleSet, initialState) => (eventName, behaviourState, action, error = {message:null}) => { | |
// checking if argument passed is "null" or "undefined" and setting a default | |
if(behaviourState == void 0){ | |
behaviourState = initialState; | |
} | |
let transitionSet = transitionGraph[behaviourState.current]; | |
let newBehaviourState = transitionSet[eventName]; | |
let transitionRule = transitionRuleSet[newBehaviourState.current]; | |
// check that the transition is valid | |
if(! newBehaviourState){ | |
throw new Error("Action call on current state invalid"); | |
} | |
let _subState = null; | |
let _parallelState = null; | |
let _nextState = newBehaviourState.current || newBehaviourState; | |
let nextTransitionSet = transitionGraph[_nextState]; | |
// deal with nested states | |
if(_nextState.indexOf(".") + 1){ | |
let hierachies = _nextState.split("."); | |
_nextState = hierachies.shift(); | |
_subState = hierachies.shift(); | |
} | |
// deal with parallel states | |
if(newBehaviourState.parallel){ | |
_parallelState = newBehaviourState.parallel; | |
}else{ | |
_parallelState = behaviourState.parallel; | |
} | |
// deal with actions by dispatching to the redux store | |
// also setup action guards for state transition checks | |
if(transitionRule && transitionRule["_action"] !== null){ | |
let type = transitionRule["_action"]; | |
let actionGuard = transitionRule["_guard"]; | |
if(typeof action != 'function'){ | |
/*if(typeof actionGuard === 'function'){ | |
actionGuard( action ); | |
}*/ | |
action.type = type; | |
}else{ | |
action = function(dispatch, getState) { | |
return action(dispatch, getState, actionGuard, type); | |
} | |
} | |
actionDispatcher( action ); | |
} | |
let _behavior = { | |
current:_nextState, | |
sub:_subState, | |
parallel:_parallelState, | |
error:error.message | |
}; | |
console.log("CURRENT STATE-CHART VALUES: ", _behavior); | |
return _behavior; | |
} | |
const updateAppBehavior = (behavior) => { | |
this.setState( | |
behavior | |
); | |
}; | |
export { updateAppBehavior, transitionFunctionFactory } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment