From c12b669a3ec72c077d711bf83b80521d58a7ef90 Mon Sep 17 00:00:00 2001 From: Curran Date: Thu, 24 Apr 2014 11:40:49 -0400 Subject: [PATCH 1/2] Added model.when --- backbone.js | 52 +++++++++++++++ test/model.js | 182 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+) diff --git a/backbone.js b/backbone.js index 28e9e30eb..438e060fe 100644 --- a/backbone.js +++ b/backbone.js @@ -586,6 +586,58 @@ 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`. + when: function (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 get = _.bind(this.get, this), + callFn = _.debounce(function(){ + + // Extract the values for each dependency property. + var args = dependencies.map(get), + 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){ + this.on('change:' + property, callFn); + }, this); } }); diff --git a/test/model.js b/test/model.js index 89f01cf0d..11b45aca5 100644 --- a/test/model.js +++ b/test/model.js @@ -1127,4 +1127,186 @@ 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); + }); + })(); From 142f578fb56e00d3f3f2edc8fb361cd988d4a8ee Mon Sep 17 00:00:00 2001 From: curran Date: Fri, 25 Apr 2014 15:02:50 -0400 Subject: [PATCH 2/2] Added model.cancel for canceling 'when' callbacks --- backbone.js | 98 ++++++++++++++++++-------- test/model.js | 186 ++++++++++++++++++++++++++++++++++---------------- 2 files changed, 195 insertions(+), 89 deletions(-) diff --git a/backbone.js b/backbone.js index 438e060fe..78ecabf33 100644 --- a/backbone.js +++ b/backbone.js @@ -605,38 +605,78 @@ // 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`. - when: function (dependencies, fn, thisArg){ + // * 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]; + } - // 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 + }; } - // `callFn()` will invoke `fn` with values of dependency properties - // on the next tick of the JavaScript event loop. - var get = _.bind(this.get, this), - callFn = _.debounce(function(){ - - // Extract the values for each dependency property. - var args = dependencies.map(get), - 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){ - this.on('change:' + property, callFn); + 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 11b45aca5..01c637a2e 100644 --- a/test/model.js +++ b/test/model.js @@ -1129,51 +1129,51 @@ asyncTest("when listens for changes to a single property", 1, function() { var model = new Backbone.Model(); - model.when('x', function (x) { + model.when("x", function (x) { strictEqual(x, 30); start(); }); - model.set('x', 30); + model.set("x", 30); }); - asyncTest('when calls fn once to initialize', function() { + asyncTest("when calls fn once to initialize", function() { var model = new Backbone.Model(); - model.set('x', 55); - model.when('x', function (x) { + model.set("x", 55); + model.when("x", function (x) { strictEqual(x, 55); start(); }); }); - asyncTest('when calls fn with multiple dependency properties', function() { + asyncTest("when calls fn with multiple dependency properties", function() { var model = new Backbone.Model(); - model.when(['x', 'y', 'z'], function (x, y, z) { + 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); + model.set("x", 5); + model.set("y", 6); + model.set("z", 7); }); - asyncTest('when calls fn with dependency properties in the specified order', function() { + 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) { + 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); + model.set("x", 5); + model.set("y", 6); + model.set("z", 7); }); - asyncTest('when calls fn only when all properties are defined', function() { + 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) { + model.when(["y", "x", "z"], function (y, x, z) { strictEqual(x, 5); strictEqual(y, 6); strictEqual(z, 9); @@ -1181,84 +1181,84 @@ }); model.set({ x: 5, y: 6 }); setTimeout(function () { - model.set('z', 9); + model.set("z", 9); }, 50); }); - asyncTest('when calls fn only once for multiple updates', function() { + asyncTest("when calls fn only once for multiple updates", function() { var model = new Backbone.Model(); - model.when('x', function (x) { + model.when("x", function (x) { strictEqual(x, 30); start(); }); - model.set('x', 10); - model.set('x', 20); - model.set('x', 30); + 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() { + 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) { + 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); + 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() { + 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(["firstName", "lastName"], function (firstName, lastName) { + model.set("fullName", firstName + " " + lastName); }); - model.when('fullName', function (fullName) { - strictEqual(fullName, 'John Doe'); + model.when("fullName", function (fullName) { + strictEqual(fullName, "John Doe"); start(); }); - model.set('firstName', 'John'); - model.set('lastName', 'Doe'); + model.set("firstName", "John"); + model.set("lastName", "Doe"); }); - asyncTest('when should propagate changes through a data dependency graph', function() { + asyncTest("when should propagate changes through a data dependency graph", function() { var model = new Backbone.Model(); - model.when(['w'], function (w) { + model.when(["w"], function (w) { strictEqual(w, 5); - model.set('x', w * 2); + model.set("x", w * 2); }); - model.when(['x'], function (x) { + model.when(["x"], function (x) { strictEqual(x, 10); - model.set('y', x + 1); + model.set("y", x + 1); }); - model.when(['y'], function (y) { + model.when(["y"], function (y) { strictEqual(y, 11); - model.set('z', y * 2); + model.set("z", y * 2); }); - model.when(['z'], function (z) { + model.when(["z"], function (z) { strictEqual(z, 22); start(); }); - model.set('w', 5); + model.set("w", 5); }); - asyncTest('when should use thisArg', function() { + asyncTest("when should use thisArg", function() { var model = new Backbone.Model(), theThing = { foo: "bar" }; - model.when('x', function (x) { + model.when("x", function (x) { strictEqual(x, 5); strictEqual(this, theThing); strictEqual(this.foo, "bar"); start(); }, theThing); - model.set('x', 5); + model.set("x", 5); }); - asyncTest('when should propagate changes breadth first', function () { + asyncTest("when should propagate changes breadth first", function () { var model = new Backbone.Model(); // Here is a data dependency graph that can test this @@ -1276,7 +1276,7 @@ // * a -> c -> e -> f // a -> (b, c) - model.when('a', function (a) { + model.when("a", function (a) { model.set({ b: a + 1, c: a + 2 @@ -1284,29 +1284,95 @@ }); // b -> d - model.when('b', function (b) { - model.set('d', b + 1); + model.when("b", function (b) { + model.set("d", b + 1); }); // c -> e - model.when('c', function (c) { - model.set('e', c + 1); + 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(["d", "e"], function (d, e) { + model.set("f", d + e); }); - model.when('f', function (f) { + model.when("f", function (f) { if(f == 15){ - model.set('a', 10); + model.set("a", 10); } else { strictEqual(f, 25); start(); } }); - model.set('a', 5); + 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); + }); })();