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 db044c4 commit 2ae4f40
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 @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
*
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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


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

Expand Down Expand Up @@ -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
Expand All @@ -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;
};
Expand Down Expand Up @@ -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() {
Expand Down
Loading

7 comments on commit 2ae4f40

@twhitbeck
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this api is finalized, but I'm just wondering: why does the promise need to be rejected to indicate invalidity? In the $validators pipeline, we return true for valid and false for invalid. Couldn't a resolved promise which returns a falsy value be used to indicate invalidity?

Say I have a service which calls out to the backend to determine whether a username is available. Rejection of that promise would indicate an error during the check. In that case, the field could be marked invalid. But in the case that the promise resolves, it is resolved with true if the username is available and false if the username is unavailable. The field should be marked as invalid if it is resolved with a falsy value. I know I could create a separate promise with $q and reject if the check resolves with false, but that seems like the long way around.

@shahata
Copy link
Contributor

@shahata shahata commented on 2ae4f40 Sep 3, 2014

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you don't need to create a separate promise, just do something like:

return $http.get(...).then(function (response) {
  return response.data.isValid ? response : $q.reject(response);
});

@matsko
Copy link
Contributor Author

@matsko matsko commented on 2ae4f40 Sep 3, 2014

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly. There can be hundreds of fasly HTTP success responses. This is the most straightforward way to ensure the validators pass. You can also setup an interceptor incase you have a consistent response on your API.

@twhitbeck
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

beautiful, what I was missing was $q.reject. I hadn't made use of that. In fact, my understanding of promise chains was just rocked. I made the incorrect assumption that as soon as a promise was rejected, dependant promises were also rejected. That's only the case if $q.reject is returned from the rejection. woah! ty

@matsko
Copy link
Contributor Author

@matsko matsko commented on 2ae4f40 Sep 3, 2014

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, @twhitbeck, as soon as you provide a fallback handler for then (the 2nd function) then it saves the promise (recovers it). You will also need to provide another $q.reject() to make it continue to reject from there on.

@twhitbeck
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, thank you @matsko and @shahata for helping me understand this api and the nature of promises

@matsko
Copy link
Contributor Author

@matsko matsko commented on 2ae4f40 Sep 3, 2014

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to help :) Also @twhitbeck have a look at $q.all().

Please sign in to comment.