diff --git a/README.md b/README.md index fbf8bd6..17243c9 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,61 @@ var Actions = Reflux.createActions([ Actions.statusUpdate(); ``` +#### Asynchronous actions + +For actions that represent asynchronous operations (e.g. API calls), a few separate dataflows result from the operation. In the most typical case, we consider completion and failure of the operation. To create related actions for these dataflows, which you can then access as attributes, use `options.children`. + +```javascript +// this creates 'load', 'load.completed' and 'load.failed' +var Actions = Reflux.createActions({ + "load": {children: ["completed","failed"]} +}); + +// when 'load' is triggered, call async operation and trigger related actions +Actions.load.listen( function() { + // By default, the listener is bound to the action + // so we can access child actions using 'this' + someAsyncOperation() + .then( this.completed ) + .catch( this.failed ); +}); +``` + +There is a shorthand to define the `completed` and `failed` actions in the typical case: `options.asyncResult`. The following are equivalent: + +```javascript +createAction({ + children: ["progressed","completed","failed"] +}); + +createAction({ + asyncResult: true, + children: ["progressed"] +}); +``` + +There are a couple of helper methods available to trigger the `completed` and `failed` actions: + +* `promise` - Expects a promise object and binds the triggers of the `completed` and `failed` child actions to that promise, using `then()` and `catch()`. + +* `listenAndPromise` - Expects a function that returns a promise object, which is called when the action is triggered, after which `promise` is called with the returned promise object. Essentially calls the function on trigger of the action, which then triggers the `completed` or `failed` child actions after the promise is fulfilled. + +Therefore, the following are all equivalent: + +```javascript +asyncResultAction.listen( function(arguments) { + someAsyncOperation(arguments) + .then(asyncResultAction.completed) + .catch(asyncResultAction.failed); +}); + +asyncResultAction.listen( function(arguments) { + asyncResultAction.promise( someAsyncOperation(arguments) ); +}); + +asyncResultAction.listenAndPromise( someAsyncOperation ); +``` + #### Action hooks There are a couple of hooks available for each action. diff --git a/src/ListenerMethods.js b/src/ListenerMethods.js index 3533a7b..5f42db4 100644 --- a/src/ListenerMethods.js +++ b/src/ListenerMethods.js @@ -1,6 +1,49 @@ var _ = require('./utils'), maker = require('./joins').instanceJoinCreator; +/** + * Extract child listenables from a parent from their + * children property and return them in a keyed Object + * + * @param {Object} listenable The parent listenable + */ +mapChildListenables = function(listenable) { + var i = 0, children = {}; + for (;i < (listenable.children||[]).length; ++i) { + childName = listenable.children[i]; + if(listenable[childName]){ + children[childName] = listenable[childName]; + } + } + return children; +}; + +/** + * Make a flat dictionary of all listenables including their + * possible children (recursively), concatenating names in camelCase. + * + * @param {Object} listenables The top-level listenables + */ +flattenListenables = function(listenables) { + var flattened = {}; + for(var key in listenables){ + var listenable = listenables[key]; + var childMap = mapChildListenables(listenable); + + // recursively flatten children + var children = flattenListenables(childMap); + + // add the primary listenable and chilren + flattened[key] = listenable; + for(var childKey in children){ + var childListenable = children[childKey]; + flattened[key + _.capitalize(childKey)] = childListenable; + } + } + + return flattened; +}; + /** * A module of methods related to listening. */ @@ -32,11 +75,12 @@ module.exports = { * @param {Object} listenables An object of listenables. Keys will be used as callback method names. */ listenToMany: function(listenables){ - for(var key in listenables){ + var allListenables = flattenListenables(listenables); + for(var key in allListenables){ var cbname = _.callbackName(key), localname = this[cbname] ? cbname : this[key] ? key : undefined; if (localname){ - this.listenTo(listenables[key],localname,this[cbname+"Default"]||this[localname+"Default"]||localname); + this.listenTo(allListenables[key],localname,this[cbname+"Default"]||this[localname+"Default"]||localname); } } }, diff --git a/src/PublisherMethods.js b/src/PublisherMethods.js index 325cc13..80564d6 100644 --- a/src/PublisherMethods.js +++ b/src/PublisherMethods.js @@ -32,6 +32,7 @@ module.exports = { * @returns {Function} Callback that unsubscribes the registered event handler */ listen: function(callback, bindContext) { + bindContext = bindContext || this; var eventHandler = function(args) { callback.apply(bindContext, args); }, me = this; @@ -41,6 +42,47 @@ module.exports = { }; }, + /** + * Attach handlers to promise that trigger the completed and failed + * child publishers, if available. + * + * @param {Object} The promise to attach to + */ + promise: function(promise) { + var me = this; + + var canHandlePromise = + this.children.indexOf('completed') >= 0 && + this.children.indexOf('failed') >= 0; + + if (!canHandlePromise){ + throw new Error('Publisher must have "completed" and "failed" child publishers'); + } + + promise.then(function(response) { + return me.completed(response); + }).catch(function(error) { + return me.failed(error); + }); + }, + + /** + * Subscribes the given callback for action triggered, which should + * return a promise that in turn is passed to `this.promise` + * + * @param {Function} callback The callback to register as event handler + */ + listenAndPromise: function(callback, bindContext) { + var me = this; + bindContext = bindContext || this; + + return this.listen(function() { + var args = arguments, + promise = callback.apply(bindContext, args); + return me.promise.call(me, promise); + }, bindContext); + }, + /** * Publishes an event using `this.emitter` (if `shouldEmit` agrees) */ diff --git a/src/createAction.js b/src/createAction.js index 63cf217..8fb6c0e 100644 --- a/src/createAction.js +++ b/src/createAction.js @@ -10,9 +10,12 @@ var _ = require('./utils'), * * @param {Object} definition The action object definition */ -module.exports = function(definition) { +var createAction = function(definition) { definition = definition || {}; + if (!_.isObject(definition)){ + definition = {name: definition}; + } for(var a in Reflux.ActionMethods){ if (!allowed[a] && Reflux.PublisherMethods[a]) { @@ -30,6 +33,17 @@ module.exports = function(definition) { } } + definition.children = definition.children || []; + if (definition.asyncResult){ + definition.children = definition.children.concat(["completed","failed"]); + } + + var i = 0, childActions = {}; + for (; i < definition.children.length; i++) { + var name = definition.children[i]; + childActions[name] = createAction(name); + } + var context = _.extend({ eventLabel: "action", emitter: new _.EventEmitter(), @@ -40,10 +54,12 @@ module.exports = function(definition) { functor[functor.sync?"trigger":"triggerAsync"].apply(functor, arguments); }; - _.extend(functor,context); + _.extend(functor,childActions,context); Keep.createdActions.push(functor); return functor; }; + +module.exports = createAction; diff --git a/src/index.js b/src/index.js index a81ae12..22d351b 100644 --- a/src/index.js +++ b/src/index.js @@ -29,17 +29,21 @@ exports.joinStrict = maker("strict"); exports.joinConcat = maker("all"); +var _ = require('./utils'); /** * Convenience function for creating a set of actions * - * @param actionNames the names for the actions to be created + * @param definitions the definitions for the actions to be created * @returns an object with actions of corresponding action names */ -exports.createActions = function(actionNames) { - var i = 0, actions = {}; - for (; i < actionNames.length; i++) { - actions[actionNames[i]] = exports.createAction(); +exports.createActions = function(definitions) { + var actions = {}; + for (var k in definitions){ + var val = definitions[k], + actionName = _.isObject(val) ? k : val; + + actions[actionName] = exports.createAction(val); } return actions; }; diff --git a/src/utils.js b/src/utils.js index fe5e2ee..c9b31f0 100644 --- a/src/utils.js +++ b/src/utils.js @@ -31,8 +31,12 @@ exports.nextTick = function(callback) { setTimeout(callback, 0); }; +exports.capitalize = function(string){ + return string.charAt(0).toUpperCase()+string.slice(1); +}; + exports.callbackName = function(string){ - return "on"+string.charAt(0).toUpperCase()+string.slice(1); + return "on"+exports.capitalize(string); }; exports.object = function(keys,vals){ diff --git a/test/creatingActions.spec.js b/test/creatingActions.spec.js index b192ed7..29a2d5b 100644 --- a/test/creatingActions.spec.js +++ b/test/creatingActions.spec.js @@ -23,6 +23,28 @@ describe('Creating action', function() { assert.equal(action.random, def.random); }); + it("should create specified child actions",function(){ + var def = {children: ["foo","BAR"]}, + action = Reflux.createAction(def); + + assert.deepEqual(action.children, ["foo", "BAR"]); + assert.equal(action.foo._isAction, true); + assert.deepEqual(action.foo.children, []); + assert.equal(action.BAR._isAction, true); + + }); + + it("should create completed and failed child actions for async actions",function(){ + var def = {asyncResult: true, sync: true}, + action = Reflux.createAction(def); + + assert.equal(action.asyncResult, true); + assert.deepEqual(action.children, ["completed", "failed"]); + assert.equal(action.completed._isAction, true); + assert.equal(action.failed._isAction, true); + }); + + it("should throw an error if you overwrite any API other than preEmit and shouldEmit",function(){ assert.throws(function(){ Reflux.createAction({listen:"FOO"}); @@ -181,6 +203,79 @@ describe('Creating action', function() { }); +describe('Creating actions with children to an action definition object', function() { + var actionNames, actions; + + beforeEach(function () { + actionNames = {'foo': {asyncResult: true}, 'bar': {children: ['baz']}}; + actions = Reflux.createActions(actionNames); + }); + + it('should contain foo and bar properties', function() { + assert.property(actions, 'foo'); + assert.property(actions, 'bar'); + }); + + it('should contain action functor on foo and bar properties with children', function() { + assert.isFunction(actions.foo); + assert.isFunction(actions.foo.completed); + assert.isFunction(actions.foo.failed); + assert.isFunction(actions.bar); + assert.isFunction(actions.bar.baz); + }); + + describe('when listening to the child action created this way', function() { + var promise; + + beforeEach(function() { + promise = Q.promise(function(resolve) { + actions.bar.baz.listen(function() { + resolve(Array.prototype.slice.call(arguments, 0)); + }, {}); // pass empty context + }); + }); + + it('should receive the correct arguments', function() { + var testArgs = [1337, 'test']; + actions.bar.baz(testArgs[0], testArgs[1]); + + return assert.eventually.deepEqual(promise, testArgs); + }); + }); + + describe('when promising an async action created this way', function() { + var promise; + + beforeEach(function() { + // promise resolves on foo.completed + promise = Q.promise(function(resolve) { + actions.foo.completed.listen(function(){ + resolve.apply(null, arguments); + }, {}); // pass empty context + }); + + // listen for foo and return a promise + actions.foo.listenAndPromise(function() { + var args = Array.prototype.slice.call(arguments, 0); + var deferred = Q.defer(); + + setTimeout(function() { + deferred.resolve(args); + }, 0); + + return deferred.promise; + }); + }); + + it('should invoke the completed action with the correct arguments', function() { + var testArgs = [1337, 'test']; + actions.foo(testArgs[0], testArgs[1]); + + return assert.eventually.deepEqual(promise, testArgs); + }); + }); +}); + describe('Creating multiple actions to an action definition object', function() { var actionNames, actions; @@ -207,8 +302,9 @@ describe('Creating multiple actions to an action definition object', function() beforeEach(function() { promise = Q.promise(function(resolve) { actions.foo.listen(function() { + assert.equal(this, actions.foo); resolve(Array.prototype.slice.call(arguments, 0)); - }); + }); // not passing context, should default to action }); }); diff --git a/test/usingListenerMethodsMixin.spec.js b/test/usingListenerMethodsMixin.spec.js index 6661bf6..aa24945 100644 --- a/test/usingListenerMethodsMixin.spec.js +++ b/test/usingListenerMethodsMixin.spec.js @@ -7,21 +7,32 @@ describe("using the ListenerMethods",function(){ var ListenerMethods = Reflux.ListenerMethods; describe("the listenToMany function",function(){ - var listenables = { foo: "FOO", bar: "BAR", baz: "BAZ", missing: "MISSING"}, + var listenables = { foo: "FOO", bar: "BAR", baz: "BAZ", missing: "MISSING", parent: { + children: ['foo', 'bar', 'baz', "notThere"], foo: "FOOChild", bar: "BARChild", baz: "BAZChild" + }}, context = { onFoo:"onFoo", bar:"bar", onBaz:"onBaz", onBazDefault:"onBazDefault", + onParent: "onParent", + onParentFoo: "onParentFoo", + parentBar: "parentBar", + onParentBaz: "onParentBaz", + onParentBazDefault: "onParentBazDefault", listenTo:sinon.spy() }; Reflux.ListenerMixin.listenToMany.call(context,listenables); it("should call listenTo for all listenables with corresponding callbacks",function(){ - assert.equal(context.listenTo.callCount,3); + assert.equal(context.listenTo.callCount,7); assert.deepEqual(context.listenTo.firstCall.args,[listenables.foo,"onFoo","onFoo"]); assert.deepEqual(context.listenTo.secondCall.args,[listenables.bar,"bar","bar"]); assert.deepEqual(context.listenTo.thirdCall.args,[listenables.baz,"onBaz","onBazDefault"]); + assert.deepEqual(context.listenTo.getCall(3).args,[listenables.parent,"onParent","onParent"]); + assert.deepEqual(context.listenTo.getCall(4).args,[listenables.parent.foo,"onParentFoo","onParentFoo"]); + assert.deepEqual(context.listenTo.getCall(5).args,[listenables.parent.bar,"parentBar","parentBar"]); + assert.deepEqual(context.listenTo.getCall(6).args,[listenables.parent.baz,"onParentBaz","onParentBazDefault"]); }); }); diff --git a/test/usingPublisherMethodsMixin.spec.js b/test/usingPublisherMethodsMixin.spec.js index a3e8f27..615400d 100644 --- a/test/usingPublisherMethodsMixin.spec.js +++ b/test/usingPublisherMethodsMixin.spec.js @@ -1,11 +1,67 @@ var chai = require('chai'), assert = chai.assert, Reflux = require('../src'), + Q = require('q'), sinon = require('sinon'); describe("using the publisher methods mixin",function(){ var pub = Reflux.PublisherMethods; + describe("the promise method",function(){ + + describe("when the promise completes",function(){ + var deferred = Q.defer(), + promise = deferred.promise, + context = { + children:['completed','failed'], + completed:sinon.spy(), + failed:sinon.spy() + }, + result = pub.promise.call(context,promise); + + deferred.resolve('foo'); + + it("should not return a value",function(){ + assert.equal(result, undefined); + }); + + it("should call the completed child trigger",function(){ + var args = context.completed.firstCall.args; + assert.deepEqual(args, ["foo"]); + }); + + it("should not call the failed child trigger",function(){ + assert.equal(context.failed.callCount, 0); + }); + }); + + describe("when the promise fails",function(){ + var deferred = Q.defer(), + promise = deferred.promise, + context = { + children:['completed','failed'], + completed:sinon.spy(), + failed:sinon.spy() + }, + result = pub.promise.call(context,promise); + + deferred.reject('bar'); + + it("should not return a value",function(){ + assert.equal(result, undefined); + }); + + it("should call the failed child trigger",function(){ + var args = context.failed.firstCall.args; + assert.deepEqual(args, ["bar"]); + }); + + it("should not the completed child trigger",function(){ + assert.equal(context.completed.callCount, 0); + }); + }); + }); + describe("the listen method",function(){ var emitter = { addListener:sinon.spy(),