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 private `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.
- `ctrl.$setValidity` now differentiates between `true`, `false`,
  `undefined` and `null`, instead of previously only truthy vs falsy.

Closes #8941
This commit is contained in:
Tobias Bosch
2014-09-04 17:50:26 -07:00
parent 2a5af502c5
commit 6046e14bd2
4 changed files with 380 additions and 351 deletions

View File

@@ -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,11 @@ 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.
*
* Built-in validation tokens:
*
@@ -55,12 +55,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 +72,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 +140,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 +160,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);
}
}
} else {
if (!form.$pending) {
form.$valid = false;
form.$invalid = true;
}
if (!invalidCount) {
toggleValidCss(isValid);
}
if (queue) {
if (includes(queue, control)) return;
addSetValidityMethod({
ctrl: this,
$element: element,
set: function(object, property, control) {
var list = object[property];
if (!list) {
object[property] = [control];
} else {
errors[validationToken] = queue = [];
invalidCount++;
toggleValidCss(false, validationToken);
parentForm.$setValidity(validationToken, false, form);
var index = list.indexOf(control);
if (index === -1) {
list.push(control);
}
}
queue.push(control);
}
};
},
unset: function(object, property, control) {
var list = object[property];
if (!list) {
return;
}
arrayRemove(list, control);
if (list.length === 0) {
delete object[property];
}
},
parentForm: parentForm,
$animate: $animate
});
/**
* @ngdoc method

View File

@@ -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,11 @@ 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;
// Detect bug in FF35 for input[email] (https://bugzilla.mozilla.org/show_bug.cgi?id=1064430):
// - also sets validity.badInput (should only be validity.typeMismatch).
// - see http://www.whatwg.org/specs/web-apps/current-work/multipage/forms.html#e-mail-state-(type=email)
// - can ignore this case as we can still read out the erroneous email...
return validity.badInput && !validity.typeMismatch ? undefined : value;
});
}
}
@@ -1181,7 +1186,8 @@ function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) {
}
function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) {
badInputChecker(scope, element, attr, ctrl);
// Note: no badInputChecker here by purpose as `url` is only a validation
// in browsers, i.e. we can always read out input.value even if it is not valid!
baseInputType(scope, element, attr, ctrl, $sniffer, $browser);
stringBasedInputType(ctrl);
@@ -1193,7 +1199,8 @@ function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) {
}
function emailInputType(scope, element, attr, ctrl, $sniffer, $browser) {
badInputChecker(scope, element, attr, ctrl);
// Note: no badInputChecker here by purpose as `url` is only a validation
// in browsers, i.e. we can always read out input.value even if it is not valid!
baseInputType(scope, element, attr, ctrl, $sniffer, $browser);
stringBasedInputType(ctrl);
@@ -1512,7 +1519,8 @@ 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 {boolean} $untouched True if control has not lost focus yet.
* @property {boolean} $touched True if control has lost focus.
@@ -1520,7 +1528,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.<string, boolean>} $pending True if one or more asynchronous validators is still yet to be delivered.
*
* @description
*
@@ -1623,13 +1630,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 +1645,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 +1709,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]` 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 +1881,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();
}
};
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();
}
var continueValidation = validate(ctrl.$validators, function(validator, result) {
ctrl.$setValidity(validator, result);
ctrl.$$runValidators(undefined, ctrl.$$invalidModelValue || ctrl.$modelValue, ctrl.$viewValue, function() {
if (prev !== ctrl.$modelValue) {
ctrl.$$writeModelToScope();
}
});
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;
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();
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;
}
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.", promise);
}
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);
}
}
function setValidity(name, isValid) {
if (localValidationRunId === currentValidationRunId) {
ctrl.$setValidity(name, isValid);
}
}
function validationDone() {
if (localValidationRunId === currentValidationRunId) {
// set the validated model value
ctrl.$modelValue = ctrl.$valid ? modelValue : undefined;
doneCallback();
}
}
};
/**
@@ -2037,27 +2012,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.$$runValidators(parserValid, modelValue, viewValue, function() {
ctrl.$$writeModelToScope();
} else if (ctrl.$modelValue !== modelValue &&
(isUndefined(ctrl.$$invalidModelValue) || ctrl.$$invalidModelValue != modelValue)) {
ctrl.$setValidity(parserName, true);
ctrl.$$runValidators(modelValue, viewValue);
ctrl.$$writeModelToScope();
}
});
};
this.$$writeModelToScope = function() {
@@ -2175,12 +2141,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 +2886,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;
}

View File

@@ -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');
}));
});

View File

@@ -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');
}));
});