Handling 'back' events #4

Closed
burin opened this Issue Apr 11, 2012 · 5 comments

Projects

None yet

2 participants

@burin
burin commented Apr 11, 2012

I've been using machina.js to model workflows and it's been working great so far. One thing I support is the ability for a user to go back in the workflow.

In any given state, a user can "save" their progress, transitioning to the next state, or go "back", which will take them back to the previous state.

Currently, each state has an explicit handler for "back", which calls transition() with the appropriate name of the expected action to go back to. Unfortunately, this only allows for "linear" progress, and conditional transitions are a little tricky to deal with.

Here is a stripped down example of a workflow I have created for adding a flight to an itinerary.

define([
    'machina', 'mediator'
], function(
    machina, mediator
) {
var AddFlightWorkflow = window.workflow = new machina.Fsm({
    initialState: 'uninitialized',

    states: {
        uninitialized: {
            initialize: function() {
                this.transition( 'showingForm' );
            }
        },

        // gathering user input
        showingForm: {
            _onEnter: function() {
                // show flights:add view,
            },
            selectDate: function() {
                this.transition( 'selectingDate' );
            },
            selectCarrier: function() {
                this.transition( 'selectingCarrier' );
            },
            save: function() {
                this.transition( 'selectingOriginDestinationAirports' );
            },
            back: function() {
                // kill this workflow
            }
        },

        selectingDate: {
            _onEnter: function() {
                // show calendar widget
            },
            save: function() {
                // save the new user date selection to the model
                // kill the widget
                // transition back
                this.transition( 'showingForm' );
            },
            back: function() {
                // kill the view/widget
                this.transition( 'showingForm' );
            }
        },

        selectingCarrier: {
            _onEnter: function() {
                // show carrier selection widget
            },
            save: function () {
                // save the user selected carrier
                // kill the widget
                // transition back
                this.transition( 'showingForm' );
            },
            back: function() {
                // kill the view/widget
                this.transition( 'showingForm' );
            }
        },

        // after submitting user data to svc (flight paths)
        selectingOriginDestinationAirports: {
            _onEnter: function() {
                // if the response had more than one option, show view
                // otherwise transition to confirmingFlight
            },
            save: function() {
                this.transition( 'confirmingFlight' );
            },
            back: function() {
                // transition to the last thing
                this.transition( 'showingForm' );
            }
        },

        // prior to sending data to svc (monitor flights)
        confirmingFlight: {
            _onEnter: function() {
                // show confirmation view
            },
            save: function() {
                // finally! we can save all the data
                this.transition( 'uninitialized' );
            },
            back: function() {
                // transition to the last thing
                this.transition( 'selectingOriginDestinationAirports' );
            }

        }
    }
});

mediator.subscribe( 'add_flight_workflow:initialize', function() {
    AddFlightWorkflow.handle('initialize');
});

mediator.subscribe( 'add_flight_workflow:save', function() {
    AddFlightWorkflow.handle('save');
});

mediator.subscribe( 'add_flight_workflow:back', function() {
    AddFlightWorkflow.handle('back');
});

mediator.subscribe( 'add_flight_workflow:selectDate', function() {
    AddFlightWorkflow.handle('selectDate');
});

mediator.subscribe( 'add_flight_workflow:selectCarrier', function() {
    AddFlightWorkflow.handle('selectCarrier');
});

return AddFlightWorkflow;
});

My question is whether this is a good approach to tackle this problem or if there is some alternative. On one hand, it's good because it's very explicit. On the other, it doesn't seem very DRY and I'm not sure how to go "back" if a particular state has two or more possible entry points (confirmingFlight can be transitioned to from showingForm in one case, or selectingOriginDestinationAirports in another)

I tried looking through the machina.js code to see if there was a "previous state" of some sort, but I didn't want to dig into something that could possibly change in the future (private API or such).

Any tips would be appreciated! And thanks for publishing your blog post about FSMs and creating a super clean library with a nice API!

@ifandelse
Owner

@burin - My apologies that it has taken me two days to get back to you! Thanks a ton for including such a clean code example as part of your question - it's really helpful (not to mention exciting to see machina being put to good use!). I've wrestled a lot with this same kind of question, but 90% of the time I err on the side of being explicit. So - at the outset - I find your approach expressive, and appreciate the explicit nature of the transitions. I hadn't thought about adding a "previousState" member to the fsm, but I can see how that could be useful. I'm working on finalizing v0.2.0 and can include that as part of it. I'm not sure how useful this style of approach could be to you, but one thing I've found myself doing (though this is focused on linear progression through states) can be seen in the "load" example:

my fsm has a "constraints" object I add that looks something like this:

