UNPKG

sebastian

Version:

A flow-control library aimed at encouraging organized, testable code.

799 lines (681 loc) 22.5 kB
/*jshint loopfunc:true*/ (function(root, factory) { // Set up appropriately for the environment. if (typeof exports !== 'undefined') { // Node/CommonJS, need jQuery-deferred instead of regular jQuery exports.flow = factory(root, {}, require('jquery-deferred')); } else if (typeof define === 'function' && define.amd) { // AMD define(['jquery', 'exports'], function($, exports) { return {flow : factory(root, exports, $)}; }); } else { // Browser globals var flow = factory(root, {}, root.jQuery); //Set it's own window property window.Sebastian = {flow : flow}; //Attach to jQuery $.Flow = flow; } }(this, function(root, Flow, $) { var internals = { knownEvents : ["step-succeeded", "step-failed"], /** * Private method for executing a step * * Meant to be called with .call(step), where step was created via public API .step(name, callback); * * @param ctx * @param args * @api private * @return Deferred */ step : function(ctx, args) { return this.callback.apply(ctx, args); }, stepWithEvents : function(ctx, args, flow) { var stepResult = internals.step.call(this, ctx, args); internals.fireStepCallbacks.call(this, stepResult, flow); return stepResult; }, fireStepCallbacks : function(stepResult, flow) { var self = this; if (!flow.callbackLists) { return; } if (flow.callbackLists["step-succeeded"]) { $.when(stepResult).done(function() { flow.callbackLists["step-succeeded"].fire(self.name); }); } if (flow.callbackLists["step-failed"]) { $.when(stepResult).fail(function() { flow.callbackLists["step-failed"].fire(self.name); }); } }, /** * Create a shallow copy/clone * * @param source * @api private * @return object */ clone : function(source) { var clone = {}; for (var prop in source) { clone[prop] = source[prop]; } return clone; }, onceCache : {} }; /** * Execution constructor * * @param flow * @api private */ var execution = function(flow) { this.masterPromise = $.Deferred(); this.flow = flow; this.ctx = internals.clone(flow.ctx); }; /** * Set the execution context..useful when you need a context that's completely isolated from other execution contexts * flow.context() can be used to set execution context for all executions, but it is only shallow-cloned when * a new execution is created. Use this to set an execution-specific context * * @param ctx */ execution.prototype.context = function(ctx) { this.ctx = ctx; return this; }; /** * Start/begin an execution. An execution is essentially an instance of a flow, with its own scope/context that * is isolated from other instances. * * @returns {Deferred} */ execution.prototype.execute = function() { /*! * If once() applied, check for a cached deferred */ if (this.flow.runOnce) { var cached = internals.onceCache[this.flow.name]; /*! * If cached, use the cached promise, otherwise cache it */ if (cached) { return cached; } else { internals.onceCache[this.flow.name] = this.masterPromise; } } //generate execution plan based on starting step name and 'skipped' steps var self = this, stepsToExecute = [], startStepReached = false; //build execution plan for (var i = 0; i < this.flow.steps.length; i++) { if ( !startStepReached && this.flow.startingStep && this.flow.steps[i].name !== this.flow.startingStep) { continue; } if (this.flow.skipSteps.indexOf(this.flow.steps[i].name) >= 0) { continue; } startStepReached = true; stepsToExecute.push(this.flow.steps[i]); } if (stepsToExecute.length === 0) { return $.Deferred().reject("There are no steps to execute in current execution plan."); } var promise, args = [].slice.call(arguments); if (stepsToExecute.length === 1) { promise = $.when(internals.step.call(stepsToExecute[0], self.ctx, args)); } else { promise = this.modes[this.flow.mode].call(self, this.ctx, stepsToExecute, args); } //attach failure callback wrapper...this will get the failure result and decide whether to invoke conditional //callbacks or the general failure callback this.attachFailCallback(promise); //jump to any flows specified by 'jumpTo' this.attachFailDelegate(promise); this.attachSuccessDelegate(promise); promise.then(this.masterPromise.resolve); return this.masterPromise; }; execution.prototype.on = function(eventName, callback) { if (internals.knownEvents.indexOf(eventName) === -1) { return this; } if (!this.callbackLists) { this.callbackLists = {}; } if (!this.callbackLists[eventName]) { this.callbackLists[eventName] = $.Callbacks(); } this.callbackLists[eventName].add(callback); return this; }; /** * Attach a fail callback to the execution * * @param {Promise} promise * @api private */ execution.prototype.attachFailCallback = function(promise) { var self = this; //Attach wrapper failure callback promise.fail(function(response) { var code = response && response.responseCode ? response.responseCode : response; var failureCallback, conditionalCallback = self.flow.conditionalFailCallbacks[code]; //If there's a conditional failure callback, set it to common variable if (conditionalCallback) { failureCallback = conditionalCallback; //Else, set it to the default (declared on flow w/ onFailure()) } else if (self.flow.defaultFailCallback) { failureCallback = self.flow.defaultFailCallback; //Else return } else { return; } //If there's a context, invoke .call(ctx) if (self.ctx) { failureCallback.call(self.ctx, response); //Else call 'normally' } else { failureCallback(response); } }); }; /** * Attach flow to handle failure * * @param {Promise} promise * @api private */ execution.prototype.attachFailDelegate = function(promise) { var self = this; promise.fail(function(response) { var code = response && response.responseCode ? response.responseCode : response; var failureDelegate, conditionalDelegate = self.flow.conditionalFailDelegates[code]; //If there's a conditional failure callback, set it to common variable if (conditionalDelegate) { failureDelegate = conditionalDelegate; } else if (self.flow.defaultFailDelegate) { //Else if there's a defaultFailDelegate, set it to the default (declared on flow class) failureDelegate = self.flow.defaultFailDelegate; } else { /** * Else if we found no delegate, go ahead and reject the main promise for the flow */ return self.masterPromise.reject(response); } //TODO: figure out why I can't do delegate.flow instanceof flow - flow is null for some reason if (!failureDelegate.flow) { throw new Error("Flow Error: a failure flow was provided, but it doesn't appear to be a flow."); } failureDelegate.context(self.ctx) .begin() .then(self.masterPromise.resolve); }); }; /** * Attach flow to handle success * * @param {Promise} promise * @api private */ execution.prototype.attachSuccessDelegate = function(promise) { var exe = this; if (this.flow.defaultSuccessDelegate) { promise.then(function() { var delegate = exe.flow.defaultSuccessDelegate; //TODO: figure out why I can't do delegate.flow instanceof flow - flow is null for some reason if (delegate.flow !== true) { throw new Error("Flow Error: a success flow was provided, but it doesn't appear to be a flow."); } delegate.context(exe.ctx) .begin() .then(exe.masterPromise.resolve); }); } }; execution.prototype.modes = {}; /** * Execute the steps sequentially, and pass return value from upstream step to downstream step args * * @param ctx * @param stepsToExecute * @param args pass to first step * @api private * @return {*} */ execution.prototype.modes.waterfall = function(ctx, stepsToExecute, args) { var self = this, masterPromise = this.masterPromise, lastPromise, chain = function(step) { return function() { //get the arguments into an array var args = [].slice.call(arguments); //check for the resolve command, and if found stop the flow in its tracks. if (args[0] === "flow::resolve") { //remove first arg, which is the special command args.shift(); //resolve the master promise masterPromise.resolve.apply(masterPromise, args); //return (and stop flow execution). return; } else if (args[0] === "flow::resolve::tether") { //tether master promise to deferred argument...arg[1] is expected to be a Deferred! return args[1] .then(masterPromise.resolve, masterPromise.reject); } return internals.stepWithEvents.call(step, ctx, args, self); }; }; for (var i = 0; i < stepsToExecute.length; i++) { var step = stepsToExecute[i]; //If no promise set, then it's the first step. Execute it and continue if (!lastPromise) { lastPromise = $.when(internals.stepWithEvents.call(step, ctx, args, self)); continue; } lastPromise = $.when(lastPromise).then(chain(step)); } return lastPromise; }; /** * Execute all steps at once * * @param ctx * @param stepsToExecute * @param args passed to all steps * @api private * @return {*} */ execution.prototype.modes.parallel = function(ctx, stepsToExecute, args) { var promises = []; for (var index in stepsToExecute) { promises.push(internals.stepWithEvents.call(stepsToExecute[index], ctx, args, this)); } return $.when.apply(null, promises); }; /** * flow constructor * @param name * @api private */ var flow = function(name) { /*! * The flow name */ this.name = name; /*! * Simple flag to use when determining if step should be executed as a flow or callback */ this.flow = true; /*! * The 'this' context for the flow steps */ this.ctx = null; /*! * List of steps in the flow */ this.steps = []; /*! * A list of step names to skip */ this.skipSteps = []; /*! * Step with which to start the flow. */ this.startingStep = null; /*! * List of conditional fail callbacks */ this.conditionalFailCallbacks = {}; /*! * List of conditional fail delegates */ this.conditionalFailDelegates = {}; /*! * The default fail callback. This is fired if no appropriate conditional fail callback is used. */ this.defaultFailCallback = null; /*! * The default fail flow. This is fired if no appropriate conditional fail flow is used. */ this.defaultFailDelegate = null; /*! * The default success flow. This is fired when the flow is resolved. Used to chain flows together */ this.defaultSuccessDelegate = null; /*! * The default execution mode is waterfall */ this.mode = "waterfall"; /*! * Expose for testing, would rather do this another way.. */ this.internals = { execution : execution }; }; /** * Get or set a step. * * Basic usage: * * - ```flow.step(name, callback);``` * * Overloads: * * - ```flow.step(callback);``` * - ```flow.step(name, flow);``` * - ```flow.step(flow);``` * * @param {Object} arg1 name of the step or a step callback (when no name is specified) * @param {Object} arg2 step callback/flow. If this is falsey and first arg is a string the function acts as a getter, otherwise it's a setter * @api public * @return mixed Flow or step */ flow.prototype.step = function(arg1, arg2) { var self = this, wrapFlowCallback = function(flow) { return function() { return flow.context(this).begin.apply(flow, [].slice.call(arguments)); }; }; //Check if second arg is present, if so add/create step object with explicit name if (arg2) { if (typeof arg2 === "string") { arg2 = Flow(arg2); } this.steps.push({ name : arg1, callback : arg2.flow ? wrapFlowCallback(arg2) : arg2 //if callback is a flow, create a flow wrapper callback }); return this; } else if (typeof arg1 === "function"){ //If first arg is a function, make up a name for them this.steps.push({ name : "step." + (this.steps.length + 1), callback : arg1 }); return this; } else if (typeof arg1 === "object" && arg1.flow) { //If first arg is an object, make up a name for them and create a flow wrapper callback this.steps.push({ name : "step." + (this.steps.length + 1) + "." + arg1.name, callback : wrapFlowCallback(arg1) }); return this; } else { var foundStep; for (var i = 0; i < this.steps.length; i++) { if (self.steps[i].name === arg1) { foundStep = self.steps[i]; break; } } return foundStep; } }; /** * Set flow to execute all steps in parallel * * Usage: * * - ```flow.parallel()``` * * * @return Flow */ flow.prototype.parallel = function() { this.mode = "parallel"; return this; }; /** * Set flow to execute steps in sequence, passing results from upstream step as argument to downstream step * * Usage: * * - ```flow.waterfall()``` * * @return Flow */ flow.prototype.waterfall = function() { this.mode = "waterfall"; return this; }; /** * Begin the flow. All arguments are passed on to either first step (waterfall) or all steps (parallel). * * Basic usage: * * - ```flow.begin()``` * * Overloads: * * - ```flow.begin(arg1, arg2, arg3, ...);``` * * @return Flow */ flow.prototype.begin = function() { var exe = new execution(this); return exe.execute.apply(exe, [].slice.call(arguments)); }; /** * Create a new execution, but don't start it, just return. Useful for setting context on the execution when you need isolation. * * e.g.: * * ``` * var execution = flow.create(); * execution.context({ some : "value"}); * execution.execute(); * ``` * * @returns {execution} */ flow.prototype.create = function() { return new execution(this); }; /** * Specify the 'this' context for the flow * * Usage : * * - ```flow.context(object);``` * * @param {Object} context the context object for the flow * @return {Flow} flow */ flow.prototype.context = function(context) { this.ctx = context; return this; }; /** * Specify a step to start the flow on * * Usage: * * - ```flow.startOn(stepName);``` * * @param {String} name name of the step to skip * @return {Flow} flow */ flow.prototype.startOn = function(name) { this.startingStep = name; return this; }; /** * Set the flow to only run one time...always returning the promise from the first execution. * * @returns {Flow} */ flow.prototype.once = function() { this.runOnce = true; return this; }; /** * Specify a step to skip * * Usage: * * - ``` flow.skip(stepName);``` * * @param {String} name name of the step to skip * @return {Flow} Flow */ flow.prototype.skip = function(name) { this.skipSteps.push(name); return this; }; /** * Add a delay after execution of step added last * * @param {Number} ms milliseconds to delay * @return {Flow} */ flow.prototype.delay = function(ms) { var last = this.steps[this.steps.length - 1]; this.step(last.name + "-delay", function() { var deferred = $.Deferred(); setTimeout(deferred.resolve, ms); return deferred; }); return this; }; /** * Used to attach failure callbacks/delegates * * @param responseCode optional responseCode to be used w/ conditional fail callbacks/delegates * @return {Object} */ flow.prototype.onFailure = function(responseCode) { var self = this, storeDelegate = function(flow) { if (responseCode) { self.conditionalFailDelegates[responseCode] = flow; } else { self.defaultFailDelegate = flow; } return self; }, onFailureOptions = {}; /** * Handle failure with a callback * * Usage: * * - ```flow.onFailure().handleWith(callback);``` * - ```flow.onFailure(responseCode).handleWith(callback);``` * * @param {Function} callback the callback to execute on failure * @return Flow */ onFailureOptions.handleWith = function(callback) { if (responseCode) { self.conditionalFailCallbacks[responseCode] = callback; } else { self.defaultFailCallback = callback; } return self; }; /** * Handle failure by starting the flow over * * Usage: * * - ```flow.onFailure().loop();``` * - ```flow.onFailure(responseCode).loop();``` * * @return {Flow} flow */ onFailureOptions.loop = function() { return storeDelegate(self); }; /** * Handle failure by jumping to another flow * * Usage: * * - ```flow.onFailure().jumpTo(flow);``` * - ```flow.onFailure(responseCode).jumpTo(flow);``` * * @param {Flow} flow the flow to delegate to on failure * @return {Flow} flow */ onFailureOptions.jumpTo = function(flow) { return storeDelegate(flow); }; return onFailureOptions; }; /** * Add success condition flows * * @return {Object} */ flow.prototype.onSuccess = function() { var self = this, onSuccessOptions = {}; /** * Handle Success by jumping to another flow * * Usage: * * - ```flow.onSuccess().jumpTo(flow);``` * * @param {Flow} flow the flow to delegate to on success * @return {Flow} flow */ onSuccessOptions.jumpTo = function(flow) { self.defaultSuccessDelegate = flow; return self; }; /** * Handle success by starting the flow over */ onSuccessOptions.loop = function() { self.defaultSuccessDelegate = self; return self; }; return onSuccessOptions; }; /** * Alias for onSuccess().loop() * * */ flow.prototype.loop = function() { this.onSuccess().loop(); return this; }; /** * Flow factory * * @param {String} name name of the flow * @return {Flow} * @constructor */ Flow = function(name) { return new flow(name); }; return Flow; }));