diff --git a/src/ng/directive/form.js b/src/ng/directive/form.js index 43041c47e708..335ac606582f 100644 --- a/src/ng/directive/form.js +++ b/src/ng/directive/form.js @@ -5,9 +5,11 @@ var nullFormCtrl = { $addControl: noop, $removeControl: noop, $setValidity: noop, + $$setPending: noop, $setDirty: noop, $setPristine: noop, - $setSubmitted: noop + $setSubmitted: noop, + $$clearControlValidity: noop }, SUBMITTED_CLASS = 'ng-submitted'; @@ -53,8 +55,9 @@ function FormController(element, attrs, $scope, $animate) { var form = this, parentForm = element.parent().controller('form') || nullFormCtrl, invalidCount = 0, // used to easily determine if we are valid - errors = form.$error = {}, - controls = []; + pendingCount = 0, + controls = [], + errors = form.$error = {}; // init state form.$name = attrs.name || attrs.ngForm; @@ -144,13 +147,37 @@ function FormController(element, attrs, $scope, $animate) { if (control.$name && form[control.$name] === control) { delete form[control.$name]; } - forEach(errors, function(queue, validationToken) { - form.$setValidity(validationToken, true, control); - }); + form.$$clearControlValidity(control); arrayRemove(controls, control); }; + form.$$clearControlValidity = function(control) { + forEach(form.$pending, clear); + forEach(errors, clear); + + function clear(queue, validationToken) { + form.$setValidity(validationToken, true, control); + } + + parentForm.$$clearControlValidity(form); + }; + + form.$$setPending = function(validationToken, control) { + var pending = form.$pending && form.$pending[validationToken]; + + if (!pending || !includes(pending, control)) { + pendingCount++; + form.$valid = form.$invalid = undefined; + form.$pending = form.$pending || {}; + if (!pending) { + pending = form.$pending[validationToken] = []; + } + pending.push(control); + parentForm.$$setPending(validationToken, form); + } + }; + /** * @ngdoc method * @name form.FormController#$setValidity @@ -162,24 +189,56 @@ function FormController(element, attrs, $scope, $animate) { */ form.$setValidity = function(validationToken, isValid, control) { var queue = errors[validationToken]; + var pendingChange, pending = form.$pending && form.$pending[validationToken]; + + if (pending) { + pendingChange = indexOf(pending, control) >= 0; + if (pendingChange) { + arrayRemove(pending, control); + pendingCount--; + + if (pending.length === 0) { + delete form.$pending[validationToken]; + } + } + } + + var pendingNoMore = form.$pending && pendingCount === 0; + if (pendingNoMore) { + form.$pending = undefined; + } if (isValid) { - if (queue) { - arrayRemove(queue, control); - if (!queue.length) { - invalidCount--; + if (queue || pendingChange) { + if (queue) { + arrayRemove(queue, control); + } + if (!queue || !queue.length) { + if (errors[validationToken]) { + invalidCount--; + } if (!invalidCount) { - toggleValidCss(isValid); - form.$valid = true; - form.$invalid = false; + if (!form.$pending) { + toggleValidCss(isValid); + form.$valid = true; + form.$invalid = false; + } + } else if(pendingNoMore) { + toggleValidCss(false); + form.$valid = false; + form.$invalid = true; } errors[validationToken] = false; toggleValidCss(true, validationToken); parentForm.$setValidity(validationToken, true, form); } } - } else { + if (!form.$pending) { + form.$valid = false; + form.$invalid = true; + } + if (!invalidCount) { toggleValidCss(isValid); } @@ -192,9 +251,6 @@ function FormController(element, attrs, $scope, $animate) { parentForm.$setValidity(validationToken, false, form); } queue.push(control); - - form.$valid = false; - form.$invalid = true; } }; diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index 630f4c15fc7c..911ec30f0cd7 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -18,6 +18,7 @@ var MONTH_REGEXP = /^(\d{4})-(\d\d)$/; var TIME_REGEXP = /^(\d\d):(\d\d)(?::(\d\d))?$/; var DEFAULT_REGEXP = /(\s+|^)default(\s+|$)/; +var $ngModelMinErr = new minErr('ngModel'); var inputType = { /** @@ -885,13 +886,6 @@ var inputType = { 'file': noop }; -// A helper function to call $setValidity and return the value / undefined, -// a pattern that is repeated a lot in the input validation logic. -function validate(ctrl, validatorName, validity, value){ - ctrl.$setValidity(validatorName, validity); - return validity ? value : undefined; -} - function testFlags(validity, flags) { var i, flag; if (flags) { @@ -905,25 +899,6 @@ function testFlags(validity, flags) { return false; } -// Pass validity so that behaviour can be mocked easier. -function addNativeHtml5Validators(ctrl, validatorName, badFlags, ignoreFlags, validity) { - if (isObject(validity)) { - ctrl.$$hasNativeValidators = true; - var validator = function(value) { - // Don't overwrite previous validation, don't consider valueMissing to apply (ng-required can - // perform the required validation) - if (!ctrl.$error[validatorName] && - !testFlags(validity, ignoreFlags) && - testFlags(validity, badFlags)) { - ctrl.$setValidity(validatorName, false); - return; - } - return value; - }; - ctrl.$parsers.push(validator); - } -} - function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { var validity = element.prop(VALIDITY_STATE_PROPERTY); var placeholder = element[0].placeholder, noevent = {}; @@ -1074,25 +1049,20 @@ function createDateParser(regexp, mapping) { function createDateInputType(type, regexp, parseDate, format) { return function dynamicDateInputType(scope, element, attr, ctrl, $sniffer, $browser, $filter) { + badInputChecker(scope, element, attr, ctrl); textInputType(scope, element, attr, ctrl, $sniffer, $browser); var timezone = ctrl && ctrl.$options && ctrl.$options.timezone; + ctrl.$$parserName = type; ctrl.$parsers.push(function(value) { - if(ctrl.$isEmpty(value)) { - ctrl.$setValidity(type, true); - return null; - } - - if(regexp.test(value)) { - ctrl.$setValidity(type, true); + if (ctrl.$isEmpty(value)) return null; + if (regexp.test(value)) { var parsedDate = parseDate(value); if (timezone === 'UTC') { parsedDate.setMinutes(parsedDate.getMinutes() - parsedDate.getTimezoneOffset()); } return parsedDate; } - - ctrl.$setValidity(type, false); return undefined; }); @@ -1104,81 +1074,69 @@ function createDateInputType(type, regexp, parseDate, format) { }); if(attr.min) { - var minValidator = function(value) { - var valid = ctrl.$isEmpty(value) || - (parseDate(value) >= parseDate(attr.min)); - ctrl.$setValidity('min', valid); - return valid ? value : undefined; - }; - - ctrl.$parsers.push(minValidator); - ctrl.$formatters.push(minValidator); + ctrl.$validators.min = function(value) { + return ctrl.$isEmpty(value) || isUndefined(attr.min) || parseDate(value) >= parseDate(attr.min); + }; } if(attr.max) { - var maxValidator = function(value) { - var valid = ctrl.$isEmpty(value) || - (parseDate(value) <= parseDate(attr.max)); - ctrl.$setValidity('max', valid); - return valid ? value : undefined; - }; - - ctrl.$parsers.push(maxValidator); - ctrl.$formatters.push(maxValidator); + ctrl.$validators.max = function(value) { + return ctrl.$isEmpty(value) || isUndefined(attr.max) || parseDate(value) <= parseDate(attr.max); + }; } }; } -var numberBadFlags = ['badInput']; +function badInputChecker(scope, element, attr, ctrl) { + var node = element[0]; + var nativeValidation = ctrl.$$hasNativeValidators = isObject(node.validity); + if (nativeValidation) { + ctrl.$parsers.push(function(value) { + var validity = element.prop(VALIDITY_STATE_PROPERTY) || {}; + return validity.badInput || validity.typeMismatch ? undefined : value; + }); + } +} function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { + badInputChecker(scope, element, attr, ctrl); textInputType(scope, element, attr, ctrl, $sniffer, $browser); + ctrl.$$parserName = 'number'; ctrl.$parsers.push(function(value) { - var empty = ctrl.$isEmpty(value); - if (empty || NUMBER_REGEXP.test(value)) { - ctrl.$setValidity('number', true); - return value === '' ? null : (empty ? value : parseFloat(value)); - } else { - ctrl.$setValidity('number', false); - return undefined; - } + if(ctrl.$isEmpty(value)) return null; + if(NUMBER_REGEXP.test(value)) return parseFloat(value); + return undefined; }); - addNativeHtml5Validators(ctrl, 'number', numberBadFlags, null, ctrl.$$validityState); - ctrl.$formatters.push(function(value) { - return ctrl.$isEmpty(value) ? '' : '' + value; + if (!ctrl.$isEmpty(value)) { + if (!isNumber(value)) { + throw $ngModelMinErr('numfmt', 'Expected `{0}` to be a number', value); + } + value = value.toString(); + } + return value; }); if (attr.min) { - var minValidator = function(value) { - var min = parseFloat(attr.min); - return validate(ctrl, 'min', ctrl.$isEmpty(value) || value >= min, value); + ctrl.$validators.min = function(value) { + return ctrl.$isEmpty(value) || isUndefined(attr.min) || value >= parseFloat(attr.min); }; - - ctrl.$parsers.push(minValidator); - ctrl.$formatters.push(minValidator); } if (attr.max) { - var maxValidator = function(value) { - var max = parseFloat(attr.max); - return validate(ctrl, 'max', ctrl.$isEmpty(value) || value <= max, value); + ctrl.$validators.max = function(value) { + return ctrl.$isEmpty(value) || isUndefined(attr.max) || value <= parseFloat(attr.max); }; - - ctrl.$parsers.push(maxValidator); - ctrl.$formatters.push(maxValidator); } - - ctrl.$formatters.push(function(value) { - return validate(ctrl, 'number', ctrl.$isEmpty(value) || isNumber(value), value); - }); } function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) { + badInputChecker(scope, element, attr, ctrl); textInputType(scope, element, attr, ctrl, $sniffer, $browser); + ctrl.$$parserName = 'url'; ctrl.$validators.url = function(modelValue, viewValue) { var value = modelValue || viewValue; return ctrl.$isEmpty(value) || URL_REGEXP.test(value); @@ -1186,8 +1144,10 @@ function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) { } function emailInputType(scope, element, attr, ctrl, $sniffer, $browser) { + badInputChecker(scope, element, attr, ctrl); textInputType(scope, element, attr, ctrl, $sniffer, $browser); + ctrl.$$parserName = 'email'; ctrl.$validators.email = function(modelValue, viewValue) { var value = modelValue || viewValue; return ctrl.$isEmpty(value) || EMAIL_REGEXP.test(value); @@ -1223,7 +1183,7 @@ function parseConstantExpr($parse, context, name, expression, fallback) { if (isDefined(expression)) { parseFn = $parse(expression); if (!parseFn.constant) { - throw new minErr('ngModel')('constexpr', 'Expected constant expression for `{0}`, but saw ' + + throw minErr('ngModel')('constexpr', 'Expected constant expression for `{0}`, but saw ' + '`{1}`.', name, expression); } return parseFn(context); @@ -1426,7 +1386,8 @@ var VALID_CLASS = 'ng-valid', PRISTINE_CLASS = 'ng-pristine', DIRTY_CLASS = 'ng-dirty', UNTOUCHED_CLASS = 'ng-untouched', - TOUCHED_CLASS = 'ng-touched'; + TOUCHED_CLASS = 'ng-touched', + PENDING_CLASS = 'ng-pending'; /** * @ngdoc type @@ -1461,6 +1422,44 @@ var VALID_CLASS = 'ng-valid', * provided with the model value as an argument and must return a true or false value depending * on the response of that validation. * + * ```js + * ngModel.$validators.validCharacters = function(modelValue, viewValue) { + * var value = modelValue || viewValue; + * return /[0-9]+/.test(value) && + * /[a-z]+/.test(value) && + * /[A-Z]+/.test(value) && + * /\W+/.test(value); + * }; + * ``` + * + * @property {Object.} $asyncValidators A collection of validations that are expected to + * perform an asynchronous validation (e.g. a HTTP request). The validation function that is provided + * is expected to return a promise when it is run during the model validation process. Once the promise + * is delivered then the validation status will be set to true when fulfilled and false when rejected. + * When the asynchronous validators are trigged, each of the validators will run in parallel and the model + * value will only be updated once all validators have been fulfilled. Also, keep in mind that all + * asynchronous validators will only run once all synchronous validators have passed. + * + * Please note that if $http is used then it is important that the server returns a success HTTP response code + * in order to fulfill the validation and a status level of `4xx` in order to reject the validation. + * + * ```js + * ngModel.$asyncValidators.uniqueUsername = function(modelValue, viewValue) { + * var value = modelValue || viewValue; + * return $http.get('/api/users/' + value). + * then(function() { + * //username exists, this means the validator fails + * return false; + * }, function() { + * //username does not exist, therefore this validation is true + * return true; + * }); + * }; + * ``` + * + * @param {string} name The name of the validator. + * @param {Function} validationFn The validation function that will be run. + * * @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. @@ -1473,6 +1472,7 @@ var VALID_CLASS = 'ng-valid', * @property {boolean} $dirty True if user has already interacted with the control. * @property {boolean} $valid True if there is no error. * @property {boolean} $invalid True if at least one error on the control. + * @property {Object.} $pending True if one or more asynchronous validators is still yet to be delivered. * * @description * @@ -1580,6 +1580,8 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ this.$viewValue = Number.NaN; this.$modelValue = Number.NaN; this.$validators = {}; + this.$asyncValidators = {}; + this.$validators = {}; this.$parsers = []; this.$formatters = []; this.$viewChangeListeners = []; @@ -1598,7 +1600,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ ctrl = this; if (!ngModelSet) { - throw minErr('ngModel')('nonassign', "Expression '{0}' is non-assignable. Element: {1}", + throw $ngModelMinErr('nonassign', "Expression '{0}' is non-assignable. Element: {1}", $attr.ngModel, startingTag($element)); } @@ -1647,6 +1649,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ var parentForm = $element.inheritedData('$formController') || nullFormCtrl, invalidCount = 0, // used to easily determine if we are valid + pendingCount = 0, // used to easily determine if there are any pending validations $error = this.$error = {}; // keep invalid keys here @@ -1663,6 +1666,68 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ $animate.addClass($element, (isValid ? VALID_CLASS : INVALID_CLASS) + validationErrorKey); } + this.$$clearValidity = function() { + $animate.removeClass($element, PENDING_CLASS); + forEach(ctrl.$error, function(val, key) { + var validationKey = snake_case(key, '-'); + $animate.removeClass($element, VALID_CLASS + validationKey); + $animate.removeClass($element, INVALID_CLASS + validationKey); + }); + + // just incase an asnyc validator is still running while + // the parser fails + if(ctrl.$pending) { + ctrl.$$clearPending(); + } + + invalidCount = 0; + $error = ctrl.$error = {}; + + parentForm.$$clearControlValidity(ctrl); + }; + + this.$$clearPending = function() { + pendingCount = 0; + ctrl.$pending = undefined; + $animate.removeClass($element, PENDING_CLASS); + }; + + this.$$setPending = function(validationErrorKey, promise, currentValue) { + ctrl.$pending = ctrl.$pending || {}; + if (angular.isUndefined(ctrl.$pending[validationErrorKey])) { + ctrl.$pending[validationErrorKey] = true; + pendingCount++; + } + + ctrl.$valid = ctrl.$invalid = undefined; + parentForm.$$setPending(validationErrorKey, ctrl); + + $animate.addClass($element, PENDING_CLASS); + $animate.removeClass($element, INVALID_CLASS); + $animate.removeClass($element, VALID_CLASS); + + //Special-case for (undefined|null|false|NaN) values to avoid + //having to compare each of them with each other + currentValue = currentValue || ''; + promise.then(resolve(true), resolve(false)); + + function resolve(bool) { + return function() { + var value = ctrl.$viewValue || ''; + if (ctrl.$pending && ctrl.$pending[validationErrorKey] && currentValue === value) { + pendingCount--; + delete ctrl.$pending[validationErrorKey]; + ctrl.$setValidity(validationErrorKey, bool); + if (pendingCount === 0) { + ctrl.$$clearPending(); + ctrl.$$updateValidModelValue(value); + ctrl.$$writeModelToScope(); + } + } + }; + } + }; + /** * @ngdoc method * @name ngModel.NgModelController#$setValidity @@ -1682,28 +1747,30 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ * @param {boolean} isValid Whether the current state is valid (true) or invalid (false). */ this.$setValidity = function(validationErrorKey, isValid) { - // Purposeful use of ! here to cast isValid to boolean in case it is undefined + + // avoid doing anything if the validation value has not changed // jshint -W018 - if ($error[validationErrorKey] === !isValid) return; + if (!ctrl.$pending && $error[validationErrorKey] === !isValid) return; // jshint +W018 if (isValid) { if ($error[validationErrorKey]) invalidCount--; - if (!invalidCount) { + if (!invalidCount && !pendingCount) { toggleValidCss(true); ctrl.$valid = true; ctrl.$invalid = false; } - } else { - toggleValidCss(false); - ctrl.$invalid = true; - ctrl.$valid = false; + } else if(!$error[validationErrorKey]) { invalidCount++; + if (!pendingCount) { + toggleValidCss(false); + ctrl.$invalid = true; + ctrl.$valid = false; + } } $error[validationErrorKey] = !isValid; toggleValidCss(isValid, validationErrorKey); - parentForm.$setValidity(validationErrorKey, isValid, ctrl); }; @@ -1831,7 +1898,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ * @name ngModel.NgModelController#$validate * * @description - * Runs each of the registered validations set on the $validators object. + * Runs each of the registered validators (first synchronous validators and then asynchronous validators). */ this.$validate = function() { // ignore $validate before model initialized @@ -1847,9 +1914,40 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ }; this.$$runValidators = function(modelValue, viewValue) { - forEach(ctrl.$validators, function(fn, name) { - ctrl.$setValidity(name, fn(modelValue, viewValue)); + // this is called in the event if incase the input value changes + // while a former asynchronous validator is still doing its thing + if(ctrl.$pending) { + ctrl.$$clearPending(); + } + + var continueValidation = validate(ctrl.$validators, function(validator, result) { + ctrl.$setValidity(validator, result); }); + + if (continueValidation) { + validate(ctrl.$asyncValidators, function(validator, result) { + if (!isPromiseLike(result)) { + throw $ngModelMinErr("$asyncValidators", + "Expected asynchronous validator to return a promise but got '{0}' instead.", result); + } + ctrl.$$setPending(validator, result, modelValue); + }); + } + + ctrl.$$updateValidModelValue(modelValue); + + function validate(validators, callback) { + var status = true; + forEach(validators, function(fn, name) { + var result = fn(modelValue, viewValue); + callback(name, result); + status = status && result; + }); + return status; + } + }; + + this.$$updateValidModelValue = function(modelValue) { ctrl.$modelValue = ctrl.$valid ? modelValue : undefined; ctrl.$$invalidModelValue = ctrl.$valid ? undefined : modelValue; }; @@ -1883,13 +1981,24 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ parentForm.$setDirty(); } - var modelValue = viewValue; - forEach(ctrl.$parsers, function(fn) { - modelValue = fn(modelValue); - }); + var hasBadInput, modelValue = viewValue; + for(var i = 0; i < ctrl.$parsers.length; i++) { + modelValue = ctrl.$parsers[i](modelValue); + if(isUndefined(modelValue)) { + hasBadInput = true; + break; + } + } - if (ctrl.$modelValue !== modelValue && - (isUndefined(ctrl.$$invalidModelValue) || ctrl.$$invalidModelValue != modelValue)) { + var parserName = ctrl.$$parserName || 'parse'; + if (hasBadInput) { + ctrl.$$invalidModelValue = ctrl.$modelValue = undefined; + ctrl.$$clearValidity(); + ctrl.$setValidity(parserName, false); + ctrl.$$writeModelToScope(); + } else if (ctrl.$modelValue !== modelValue && + (isUndefined(ctrl.$$invalidModelValue) || ctrl.$$invalidModelValue != modelValue)) { + ctrl.$setValidity(parserName, true); ctrl.$$runValidators(modelValue, viewValue); ctrl.$$writeModelToScope(); } diff --git a/test/ng/directive/formSpec.js b/test/ng/directive/formSpec.js index 21e21f3234dd..2a9657955e74 100644 --- a/test/ng/directive/formSpec.js +++ b/test/ng/directive/formSpec.js @@ -579,6 +579,35 @@ describe('form', function() { }); }); + describe('$pending', function() { + beforeEach(function() { + doc = $compile('
')(scope); + scope.$digest(); + }); + + it('should set valid and invalid to undefined when a validation error state is set as pending', inject(function($q, $rootScope) { + var defer, form = doc.data('$formController'); + + var ctrl = {}; + form.$$setPending('matias', ctrl); + + expect(form.$valid).toBeUndefined(); + expect(form.$invalid).toBeUndefined(); + expect(form.$pending.matias).toEqual([ctrl]); + + form.$setValidity('matias', true, ctrl); + + expect(form.$valid).toBe(true); + expect(form.$invalid).toBe(false); + expect(form.$pending).toBeUndefined(); + + form.$setValidity('matias', false, ctrl); + + expect(form.$valid).toBe(false); + expect(form.$invalid).toBe(true); + expect(form.$pending).toBeUndefined(); + })); + }); describe('$setPristine', function() { diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index abd8dc11e4c2..98bc3537e754 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -8,8 +8,10 @@ describe('NgModelController', function() { var attrs = {name: 'testAlias', ngModel: 'value'}; parentFormCtrl = { + $$setPending: jasmine.createSpy('$$setPending'), $setValidity: jasmine.createSpy('$setValidity'), - $setDirty: jasmine.createSpy('$setDirty') + $setDirty: jasmine.createSpy('$setDirty'), + $$clearControlValidity: noop }; element = jqLite('
'); @@ -223,6 +225,106 @@ describe('NgModelController', function() { expect(ctrl.$dirty).toBe(true); expect(parentFormCtrl.$setDirty).not.toHaveBeenCalled(); }); + + it('should remove all other errors when any parser returns undefined', function() { + var a, b, val = function(val, x) { + return x ? val : x; + }; + + ctrl.$parsers.push(function(v) { return val(v, a); }); + ctrl.$parsers.push(function(v) { return val(v, b); }); + + ctrl.$validators.high = function(value) { + return !isDefined(value) || value > 5; + }; + + ctrl.$validators.even = function(value) { + return !isDefined(value) || value % 2 === 0; + }; + + a = b = true; + + ctrl.$setViewValue('3'); + expect(ctrl.$error).toEqual({ parse: false, high : true, even : true }); + + ctrl.$setViewValue('10'); + expect(ctrl.$error).toEqual({ parse: false, high : false, even : false }); + + a = undefined; + + ctrl.$setViewValue('12'); + expect(ctrl.$error).toEqual({ parse: true }); + + a = true; + b = undefined; + + ctrl.$setViewValue('14'); + expect(ctrl.$error).toEqual({ parse: true }); + + a = undefined; + b = undefined; + + ctrl.$setViewValue('16'); + expect(ctrl.$error).toEqual({ parse: true }); + + a = b = false; //not undefined + + ctrl.$setViewValue('2'); + expect(ctrl.$error).toEqual({ parse: false, high : true, even : false }); + }); + + it('should remove all non-parse-related CSS classes from the form when a parser fails', + inject(function($compile, $rootScope) { + + var element = $compile('
' + + '' + + '
')($rootScope); + var inputElm = element.find('input'); + var ctrl = $rootScope.myForm.myControl; + + var parserIsFailing = false; + ctrl.$parsers.push(function(value) { + return parserIsFailing ? undefined : value; + }); + + ctrl.$validators.alwaysFail = function() { + return false; + }; + + ctrl.$setViewValue('123'); + scope.$digest(); + + expect(element).not.toHaveClass('ng-valid-parse'); + expect(element).toHaveClass('ng-invalid-always-fail'); + + parserIsFailing = true; + ctrl.$setViewValue('12345'); + scope.$digest(); + + expect(element).toHaveClass('ng-invalid-parse'); + expect(element).not.toHaveClass('ng-invalid-always-fail'); + + dealoc(element); + })); + + it('should set the ng-invalid-parse and ng-valid-parse CSS class when parsers fail and pass', function() { + var pass = true; + ctrl.$parsers.push(function(v) { + return pass ? v : undefined; + }); + + var input = element.find('input'); + + ctrl.$setViewValue('1'); + expect(input).toHaveClass('ng-valid-parse'); + expect(input).not.toHaveClass('ng-invalid-parse'); + + pass = undefined; + + ctrl.$setViewValue('2'); + expect(input).not.toHaveClass('ng-valid-parse'); + expect(input).toHaveClass('ng-invalid-parse'); + }); }); @@ -276,7 +378,7 @@ describe('NgModelController', function() { }); }); - describe('$validators', function() { + describe('validations pipeline', function() { it('should perform validations when $validate() is called', function() { ctrl.$validators.uppercase = function(value) { @@ -403,6 +505,251 @@ describe('NgModelController', function() { expect(ctrl.$error.tooLong).toBe(true); expect(ctrl.$error.notNumeric).not.toBe(true); }); + + it('should render a validator asynchronously when a promise is returned', inject(function($q) { + var defer; + ctrl.$asyncValidators.promiseValidator = function(value) { + defer = $q.defer(); + return defer.promise; + }; + + scope.$apply('value = ""'); + + expect(ctrl.$valid).toBeUndefined(); + expect(ctrl.$invalid).toBeUndefined(); + expect(ctrl.$pending.promiseValidator).toBe(true); + + defer.resolve(); + scope.$digest(); + + expect(ctrl.$valid).toBe(true); + expect(ctrl.$invalid).toBe(false); + expect(ctrl.$pending).toBeUndefined(); + + scope.$apply('value = "123"'); + + defer.reject(); + scope.$digest(); + + expect(ctrl.$valid).toBe(false); + expect(ctrl.$invalid).toBe(true); + expect(ctrl.$pending).toBeUndefined(); + })); + + it('should throw an error when a promise is not returned for an asynchronous validator', inject(function($q) { + ctrl.$asyncValidators.async = function(value) { + return true; + }; + + expect(function() { + scope.$apply('value = "123"'); + }).toThrowMinErr("ngModel", "$asyncValidators", + "Expected asynchronous validator to return a promise but got 'true' instead."); + })); + + it('should only run the async validators once all the sync validators have passed', + inject(function($q) { + + var stages = {}; + + stages.sync = { status1 : false, status2: false, count : 0 }; + ctrl.$validators.syncValidator1 = function(modelValue, viewValue) { + stages.sync.count++; + return stages.sync.status1; + }; + + ctrl.$validators.syncValidator2 = function(modelValue, viewValue) { + stages.sync.count++; + return stages.sync.status2; + }; + + stages.async = { defer : null, count : 0 }; + ctrl.$asyncValidators.asyncValidator = function(modelValue, viewValue) { + stages.async.defer = $q.defer(); + stages.async.count++; + return stages.async.defer.promise; + }; + + scope.$apply('value = "123"'); + + expect(ctrl.$valid).toBe(false); + expect(ctrl.$invalid).toBe(true); + + expect(stages.sync.count).toBe(2); + expect(stages.async.count).toBe(0); + + stages.sync.status1 = true; + + scope.$apply('value = "456"'); + + expect(stages.sync.count).toBe(4); + expect(stages.async.count).toBe(0); + + stages.sync.status2 = true; + + scope.$apply('value = "789"'); + + expect(stages.sync.count).toBe(6); + expect(stages.async.count).toBe(1); + + stages.async.defer.resolve(); + scope.$apply(); + + expect(ctrl.$valid).toBe(true); + expect(ctrl.$invalid).toBe(false); + })); + + it('should ignore expired async validation promises once delivered', inject(function($q) { + var defer, oldDefer, newDefer; + ctrl.$asyncValidators.async = function(value) { + defer = $q.defer(); + return defer.promise; + }; + + scope.$apply('value = ""'); + oldDefer = defer; + scope.$apply('value = "123"'); + newDefer = defer; + + newDefer.reject(); + scope.$digest(); + oldDefer.resolve(); + scope.$digest(); + + expect(ctrl.$valid).toBe(false); + expect(ctrl.$invalid).toBe(true); + expect(ctrl.$pending).toBeUndefined(); + })); + + it('should clear and ignore all pending promises when the input values changes', inject(function($q) { + var isPending = false; + ctrl.$validators.sync = function(value) { + isPending = isObject(ctrl.$pending); + return true; + }; + + var defers = []; + ctrl.$asyncValidators.async = function(value) { + var defer = $q.defer(); + defers.push(defer); + return defer.promise; + }; + + scope.$apply('value = "123"'); + expect(isPending).toBe(false); + expect(ctrl.$valid).toBe(undefined); + expect(ctrl.$invalid).toBe(undefined); + expect(defers.length).toBe(1); + expect(isObject(ctrl.$pending)).toBe(true); + + scope.$apply('value = "456"'); + expect(isPending).toBe(false); + expect(ctrl.$valid).toBe(undefined); + expect(ctrl.$invalid).toBe(undefined); + expect(defers.length).toBe(2); + expect(isObject(ctrl.$pending)).toBe(true); + + defers[1].resolve(); + scope.$digest(); + expect(ctrl.$valid).toBe(true); + expect(ctrl.$invalid).toBe(false); + expect(isObject(ctrl.$pending)).toBe(false); + })); + + it('should clear and ignore all pending promises when a parser fails', inject(function($q) { + var failParser = false; + ctrl.$parsers.push(function(value) { + return failParser ? undefined : value; + }); + + var defer; + ctrl.$asyncValidators.async = function(value) { + defer = $q.defer(); + return defer.promise; + }; + + ctrl.$setViewValue('x..y..z'); + expect(ctrl.$valid).toBe(undefined); + expect(ctrl.$invalid).toBe(undefined); + + failParser = true; + + ctrl.$setViewValue('1..2..3'); + expect(ctrl.$valid).toBe(false); + expect(ctrl.$invalid).toBe(true); + expect(isObject(ctrl.$pending)).toBe(false); + + defer.resolve(); + scope.$digest(); + + expect(ctrl.$valid).toBe(false); + expect(ctrl.$invalid).toBe(true); + expect(isObject(ctrl.$pending)).toBe(false); + })); + + it('should re-evaluate the form validity state once the asynchronous promise has been delivered', + inject(function($compile, $rootScope, $q) { + + var element = $compile('
' + + '' + + '' + + '
')($rootScope); + var inputElm = element.find('input'); + + var formCtrl = $rootScope.myForm; + var usernameCtrl = formCtrl.username; + var ageCtrl = formCtrl.age; + + var usernameDefer; + usernameCtrl.$asyncValidators.usernameAvailability = function() { + usernameDefer = $q.defer(); + return usernameDefer.promise; + }; + + $rootScope.$digest(); + expect(usernameCtrl.$invalid).toBe(true); + expect(formCtrl.$invalid).toBe(true); + + usernameCtrl.$setViewValue('valid-username'); + $rootScope.$digest(); + + expect(formCtrl.$pending.usernameAvailability).toBeTruthy(); + expect(usernameCtrl.$invalid).toBe(undefined); + expect(formCtrl.$invalid).toBe(undefined); + + usernameDefer.resolve(); + $rootScope.$digest(); + expect(usernameCtrl.$invalid).toBe(false); + expect(formCtrl.$invalid).toBe(true); + + ageCtrl.$setViewValue(22); + $rootScope.$digest(); + + expect(usernameCtrl.$invalid).toBe(false); + expect(ageCtrl.$invalid).toBe(false); + expect(formCtrl.$invalid).toBe(false); + + usernameCtrl.$setViewValue('valid'); + $rootScope.$digest(); + + expect(usernameCtrl.$invalid).toBe(true); + expect(ageCtrl.$invalid).toBe(false); + expect(formCtrl.$invalid).toBe(true); + + usernameCtrl.$setViewValue('another-valid-username'); + $rootScope.$digest(); + + usernameDefer.resolve(); + $rootScope.$digest(); + + expect(usernameCtrl.$invalid).toBe(false); + expect(formCtrl.$invalid).toBe(false); + expect(formCtrl.$pending).toBeFalsy(); + expect(ageCtrl.$invalid).toBe(false); + + dealoc(element); + })); + }); }); @@ -1639,6 +1986,16 @@ describe('input', function() { expect(inputElm.val()).toBe('2014-07'); }); + it('should label parse errors as `month`', function() { + compileInput('', { + valid: false, + badInput: true + }); + + changeInputValueTo('xxx'); + expect(inputElm).toBeInvalid(); + expect(scope.form.alias.$error.month).toBeTruthy(); + }); describe('min', function (){ beforeEach(function (){ @@ -1770,6 +2127,17 @@ describe('input', function() { expect(inputElm.val()).toBe('2014-W03'); }); + it('should label parse errors as `week`', function() { + compileInput('', { + valid: false, + badInput: true + }); + + changeInputValueTo('yyy'); + expect(inputElm).toBeInvalid(); + expect(scope.form.alias.$error.week).toBeTruthy(); + }); + describe('min', function (){ beforeEach(function (){ compileInput(''); @@ -1918,6 +2286,17 @@ describe('input', function() { expect(+scope.value).toBe(+new Date(2000, 0, 1, 1, 2, 0)); }); + it('should label parse errors as `datetimelocal`', function() { + compileInput('', { + valid: false, + badInput: true + }); + + changeInputValueTo('zzz'); + expect(inputElm).toBeInvalid(); + expect(scope.form.alias.$error.datetimelocal).toBeTruthy(); + }); + describe('min', function (){ beforeEach(function (){ compileInput(''); @@ -2094,6 +2473,17 @@ describe('input', function() { expect(+scope.value).toBe(+new Date(1970, 0, 1, 1, 2, 0)); }); + it('should label parse errors as `time`', function() { + compileInput('', { + valid: false, + badInput: true + }); + + changeInputValueTo('mmm'); + expect(inputElm).toBeInvalid(); + expect(scope.form.alias.$error.time).toBeTruthy(); + }); + describe('min', function (){ beforeEach(function (){ compileInput(''); @@ -2251,6 +2641,17 @@ describe('input', function() { expect(inputElm.val()).toBe('2001-01-01'); }); + it('should label parse errors as `date`', function() { + compileInput('', { + valid: false, + badInput: true + }); + + changeInputValueTo('nnn'); + expect(inputElm).toBeInvalid(); + expect(scope.form.alias.$error.date).toBeTruthy(); + }); + describe('min', function (){ beforeEach(function (){ compileInput(''); @@ -2374,13 +2775,17 @@ describe('input', function() { }); - it('should invalidate number if suffering from bad input', function() { + it('should only invalidate the model if suffering from bad input when the data is parsed', function() { compileInput('', { valid: false, badInput: true }); - changeInputValueTo('10a'); + expect(scope.age).toBeUndefined(); + expect(inputElm).toBeValid(); + + changeInputValueTo('this-will-fail-because-of-the-badInput-flag'); + expect(scope.age).toBeUndefined(); expect(inputElm).toBeInvalid(); }); @@ -2400,6 +2805,13 @@ describe('input', function() { expect(inputElm).toBeValid(); }); + it('should throw if the model value is not a number', function() { + expect(function() { + scope.value = 'one'; + compileInput(''); + }).toThrowMinErr('ngModel', 'numfmt', "Expected `one` to be a number"); + }); + describe('min', function() { @@ -2440,7 +2852,7 @@ describe('input', function() { changeInputValueTo('20'); expect(inputElm).toBeInvalid(); - expect(scope.value).toBeFalsy(); + expect(scope.value).toBeUndefined(); expect(scope.form.alias.$error.max).toBeTruthy(); changeInputValueTo('0'); @@ -2914,13 +3326,18 @@ describe('input', function() { }); - it('should set $valid even if model fails other validators', function() { - compileInput(''); - changeInputValueTo('bademail'); + it('should consider bad input as an error before any other errors are considered', function() { + compileInput('', { badInput : true }); + var ctrl = inputElm.controller('ngModel'); + ctrl.$parsers.push(function() { + return undefined; + }); + + changeInputValueTo('abc123'); - expect(inputElm).toHaveClass('ng-valid-required'); - expect(inputElm.controller('ngModel').$error.required).toBe(false); - expect(inputElm).toBeInvalid(); // invalid because of the email validator + expect(ctrl.$error.parse).toBe(true); + expect(inputElm).toHaveClass('ng-invalid-parse'); + expect(inputElm).toBeInvalid(); // invalid because of the number validator }); @@ -3123,9 +3540,10 @@ describe('NgModel animations', function() { return animations; } - function assertValidAnimation(animation, event, className) { + function assertValidAnimation(animation, event, classNameA, classNameB) { expect(animation.event).toBe(event); - expect(animation.args[1]).toBe(className); + expect(animation.args[1]).toBe(classNameA); + if(classNameB) expect(animation.args[2]).toBe(classNameB); } var doc, input, scope, model;