diff options
| -rw-r--r-- | src/apis.js | 44 | ||||
| -rw-r--r-- | src/ng/directive/ngRepeat.js | 293 | ||||
| -rw-r--r-- | test/ApiSpecs.js | 37 | ||||
| -rw-r--r-- | test/ng/directive/ngClassSpec.js | 2 | ||||
| -rw-r--r-- | test/ng/directive/ngRepeatSpec.js | 322 | 
5 files changed, 346 insertions, 352 deletions
| diff --git a/src/apis.js b/src/apis.js index 0e94e2a5..c5d2b3d3 100644 --- a/src/apis.js +++ b/src/apis.js @@ -65,47 +65,3 @@ HashMap.prototype = {      return value;    }  }; - -/** - * A map where multiple values can be added to the same key such that they form a queue. - * @returns {HashQueueMap} - */ -function HashQueueMap() {} -HashQueueMap.prototype = { -  /** -   * Same as array push, but using an array as the value for the hash -   */ -  push: function(key, value) { -    var array = this[key = hashKey(key)]; -    if (!array) { -      this[key] = [value]; -    } else { -      array.push(value); -    } -  }, - -  /** -   * Same as array shift, but using an array as the value for the hash -   */ -  shift: function(key) { -    var array = this[key = hashKey(key)]; -    if (array) { -      if (array.length == 1) { -        delete this[key]; -        return array[0]; -      } else { -        return array.shift(); -      } -    } -  }, - -  /** -   * return the first item without deleting it -   */ -  peek: function(key) { -    var array = this[hashKey(key)]; -    if (array) { -    return array[0]; -    } -  } -}; diff --git a/src/ng/directive/ngRepeat.js b/src/ng/directive/ngRepeat.js index ea96d0ad..a00bc9e4 100644 --- a/src/ng/directive/ngRepeat.js +++ b/src/ng/directive/ngRepeat.js @@ -20,7 +20,7 @@   * @element ANY   * @scope   * @priority 1000 - * @param {repeat_expression} ngRepeat The expression indicating how to enumerate a collection. Two + * @param {repeat_expression} ngRepeat The expression indicating how to enumerate a collection. These   *   formats are currently supported:   *   *   * `variable in expression` – where variable is the user defined loop variable and `expression` @@ -33,6 +33,24 @@   *   *     For example: `(name, age) in {'adam':10, 'amalie':12}`.   * + *   * `variable in expression track by tracking_expression` – You can also provide an optional tracking function + *     which can be used to associate the objects in the collection with the DOM elements. If no tractking function + *     is specified the ng-repeat associates elements by identity in the collection. It is an error to have + *     more then one tractking function to  resolve to the same key. (This would mean that two distinct objects are + *     mapped to the same DOM element, which is not possible.) + * + *     For example: `item in items` is equivalent to `item in items track by $id(item)'. This implies that the DOM elements + *     will be associated by item identity in the array. + * + *     For example: `item in items track by $id(item)`. A built in `$id()` function can be used to assign a unique + *     `$$hashKey` property to each item in the array. This property is then used as a key to associated DOM elements + *     with the corresponding item in the array by identity. Moving the same object in array would move the DOM + *     element in the same way ian the DOM. + * + *     For example: `item in items track by item.id` Is a typical pattern when the items come from the database. In this + *     case the object identity does not matter. Two objects are considered equivalent as long as their `id` + *     property is same. + *   * @example   * This example initializes the scope to a list of names and   * then uses `ngRepeat` to display every person: @@ -57,133 +75,164 @@        </doc:scenario>      </doc:example>   */ -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 + "'."); -      } -      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(); - -      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(), -            arrayBound, -            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); -            } -          } -          array.sort(); -        } else { -          array = collection || []; +var ngRepeatDirective = ['$parse', function($parse) { +  return { +    transclude: 'element', +    priority: 1000, +    terminal: true, +    compile: function(element, attr, linker) { +      return function($scope, $element, $attr){ +        var expression = $attr.ngRepeat; +        var match = expression.match(/^\s*(.+)\s+in\s+(.*?)\s*(\s+track\s+by\s+(.+)\s*)?$/), +          trackByExp, hashExpFn, trackByIdFn, lhs, rhs, valueIdentifier, keyIdentifier, +          hashFnLocals = {$id: hashKey}; + +        if (!match) { +          throw Error("Expected ngRepeat in form of '_item_ in _collection_[ track by _id_]' but got '" + +            expression + "'.");          } -        arrayBound = array.length-1; - -        // 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; -            } +        lhs = match[1]; +        rhs = match[2]; +        trackByExp = match[4]; + +        if (trackByExp) { +          hashExpFn = $parse(trackByExp); +          trackByIdFn = function(key, value, index) { +            // assign key, value, and $index to the locals so that they can be used in hash functions +            if (keyIdentifier) hashFnLocals[keyIdentifier] = key; +            hashFnLocals[valueIdentifier] = value; +            hashFnLocals.$index = index; +            return hashExpFn($scope, hashFnLocals); +          }; +        } else { +          trackByIdFn = function(key, value) { +            return hashKey(value); +          } +        } +         +        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 + "'."); +        } +        valueIdentifier = match[3] || match[1]; +        keyIdentifier = 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 objects with following properties. +        //   - scope: bound scope +        //   - element: previous element. +        //   - index: position +        var lastBlockMap = {}; + +        //watch props +        $scope.$watchCollection(rhs, function ngRepeatAction(collection){ +          var index, length, +              cursor = $element,     // current position of the node +              // Same as lastBlockMap but it has the current state. It will become the +              // lastBlockMap on the next iteration. +              nextBlockMap = {}, +              arrayLength, +              childScope, +              key, value, // key/value of iteration +              trackById, +              collectionKeys, +              block,       // last object information {scope, element, id} +              nextBlockOrder = []; + + +          if (isArray(collection)) { +            collectionKeys = collection;            } else { -            // new item which we don't know about -            childScope = scope.$new(); +            // if object, extract keys, sort them and use to determine order of iteration over obj props +            collectionKeys = []; +            for (key in collection) { +              if (collection.hasOwnProperty(key) && key.charAt(0) != '$') { +                collectionKeys.push(key); +              } +            } +            collectionKeys.sort();            } -          childScope[valueIdent] = value; -          if (keyIdent) childScope[keyIdent] = key; -          childScope.$index = index; - -          childScope.$first = (index === 0); -          childScope.$last = (index === arrayBound); -          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); -            }); +          arrayLength = collectionKeys.length; + +          // locate existing items +          length = nextBlockOrder.length = collectionKeys.length; +          for(index = 0; index < length; index++) { +           key = (collection === collectionKeys) ? index : collectionKeys[index]; +           value = collection[key]; +           trackById = trackByIdFn(key, value, index); +           if((block = lastBlockMap[trackById])) { +             delete lastBlockMap[trackById]; +             nextBlockMap[trackById] = block; +             nextBlockOrder[index] = block; +           } else if (nextBlockMap.hasOwnProperty(trackById)) { +             // restore lastBlockMap +             forEach(nextBlockOrder, function(block) { +               if (block && block.element) lastBlockMap[block.id] = block; +             }); +             // This is a duplicate and we need to throw an error +             throw new Error('Duplicates in a repeater are not allowed. Repeater: ' + expression); +           } else { +             // new never before seen block +             nextBlockOrder[index] = { id: trackById }; +           } +         } + +          // remove existing items +          for (key in lastBlockMap) { +            if (lastBlockMap.hasOwnProperty(key)) { +              block = lastBlockMap[key]; +              block.element.remove(); +              block.scope.$destroy(); +            }            } -        } -        //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(); +          // we are not using forEach for perf reasons (trying to avoid #call) +          for (index = 0, length = collectionKeys.length; index < length; index++) { +            key = (collection === collectionKeys) ? index : collectionKeys[index]; +            value = collection[key]; +            block = nextBlockOrder[index]; + +            if (block.element) { +              // if we have already seen this object, then we need to reuse the +              // associated scope/element +              childScope = block.scope; + +              if (block.element == cursor) { +                // do nothing +                cursor = block.element; +              } else { +                // existing item which got moved +                cursor.after(block.element); +                cursor = block.element; +              } +            } else { +              // new item which we don't know about +              childScope = $scope.$new();              } -          } -        } -        lastOrder = nextOrder; -      }); -    }; -  } -}); +            childScope[valueIdentifier] = value; +            if (keyIdentifier) childScope[keyIdentifier] = key; +            childScope.$index = index; +            childScope.$first = (index === 0); +            childScope.$last = (index === (arrayLength - 1)); +            childScope.$middle = !(childScope.$first || childScope.$last); + +            if (!block.element) { +              linker(childScope, function(clone){ +                cursor.after(clone); +                cursor = clone; +                block.scope = childScope; +                block.element = clone; +                nextBlockMap[block.id] = block; +              }); +            } +          } +          lastBlockMap = nextBlockMap; +        }); +      }; +    } +  }; +}]; diff --git a/test/ApiSpecs.js b/test/ApiSpecs.js index 1e52cf44..12de39d0 100644 --- a/test/ApiSpecs.js +++ b/test/ApiSpecs.js @@ -23,42 +23,5 @@ describe('api', function() {        expect(map.get('c')).toBe(undefined);      });    }); - - -  describe('HashQueueMap', function() { -    it('should do basic crud with collections', function() { -      var map = new HashQueueMap(); -      map.push('key', 'a'); -      map.push('key', 'b'); -      expect(map[hashKey('key')]).toEqual(['a', 'b']); -      expect(map.peek('key')).toEqual('a'); -      expect(map[hashKey('key')]).toEqual(['a', 'b']); -      expect(map.shift('key')).toEqual('a'); -      expect(map.peek('key')).toEqual('b'); -      expect(map[hashKey('key')]).toEqual(['b']); -      expect(map.shift('key')).toEqual('b'); -      expect(map.shift('key')).toEqual(undefined); -      expect(map[hashKey('key')]).toEqual(undefined); -    }); - -    it('should support primitive and object keys', function() { -      var obj1 = {}, -          obj2 = {}; - -      var map = new HashQueueMap(); -      map.push(obj1, 'a1'); -      map.push(obj1, 'a2'); -      map.push(obj2, 'b'); -      map.push(1, 'c'); -      map.push(undefined, 'd'); -      map.push(null, 'e'); - -      expect(map[hashKey(obj1)]).toEqual(['a1', 'a2']); -      expect(map[hashKey(obj2)]).toEqual(['b']); -      expect(map[hashKey(1)]).toEqual(['c']); -      expect(map[hashKey(undefined)]).toEqual(['d']); -      expect(map[hashKey(null)]).toEqual(['e']); -    }); -  });  }); diff --git a/test/ng/directive/ngClassSpec.js b/test/ng/directive/ngClassSpec.js index 69afef7a..d4bd76fe 100644 --- a/test/ng/directive/ngClassSpec.js +++ b/test/ng/directive/ngClassSpec.js @@ -249,7 +249,7 @@ describe('ngClass', function() {    it('should update ngClassOdd/Even when model is changed by filtering', inject(function($rootScope, $compile) {      element = $compile('<ul>' + -      '<li ng-repeat="i in items" ' + +      '<li ng-repeat="i in items track by $index" ' +        'ng-class-odd="\'odd\'" ng-class-even="\'even\'"></li>' +        '<ul>')($rootScope);      $rootScope.items = ['a','b','a']; diff --git a/test/ng/directive/ngRepeatSpec.js b/test/ng/directive/ngRepeatSpec.js index 33e4dcfd..44406d6d 100644 --- a/test/ng/directive/ngRepeatSpec.js +++ b/test/ng/directive/ngRepeatSpec.js @@ -1,16 +1,27 @@  'use strict';  describe('ngRepeat', function() { -  var element, $compile, scope; +  var element, $compile, scope, $exceptionHandler; -  beforeEach(inject(function(_$compile_, $rootScope) { +  beforeEach(module(function($exceptionHandlerProvider) { +    $exceptionHandlerProvider.mode('log'); +  })); + +  beforeEach(inject(function(_$compile_, $rootScope, _$exceptionHandler_) {      $compile = _$compile_; +    $exceptionHandler = _$exceptionHandler_;      scope = $rootScope.$new();    })); -  afterEach(function(){ +  afterEach(function() { +    if ($exceptionHandler.errors.length) { +      dump(jasmine.getEnv().currentSpec.getFullName()); +      dump('$exceptionHandler has errors'); +      dump($exceptionHandler.errors); +      expect($exceptionHandler.errors).toBe([]); +    }      dealoc(element);    }); @@ -44,141 +55,177 @@ describe('ngRepeat', function() {    }); -  it('should iterate over an array of primitives', function() { +  it('should iterate over on object/map', function() {      element = $compile(        '<ul>' + -        '<li ng-repeat="item in items">{{item}};</li>' + +        '<li ng-repeat="(key, value) in items">{{key}}:{{value}}|</li>' +        '</ul>')(scope); - -    Array.prototype.extraProperty = "should be ignored"; -    // INIT -    scope.items = [true, true, true]; +    scope.items = {misko:'swe', shyam:'set'};      scope.$digest(); -    expect(element.find('li').length).toEqual(3); -    expect(element.text()).toEqual('true;true;true;'); -    delete Array.prototype.extraProperty; +    expect(element.text()).toEqual('misko:swe|shyam:set|'); +  }); -    scope.items = [false, true, true]; -    scope.$digest(); -    expect(element.find('li').length).toEqual(3); -    expect(element.text()).toEqual('false;true;true;'); -    scope.items = [false, true, false]; -    scope.$digest(); -    expect(element.find('li').length).toEqual(3); -    expect(element.text()).toEqual('false;true;false;'); +  describe('track by', function() { +    it('should track using expression function', function() { +      element = $compile( +          '<ul>' + +              '<li ng-repeat="item in items track by item.id">{{item.name}};</li>' + +              '</ul>')(scope); +      scope.items = [{id: 'misko'}, {id: 'igor'}]; +      scope.$digest(); +      var li0 = element.find('li')[0]; +      var li1 = element.find('li')[1]; -    scope.items = [true]; -    scope.$digest(); -    expect(element.find('li').length).toEqual(1); -    expect(element.text()).toEqual('true;'); +      scope.items.push(scope.items.shift()); +      scope.$digest(); +      expect(element.find('li')[0]).toBe(li1); +      expect(element.find('li')[1]).toBe(li0); +    }); -    scope.items = [true, true, false]; -    scope.$digest(); -    expect(element.find('li').length).toEqual(3); -    expect(element.text()).toEqual('true;true;false;'); -    scope.items = [true, false, false]; -    scope.$digest(); -    expect(element.find('li').length).toEqual(3); -    expect(element.text()).toEqual('true;false;false;'); +    it('should track using build in $id function', function() { +      element = $compile( +          '<ul>' + +              '<li ng-repeat="item in items track by $id(item)">{{item.name}};</li>' + +              '</ul>')(scope); +      scope.items = [{name: 'misko'}, {name: 'igor'}]; +      scope.$digest(); +      var li0 = element.find('li')[0]; +      var li1 = element.find('li')[1]; -    // string -    scope.items = ['a', 'a', 'a']; -    scope.$digest(); -    expect(element.find('li').length).toEqual(3); -    expect(element.text()).toEqual('a;a;a;'); +      scope.items.push(scope.items.shift()); +      scope.$digest(); +      expect(element.find('li')[0]).toBe(li1); +      expect(element.find('li')[1]).toBe(li0); +    }); -    scope.items = ['ab', 'a', 'a']; -    scope.$digest(); -    expect(element.find('li').length).toEqual(3); -    expect(element.text()).toEqual('ab;a;a;'); -    scope.items = ['test']; -    scope.$digest(); -    expect(element.find('li').length).toEqual(1); -    expect(element.text()).toEqual('test;'); +    it('should iterate over an array of primitives', function() { +      element = $compile( +          '<ul>' + +              '<li ng-repeat="item in items track by $index">{{item}};</li>' + +          '</ul>')(scope); -    scope.items = ['same', 'value']; -    scope.$digest(); -    expect(element.find('li').length).toEqual(2); -    expect(element.text()).toEqual('same;value;'); +      Array.prototype.extraProperty = "should be ignored"; +      // INIT +      scope.items = [true, true, true]; +      scope.$digest(); +      expect(element.find('li').length).toEqual(3); +      expect(element.text()).toEqual('true;true;true;'); +      delete Array.prototype.extraProperty; -    // number -    scope.items = [12, 12, 12]; -    scope.$digest(); -    expect(element.find('li').length).toEqual(3); -    expect(element.text()).toEqual('12;12;12;'); +      scope.items = [false, true, true]; +      scope.$digest(); +      expect(element.find('li').length).toEqual(3); +      expect(element.text()).toEqual('false;true;true;'); -    scope.items = [53, 12, 27]; -    scope.$digest(); -    expect(element.find('li').length).toEqual(3); -    expect(element.text()).toEqual('53;12;27;'); +      scope.items = [false, true, false]; +      scope.$digest(); +      expect(element.find('li').length).toEqual(3); +      expect(element.text()).toEqual('false;true;false;'); -    scope.items = [89]; -    scope.$digest(); -    expect(element.find('li').length).toEqual(1); -    expect(element.text()).toEqual('89;'); +      scope.items = [true]; +      scope.$digest(); +      expect(element.find('li').length).toEqual(1); +      expect(element.text()).toEqual('true;'); -    scope.items = [89, 23]; -    scope.$digest(); -    expect(element.find('li').length).toEqual(2); -    expect(element.text()).toEqual('89;23;'); -  }); +      scope.items = [true, true, false]; +      scope.$digest(); +      expect(element.find('li').length).toEqual(3); +      expect(element.text()).toEqual('true;true;false;'); +      scope.items = [true, false, false]; +      scope.$digest(); +      expect(element.find('li').length).toEqual(3); +      expect(element.text()).toEqual('true;false;false;'); -  it('should iterate over on object/map', function() { -    element = $compile( -      '<ul>' + -        '<li ng-repeat="(key, value) in items">{{key}}:{{value}}|</li>' + -      '</ul>')(scope); -    scope.items = {misko:'swe', shyam:'set'}; -    scope.$digest(); -    expect(element.text()).toEqual('misko:swe|shyam:set|'); -  }); +      // string +      scope.items = ['a', 'a', 'a']; +      scope.$digest(); +      expect(element.find('li').length).toEqual(3); +      expect(element.text()).toEqual('a;a;a;'); +      scope.items = ['ab', 'a', 'a']; +      scope.$digest(); +      expect(element.find('li').length).toEqual(3); +      expect(element.text()).toEqual('ab;a;a;'); -  it('should iterate over object with changing primitive property values', function() { -    // test for issue #933 +      scope.items = ['test']; +      scope.$digest(); +      expect(element.find('li').length).toEqual(1); +      expect(element.text()).toEqual('test;'); -    element = $compile( -      '<ul>' + -        '<li ng-repeat="(key, value) in items">' + -          '{{key}}:{{value}};' + -          '<input type="checkbox" ng-model="items[key]">' + -        '</li>' + -      '</ul>')(scope); +      scope.items = ['same', 'value']; +      scope.$digest(); +      expect(element.find('li').length).toEqual(2); +      expect(element.text()).toEqual('same;value;'); -    scope.items = {misko: true, shyam: true, zhenbo:true}; -    scope.$digest(); -    expect(element.find('li').length).toEqual(3); -    expect(element.text()).toEqual('misko:true;shyam:true;zhenbo:true;'); +      // number +      scope.items = [12, 12, 12]; +      scope.$digest(); +      expect(element.find('li').length).toEqual(3); +      expect(element.text()).toEqual('12;12;12;'); -    browserTrigger(element.find('input').eq(0), 'click'); +      scope.items = [53, 12, 27]; +      scope.$digest(); +      expect(element.find('li').length).toEqual(3); +      expect(element.text()).toEqual('53;12;27;'); -    expect(element.text()).toEqual('misko:false;shyam:true;zhenbo:true;'); -    expect(element.find('input')[0].checked).toBe(false); -    expect(element.find('input')[1].checked).toBe(true); -    expect(element.find('input')[2].checked).toBe(true); +      scope.items = [89]; +      scope.$digest(); +      expect(element.find('li').length).toEqual(1); +      expect(element.text()).toEqual('89;'); + +      scope.items = [89, 23]; +      scope.$digest(); +      expect(element.find('li').length).toEqual(2); +      expect(element.text()).toEqual('89;23;'); +    }); -    browserTrigger(element.find('input').eq(0), 'click'); -    expect(element.text()).toEqual('misko:true;shyam:true;zhenbo:true;'); -    expect(element.find('input')[0].checked).toBe(true); -    expect(element.find('input')[1].checked).toBe(true); -    expect(element.find('input')[2].checked).toBe(true); -    browserTrigger(element.find('input').eq(1), 'click'); -    expect(element.text()).toEqual('misko:true;shyam:false;zhenbo:true;'); -    expect(element.find('input')[0].checked).toBe(true); -    expect(element.find('input')[1].checked).toBe(false); -    expect(element.find('input')[2].checked).toBe(true); +    it('should iterate over object with changing primitive property values', function() { +      // test for issue #933 -    scope.items = {misko: false, shyam: true, zhenbo: true}; -    scope.$digest(); -    expect(element.text()).toEqual('misko:false;shyam:true;zhenbo:true;'); -    expect(element.find('input')[0].checked).toBe(false); -    expect(element.find('input')[1].checked).toBe(true); -    expect(element.find('input')[2].checked).toBe(true); +      element = $compile( +          '<ul>' + +              '<li ng-repeat="(key, value) in items track by $index">' + +              '{{key}}:{{value}};' + +              '<input type="checkbox" ng-model="items[key]">' + +              '</li>' + +              '</ul>')(scope); + +      scope.items = {misko: true, shyam: true, zhenbo:true}; +      scope.$digest(); +      expect(element.find('li').length).toEqual(3); +      expect(element.text()).toEqual('misko:true;shyam:true;zhenbo:true;'); + +      browserTrigger(element.find('input').eq(0), 'click'); + +      expect(element.text()).toEqual('misko:false;shyam:true;zhenbo:true;'); +      expect(element.find('input')[0].checked).toBe(false); +      expect(element.find('input')[1].checked).toBe(true); +      expect(element.find('input')[2].checked).toBe(true); + +      browserTrigger(element.find('input').eq(0), 'click'); +      expect(element.text()).toEqual('misko:true;shyam:true;zhenbo:true;'); +      expect(element.find('input')[0].checked).toBe(true); +      expect(element.find('input')[1].checked).toBe(true); +      expect(element.find('input')[2].checked).toBe(true); + +      browserTrigger(element.find('input').eq(1), 'click'); +      expect(element.text()).toEqual('misko:true;shyam:false;zhenbo:true;'); +      expect(element.find('input')[0].checked).toBe(true); +      expect(element.find('input')[1].checked).toBe(false); +      expect(element.find('input')[2].checked).toBe(true); + +      scope.items = {misko: false, shyam: true, zhenbo: true}; +      scope.$digest(); +      expect(element.text()).toEqual('misko:false;shyam:true;zhenbo:true;'); +      expect(element.find('input')[0].checked).toBe(false); +      expect(element.find('input')[1].checked).toBe(true); +      expect(element.find('input')[2].checked).toBe(true); +    });    }); @@ -199,19 +246,18 @@ describe('ngRepeat', function() {    it('should error on wrong parsing of ngRepeat', function() { -    expect(function() { -      element = jqLite('<ul><li ng-repeat="i dont parse"></li></ul>'); -      $compile(element)(scope); -    }).toThrow("Expected ngRepeat in form of '_item_ in _collection_' but got 'i dont parse'."); +    element = jqLite('<ul><li ng-repeat="i dont parse"></li></ul>'); +    $compile(element)(scope); +    expect($exceptionHandler.errors.shift()[0].message). +        toBe("Expected ngRepeat in form of '_item_ in _collection_[ track by _id_]' but got 'i dont parse'.");    });    it("should throw error when left-hand-side of ngRepeat can't be parsed", function() { -    expect(function() {        element = jqLite('<ul><li ng-repeat="i dont parse in foo"></li></ul>');        $compile(element)(scope); -    }).toThrow("'item' in 'item in collection' should be identifier or (key, value) but got " + -               "'i dont parse'."); +    expect($exceptionHandler.errors.shift()[0].message). +        toBe("'item' in 'item in collection' should be identifier or (key, value) but got 'i dont parse'.");    }); @@ -311,7 +357,7 @@ describe('ngRepeat', function() {    it('should ignore $ and $$ properties', function() {      element = $compile('<ul><li ng-repeat="i in items">{{i}}|</li></ul>')(scope);      scope.items = ['a', 'b', 'c']; -    scope.items.$$hashkey = 'xxx'; +    scope.items.$$hashKey = 'xxx';      scope.items.$root = 'yyy';      scope.$digest(); @@ -393,43 +439,23 @@ describe('ngRepeat', function() {      }); -    it('should support duplicates', function() { -      scope.items = [a, a, b, c]; -      scope.$digest(); -      var newElements = element.find('li'); -      expect(newElements[0]).toEqual(lis[0]); -      expect(newElements[1]).not.toEqual(lis[0]); -      expect(newElements[2]).toEqual(lis[1]); -      expect(newElements[3]).toEqual(lis[2]); - -      lis = newElements; +    it('should throw error on duplicates and recover', function() { +      scope.items = [a, a, a];        scope.$digest(); -      newElements = element.find('li'); -      expect(newElements[0]).toEqual(lis[0]); -      expect(newElements[1]).toEqual(lis[1]); -      expect(newElements[2]).toEqual(lis[2]); -      expect(newElements[3]).toEqual(lis[3]); +      expect($exceptionHandler.errors.shift().message). +          toEqual('Duplicates in a repeater are not allowed. Repeater: item in items'); +      // recover +      scope.items = [a];        scope.$digest(); -      newElements = element.find('li'); +      var newElements = element.find('li'); +      expect(newElements.length).toEqual(1);        expect(newElements[0]).toEqual(lis[0]); -      expect(newElements[1]).toEqual(lis[1]); -      expect(newElements[2]).toEqual(lis[2]); -      expect(newElements[3]).toEqual(lis[3]); -    }); - -    it('should remove last item when one duplicate instance is removed', function() { -      scope.items = [a, a, a]; -      scope.$digest(); -      lis = element.find('li'); - -      scope.items = [a, a]; +      scope.items = [];        scope.$digest();        var newElements = element.find('li'); -      expect(newElements.length).toEqual(2); -      expect(newElements[0]).toEqual(lis[0]); -      expect(newElements[1]).toEqual(lis[1]); +      expect(newElements.length).toEqual(0);      }); | 
