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'; 'use strict';
/* global -nullFormCtrl, -SUBMITTED_CLASS */ /* global -nullFormCtrl, -SUBMITTED_CLASS, addSetValidityMethod: true
*/
var nullFormCtrl = { var nullFormCtrl = {
$addControl: noop, $addControl: noop,
$removeControl: 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} $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 {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 * @property {Object} $error Is an object hash, containing references to controls or
* forms, where: * forms with failing validators, where:
* *
* - keys are validation tokens (error names), * - 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: * Built-in validation tokens:
* *
@@ -55,12 +55,12 @@ FormController.$inject = ['$element', '$attrs', '$scope', '$animate'];
function FormController(element, attrs, $scope, $animate) { function FormController(element, attrs, $scope, $animate) {
var form = this, var form = this,
parentForm = element.parent().controller('form') || nullFormCtrl, parentForm = element.parent().controller('form') || nullFormCtrl,
invalidCount = 0, // used to easily determine if we are valid controls = [];
pendingCount = 0,
controls = [],
errors = form.$error = {};
// init state // init state
form.$error = {};
form.$$success = {};
form.$pending = undefined;
form.$name = attrs.name || attrs.ngForm; form.$name = attrs.name || attrs.ngForm;
form.$dirty = false; form.$dirty = false;
form.$pristine = true; form.$pristine = true;
@@ -72,14 +72,6 @@ function FormController(element, attrs, $scope, $animate) {
// Setup initial state of the control // Setup initial state of the control
element.addClass(PRISTINE_CLASS); 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 * @ngdoc method
@@ -148,34 +140,16 @@ function FormController(element, attrs, $scope, $animate) {
if (control.$name && form[control.$name] === control) { if (control.$name && form[control.$name] === control) {
delete form[control.$name]; 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); 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 * @ngdoc method
@@ -186,72 +160,33 @@ function FormController(element, attrs, $scope, $animate) {
* *
* This method will also propagate to parent forms. * This method will also propagate to parent forms.
*/ */
form.$setValidity = function(validationToken, isValid, control) { addSetValidityMethod({
var queue = errors[validationToken]; ctrl: this,
var pendingChange, pending = form.$pending && form.$pending[validationToken]; $element: element,
set: function(object, property, control) {
if (pending) { var list = object[property];
pendingChange = pending.indexOf(control) >= 0; if (!list) {
if (pendingChange) { object[property] = [control];
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;
} else { } else {
errors[validationToken] = queue = []; var index = list.indexOf(control);
invalidCount++; if (index === -1) {
toggleValidCss(false, validationToken); list.push(control);
parentForm.$setValidity(validationToken, false, form); }
} }
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 * @ngdoc method

View File

@@ -21,6 +21,7 @@ var TIME_REGEXP = /^(\d\d):(\d\d)(?::(\d\d))?$/;
var DEFAULT_REGEXP = /(\s+|^)default(\s+|$)/; var DEFAULT_REGEXP = /(\s+|^)default(\s+|$)/;
var $ngModelMinErr = new minErr('ngModel'); var $ngModelMinErr = new minErr('ngModel');
var inputType = { var inputType = {
/** /**
@@ -1121,7 +1122,11 @@ function badInputChecker(scope, element, attr, ctrl) {
if (nativeValidation) { if (nativeValidation) {
ctrl.$parsers.push(function(value) { ctrl.$parsers.push(function(value) {
var validity = element.prop(VALIDITY_STATE_PROPERTY) || {}; 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) { 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); baseInputType(scope, element, attr, ctrl, $sniffer, $browser);
stringBasedInputType(ctrl); stringBasedInputType(ctrl);
@@ -1193,7 +1199,8 @@ function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) {
} }
function emailInputType(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); baseInputType(scope, element, attr, ctrl, $sniffer, $browser);
stringBasedInputType(ctrl); 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. * 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. * 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} $untouched True if control has not lost focus yet.
* @property {boolean} $touched True if control has lost focus. * @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} $dirty True if user has already interacted with the control.
* @property {boolean} $valid True if there is no error. * @property {boolean} $valid True if there is no error.
* @property {boolean} $invalid True if at least one error on the control. * @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 * @description
* *
@@ -1623,13 +1630,12 @@ var VALID_CLASS = 'ng-valid',
* *
* *
*/ */
var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$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) { function($scope, $exceptionHandler, $attr, $element, $parse, $animate, $timeout, $rootScope, $q) {
this.$viewValue = Number.NaN; this.$viewValue = Number.NaN;
this.$modelValue = Number.NaN; this.$modelValue = Number.NaN;
this.$validators = {}; this.$validators = {};
this.$asyncValidators = {}; this.$asyncValidators = {};
this.$validators = {};
this.$parsers = []; this.$parsers = [];
this.$formatters = []; this.$formatters = [];
this.$viewChangeListeners = []; this.$viewChangeListeners = [];
@@ -1639,6 +1645,9 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
this.$dirty = false; this.$dirty = false;
this.$valid = true; this.$valid = true;
this.$invalid = false; 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; this.$name = $attr.name;
@@ -1700,131 +1709,44 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
}; };
var parentForm = $element.inheritedData('$formController') || nullFormCtrl, var parentForm = $element.inheritedData('$formController') || nullFormCtrl,
invalidCount = 0, // used to easily determine if we are valid currentValidationRunId = 0;
pendingCount = 0, // used to easily determine if there are any pending validations
$error = this.$error = {}; // keep invalid keys here
// Setup initial state of the control // Setup initial state of the control
$element $element
.addClass(PRISTINE_CLASS) .addClass(PRISTINE_CLASS)
.addClass(UNTOUCHED_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 * @ngdoc method
* @name ngModel.NgModelController#$setValidity * @name ngModel.NgModelController#$setValidity
* *
* @description * @description
* Change the validity state, and notifies the form when the control changes validity. (i.e. it * Change the validity state, and notifies the form.
* does not notify form if given validator is already marked as invalid).
* *
* This method can be called within $parsers/$formatters. However, if possible, please use the * 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 * @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 * 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` * 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}}` . * 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) { addSetValidityMethod({
ctrl: this,
// avoid doing anything if the validation value has not changed $element: $element,
// jshint -W018 set: function(object, property) {
if (!ctrl.$pending && $error[validationErrorKey] === !isValid) return; object[property] = true;
// jshint +W018 },
unset: function(object, property) {
if (isValid) { delete object[property];
if ($error[validationErrorKey]) invalidCount--; },
if (!invalidCount && !pendingCount) { parentForm: parentForm,
toggleValidCss(true); $animate: $animate
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);
};
/** /**
* @ngdoc method * @ngdoc method
@@ -1959,49 +1881,102 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
} }
var prev = ctrl.$modelValue; var prev = ctrl.$modelValue;
ctrl.$$runValidators(ctrl.$$invalidModelValue || ctrl.$modelValue, ctrl.$viewValue); ctrl.$$runValidators(undefined, ctrl.$$invalidModelValue || ctrl.$modelValue, ctrl.$viewValue, function() {
if (prev !== ctrl.$modelValue) { if (prev !== ctrl.$modelValue) {
ctrl.$$writeModelToScope(); 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);
}); });
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) { this.$$runValidators = function(parseValid, modelValue, viewValue, doneCallback) {
ctrl.$modelValue = ctrl.$valid ? modelValue : undefined; currentValidationRunId++;
ctrl.$$invalidModelValue = ctrl.$valid ? undefined : modelValue; 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(); parentForm.$setDirty();
} }
var hasBadInput, modelValue = viewValue; var parserValid = true, modelValue = viewValue;
for(var i = 0; i < ctrl.$parsers.length; i++) { for(var i = 0; i < ctrl.$parsers.length; i++) {
modelValue = ctrl.$parsers[i](modelValue); modelValue = ctrl.$parsers[i](modelValue);
if (isUndefined(modelValue)) { if (isUndefined(modelValue)) {
hasBadInput = true; parserValid = false;
break; break;
} }
} }
var parserName = ctrl.$$parserName || 'parse'; ctrl.$$runValidators(parserValid, modelValue, viewValue, function() {
if (hasBadInput) {
ctrl.$$invalidModelValue = ctrl.$modelValue = undefined;
ctrl.$$clearValidity();
ctrl.$setValidity(parserName, false);
ctrl.$$writeModelToScope(); 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() { this.$$writeModelToScope = function() {
@@ -2175,12 +2141,12 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
while(idx--) { while(idx--) {
viewValue = formatters[idx](viewValue); viewValue = formatters[idx](viewValue);
} }
var lastViewValue = ctrl.$viewValue;
ctrl.$$runValidators(modelValue, viewValue); if (lastViewValue !== viewValue) {
ctrl.$$runValidators(undefined, modelValue, viewValue, function() {
if (ctrl.$viewValue !== viewValue) { ctrl.$viewValue = ctrl.$$lastCommittedViewValue = viewValue;
ctrl.$viewValue = ctrl.$$lastCommittedViewValue = viewValue; ctrl.$render();
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.inputPresent = false;
scope.$apply(); scope.$apply();
expect(form.$error.required).toBe(false); expect(form.$error.required).toBeFalsy();
expect(form.alias).toBeUndefined(); expect(form.alias).toBeUndefined();
}); });
@@ -125,8 +125,8 @@ describe('form', function() {
expect(scope.firstName).toBe('val1'); expect(scope.firstName).toBe('val1');
expect(scope.lastName).toBe('val2'); expect(scope.lastName).toBe('val2');
expect(scope.formA.$error.required).toBe(false); expect(scope.formA.$error.required).toBeFalsy();
expect(scope.formB.$error.required).toBe(false); expect(scope.formB.$error.required).toBeFalsy();
}); });
@@ -399,8 +399,8 @@ describe('form', function() {
expect(child.$error.MyError).toEqual([inputB]); expect(child.$error.MyError).toEqual([inputB]);
inputB.$setValidity('MyError', true); inputB.$setValidity('MyError', true);
expect(parent.$error.MyError).toBe(false); expect(parent.$error.MyError).toBeFalsy();
expect(child.$error.MyError).toBe(false); expect(child.$error.MyError).toBeFalsy();
child.$setDirty(); child.$setDirty();
expect(parent.$dirty).toBeTruthy(); expect(parent.$dirty).toBeTruthy();
@@ -430,7 +430,7 @@ describe('form', function() {
expect(parent.child).toBeUndefined(); expect(parent.child).toBeUndefined();
expect(scope.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(parent.child).toBeUndefined();
expect(scope.child.form).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.inputPresent = false;
scope.$apply(); scope.$apply();
expect(parent.$error.required).toBe(false); expect(parent.$error.required).toBeFalsy();
expect(child.$error.required).toBe(false); expect(child.$error.required).toBeFalsy();
expect(doc.hasClass('ng-valid')).toBe(true); 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')).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() { 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]); expect(parent.$error.myRule).toEqual([child]);
input.$setValidity('myRule', true); input.$setValidity('myRule', true);
expect(parent.$error.myRule).toBe(false); expect(parent.$error.myRule).toBeFalsy();
expect(child.$error.myRule).toBe(false); expect(child.$error.myRule).toBeFalsy();
}); });
}); });
@@ -596,8 +596,15 @@ describe('form', function() {
expect(doc.hasClass('ng-invalid-error')).toBe(false); expect(doc.hasClass('ng-invalid-error')).toBe(false);
expect(doc.hasClass('ng-valid-another')).toBe(true); expect(doc.hasClass('ng-valid-another')).toBe(true);
expect(doc.hasClass('ng-invalid-another')).toBe(false); 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() { it('should have ng-pristine/ng-dirty css class', function() {
expect(doc).toBePristine(); expect(doc).toBePristine();
@@ -618,7 +625,7 @@ describe('form', function() {
var defer, form = doc.data('$formController'); var defer, form = doc.data('$formController');
var ctrl = {}; var ctrl = {};
form.$$setPending('matias', ctrl); form.$setValidity('matias', undefined, ctrl);
expect(form.$valid).toBeUndefined(); expect(form.$valid).toBeUndefined();
expect(form.$invalid).toBeUndefined(); expect(form.$invalid).toBeUndefined();
@@ -799,8 +806,7 @@ describe('form animations', function() {
assertValidAnimation($animate.queue[0], 'removeClass', 'ng-valid'); assertValidAnimation($animate.queue[0], 'removeClass', 'ng-valid');
assertValidAnimation($animate.queue[1], 'addClass', 'ng-invalid'); assertValidAnimation($animate.queue[1], 'addClass', 'ng-invalid');
assertValidAnimation($animate.queue[2], 'removeClass', 'ng-valid-required'); assertValidAnimation($animate.queue[2], 'addClass', 'ng-invalid-required');
assertValidAnimation($animate.queue[3], 'addClass', 'ng-invalid-required');
})); }));
it('should trigger an animation when valid', inject(function($animate) { it('should trigger an animation when valid', inject(function($animate) {
@@ -810,10 +816,9 @@ describe('form animations', function() {
form.$setValidity('required', true); form.$setValidity('required', true);
assertValidAnimation($animate.queue[0], 'removeClass', 'ng-invalid'); assertValidAnimation($animate.queue[0], 'addClass', 'ng-valid');
assertValidAnimation($animate.queue[1], 'addClass', 'ng-valid'); assertValidAnimation($animate.queue[1], 'removeClass', 'ng-invalid');
assertValidAnimation($animate.queue[2], 'removeClass', 'ng-invalid-required'); assertValidAnimation($animate.queue[2], 'addClass', 'ng-valid-required');
assertValidAnimation($animate.queue[3], 'addClass', 'ng-valid-required');
})); }));
it('should trigger an animation when dirty', inject(function($animate) { 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[0], 'removeClass', 'ng-valid');
assertValidAnimation($animate.queue[1], 'addClass', 'ng-invalid'); assertValidAnimation($animate.queue[1], 'addClass', 'ng-invalid');
assertValidAnimation($animate.queue[2], 'removeClass', 'ng-valid-custom-error'); assertValidAnimation($animate.queue[2], 'addClass', 'ng-invalid-custom-error');
assertValidAnimation($animate.queue[3], 'addClass', 'ng-invalid-custom-error');
$animate.queue = []; $animate.queue = [];
form.$setValidity('custom-error', true); form.$setValidity('custom-error', true);
assertValidAnimation($animate.queue[0], 'removeClass', 'ng-invalid'); assertValidAnimation($animate.queue[0], 'addClass', 'ng-valid');
assertValidAnimation($animate.queue[1], 'addClass', 'ng-valid'); assertValidAnimation($animate.queue[1], 'removeClass', 'ng-invalid');
assertValidAnimation($animate.queue[2], 'removeClass', 'ng-invalid-custom-error'); assertValidAnimation($animate.queue[2], 'addClass', 'ng-valid-custom-error');
assertValidAnimation($animate.queue[3], '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() { 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(); expect(parentFormCtrl.$setValidity).not.toHaveBeenCalled();
ctrl.$setValidity('ERROR', false); ctrl.$setValidity('ERROR', false);
expect(parentFormCtrl.$setValidity).toHaveBeenCalledOnceWith('ERROR', false, ctrl); 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('someError', false);
ctrl.$setValidity('required', false); expectOneError();
expect(ctrl.$error.required).toBe(true);
ctrl.$setValidity('required', true); ctrl.$setValidity('someError', undefined);
expect(ctrl.$error.required).toBe(false); expectOnePending();
ctrl.$setValidity('someError', true);
expectOneSuccess();
ctrl.$setValidity('someError', null);
expectCleared();
}); });
it('should set valid/invalid with multiple errors', function() {
it('should set valid/invalid', function() {
ctrl.$setValidity('first', false); ctrl.$setValidity('first', false);
expect(ctrl.$valid).toBe(false); expect(ctrl.$valid).toBe(false);
expect(ctrl.$invalid).toBe(true); expect(ctrl.$invalid).toBe(true);
@@ -81,6 +107,14 @@ describe('NgModelController', function() {
expect(ctrl.$valid).toBe(false); expect(ctrl.$valid).toBe(false);
expect(ctrl.$invalid).toBe(true); 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); ctrl.$setValidity('second', true);
expect(ctrl.$valid).toBe(false); expect(ctrl.$valid).toBe(false);
expect(ctrl.$invalid).toBe(true); expect(ctrl.$invalid).toBe(true);
@@ -90,18 +124,6 @@ describe('NgModelController', function() {
expect(ctrl.$invalid).toBe(false); 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() { describe('setPristine', function() {
@@ -225,10 +247,10 @@ describe('NgModelController', function() {
a = b = true; a = b = true;
ctrl.$setViewValue('3'); ctrl.$setViewValue('3');
expect(ctrl.$error).toEqual({ parse: false, high : true, even : true }); expect(ctrl.$error).toEqual({ high : true, even : true });
ctrl.$setViewValue('10'); ctrl.$setViewValue('10');
expect(ctrl.$error).toEqual({ parse: false, high : false, even : false }); expect(ctrl.$error).toEqual({});
a = undefined; a = undefined;
@@ -250,7 +272,7 @@ describe('NgModelController', function() {
a = b = false; //not undefined a = b = false; //not undefined
ctrl.$setViewValue('2'); 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', 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'); ctrl.$setViewValue('123');
scope.$digest(); 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'); expect(element).toHaveClass('ng-invalid-always-fail');
parserIsFailing = true; parserIsFailing = true;
ctrl.$setViewValue('12345'); ctrl.$setViewValue('12345');
scope.$digest(); scope.$digest();
expect(element).not.toHaveClass('ng-valid-parse');
expect(element).toHaveClass('ng-invalid-parse'); expect(element).toHaveClass('ng-invalid-parse');
expect(element).not.toHaveClass('ng-invalid-always-fail'); 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) { it('should clear and ignore all pending promises when the input values changes', inject(function($q) {
var isPending = false;
ctrl.$validators.sync = function(value) { ctrl.$validators.sync = function(value) {
isPending = isObject(ctrl.$pending);
return true; return true;
}; };
@@ -616,14 +638,14 @@ describe('NgModelController', function() {
}; };
scope.$apply('value = "123"'); scope.$apply('value = "123"');
expect(isPending).toBe(false); expect(ctrl.$pending).toEqual({async: true});
expect(ctrl.$valid).toBe(undefined); expect(ctrl.$valid).toBe(undefined);
expect(ctrl.$invalid).toBe(undefined); expect(ctrl.$invalid).toBe(undefined);
expect(defers.length).toBe(1); expect(defers.length).toBe(1);
expect(isObject(ctrl.$pending)).toBe(true); expect(isObject(ctrl.$pending)).toBe(true);
scope.$apply('value = "456"'); scope.$apply('value = "456"');
expect(isPending).toBe(false); expect(ctrl.$pending).toEqual({async: true});
expect(ctrl.$valid).toBe(undefined); expect(ctrl.$valid).toBe(undefined);
expect(ctrl.$invalid).toBe(undefined); expect(ctrl.$invalid).toBe(undefined);
expect(defers.length).toBe(2); expect(defers.length).toBe(2);
@@ -734,6 +756,7 @@ describe('NgModelController', function() {
}); });
describe('ngModel', 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)', it('should set css classes (ng-valid, ng-invalid, ng-pristine, ng-dirty, ng-untouched, ng-touched)',
inject(function($compile, $rootScope, $sniffer) { inject(function($compile, $rootScope, $sniffer) {
@@ -3443,7 +3466,7 @@ describe('input', function() {
expect(scope.email).toBe('vojta@google.com'); expect(scope.email).toBe('vojta@google.com');
expect(inputElm).toBeValid(); expect(inputElm).toBeValid();
expect(widget.$error.email).toBe(false); expect(widget.$error.email).toBeFalsy();
changeInputValueTo('invalid@'); changeInputValueTo('invalid@');
expect(scope.email).toBeUndefined(); expect(scope.email).toBeUndefined();
@@ -3477,7 +3500,7 @@ describe('input', function() {
changeInputValueTo('http://www.something.com'); changeInputValueTo('http://www.something.com');
expect(scope.url).toBe('http://www.something.com'); expect(scope.url).toBe('http://www.something.com');
expect(inputElm).toBeValid(); expect(inputElm).toBeValid();
expect(widget.$error.url).toBe(false); expect(widget.$error.url).toBeFalsy();
changeInputValueTo('invalid.com'); changeInputValueTo('invalid.com');
expect(scope.url).toBeUndefined(); expect(scope.url).toBeUndefined();
@@ -4094,8 +4117,7 @@ describe('NgModel animations', function() {
var animations = findElementAnimations(input, $animate.queue); var animations = findElementAnimations(input, $animate.queue);
assertValidAnimation(animations[0], 'removeClass', 'ng-valid'); assertValidAnimation(animations[0], 'removeClass', 'ng-valid');
assertValidAnimation(animations[1], 'addClass', 'ng-invalid'); assertValidAnimation(animations[1], 'addClass', 'ng-invalid');
assertValidAnimation(animations[2], 'removeClass', 'ng-valid-required'); assertValidAnimation(animations[2], 'addClass', 'ng-invalid-required');
assertValidAnimation(animations[3], 'addClass', 'ng-invalid-required');
})); }));
it('should trigger an animation when valid', inject(function($animate) { it('should trigger an animation when valid', inject(function($animate) {
@@ -4106,10 +4128,10 @@ describe('NgModel animations', function() {
model.$setValidity('required', true); model.$setValidity('required', true);
var animations = findElementAnimations(input, $animate.queue); var animations = findElementAnimations(input, $animate.queue);
assertValidAnimation(animations[0], 'removeClass', 'ng-invalid'); assertValidAnimation(animations[0], 'addClass', 'ng-valid');
assertValidAnimation(animations[1], 'addClass', 'ng-valid'); assertValidAnimation(animations[1], 'removeClass', 'ng-invalid');
assertValidAnimation(animations[2], 'removeClass', 'ng-invalid-required'); assertValidAnimation(animations[2], 'addClass', 'ng-valid-required');
assertValidAnimation(animations[3], 'addClass', 'ng-valid-required'); assertValidAnimation(animations[3], 'removeClass', 'ng-invalid-required');
})); }));
it('should trigger an animation when dirty', inject(function($animate) { it('should trigger an animation when dirty', inject(function($animate) {
@@ -4150,16 +4172,15 @@ describe('NgModel animations', function() {
var animations = findElementAnimations(input, $animate.queue); var animations = findElementAnimations(input, $animate.queue);
assertValidAnimation(animations[0], 'removeClass', 'ng-valid'); assertValidAnimation(animations[0], 'removeClass', 'ng-valid');
assertValidAnimation(animations[1], 'addClass', 'ng-invalid'); assertValidAnimation(animations[1], 'addClass', 'ng-invalid');
assertValidAnimation(animations[2], 'removeClass', 'ng-valid-custom-error'); assertValidAnimation(animations[2], 'addClass', 'ng-invalid-custom-error');
assertValidAnimation(animations[3], 'addClass', 'ng-invalid-custom-error');
$animate.queue = []; $animate.queue = [];
model.$setValidity('custom-error', true); model.$setValidity('custom-error', true);
animations = findElementAnimations(input, $animate.queue); animations = findElementAnimations(input, $animate.queue);
assertValidAnimation(animations[0], 'removeClass', 'ng-invalid'); assertValidAnimation(animations[0], 'addClass', 'ng-valid');
assertValidAnimation(animations[1], 'addClass', 'ng-valid'); assertValidAnimation(animations[1], 'removeClass', 'ng-invalid');
assertValidAnimation(animations[2], 'removeClass', 'ng-invalid-custom-error'); assertValidAnimation(animations[2], 'addClass', 'ng-valid-custom-error');
assertValidAnimation(animations[3], 'addClass', 'ng-valid-custom-error'); assertValidAnimation(animations[3], 'removeClass', 'ng-invalid-custom-error');
})); }));
}); });