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