From 5eb968553a1130461ab8704535691e00eb154ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemela=CC=88?= Date: Fri, 15 Mar 2013 15:42:00 -0400 Subject: [PATCH] feat(Scope): add $watchCollection method for observing collections The new method allows to shallow watch collections (Arrays/Maps). --- src/ng/rootScope.js | 141 ++++++++++++++++++++++++++++++++++ test/ng/rootScopeSpec.js | 159 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 300 insertions(+) diff --git a/src/ng/rootScope.js b/src/ng/rootScope.js index 48ba038fb01b..1fad7b0edb9a 100644 --- a/src/ng/rootScope.js +++ b/src/ng/rootScope.js @@ -320,6 +320,147 @@ function $RootScopeProvider(){ }; }, + + /** + * @ngdoc function + * @name ng.$rootScope.Scope#$watchCollection + * @methodOf ng.$rootScope.Scope + * @function + * + * @description + * Shallow watches the properties of an object and fires whenever any of the properties change + * (for arrays this implies watching the array items, for object maps this implies watching the properties). + * If a change is detected the `listener` callback is fired. + * + * - The `obj` collection is observed via standard $watch operation and is examined on every call to $digest() to + * see if any items have been added, removed, or moved. + * - The `listener` is called whenever anything within the `obj` has changed. Examples include adding new items + * into the object or array, removing and moving items around. + * + * + * # Example + *
+          $scope.names = ['igor', 'matias', 'misko', 'james'];
+          $scope.dataCount = 4;
+
+          $scope.$watchCollection('names', function(newNames, oldNames) {
+            $scope.dataCount = newNames.length;
+          });
+
+          expect($scope.dataCount).toEqual(4);
+          $scope.$digest();
+
+          //still at 4 ... no changes
+          expect($scope.dataCount).toEqual(4);
+
+          $scope.names.pop();
+          $scope.$digest();
+
+          //now there's been a change
+          expect($scope.dataCount).toEqual(3);
+       * 
+ * + * + * @param {string|Function(scope)} obj Evaluated as {@link guide/expression expression}. The expression value + * should evaluate to an object or an array which is observed on each + * {@link ng.$rootScope.Scope#$digest $digest} cycle. Any shallow change within the collection will trigger + * a call to the `listener`. + * + * @param {function(newCollection, oldCollection, scope)} listener a callback function that is fired with both + * the `newCollection` and `oldCollection` as parameters. + * The `newCollection` object is the newly modified data obtained from the `obj` expression and the + * `oldCollection` object is a copy of the former collection data. + * The `scope` refers to the current scope. + * + * @returns {function()} Returns a de-registration function for this listener. When the de-registration function is executed + * then the internal watch operation is terminated. + */ + $watchCollection: function(obj, listener) { + var self = this; + var oldValue; + var newValue; + var changeDetected = 0; + var objGetter = $parse(obj); + var internalArray = []; + var internalObject = {}; + var oldLength = 0; + + function $watchCollectionWatch() { + newValue = objGetter(self); + var newLength, key; + + if (!isObject(newValue)) { + if (oldValue !== newValue) { + oldValue = newValue; + changeDetected++; + } + } else if (isArray(newValue)) { + if (oldValue !== internalArray) { + // we are transitioning from something which was not an array into array. + oldValue = internalArray; + oldLength = oldValue.length = 0; + changeDetected++; + } + + newLength = newValue.length; + + if (oldLength !== newLength) { + // if lengths do not match we need to trigger change notification + changeDetected++; + oldValue.length = oldLength = newLength; + } + // copy the items to oldValue and look for changes. + for (var i = 0; i < newLength; i++) { + if (oldValue[i] !== newValue[i]) { + changeDetected++; + oldValue[i] = newValue[i]; + } + } + } else { + if (oldValue !== internalObject) { + // we are transitioning from something which was not an object into object. + oldValue = internalObject = {}; + oldLength = 0; + changeDetected++; + } + // copy the items to oldValue and look for changes. + newLength = 0; + for (key in newValue) { + if (newValue.hasOwnProperty(key)) { + newLength++; + if (oldValue.hasOwnProperty(key)) { + if (oldValue[key] !== newValue[key]) { + changeDetected++; + oldValue[key] = newValue[key]; + } + } else { + oldLength++; + oldValue[key] = newValue[key]; + changeDetected++; + } + } + } + if (oldLength > newLength) { + // we used to have more keys, need to find them and destroy them. + changeDetected++; + for(key in oldValue) { + if (oldValue.hasOwnProperty(key) && !newValue.hasOwnProperty(key)) { + oldLength--; + delete oldValue[key]; + } + } + } + } + return changeDetected; + } + + function $watchCollectionAction() { + listener(newValue, oldValue, self); + } + + return this.$watch($watchCollectionWatch, $watchCollectionAction); + }, + /** * @ngdoc function * @name ng.$rootScope.Scope#$digest diff --git a/test/ng/rootScopeSpec.js b/test/ng/rootScopeSpec.js index 33db814cd96f..f4b6e8a1b869 100644 --- a/test/ng/rootScopeSpec.js +++ b/test/ng/rootScopeSpec.js @@ -362,6 +362,165 @@ describe('Scope', function() { $rootScope.$digest(); expect(log).toEqual([]); })); + + describe('$watchCollection', function() { + var log, $rootScope, deregister; + + beforeEach(inject(function(_$rootScope_) { + log = []; + $rootScope = _$rootScope_; + deregister = $rootScope.$watchCollection('obj', function logger(obj) { + log.push(toJson(obj)); + }); + })); + + + it('should not trigger if nothing change', inject(function($rootScope) { + $rootScope.$digest(); + expect(log).toEqual([undefined]); + + $rootScope.$digest(); + expect(log).toEqual([undefined]); + })); + + + it('should allow deregistration', inject(function($rootScope) { + $rootScope.obj = []; + $rootScope.$digest(); + + expect(log).toEqual(['[]']); + + $rootScope.obj.push('a'); + deregister(); + + $rootScope.$digest(); + expect(log).toEqual(['[]']); + })); + + + describe('array', function() { + it('should trigger when property changes into array', function() { + $rootScope.obj = 'test'; + $rootScope.$digest(); + expect(log).toEqual(['"test"']); + + $rootScope.obj = []; + $rootScope.$digest(); + expect(log).toEqual(['"test"', '[]']); + + $rootScope.obj = {}; + $rootScope.$digest(); + expect(log).toEqual(['"test"', '[]', '{}']); + + $rootScope.obj = []; + $rootScope.$digest(); + expect(log).toEqual(['"test"', '[]', '{}', '[]']); + + $rootScope.obj = undefined; + $rootScope.$digest(); + expect(log).toEqual(['"test"', '[]', '{}', '[]', undefined]); + }); + + + it('should not trigger change when object in collection changes', function() { + $rootScope.obj = [{}]; + $rootScope.$digest(); + expect(log).toEqual(['[{}]']); + + $rootScope.obj[0].name = 'foo'; + $rootScope.$digest(); + expect(log).toEqual(['[{}]']); + }); + + + it('should watch array properties', function() { + $rootScope.obj = []; + $rootScope.$digest(); + expect(log).toEqual(['[]']); + + $rootScope.obj.push('a'); + $rootScope.$digest(); + expect(log).toEqual(['[]', '["a"]']); + + $rootScope.obj[0] = 'b'; + $rootScope.$digest(); + expect(log).toEqual(['[]', '["a"]', '["b"]']); + + $rootScope.obj.push([]); + $rootScope.obj.push({}); + log = []; + $rootScope.$digest(); + expect(log).toEqual(['["b",[],{}]']); + + var temp = $rootScope.obj[1]; + $rootScope.obj[1] = $rootScope.obj[2]; + $rootScope.obj[2] = temp; + $rootScope.$digest(); + expect(log).toEqual([ '["b",[],{}]', '["b",{},[]]' ]); + + $rootScope.obj.shift() + log = []; + $rootScope.$digest(); + expect(log).toEqual([ '[{},[]]' ]); + }); + }); + + + describe('object', function() { + it('should trigger when property changes into object', function() { + $rootScope.obj = 'test'; + $rootScope.$digest(); + expect(log).toEqual(['"test"']); + + $rootScope.obj = {}; + $rootScope.$digest(); + expect(log).toEqual(['"test"', '{}']); + }); + + + it('should not trigger change when object in collection changes', function() { + $rootScope.obj = {name: {}}; + $rootScope.$digest(); + expect(log).toEqual(['{"name":{}}']); + + $rootScope.obj.name.bar = 'foo'; + $rootScope.$digest(); + expect(log).toEqual(['{"name":{}}']); + }); + + + it('should watch object properties', function() { + $rootScope.obj = {}; + $rootScope.$digest(); + expect(log).toEqual(['{}']); + + $rootScope.obj.a= 'A'; + $rootScope.$digest(); + expect(log).toEqual(['{}', '{"a":"A"}']); + + $rootScope.obj.a = 'B'; + $rootScope.$digest(); + expect(log).toEqual(['{}', '{"a":"A"}', '{"a":"B"}']); + + $rootScope.obj.b = []; + $rootScope.obj.c = {}; + log = []; + $rootScope.$digest(); + expect(log).toEqual(['{"a":"B","b":[],"c":{}}']); + + var temp = $rootScope.obj.a; + $rootScope.obj.a = $rootScope.obj.b; + $rootScope.obj.c = temp; + $rootScope.$digest(); + expect(log).toEqual([ '{"a":"B","b":[],"c":{}}', '{"a":[],"b":[],"c":"B"}' ]); + + delete $rootScope.obj.a; + log = []; + $rootScope.$digest(); + expect(log).toEqual([ '{"b":[],"c":"B"}' ]); + }) + }); + }); });