From c7f494c4e90aab3b2c7af04d25fce5186986775c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemel=C3=A4?= Date: Tue, 19 Aug 2014 00:04:59 -0400 Subject: [PATCH 1/2] fix(ngModel): treat undefined parse responses as parse errors With this commit, ngModel will now handle parsing first and then validation afterwards once the parsing is successful. If any parser along the way returns `undefined` then ngModel will break the chain of parsing and register a a parser error represented by the type of input that is being collected (e.g. number, date, datetime, url, etc...). If a parser fails for a standard text input field then an error of `parse` will be placed on `model.$error`. BREAKING CHANGE Any parser code from before that returned an `undefined` value (or nothing at all) will now cause a parser failure. When this occurs none of the validators present in `$validators` will run until the parser error is gone. --- src/ng/directive/form.js | 11 +- src/ng/directive/input.js | 166 +++++++++++++--------------- test/ng/directive/inputSpec.js | 191 +++++++++++++++++++++++++++++++-- 3 files changed, 264 insertions(+), 104 deletions(-) diff --git a/src/ng/directive/form.js b/src/ng/directive/form.js index 43041c47e708..a499e1fc8119 100644 --- a/src/ng/directive/form.js +++ b/src/ng/directive/form.js @@ -7,7 +7,8 @@ var nullFormCtrl = { $setValidity: noop, $setDirty: noop, $setPristine: noop, - $setSubmitted: noop + $setSubmitted: noop, + $$clearControlValidity: noop }, SUBMITTED_CLASS = 'ng-submitted'; @@ -144,11 +145,15 @@ function FormController(element, attrs, $scope, $animate) { if (control.$name && form[control.$name] === control) { delete form[control.$name]; } + + form.$$clearControlValidity(control); + arrayRemove(controls, control); + }; + + form.$$clearControlValidity = function(control) { forEach(errors, function(queue, validationToken) { form.$setValidity(validationToken, true, control); }); - - arrayRemove(controls, control); }; /** diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index 630f4c15fc7c..8a8d4b870ceb 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); @@ -1598,7 +1558,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)); } @@ -1663,6 +1623,19 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ $animate.addClass($element, (isValid ? VALID_CLASS : INVALID_CLASS) + validationErrorKey); } + this.$$clearValidity = function() { + forEach(ctrl.$error, function(val, key) { + var validationKey = snake_case(key, '-'); + $animate.removeClass($element, VALID_CLASS + validationKey); + $animate.removeClass($element, INVALID_CLASS + validationKey); + }); + + invalidCount = 0; + $error = ctrl.$error = {}; + + parentForm.$$clearControlValidity(ctrl); + }; + /** * @ngdoc method * @name ngModel.NgModelController#$setValidity @@ -1694,7 +1667,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ ctrl.$valid = true; ctrl.$invalid = false; } - } else { + } else if(!$error[validationErrorKey]) { toggleValidCss(false); ctrl.$invalid = true; ctrl.$valid = false; @@ -1883,16 +1856,27 @@ 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); + } 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/inputSpec.js b/test/ng/directive/inputSpec.js index abd8dc11e4c2..b574bd0e1871 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -9,7 +9,8 @@ describe('NgModelController', function() { parentFormCtrl = { $setValidity: jasmine.createSpy('$setValidity'), - $setDirty: jasmine.createSpy('$setDirty') + $setDirty: jasmine.createSpy('$setDirty'), + $$clearControlValidity: noop }; element = jqLite('
'); @@ -223,6 +224,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'); + }); }); @@ -1639,6 +1740,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 +1881,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 +2040,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 +2227,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 +2395,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 +2529,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 +2559,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 +2606,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 +3080,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 }); From 04ab51c217ef7b679de53470c9408f0a7ee67d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemel=C3=A4?= Date: Sat, 19 Jul 2014 11:21:31 -0400 Subject: [PATCH 2/2] feat(ngModel): provide validation API functions for sync and async validations This commit introduces a 2nd validation queue called `$asyncValidators`. Each time a value is processed by the validation pipeline, if all synchronous `$validators` succeed, the value is then passed through the `$asyncValidators` validation queue. These validators should return a promise. Rejection of a validation promise indicates a failed validation. --- src/ng/directive/form.js | 81 +++++++++-- src/ng/directive/input.js | 151 ++++++++++++++++++-- test/ng/directive/formSpec.js | 29 ++++ test/ng/directive/inputSpec.js | 253 ++++++++++++++++++++++++++++++++- 4 files changed, 483 insertions(+), 31 deletions(-) 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 8a8d4b870ceb..911ec30f0cd7 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -1386,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 @@ -1421,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. @@ -1433,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 * @@ -1540,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 = []; @@ -1607,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 @@ -1624,18 +1667,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 @@ -1655,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 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); }; @@ -1804,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 @@ -1820,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; }; @@ -1870,13 +1995,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 b574bd0e1871..98bc3537e754 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); + })); + }); }); @@ -3294,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;