diff --git a/backbone.js b/backbone.js index 28e9e30eb..78ecabf33 100644 --- a/backbone.js +++ b/backbone.js @@ -586,6 +586,98 @@ if (!error) return true; this.trigger('invalid', this, error, _.extend(options, {validationError: error})); return false; + }, + + // Listens for changes on the model such that: + // + // * Multiple sequential model updates cause the callback + // to execute only once + // * The callback is only executed if all dependency property + // values are defined + // + // `model.when(dependencies, callback [, thisArg])` + // + // * `dependencies` specifies the names of model properties that are + // dependencies of the callback function. `dependencies` can be + // * a string (in the case of a single dependency) or + // * an array of strings (in the case of many dependencies). + // * `callback(values...)` the callback function that is invoked after dependency + // properties change. The values of dependency properties are passed + // as arguments to the callback, in the same order specified by `dependencies`. + // * `thisArg` value to use as `this` when executing `callback`. + // * returns a `whens` object containing + // * a chainable `when` function + // * `callbacks` an object containing the callbacks for all + // `when` calls in the chain, an array of objects with: + // * `properties` an array of property names + // * `fn` the callback function added to the properties + when: function (dependencies, fn, thisArg) { + + // The list of callbacks added, exposed as `whens.callbacks` + var callbacks = [], + + // Grab `this` for later use. + model = this; + + // Create a function inside the closure with `callbacks`. + function _when(dependencies, fn, thisArg){ + + // Support passing a single string as `dependencies` + if(!(dependencies instanceof Array)) { + dependencies = [dependencies]; + } + + // `callFn()` will invoke `fn` with values of dependency properties + // on the next tick of the JavaScript event loop. + var callFn = _.debounce(function(){ + + // Extract the values for each dependency property. + var args = dependencies.map(model.get, model), + allAreDefined = !args.some(function (d) { + return typeof d === 'undefined' || d === null; + }); + + // Only call the function if all values are defined. + if(allAreDefined) { + + // Call `fn` with the dependency property values. + fn.apply(thisArg, args); + } + }, 0); + + // Invoke `fn` once for initialization. + callFn(); + + // Invoke `fn` when dependency properties change. + dependencies.forEach(function(property){ + + // Listen for changes on the property. + model.on('change:' + property, callFn); + + // Store the added callbacks for canceling later. + callbacks.push({ + property: property, + fn: callFn + }); + }); + + return { + when: _when, + callbacks: callbacks + }; + } + + return _when(dependencies, fn, thisArg); + }, + // Cancels previously added `when` callback functions. + // + // `model.cancel(whens)` + // + // * `whens` the object returned from `when` or a chain of `when` calls + cancel: function(whens){ + whens.callbacks.forEach(function (callback) { + this.off('change:' + callback.property, callback.fn); + }, this); } }); diff --git a/test/model.js b/test/model.js index 89f01cf0d..01c637a2e 100644 --- a/test/model.js +++ b/test/model.js @@ -1127,4 +1127,252 @@ model.set({a: true}); }); + asyncTest("when listens for changes to a single property", 1, function() { + var model = new Backbone.Model(); + model.when("x", function (x) { + strictEqual(x, 30); + start(); + }); + model.set("x", 30); + }); + + asyncTest("when calls fn once to initialize", function() { + var model = new Backbone.Model(); + model.set("x", 55); + model.when("x", function (x) { + strictEqual(x, 55); + start(); + }); + }); + + asyncTest("when calls fn with multiple dependency properties", function() { + var model = new Backbone.Model(); + model.when(["x", "y", "z"], function (x, y, z) { + strictEqual(x, 5); + strictEqual(y, 6); + strictEqual(z, 7); + start(); + }); + model.set("x", 5); + model.set("y", 6); + model.set("z", 7); + }); + + asyncTest("when calls fn with dependency properties in the specified order", function() { + var model = new Backbone.Model(); + model.when(["y", "z", "x"], function (y, z, x) { + strictEqual(x, 5); + strictEqual(y, 6); + strictEqual(z, 7); + start(); + }); + model.set("x", 5); + model.set("y", 6); + model.set("z", 7); + }); + + asyncTest("when calls fn only when all properties are defined", function() { + var model = new Backbone.Model(); + model.when(["y", "x", "z"], function (y, x, z) { + strictEqual(x, 5); + strictEqual(y, 6); + strictEqual(z, 9); + start(); + }); + model.set({ x: 5, y: 6 }); + setTimeout(function () { + model.set("z", 9); + }, 50); + }); + + asyncTest("when calls fn only once for multiple updates", function() { + var model = new Backbone.Model(); + model.when("x", function (x) { + strictEqual(x, 30); + start(); + }); + model.set("x", 10); + model.set("x", 20); + model.set("x", 30); + }); + + asyncTest("when calls fn only once for multiple updates with many dependencies", function() { + var model = new Backbone.Model(); + model.when(["y", "x", "z"], function (y, x, z) { + strictEqual(x, 5); + strictEqual(y, 6); + strictEqual(z, 9); + start(); + }); + model.set({ x: 5, y: 6 }); + model.set("z", 5); + model.set("z", 6); + model.set("z", 7); + model.set("z", 8); + model.set("z", 9); + }); + + asyncTest("when can compute fullName from firstName and lastName", function() { + var model = new Backbone.Model(); + model.when(["firstName", "lastName"], function (firstName, lastName) { + model.set("fullName", firstName + " " + lastName); + }); + model.when("fullName", function (fullName) { + strictEqual(fullName, "John Doe"); + start(); + }); + model.set("firstName", "John"); + model.set("lastName", "Doe"); + }); + + asyncTest("when should propagate changes through a data dependency graph", function() { + var model = new Backbone.Model(); + model.when(["w"], function (w) { + strictEqual(w, 5); + model.set("x", w * 2); + }); + model.when(["x"], function (x) { + strictEqual(x, 10); + model.set("y", x + 1); + }); + model.when(["y"], function (y) { + strictEqual(y, 11); + model.set("z", y * 2); + }); + model.when(["z"], function (z) { + strictEqual(z, 22); + start(); + }); + model.set("w", 5); + }); + + asyncTest("when should use thisArg", function() { + var model = new Backbone.Model(), + theThing = { foo: "bar" }; + model.when("x", function (x) { + strictEqual(x, 5); + strictEqual(this, theThing); + strictEqual(this.foo, "bar"); + start(); + }, theThing); + model.set("x", 5); + }); + + asyncTest("when should propagate changes breadth first", function () { + var model = new Backbone.Model(); + + // Here is a data dependency graph that can test this + // (data flowing left to right): + //``` + // b d + // a f + // c e + //``` + // + // When "a" changes, "f" should update once only, after the changes propagated + // through the following two paths simultaneously: + // + // * a -> b -> d -> f + // * a -> c -> e -> f + + // a -> (b, c) + model.when("a", function (a) { + model.set({ + b: a + 1, + c: a + 2 + }); + }); + + // b -> d + model.when("b", function (b) { + model.set("d", b + 1); + }); + + // c -> e + model.when("c", function (c) { + model.set("e", c + 1); + }); + + // (d, e) -> f + model.when(["d", "e"], function (d, e) { + model.set("f", d + e); + }); + + model.when("f", function (f) { + if(f == 15){ + model.set("a", 10); + } else { + strictEqual(f, 25); + start(); + } + }); + model.set("a", 5); + }); + + asyncTest("cancel should work for a single callback", function () { + var model = new Backbone.Model(), + xValue, + whens = model.when("x", function (x) { + xValue = x; + }); + model.set("x", 5); + setTimeout(function () { + strictEqual(xValue, 5); + model.cancel(whens); + model.set("x", 6); + setTimeout(function () { + strictEqual(xValue, 5); + start(); + }, 0); + }, 0); + }); + + asyncTest("cancel should work for a multiple chained callback", function () { + var model = new Backbone.Model(), + xValue, + yValue, + whens = model.when("x", function (x) { xValue = x; }) + .when("y", function (y) { yValue = y; }); + model.set("x", 5); + model.set("y", 10); + setTimeout(function () { + strictEqual(xValue, 5); + strictEqual(yValue, 10); + model.cancel(whens); + model.set("x", 6); + model.set("y", 11); + setTimeout(function () { + strictEqual(xValue, 5); + strictEqual(yValue, 10); + start(); + }, 0); + }, 0); + }); + + asyncTest("cancel should work for independently tracked callbacks", function() { + var model = new Backbone.Model(), + xValue, + yValue, + whenX = model.when("x", function (x) { xValue = x; }), + whenY = model.when("y", function (y) { yValue = y; }); + model.set("x", 5); + setTimeout(function () { + strictEqual(xValue, 5); + model.cancel(whenX); + model.set("x", 6); + model.set("y", 10); + setTimeout(function () { + strictEqual(xValue, 5); + strictEqual(yValue, 10); + model.cancel(whenY); + model.set("x", 7); + model.set("y", 11); + setTimeout(function () { + strictEqual(xValue, 5); + strictEqual(yValue, 10); + start(); + }, 0); + }, 0); + }, 0); + }); })();