Skip to content

Commit 678764c

Browse files
committed
feat(NgModel): introduce the $validators pipeline
1 parent e4fb535 commit 678764c

File tree

2 files changed

+200
-15
lines changed

2 files changed

+200
-15
lines changed

src/ng/directive/input.js

+51-15
Original file line numberDiff line numberDiff line change
@@ -1435,6 +1435,12 @@ var VALID_CLASS = 'ng-valid',
14351435
* ngModel.$formatters.push(formatter);
14361436
* ```
14371437
*
1438+
* @property {Object.<string, function>} $validators A collection of validators that are applied
1439+
* whenever the model value changes. The key value within the object refers to the name of the
1440+
* validator while the function refers to the validation operation. The validation operation is
1441+
* provided with the model value as an argument and must return a true or false value depending
1442+
* on the response of that validation.
1443+
*
14381444
* @property {Array.<Function>} $viewChangeListeners Array of functions to execute whenever the
14391445
* view value has changed. It is called with no arguments, and its return value is ignored.
14401446
* This can be used in place of additional $watches against the model value.
@@ -1551,6 +1557,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
15511557
function($scope, $exceptionHandler, $attr, $element, $parse, $animate, $timeout) {
15521558
this.$viewValue = Number.NaN;
15531559
this.$modelValue = Number.NaN;
1560+
this.$validators = {};
15541561
this.$parsers = [];
15551562
this.$formatters = [];
15561563
this.$viewChangeListeners = [];
@@ -1626,7 +1633,8 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
16261633
* Change the validity state, and notifies the form when the control changes validity. (i.e. it
16271634
* does not notify form if given validator is already marked as invalid).
16281635
*
1629-
* This method should be called by validators - i.e. the parser or formatter functions.
1636+
* This method can be called within $parsers/$formatters. However, if possible, please use the
1637+
* `ngModel.$validators` pipeline which is designed to handle validations with true/false values.
16301638
*
16311639
* @param {string} validationErrorKey Name of the validator. the `validationErrorKey` will assign
16321640
* to `$error[validationErrorKey]=isValid` so that it is available for data-binding.
@@ -1743,6 +1751,23 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
17431751
ctrl.$render();
17441752
};
17451753

1754+
/**
1755+
* @ngdoc method
1756+
* @name ngModel.NgModelController#$validate
1757+
*
1758+
* @description
1759+
* Runs each of the registered validations set on the $validators object.
1760+
*/
1761+
this.$validate = function() {
1762+
this.$$runValidators(ctrl.$modelValue, ctrl.$viewValue);
1763+
};
1764+
1765+
this.$$runValidators = function(modelValue, viewValue) {
1766+
forEach(ctrl.$validators, function(fn, name) {
1767+
ctrl.$setValidity(name, fn(modelValue, viewValue));
1768+
});
1769+
};
1770+
17461771
/**
17471772
* @ngdoc method
17481773
* @name ngModel.NgModelController#$commitViewValue
@@ -1755,12 +1780,12 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
17551780
* usually handles calling this in response to input events.
17561781
*/
17571782
this.$commitViewValue = function() {
1758-
var value = ctrl.$viewValue;
1783+
var viewValue = ctrl.$viewValue;
17591784
$timeout.cancel(pendingDebounce);
1760-
if (ctrl.$$lastCommittedViewValue === value) {
1785+
if (ctrl.$$lastCommittedViewValue === viewValue) {
17611786
return;
17621787
}
1763-
ctrl.$$lastCommittedViewValue = value;
1788+
ctrl.$$lastCommittedViewValue = viewValue;
17641789

17651790
// change to dirty
17661791
if (ctrl.$pristine) {
@@ -1771,13 +1796,19 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
17711796
parentForm.$setDirty();
17721797
}
17731798

1799+
var modelValue = viewValue;
17741800
forEach(ctrl.$parsers, function(fn) {
1775-
value = fn(value);
1801+
modelValue = fn(modelValue);
17761802
});
17771803

1778-
if (ctrl.$modelValue !== value) {
1779-
ctrl.$modelValue = value;
1780-
ngModelSet($scope, value);
1804+
if (ctrl.$modelValue !== modelValue &&
1805+
(isUndefined(ctrl.$$invalidModelValue) || ctrl.$$invalidModelValue != modelValue)) {
1806+
1807+
ctrl.$$runValidators(modelValue, viewValue);
1808+
ctrl.$modelValue = ctrl.$valid ? modelValue : undefined;
1809+
ctrl.$$invalidModelValue = ctrl.$valid ? undefined : modelValue;
1810+
1811+
ngModelSet($scope, ctrl.$modelValue);
17811812
forEach(ctrl.$viewChangeListeners, function(listener) {
17821813
try {
17831814
listener();
@@ -1851,26 +1882,31 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
18511882

18521883
// model -> value
18531884
$scope.$watch(function ngModelWatch() {
1854-
var value = ngModelGet($scope);
1885+
var modelValue = ngModelGet($scope);
18551886

18561887
// if scope model value and ngModel value are out of sync
1857-
if (ctrl.$modelValue !== value) {
1888+
if (ctrl.$modelValue !== modelValue &&
1889+
(isUndefined(ctrl.$$invalidModelValue) || ctrl.$$invalidModelValue != modelValue)) {
18581890

18591891
var formatters = ctrl.$formatters,
18601892
idx = formatters.length;
18611893

1862-
ctrl.$modelValue = value;
1894+
var viewValue = modelValue;
18631895
while(idx--) {
1864-
value = formatters[idx](value);
1896+
viewValue = formatters[idx](viewValue);
18651897
}
18661898

1867-
if (ctrl.$viewValue !== value) {
1868-
ctrl.$viewValue = ctrl.$$lastCommittedViewValue = value;
1899+
ctrl.$$runValidators(modelValue, viewValue);
1900+
ctrl.$modelValue = ctrl.$valid ? modelValue : undefined;
1901+
ctrl.$$invalidModelValue = ctrl.$valid ? undefined : modelValue;
1902+
1903+
if (ctrl.$viewValue !== viewValue) {
1904+
ctrl.$viewValue = ctrl.$$lastCommittedViewValue = viewValue;
18691905
ctrl.$render();
18701906
}
18711907
}
18721908

1873-
return value;
1909+
return modelValue;
18741910
});
18751911
}];
18761912

test/ng/directive/inputSpec.js

+149
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,155 @@ describe('NgModelController', function() {
261261
expect(ctrl.$render).toHaveBeenCalledOnce();
262262
});
263263
});
264+
265+
describe('$validators', function() {
266+
267+
it('should perform validations when $validate() is called', function() {
268+
ctrl.$validators.uppercase = function(value) {
269+
return (/^[A-Z]+$/).test(value);
270+
};
271+
272+
ctrl.$modelValue = 'test';
273+
ctrl.$validate();
274+
275+
expect(ctrl.$valid).toBe(false);
276+
277+
ctrl.$modelValue = 'TEST';
278+
ctrl.$validate();
279+
280+
expect(ctrl.$valid).toBe(true);
281+
});
282+
283+
it('should perform validations when $validate() is called', function() {
284+
ctrl.$validators.uppercase = function(value) {
285+
return (/^[A-Z]+$/).test(value);
286+
};
287+
288+
ctrl.$modelValue = 'test';
289+
ctrl.$validate();
290+
291+
expect(ctrl.$valid).toBe(false);
292+
293+
ctrl.$modelValue = 'TEST';
294+
ctrl.$validate();
295+
296+
expect(ctrl.$valid).toBe(true);
297+
});
298+
299+
it('should always perform validations using the parsed model value', function() {
300+
var captures;
301+
ctrl.$validators.raw = function() {
302+
captures = arguments;
303+
return captures[0];
304+
};
305+
306+
ctrl.$parsers.push(function(value) {
307+
return value.toUpperCase();
308+
});
309+
310+
ctrl.$setViewValue('my-value');
311+
312+
expect(captures).toEqual(['MY-VALUE', 'my-value']);
313+
});
314+
315+
it('should always perform validations using the formatted view value', function() {
316+
var captures;
317+
ctrl.$validators.raw = function() {
318+
captures = arguments;
319+
return captures[0];
320+
};
321+
322+
ctrl.$formatters.push(function(value) {
323+
return value + '...';
324+
});
325+
326+
scope.$apply(function() {
327+
scope.value = 'matias';
328+
});
329+
330+
expect(captures).toEqual(['matias', 'matias...']);
331+
});
332+
333+
it('should only perform validations if the view value is different', function() {
334+
var count = 0;
335+
ctrl.$validators.countMe = function() {
336+
count++;
337+
};
338+
339+
ctrl.$setViewValue('my-value');
340+
expect(count).toBe(1);
341+
342+
ctrl.$setViewValue('my-value');
343+
expect(count).toBe(1);
344+
345+
ctrl.$setViewValue('your-value');
346+
expect(count).toBe(2);
347+
});
348+
349+
it('should perform validations twice each time the model value changes within a digest', function() {
350+
var count = 0;
351+
ctrl.$validators.number = function(value) {
352+
count++;
353+
return (/^\d+$/).test(value);
354+
};
355+
356+
function val(v) {
357+
scope.$apply(function() {
358+
scope.value = v;
359+
});
360+
}
361+
362+
val('');
363+
expect(count).toBe(1);
364+
365+
val(1);
366+
expect(count).toBe(2);
367+
368+
val(1);
369+
expect(count).toBe(2);
370+
371+
val('');
372+
expect(count).toBe(3);
373+
});
374+
375+
it('should only validate to true if all validations are true', function() {
376+
var curry = function(v) {
377+
return function() {
378+
return v;
379+
};
380+
};
381+
382+
ctrl.$validators.a = curry(true);
383+
ctrl.$validators.b = curry(true);
384+
ctrl.$validators.c = curry(false);
385+
386+
ctrl.$validate();
387+
expect(ctrl.$valid).toBe(false);
388+
389+
ctrl.$validators.c = curry(true);
390+
391+
ctrl.$validate();
392+
expect(ctrl.$valid).toBe(true);
393+
});
394+
395+
it('should register invalid validations on the $error object', function() {
396+
var curry = function(v) {
397+
return function() {
398+
return v;
399+
};
400+
};
401+
402+
ctrl.$validators.unique = curry(false);
403+
ctrl.$validators.tooLong = curry(false);
404+
ctrl.$validators.notNumeric = curry(true);
405+
406+
ctrl.$validate();
407+
408+
expect(ctrl.$error.unique).toBe(true);
409+
expect(ctrl.$error.tooLong).toBe(true);
410+
expect(ctrl.$error.notNumeric).not.toBe(true);
411+
});
412+
});
264413
});
265414

266415
describe('ngModel', function() {

0 commit comments

Comments
 (0)