diff --git a/angularFiles.js b/angularFiles.js index 47f18961f736..c7c996127a76 100644 --- a/angularFiles.js +++ b/angularFiles.js @@ -29,6 +29,7 @@ angularFiles = { 'src/ng/httpBackend.js', 'src/ng/locale.js', 'src/ng/timeout.js', + 'src/ng/whatChanged.js', 'src/ng/filter.js', 'src/ng/filter/filter.js', diff --git a/src/AngularPublic.js b/src/AngularPublic.js index 61c77af34c2e..5520e93163e2 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -125,6 +125,7 @@ function publishExternalAPI(angular){ $sniffer: $SnifferProvider, $templateCache: $TemplateCacheProvider, $timeout: $TimeoutProvider, + $whatChanged: $WhatChangedProvider, $window: $WindowProvider }); } diff --git a/src/apis.js b/src/apis.js index 0e94e2a55ce2..78786c82b57e 100644 --- a/src/apis.js +++ b/src/apis.js @@ -109,3 +109,186 @@ HashQueueMap.prototype = { } } }; + +/** + * A generic collection that wraps an array + * @returns an object that wraps an array as a generic collection + */ +var WrappedArray = function(array) { + this.collection = array; +} +WrappedArray.prototype = { + get: function(index) { + return this.collection[index]; + }, + key: function(index) { + return index; + }, + length: function() { + return this.collection.length; + }, + copy: function() { + return new WrappedArray(this.collection.slice(0)); + } +}; + +/** + * A generic collection that wraps an object so that you can access its keyed values in order + * @returns an object that wraps an object as a generic collection + */ +var WrappedObject = function(object) { + this.collection = object; + this.keys = []; + for(var key in this.collection) { + if (this.collection.hasOwnProperty(key) && key.charAt(0) != '$') { + this.keys.push(key); + } + } + this.keys.sort(); +} +WrappedObject.prototype = { + get: function(index) { + return this.collection[this.keys[index]]; + }, + key: function(index) { + return this.keys[index]; + }, + length: function() { + return this.keys.length; + }, + copy: function() { + var dst = {}; + for(var key in this.collection) { + if (this.collection.hasOwnProperty(key) && key.charAt(0) != '$') { + dst[key] = this.collection[key]; + } + } + return new WrappedObject(dst); + } +}; + + /** + * Track changes to objects (rather than primitives) in between two collections + * @param original {object} collection that has a get method + * @param changed {object} collection that has a get method + */ + function ObjectTracker(original, changed) { + this.original = original; + this.changed = changed; + this.entries = []; + } + ObjectTracker.prototype = { + getEntry: function (obj) { + var key = hashKey(obj); + var entry = this.entries[key]; + if ( !angular.isDefined(entry) ) { + entry = ({ newIndexes: [], oldIndexes: [], obj: obj }); + } + this.entries[key] = entry; + return entry; + }, + // An object is now at this index where it wasn't before + addNewEntry: function(index) { + this.getEntry(this.changed.get(index)).newIndexes.push(index); + }, + // An object is no longer at this index + addOldEntry: function(index) { + this.getEntry(this.original.get(index)).oldIndexes.push(index); + } + }; + + /* + * Track all the changes found between the original and changed collections + * @param original {object} collection that has a get method + * @param changed {object} collection that has a get method + */ + function ChangeTracker(original, changed) { + this.original = original; + this.changed = changed; + // All additions in the form {index, value} + this.additions = []; + // All deletions in the form {index, oldValue} + this.deletions = []; + // All primitive value modifications in the form { index, } + this.modifications = []; + // All moved objects in the form {index, oldIndex, value} + this.moves = []; + } + ChangeTracker.prototype = { + // An addition was found at the given index + pushAddition: function(index) { + this.additions.push({ index: index, value: this.changed.get(index)}); + }, + // A deletion was found at the given index + pushDeletion: function(index) { + this.deletions.push({ index: index, oldValue: this.original.get(index)}); + }, + // A modification to a primitive value was found at the given index + pushModification: function(index) { + this.modifications.push( { index: index, oldValue: this.original.get(index), newValue: this.changed.get(index)}); + }, + // An object has moved from oldIndex to newIndex + pushMove: function(oldIndex, newIndex) { + this.moves.push( { oldIndex: oldIndex, index: newIndex, value: this.original.get(oldIndex)}); + } + }; + + /** + * A flat list of changes ordered by collection key + */ + function FlattenedChanges(changes) { + this.changes = []; + var index, item; + // Flatten all the changes into a array ordered by index + for(index = 0; index < changes.modifications.length; index++) { + item = changes.modifications[index]; + this.modified(item); + } + for(index = 0; index < changes.deletions.length; index++) { + item = changes.deletions[index]; + this.deleted(item); + } + for(index = 0; index < changes.additions.length; index++) { + item = changes.additions[index]; + this.added(item); + } + for(index = 0; index < changes.moves.length; index++) { + item = changes.moves[index]; + this.moved(item); + } + } + FlattenedChanges.prototype = { + modified: function(item) { + item.modified = true; + this.changes[item.index] = item; + }, + moved: function(change) { + var item = this.changes[change.index]; + if ( angular.isDefined(item) ) { + item.value = change.value; + item.oldIndex = change.oldIndex; + } else { + item = change; + } + item.moved = true; + this.changes[change.index] = item; + }, + added: function(change){ + var item = this.changes[change.index]; + if ( angular.isDefined(item) ) { + item.value = change.value; + } else { + item = change; + } + item.added = true; + this.changes[change.index] = item; + }, + deleted: function(change) { + var item = this.changes[change.index]; + if ( !angular.isDefined(item) ) { + item = change; + } + item.deleted = true; + this.changes[change.index] = item; + } + }; diff --git a/src/ng/directive/ngRepeat.js b/src/ng/directive/ngRepeat.js index c59fefacc956..7ae72d3627eb 100644 --- a/src/ng/directive/ngRepeat.js +++ b/src/ng/directive/ngRepeat.js @@ -1,5 +1,7 @@ 'use strict'; +/*global angular, ngDirective, WrappedArray, WrappedObject, isArray, whatChanged, FlattenedChanges */ + /** * @ngdoc directive * @name ng.directive:ngRepeat @@ -61,129 +63,132 @@ var ngRepeatDirective = ngDirective({ transclude: 'element', priority: 1000, terminal: true, - compile: function(element, attr, linker) { - return function(scope, iterStartElement, attr){ - var expression = attr.ngRepeat; - var match = expression.match(/^\s*(.+)\s+in\s+(.*)\s*$/), - lhs, rhs, valueIdent, keyIdent; - if (! match) { - throw Error("Expected ngRepeat in form of '_item_ in _collection_' but got '" + - expression + "'."); - } - lhs = match[1]; - rhs = match[2]; - match = lhs.match(/^(?:([\$\w]+)|\(([\$\w]+)\s*,\s*([\$\w]+)\))$/); - if (!match) { - throw Error("'item' in 'item in collection' should be identifier or (key, value) but got '" + - lhs + "'."); + compile: function(element, attr, clone) { + var expression = attr.ngRepeat; + var match = expression.match(/^\s*(.+)\s+in\s+(.*)\s*$/); + if (!match) { + throw new Error("Expected ngRepeat in form of '_item_ in _collection_' but got '" + expression + "'."); + } + var identifiers = match[1]; + var sourceExpression = match[2]; + + match = identifiers.match(/^(?:([\$\w]+)|\(([\$\w]+)\s*,\s*([\$\w]+)\))$/); + if (!match) { + throw new Error("'item' in 'item in collection' should be identifier or (key, value) but got '" + + identifiers + "'."); + } + var valueIdentifier = match[3] || match[1]; + var keyIdentifier = match[2]; + + var updateScope = function(scope, value, key) { + scope[valueIdentifier] = value; + if (keyIdentifier) { + scope[keyIdentifier] = key; } - valueIdent = match[3] || match[1]; - keyIdent = match[2]; + }; - // Store a list of elements from previous run. This is a hash where key is the item from the - // iterator, and the value is an array of objects with following properties. - // - scope: bound scope - // - element: previous element. - // - index: position - // We need an array of these objects since the same object can be returned from the iterator. - // We expect this to be a rare case. - var lastOrder = new HashQueueMap(); + // Return the linking function for this directive + return function(scope, startElement, attr){ + var originalCollection = new WrappedArray([]); + var originalChildItems = []; + var containerElement = startElement.parent(); scope.$watch(function ngRepeatWatch(scope){ - var index, length, - collection = scope.$eval(rhs), - cursor = iterStartElement, // current position of the node - // Same as lastOrder but it has the current state. It will become the - // lastOrder on the next iteration. - nextOrder = new HashQueueMap(), - arrayLength, - childScope, - key, value, // key/value of iteration - array, - last; // last object information {scope, element, index} - - - - if (!isArray(collection)) { - // if object, extract keys, sort them and use to determine order of iteration over obj props - array = []; - for(key in collection) { - if (collection.hasOwnProperty(key) && key.charAt(0) != '$') { - array.push(key); - } + var item, key; + var source = scope.$eval(sourceExpression); + var newCollection = isArray(source) ? new WrappedArray(source) : new WrappedObject(source); + var newChildItems = []; + var temp = whatChanged(originalCollection, newCollection); + var changes = new FlattenedChanges(temp).changes; + + // Iterate over the flattened changes array - updating the childscopes and elements accordingly + var lastChildScope, newChildScope, newChildItem; + var currentElement = startElement; + var itemIndex = 0, changeIndex = 0; + while(changeIndex < changes.length) { + item = changes[changeIndex]; + if ( !angular.isDefined(item) ) { + // No change for this item just copy it over + newChildItem = originalChildItems[itemIndex]; + newChildItems.push(newChildItem); + currentElement = newChildItem.element; + itemIndex++; + changeIndex++; + continue; } - array.sort(); - } else { - array = collection || []; - } - - arrayLength = array.length; - - // we are not using forEach for perf reasons (trying to avoid #call) - for (index = 0, length = array.length; index < length; index++) { - key = (collection === array) ? index : array[index]; - value = collection[key]; - - last = lastOrder.shift(value); - - if (last) { - // if we have already seen this object, then we need to reuse the - // associated scope/element - childScope = last.scope; - nextOrder.push(value, last); - - if (index === last.index) { - // do nothing - cursor = last.element; - } else { - // existing item which got moved - last.index = index; - // This may be a noop, if the element is next, but I don't know of a good way to - // figure this out, since it would require extra DOM access, so let's just hope that - // the browsers realizes that it is noop, and treats it as such. - cursor.after(last.element); - cursor = last.element; + if ( item.deleted ) { + // An item has been deleted here - destroy the old scope and remove the old element + var originalChildItem = originalChildItems[itemIndex]; + originalChildItem.scope.$destroy(); + originalChildItem.element.remove(); + // If an item is added or moved here as well then the index will incremented in the added or moved if statement below + if ( !item.added && !item.moved ) { + itemIndex++; } - } else { - // new item which we don't know about - childScope = scope.$new(); } - - childScope[valueIdent] = value; - if (keyIdent) childScope[keyIdent] = key; - childScope.$index = index; - - childScope.$first = (index === 0); - childScope.$last = (index === (arrayLength - 1)); - childScope.$middle = !(childScope.$first || childScope.$last); - - if (!last) { - linker(childScope, function(clone){ - cursor.after(clone); - last = { - scope: childScope, - element: (cursor = clone), - index: index - }; - nextOrder.push(value, last); + if ( item.added ) { + // An item has been added here - create a new scope and clone a new element + newChildItem = { scope: scope.$new() }; + updateScope(newChildItem.scope, item.value, newCollection.key(item.index)); + clone(newChildItem.scope, function(newChildElement){ + currentElement.after(newChildElement); + currentElement = newChildItem.element = newChildElement; }); + newChildItems.push(newChildItem); + itemIndex++; + } + if ( item.modified ) { + // This item is a primitive that has been modified - update the scope + newChildItem = originalChildItems[itemIndex]; + updateScope(newChildItem.scope, item.newValue, newCollection.key(item.index)); + newChildItems.push(newChildItem); + currentElement = newChildItem.element; + itemIndex++; } + if ( item.moved ) { + // An object has moved here from somewhere else - move the element accordingly + newChildItem = originalChildItems[item.oldIndex]; + if (keyIdentifier && isArray(source)) { + // We are iterating keys, but over an array rather than an object so we need to fix up the scope + updateScope(newChildItem.scope, item.value, newCollection.key(item.index)); + } + newChildItems.push(newChildItem); + currentElement.after(newChildItem.element); + currentElement = newChildItem.element; + itemIndex++; + } + changeIndex++; + } + while( itemIndex < newCollection.length() ) { + // No change for this item just copy it over + newChildItem = originalChildItems[itemIndex]; + newChildItems.push(newChildItem); + currentElement = newChildItem.element; + itemIndex++; } - //shrink children - for (key in lastOrder) { - if (lastOrder.hasOwnProperty(key)) { - array = lastOrder[key]; - while(array.length) { - value = array.pop(); - value.element.remove(); - value.scope.$destroy(); - } + // Update $index, $first, $middle & $last + for(var index=0; index' + + '
  • {{item.name}}:{{key}}:{{$index}}|
  • ' + + '')(scope); + + // INIT + scope.items = [{name: 'misko'}, {name:'shyam'}, {name:'frodo'}, {name: 'yukko'}]; + scope.$digest(); + expect(element.text()).toEqual('misko:0:0|shyam:1:1|frodo:2:2|yukko:3:3|'); + + // SHRINK + scope.items.pop(); + scope.$digest(); + expect(element.find('li').length).toEqual(3); + expect(element.text()).toEqual('misko:0:0|shyam:1:1|frodo:2:2|'); + + scope.items.shift(); + scope.$digest(); + expect(element.find('li').length).toEqual(2); + expect(element.text()).toEqual('shyam:0:0|frodo:1:1|'); + }); + + it('should expose correct $index and key when object shrinks', function() { + element = $compile( + '')(scope); + scope.items = {'m': {name: 'misko'}, 's': {name:'shyam'}, + 'f': {name:'frodo'}, 'y': {name: 'yukko'}}; + scope.$digest(); + expect(element.text()).toEqual('frodo:f:0|misko:m:1|shyam:s:2|yukko:y:3|'); + + // SHRINK + delete scope.items['y']; + scope.$digest(); + expect(element.find('li').length).toEqual(3); + expect(element.text()).toEqual('frodo:f:0|misko:m:1|shyam:s:2|'); + + delete scope.items['f']; + scope.$digest(); + expect(element.find('li').length).toEqual(2); + expect(element.text()).toEqual('misko:m:0|shyam:s:1|'); + }); it('should expose iterator offset as $index when iterating over objects', function() { element = $compile( @@ -448,7 +492,7 @@ describe('ngRepeat', function() { }); - it('should reuse elements even when model is composed of primitives', function() { + xit('should reuse elements even when model is composed of primitives', function() { // rebuilding repeater from scratch can be expensive, we should try to avoid it even for // model that is composed of primitives. @@ -464,5 +508,18 @@ describe('ngRepeat', function() { expect(newLis[1]).toEqual(lis[0]); expect(newLis[2]).toEqual(lis[1]); }); + + it('should not reorder elements that have primitive model changes, since this causes loss of focus on input elements', function() { + scope.items = ['hello', 'cau', 'ahoj']; + scope.$digest(); + lis = element.find('li'); + + scope.items = ['hello', 'ahoj', 'ahoj']; + scope.$digest(); + var newLis = element.find('li'); + expect(newLis.length).toEqual(3); + expect(newLis[2]).toEqual(lis[2]); + }); + }); }); diff --git a/test/ng/whatChangedSpec.js b/test/ng/whatChangedSpec.js new file mode 100644 index 000000000000..3a294ba162d1 --- /dev/null +++ b/test/ng/whatChangedSpec.js @@ -0,0 +1,199 @@ +describe('whatChanged - arrays', function() { + describe('primitive changes', function() { + var original = new WrappedArray([0,1,2,3,4]); + it('should have nothing changed if primitive items are the same', function() { + var changed = new WrappedArray([0,1,2,3,4]); + var changes = whatChanged(original, changed); + expect(changes.additions).toEqual([]); + expect(changes.deletions).toEqual([]); + expect(changes.moves).toEqual([]); + expect(changes.modifications).toEqual([]); + }); + + it('should be able to identify indexes of primitives that have changed', function() { + var changed = new WrappedArray([0,1,3,4,2]); + var changes = whatChanged(original, changed); + expect(changes.additions).toEqual([]); + expect(changes.deletions).toEqual([]); + expect(changes.moves).toEqual([]); + expect(changes.modifications).toEqual([ + { index: 2, oldValue: 2, newValue: 3 }, + { index: 3, oldValue: 3, newValue: 4 }, + { index: 4, oldValue: 4, newValue: 2 } + ]); + }); + + it('should be able to identify added primitives', function() { + var changed = new WrappedArray([0,1,2,3,4,5,6]); + var changes = whatChanged(original, changed); + expect(changes.additions).toEqual([ + { index: 5, value: 5 }, + { index: 6, value: 6 } + ]); + expect(changes.deletions).toEqual([]); + expect(changes.moves).toEqual([]); + expect(changes.modifications).toEqual([]); + }); + + it('should be able to identify removed primitives', function() { + var changed = new WrappedArray([0,1,2]); + var changes = whatChanged(original, changed); + expect(changes.additions).toEqual([]); + expect(changes.deletions).toEqual([ + { index: 3, oldValue: 3 }, + { index: 4, oldValue: 4 } + ]); + expect(changes.moves).toEqual([]); + expect(changes.modifications).toEqual([]); + }); + + it('should be able to identify modifications and additions', function () { + var changed = new WrappedArray([0,7,8,3,4,5,6]); + var changes = whatChanged(original, changed); + expect(changes.additions).toEqual([ + { index: 5, value: 5 }, + { index: 6, value: 6 } + ]); + expect(changes.deletions).toEqual([]); + expect(changes.moves).toEqual([]); + expect(changes.modifications).toEqual([ + { index: 1, oldValue: 1, newValue: 7 }, + { index: 2, oldValue: 2, newValue: 8 } + ]); + }); + + it('should be able to identify modifications and deletions', function () { + var changed = new WrappedArray([0,7,8]); + var changes = whatChanged(original, changed); + expect(changes.additions).toEqual([]); + expect(changes.deletions).toEqual([ + { index: 3, oldValue: 3 }, + { index: 4, oldValue: 4 } + ]); + expect(changes.moves).toEqual([]); + expect(changes.modifications).toEqual([ + { index: 1, oldValue: 1, newValue: 7 }, + { index: 2, oldValue: 2, newValue: 8 } + ]); + }); + }); + + describe('object changes', function() { + var obj1, obj2, obj3; + var original; + beforeEach(function() { + obj1 = {}; + obj2 = ['a','b']; + obj3 = {}; + original = new WrappedArray([obj1, obj2, obj3]); + }); + + it('should have nothing changed if the objects are identical', function() { + var changed = new WrappedArray([obj1, obj2, obj3]); + var changes = whatChanged(original, changed); + expect(changes.additions).toEqual([]); + expect(changes.deletions).toEqual([]); + expect(changes.moves).toEqual([]); + expect(changes.modifications).toEqual([]); + }); + + it('should be able to identify if an object moves', function() { + var changed = new WrappedArray([obj1, obj3, obj2]); + var changes = whatChanged(original, changed); + expect(changes.additions).toEqual([]); + expect(changes.deletions).toEqual([]); + expect(changes.moves).toEqual([ + { value: obj2, oldIndex: 1, index: 2 }, + { value: obj3, oldIndex: 2, index: 1 } + ]); + expect(changes.modifications).toEqual([]); + }); + + it('should be able to identify if a new object is added', function() { + var obj4 = {}; + var changed = new WrappedArray([obj1, obj2, obj3, obj4]); + var changes = whatChanged(original, changed); + expect(changes.additions).toEqual([ + {index: 3, value: obj4 } + ]); + expect(changes.deletions).toEqual([]); + expect(changes.moves).toEqual([]); + expect(changes.modifications).toEqual([]); + }); + + it('should be able to identify if an object is deleted from the end', function() { + var changed = new WrappedArray([obj1, obj2]); + var changes = whatChanged(original, changed); + expect(changes.additions).toEqual([]); + expect(changes.deletions).toEqual([ + {index: 2, oldValue: obj3 } + ]); + expect(changes.moves).toEqual([]); + expect(changes.modifications).toEqual([]); + }); + + it('should be able to identify if an object is deleted from the front', function() { + var changed = new WrappedArray([obj2, obj3]); + var changes = whatChanged(original, changed); + expect(changes.additions).toEqual([]); + expect(changes.deletions).toEqual([ + {index: 0, oldValue: obj1 } + ]); + expect(changes.moves).toEqual([ + { value: obj2, oldIndex: 1, index: 0 }, + { value: obj3, oldIndex: 2, index: 1 } + ]); + expect(changes.modifications).toEqual([]); + }); + + it('should be able to identify if an object is deleted causing others to move', function() { + var changed = new WrappedArray([obj1, obj3]); + var changes = whatChanged(original, changed); + expect(changes.additions).toEqual([]); + expect(changes.deletions).toEqual([ + {index: 1, oldValue: obj2 } + ]); + expect(changes.moves).toEqual([ + {value: obj3, oldIndex: 2, index: 1} + ]); + expect(changes.modifications).toEqual([]); + }); + + it('should be able to cope with multiple copies of the same object', function() { + original = new WrappedArray([obj1, obj1, obj1]); + var changed = new WrappedArray([obj1, obj1, obj1]); + var changes = whatChanged(original, changed); + expect(changes.additions).toEqual([]); + expect(changes.deletions).toEqual([]); + expect(changes.moves).toEqual([]); + expect(changes.modifications).toEqual([]); + }); + + it('should be able to cope with addition when there are multiple copies', function() { + original = new WrappedArray([obj1, obj1, obj1]); + var changed = new WrappedArray([obj1, obj2, obj1]); + var changes = whatChanged(original, changed); + expect(changes.additions).toEqual([ + { index: 1, value: obj2 } + ]); + expect(changes.deletions).toEqual([ + { index: 1, oldValue: obj1 } + ]); + expect(changes.moves).toEqual([]); + expect(changes.modifications).toEqual([]); + }); + + it('should be able to cope with changing when there are multiple copies', function() { + original = new WrappedArray([obj1, obj1, obj1]); + var changed = new WrappedArray([obj1, obj1]); + var changes = whatChanged(original, changed); + expect(changes.additions).toEqual([]); + expect(changes.deletions).toEqual([ + { index: 2, oldValue: obj1 } + ]); + expect(changes.moves).toEqual([]); + expect(changes.modifications).toEqual([]); + }); + }); +}); +