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

Commit 1bdab6b

Browse files
committed
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.
1 parent 171c1c1 commit 1bdab6b

File tree

4 files changed

+483
-31
lines changed

4 files changed

+483
-31
lines changed

src/ng/directive/form.js

+66-15
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ var nullFormCtrl = {
55
$addControl: noop,
66
$removeControl: noop,
77
$setValidity: noop,
8+
$$setPending: noop,
89
$setDirty: noop,
910
$setPristine: noop,
1011
$setSubmitted: noop,
@@ -54,8 +55,9 @@ function FormController(element, attrs, $scope, $animate) {
5455
var form = this,
5556
parentForm = element.parent().controller('form') || nullFormCtrl,
5657
invalidCount = 0, // used to easily determine if we are valid
57-
errors = form.$error = {},
58-
controls = [];
58+
pendingCount = 0,
59+
controls = [],
60+
errors = form.$error = {};
5961

6062
// init state
6163
form.$name = attrs.name || attrs.ngForm;
@@ -151,9 +153,29 @@ function FormController(element, attrs, $scope, $animate) {
151153
};
152154

153155
form.$$clearControlValidity = function(control) {
154-
forEach(errors, function(queue, validationToken) {
156+
forEach(form.$pending, clear);
157+
forEach(errors, clear);
158+
159+
function clear(queue, validationToken) {
155160
form.$setValidity(validationToken, true, control);
156-
});
161+
}
162+
163+
parentForm.$$clearControlValidity(form);
164+
};
165+
166+
form.$$setPending = function(validationToken, control) {
167+
var pending = form.$pending && form.$pending[validationToken];
168+
169+
if (!pending || !includes(pending, control)) {
170+
pendingCount++;
171+
form.$valid = form.$invalid = undefined;
172+
form.$pending = form.$pending || {};
173+
if (!pending) {
174+
pending = form.$pending[validationToken] = [];
175+
}
176+
pending.push(control);
177+
parentForm.$$setPending(validationToken, form);
178+
}
157179
};
158180

159181
/**
@@ -167,24 +189,56 @@ function FormController(element, attrs, $scope, $animate) {
167189
*/
168190
form.$setValidity = function(validationToken, isValid, control) {
169191
var queue = errors[validationToken];
192+
var pendingChange, pending = form.$pending && form.$pending[validationToken];
193+
194+
if (pending) {
195+
pendingChange = indexOf(pending, control) >= 0;
196+
if (pendingChange) {
197+
arrayRemove(pending, control);
198+
pendingCount--;
199+
200+
if (pending.length === 0) {
201+
delete form.$pending[validationToken];
202+
}
203+
}
204+
}
205+
206+
var pendingNoMore = form.$pending && pendingCount === 0;
207+
if (pendingNoMore) {
208+
form.$pending = undefined;
209+
}
170210

171211
if (isValid) {
172-
if (queue) {
173-
arrayRemove(queue, control);
174-
if (!queue.length) {
175-
invalidCount--;
212+
if (queue || pendingChange) {
213+
if (queue) {
214+
arrayRemove(queue, control);
215+
}
216+
if (!queue || !queue.length) {
217+
if (errors[validationToken]) {
218+
invalidCount--;
219+
}
176220
if (!invalidCount) {
177-
toggleValidCss(isValid);
178-
form.$valid = true;
179-
form.$invalid = false;
221+
if (!form.$pending) {
222+
toggleValidCss(isValid);
223+
form.$valid = true;
224+
form.$invalid = false;
225+
}
226+
} else if(pendingNoMore) {
227+
toggleValidCss(false);
228+
form.$valid = false;
229+
form.$invalid = true;
180230
}
181231
errors[validationToken] = false;
182232
toggleValidCss(true, validationToken);
183233
parentForm.$setValidity(validationToken, true, form);
184234
}
185235
}
186-
187236
} else {
237+
if (!form.$pending) {
238+
form.$valid = false;
239+
form.$invalid = true;
240+
}
241+
188242
if (!invalidCount) {
189243
toggleValidCss(isValid);
190244
}
@@ -197,9 +251,6 @@ function FormController(element, attrs, $scope, $animate) {
197251
parentForm.$setValidity(validationToken, false, form);
198252
}
199253
queue.push(control);
200-
201-
form.$valid = false;
202-
form.$invalid = true;
203254
}
204255
};
205256

src/ng/directive/input.js

+138-13
Original file line numberDiff line numberDiff line change
@@ -1365,7 +1365,8 @@ var VALID_CLASS = 'ng-valid',
13651365
PRISTINE_CLASS = 'ng-pristine',
13661366
DIRTY_CLASS = 'ng-dirty',
13671367
UNTOUCHED_CLASS = 'ng-untouched',
1368-
TOUCHED_CLASS = 'ng-touched';
1368+
TOUCHED_CLASS = 'ng-touched',
1369+
PENDING_CLASS = 'ng-pending';
13691370

13701371
/**
13711372
* @ngdoc type
@@ -1400,6 +1401,44 @@ var VALID_CLASS = 'ng-valid',
14001401
* provided with the model value as an argument and must return a true or false value depending
14011402
* on the response of that validation.
14021403
*
1404+
* ```js
1405+
* ngModel.$validators.validCharacters = function(modelValue, viewValue) {
1406+
* var value = modelValue || viewValue;
1407+
* return /[0-9]+/.test(value) &&
1408+
* /[a-z]+/.test(value) &&
1409+
* /[A-Z]+/.test(value) &&
1410+
* /\W+/.test(value);
1411+
* };
1412+
* ```
1413+
*
1414+
* @property {Object.<string, function>} $asyncValidators A collection of validations that are expected to
1415+
* perform an asynchronous validation (e.g. a HTTP request). The validation function that is provided
1416+
* is expected to return a promise when it is run during the model validation process. Once the promise
1417+
* is delivered then the validation status will be set to true when fulfilled and false when rejected.
1418+
* When the asynchronous validators are trigged, each of the validators will run in parallel and the model
1419+
* value will only be updated once all validators have been fulfilled. Also, keep in mind that all
1420+
* asynchronous validators will only run once all synchronous validators have passed.
1421+
*
1422+
* Please note that if $http is used then it is important that the server returns a success HTTP response code
1423+
* in order to fulfill the validation and a status level of `4xx` in order to reject the validation.
1424+
*
1425+
* ```js
1426+
* ngModel.$asyncValidators.uniqueUsername = function(modelValue, viewValue) {
1427+
* var value = modelValue || viewValue;
1428+
* return $http.get('/api/users/' + value).
1429+
* then(function() {
1430+
* //username exists, this means the validator fails
1431+
* return false;
1432+
* }, function() {
1433+
* //username does not exist, therefore this validation is true
1434+
* return true;
1435+
* });
1436+
* };
1437+
* ```
1438+
*
1439+
* @param {string} name The name of the validator.
1440+
* @param {Function} validationFn The validation function that will be run.
1441+
*
14031442
* @property {Array.<Function>} $viewChangeListeners Array of functions to execute whenever the
14041443
* view value has changed. It is called with no arguments, and its return value is ignored.
14051444
* This can be used in place of additional $watches against the model value.
@@ -1412,6 +1451,7 @@ var VALID_CLASS = 'ng-valid',
14121451
* @property {boolean} $dirty True if user has already interacted with the control.
14131452
* @property {boolean} $valid True if there is no error.
14141453
* @property {boolean} $invalid True if at least one error on the control.
1454+
* @property {Object.<string, boolean>} $pending True if one or more asynchronous validators is still yet to be delivered.
14151455
*
14161456
* @description
14171457
*
@@ -1519,6 +1559,8 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
15191559
this.$viewValue = Number.NaN;
15201560
this.$modelValue = Number.NaN;
15211561
this.$validators = {};
1562+
this.$asyncValidators = {};
1563+
this.$validators = {};
15221564
this.$parsers = [];
15231565
this.$formatters = [];
15241566
this.$viewChangeListeners = [];
@@ -1586,6 +1628,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
15861628

15871629
var parentForm = $element.inheritedData('$formController') || nullFormCtrl,
15881630
invalidCount = 0, // used to easily determine if we are valid
1631+
pendingCount = 0, // used to easily determine if there are any pending validations
15891632
$error = this.$error = {}; // keep invalid keys here
15901633

15911634

@@ -1603,18 +1646,67 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
16031646
}
16041647

16051648
this.$$clearValidity = function() {
1649+
$animate.removeClass($element, PENDING_CLASS);
16061650
forEach(ctrl.$error, function(val, key) {
16071651
var validationKey = snake_case(key, '-');
16081652
$animate.removeClass($element, VALID_CLASS + validationKey);
16091653
$animate.removeClass($element, INVALID_CLASS + validationKey);
16101654
});
16111655

1656+
// just incase an asnyc validator is still running while
1657+
// the parser fails
1658+
if(ctrl.$pending) {
1659+
ctrl.$$clearPending();
1660+
}
1661+
16121662
invalidCount = 0;
16131663
$error = ctrl.$error = {};
16141664

16151665
parentForm.$$clearControlValidity(ctrl);
16161666
};
16171667

1668+
this.$$clearPending = function() {
1669+
pendingCount = 0;
1670+
ctrl.$pending = undefined;
1671+
$animate.removeClass($element, PENDING_CLASS);
1672+
};
1673+
1674+
this.$$setPending = function(validationErrorKey, promise, currentValue) {
1675+
ctrl.$pending = ctrl.$pending || {};
1676+
if (angular.isUndefined(ctrl.$pending[validationErrorKey])) {
1677+
ctrl.$pending[validationErrorKey] = true;
1678+
pendingCount++;
1679+
}
1680+
1681+
ctrl.$valid = ctrl.$invalid = undefined;
1682+
parentForm.$$setPending(validationErrorKey, ctrl);
1683+
1684+
$animate.addClass($element, PENDING_CLASS);
1685+
$animate.removeClass($element, INVALID_CLASS);
1686+
$animate.removeClass($element, VALID_CLASS);
1687+
1688+
//Special-case for (undefined|null|false|NaN) values to avoid
1689+
//having to compare each of them with each other
1690+
currentValue = currentValue || '';
1691+
promise.then(resolve(true), resolve(false));
1692+
1693+
function resolve(bool) {
1694+
return function() {
1695+
var value = ctrl.$viewValue || '';
1696+
if (ctrl.$pending && ctrl.$pending[validationErrorKey] && currentValue === value) {
1697+
pendingCount--;
1698+
delete ctrl.$pending[validationErrorKey];
1699+
ctrl.$setValidity(validationErrorKey, bool);
1700+
if (pendingCount === 0) {
1701+
ctrl.$$clearPending();
1702+
ctrl.$$updateValidModelValue(value);
1703+
ctrl.$$writeModelToScope();
1704+
}
1705+
}
1706+
};
1707+
}
1708+
};
1709+
16181710
/**
16191711
* @ngdoc method
16201712
* @name ngModel.NgModelController#$setValidity
@@ -1634,28 +1726,30 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
16341726
* @param {boolean} isValid Whether the current state is valid (true) or invalid (false).
16351727
*/
16361728
this.$setValidity = function(validationErrorKey, isValid) {
1637-
// Purposeful use of ! here to cast isValid to boolean in case it is undefined
1729+
1730+
// avoid doing anything if the validation value has not changed
16381731
// jshint -W018
1639-
if ($error[validationErrorKey] === !isValid) return;
1732+
if (!ctrl.$pending && $error[validationErrorKey] === !isValid) return;
16401733
// jshint +W018
16411734

16421735
if (isValid) {
16431736
if ($error[validationErrorKey]) invalidCount--;
1644-
if (!invalidCount) {
1737+
if (!invalidCount && !pendingCount) {
16451738
toggleValidCss(true);
16461739
ctrl.$valid = true;
16471740
ctrl.$invalid = false;
16481741
}
16491742
} else if(!$error[validationErrorKey]) {
1650-
toggleValidCss(false);
1651-
ctrl.$invalid = true;
1652-
ctrl.$valid = false;
16531743
invalidCount++;
1744+
if (!pendingCount) {
1745+
toggleValidCss(false);
1746+
ctrl.$invalid = true;
1747+
ctrl.$valid = false;
1748+
}
16541749
}
16551750

16561751
$error[validationErrorKey] = !isValid;
16571752
toggleValidCss(isValid, validationErrorKey);
1658-
16591753
parentForm.$setValidity(validationErrorKey, isValid, ctrl);
16601754
};
16611755

@@ -1783,7 +1877,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
17831877
* @name ngModel.NgModelController#$validate
17841878
*
17851879
* @description
1786-
* Runs each of the registered validations set on the $validators object.
1880+
* Runs each of the registered validators (first synchronous validators and then asynchronous validators).
17871881
*/
17881882
this.$validate = function() {
17891883
// ignore $validate before model initialized
@@ -1799,9 +1893,40 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
17991893
};
18001894

18011895
this.$$runValidators = function(modelValue, viewValue) {
1802-
forEach(ctrl.$validators, function(fn, name) {
1803-
ctrl.$setValidity(name, fn(modelValue, viewValue));
1896+
// this is called in the event if incase the input value changes
1897+
// while a former asynchronous validator is still doing its thing
1898+
if(ctrl.$pending) {
1899+
ctrl.$$clearPending();
1900+
}
1901+
1902+
var continueValidation = validate(ctrl.$validators, function(validator, result) {
1903+
ctrl.$setValidity(validator, result);
18041904
});
1905+
1906+
if (continueValidation) {
1907+
validate(ctrl.$asyncValidators, function(validator, result) {
1908+
if (!isPromiseLike(result)) {
1909+
throw $ngModelMinErr("$asyncValidators",
1910+
"Expected asynchronous validator to return a promise but got '{0}' instead.", result);
1911+
}
1912+
ctrl.$$setPending(validator, result, modelValue);
1913+
});
1914+
}
1915+
1916+
ctrl.$$updateValidModelValue(modelValue);
1917+
1918+
function validate(validators, callback) {
1919+
var status = true;
1920+
forEach(validators, function(fn, name) {
1921+
var result = fn(modelValue, viewValue);
1922+
callback(name, result);
1923+
status = status && result;
1924+
});
1925+
return status;
1926+
}
1927+
};
1928+
1929+
this.$$updateValidModelValue = function(modelValue) {
18051930
ctrl.$modelValue = ctrl.$valid ? modelValue : undefined;
18061931
ctrl.$$invalidModelValue = ctrl.$valid ? undefined : modelValue;
18071932
};
@@ -1849,13 +1974,13 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
18491974
ctrl.$$invalidModelValue = ctrl.$modelValue = undefined;
18501975
ctrl.$$clearValidity();
18511976
ctrl.$setValidity(parserName, false);
1977+
ctrl.$$writeModelToScope();
18521978
} else if (ctrl.$modelValue !== modelValue &&
18531979
(isUndefined(ctrl.$$invalidModelValue) || ctrl.$$invalidModelValue != modelValue)) {
18541980
ctrl.$setValidity(parserName, true);
18551981
ctrl.$$runValidators(modelValue, viewValue);
1982+
ctrl.$$writeModelToScope();
18561983
}
1857-
1858-
ctrl.$$writeModelToScope();
18591984
};
18601985

18611986
this.$$writeModelToScope = function() {

0 commit comments

Comments
 (0)