From 4a8e05130dcd0131c10219aba35b901313a15102 Mon Sep 17 00:00:00 2001 From: Tobias Bosch Date: Thu, 4 Sep 2014 17:50:26 -0700 Subject: [PATCH] refactor(ngModelController,formController): centralize and simplify logic The previous logic for async validation in `ngModelController` and `formController` was not maintainable: - control logic is in multiple parts, e.g. `ctrl.$setValidity` waits for end of promises and continuous the control flow for async validation - logic for updating the flags `ctrl.$error`, `ctrl.$pending`, `ctrl.$valid` is super complicated, especially in `formController` This refactoring makes the following changes: - simplify async validation: centralize control logic into one method in `ngModelController`: * remove counters `invalidCount` and `pendingCount` * use a flag `currentValidationRunId` to separate async validator runs from each other * use `$q.all` to determine when all async validators are done - centralize way how `ctrl.$modelValue` and `ctrl.$invalidModelValue` is updated - simplify `ngModelController/formCtrl.$setValidity` and merge `$$setPending/$$clearControlValidity/$$clearValidity/$$clearPending` into one method, that is used by `ngModelController` AND `formController` * remove diff calculation, always calculate the correct state anew, only cache the css classes that have been set to not trigger too many css animations. * remove fields from `ctrl.$error` that are valid and add `ctrl.$success`: allows to correctly separate states for valid, invalid, skipped and pending, especially transitively across parent forms. - fix bug in `ngModelController`: * only read out `input.validity.badInput`, but not `input.validity.typeMismatch`, to determine parser error: We still want our `email` validator to run event when the model is validated. - fix bugs in tests that were found as the logic is now consistent between `ngModelController` and `formController` BREAKING CHANGE: - `ctrl.$error` does no more contain entries for validators that were successful. Instead, they are now saved in `ctrl.$success`. - `ctrl.$setValidity` now differentiates between `true`, `false`, `undefined` and `null`, instead of previously only truthy vs falsy. --- src/ng/directive/form.js | 145 ++++-------- src/ng/directive/input.js | 388 +++++++++++++++++++-------------- test/ng/directive/formSpec.js | 58 ++--- test/ng/directive/inputSpec.js | 113 ++++++---- 4 files changed, 367 insertions(+), 337 deletions(-) diff --git a/src/ng/directive/form.js b/src/ng/directive/form.js index f740bf8c2021..4aa2ca449715 100644 --- a/src/ng/directive/form.js +++ b/src/ng/directive/form.js @@ -1,6 +1,7 @@ 'use strict'; -/* global -nullFormCtrl, -SUBMITTED_CLASS */ +/* global -nullFormCtrl, -SUBMITTED_CLASS, addSetValidityMethod: true + */ var nullFormCtrl = { $addControl: noop, $removeControl: noop, @@ -23,12 +24,17 @@ SUBMITTED_CLASS = 'ng-submitted'; * @property {boolean} $invalid True if at least one containing control or form is invalid. * @property {boolean} $submitted True if user has submitted the form even if its invalid. * - * @property {Object} $error Is an object hash, containing references to all invalid controls or - * forms, where: + * @property {Object} $error Is an object hash, containing references to controls or + * forms with failing validators, where: * * - keys are validation tokens (error names), - * - values are arrays of controls or forms that are invalid for given error name. + * - values are arrays of controls or forms that have a failing validator for given error name. + * + * @property {Object} $success Is an object hash, containing references to controls or + * forms with successful validators, where: * + * - keys are validation tokens (error names), + * - values are arrays of controls or forms that have a successful validator for given error name. * * Built-in validation tokens: * @@ -55,12 +61,12 @@ FormController.$inject = ['$element', '$attrs', '$scope', '$animate']; 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 - pendingCount = 0, - controls = [], - errors = form.$error = {}; + controls = []; // init state + form.$error = {}; + form.$success = {}; + form.$pending = undefined; form.$name = attrs.name || attrs.ngForm; form.$dirty = false; form.$pristine = true; @@ -72,14 +78,6 @@ function FormController(element, attrs, $scope, $animate) { // Setup initial state of the control element.addClass(PRISTINE_CLASS); - toggleValidCss(true); - - // convenience method for easy toggling of classes - function toggleValidCss(isValid, validationErrorKey) { - validationErrorKey = validationErrorKey ? '-' + snake_case(validationErrorKey, '-') : ''; - $animate.removeClass(element, (isValid ? INVALID_CLASS : VALID_CLASS) + validationErrorKey); - $animate.addClass(element, (isValid ? VALID_CLASS : INVALID_CLASS) + validationErrorKey); - } /** * @ngdoc method @@ -148,34 +146,16 @@ function FormController(element, attrs, $scope, $animate) { if (control.$name && form[control.$name] === control) { delete form[control.$name]; } + forEach(form.$pending, function(value, name) { + form.$setValidity(name, null, control); + }); + forEach(form.$error, function(value, name) { + form.$setValidity(name, null, 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); - } - }; - - 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 @@ -186,72 +166,33 @@ function FormController(element, attrs, $scope, $animate) { * * This method will also propagate to parent forms. */ - form.$setValidity = function(validationToken, isValid, control) { - var queue = errors[validationToken]; - var pendingChange, pending = form.$pending && form.$pending[validationToken]; - - if (pending) { - pendingChange = pending.indexOf(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 || pendingChange) { - if (queue) { - arrayRemove(queue, control); - } - if (!queue || !queue.length) { - if (errors[validationToken]) { - invalidCount--; - } - if (!invalidCount) { - 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); + addSetValidityMethod({ + ctrl: this, + $element: element, + set: function(object, property, control) { + var list = object[property]; + if (!list) { + object[property] = [control]; + } else { + var index = list.indexOf(control); + if (index === -1) { + list.push(control); } } - } else { - if (!form.$pending) { - form.$valid = false; - form.$invalid = true; - } - - if (!invalidCount) { - toggleValidCss(isValid); + }, + unset: function(object, property, control) { + var list = object[property]; + if (!list) { + return; } - if (queue) { - if (includes(queue, control)) return; - } else { - errors[validationToken] = queue = []; - invalidCount++; - toggleValidCss(false, validationToken); - parentForm.$setValidity(validationToken, false, form); + arrayRemove(list, control); + if (list.length === 0) { + delete object[property]; } - queue.push(control); - } - }; + }, + parentForm: parentForm, + $animate: $animate + }); /** * @ngdoc method diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index 6328420f460d..262201f3d969 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -21,6 +21,7 @@ var TIME_REGEXP = /^(\d\d):(\d\d)(?::(\d\d))?$/; var DEFAULT_REGEXP = /(\s+|^)default(\s+|$)/; var $ngModelMinErr = new minErr('ngModel'); + var inputType = { /** @@ -1121,7 +1122,7 @@ function badInputChecker(scope, element, attr, ctrl) { if (nativeValidation) { ctrl.$parsers.push(function(value) { var validity = element.prop(VALIDITY_STATE_PROPERTY) || {}; - return validity.badInput || validity.typeMismatch ? undefined : value; + return validity.badInput ? undefined : value; }); } } @@ -1512,7 +1513,9 @@ var VALID_CLASS = 'ng-valid', * 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. * - * @property {Object} $error An object hash with all errors as keys. + * @property {Object} $error An object hash with all failing validator ids as keys. + * @property {Object} $pending An object hash with all pending validator ids as keys. + * @property {Object} $success An object hash with all successful validator ids as keys. * * @property {boolean} $untouched True if control has not lost focus yet. * @property {boolean} $touched True if control has lost focus. @@ -1520,7 +1523,6 @@ 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 * @@ -1623,13 +1625,12 @@ var VALID_CLASS = 'ng-valid', * * */ -var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$parse', '$animate', '$timeout', '$rootScope', - function($scope, $exceptionHandler, $attr, $element, $parse, $animate, $timeout, $rootScope) { +var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$parse', '$animate', '$timeout', '$rootScope', '$q', + function($scope, $exceptionHandler, $attr, $element, $parse, $animate, $timeout, $rootScope, $q) { this.$viewValue = Number.NaN; this.$modelValue = Number.NaN; this.$validators = {}; this.$asyncValidators = {}; - this.$validators = {}; this.$parsers = []; this.$formatters = []; this.$viewChangeListeners = []; @@ -1639,6 +1640,9 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ this.$dirty = false; this.$valid = true; this.$invalid = false; + this.$error = {}; // keep invalid keys here + this.$success = {}; // keep valid keys here + this.$pending = undefined; // keep pending keys here this.$name = $attr.name; @@ -1700,131 +1704,44 @@ 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 - + currentValidationRunId = 0; // Setup initial state of the control $element .addClass(PRISTINE_CLASS) .addClass(UNTOUCHED_CLASS); - toggleValidCss(true); - - // convenience method for easy toggling of classes - function toggleValidCss(isValid, validationErrorKey) { - validationErrorKey = validationErrorKey ? '-' + snake_case(validationErrorKey, '-') : ''; - $animate.removeClass($element, (isValid ? INVALID_CLASS : VALID_CLASS) + validationErrorKey); - $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 * * @description - * Change the validity state, and notifies the form when the control changes validity. (i.e. it - * does not notify form if given validator is already marked as invalid). + * Change the validity state, and notifies the form. * * This method can be called within $parsers/$formatters. However, if possible, please use the - * `ngModel.$validators` pipeline which is designed to handle validations with true/false values. + * `ngModel.$validators` pipeline which is designed to call this method automatically. * * @param {string} validationErrorKey Name of the validator. the `validationErrorKey` will assign - * to `$error[validationErrorKey]=!isValid` so that it is available for data-binding. + * to `$error[validationErrorKey]`, `$success[validationErrorKey]` and `$pending[validationErrorKey]` + * so that it is available for data-binding. * The `validationErrorKey` should be in camelCase and will get converted into dash-case * for class name. Example: `myError` will result in `ng-valid-my-error` and `ng-invalid-my-error` * class and can be bound to as `{{someForm.someControl.$error.myError}}` . - * @param {boolean} isValid Whether the current state is valid (true) or invalid (false). + * @param {boolean} isValid Whether the current state is valid (true), invalid (false), pending (undefined), + * or skipped (null). */ - this.$setValidity = function(validationErrorKey, isValid) { - - // avoid doing anything if the validation value has not changed - // jshint -W018 - if (!ctrl.$pending && $error[validationErrorKey] === !isValid) return; - // jshint +W018 - - if (isValid) { - if ($error[validationErrorKey]) invalidCount--; - if (!invalidCount && !pendingCount) { - toggleValidCss(true); - ctrl.$valid = true; - ctrl.$invalid = 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); - }; + addSetValidityMethod({ + ctrl: this, + $element: $element, + set: function(object, property) { + object[property] = true; + }, + unset: function(object, property) { + delete object[property]; + }, + parentForm: parentForm, + $animate: $animate + }); /** * @ngdoc method @@ -1959,49 +1876,102 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ } var prev = ctrl.$modelValue; - ctrl.$$runValidators(ctrl.$$invalidModelValue || ctrl.$modelValue, ctrl.$viewValue); - if (prev !== ctrl.$modelValue) { - ctrl.$$writeModelToScope(); - } + ctrl.$$runValidators(undefined, ctrl.$$invalidModelValue || ctrl.$modelValue, ctrl.$viewValue, function() { + if (prev !== ctrl.$modelValue) { + ctrl.$$writeModelToScope(); + } + }); }; - this.$$runValidators = function(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(); + this.$$runValidators = function(parseValid, modelValue, viewValue, doneCallback) { + currentValidationRunId++; + var localValidationRunId = currentValidationRunId; + + // We can update the $$invalidModelValue immediately as we don't have to wait for validators! + ctrl.$$invalidModelValue = modelValue; + + // check parser error + if (!processParseErrors(parseValid)) { + return; } + if (!processSyncValidators()) { + return; + } + processAsyncValidators(); - var continueValidation = validate(ctrl.$validators, function(validator, result) { - ctrl.$setValidity(validator, result); - }); + function processParseErrors(parseValid) { + var errorKey = ctrl.$$parserName || 'parse'; + if (parseValid === undefined) { + setValidity(errorKey, null); + } else { + setValidity(errorKey, parseValid); + if (!parseValid) { + forEach(ctrl.$validators, function(v, name) { + setValidity(name, null); + }); + forEach(ctrl.$asyncValidators, function(v, name) { + setValidity(name, null); + }); + validationDone(); + return false; + } + } + return true; + } - if (continueValidation) { - validate(ctrl.$asyncValidators, function(validator, result) { - if (!isPromiseLike(result)) { + function processSyncValidators() { + var syncValidatorsValid = true; + forEach(ctrl.$validators, function(validator, name) { + var result = validator(modelValue, viewValue); + syncValidatorsValid = syncValidatorsValid && result; + setValidity(name, result); + }); + if (!syncValidatorsValid) { + forEach(ctrl.$asyncValidators, function(v, name) { + setValidity(name, null); + }); + validationDone(); + return false; + } + return true; + } + + function processAsyncValidators() { + var validatorPromises = []; + forEach(ctrl.$asyncValidators, function(validator, name) { + var promise = validator(modelValue, viewValue); + if (!isPromiseLike(promise)) { throw $ngModelMinErr("$asyncValidators", - "Expected asynchronous validator to return a promise but got '{0}' instead.", result); + "Expected asynchronous validator to return a promise but got '{0}' instead.", promise); } - ctrl.$$setPending(validator, result, modelValue); + setValidity(name, undefined); + validatorPromises.push(promise.then(function() { + setValidity(name, true); + }, function(error) { + setValidity(name, false); + })); }); + if (!validatorPromises.length) { + validationDone(); + } else { + $q.all(validatorPromises).then(validationDone); + } } - 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; + function setValidity(name, isValid) { + if (localValidationRunId === currentValidationRunId) { + ctrl.$setValidity(name, isValid); + } } - }; - this.$$updateValidModelValue = function(modelValue) { - ctrl.$modelValue = ctrl.$valid ? modelValue : undefined; - ctrl.$$invalidModelValue = ctrl.$valid ? undefined : modelValue; + function validationDone() { + if (localValidationRunId === currentValidationRunId) { + // set the validated model value + ctrl.$modelValue = ctrl.$valid ? modelValue : undefined; + + doneCallback(); + } + } }; /** @@ -2037,27 +2007,18 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ parentForm.$setDirty(); } - var hasBadInput, modelValue = viewValue; + var parserValid = true, modelValue = viewValue; for(var i = 0; i < ctrl.$parsers.length; i++) { modelValue = ctrl.$parsers[i](modelValue); if (isUndefined(modelValue)) { - hasBadInput = true; + parserValid = false; break; } } - 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.$$runValidators(parserValid, modelValue, viewValue, function() { ctrl.$$writeModelToScope(); - } + }); }; this.$$writeModelToScope = function() { @@ -2175,12 +2136,12 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ while(idx--) { viewValue = formatters[idx](viewValue); } - - ctrl.$$runValidators(modelValue, viewValue); - - if (ctrl.$viewValue !== viewValue) { - ctrl.$viewValue = ctrl.$$lastCommittedViewValue = viewValue; - ctrl.$render(); + var lastViewValue = ctrl.$viewValue; + if (lastViewValue !== viewValue) { + ctrl.$$runValidators(undefined, modelValue, viewValue, function() { + ctrl.$viewValue = ctrl.$$lastCommittedViewValue = viewValue; + ctrl.$render(); + }); } } @@ -2920,3 +2881,106 @@ var ngModelOptionsDirective = function() { }] }; }; + +// helper methods +function addSetValidityMethod(context) { + var ctrl = context.ctrl, + $element = context.$element, + classCache = {}, + set = context.set, + unset = context.unset, + parentForm = context.parentForm, + $animate = context.$animate; + + ctrl.$setValidity = setValidity; + toggleValidationCss('', true); + + function setValidity(validationErrorKey, state, options) { + if (state === undefined) { + createAndSet('$pending', validationErrorKey, options); + } else { + unsetAndCleanup('$pending', validationErrorKey, options); + } + if (!isBoolean(state)) { + unset(ctrl.$error, validationErrorKey, options); + unset(ctrl.$success, validationErrorKey, options); + } else { + if (state) { + unset(ctrl.$error, validationErrorKey, options); + set(ctrl.$success, validationErrorKey, options); + } else { + set(ctrl.$error, validationErrorKey, options); + unset(ctrl.$success, validationErrorKey, options); + } + } + if (ctrl.$pending) { + cachedToggleClass(PENDING_CLASS, true); + ctrl.$valid = ctrl.$invalid = undefined; + toggleValidationCss('', null); + } else { + cachedToggleClass(PENDING_CLASS, false); + ctrl.$valid = isObjectEmpty(ctrl.$error); + ctrl.$invalid = !ctrl.$valid; + toggleValidationCss('', ctrl.$valid); + } + + // re-read the state as the set/unset methods could have + // combined state in ctrl.$error[validationError] (used for forms), + // where setting/unsetting only increments/decrements the value, + // and does not replace it. + var combinedState; + if (ctrl.$pending && ctrl.$pending[validationErrorKey]) { + combinedState = undefined; + } else if (ctrl.$error[validationErrorKey]) { + combinedState = false; + } else if (ctrl.$success[validationErrorKey]) { + combinedState = true; + } else { + combinedState = null; + } + toggleValidationCss(validationErrorKey, combinedState); + parentForm.$setValidity(validationErrorKey, combinedState, ctrl); + } + + function createAndSet(name, value, options) { + if (!ctrl[name]) { + ctrl[name] = {}; + } + set(ctrl[name], value, options); + } + + function unsetAndCleanup(name, value, options) { + if (ctrl[name]) { + unset(ctrl[name], value, options); + } + if (isObjectEmpty(ctrl[name])) { + ctrl[name] = undefined; + } + } + + function cachedToggleClass(className, switchValue) { + if (switchValue && !classCache[className]) { + $animate.addClass($element, className); + classCache[className] = true; + } else if (!switchValue && classCache[className]) { + $animate.removeClass($element, className); + classCache[className] = false; + } + } + + function toggleValidationCss(validationErrorKey, isValid) { + validationErrorKey = validationErrorKey ? '-' + snake_case(validationErrorKey, '-') : ''; + + cachedToggleClass(VALID_CLASS + validationErrorKey, isValid === true); + cachedToggleClass(INVALID_CLASS + validationErrorKey, isValid === false); + } +} + +function isObjectEmpty(obj) { + if (obj) { + for (var prop in obj) { + return false; + } + } + return true; +} diff --git a/test/ng/directive/formSpec.js b/test/ng/directive/formSpec.js index e780c615b413..57f67f752313 100644 --- a/test/ng/directive/formSpec.js +++ b/test/ng/directive/formSpec.js @@ -54,7 +54,7 @@ describe('form', function() { scope.inputPresent = false; scope.$apply(); - expect(form.$error.required).toBe(false); + expect(form.$error.required).toBeFalsy(); expect(form.alias).toBeUndefined(); }); @@ -125,8 +125,8 @@ describe('form', function() { expect(scope.firstName).toBe('val1'); expect(scope.lastName).toBe('val2'); - expect(scope.formA.$error.required).toBe(false); - expect(scope.formB.$error.required).toBe(false); + expect(scope.formA.$error.required).toBeFalsy(); + expect(scope.formB.$error.required).toBeFalsy(); }); @@ -399,8 +399,8 @@ describe('form', function() { expect(child.$error.MyError).toEqual([inputB]); inputB.$setValidity('MyError', true); - expect(parent.$error.MyError).toBe(false); - expect(child.$error.MyError).toBe(false); + expect(parent.$error.MyError).toBeFalsy(); + expect(child.$error.MyError).toBeFalsy(); child.$setDirty(); expect(parent.$dirty).toBeTruthy(); @@ -430,7 +430,7 @@ describe('form', function() { expect(parent.child).toBeUndefined(); expect(scope.child).toBeUndefined(); - expect(parent.$error.required).toBe(false); + expect(parent.$error.required).toBeFalsy(); }); @@ -454,7 +454,7 @@ describe('form', function() { expect(parent.child).toBeUndefined(); expect(scope.child.form).toBeUndefined(); - expect(parent.$error.required).toBe(false); + expect(parent.$error.required).toBeFalsy(); }); @@ -486,12 +486,12 @@ describe('form', function() { scope.inputPresent = false; scope.$apply(); - expect(parent.$error.required).toBe(false); - expect(child.$error.required).toBe(false); + expect(parent.$error.required).toBeFalsy(); + expect(child.$error.required).toBeFalsy(); expect(doc.hasClass('ng-valid')).toBe(true); - expect(doc.hasClass('ng-valid-required')).toBe(true); + expect(doc.hasClass('ng-valid-required')).toBe(false); expect(doc.find('div').hasClass('ng-valid')).toBe(true); - expect(doc.find('div').hasClass('ng-valid-required')).toBe(true); + expect(doc.find('div').hasClass('ng-valid-required')).toBe(false); }); it('should leave the parent form invalid when deregister a removed input', function() { @@ -551,8 +551,8 @@ describe('form', function() { expect(parent.$error.myRule).toEqual([child]); input.$setValidity('myRule', true); - expect(parent.$error.myRule).toBe(false); - expect(child.$error.myRule).toBe(false); + expect(parent.$error.myRule).toBeFalsy(); + expect(child.$error.myRule).toBeFalsy(); }); }); @@ -596,8 +596,15 @@ describe('form', function() { expect(doc.hasClass('ng-invalid-error')).toBe(false); expect(doc.hasClass('ng-valid-another')).toBe(true); expect(doc.hasClass('ng-invalid-another')).toBe(false); - }); + // validators are skipped, e.g. becuase of a parser error + control.$setValidity('error', null); + control.$setValidity('another', null); + expect(doc.hasClass('ng-valid-error')).toBe(false); + expect(doc.hasClass('ng-invalid-error')).toBe(false); + expect(doc.hasClass('ng-valid-another')).toBe(false); + expect(doc.hasClass('ng-invalid-another')).toBe(false); + }); it('should have ng-pristine/ng-dirty css class', function() { expect(doc).toBePristine(); @@ -618,7 +625,7 @@ describe('form', function() { var defer, form = doc.data('$formController'); var ctrl = {}; - form.$$setPending('matias', ctrl); + form.$setValidity('matias', undefined, ctrl); expect(form.$valid).toBeUndefined(); expect(form.$invalid).toBeUndefined(); @@ -799,8 +806,7 @@ describe('form animations', function() { assertValidAnimation($animate.queue[0], 'removeClass', 'ng-valid'); assertValidAnimation($animate.queue[1], 'addClass', 'ng-invalid'); - assertValidAnimation($animate.queue[2], 'removeClass', 'ng-valid-required'); - assertValidAnimation($animate.queue[3], 'addClass', 'ng-invalid-required'); + assertValidAnimation($animate.queue[2], 'addClass', 'ng-invalid-required'); })); it('should trigger an animation when valid', inject(function($animate) { @@ -810,10 +816,9 @@ describe('form animations', function() { form.$setValidity('required', true); - assertValidAnimation($animate.queue[0], 'removeClass', 'ng-invalid'); - assertValidAnimation($animate.queue[1], 'addClass', 'ng-valid'); - assertValidAnimation($animate.queue[2], 'removeClass', 'ng-invalid-required'); - assertValidAnimation($animate.queue[3], 'addClass', 'ng-valid-required'); + assertValidAnimation($animate.queue[0], 'addClass', 'ng-valid'); + assertValidAnimation($animate.queue[1], 'removeClass', 'ng-invalid'); + assertValidAnimation($animate.queue[2], 'addClass', 'ng-valid-required'); })); it('should trigger an animation when dirty', inject(function($animate) { @@ -838,15 +843,14 @@ describe('form animations', function() { assertValidAnimation($animate.queue[0], 'removeClass', 'ng-valid'); assertValidAnimation($animate.queue[1], 'addClass', 'ng-invalid'); - assertValidAnimation($animate.queue[2], 'removeClass', 'ng-valid-custom-error'); - assertValidAnimation($animate.queue[3], 'addClass', 'ng-invalid-custom-error'); + assertValidAnimation($animate.queue[2], 'addClass', 'ng-invalid-custom-error'); $animate.queue = []; form.$setValidity('custom-error', true); - assertValidAnimation($animate.queue[0], 'removeClass', 'ng-invalid'); - assertValidAnimation($animate.queue[1], 'addClass', 'ng-valid'); - assertValidAnimation($animate.queue[2], 'removeClass', 'ng-invalid-custom-error'); - assertValidAnimation($animate.queue[3], 'addClass', 'ng-valid-custom-error'); + assertValidAnimation($animate.queue[0], 'addClass', 'ng-valid'); + assertValidAnimation($animate.queue[1], 'removeClass', 'ng-invalid'); + assertValidAnimation($animate.queue[2], 'addClass', 'ng-valid-custom-error'); + assertValidAnimation($animate.queue[3], 'removeClass', 'ng-invalid-custom-error'); })); }); diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index a239d4511d66..179d15220b75 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -52,27 +52,53 @@ describe('NgModelController', function() { describe('setValidity', function() { - it('should propagate invalid to the parent form only when valid', function() { + function expectOneError() { + expect(ctrl.$error).toEqual({someError: true}); + expect(ctrl.$success).toEqual({}); + expect(ctrl.$pending).toBeUndefined(); + } + + function expectOneSuccess() { + expect(ctrl.$error).toEqual({}); + expect(ctrl.$success).toEqual({someError: true}); + expect(ctrl.$pending).toBeUndefined(); + } + + function expectOnePending() { + expect(ctrl.$error).toEqual({}); + expect(ctrl.$success).toEqual({}); + expect(ctrl.$pending).toEqual({someError: true}); + } + + function expectCleared() { + expect(ctrl.$error).toEqual({}); + expect(ctrl.$success).toEqual({}); + expect(ctrl.$pending).toBeUndefined(); + } + + it('should propagate validity to the parent form', function() { expect(parentFormCtrl.$setValidity).not.toHaveBeenCalled(); ctrl.$setValidity('ERROR', false); expect(parentFormCtrl.$setValidity).toHaveBeenCalledOnceWith('ERROR', false, ctrl); - - parentFormCtrl.$setValidity.reset(); - ctrl.$setValidity('ERROR', false); - expect(parentFormCtrl.$setValidity).not.toHaveBeenCalled(); }); + it('should transition from states correctly', function() { + expectCleared(); - it('should set and unset the error', function() { - ctrl.$setValidity('required', false); - expect(ctrl.$error.required).toBe(true); + ctrl.$setValidity('someError', false); + expectOneError(); - ctrl.$setValidity('required', true); - expect(ctrl.$error.required).toBe(false); - }); + ctrl.$setValidity('someError', undefined); + expectOnePending(); + + ctrl.$setValidity('someError', true); + expectOneSuccess(); + ctrl.$setValidity('someError', null); + expectCleared(); + }); - it('should set valid/invalid', function() { + it('should set valid/invalid with multiple errors', function() { ctrl.$setValidity('first', false); expect(ctrl.$valid).toBe(false); expect(ctrl.$invalid).toBe(true); @@ -81,6 +107,14 @@ describe('NgModelController', function() { expect(ctrl.$valid).toBe(false); expect(ctrl.$invalid).toBe(true); + ctrl.$setValidity('third', undefined); + expect(ctrl.$valid).toBe(undefined); + expect(ctrl.$invalid).toBe(undefined); + + ctrl.$setValidity('third', null); + expect(ctrl.$valid).toBe(false); + expect(ctrl.$invalid).toBe(true); + ctrl.$setValidity('second', true); expect(ctrl.$valid).toBe(false); expect(ctrl.$invalid).toBe(true); @@ -90,18 +124,6 @@ describe('NgModelController', function() { expect(ctrl.$invalid).toBe(false); }); - - it('should emit $valid only when $invalid', function() { - ctrl.$setValidity('error', true); - expect(parentFormCtrl.$setValidity).toHaveBeenCalledOnceWith('error', true, ctrl); - parentFormCtrl.$setValidity.reset(); - - ctrl.$setValidity('error', false); - expect(parentFormCtrl.$setValidity).toHaveBeenCalledOnceWith('error', false, ctrl); - parentFormCtrl.$setValidity.reset(); - ctrl.$setValidity('error', true); - expect(parentFormCtrl.$setValidity).toHaveBeenCalledOnceWith('error', true, ctrl); - }); }); describe('setPristine', function() { @@ -225,10 +247,10 @@ describe('NgModelController', function() { a = b = true; ctrl.$setViewValue('3'); - expect(ctrl.$error).toEqual({ parse: false, high : true, even : true }); + expect(ctrl.$error).toEqual({ high : true, even : true }); ctrl.$setViewValue('10'); - expect(ctrl.$error).toEqual({ parse: false, high : false, even : false }); + expect(ctrl.$error).toEqual({}); a = undefined; @@ -250,7 +272,7 @@ describe('NgModelController', function() { a = b = false; //not undefined ctrl.$setViewValue('2'); - expect(ctrl.$error).toEqual({ parse: false, high : true, even : false }); + expect(ctrl.$error).toEqual({ high : true }); }); it('should remove all non-parse-related CSS classes from the form when a parser fails', @@ -274,13 +296,15 @@ describe('NgModelController', function() { ctrl.$setViewValue('123'); scope.$digest(); - expect(element).not.toHaveClass('ng-valid-parse'); + expect(element).toHaveClass('ng-valid-parse'); + expect(element).not.toHaveClass('ng-invalid-parse'); expect(element).toHaveClass('ng-invalid-always-fail'); parserIsFailing = true; ctrl.$setViewValue('12345'); scope.$digest(); + expect(element).not.toHaveClass('ng-valid-parse'); expect(element).toHaveClass('ng-invalid-parse'); expect(element).not.toHaveClass('ng-invalid-always-fail'); @@ -602,9 +626,7 @@ describe('NgModelController', function() { })); 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; }; @@ -616,14 +638,14 @@ describe('NgModelController', function() { }; scope.$apply('value = "123"'); - expect(isPending).toBe(false); + expect(ctrl.$pending).toEqual({async: true}); 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.$pending).toEqual({async: true}); expect(ctrl.$valid).toBe(undefined); expect(ctrl.$invalid).toBe(undefined); expect(defers.length).toBe(2); @@ -734,6 +756,7 @@ describe('NgModelController', function() { }); describe('ngModel', function() { + var EMAIL_REGEXP = /^[a-z0-9!#$%&'*+\/=?^_`{|}~.-]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i; it('should set css classes (ng-valid, ng-invalid, ng-pristine, ng-dirty, ng-untouched, ng-touched)', inject(function($compile, $rootScope, $sniffer) { @@ -3443,7 +3466,7 @@ describe('input', function() { expect(scope.email).toBe('vojta@google.com'); expect(inputElm).toBeValid(); - expect(widget.$error.email).toBe(false); + expect(widget.$error.email).toBeFalsy(); changeInputValueTo('invalid@'); expect(scope.email).toBeUndefined(); @@ -3477,7 +3500,7 @@ describe('input', function() { changeInputValueTo('http://www.something.com'); expect(scope.url).toBe('http://www.something.com'); expect(inputElm).toBeValid(); - expect(widget.$error.url).toBe(false); + expect(widget.$error.url).toBeFalsy(); changeInputValueTo('invalid.com'); expect(scope.url).toBeUndefined(); @@ -4094,8 +4117,7 @@ describe('NgModel animations', function() { var animations = findElementAnimations(input, $animate.queue); assertValidAnimation(animations[0], 'removeClass', 'ng-valid'); assertValidAnimation(animations[1], 'addClass', 'ng-invalid'); - assertValidAnimation(animations[2], 'removeClass', 'ng-valid-required'); - assertValidAnimation(animations[3], 'addClass', 'ng-invalid-required'); + assertValidAnimation(animations[2], 'addClass', 'ng-invalid-required'); })); it('should trigger an animation when valid', inject(function($animate) { @@ -4106,10 +4128,10 @@ describe('NgModel animations', function() { model.$setValidity('required', true); var animations = findElementAnimations(input, $animate.queue); - assertValidAnimation(animations[0], 'removeClass', 'ng-invalid'); - assertValidAnimation(animations[1], 'addClass', 'ng-valid'); - assertValidAnimation(animations[2], 'removeClass', 'ng-invalid-required'); - assertValidAnimation(animations[3], 'addClass', 'ng-valid-required'); + assertValidAnimation(animations[0], 'addClass', 'ng-valid'); + assertValidAnimation(animations[1], 'removeClass', 'ng-invalid'); + assertValidAnimation(animations[2], 'addClass', 'ng-valid-required'); + assertValidAnimation(animations[3], 'removeClass', 'ng-invalid-required'); })); it('should trigger an animation when dirty', inject(function($animate) { @@ -4150,16 +4172,15 @@ describe('NgModel animations', function() { var animations = findElementAnimations(input, $animate.queue); assertValidAnimation(animations[0], 'removeClass', 'ng-valid'); assertValidAnimation(animations[1], 'addClass', 'ng-invalid'); - assertValidAnimation(animations[2], 'removeClass', 'ng-valid-custom-error'); - assertValidAnimation(animations[3], 'addClass', 'ng-invalid-custom-error'); + assertValidAnimation(animations[2], 'addClass', 'ng-invalid-custom-error'); $animate.queue = []; model.$setValidity('custom-error', true); animations = findElementAnimations(input, $animate.queue); - assertValidAnimation(animations[0], 'removeClass', 'ng-invalid'); - assertValidAnimation(animations[1], 'addClass', 'ng-valid'); - assertValidAnimation(animations[2], 'removeClass', 'ng-invalid-custom-error'); - assertValidAnimation(animations[3], 'addClass', 'ng-valid-custom-error'); + assertValidAnimation(animations[0], 'addClass', 'ng-valid'); + assertValidAnimation(animations[1], 'removeClass', 'ng-invalid'); + assertValidAnimation(animations[2], 'addClass', 'ng-valid-custom-error'); + assertValidAnimation(animations[3], 'removeClass', 'ng-invalid-custom-error'); })); });