diff --git a/src/ng/directive/ngList.js b/src/ng/directive/ngList.js
index 0b48853eb07d..68322d3ebee2 100644
--- a/src/ng/directive/ngList.js
+++ b/src/ng/directive/ngList.js
@@ -90,40 +90,49 @@ var ngListDirective = function() {
restrict: 'A',
priority: 100,
require: 'ngModel',
- link: function(scope, element, attr, ctrl) {
- // We want to control whitespace trimming so we use this convoluted approach
- // to access the ngList attribute, which doesn't pre-trim the attribute
- var ngList = element.attr(attr.$attr.ngList) || ', ';
- var trimValues = attr.ngTrim !== 'false';
- var separator = trimValues ? trim(ngList) : ngList;
+ compile: function() {
+ return {
+ pre: function(scope, element, attr, ctrl) {
+ // Inform the model controller that the model is a collection, so it can use deepWatch
+ // to detect changes
+ ctrl.$isCollection = true;
+ },
+ post: function(scope, element, attr, ctrl) {
+ // We want to control whitespace trimming so we use this convoluted approach
+ // to access the ngList attribute, which doesn't pre-trim the attribute
+ var ngList = element.attr(attr.$attr.ngList) || ', ';
+ var trimValues = attr.ngTrim !== 'false';
+ var separator = trimValues ? trim(ngList) : ngList;
- var parse = function(viewValue) {
- // If the viewValue is invalid (say required but empty) it will be `undefined`
- if (isUndefined(viewValue)) return;
+ var parse = function(viewValue) {
+ // If the viewValue is invalid (say required but empty) it will be `undefined`
+ if (isUndefined(viewValue)) return;
- var list = [];
+ var list = [];
- if (viewValue) {
- forEach(viewValue.split(separator), function(value) {
- if (value) list.push(trimValues ? trim(value) : value);
- });
- }
+ if (viewValue) {
+ forEach(viewValue.split(separator), function(value) {
+ if (value) list.push(trimValues ? trim(value) : value);
+ });
+ }
- return list;
- };
+ return list;
+ };
- ctrl.$parsers.push(parse);
- ctrl.$formatters.push(function(value) {
- if (isArray(value)) {
- return value.join(ngList);
- }
+ ctrl.$parsers.push(parse);
+ ctrl.$formatters.push(function(value) {
+ if (isArray(value)) {
+ return value.join(ngList);
+ }
- return undefined;
- });
+ return undefined;
+ });
- // Override the standard $isEmpty because an empty array means the input is empty.
- ctrl.$isEmpty = function(value) {
- return !value || !value.length;
+ // Override the standard $isEmpty because an empty array means the input is empty.
+ ctrl.$isEmpty = function(value) {
+ return !value || !value.length;
+ };
+ }
};
}
};
diff --git a/src/ng/directive/ngModel.js b/src/ng/directive/ngModel.js
index aa3a6f532141..14708915e305 100644
--- a/src/ng/directive/ngModel.js
+++ b/src/ng/directive/ngModel.js
@@ -18,6 +18,18 @@ var VALID_CLASS = 'ng-valid',
var ngModelMinErr = minErr('ngModel');
+function NgModelTransformer(name, transformFn, expectsItem) {
+ this.name = name;
+ this.transformFn = transformFn;
+ this.expectsItem = expectsItem;
+}
+
+function NgModelValidator(name, validateFn, expectsItem) {
+ this.name = name;
+ this.validateFn = validateFn;
+ this.expectsItem = expectsItem;
+}
+
/**
* @ngdoc type
* @name ngModel.NgModelController
@@ -221,6 +233,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
this.$viewValue = Number.NaN;
this.$modelValue = Number.NaN;
this.$$rawModelValue = undefined; // stores the parsed modelValue / model set from scope regardless of validity.
+ this.$$viewValueCollection = undefined; // stores the viewValue as a collection if $isCollection is true
this.$validators = {};
this.$asyncValidators = {};
this.$parsers = [];
@@ -237,6 +250,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
this.$pending = undefined; // keep pending keys here
this.$name = $interpolate($attr.name || '', false)($scope);
this.$$parentForm = nullFormCtrl;
+ this.$isCollection = false;
var parsedNgModel = $parse($attr.ngModel),
parsedNgModelAssign = parsedNgModel.assign,
@@ -576,8 +590,9 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
function processSyncValidators() {
var syncValidatorsValid = true;
+
forEach(ctrl.$validators, function(validator, name) {
- var result = validator(modelValue, viewValue);
+ var result = processValidator(validator, modelValue, viewValue);
syncValidatorsValid = syncValidatorsValid && result;
setValidity(name, result);
});
@@ -594,7 +609,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
var validatorPromises = [];
var allValid = true;
forEach(ctrl.$asyncValidators, function(validator, name) {
- var promise = validator(modelValue, viewValue);
+ var promise = processValidator(validator, modelValue, viewValue);
if (!isPromiseLike(promise)) {
throw ngModelMinErr("$asyncValidators",
"Expected asynchronous validator to return a promise but got '{0}' instead.", promise);
@@ -653,6 +668,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
return;
}
ctrl.$$lastCommittedViewValue = viewValue;
+ ctrl.$$viewValueCollection = undefined;
// change to dirty
if (ctrl.$pristine) {
@@ -661,6 +677,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
this.$$parseAndValidate();
};
+
this.$$parseAndValidate = function() {
var viewValue = ctrl.$$lastCommittedViewValue;
var modelValue = viewValue;
@@ -668,13 +685,28 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
if (parserValid) {
for (var i = 0; i < ctrl.$parsers.length; i++) {
- modelValue = ctrl.$parsers[i](modelValue);
+ var parser = ctrl.$parsers[i];
+ var modelValueWasArray = isArray(modelValue);
+ modelValue = processTransform(modelValue, parser);
+ if (ctrl.$isCollection && !modelValueWasArray && isArray(modelValue)) {
+ // Once the first parser creates a collection from the viewValue,
+ // store this as the viewValue collection
+ ctrl.$$viewValueCollection = viewValue;
+ }
+
if (isUndefined(modelValue)) {
parserValid = false;
break;
}
}
}
+
+ if (ctrl.$isCollection && !ctrl.$$viewValueCollection) {
+ // If no parser has set the viewValueCollection,
+ // assume that the viewValue is already a collection
+ ctrl.$$viewValueCollection = viewValue;
+ }
+
if (isNumber(ctrl.$modelValue) && isNaN(ctrl.$modelValue)) {
// ctrl.$modelValue has not been touched yet...
ctrl.$modelValue = ngModelGet($scope);
@@ -709,7 +741,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
};
this.$$writeModelToScope = function() {
- ngModelSet($scope, ctrl.$modelValue);
+ ngModelSet($scope, modelValueGetter(ctrl.$modelValue));
forEach(ctrl.$viewChangeListeners, function(listener) {
try {
listener();
@@ -806,43 +838,129 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
}
};
- // model -> value
- // Note: we cannot use a normal scope.$watch as we want to detect the following:
- // 1. scope value is 'a'
- // 2. user enters 'b'
- // 3. ng-change kicks in and reverts scope value to 'a'
- // -> scope value did not change since the last digest as
- // ng-change executes in apply phase
- // 4. view should be changed back to 'a'
- $scope.$watch(function ngModelWatch() {
+ this.$$setupModelWatch = function() {
+ // model -> value
+ // Note: we cannot use a normal scope.$watch as we want to detect the following:
+ // 1. scope value is 'a'
+ // 2. user enters 'b'
+ // 3. ng-change kicks in and reverts scope value to 'a'
+ // -> scope value did not change since the last digest as
+ // ng-change executes in apply phase
+ // 4. view should be changed back to 'a'
+
+ // options.deepWatch
+ // options.collection
+
+ setModelValueHelpers();
+ $scope.$watch(ngModelWatch);
+ };
+
+ var modelValueGetter = function modelValueGetter(modelValue) {
+ return modelValue;
+ };
+
+ var modelValueChanged = function modelValueChanged(newModelValue, currentModelValue) {
+ return newModelValue !== currentModelValue;
+ };
+
+ /**
+ * If ngModelOptions deepWatch is true, then the model must be copied after every view / scope
+ * change, so we can correctly detect changes to it with .equals(). Otherwise, the ctrl.$modelValue
+ * and the scope value are the same, and we cannot detect differences to them properly
+ */
+ function setModelValueHelpers() {
+ if (ctrl.$options && ctrl.$options.deepWatch || ctrl.$isCollection) {
+ modelValueGetter = function modelValueGetter(modelValue) {
+ return copy(modelValue);
+ };
+
+ modelValueChanged = function modelValueChanged(newModelValue, currentModelValue) {
+ return !equals(newModelValue, currentModelValue);
+ };
+ }
+ }
+
+ function ngModelWatch() {
var modelValue = ngModelGet($scope);
// if scope model value and ngModel value are out of sync
- // TODO(perf): why not move this to the action fn?
- if (modelValue !== ctrl.$modelValue &&
+ if (modelValueChanged(modelValue, ctrl.$modelValue) &&
// checks for NaN is needed to allow setting the model to NaN when there's an asyncValidator
(ctrl.$modelValue === ctrl.$modelValue || modelValue === modelValue)
) {
- ctrl.$modelValue = ctrl.$$rawModelValue = modelValue;
- parserValid = undefined;
+ modelToViewAction(modelValue);
+ }
+ return modelValue;
+ }
- var formatters = ctrl.$formatters,
- idx = formatters.length;
+ function modelToViewAction(modelValue) {
+ ctrl.$modelValue = ctrl.$$rawModelValue = modelValueGetter(modelValue);
+ ctrl.$$viewValueCollection = undefined;
+ parserValid = undefined;
- var viewValue = modelValue;
- while (idx--) {
- viewValue = formatters[idx](viewValue);
- }
- if (ctrl.$viewValue !== viewValue) {
- ctrl.$viewValue = ctrl.$$lastCommittedViewValue = viewValue;
- ctrl.$render();
+ var formatters = ctrl.$formatters,
+ idx = formatters.length;
- ctrl.$$runValidators(modelValue, viewValue, noop);
+
+ var viewValue = modelValueGetter(modelValue);
+ while (idx--) {
+ var formatter = formatters[idx];
+ viewValue = processTransform(viewValue, formatter);
+ if (ctrl.$isCollection && isArray(viewValue)) {
+ ctrl.$$viewValueCollection = viewValue;
}
}
+ if (ctrl.$viewValue !== viewValue) {
+ ctrl.$viewValue = ctrl.$$lastCommittedViewValue = viewValue;
+ if (ctrl.$isCollection && !ctrl.$$viewValueCollection) {
+ //No formatter returned a collection or no formatters ran
+ ctrl.$$viewValueCollection = modelValue;
+ }
- return modelValue;
- });
+ ctrl.$render();
+ ctrl.$$runValidators(modelValue, viewValue, noop);
+ }
+ }
+
+ function processTransform(value, transform) {
+ var result;
+ var transformFn = transform;
+ var expectsItem = false;
+
+ if (isObject(transform)) {
+ transformFn = transform.transformFn;
+ expectsItem = transform.expectsItem;
+ }
+
+ if (ctrl.$isCollection && isArray(value) && expectsItem) {
+ result = value.map(transformFn);
+ } else {
+ result = transformFn(value);
+ }
+
+ return result;
+ }
+
+ function processValidator(validator, modelValue, viewValue) {
+ var result;
+ var validateFn = validator;
+ var expectsItem = false;
+
+ if (isObject(validator)) {
+ validateFn = validator.validateFn;
+ expectsItem = validator.expectsItem;
+ }
+
+ if (ctrl.$isCollection && isArray(modelValue) && expectsItem) {
+ result = modelValue.reduce(function (result, value, index) {
+ return result && validateFn(modelValue[index], ctrl.$$viewValueCollection[index]);
+ }, true);
+ } else {
+ result = validateFn(modelValue, viewValue);
+ }
+
+ return result;
+ }
}];
@@ -1032,6 +1150,7 @@ var ngModelDirective = ['$rootScope', function($rootScope) {
formCtrl = ctrls[1] || modelCtrl.$$parentForm;
modelCtrl.$$setOptions(ctrls[2] && ctrls[2].$options);
+ modelCtrl.$$setupModelWatch();
// notify others, especially parent forms
formCtrl.$addControl(modelCtrl);
diff --git a/src/ng/directive/validators.js b/src/ng/directive/validators.js
index 474402b107cc..424b8596ad0c 100644
--- a/src/ng/directive/validators.js
+++ b/src/ng/directive/validators.js
@@ -1,5 +1,7 @@
'use strict';
+/* global NgModelValidator: true */
+
var requiredDirective = function() {
return {
restrict: 'A',
@@ -65,6 +67,7 @@ var maxlengthDirective = function() {
maxlength = isNaN(intVal) ? -1 : intVal;
ctrl.$validate();
});
+
ctrl.$validators.maxlength = function(modelValue, viewValue) {
return (maxlength < 0) || ctrl.$isEmpty(viewValue) || (viewValue.length <= maxlength);
};
@@ -84,6 +87,7 @@ var minlengthDirective = function() {
minlength = toInt(value) || 0;
ctrl.$validate();
});
+
ctrl.$validators.minlength = function(modelValue, viewValue) {
return ctrl.$isEmpty(viewValue) || viewValue.length >= minlength;
};
diff --git a/test/ng/directive/ngChangeSpec.js b/test/ng/directive/ngChangeSpec.js
index fc4990e14425..2be942543ed2 100644
--- a/test/ng/directive/ngChangeSpec.js
+++ b/test/ng/directive/ngChangeSpec.js
@@ -58,4 +58,19 @@ describe('ngChange', function() {
helper.changeInputValueTo('a');
expect(inputElm.val()).toBe('b');
});
+
+
+ it('should set the view if the model is changed by ngChange', function() {
+ $rootScope.reset = function() {
+ $rootScope.value = 'a';
+ };
+ $rootScope.value = 'a';
+ var input = helper.compileInput('');
+ var inputController = input.controller('ngModel');
+
+ $rootScope.$digest();
+
+ helper.changeInputValueTo('b');
+ expect(input.val()).toBe('a');
+ });
});
diff --git a/test/ng/directive/ngListSpec.js b/test/ng/directive/ngListSpec.js
index dd06913ba029..1e1f392ad9c4 100644
--- a/test/ng/directive/ngListSpec.js
+++ b/test/ng/directive/ngListSpec.js
@@ -74,6 +74,18 @@ describe('ngList', function() {
expect(inputElm).toBeValid();
});
+
+ it('should update the view if a part of the collection changes', function() {
+ var inputElm = helper.compileInput('');
+ helper.changeInputValueTo('a,b');
+ expect($rootScope.list).toEqual(['a','b']);
+
+ $rootScope.list[1] = 'c';
+ $rootScope.$digest();
+
+ expect(inputElm.val()).toEqual('a, c');
+ });
+
describe('with a custom separator', function() {
it('should split on the custom separator', function() {
helper.compileInput('');
diff --git a/test/ng/directive/ngModelSpec.js b/test/ng/directive/ngModelSpec.js
index b45ea2e0c0e0..a7a2766228c3 100644
--- a/test/ng/directive/ngModelSpec.js
+++ b/test/ng/directive/ngModelSpec.js
@@ -1,6 +1,9 @@
'use strict';
-/* globals getInputCompileHelper: false */
+/* global getInputCompileHelper: false,
+ NgModelValidator: true,
+ NgModelTransformer: true
+*/
describe('ngModel', function() {
@@ -30,6 +33,7 @@ describe('ngModel', function() {
//Assign the mocked parentFormCtrl to the model controller
ctrl.$$parentForm = parentFormCtrl;
+ ctrl.$$setupModelWatch();
}));
@@ -1362,6 +1366,261 @@ describe('ngModel', function() {
});
});
+
+ describe('collections', function() {
+
+ beforeEach(function() {
+ ctrl.$isCollection = true;
+ ctrl.$$setupModelWatch();
+ });
+
+ it('should call parsers with the whole collection by default or if expectsItem is false', function() {
+ var parse = {
+ fn: function(value) {
+ return ['a', 'b', 'c'];
+ }
+ };
+
+ var parser = new NgModelTransformer(
+ 'parse',
+ function(value) {
+ return ['a', 'b', 'c', 'd'];
+ },
+ false
+ );
+
+ spyOn(parse, 'fn').andCallThrough();
+ spyOn(parser, 'transformFn').andCallThrough();
+ ctrl.$parsers.push(parse.fn);
+ ctrl.$parsers.push(parser);
+
+ ctrl.$setViewValue(['a', 'b']);
+ expect(parse.fn).toHaveBeenCalledOnceWith(['a', 'b']);
+ expect(parser.transformFn).toHaveBeenCalledOnceWith(['a', 'b', 'c']);
+ });
+
+
+ it('should call parsers for each item if expectsItem is true', function() {
+ var parser = new NgModelTransformer(
+ 'parse',
+ function(value) {
+ return value + '1';
+ },
+ true
+ );
+
+ spyOn(parser, 'transformFn').andCallThrough();
+ ctrl.$parsers.push(parser);
+
+ ctrl.$setViewValue(['a', 'b', 'c']);
+ expect(parser.transformFn.calls.length).toBe(3);
+ expect(parser.transformFn.calls[0].args[0]).toBe('a');
+ expect(parser.transformFn.calls[1].args[0]).toBe('b');
+ expect(parser.transformFn.calls[2].args[0]).toBe('c');
+ expect(scope.value).toEqual(['a1', 'b1', 'c1']);
+ });
+
+
+ it('should call formatters with the whole collection by default or if expectsItem is false', function() {
+ var format = {
+ fn: function(value) {
+ return value;
+ }
+ };
+
+ var formatter = new NgModelTransformer(
+ 'format',
+ function(value) {
+ return value;
+ },
+ false
+ );
+
+ spyOn(format, 'fn').andCallThrough();
+ spyOn(formatter, 'transformFn').andCallThrough();
+ ctrl.$formatters.push(format.fn);
+ ctrl.$formatters.push(formatter);
+
+ scope.value = ['a', 'b', 'c'];
+ scope.$digest();
+
+ expect(format.fn).toHaveBeenCalledOnceWith(['a', 'b', 'c']);
+ expect(formatter.transformFn).toHaveBeenCalledOnceWith(['a', 'b', 'c']);
+ });
+
+
+ it('should call formatters for each item if expectsItem is true', function() {
+ var formatter = new NgModelTransformer(
+ 'format',
+ function(value) {
+ return value.toUpperCase();
+ },
+ true
+ );
+
+ spyOn(formatter, 'transformFn').andCallThrough();
+ ctrl.$formatters.push(formatter);
+ scope.value = ['a', 'b', 'c'];
+ scope.$digest();
+
+ expect(formatter.transformFn.calls.length).toBe(3);
+ expect(formatter.transformFn.calls[0].args[0]).toBe('a');
+ expect(formatter.transformFn.calls[1].args[0]).toBe('b');
+ expect(formatter.transformFn.calls[2].args[0]).toBe('c');
+
+ expect(ctrl.$viewValue).toEqual(['A', 'B', 'C']);
+ });
+
+
+ it('should call validators with the whole collection by default or if expectsItem is false', function() {
+ ctrl.$validators.testLegacy = jasmine.createSpy().andReturn(true);
+
+ ctrl.$validators.test = new NgModelValidator(
+ 'test',
+ jasmine.createSpy('validator').andReturn(true),
+ false
+ );
+
+ ctrl.$setViewValue(['a', 'b']);
+ expect(ctrl.$validators.testLegacy).toHaveBeenCalledOnceWith(['a', 'b'], ['a', 'b']);
+ expect(ctrl.$validators.test.validateFn).toHaveBeenCalledOnceWith(['a', 'b'], ['a', 'b']);
+ });
+
+
+ it('should call validators for each item if expectsItem is true', function() {
+
+ ctrl.$validators.test = new NgModelValidator(
+ 'test',
+ jasmine.createSpy('validator').andReturn(true),
+ true
+ );
+
+ ctrl.$setViewValue(['a', 'b']);
+ expect(ctrl.$validators.test.validateFn.calls.length).toBe(2);
+ expect(ctrl.$validators.test.validateFn.calls[0].args).toEqual(['a', 'a']);
+ expect(ctrl.$validators.test.validateFn.calls[1].args).toEqual(['b', 'b']);
+ });
+
+
+ it('should invalidate the model if one item is invalid', function() {
+ ctrl.$validators.test = new NgModelValidator(
+ 'test',
+ function(modelValue, viewValue) {
+ return modelValue === 'a' ? true : false;
+ },
+ true
+ );
+
+ ctrl.$setViewValue(['a', 'b']);
+ expect(ctrl.$valid).toBe(false);
+ expect(ctrl.$error.test).toBe(true);
+ });
+
+
+ it('should not modify the $modelValue when a formatter modifies a collection', function() {
+ var formatter = new NgModelTransformer(
+ 'format',
+ function(value) {
+ if (value) {
+ value.pop();
+ }
+ return value;
+ },
+ false
+ );
+
+ ctrl.$formatters.push(formatter);
+ scope.value = ['a', 'b', 'c'];
+ scope.$digest();
+
+ expect(ctrl.$viewValue).toEqual(['a', 'b']);
+ expect(ctrl.$modelValue).toEqual(['a', 'b', 'c']);
+ expect(scope.value).toEqual(['a', 'b', 'c']);
+ });
+
+ describe('passing viewValue as collection item to a validator if expectsItem is true ', function() {
+
+ it('should pass the the viewValue represention of an item to the validator if expectsItem is true', function() {
+ ctrl.$validators.test = new NgModelValidator(
+ 'test',
+ jasmine.createSpy('validator').andReturn(true),
+ true
+ );
+
+ ctrl.$setViewValue(['a', 'b']);
+ expect(ctrl.$validators.test.validateFn.calls[0].args).toEqual(['a', 'a']);
+ expect(ctrl.$validators.test.validateFn.calls[1].args).toEqual(['b', 'b']);
+
+ scope.value = ['aa', 'bb'];
+ scope.$digest();
+ expect(ctrl.$validators.test.validateFn.calls[2].args).toEqual(['aa', 'aa']);
+ expect(ctrl.$validators.test.validateFn.calls[3].args).toEqual(['bb', 'bb']);
+ });
+
+
+ it('should use the result of the first parser that returns a collection', function() {
+ ctrl.$parsers.push(function() {
+ // The first parser returns a string
+ return 'xyz';
+ });
+
+ ctrl.$parsers.push(function() {
+ // The second parser returns an array -> viewValueCollection
+ return ['a', 'b'];
+ });
+
+
+ ctrl.$parsers.push(function(value) {
+ // The third parser returns another array -> modelValue
+ return ['aa', 'bb'];
+ });
+
+ ctrl.$validators.test = new NgModelValidator(
+ 'test',
+ jasmine.createSpy('validator').andReturn(true),
+ true
+ );
+
+ ctrl.$setViewValue('abc');
+ expect(ctrl.$validators.test.validateFn.calls[0].args).toEqual(['aa', 'a']);
+ expect(ctrl.$validators.test.validateFn.calls[1].args).toEqual(['bb', 'b']);
+ });
+
+
+ it('should use the result of the last formatter that returns a collection', function() {
+ ctrl.$formatters.unshift(function(value) {
+ return ['aa', 'bb'];
+ });
+
+ ctrl.$formatters.unshift(function(value) {
+ return ['a', 'b'];
+ });
+
+ ctrl.$formatters.unshift(new NgModelTransformer(
+ // The last formatter handles the whole collection and returns a string -> viewValue
+ 'formatToString',
+ function() {
+ return 'ab';
+ },
+ false
+ ));
+
+ ctrl.$validators.test = new NgModelValidator(
+ 'test',
+ jasmine.createSpy('validator').andReturn(true),
+ true
+ );
+
+ scope.value = ['aaa', 'bbb'];
+ scope.$digest();
+
+ expect(ctrl.$validators.test.validateFn.calls[0].args).toEqual(['aaa', 'a']);
+ expect(ctrl.$validators.test.validateFn.calls[1].args).toEqual(['bbb', 'b']);
+ });
+ });
+
+
+ });
});
@@ -1790,6 +2049,26 @@ describe('ngModelOptions attributes', function() {
var helper, $rootScope, $compile, $timeout, $q;
+ beforeEach(module(function($compileProvider) {
+ $compileProvider.directive('formatObject', function() {
+ return {
+ require: 'ngModel',
+ link: function(scope, element, attrs, ngModelCtrl) {
+ ngModelCtrl.$formatters.push(function(value) {
+ return value.a + '-' + value.b;
+ });
+ ngModelCtrl.$parsers.push(function(value) {
+ var split = value.split('-');
+ return {
+ a: split[0],
+ b: split[1]
+ };
+ });
+ }
+ };
+ });
+ }));
+
beforeEach(function() {
helper = getInputCompileHelper(this);
});
@@ -2402,4 +2681,27 @@ describe('ngModelOptions attributes', function() {
expect($rootScope.value).toBe('modelValue');
expect($rootScope.changed).toHaveBeenCalledOnce();
});
+
+
+ it('should watch the model with object equality if deepWatch is true', function() {
+ $rootScope.value = {
+ a: 'alpha',
+ b: 'beta',
+ };
+
+ var input = helper.compileInput('');
+
+ expect(input.val()).toBe('alpha-beta');
+
+ helper.changeInputValueTo('alpha-omega');
+ expect($rootScope.value).toEqual({
+ a: 'alpha',
+ b: 'omega'
+ });
+
+ $rootScope.value.b = 'gamma';
+ $rootScope.$digest();
+ expect(input.val()).toBe('alpha-gamma');
+ });
});
diff --git a/test/ng/directive/validatorsSpec.js b/test/ng/directive/validatorsSpec.js
index 8f603cf306a5..3277247620e0 100644
--- a/test/ng/directive/validatorsSpec.js
+++ b/test/ng/directive/validatorsSpec.js
@@ -319,6 +319,25 @@ describe('validators', function() {
expect(ctrl.$error.minlength).toBe(true);
expect(ctrlNg.$error.minlength).toBe(true);
}));
+
+ describe('collection validation', function() {
+
+ it('should validate the viewValue and not the individual values', function() {
+ var inputElm = helper.compileInput('');
+ var ctrl = inputElm.controller('ngModel');
+ spyOn(ctrl.$validators, 'minlength').andCallThrough();
+
+ helper.changeInputValueTo('2,2');
+ expect(inputElm).toBeInvalid();
+ expect(ctrl.$error.minlength).toBe(true);
+ expect(ctrl.$validators.minlength.calls[0].args).toEqual([['2','2'], '2,2']);
+
+ helper.changeInputValueTo('20000,20000');
+ expect(inputElm).toBeValid();
+ expect(ctrl.$error.minlength).toBeFalsy();
+ });
+ });
+
});
@@ -507,6 +526,24 @@ describe('validators', function() {
expect(ctrl.$error.maxlength).toBe(true);
expect(ctrlNg.$error.maxlength).toBe(true);
}));
+
+ describe('collection validation', function() {
+
+ it('should validate the viewValue and not the individual values', function() {
+ var inputElm = helper.compileInput('');
+ var ctrl = inputElm.controller('ngModel');
+ spyOn(ctrl.$validators, 'maxlength').andCallThrough();
+
+ helper.changeInputValueTo('2,2');
+ expect(inputElm).toBeValid();
+ expect(ctrl.$error.maxlength).toBeFalsy();
+ expect(ctrl.$validators.maxlength.calls[0].args).toEqual([['2','2'], '2,2']);
+
+ helper.changeInputValueTo('20000,20000');
+ expect(inputElm).toBeInvalid();
+ expect(ctrl.$error.maxlength).toBe(true);
+ });
+ });
});