Skip to content

Instantly share code, notes, and snippets.

@isocroft
Last active November 13, 2018 11:41
Show Gist options
  • Save isocroft/ec3345550880006cf22e73306fd417e4 to your computer and use it in GitHub Desktop.
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
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 }
import { ReactDOM } from 'react-dom'
import { App } from 'app.js'
ReactDOM.render(<App />, document.body);
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 }
// 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