diff --git a/src/ng/directive/form.js b/src/ng/directive/form.js index a499e1fc8119..335ac606582f 100644 --- a/src/ng/directive/form.js +++ b/src/ng/directive/form.js @@ -5,6 +5,7 @@ var nullFormCtrl = { $addControl: noop, $removeControl: noop, $setValidity: noop, + $$setPending: noop, $setDirty: noop, $setPristine: noop, $setSubmitted: noop, @@ -54,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; @@ -151,9 +153,29 @@ function FormController(element, attrs, $scope, $animate) { }; form.$$clearControlValidity = function(control) { - forEach(errors, function(queue, validationToken) { + 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); + } }; /** @@ -167,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); } @@ -197,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 e678a17bbc78..1aa4dba4cfa0 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -1365,7 +1365,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 @@ -1400,6 +1401,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. @@ -1412,6 +1451,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 * @@ -1519,6 +1559,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 = []; @@ -1586,6 +1628,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 @@ -1603,18 +1646,67 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ } 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 @@ -1634,28 +1726,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 if(!$error[validationErrorKey]) { - toggleValidCss(false); - ctrl.$invalid = true; - ctrl.$valid = false; invalidCount++; + if (!pendingCount) { + toggleValidCss(false); + ctrl.$invalid = true; + ctrl.$valid = false; + } } $error[validationErrorKey] = !isValid; toggleValidCss(isValid, validationErrorKey); - parentForm.$setValidity(validationErrorKey, isValid, ctrl); }; @@ -1783,7 +1877,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 @@ -1799,9 +1893,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; }; @@ -1849,13 +1974,13 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ 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(); } - - ctrl.$$writeModelToScope(); }; this.$$writeModelToScope = function() { 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 26d0fb940f47..b4c7ffbbc983 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -8,6 +8,7 @@ describe('NgModelController', function() { var attrs = {name: 'testAlias', ngModel: 'value'}; parentFormCtrl = { + $$setPending: jasmine.createSpy('$$setPending'), $setValidity: jasmine.createSpy('$setValidity'), $setDirty: jasmine.createSpy('$setDirty'), $$clearControlValidity: noop @@ -377,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) { @@ -504,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); + })); + }); }); @@ -3196,9 +3442,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;