constraints: {
            waitingOnTemplates: {
                nextState: "waitingOnData",
                checkList: {
                    haveMainTemplate: false,
                    haveItemTemplate: false,
                    haveErrorTemplate: false
                }
            },
            waitingOnData: {
                nextState: "ready",
                attempts: 0,
                checkList: {
                    haveItemData: false
                }
            }
        }

Each member of constraints is a state name, and contains a "nextState" (for the linear progression) and a checklist of bools that have to be true before the state transition can occur. In handlers for each state, I'll call the checkIfReady method that looks like this:

checkIfReady: function() {
            if(_.all(this.constraints[this.state].checkList, function(constraint) { return constraint; })) {
                this.transition(this.constraints[this.state].nextState);
            }
        }

So - to handle non-linear forward-and-back transitions, a couple of options come to mind:

  • If it's a simple matter of moving to the last state the fsm was in, I can add "previousState" to the fsm and you could call this.transition(this.previousState) (or even add a this.transitionToPrevious() utility method).
  • Or - adapt the above approach using something like a constraints object, but with more logic/metadata to help the fsm decide which state should be next. I'm completely brainstorming here, but you could potentially do something like this:

(including this as part of the new FSM options arg, and calling determineTransition() from within whatever handler(s) you need):

// There are probably better ways to do this, but the gist
    // is to iterate through the "conditions" until a state is
    // found that matches the current checklist (or the current
    // state is used instead....
    determineTransition: function() {
        return _.reduce(this.transitionFlags, function(memo, v, k) {
            if (!memo && _.isEqual(v.conditions, this.checkList)) {
                return k;
            }
            return memo;
        }, "", this) || this.state;
    },

    // Working check list of where things are
    checkList: {
        constraint1: false,
        constraint2: true,
        constraint3: false,
    },

    // the flags that determine when a state
    // should be the transition target
    transitionFlags: {
        stateA: {
            conditions: {
                constraint1: false,
                constraint2: false,
                constraint3: false
            }
        },
        stateB: {
            conditions: {
                constraint1: false,
                constraint2: true,
                constraint3: false
            }
        },
        stateC: {
            conditions: {
                constraint1: true,
                constraint2: true,
                constraint3: false
            }
        }
    }
    // based on the "checkList" values above, if determineTransition() was called,
    // it would transition the fsm to stateB

Of course, that's a bit contrived (and it might be a horrible idea), but it might also spark some ideas. At the end of the day, if you find "DRY" and "SOLID" principles in conflict, my advice would be to err on the side of SOLID. Let me know if you have any thoughts. Thanks!

@burin
burin commented Apr 13, 2012

@ifandelse Thanks for taking the time to address this question! Your response has definitely sparked some thoughts.

I think the constraints object is definitely a good approach, especially as the workflows can become more complex.

In my particular example though, I think changing the confirmingFlights:back handler to include a this.transition( this.previousState ) would be the simplest to implement. I wouldn't change the other handlers to use it though, for the sake of allowing the code to be easily followed.

Another thing to note is that this.transition( this.previousState ) and this.transitionToPrevious() could potentially differ in their behavior (i.e. maybe a state has a different _onEnter when you use transitionToPrevious()). That being said, I think transitionToPrevious() is probably best left to be implemented by me if I needed to do something like that, and machina.js could just provide the hook into previousState. Providing an example of a transitionToPrevious() implementation might be good enough.

Your example of the checkList will definitely come in handy for a past project I've worked on (that we will have to extend in the future). In that case, we have an extremely complex workflow that has policy/permission checks. An example is something like "you can only book the flight if it's not First Class, but if you have a certain permission, you could give a proper reason and still be allowed to book" or "you're exempt from all these checks because you're the CEO ;)".

Using the checkList / constraints approach would allow us to quickly add additional conditions and test them in an automated fashion without having someone clicking through the app trying every single scenario :)

Thanks for the examples! They have helped me understand the possibilities more than anything. Since machina.js IS so flexible, it's hard to see what you could do with it, but having that flexibility is definitely one of the strengths.

@ifandelse
Owner

@burin - Sounds great. I'll leave this issue open until I commit v0.2.0 (which will include the previousState member). I think your suggestion to leave the implementation of transitionToPrevious is a wise one (and I'd love to see what you do with that if you do implement it). It's always easier to add those details into machina later, if it seems like the core lib should have it, rather than force one in now and rip it out later. I'm really impressed with how you're using it - keep me posted on thoughts/concerns you have going forward. Glad I was able to help!

@ifandelse
Owner

I've released version 0.2.0 - added a priorState member to the FSM. Thanks!

@ifandelse ifandelse closed this Apr 25, 2012
@burin
burin commented Apr 26, 2012

Thanks for getting this in! This is great work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment