diff --git a/CHANGELOG.md b/CHANGELOG.md index 856731b28b99..cd5fb2b50db6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,10 @@ - If Angular is being used with jQuery older than 1.6, some features might not work properly. Please upgrade to jQuery version 1.6.4. - +## Breaking Changes +- ng:repeat no longer has ng:repeat-index property. This is because the elements now have + affinity to the underlying collection, and moving items around in the collection would move + ng:repeat-index property rendering it meaningless. @@ -88,7 +91,7 @@ - $location.hashPath -> $location.path() - $location.hashSearch -> $location.search() - $location.search -> no equivalent, use $window.location.search (this is so that we can work in - hashBang and html5 mode at the same time, check out the docs) + hashBang and html5 mode at the same time, check out the docs) - $location.update() / $location.updateHash() -> use $location.url() - n/a -> $location.replace() - new api for replacing history record instead of creating a new one diff --git a/src/apis.js b/src/apis.js index 7aa9f0c2bfae..bec54b8e350f 100644 --- a/src/apis.js +++ b/src/apis.js @@ -840,20 +840,22 @@ var angularFunction = { * Hash of a: * string is string * number is number as string - * object is either call $hashKey function on object or assign unique hashKey id. + * object is either result of calling $$hashKey function on the object or uniquely generated id, + * that is also assigned to the $$hashKey property of the object. * * @param obj - * @returns {String} hash string such that the same input will have the same hash string + * @returns {String} hash string such that the same input will have the same hash string. + * The resulting string key is in 'type:hashKey' format. */ function hashKey(obj) { var objType = typeof obj; var key = obj; if (objType == 'object') { - if (typeof (key = obj.$hashKey) == 'function') { + if (typeof (key = obj.$$hashKey) == 'function') { // must invoke on object to keep the right this - key = obj.$hashKey(); + key = obj.$$hashKey(); } else if (key === undefined) { - key = obj.$hashKey = nextUid(); + key = obj.$$hashKey = nextUid(); } } return objType + ':' + key; @@ -868,13 +870,9 @@ HashMap.prototype = { * Store key value pair * @param key key to store can be any type * @param value value to store can be any type - * @returns old value if any */ put: function(key, value) { - var _key = hashKey(key); - var oldValue = this[_key]; - this[_key] = value; - return oldValue; + this[hashKey(key)] = value; }, /** @@ -888,16 +886,48 @@ HashMap.prototype = { /** * Remove the key/value pair * @param key - * @returns value associated with key before it was removed */ remove: function(key) { - var _key = hashKey(key); - var value = this[_key]; - delete this[_key]; + var value = this[key = hashKey(key)]; + delete this[key]; return value; } }; +/** + * A map where multiple values can be added to the same key such that the 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(); + } + } + } +}; + function defineApi(dst, chain){ angular[dst] = angular[dst] || {}; forEach(chain, function(parent){ diff --git a/src/widgets.js b/src/widgets.js index bd7c3d7f8847..1047c3ce29cf 100644 --- a/src/widgets.js +++ b/src/widgets.js @@ -1182,10 +1182,9 @@ angularWidget('a', function() { * @name angular.widget.@ng:repeat * * @description - * The `ng:repeat` widget instantiates a template once per item from a collection. The collection is - * enumerated with the `ng:repeat-index` attribute, starting from 0. Each template instance gets - * its own scope, where the given loop variable is set to the current collection item, and `$index` - * is set to the item index or key. + * The `ng:repeat` widget instantiates a template once per item from a collection. Each template + * instance gets its own scope, where the given loop variable is set to the current collection item, + * and `$index` is set to the item index or key. * * Special properties are exposed on the local scope of each template instance, including: * @@ -1256,68 +1255,89 @@ angularWidget('@ng:repeat', function(expression, element){ valueIdent = match[3] || match[1]; keyIdent = match[2]; - var childScopes = []; - var childElements = [iterStartElement]; var parentScope = this; + // 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(); this.$watch(function(scope){ var index = 0, - childCount = childScopes.length, collection = scope.$eval(rhs), collectionLength = size(collection, true), - fragment = document.createDocumentFragment(), - addFragmentTo = (childCount < collectionLength) ? childElements[childCount] : null, childScope, - key; + // Same as lastOrder but it has the current state. It will become the + // lastOrder on the next iteration. + nextOrder = new HashQueueMap(), + key, value, // key/value of iteration + array, last, // last object information {scope, element, index} + cursor = iterStartElement; // current position of the node for (key in collection) { if (collection.hasOwnProperty(key)) { - if (index < childCount) { - // reuse existing child - childScope = childScopes[index]; - childScope[valueIdent] = collection[key]; - if (keyIdent) childScope[keyIdent] = key; - childScope.$position = index == 0 - ? 'first' - : (index == collectionLength - 1 ? 'last' : 'middle'); - childScope.$eval(); + last = lastOrder.shift(value = collection[key]); + 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; + } } else { - // grow children + // new item which we don't know about childScope = parentScope.$new(); - childScope[valueIdent] = collection[key]; - if (keyIdent) childScope[keyIdent] = key; - childScope.$index = index; - childScope.$position = index == 0 - ? 'first' - : (index == collectionLength - 1 ? 'last' : 'middle'); - childScopes.push(childScope); + } + + childScope[valueIdent] = collection[key]; + if (keyIdent) childScope[keyIdent] = key; + childScope.$index = index; + childScope.$position = index == 0 + ? 'first' + : (index == collectionLength - 1 ? 'last' : 'middle'); + + if (!last) { linker(childScope, function(clone){ - clone.attr('ng:repeat-index', index); - fragment.appendChild(clone[0]); - // TODO(misko): Temporary hack - maybe think about it - removed after we add fragment after $digest() - // This causes double $digest for children - // The first flush will couse a lot of DOM access (initial) - // Second flush shuld be noop since nothing has change hence no DOM access. - childScope.$digest(); - childElements[index + 1] = clone; + cursor.after(clone); + last = { + scope: childScope, + element: (cursor = clone), + index: index + }; + nextOrder.push(value, last); }); } + index ++; } } - //attach new nodes buffered in doc fragment - if (addFragmentTo) { - // TODO(misko): For performance reasons, we should do the addition after all other widgets - // have run. For this should happend after $digest() is done! - addFragmentTo.after(jqLite(fragment)); + //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(); + } + } } - // shrink children - while(childScopes.length > index) { - // can not use $destroy(true) since there may be multiple iterators on same parent. - childScopes.pop().$destroy(); - childElements.pop().remove(); - } + lastOrder = nextOrder; }); }; }); diff --git a/test/ApiSpecs.js b/test/ApiSpecs.js index ef25bb413149..9683a7b769e4 100644 --- a/test/ApiSpecs.js +++ b/test/ApiSpecs.js @@ -1,26 +1,39 @@ 'use strict'; -describe('api', function(){ +describe('api', function() { - describe('HashMap', function(){ - it('should do basic crud', function(){ + describe('HashMap', function() { + it('should do basic crud', function() { var map = new HashMap(); var key = {}; var value1 = {}; var value2 = {}; - expect(map.put(key, value1)).toEqual(undefined); - expect(map.put(key, value2)).toEqual(value1); - expect(map.get(key)).toEqual(value2); - expect(map.get({})).toEqual(undefined); - expect(map.remove(key)).toEqual(value2); - expect(map.get(key)).toEqual(undefined); + map.put(key, value1); + map.put(key, value2); + expect(map.get(key)).toBe(value2); + expect(map.get({})).toBe(undefined); + expect(map.remove(key)).toBe(value2); + expect(map.get(key)).toBe(undefined); }); }); - describe('Object', function(){ + 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.shift('key')).toEqual('a'); + expect(map.shift('key')).toEqual('b'); + expect(map.shift('key')).toEqual(undefined); + expect(map[hashKey('key')]).toEqual(undefined); + }); + }); + - it('should return type of', function(){ + describe('Object', function() { + it('should return type of', function() { assertEquals("undefined", angular.Object.typeOf(undefined)); assertEquals("null", angular.Object.typeOf(null)); assertEquals("object", angular.Collection.typeOf({})); @@ -28,46 +41,45 @@ describe('api', function(){ assertEquals("string", angular.Object.typeOf("")); assertEquals("date", angular.Object.typeOf(new Date())); assertEquals("element", angular.Object.typeOf(document.body)); - assertEquals('function', angular.Object.typeOf(function(){})); + assertEquals('function', angular.Object.typeOf(function() {})); }); - it('should extend object', function(){ + it('should extend object', function() { assertEquals({a:1, b:2}, angular.Object.extend({a:1}, {b:2})); }); - }); - it('should return size', function(){ + it('should return size', function() { assertEquals(0, angular.Collection.size({})); assertEquals(1, angular.Collection.size({a:"b"})); assertEquals(0, angular.Object.size({})); assertEquals(1, angular.Array.size([0])); }); - describe('Array', function(){ - describe('sum', function(){ + describe('Array', function() { - it('should sum', function(){ + describe('sum', function() { + it('should sum', function() { assertEquals(3, angular.Array.sum([{a:"1"}, {a:"2"}], 'a')); }); - it('should sum containing NaN', function(){ + it('should sum containing NaN', function() { assertEquals(1, angular.Array.sum([{a:1}, {a:Number.NaN}], 'a')); - assertEquals(1, angular.Array.sum([{a:1}, {a:Number.NaN}], function($){return $.a;})); + assertEquals(1, angular.Array.sum([{a:1}, {a:Number.NaN}], function($) {return $.a;})); }); - }); - it('should find indexOf', function(){ + + it('should find indexOf', function() { assertEquals(angular.Array.indexOf(['a'], 'a'), 0); assertEquals(angular.Array.indexOf(['a', 'b'], 'a'), 0); assertEquals(angular.Array.indexOf(['b', 'a'], 'a'), 1); assertEquals(angular.Array.indexOf(['b', 'b'],'x'), -1); }); - it('should remove item from array', function(){ + it('should remove item from array', function() { var items = ['a', 'b', 'c']; assertEquals(angular.Array.remove(items, 'q'), 'q'); assertEquals(items.length, 3); @@ -85,8 +97,8 @@ describe('api', function(){ assertEquals(items.length, 0); }); - describe('filter', function(){ + describe('filter', function() { it('should filter by string', function() { var items = ["MIsKO", {name:"shyam"}, ["adam"], 1234]; assertEquals(4, angular.Array.filter(items, "").length); @@ -113,7 +125,7 @@ describe('api', function(){ assertEquals(0, angular.Array.filter(items, "misko").length); }); - it('should filter on specific property', function(){ + it('should filter on specific property', function() { var items = [{ignore:"a", name:"a"}, {ignore:"a", name:"abc"}]; assertEquals(2, angular.Array.filter(items, {}).length); @@ -123,12 +135,12 @@ describe('api', function(){ assertEquals("abc", angular.Array.filter(items, {name:'b'})[0].name); }); - it('should take function as predicate', function(){ + it('should take function as predicate', function() { var items = [{name:"a"}, {name:"abc", done:true}]; - assertEquals(1, angular.Array.filter(items, function(i){return i.done;}).length); + assertEquals(1, angular.Array.filter(items, function(i) {return i.done;}).length); }); - it('should take object as perdicate', function(){ + it('should take object as perdicate', function() { var items = [{first:"misko", last:"hevery"}, {first:"adam", last:"abrons"}]; @@ -139,7 +151,7 @@ describe('api', function(){ assertEquals(items[0], angular.Array.filter(items, {first:'misko', last:'hevery'})[0]); }); - it('should support negation operator', function(){ + it('should support negation operator', function() { var items = ["misko", "adam"]; assertEquals(1, angular.Array.filter(items, '!isk').length); @@ -198,12 +210,12 @@ describe('api', function(){ }); - it('add', function(){ + it('add', function() { var add = angular.Array.add; assertJsonEquals([{}, "a"], add(add([]),"a")); }); - it('count', function(){ + it('count', function() { var array = [{name:'a'},{name:'b'},{name:''}]; var obj = {}; @@ -212,24 +224,25 @@ describe('api', function(){ assertEquals(1, angular.Array.count(array, 'name=="a"')); }); - describe('orderBy', function(){ + + describe('orderBy', function() { var orderBy; - beforeEach(function(){ + beforeEach(function() { orderBy = angular.Array.orderBy; }); - it('should return same array if predicate is falsy', function(){ + it('should return same array if predicate is falsy', function() { var array = [1, 2, 3]; expect(orderBy(array)).toBe(array); }); - it('shouldSortArrayInReverse', function(){ + it('shouldSortArrayInReverse', function() { assertJsonEquals([{a:15},{a:2}], angular.Array.orderBy([{a:15},{a:2}], 'a', true)); assertJsonEquals([{a:15},{a:2}], angular.Array.orderBy([{a:15},{a:2}], 'a', "T")); assertJsonEquals([{a:15},{a:2}], angular.Array.orderBy([{a:15},{a:2}], 'a', "reverse")); }); - it('should sort array by predicate', function(){ + it('should sort array by predicate', function() { assertJsonEquals([{a:2, b:1},{a:15, b:1}], angular.Array.orderBy([{a:15, b:1},{a:2, b:1}], ['a', 'b'])); assertJsonEquals([{a:2, b:1},{a:15, b:1}], @@ -238,11 +251,11 @@ describe('api', function(){ angular.Array.orderBy([{a:15, b:1},{a:2, b:1}], ['+b', '-a'])); }); - it('should use function', function(){ + it('should use function', function() { expect( orderBy( [{a:15, b:1},{a:2, b:1}], - function(value){ return value.a; })). + function(value) { return value.a; })). toEqual([{a:2, b:1},{a:15, b:1}]); }); @@ -250,9 +263,9 @@ describe('api', function(){ }); - describe('string', function(){ - it('should quote', function(){ + describe('string', function() { + it('should quote', function() { assertEquals(angular.String.quote('a'), '"a"'); assertEquals(angular.String.quote('\\'), '"\\\\"'); assertEquals(angular.String.quote("'a'"), '"\'a\'"'); @@ -260,22 +273,22 @@ describe('api', function(){ assertEquals(angular.String.quote('\n\f\r\t'), '"\\n\\f\\r\\t"'); }); - it('should quote slashes', function(){ + it('should quote slashes', function() { assertEquals('"7\\\\\\\"7"', angular.String.quote("7\\\"7")); }); - it('should quote unicode', function(){ + it('should quote unicode', function() { assertEquals('"abc\\u00a0def"', angular.String.quoteUnicode('abc\u00A0def')); }); - it('should read/write to date', function(){ + it('should read/write to date', function() { var date = new Date("Sep 10 2003 13:02:03 GMT"); assertEquals("date", angular.Object.typeOf(date)); assertEquals("2003-09-10T13:02:03.000Z", angular.Date.toString(date)); assertEquals(date.getTime(), angular.String.toDate(angular.Date.toString(date)).getTime()); }); - it('should convert to date', function(){ + it('should convert to date', function() { //full ISO8061 expect(angular.String.toDate("2003-09-10T13:02:03.000Z")). toEqual(new Date("Sep 10 2003 13:02:03 GMT")); @@ -297,14 +310,12 @@ describe('api', function(){ toEqual(new Date("Sep 10 2003 00:00:00 GMT")); }); - it('should parse date', function(){ + it('should parse date', function() { var date = angular.String.toDate("2003-09-10T13:02:03.000Z"); assertEquals("date", angular.Object.typeOf(date)); assertEquals("2003-09-10T13:02:03.000Z", angular.Date.toString(date)); assertEquals("str", angular.String.toDate("str")); }); - }); - }); diff --git a/test/BinderSpec.js b/test/BinderSpec.js index 68513f625b34..93f23eefdfa4 100644 --- a/test/BinderSpec.js +++ b/test/BinderSpec.js @@ -194,25 +194,25 @@ describe('Binder', function(){ scope.$apply(); assertEquals('', sortedHtml(form)); items.unshift({a:'C'}); scope.$apply(); assertEquals('', sortedHtml(form)); items.shift(); scope.$apply(); assertEquals('', sortedHtml(form)); items.shift(); @@ -226,7 +226,7 @@ describe('Binder', function(){ scope.$apply(); assertEquals('', sortedHtml(scope.$element)); }); @@ -329,15 +329,15 @@ describe('Binder', function(){ assertEquals('
'+ '<#comment>'+ - '
'+ + '
'+ '<#comment>'+ - '
    '+ - '
      '+ + '
        '+ + '
          '+ '
          '+ - '
          '+ + '
          '+ '<#comment>'+ - '
            '+ - '
              '+ + '
                '+ + '
                  '+ '
                  ', sortedHtml(scope.$element)); }); @@ -417,8 +417,8 @@ describe('Binder', function(){ expect(d2.hasClass('e')).toBeTruthy(); assertEquals( '
                  <#comment>' + - '
                  ' + - '
                  ', + '
                  ' + + '
                  ', sortedHtml(scope.$element)); }); @@ -459,8 +459,8 @@ describe('Binder', function(){ scope.items = [{}, {name:'misko'}]; scope.$apply(); - assertEquals("123", scope.$eval('items[0].name')); - assertEquals("misko", scope.$eval('items[1].name')); + expect(scope.$eval('items[0].name')).toEqual("123"); + expect(scope.$eval('items[1].name')).toEqual("misko"); }); it('ShouldTemplateBindPreElements', function () { @@ -593,8 +593,8 @@ describe('Binder', function(){ scope.$apply(); assertEquals('', sortedHtml(scope.$element)); }); diff --git a/test/scenario/dslSpec.js b/test/scenario/dslSpec.js index 3160da8d76b4..c5d0a29d3e7d 100644 --- a/test/scenario/dslSpec.js +++ b/test/scenario/dslSpec.js @@ -378,9 +378,9 @@ describe("angular.scenario.dsl", function() { beforeEach(function() { doc.append( '' ); diff --git a/test/widgetsSpec.js b/test/widgetsSpec.js index 6fccaa488f16..02d0ef71f69a 100644 --- a/test/widgetsSpec.js +++ b/test/widgetsSpec.js @@ -1,6 +1,6 @@ 'use strict'; -describe("widget", function(){ +describe("widget", function() { var compile, element, scope; beforeEach(function() { @@ -19,14 +19,15 @@ describe("widget", function(){ }; }); - afterEach(function(){ + afterEach(function() { dealoc(element); }); - describe("input", function(){ - describe("text", function(){ - it('should input-text auto init and handle keydown/change events', function(){ + describe("input", function() { + + describe("text", function() { + it('should input-text auto init and handle keydown/change events', function() { compile(''); expect(scope.name).toEqual("Misko"); expect(scope.count).toEqual(0); @@ -49,7 +50,7 @@ describe("widget", function(){ expect(scope.count).toEqual(2); }); - it('should not trigger eval if value does not change', function(){ + it('should not trigger eval if value does not change', function() { compile(''); expect(scope.name).toEqual("Misko"); expect(scope.count).toEqual(0); @@ -58,16 +59,16 @@ describe("widget", function(){ expect(scope.count).toEqual(0); }); - it('should allow complex refernce binding', function(){ + it('should allow complex refernce binding', function() { compile('
                  '+ ''+ '
                  '); expect(scope.obj['abc'].name).toEqual('Misko'); }); - describe("ng:format", function(){ - it("should format text", function(){ + describe("ng:format", function() { + it("should format text", function() { compile(''); expect(scope.list).toEqual(['a', 'b', 'c']); @@ -80,13 +81,13 @@ describe("widget", function(){ expect(scope.list).toEqual(['1', '2', '3']); }); - it("should come up blank if null", function(){ + it("should come up blank if null", function() { compile(''); expect(scope.age).toBeNull(); expect(scope.$element[0].value).toEqual(''); }); - it("should show incorect text while number does not parse", function(){ + it("should show incorect text while number does not parse", function() { compile(''); scope.age = 123; scope.$digest(); @@ -97,14 +98,14 @@ describe("widget", function(){ expect(scope.$element).toBeInvalid(); }); - it("should clober incorect text if model changes", function(){ + it("should clober incorect text if model changes", function() { compile(''); scope.age = 456; scope.$digest(); expect(scope.$element.val()).toEqual('456'); }); - it("should not clober text if model changes due to itself", function(){ + it("should not clober text if model changes due to itself", function() { compile(''); scope.$element.val('a '); @@ -128,23 +129,23 @@ describe("widget", function(){ expect(scope.list).toEqual(['a', 'b']); }); - it("should come up blank when no value specifiend", function(){ + it("should come up blank when no value specifiend", function() { compile(''); scope.$digest(); expect(scope.$element.val()).toEqual(''); expect(scope.age).toEqual(null); }); - }); - describe("checkbox", function(){ - it("should format booleans", function(){ + + describe("checkbox", function() { + it("should format booleans", function() { compile(''); expect(scope.name).toEqual(false); expect(scope.$element[0].checked).toEqual(false); }); - it('should support type="checkbox"', function(){ + it('should support type="checkbox"', function() { compile(''); expect(scope.checkbox).toEqual(true); browserTrigger(element); @@ -154,9 +155,9 @@ describe("widget", function(){ expect(scope.checkbox).toEqual(true); }); - it("should use ng:format", function(){ + it("should use ng:format", function() { angularFormatter('testFormat', { - parse: function(value){ + parse: function(value) { return value ? "Worked" : "Failed"; }, @@ -181,8 +182,9 @@ describe("widget", function(){ }); }); - describe("ng:validate", function(){ - it("should process ng:validate", function(){ + + describe("ng:validate", function() { + it("should process ng:validate", function() { compile('', jqLite(document.body)); expect(element.hasClass('ng-validation-error')).toBeTruthy(); @@ -210,9 +212,9 @@ describe("widget", function(){ expect(element.attr('ng-validation-error')).toBeFalsy(); }); - it("should not call validator if undefined/empty", function(){ + it("should not call validator if undefined/empty", function() { var lastValue = "NOT_CALLED"; - angularValidator.myValidator = function(value){lastValue = value;}; + angularValidator.myValidator = function(value) {lastValue = value;}; compile(''); expect(lastValue).toEqual("NOT_CALLED"); @@ -225,19 +227,20 @@ describe("widget", function(){ }); }); - it("should ignore disabled widgets", function(){ + + it("should ignore disabled widgets", function() { compile(''); expect(element.hasClass('ng-validation-error')).toBeFalsy(); expect(element.attr('ng-validation-error')).toBeFalsy(); }); - it("should ignore readonly widgets", function(){ + it("should ignore readonly widgets", function() { compile(''); expect(element.hasClass('ng-validation-error')).toBeFalsy(); expect(element.attr('ng-validation-error')).toBeFalsy(); }); - it("should process ng:required", function(){ + it("should process ng:required", function() { compile('', jqLite(document.body)); expect(element.hasClass('ng-validation-error')).toBeTruthy(); expect(element.attr('ng-validation-error')).toEqual('Required'); @@ -296,9 +299,8 @@ describe("widget", function(){ }); - describe('radio', function(){ - - it('should support type="radio"', function(){ + describe('radio', function() { + it('should support type="radio"', function() { compile('
                  ' + '' + '' + @@ -323,7 +325,7 @@ describe("widget", function(){ expect(scope.clicked).toEqual(1); }); - it('should honor model over html checked keyword after', function(){ + it('should honor model over html checked keyword after', function() { compile('
                  ' + '' + '' + @@ -333,7 +335,7 @@ describe("widget", function(){ expect(scope.choose).toEqual('C'); }); - it('should honor model over html checked keyword before', function(){ + it('should honor model over html checked keyword before', function() { compile('
                  ' + '' + '' + @@ -345,8 +347,9 @@ describe("widget", function(){ }); - describe('select-one', function(){ - it('should initialize to selected', function(){ + + describe('select-one', function() { + it('should initialize to selected', function() { compile( '' + '' + '' + @@ -386,32 +389,32 @@ describe("widget", function(){ scope.$digest(); expect(element[0].childNodes[0].selected).toEqual(true); }); - }); - it('should ignore text widget which have no name', function(){ + + it('should ignore text widget which have no name', function() { compile(''); expect(scope.$element.attr('ng-exception')).toBeFalsy(); expect(scope.$element.hasClass('ng-exception')).toBeFalsy(); }); - it('should ignore checkbox widget which have no name', function(){ + it('should ignore checkbox widget which have no name', function() { compile(''); expect(scope.$element.attr('ng-exception')).toBeFalsy(); expect(scope.$element.hasClass('ng-exception')).toBeFalsy(); }); - it('should report error on assignment error', function(){ - expect(function(){ + it('should report error on assignment error', function() { + expect(function() { compile(''); }).toThrow("Syntax Error: Token '''' is an unexpected token at column 7 of the expression [throw ''] starting at ['']."); $logMock.error.logs.shift(); }); - }); - describe('ng:switch', function(){ - it('should switch on value change', function(){ + + describe('ng:switch', function() { + it('should switch on value change', function() { compile('' + '
                  first:{{name}}
                  ' + '
                  second:{{name}}
                  ' + @@ -435,7 +438,7 @@ describe("widget", function(){ expect(element.text()).toEqual('true:misko'); }); - it('should switch on switch-when-default', function(){ + it('should switch on switch-when-default', function() { compile('' + '
                  one
                  ' + '
                  other
                  ' + @@ -447,7 +450,7 @@ describe("widget", function(){ expect(element.text()).toEqual('one'); }); - it('should call change on switch', function(){ + it('should call change on switch', function() { var scope = angular.compile('
                  {{name}}
                  ')(); scope.url = 'a'; scope.$apply(); @@ -457,7 +460,8 @@ describe("widget", function(){ }); }); - describe('ng:include', function(){ + + describe('ng:include', function() { it('should include on external file', function() { var element = jqLite(''); var scope = angular.compile(element)(); @@ -488,7 +492,7 @@ describe("widget", function(){ dealoc(scope); }); - it('should allow this for scope', function(){ + it('should allow this for scope', function() { var element = jqLite(''); var scope = angular.compile(element)(); scope.url = 'myUrl'; @@ -518,7 +522,7 @@ describe("widget", function(){ dealoc(element); }); - it('should destroy old scope', function(){ + it('should destroy old scope', function() { var element = jqLite(''); var scope = angular.compile(element)(); @@ -536,6 +540,7 @@ describe("widget", function(){ }); }); + describe('a', function() { it('should prevent default action to be executed when href is empty', function() { var orgLocation = document.location.href, @@ -571,12 +576,13 @@ describe("widget", function(){ }); }); - describe('ng:options', function(){ + + describe('ng:options', function() { var select, scope; - function createSelect(attrs, blank, unknown){ + function createSelect(attrs, blank, unknown) { var html = ''); }).toThrow("Expected ng:options in form of '_select_ (as _label_)? for (_key_,)?_value_ in" + " _collection_' but got 'i dont parse'."); }); - it('should render a list', function(){ + it('should render a list', function() { createSingleSelect(); scope.values = [{name:'A'}, {name:'B'}, {name:'C'}]; scope.selected = scope.values[0]; @@ -630,7 +636,7 @@ describe("widget", function(){ expect(sortedHtml(options[2])).toEqual(''); }); - it('should render an object', function(){ + it('should render an object', function() { createSelect({ 'name':'selected', 'ng:options': 'value as key for (key, value) in object' @@ -651,7 +657,7 @@ describe("widget", function(){ expect(options[3].selected).toEqual(true); }); - it('should grow list', function(){ + it('should grow list', function() { createSingleSelect(); scope.values = []; scope.$digest(); @@ -671,7 +677,7 @@ describe("widget", function(){ expect(sortedHtml(select.find('option')[1])).toEqual(''); }); - it('should shrink list', function(){ + it('should shrink list', function() { createSingleSelect(); scope.values = [{name:'A'}, {name:'B'}, {name:'C'}]; scope.selected = scope.values[0]; @@ -695,7 +701,7 @@ describe("widget", function(){ expect(select.find('option').length).toEqual(1); // we add back the special empty option }); - it('should shrink and then grow list', function(){ + it('should shrink and then grow list', function() { createSingleSelect(); scope.values = [{name:'A'}, {name:'B'}, {name:'C'}]; scope.selected = scope.values[0]; @@ -713,7 +719,7 @@ describe("widget", function(){ expect(select.find('option').length).toEqual(3); }); - it('should update list', function(){ + it('should update list', function() { createSingleSelect(); scope.values = [{name:'A'}, {name:'B'}, {name:'C'}]; scope.selected = scope.values[0]; @@ -729,7 +735,7 @@ describe("widget", function(){ expect(sortedHtml(options[2])).toEqual(''); }); - it('should preserve existing options', function(){ + it('should preserve existing options', function() { createSingleSelect(true); scope.$digest(); @@ -749,8 +755,9 @@ describe("widget", function(){ expect(jqLite(select.find('option')[0]).text()).toEqual('blank'); }); - describe('binding', function(){ - it('should bind to scope value', function(){ + + describe('binding', function() { + it('should bind to scope value', function() { createSingleSelect(); scope.values = [{name:'A'}, {name:'B'}]; scope.selected = scope.values[0]; @@ -762,7 +769,8 @@ describe("widget", function(){ expect(select.val()).toEqual('1'); }); - it('should bind to scope value and group', function(){ + + it('should bind to scope value and group', function() { createSelect({ 'name':'selected', 'ng:options':'item.name group by item.group for item in values' @@ -795,7 +803,7 @@ describe("widget", function(){ expect(select.val()).toEqual('0'); }); - it('should bind to scope value through experession', function(){ + it('should bind to scope value through experession', function() { createSelect({'name':'selected', 'ng:options':'item.id as item.name for item in values'}); scope.values = [{id:10, name:'A'}, {id:20, name:'B'}]; scope.selected = scope.values[0].id; @@ -807,7 +815,7 @@ describe("widget", function(){ expect(select.val()).toEqual('1'); }); - it('should bind to object key', function(){ + it('should bind to object key', function() { createSelect({ 'name':'selected', 'ng:options':'key as value for (key, value) in object' @@ -822,7 +830,7 @@ describe("widget", function(){ expect(select.val()).toEqual('blue'); }); - it('should bind to object value', function(){ + it('should bind to object value', function() { createSelect({ name:'selected', 'ng:options':'value as key for (key, value) in object' @@ -837,7 +845,7 @@ describe("widget", function(){ expect(select.val()).toEqual('blue'); }); - it('should insert a blank option if bound to null', function(){ + it('should insert a blank option if bound to null', function() { createSingleSelect(); scope.values = [{name:'A'}]; scope.selected = null; @@ -852,7 +860,7 @@ describe("widget", function(){ expect(select.find('option').length).toEqual(1); }); - it('should reuse blank option if bound to null', function(){ + it('should reuse blank option if bound to null', function() { createSingleSelect(true); scope.values = [{name:'A'}]; scope.selected = null; @@ -867,7 +875,7 @@ describe("widget", function(){ expect(select.find('option').length).toEqual(2); }); - it('should insert a unknown option if bound to something not in the list', function(){ + it('should insert a unknown option if bound to something not in the list', function() { createSingleSelect(); scope.values = [{name:'A'}]; scope.selected = {}; @@ -883,8 +891,9 @@ describe("widget", function(){ }); }); - describe('on change', function(){ - it('should update model on change', function(){ + + describe('on change', function() { + it('should update model on change', function() { createSingleSelect(); scope.values = [{name:'A'}, {name:'B'}]; scope.selected = scope.values[0]; @@ -896,7 +905,7 @@ describe("widget", function(){ expect(scope.selected).toEqual(scope.values[1]); }); - it('should fire ng:change if present', function(){ + it('should fire ng:change if present', function() { createSelect({ name:'selected', 'ng:options':'value for value in values', @@ -924,7 +933,7 @@ describe("widget", function(){ expect(scope.selected).toEqual(scope.values[0]); }); - it('should update model on change through expression', function(){ + it('should update model on change through expression', function() { createSelect({name:'selected', 'ng:options':'item.id as item.name for item in values'}); scope.values = [{id:10, name:'A'}, {id:20, name:'B'}]; scope.selected = scope.values[0].id; @@ -936,7 +945,7 @@ describe("widget", function(){ expect(scope.selected).toEqual(scope.values[1].id); }); - it('should update model to null on change', function(){ + it('should update model to null on change', function() { createSingleSelect(true); scope.values = [{name:'A'}, {name:'B'}]; scope.selected = scope.values[0]; @@ -949,8 +958,9 @@ describe("widget", function(){ }); }); - describe('select-many', function(){ - it('should read multiple selection', function(){ + + describe('select-many', function() { + it('should read multiple selection', function() { createMultiSelect(); scope.values = [{name:'A'}, {name:'B'}]; @@ -973,7 +983,7 @@ describe("widget", function(){ expect(select.find('option')[1].selected).toEqual(true); }); - it('should update model on change', function(){ + it('should update model on change', function() { createMultiSelect(); scope.values = [{name:'A'}, {name:'B'}]; @@ -990,8 +1000,7 @@ describe("widget", function(){ describe('@ng:repeat', function() { - - it('should ng:repeat over array', function(){ + it('should ng:repeat over array', function() { var scope = compile('
                  '); Array.prototype.extraProperty = "should be ignored"; @@ -1015,16 +1024,16 @@ describe("widget", function(){ expect(element.text()).toEqual('brad;'); }); - it('should ng:repeat over object', function(){ + it('should ng:repeat over object', function() { var scope = compile('
                  '); scope.items = {misko:'swe', shyam:'set'}; scope.$digest(); expect(element.text()).toEqual('misko:swe;shyam:set;'); }); - it('should not ng:repeat over parent properties', function(){ - var Class = function(){}; - Class.prototype.abc = function(){}; + it('should not ng:repeat over parent properties', function() { + var Class = function() {}; + Class.prototype.abc = function() {}; Class.prototype.value = 'abc'; var scope = compile('
                  '); @@ -1034,8 +1043,8 @@ describe("widget", function(){ expect(element.text()).toEqual('name:value;'); }); - it('should error on wrong parsing of ng:repeat', function(){ - expect(function(){ + it('should error on wrong parsing of ng:repeat', function() { + expect(function() { compile('
                  '); }).toThrow("Expected ng:repeat in form of '_item_ in _collection_' but got 'i dont parse'."); @@ -1076,8 +1085,11 @@ describe("widget", function(){ }); it('should expose iterator position as $position when iterating over objects', function() { - var scope = compile('
                  '); + var scope = compile( + '
                    ' + + '
                  • ' + + '
                  • ' + + '
                  '); scope.items = {'misko':'m', 'shyam':'s', 'doug':'d', 'frodo':'f'}; scope.$digest(); expect(element.text()).toEqual('misko:m:first|shyam:s:middle|doug:d:middle|frodo:f:last|'); @@ -1087,12 +1099,93 @@ describe("widget", function(){ scope.$digest(); expect(element.text()).toEqual('misko:m:first|shyam:s:last|'); }); + + + describe('stability', function() { + var a, b, c, d, scope, lis; + + beforeEach(function() { + scope = compile( + '
                    ' + + '
                  • ' + + '
                  • ' + + '
                  '); + a = {}; + b = {}; + c = {}; + d = {}; + + scope.items = [a, b, c]; + scope.$digest(); + lis = element.find('li'); + }); + + it('should preserve the order of elements', function() { + scope.items = [a, c, d]; + scope.$digest(); + var newElements = element.find('li'); + expect(newElements[0]).toEqual(lis[0]); + expect(newElements[1]).toEqual(lis[2]); + expect(newElements[2]).not.toEqual(lis[1]); + }); + + 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; + 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]); + + 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]); + }); + + 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.$digest(); + var newElements = element.find('li'); + expect(newElements.length).toEqual(2); + expect(newElements[0]).toEqual(lis[0]); + expect(newElements[1]).toEqual(lis[1]); + }); + + it('should reverse items when the collection is reversed', function() { + scope.items = [a, b, c]; + scope.$digest(); + lis = element.find('li'); + + scope.items = [c, b, a]; + scope.$digest(); + var newElements = element.find('li'); + expect(newElements.length).toEqual(3); + expect(newElements[0]).toEqual(lis[2]); + expect(newElements[1]).toEqual(lis[1]); + expect(newElements[2]).toEqual(lis[0]); + }); + }); }); describe('@ng:non-bindable', function() { - - it('should prevent compilation of the owning element and its children', function(){ + it('should prevent compilation of the owning element and its children', function() { var scope = compile('
                  '); scope.name = 'misko'; scope.$digest(); @@ -1203,7 +1296,6 @@ describe("widget", function(){ dealoc($route.current.scope); }); - it('should initialize view template after the view controller was initialized even when ' + 'templates were cached', function() { //this is a test for a regression that was introduced by making the ng:view cache sync @@ -1245,6 +1337,8 @@ describe("widget", function(){ describe('ng:pluralize', function() { + + describe('deal with pluralized strings without offset', function() { beforeEach(function() { compile('