feat(ng:repeat) collection items and DOM elements affinity / stability

This commit is contained in:
Misko Hevery 2011-08-16 23:08:13 -07:00 committed by Igor Minar
parent e134a8335f
commit 75f11f1fc4
7 changed files with 384 additions and 227 deletions

View file

@ -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.
<a name="0.10.1"><a/>
@ -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

View file

@ -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){

View file

@ -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;
});
};
});

View file

@ -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"));
});
});
});

View file

@ -194,25 +194,25 @@ describe('Binder', function(){
scope.$apply();
assertEquals('<ul>' +
'<#comment></#comment>' +
'<li ng:bind="item.a" ng:repeat-index="0">A</li>' +
'<li ng:bind="item.a" ng:repeat-index="1">B</li>' +
'<li ng:bind="item.a">A</li>' +
'<li ng:bind="item.a">B</li>' +
'</ul>', sortedHtml(form));
items.unshift({a:'C'});
scope.$apply();
assertEquals('<ul>' +
'<#comment></#comment>' +
'<li ng:bind="item.a" ng:repeat-index="0">C</li>' +
'<li ng:bind="item.a" ng:repeat-index="1">A</li>' +
'<li ng:bind="item.a" ng:repeat-index="2">B</li>' +
'<li ng:bind="item.a">C</li>' +
'<li ng:bind="item.a">A</li>' +
'<li ng:bind="item.a">B</li>' +
'</ul>', sortedHtml(form));
items.shift();
scope.$apply();
assertEquals('<ul>' +
'<#comment></#comment>' +
'<li ng:bind="item.a" ng:repeat-index="0">A</li>' +
'<li ng:bind="item.a" ng:repeat-index="1">B</li>' +
'<li ng:bind="item.a">A</li>' +
'<li ng:bind="item.a">B</li>' +
'</ul>', sortedHtml(form));
items.shift();
@ -226,7 +226,7 @@ describe('Binder', function(){
scope.$apply();
assertEquals('<ul>' +
'<#comment></#comment>' +
'<li ng:repeat-index="0"><span ng:bind="item.a">A</span></li>' +
'<li><span ng:bind="item.a">A</span></li>' +
'</ul>', sortedHtml(scope.$element));
});
@ -329,15 +329,15 @@ describe('Binder', function(){
assertEquals('<div>'+
'<#comment></#comment>'+
'<div name="a" ng:bind-attr="{"name":"{{m.name}}"}" ng:repeat-index="0">'+
'<div name="a" ng:bind-attr="{"name":"{{m.name}}"}">'+
'<#comment></#comment>'+
'<ul name="a1" ng:bind-attr="{"name":"{{i}}"}" ng:repeat-index="0"></ul>'+
'<ul name="a2" ng:bind-attr="{"name":"{{i}}"}" ng:repeat-index="1"></ul>'+
'<ul name="a1" ng:bind-attr="{"name":"{{i}}"}"></ul>'+
'<ul name="a2" ng:bind-attr="{"name":"{{i}}"}"></ul>'+
'</div>'+
'<div name="b" ng:bind-attr="{"name":"{{m.name}}"}" ng:repeat-index="1">'+
'<div name="b" ng:bind-attr="{"name":"{{m.name}}"}">'+
'<#comment></#comment>'+
'<ul name="b1" ng:bind-attr="{"name":"{{i}}"}" ng:repeat-index="0"></ul>'+
'<ul name="b2" ng:bind-attr="{"name":"{{i}}"}" ng:repeat-index="1"></ul>'+
'<ul name="b1" ng:bind-attr="{"name":"{{i}}"}"></ul>'+
'<ul name="b2" ng:bind-attr="{"name":"{{i}}"}"></ul>'+
'</div></div>', sortedHtml(scope.$element));
});
@ -417,8 +417,8 @@ describe('Binder', function(){
expect(d2.hasClass('e')).toBeTruthy();
assertEquals(
'<div><#comment></#comment>' +
'<div class="o" ng:class-even="\'e\'" ng:class-odd="\'o\'" ng:repeat-index="0"></div>' +
'<div class="e" ng:class-even="\'e\'" ng:class-odd="\'o\'" ng:repeat-index="1"></div></div>',
'<div class="o" ng:class-even="\'e\'" ng:class-odd="\'o\'"></div>' +
'<div class="e" ng:class-even="\'e\'" ng:class-odd="\'o\'"></div></div>',
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('<ul>' +
'<#comment></#comment>' +
'<li ng:bind=\"k + v\" ng:repeat-index="0">a0</li>' +
'<li ng:bind=\"k + v\" ng:repeat-index="1">b1</li>' +
'<li ng:bind=\"k + v\">a0</li>' +
'<li ng:bind=\"k + v\">b1</li>' +
'</ul>',
sortedHtml(scope.$element));
});

View file

@ -378,9 +378,9 @@ describe("angular.scenario.dsl", function() {
beforeEach(function() {
doc.append(
'<ul>' +
' <li ng:repeat-index="0"><span ng:bind="name" class="ng-binding">misko</span>' +
' <li><span ng:bind="name" class="ng-binding">misko</span>' +
' <span ng:bind="test && gender" class="ng-binding">male</span></li>' +
' <li ng:repeat-index="1"><span ng:bind="name" class="ng-binding">felisa</span>' +
' <li><span ng:bind="name" class="ng-binding">felisa</span>' +
' <span ng:bind="gender | uppercase" class="ng-binding">female</span></li>' +
'</ul>'
);

View file

@ -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('<input type="Text" name="name" value="Misko" ng:change="count = count + 1" ng:init="count=0"/>');
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('<input type="Text" name="name" value="Misko" ng:change="count = count + 1" ng:init="count=0"/>');
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('<div ng:init="obj={abc:{}}">'+
'<input type="Text" name="obj[\'abc\'].name" value="Misko""/>'+
'</div>');
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('<input type="Text" name="list" value="a,b,c" ng:format="list"/>');
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('<input type="text" name="age" ng:format="number" ng:init="age=null"/>');
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('<input type="text" name="age" ng:format="number"/>');
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('<input type="text" name="age" ng:format="number" value="123X"/>');
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('<input type="text" name="list" ng:format="list" value="a"/>');
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('<input type="text" name="age" ng:format="number"/>');
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('<input type="checkbox" name="name" ng:init="name=false"/>');
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('<input type="checkBox" name="checkbox" checked ng:change="action = true"/>');
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('<input type="text" name="price" value="abc" ng:validate="number"/>',
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('<input type="text" name="url" ng:validate="myValidator"/>');
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('<input type="text" name="price" ng:required disabled/>');
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('<input type="text" name="price" ng:required readonly/>');
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('<input type="text" name="price" ng:required/>', 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('<div>' +
'<input type="radio" name="chose" value="A" ng:change="clicked = 1"/>' +
'<input type="radio" name="chose" value="B" checked ng:change="clicked = 2"/>' +
@ -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('<div ng:init="choose=\'C\'">' +
'<input type="radio" name="choose" value="A""/>' +
'<input type="radio" name="choose" value="B" checked/>' +
@ -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('<div ng:init="choose=\'A\'">' +
'<input type="radio" name="choose" value="A""/>' +
'<input type="radio" name="choose" value="B" checked/>' +
@ -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(
'<select name="selection">' +
'<option>A</option>' +
@ -372,11 +375,11 @@ describe("widget", function(){
expect(scope.$element.text()).toBe('foobarC');
});
});
describe('select-multiple', function(){
it('should support type="select-multiple"', function(){
describe('select-multiple', function() {
it('should support type="select-multiple"', function() {
compile('<select name="selection" multiple>' +
'<option>A</option>' +
'<option selected>B</option>' +
@ -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('<input type="text"/>');
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('<input type="checkbox"/>');
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('<input type="text" name="throw \'\'" value="x"/>');
}).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('<ng:switch on="select">' +
'<div ng:switch-when="1">first:{{name}}</div>' +
'<div ng:switch-when="2">second:{{name}}</div>' +
@ -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('<ng:switch on="select">' +
'<div ng:switch-when="1">one</div>' +
'<div ng:switch-default>other</div>' +
@ -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('<ng:switch on="url" change="name=\'works\'"><div ng:switch-when="a">{{name}}</div></ng:switch>')();
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('<ng:include src="url" scope="childScope"></ng:include>');
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('<ng:include src="url" scope="this"></ng:include>');
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('<ng:include src="url"></ng:include>');
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 = '<select';
forEach(attrs, function(value, key){
forEach(attrs, function(value, key) {
if (isBoolean(value)) {
if (value) html += ' ' + key;
} else {
@ -591,14 +597,14 @@ describe("widget", function(){
scope = compile(select);
}
function createSingleSelect(blank, unknown){
function createSingleSelect(blank, unknown) {
createSelect({
'name':'selected',
'ng:options':'value.name for value in values'
}, blank, unknown);
}
function createMultiSelect(blank, unknown){
function createMultiSelect(blank, unknown) {
createSelect({
'name':'selected',
'multiple':true,
@ -606,19 +612,19 @@ describe("widget", function(){
}, blank, unknown);
}
afterEach(function(){
afterEach(function() {
dealoc(select);
dealoc(scope);
});
it('should throw when not formated "? for ? in ?"', function(){
expect(function(){
it('should throw when not formated "? for ? in ?"', function() {
expect(function() {
compile('<select name="selected" ng:options="i dont parse"></select>');
}).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('<option value="2">C</option>');
});
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('<option value="1">B</option>');
});
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('<option value="2">D</option>');
});
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('<ul><li ng:repeat="item in items" ng:init="suffix = \';\'" ng:bind="item + suffix"></li></ul>');
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('<ul><li ng:repeat="(key, value) in items" ng:bind="key + \':\' + value + \';\' "></li></ul>');
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('<ul><li ng:repeat="(key, value) in items" ng:bind="key + \':\' + value + \';\' "></li></ul>');
@ -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('<ul><li ng:repeat="i dont parse"></li></ul>');
}).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('<ul><li ng:repeat="(key, val) in items" ' +
'ng:bind="key + \':\' + val + \':\' + $position + \'|\'"></li></ul>');
var scope = compile(
'<ul>' +
'<li ng:repeat="(key, val) in items" ng:bind="key + \':\' + val + \':\' + $position + \'|\'">' +
'</li>' +
'</ul>');
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(
'<ul>' +
'<li ng:repeat="item in items" ng:bind="key + \':\' + val + \':\' + $position + \'|\'">' +
'</li>' +
'</ul>');
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('<div ng:non-bindable><span ng:bind="name"></span></div>');
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('<ng:pluralize count="email"' +
@ -1366,7 +1460,6 @@ describe("widget", function(){
expect(element.text()).toBe('Igor, Misko and 2 other people are viewing.');
});
});
});
});