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

Commit 2ae4f40

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 db044c4 commit 2ae4f40

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
@@ -1386,7 +1386,8 @@ var VALID_CLASS = 'ng-valid',
13861386
PRISTINE_CLASS = 'ng-pristine',
13871387
DIRTY_CLASS = 'ng-dirty',
13881388
UNTOUCHED_CLASS = 'ng-untouched',
1389-
TOUCHED_CLASS = 'ng-touched';
1389+
TOUCHED_CLASS = 'ng-touched',
1390+
PENDING_CLASS = 'ng-pending';
13901391

13911392
/**
13921393
* @ngdoc type
@@ -1421,6 +1422,44 @@ var VALID_CLASS = 'ng-valid',
14211422
* provided with the model value as an argument and must return a true or false value depending
14221423
* on the response of that validation.
14231424
*
1425+
* ```js
1426+
* ngModel.$validators.validCharacters = function(modelValue, viewValue) {
1427+
* var value = modelValue || viewValue;
1428+
* return /[0-9]+/.test(value) &&
1429+
* /[a-z]+/.test(value) &&
1430+
* /[A-Z]+/.test(value) &&
1431+
* /\W+/.test(value);
1432+
* };
1433+
* ```
1434+
*
1435+
* @property {Object.<string, function>} $asyncValidators A collection of validations that are expected to
1436+
* perform an asynchronous validation (e.g. a HTTP request). The validation function that is provided
1437+
* is expected to return a promise when it is run during the model validation process. Once the promise
1438+
* is delivered then the validation status will be set to true when fulfilled and false when rejected.
1439+
* When the asynchronous validators are trigged, each of the validators will run in parallel and the model
1440+
* value will only be updated once all validators have been fulfilled. Also, keep in mind that all
1441+
* asynchronous validators will only run once all synchronous validators have passed.
1442+
*
1443+
* Please note that if $http is used then it is important that the server returns a success HTTP response code
1444+
* in order to fulfill the validation and a status level of `4xx` in order to reject the validation.
1445+
*
1446+
* ```js
1447+
* ngModel.$asyncValidators.uniqueUsername = function(modelValue, viewValue) {
1448+
* var value = modelValue || viewValue;
1449+
* return $http.get('/api/users/' + value).
1450+
* then(function() {
1451+
* //username exists, this means the validator fails
1452+
* return false;
1453+
* }, function() {
1454+
* //username does not exist, therefore this validation is true
1455+
* return true;
1456+
* });
1457+
* };
1458+
* ```
1459+
*
1460+
* @param {string} name The name of the validator.
1461+
* @param {Function} validationFn The validation function that will be run.
1462+
*
14241463
* @property {Array.<Function>} $viewChangeListeners Array of functions to execute whenever the
14251464
* view value has changed. It is called with no arguments, and its return value is ignored.
14261465
* This can be used in place of additional $watches against the model value.
@@ -1433,6 +1472,7 @@ var VALID_CLASS = 'ng-valid',
14331472
* @property {boolean} $dirty True if user has already interacted with the control.
14341473
* @property {boolean} $valid True if there is no error.
14351474
* @property {boolean} $invalid True if at least one error on the control.
1475+
* @property {Object.<string, boolean>} $pending True if one or more asynchronous validators is still yet to be delivered.
14361476
*
14371477
* @description
14381478
*
@@ -1540,6 +1580,8 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
15401580
this.$viewValue = Number.NaN;
15411581
this.$modelValue = Number.NaN;
15421582
this.$validators = {};
1583+
this.$asyncValidators = {};
1584+
this.$validators = {};
15431585
this.$parsers = [];
15441586
this.$formatters = [];
15451587
this.$viewChangeListeners = [];
@@ -1607,6 +1649,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
16071649

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

16121655

@@ -1624,18 +1667,67 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
16241667
}
16251668

16261669
this.$$clearValidity = function() {
1670+
$animate.removeClass($element, PENDING_CLASS);
16271671
forEach(ctrl.$error, function(val, key) {
16281672
var validationKey = snake_case(key, '-');
16291673
$animate.removeClass($element, VALID_CLASS + validationKey);
16301674
$animate.removeClass($element, INVALID_CLASS + validationKey);
16311675
});
16321676

1677+
// just incase an asnyc validator is still running while
1678+
// the parser fails
1679+
if(ctrl.$pending) {
1680+
ctrl.$$clearPending();
1681+
}
1682+
16331683
invalidCount = 0;
16341684
$error = ctrl.$error = {};
16351685

16361686
parentForm.$$clearControlValidity(ctrl);
16371687
};
16381688

1689+
this.$$clearPending = function() {
1690+
pendingCount = 0;
1691+
ctrl.$pending = undefined;
1692+
$animate.removeClass($element, PENDING_CLASS);
1693+
};
1694+
1695+
this.$$setPending = function(validationErrorKey, promise, currentValue) {
1696+
ctrl.$pending = ctrl.$pending || {};
1697+
if (angular.isUndefined(ctrl.$pending[validationErrorKey])) {
1698+
ctrl.$pending[validationErrorKey] = true;
1699+
pendingCount++;
1700+
}
1701+
1702+
ctrl.$valid = ctrl.$invalid = undefined;
1703+
parentForm.$$setPending(validationErrorKey, ctrl);
1704+
1705+
$animate.addClass($element, PENDING_CLASS);
1706+
$animate.removeClass($element, INVALID_CLASS);
1707+
$animate.removeClass($element, VALID_CLASS);
1708+
1709+
//Special-case for (undefined|null|false|NaN) values to avoid
1710+
//having to compare each of them with each other
1711+
currentValue = currentValue || '';
1712+
promise.then(resolve(true), resolve(false));
1713+
1714+
function resolve(bool) {
1715+
return function() {
1716+
var value = ctrl.$viewValue || '';
1717+
if (ctrl.$pending && ctrl.$pending[validationErrorKey] && currentValue === value) {
1718+
pendingCount--;
1719+
delete ctrl.$pending[validationErrorKey];
1720+
ctrl.$setValidity(validationErrorKey, bool);
1721+
if (pendingCount === 0) {
1722+
ctrl.$$clearPending();
1723+
ctrl.$$updateValidModelValue(value);
1724+
ctrl.$$writeModelToScope();
1725+
}
1726+
}
1727+
};
1728+
}
1729+
};
1730+
16391731
/**
16401732
* @ngdoc method
16411733
* @name ngModel.NgModelController#$setValidity
@@ -1655,28 +1747,30 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
16551747
* @param {boolean} isValid Whether the current state is valid (true) or invalid (false).
16561748
*/
16571749
this.$setValidity = function(validationErrorKey, isValid) {
1658-
// Purposeful use of ! here to cast isValid to boolean in case it is undefined
1750+
1751+
// avoid doing anything if the validation value has not changed
16591752
// jshint -W018
1660-
if ($error[validationErrorKey] === !isValid) return;
1753+
if (!ctrl.$pending && $error[validationErrorKey] === !isValid) return;
16611754
// jshint +W018
16621755

16631756
if (isValid) {
16641757
if ($error[validationErrorKey]) invalidCount--;
1665-
if (!invalidCount) {
1758+
if (!invalidCount && !pendingCount) {
16661759
toggleValidCss(true);
16671760
ctrl.$valid = true;
16681761
ctrl.$invalid = false;
16691762
}
16701763
} else if(!$error[validationErrorKey]) {
1671-
toggleValidCss(false);
1672-
ctrl.$invalid = true;
1673-
ctrl.$valid = false;
16741764
invalidCount++;
1765+
if (!pendingCount) {
1766+
toggleValidCss(false);
1767+
ctrl.$invalid = true;
1768+
ctrl.$valid = false;
1769+
}
16751770
}
16761771

16771772
$error[validationErrorKey] = !isValid;
16781773
toggleValidCss(isValid, validationErrorKey);
1679-
16801774
parentForm.$setValidity(validationErrorKey, isValid, ctrl);
16811775
};
16821776

@@ -1804,7 +1898,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
18041898
* @name ngModel.NgModelController#$validate
18051899
*
18061900
* @description
1807-
* Runs each of the registered validations set on the $validators object.
1901+
* Runs each of the registered validators (first synchronous validators and then asynchronous validators).
18081902
*/
18091903
this.$validate = function() {
18101904
// ignore $validate before model initialized
@@ -1820,9 +1914,40 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
18201914
};
18211915

18221916
this.$$runValidators = function(modelValue, viewValue) {
1823-
forEach(ctrl.$validators, function(fn, name) {
1824-
ctrl.$setValidity(name, fn(modelValue, viewValue));
1917+
// this is called in the event if incase the input value changes
1918+
// while a former asynchronous validator is still doing its thing
1919+
if(ctrl.$pending) {
1920+
ctrl.$$clearPending();
1921+
}
1922+
1923+
var continueValidation = validate(ctrl.$validators, function(validator, result) {
1924+
ctrl.$setValidity(validator, result);
18251925
});
1926+
1927+
if (continueValidation) {
1928+
validate(ctrl.$asyncValidators, function(validator, result) {
1929+
if (!isPromiseLike(result)) {
1930+
throw $ngModelMinErr("$asyncValidators",
1931+
"Expected asynchronous validator to return a promise but got '{0}' instead.", result);
1932+
}
1933+
ctrl.$$setPending(validator, result, modelValue);
1934+
});
1935+
}
1936+
1937+
ctrl.$$updateValidModelValue(modelValue);
1938+
1939+
function validate(validators, callback) {
1940+
var status = true;
1941+
forEach(validators, function(fn, name) {
1942+
var result = fn(modelValue, viewValue);
1943+
callback(name, result);
1944+
status = status && result;
1945+
});
1946+
return status;
1947+
}
1948+
};
1949+
1950+
this.$$updateValidModelValue = function(modelValue) {
18261951
ctrl.$modelValue = ctrl.$valid ? modelValue : undefined;
18271952
ctrl.$$invalidModelValue = ctrl.$valid ? undefined : modelValue;
18281953
};
@@ -1870,13 +1995,13 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
18701995
ctrl.$$invalidModelValue = ctrl.$modelValue = undefined;
18711996
ctrl.$$clearValidity();
18721997
ctrl.$setValidity(parserName, false);
1998+
ctrl.$$writeModelToScope();
18731999
} else if (ctrl.$modelValue !== modelValue &&
18742000
(isUndefined(ctrl.$$invalidModelValue) || ctrl.$$invalidModelValue != modelValue)) {
18752001
ctrl.$setValidity(parserName, true);
18762002
ctrl.$$runValidators(modelValue, viewValue);
2003+
ctrl.$$writeModelToScope();
18772004
}
1878-
1879-
ctrl.$$writeModelToScope();
18802005
};
18812006

18822007
this.$$writeModelToScope = function() {

0 commit comments

Comments
 (0)