diff --git a/src/.jshintrc b/src/.jshintrc index fc37b31ec226..1227ec50b7c7 100644 --- a/src/.jshintrc +++ b/src/.jshintrc @@ -34,7 +34,6 @@ "nodeName_": false, "uid": false, - "REGEX_STRING_REGEXP" : false, "lowercase": false, "uppercase": false, "manualLowercase": false, diff --git a/src/Angular.js b/src/Angular.js index 725164808d9a..a81fe1c9aca7 100644 --- a/src/Angular.js +++ b/src/Angular.js @@ -13,7 +13,6 @@ -angularModule, -nodeName_, -uid, - -REGEX_STRING_REGEXP, -lowercase, -uppercase, @@ -103,8 +102,6 @@ *
*/ -var REGEX_STRING_REGEXP = /^\/(.+)\/([a-z]*)$/; - /** * @ngdoc function * @name angular.lowercase diff --git a/src/AngularPublic.js b/src/AngularPublic.js index e97723ef946d..4f2474fba9f7 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -43,8 +43,14 @@ ngModelDirective, ngListDirective, ngChangeDirective, + patternDirective, + patternDirective, requiredDirective, requiredDirective, + minlengthDirective, + minlengthDirective, + maxlengthDirective, + maxlengthDirective, ngValueDirective, ngModelOptionsDirective, ngAttributeAliasDirectives, @@ -182,8 +188,14 @@ function publishExternalAPI(angular){ ngModel: ngModelDirective, ngList: ngListDirective, ngChange: ngChangeDirective, + pattern: patternDirective, + ngPattern: patternDirective, required: requiredDirective, ngRequired: requiredDirective, + minlength: minlengthDirective, + ngMinlength: minlengthDirective, + maxlength: maxlengthDirective, + ngMaxlength: maxlengthDirective, ngValue: ngValueDirective, ngModelOptions: ngModelOptionsDirective }). diff --git a/src/ng/directive/attrs.js b/src/ng/directive/attrs.js index 9fe40d34b6ff..0ea1e200c81c 100644 --- a/src/ng/directive/attrs.js +++ b/src/ng/directive/attrs.js @@ -370,7 +370,7 @@ forEach(ALIASED_ATTR, function(htmlAttr, ngAttr) { //special case ngPattern when a literal regular expression value //is used as the expression (this way we don't have to watch anything). if (ngAttr === "ngPattern" && attr.ngPattern.charAt(0) == "/") { - var match = attr.ngPattern.match(REGEX_STRING_REGEXP); + var match = attr.ngPattern.match(/^\/(.+)\/([a-z]*)$/); if (match) { attr.$set("ngPattern", new RegExp(match[1], match[2])); return; diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index 554e930e5dbf..39c0b7733aea 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -973,56 +973,6 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { ctrl.$render = function() { element.val(ctrl.$isEmpty(ctrl.$viewValue) ? '' : ctrl.$viewValue); }; - - // pattern validator - if (attr.ngPattern) { - var regexp, patternExp = attr.ngPattern; - attr.$observe('pattern', function(regex) { - if(isString(regex)) { - var match = regex.match(REGEX_STRING_REGEXP); - if(match) { - regex = new RegExp(match[1], match[2]); - } - } - - if (regex && !regex.test) { - throw minErr('ngPattern')('noregexp', - 'Expected {0} to be a RegExp but was {1}. Element: {2}', patternExp, - regex, startingTag(element)); - } - - regexp = regex || undefined; - }); - - var patternValidator = function(value) { - return validate(ctrl, 'pattern', ctrl.$isEmpty(value) || isUndefined(regexp) || regexp.test(value), value); - }; - - ctrl.$formatters.push(patternValidator); - ctrl.$parsers.push(patternValidator); - } - - // min length validator - if (attr.ngMinlength) { - var minlength = int(attr.ngMinlength); - var minLengthValidator = function(value) { - return validate(ctrl, 'minlength', ctrl.$isEmpty(value) || value.length >= minlength, value); - }; - - ctrl.$parsers.push(minLengthValidator); - ctrl.$formatters.push(minLengthValidator); - } - - // max length validator - if (attr.ngMaxlength) { - var maxlength = int(attr.ngMaxlength); - var maxLengthValidator = function(value) { - return validate(ctrl, 'maxlength', ctrl.$isEmpty(value) || value.length <= maxlength, value); - }; - - ctrl.$parsers.push(maxLengthValidator); - ctrl.$formatters.push(maxLengthValidator); - } } function weekParser(isoWeek) { @@ -1435,6 +1385,12 @@ var VALID_CLASS = 'ng-valid', * ngModel.$formatters.push(formatter); * ``` * + * @property {Object.} $validators A collection of validators that are applied + * whenever the model value changes. The key value within the object refers to the name of the + * validator while the function refers to the validation operation. The validation operation is + * provided with the model value as an argument and must return a true or false value depending + * on the response of that validation. + * * @property {Array.} $viewChangeListeners Array of functions to execute whenever the * view value has changed. It is called with no arguments, and its return value is ignored. * This can be used in place of additional $watches against the model value. @@ -1551,6 +1507,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ function($scope, $exceptionHandler, $attr, $element, $parse, $animate, $timeout) { this.$viewValue = Number.NaN; this.$modelValue = Number.NaN; + this.$validators = {}; this.$parsers = []; this.$formatters = []; this.$viewChangeListeners = []; @@ -1626,7 +1583,8 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ * Change the validity state, and notifies the form when the control changes validity. (i.e. it * does not notify form if given validator is already marked as invalid). * - * This method should be called by validators - i.e. the parser or formatter functions. + * This method can be called within $parsers/$formatters. However, if possible, please use the + * `ngModel.$validators` pipeline which is designed to handle validations with true/false values. * * @param {string} validationErrorKey Name of the validator. the `validationErrorKey` will assign * to `$error[validationErrorKey]=isValid` so that it is available for data-binding. @@ -1743,6 +1701,23 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ ctrl.$render(); }; + /** + * @ngdoc method + * @name ngModel.NgModelController#$validate + * + * @description + * Runs each of the registered validations set on the $validators object. + */ + this.$validate = function() { + this.$$runValidators(ctrl.$modelValue, ctrl.$viewValue); + }; + + this.$$runValidators = function(modelValue, viewValue) { + forEach(ctrl.$validators, function(fn, name) { + ctrl.$setValidity(name, fn(modelValue, viewValue)); + }); + }; + /** * @ngdoc method * @name ngModel.NgModelController#$commitViewValue @@ -1755,12 +1730,13 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ * usually handles calling this in response to input events. */ this.$commitViewValue = function() { - var value = ctrl.$viewValue; + var viewValue = ctrl.$viewValue; + $timeout.cancel(pendingDebounce); - if (ctrl.$$lastCommittedViewValue === value) { + if (ctrl.$$lastCommittedViewValue === viewValue) { return; } - ctrl.$$lastCommittedViewValue = value; + ctrl.$$lastCommittedViewValue = viewValue; // change to dirty if (ctrl.$pristine) { @@ -1771,13 +1747,19 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ parentForm.$setDirty(); } + var modelValue = viewValue; forEach(ctrl.$parsers, function(fn) { - value = fn(value); + modelValue = fn(modelValue); }); - if (ctrl.$modelValue !== value) { - ctrl.$modelValue = value; - ngModelSet($scope, value); + if (ctrl.$modelValue !== modelValue && + (isUndefined(ctrl.$$invalidModelValue) || ctrl.$$invalidModelValue != modelValue)) { + + ctrl.$$runValidators(modelValue, viewValue); + ctrl.$modelValue = ctrl.$valid ? modelValue : undefined; + ctrl.$$invalidModelValue = ctrl.$valid ? undefined : modelValue; + + ngModelSet($scope, ctrl.$modelValue); forEach(ctrl.$viewChangeListeners, function(listener) { try { listener(); @@ -1851,26 +1833,31 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ // model -> value $scope.$watch(function ngModelWatch() { - var value = ngModelGet($scope); + var modelValue = ngModelGet($scope); // if scope model value and ngModel value are out of sync - if (ctrl.$modelValue !== value) { + if (ctrl.$modelValue !== modelValue && + (isUndefined(ctrl.$$invalidModelValue) || ctrl.$$invalidModelValue != modelValue)) { var formatters = ctrl.$formatters, idx = formatters.length; - ctrl.$modelValue = value; + var viewValue = modelValue; while(idx--) { - value = formatters[idx](value); + viewValue = formatters[idx](viewValue); } - if (ctrl.$viewValue !== value) { - ctrl.$viewValue = ctrl.$$lastCommittedViewValue = value; + ctrl.$$runValidators(modelValue, viewValue); + ctrl.$modelValue = ctrl.$valid ? modelValue : undefined; + ctrl.$$invalidModelValue = ctrl.$valid ? undefined : modelValue; + + if (ctrl.$viewValue !== viewValue) { + ctrl.$viewValue = ctrl.$$lastCommittedViewValue = viewValue; ctrl.$render(); } } - return value; + return modelValue; }); }]; @@ -2094,22 +2081,80 @@ var requiredDirective = function() { if (!ctrl) return; attr.required = true; // force truthy in case we are on non input element - var validator = function(value) { - if (attr.required && ctrl.$isEmpty(value)) { - ctrl.$setValidity('required', false); - return; - } else { - ctrl.$setValidity('required', true); - return value; + ctrl.$validators.required = function(modelValue, viewValue) { + return !attr.required || !ctrl.$isEmpty(viewValue); + }; + + attr.$observe('required', function() { + ctrl.$validate(); + }); + } + }; +}; + + +var patternDirective = function() { + return { + require: '?ngModel', + link: function(scope, elm, attr, ctrl) { + if (!ctrl) return; + + var regexp, patternExp = attr.ngPattern || attr.pattern; + attr.$observe('pattern', function(regex) { + if(isString(regex) && regex.length > 0) { + regex = new RegExp(regex); + } + + if (regex && !regex.test) { + throw minErr('ngPattern')('noregexp', + 'Expected {0} to be a RegExp but was {1}. Element: {2}', patternExp, + regex, startingTag(elm)); } + + regexp = regex || undefined; + ctrl.$validate(); + }); + + ctrl.$validators.pattern = function(value) { + return ctrl.$isEmpty(value) || isUndefined(regexp) || regexp.test(value); }; + } + }; +}; - ctrl.$formatters.push(validator); - ctrl.$parsers.unshift(validator); - attr.$observe('required', function() { - validator(ctrl.$viewValue); +var maxlengthDirective = function() { + return { + require: '?ngModel', + link: function(scope, elm, attr, ctrl) { + if (!ctrl) return; + + var maxlength = 0; + attr.$observe('maxlength', function(value) { + maxlength = int(value) || 0; + ctrl.$validate(); + }); + ctrl.$validators.maxlength = function(value) { + return ctrl.$isEmpty(value) || value.length <= maxlength; + }; + } + }; +}; + +var minlengthDirective = function() { + return { + require: '?ngModel', + link: function(scope, elm, attr, ctrl) { + if (!ctrl) return; + + var minlength = 0; + attr.$observe('minlength', function(value) { + minlength = int(value) || 0; + ctrl.$validate(); }); + ctrl.$validators.minlength = function(value) { + return ctrl.$isEmpty(value) || value.length >= minlength; + }; } }; }; diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index 4c2f62274485..8115566f125f 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -261,6 +261,155 @@ describe('NgModelController', function() { expect(ctrl.$render).toHaveBeenCalledOnce(); }); }); + + describe('$validators', function() { + + it('should perform validations when $validate() is called', function() { + ctrl.$validators.uppercase = function(value) { + return (/^[A-Z]+$/).test(value); + }; + + ctrl.$modelValue = 'test'; + ctrl.$validate(); + + expect(ctrl.$valid).toBe(false); + + ctrl.$modelValue = 'TEST'; + ctrl.$validate(); + + expect(ctrl.$valid).toBe(true); + }); + + it('should perform validations when $validate() is called', function() { + ctrl.$validators.uppercase = function(value) { + return (/^[A-Z]+$/).test(value); + }; + + ctrl.$modelValue = 'test'; + ctrl.$validate(); + + expect(ctrl.$valid).toBe(false); + + ctrl.$modelValue = 'TEST'; + ctrl.$validate(); + + expect(ctrl.$valid).toBe(true); + }); + + it('should always perform validations using the parsed model value', function() { + var captures; + ctrl.$validators.raw = function() { + captures = arguments; + return captures[0]; + }; + + ctrl.$parsers.push(function(value) { + return value.toUpperCase(); + }); + + ctrl.$setViewValue('my-value'); + + expect(captures).toEqual(['MY-VALUE', 'my-value']); + }); + + it('should always perform validations using the formatted view value', function() { + var captures; + ctrl.$validators.raw = function() { + captures = arguments; + return captures[0]; + }; + + ctrl.$formatters.push(function(value) { + return value + '...'; + }); + + scope.$apply(function() { + scope.value = 'matias'; + }); + + expect(captures).toEqual(['matias', 'matias...']); + }); + + it('should only perform validations if the view value is different', function() { + var count = 0; + ctrl.$validators.countMe = function() { + count++; + }; + + ctrl.$setViewValue('my-value'); + expect(count).toBe(1); + + ctrl.$setViewValue('my-value'); + expect(count).toBe(1); + + ctrl.$setViewValue('your-value'); + expect(count).toBe(2); + }); + + it('should perform validations twice each time the model value changes within a digest', function() { + var count = 0; + ctrl.$validators.number = function(value) { + count++; + return (/^\d+$/).test(value); + }; + + function val(v) { + scope.$apply(function() { + scope.value = v; + }); + } + + val(''); + expect(count).toBe(1); + + val(1); + expect(count).toBe(2); + + val(1); + expect(count).toBe(2); + + val(''); + expect(count).toBe(3); + }); + + it('should only validate to true if all validations are true', function() { + var curry = function(v) { + return function() { + return v; + }; + }; + + ctrl.$validators.a = curry(true); + ctrl.$validators.b = curry(true); + ctrl.$validators.c = curry(false); + + ctrl.$validate(); + expect(ctrl.$valid).toBe(false); + + ctrl.$validators.c = curry(true); + + ctrl.$validate(); + expect(ctrl.$valid).toBe(true); + }); + + it('should register invalid validations on the $error object', function() { + var curry = function(v) { + return function() { + return v; + }; + }; + + ctrl.$validators.unique = curry(false); + ctrl.$validators.tooLong = curry(false); + ctrl.$validators.notNumeric = curry(true); + + ctrl.$validate(); + + expect(ctrl.$error.unique).toBe(true); + expect(ctrl.$error.tooLong).toBe(true); + expect(ctrl.$error.notNumeric).not.toBe(true); + }); + }); }); describe('ngModel', function() { @@ -1137,12 +1286,59 @@ describe('input', function() { expect(inputElm).toBeInvalid(); }); + it('should perform validations when the ngPattern scope value changes', function() { + scope.regexp = /^[a-z]+$/; + compileInput(''); + + changeInputValueTo('abcdef'); + expect(inputElm).toBeValid(); + + changeInputValueTo('123'); + expect(inputElm).toBeInvalid(); + + scope.$apply(function() { + scope.regexp = /^\d+$/; + }); + + expect(inputElm).toBeValid(); + + changeInputValueTo('abcdef'); + expect(inputElm).toBeInvalid(); - it('should throw an error when scope pattern is invalid', function() { + scope.$apply(function() { + scope.regexp = ''; + }); + + expect(inputElm).toBeValid(); + }); + + it('should register "pattern" with the model validations when the pattern attribute is used', function() { + compileInput(''); + + changeInputValueTo('abcd'); + expect(inputElm).toBeInvalid(); + expect(scope.form.input.$error.pattern).toBe(true); + + changeInputValueTo('12345'); + expect(inputElm).toBeValid(); + expect(scope.form.input.$error.pattern).not.toBe(true); + }); + + it('should not throw an error when scope pattern can\'t be found', function() { expect(function() { compileInput(''); scope.$apply(function() { - scope.fooRegexp = '/...'; + scope.foo = 'bar'; + }); + }).not.toThrowMatching(/^\[ngPattern:noregexp\] Expected fooRegexp to be a RegExp but was/); + }); + + it('should throw an error when the scope pattern is not a regular expression', function() { + expect(function() { + compileInput(''); + scope.$apply(function() { + scope.fooRegexp = {}; + scope.foo = 'bar'; }); }).toThrowMatching(/^\[ngPattern:noregexp\] Expected fooRegexp to be a RegExp but was/); }); @@ -1151,14 +1347,14 @@ describe('input', function() { describe('minlength', function() { - it('should invalid shorter than given minlength', function() { + it('should invalidate values that are shorter than the given minlength', function() { compileInput(''); changeInputValueTo('aa'); - expect(scope.value).toBeUndefined(); + expect(inputElm).toBeInvalid(); changeInputValueTo('aaa'); - expect(scope.value).toBe('aaa'); + expect(inputElm).toBeValid(); }); it('should listen on ng-minlength when minlength is observed', function() { @@ -1174,19 +1370,37 @@ describe('input', function() { expect(value).toBe(5); }); + + it('should observe the standard minlength attribute and register it as a validator on the model', function() { + compileInput(''); + scope.$apply(function() { + scope.min = 10; + }); + + changeInputValueTo('12345'); + expect(inputElm).toBeInvalid(); + expect(scope.form.input.$error.minlength).toBe(true); + + scope.$apply(function() { + scope.min = 5; + }); + + expect(inputElm).toBeValid(); + expect(scope.form.input.$error.minlength).not.toBe(true); + }); }); describe('maxlength', function() { - it('should invalid shorter than given maxlength', function() { + it('should invalidate values that are longer than the given maxlength', function() { compileInput(''); changeInputValueTo('aaaaaaaa'); - expect(scope.value).toBeUndefined(); + expect(inputElm).toBeInvalid(); changeInputValueTo('aaa'); - expect(scope.value).toBe('aaa'); + expect(inputElm).toBeValid(); }); it('should listen on ng-maxlength when maxlength is observed', function() { @@ -1202,6 +1416,24 @@ describe('input', function() { expect(value).toBe(10); }); + + it('should observe the standard maxlength attribute and register it as a validator on the model', function() { + compileInput(''); + scope.$apply(function() { + scope.max = 1; + }); + + changeInputValueTo('12345'); + expect(inputElm).toBeInvalid(); + expect(scope.form.input.$error.maxlength).toBe(true); + + scope.$apply(function() { + scope.max = 6; + }); + + expect(inputElm).toBeValid(); + expect(scope.form.input.$error.maxlength).not.toBe(true); + }); }); @@ -2415,7 +2647,7 @@ describe('input', function() { compileInput(''); scope.$apply(function() { - scope.name = ''; + scope.name = null; }); expect(inputElm).toBeInvalid();