feat(ngModel): provide validation API functions for sync and async validations

This commit introduces a 2nd validation queue called `$asyncValidators`. Each time a value
is processed by the validation pipeline, if all synchronous `$validators` succeed, the value
is then passed through the `$asyncValidators` validation queue. These validators should return
a promise. Rejection of a validation promise indicates a failed validation.
This commit is contained in:
Matias Niemelä
2014-07-19 11:21:31 -04:00
parent db044c408a
commit 2ae4f40be1
4 changed files with 483 additions and 31 deletions

View File

@@ -5,6 +5,7 @@ var nullFormCtrl = {
$addControl: noop,
$removeControl: noop,
$setValidity: noop,
$$setPending: noop,
$setDirty: noop,
$setPristine: noop,
$setSubmitted: noop,
@@ -54,8 +55,9 @@ function FormController(element, attrs, $scope, $animate) {
var form = this,
parentForm = element.parent().controller('form') || nullFormCtrl,
invalidCount = 0, // used to easily determine if we are valid
errors = form.$error = {},
controls = [];
pendingCount = 0,
controls = [],
errors = form.$error = {};
// init state
form.$name = attrs.name || attrs.ngForm;
@@ -151,9 +153,29 @@ function FormController(element, attrs, $scope, $animate) {
};
form.$$clearControlValidity = function(control) {
forEach(errors, function(queue, validationToken) {
forEach(form.$pending, clear);
forEach(errors, clear);
function clear(queue, validationToken) {
form.$setValidity(validationToken, true, control);
});
}
parentForm.$$clearControlValidity(form);
};
form.$$setPending = function(validationToken, control) {
var pending = form.$pending && form.$pending[validationToken];
if (!pending || !includes(pending, control)) {
pendingCount++;
form.$valid = form.$invalid = undefined;
form.$pending = form.$pending || {};
if (!pending) {
pending = form.$pending[validationToken] = [];
}
pending.push(control);
parentForm.$$setPending(validationToken, form);
}
};
/**
@@ -167,24 +189,56 @@ function FormController(element, attrs, $scope, $animate) {
*/
form.$setValidity = function(validationToken, isValid, control) {
var queue = errors[validationToken];
var pendingChange, pending = form.$pending && form.$pending[validationToken];
if (pending) {
pendingChange = indexOf(pending, control) >= 0;
if (pendingChange) {
arrayRemove(pending, control);
pendingCount--;
if (pending.length === 0) {
delete form.$pending[validationToken];
}
}
}
var pendingNoMore = form.$pending && pendingCount === 0;
if (pendingNoMore) {
form.$pending = undefined;
}
if (isValid) {
if (queue) {
arrayRemove(queue, control);
if (!queue.length) {
invalidCount--;
if (queue || pendingChange) {
if (queue) {
arrayRemove(queue, control);
}
if (!queue || !queue.length) {
if (errors[validationToken]) {
invalidCount--;
}
if (!invalidCount) {
toggleValidCss(isValid);
form.$valid = true;
form.$invalid = false;
if (!form.$pending) {
toggleValidCss(isValid);
form.$valid = true;
form.$invalid = false;
}
} else if(pendingNoMore) {
toggleValidCss(false);
form.$valid = false;
form.$invalid = true;
}
errors[validationToken] = false;
toggleValidCss(true, validationToken);
parentForm.$setValidity(validationToken, true, form);
}
}
} else {
if (!form.$pending) {
form.$valid = false;
form.$invalid = true;
}
if (!invalidCount) {
toggleValidCss(isValid);
}
@@ -197,9 +251,6 @@ function FormController(element, attrs, $scope, $animate) {
parentForm.$setValidity(validationToken, false, form);
}
queue.push(control);
form.$valid = false;
form.$invalid = true;
}
};

View File

@@ -1386,7 +1386,8 @@ var VALID_CLASS = 'ng-valid',
PRISTINE_CLASS = 'ng-pristine',
DIRTY_CLASS = 'ng-dirty',
UNTOUCHED_CLASS = 'ng-untouched',
TOUCHED_CLASS = 'ng-touched';
TOUCHED_CLASS = 'ng-touched',
PENDING_CLASS = 'ng-pending';
/**
* @ngdoc type
@@ -1421,6 +1422,44 @@ var VALID_CLASS = 'ng-valid',
* provided with the model value as an argument and must return a true or false value depending
* on the response of that validation.
*
* ```js
* ngModel.$validators.validCharacters = function(modelValue, viewValue) {
* var value = modelValue || viewValue;
* return /[0-9]+/.test(value) &&
* /[a-z]+/.test(value) &&
* /[A-Z]+/.test(value) &&
* /\W+/.test(value);
* };
* ```
*
* @property {Object.<string, function>} $asyncValidators A collection of validations that are expected to
* perform an asynchronous validation (e.g. a HTTP request). The validation function that is provided
* is expected to return a promise when it is run during the model validation process. Once the promise
* is delivered then the validation status will be set to true when fulfilled and false when rejected.
* When the asynchronous validators are trigged, each of the validators will run in parallel and the model
* value will only be updated once all validators have been fulfilled. Also, keep in mind that all
* asynchronous validators will only run once all synchronous validators have passed.
*
* Please note that if $http is used then it is important that the server returns a success HTTP response code
* in order to fulfill the validation and a status level of `4xx` in order to reject the validation.
*
* ```js
* ngModel.$asyncValidators.uniqueUsername = function(modelValue, viewValue) {
* var value = modelValue || viewValue;
* return $http.get('/api/users/' + value).
* then(function() {
* //username exists, this means the validator fails
* return false;
* }, function() {
* //username does not exist, therefore this validation is true
* return true;
* });
* };
* ```
*
* @param {string} name The name of the validator.
* @param {Function} validationFn The validation function that will be run.
*
* @property {Array.<Function>} $viewChangeListeners Array of functions to execute whenever the
* view value has changed. It is called with no arguments, and its return value is ignored.
* This can be used in place of additional $watches against the model value.
@@ -1433,6 +1472,7 @@ var VALID_CLASS = 'ng-valid',
* @property {boolean} $dirty True if user has already interacted with the control.
* @property {boolean} $valid True if there is no error.
* @property {boolean} $invalid True if at least one error on the control.
* @property {Object.<string, boolean>} $pending True if one or more asynchronous validators is still yet to be delivered.
*
* @description
*
@@ -1540,6 +1580,8 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
this.$viewValue = Number.NaN;
this.$modelValue = Number.NaN;
this.$validators = {};
this.$asyncValidators = {};
this.$validators = {};
this.$parsers = [];
this.$formatters = [];
this.$viewChangeListeners = [];
@@ -1607,6 +1649,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
var parentForm = $element.inheritedData('$formController') || nullFormCtrl,
invalidCount = 0, // used to easily determine if we are valid
pendingCount = 0, // used to easily determine if there are any pending validations
$error = this.$error = {}; // keep invalid keys here
@@ -1624,18 +1667,67 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
}
this.$$clearValidity = function() {
$animate.removeClass($element, PENDING_CLASS);
forEach(ctrl.$error, function(val, key) {
var validationKey = snake_case(key, '-');
$animate.removeClass($element, VALID_CLASS + validationKey);
$animate.removeClass($element, INVALID_CLASS + validationKey);
});
// just incase an asnyc validator is still running while
// the parser fails
if(ctrl.$pending) {
ctrl.$$clearPending();
}
invalidCount = 0;
$error = ctrl.$error = {};
parentForm.$$clearControlValidity(ctrl);
};
this.$$clearPending = function() {
pendingCount = 0;
ctrl.$pending = undefined;
$animate.removeClass($element, PENDING_CLASS);
};
this.$$setPending = function(validationErrorKey, promise, currentValue) {
ctrl.$pending = ctrl.$pending || {};
if (angular.isUndefined(ctrl.$pending[validationErrorKey])) {
ctrl.$pending[validationErrorKey] = true;
pendingCount++;
}
ctrl.$valid = ctrl.$invalid = undefined;
parentForm.$$setPending(validationErrorKey, ctrl);
$animate.addClass($element, PENDING_CLASS);
$animate.removeClass($element, INVALID_CLASS);
$animate.removeClass($element, VALID_CLASS);
//Special-case for (undefined|null|false|NaN) values to avoid
//having to compare each of them with each other
currentValue = currentValue || '';
promise.then(resolve(true), resolve(false));
function resolve(bool) {
return function() {
var value = ctrl.$viewValue || '';
if (ctrl.$pending && ctrl.$pending[validationErrorKey] && currentValue === value) {
pendingCount--;
delete ctrl.$pending[validationErrorKey];
ctrl.$setValidity(validationErrorKey, bool);
if (pendingCount === 0) {
ctrl.$$clearPending();
ctrl.$$updateValidModelValue(value);
ctrl.$$writeModelToScope();
}
}
};
}
};
/**
* @ngdoc method
* @name ngModel.NgModelController#$setValidity
@@ -1655,28 +1747,30 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
* @param {boolean} isValid Whether the current state is valid (true) or invalid (false).
*/
this.$setValidity = function(validationErrorKey, isValid) {
// Purposeful use of ! here to cast isValid to boolean in case it is undefined
// avoid doing anything if the validation value has not changed
// jshint -W018
if ($error[validationErrorKey] === !isValid) return;
if (!ctrl.$pending && $error[validationErrorKey] === !isValid) return;
// jshint +W018
if (isValid) {
if ($error[validationErrorKey]) invalidCount--;
if (!invalidCount) {
if (!invalidCount && !pendingCount) {
toggleValidCss(true);
ctrl.$valid = true;
ctrl.$invalid = false;
}
} else if(!$error[validationErrorKey]) {
toggleValidCss(false);
ctrl.$invalid = true;
ctrl.$valid = false;
invalidCount++;
if (!pendingCount) {
toggleValidCss(false);
ctrl.$invalid = true;
ctrl.$valid = false;
}
}
$error[validationErrorKey] = !isValid;
toggleValidCss(isValid, validationErrorKey);
parentForm.$setValidity(validationErrorKey, isValid, ctrl);
};
@@ -1804,7 +1898,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
* @name ngModel.NgModelController#$validate
*
* @description
* Runs each of the registered validations set on the $validators object.
* Runs each of the registered validators (first synchronous validators and then asynchronous validators).
*/
this.$validate = function() {
// ignore $validate before model initialized
@@ -1820,9 +1914,40 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
};
this.$$runValidators = function(modelValue, viewValue) {
forEach(ctrl.$validators, function(fn, name) {
ctrl.$setValidity(name, fn(modelValue, viewValue));
// this is called in the event if incase the input value changes
// while a former asynchronous validator is still doing its thing
if(ctrl.$pending) {
ctrl.$$clearPending();
}
var continueValidation = validate(ctrl.$validators, function(validator, result) {
ctrl.$setValidity(validator, result);
});
if (continueValidation) {
validate(ctrl.$asyncValidators, function(validator, result) {
if (!isPromiseLike(result)) {
throw $ngModelMinErr("$asyncValidators",
"Expected asynchronous validator to return a promise but got '{0}' instead.", result);
}
ctrl.$$setPending(validator, result, modelValue);
});
}
ctrl.$$updateValidModelValue(modelValue);
function validate(validators, callback) {
var status = true;
forEach(validators, function(fn, name) {
var result = fn(modelValue, viewValue);
callback(name, result);
status = status && result;
});
return status;
}
};
this.$$updateValidModelValue = function(modelValue) {
ctrl.$modelValue = ctrl.$valid ? modelValue : undefined;
ctrl.$$invalidModelValue = ctrl.$valid ? undefined : modelValue;
};
@@ -1870,13 +1995,13 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
ctrl.$$invalidModelValue = ctrl.$modelValue = undefined;
ctrl.$$clearValidity();
ctrl.$setValidity(parserName, false);
ctrl.$$writeModelToScope();
} else if (ctrl.$modelValue !== modelValue &&
(isUndefined(ctrl.$$invalidModelValue) || ctrl.$$invalidModelValue != modelValue)) {
ctrl.$setValidity(parserName, true);
ctrl.$$runValidators(modelValue, viewValue);
ctrl.$$writeModelToScope();
}
ctrl.$$writeModelToScope();
};
this.$$writeModelToScope = function() {

View File

@@ -579,6 +579,35 @@ describe('form', function() {
});
});
describe('$pending', function() {
beforeEach(function() {
doc = $compile('<form name="form"></form>')(scope);
scope.$digest();
});
it('should set valid and invalid to undefined when a validation error state is set as pending', inject(function($q, $rootScope) {
var defer, form = doc.data('$formController');
var ctrl = {};
form.$$setPending('matias', ctrl);
expect(form.$valid).toBeUndefined();
expect(form.$invalid).toBeUndefined();
expect(form.$pending.matias).toEqual([ctrl]);
form.$setValidity('matias', true, ctrl);
expect(form.$valid).toBe(true);
expect(form.$invalid).toBe(false);
expect(form.$pending).toBeUndefined();
form.$setValidity('matias', false, ctrl);
expect(form.$valid).toBe(false);
expect(form.$invalid).toBe(true);
expect(form.$pending).toBeUndefined();
}));
});
describe('$setPristine', function() {

View File

@@ -8,6 +8,7 @@ describe('NgModelController', function() {
var attrs = {name: 'testAlias', ngModel: 'value'};
parentFormCtrl = {
$$setPending: jasmine.createSpy('$$setPending'),
$setValidity: jasmine.createSpy('$setValidity'),
$setDirty: jasmine.createSpy('$setDirty'),
$$clearControlValidity: noop
@@ -377,7 +378,7 @@ describe('NgModelController', function() {
});
});
describe('$validators', function() {
describe('validations pipeline', function() {
it('should perform validations when $validate() is called', function() {
ctrl.$validators.uppercase = function(value) {
@@ -504,6 +505,251 @@ describe('NgModelController', function() {
expect(ctrl.$error.tooLong).toBe(true);
expect(ctrl.$error.notNumeric).not.toBe(true);
});
it('should render a validator asynchronously when a promise is returned', inject(function($q) {
var defer;
ctrl.$asyncValidators.promiseValidator = function(value) {
defer = $q.defer();
return defer.promise;
};
scope.$apply('value = ""');
expect(ctrl.$valid).toBeUndefined();
expect(ctrl.$invalid).toBeUndefined();
expect(ctrl.$pending.promiseValidator).toBe(true);
defer.resolve();
scope.$digest();
expect(ctrl.$valid).toBe(true);
expect(ctrl.$invalid).toBe(false);
expect(ctrl.$pending).toBeUndefined();
scope.$apply('value = "123"');
defer.reject();
scope.$digest();
expect(ctrl.$valid).toBe(false);
expect(ctrl.$invalid).toBe(true);
expect(ctrl.$pending).toBeUndefined();
}));
it('should throw an error when a promise is not returned for an asynchronous validator', inject(function($q) {
ctrl.$asyncValidators.async = function(value) {
return true;
};
expect(function() {
scope.$apply('value = "123"');
}).toThrowMinErr("ngModel", "$asyncValidators",
"Expected asynchronous validator to return a promise but got 'true' instead.");
}));
it('should only run the async validators once all the sync validators have passed',
inject(function($q) {
var stages = {};
stages.sync = { status1 : false, status2: false, count : 0 };
ctrl.$validators.syncValidator1 = function(modelValue, viewValue) {
stages.sync.count++;
return stages.sync.status1;
};
ctrl.$validators.syncValidator2 = function(modelValue, viewValue) {
stages.sync.count++;
return stages.sync.status2;
};
stages.async = { defer : null, count : 0 };
ctrl.$asyncValidators.asyncValidator = function(modelValue, viewValue) {
stages.async.defer = $q.defer();
stages.async.count++;
return stages.async.defer.promise;
};
scope.$apply('value = "123"');
expect(ctrl.$valid).toBe(false);
expect(ctrl.$invalid).toBe(true);
expect(stages.sync.count).toBe(2);
expect(stages.async.count).toBe(0);
stages.sync.status1 = true;
scope.$apply('value = "456"');
expect(stages.sync.count).toBe(4);
expect(stages.async.count).toBe(0);
stages.sync.status2 = true;
scope.$apply('value = "789"');
expect(stages.sync.count).toBe(6);
expect(stages.async.count).toBe(1);
stages.async.defer.resolve();
scope.$apply();
expect(ctrl.$valid).toBe(true);
expect(ctrl.$invalid).toBe(false);
}));
it('should ignore expired async validation promises once delivered', inject(function($q) {
var defer, oldDefer, newDefer;
ctrl.$asyncValidators.async = function(value) {
defer = $q.defer();
return defer.promise;
};
scope.$apply('value = ""');
oldDefer = defer;
scope.$apply('value = "123"');
newDefer = defer;
newDefer.reject();
scope.$digest();
oldDefer.resolve();
scope.$digest();
expect(ctrl.$valid).toBe(false);
expect(ctrl.$invalid).toBe(true);
expect(ctrl.$pending).toBeUndefined();
}));
it('should clear and ignore all pending promises when the input values changes', inject(function($q) {
var isPending = false;
ctrl.$validators.sync = function(value) {
isPending = isObject(ctrl.$pending);
return true;
};
var defers = [];
ctrl.$asyncValidators.async = function(value) {
var defer = $q.defer();
defers.push(defer);
return defer.promise;
};
scope.$apply('value = "123"');
expect(isPending).toBe(false);
expect(ctrl.$valid).toBe(undefined);
expect(ctrl.$invalid).toBe(undefined);
expect(defers.length).toBe(1);
expect(isObject(ctrl.$pending)).toBe(true);
scope.$apply('value = "456"');
expect(isPending).toBe(false);
expect(ctrl.$valid).toBe(undefined);
expect(ctrl.$invalid).toBe(undefined);
expect(defers.length).toBe(2);
expect(isObject(ctrl.$pending)).toBe(true);
defers[1].resolve();
scope.$digest();
expect(ctrl.$valid).toBe(true);
expect(ctrl.$invalid).toBe(false);
expect(isObject(ctrl.$pending)).toBe(false);
}));
it('should clear and ignore all pending promises when a parser fails', inject(function($q) {
var failParser = false;
ctrl.$parsers.push(function(value) {
return failParser ? undefined : value;
});
var defer;
ctrl.$asyncValidators.async = function(value) {
defer = $q.defer();
return defer.promise;
};
ctrl.$setViewValue('x..y..z');
expect(ctrl.$valid).toBe(undefined);
expect(ctrl.$invalid).toBe(undefined);
failParser = true;
ctrl.$setViewValue('1..2..3');
expect(ctrl.$valid).toBe(false);
expect(ctrl.$invalid).toBe(true);
expect(isObject(ctrl.$pending)).toBe(false);
defer.resolve();
scope.$digest();
expect(ctrl.$valid).toBe(false);
expect(ctrl.$invalid).toBe(true);
expect(isObject(ctrl.$pending)).toBe(false);
}));
it('should re-evaluate the form validity state once the asynchronous promise has been delivered',
inject(function($compile, $rootScope, $q) {
var element = $compile('<form name="myForm">' +
'<input type="text" name="username" ng-model="username" minlength="10" required />' +
'<input type="number" name="age" ng-model="age" min="10" required />' +
'</form>')($rootScope);
var inputElm = element.find('input');
var formCtrl = $rootScope.myForm;
var usernameCtrl = formCtrl.username;
var ageCtrl = formCtrl.age;
var usernameDefer;
usernameCtrl.$asyncValidators.usernameAvailability = function() {
usernameDefer = $q.defer();
return usernameDefer.promise;
};
$rootScope.$digest();
expect(usernameCtrl.$invalid).toBe(true);
expect(formCtrl.$invalid).toBe(true);
usernameCtrl.$setViewValue('valid-username');
$rootScope.$digest();
expect(formCtrl.$pending.usernameAvailability).toBeTruthy();
expect(usernameCtrl.$invalid).toBe(undefined);
expect(formCtrl.$invalid).toBe(undefined);
usernameDefer.resolve();
$rootScope.$digest();
expect(usernameCtrl.$invalid).toBe(false);
expect(formCtrl.$invalid).toBe(true);
ageCtrl.$setViewValue(22);
$rootScope.$digest();
expect(usernameCtrl.$invalid).toBe(false);
expect(ageCtrl.$invalid).toBe(false);
expect(formCtrl.$invalid).toBe(false);
usernameCtrl.$setViewValue('valid');
$rootScope.$digest();
expect(usernameCtrl.$invalid).toBe(true);
expect(ageCtrl.$invalid).toBe(false);
expect(formCtrl.$invalid).toBe(true);
usernameCtrl.$setViewValue('another-valid-username');
$rootScope.$digest();
usernameDefer.resolve();
$rootScope.$digest();
expect(usernameCtrl.$invalid).toBe(false);
expect(formCtrl.$invalid).toBe(false);
expect(formCtrl.$pending).toBeFalsy();
expect(ageCtrl.$invalid).toBe(false);
dealoc(element);
}));
});
});
@@ -3294,9 +3540,10 @@ describe('NgModel animations', function() {
return animations;
}
function assertValidAnimation(animation, event, className) {
function assertValidAnimation(animation, event, classNameA, classNameB) {
expect(animation.event).toBe(event);
expect(animation.args[1]).toBe(className);
expect(animation.args[1]).toBe(classNameA);
if(classNameB) expect(animation.args[2]).toBe(classNameB);
}
var doc, input, scope, model;