Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Child (async) actions and promise handling #140

Merged
merged 3 commits into from
Jan 3, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
48 changes: 46 additions & 2 deletions src/ListenerMethods.js
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand Down Expand Up @@ -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);
}
}
},
Expand Down
42 changes: 42 additions & 0 deletions src/PublisherMethods.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably better to check completed and failed are last two elements in this.children.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about this then?

createActions({
    myAction: {'children': ['completed','failed','progressed']}
});

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only if you use the "asyncResult" option. So you could use this:

createActions({
    myAction: {'children': ['progressed'], asyncResult: true}
});

and it would append ['completed','failed'] because of asyncResult.

But you could also do this:

createActions({
    myAction: {'children': ['completed','failed','progressed']}
});

in which case it can still handle promises, but since we've explicitly defined the child actions, they don't appear last in the list.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see. I misunderstood.


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)
*/
Expand Down
20 changes: 18 additions & 2 deletions src/createAction.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand All @@ -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(),
Expand All @@ -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;
14 changes: 9 additions & 5 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
6 changes: 5 additions & 1 deletion src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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){
Expand Down
98 changes: 97 additions & 1 deletion test/creatingActions.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"});
Expand Down Expand Up @@ -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;
Expand All @@ -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
});
});

Expand Down
Loading