Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

Commit

Permalink
feat(ngModel): provide validation API functions for sync and async va…
Browse files Browse the repository at this point in the history
…lidations

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.
  • Loading branch information
matsko committed Aug 26, 2014
1 parent 171c1c1 commit 1bdab6b
Show file tree
Hide file tree
Showing 4 changed files with 483 additions and 31 deletions.
81 changes: 66 additions & 15 deletions src/ng/directive/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ var nullFormCtrl = {
$addControl: noop,
$removeControl: noop,
$setValidity: noop,
$$setPending: noop,
$setDirty: noop,
$setPristine: noop,
$setSubmitted: noop,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
};

/**
Expand All @@ -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);
}
Expand All @@ -197,9 +251,6 @@ function FormController(element, attrs, $scope, $animate) {
parentForm.$setValidity(validationToken, false, form);
}
queue.push(control);

form.$valid = false;
form.$invalid = true;
}
};

Expand Down
151 changes: 138 additions & 13 deletions src/ng/directive/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -1365,7 +1365,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
Expand Down Expand Up @@ -1400,6 +1401,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.
Expand All @@ -1412,6 +1451,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
*
Expand Down Expand Up @@ -1519,6 +1559,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 = [];
Expand Down Expand Up @@ -1586,6 +1628,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


Expand All @@ -1603,18 +1646,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
Expand All @@ -1634,28 +1726,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);
};

Expand Down Expand Up @@ -1783,7 +1877,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
Expand All @@ -1799,9 +1893,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;
};
Expand Down Expand Up @@ -1849,13 +1974,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() {
Expand Down
Loading

0 comments on commit 1bdab6b

Please sign in to comment.