Skip to content

Commit 99fe610

Browse files
committed
feat(NgModel): introduce the $validators pipeline
1 parent 3fc95e0 commit 99fe610

File tree

2 files changed

+201
-15
lines changed

2 files changed

+201
-15
lines changed

src/ng/directive/input.js

+52-15
Original file line numberDiff line numberDiff line change
@@ -1439,6 +1439,12 @@ var VALID_CLASS = 'ng-valid',
14391439
* ngModel.$formatters.push(formatter);
14401440
* ```
14411441
*
1442+
* @property {Object.<string, function>} $validators A collection of validators that are applied
1443+
* whenever the model value changes. The key value within the object refers to the name of the
1444+
* validator while the function refers to the validation operation. The validation operation is
1445+
* provided with the model value as an argument and must return a true or false value depending
1446+
* on the response of that validation.
1447+
*
14421448
* @property {Array.<Function>} $viewChangeListeners Array of functions to execute whenever the
14431449
* view value has changed. It is called with no arguments, and its return value is ignored.
14441450
* This can be used in place of additional $watches against the model value.
@@ -1555,6 +1561,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
15551561
function($scope, $exceptionHandler, $attr, $element, $parse, $animate, $timeout) {
15561562
this.$viewValue = Number.NaN;
15571563
this.$modelValue = Number.NaN;
1564+
this.$validators = {};
15581565
this.$parsers = [];
15591566
this.$formatters = [];
15601567
this.$viewChangeListeners = [];
@@ -1630,7 +1637,8 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
16301637
* Change the validity state, and notifies the form when the control changes validity. (i.e. it
16311638
* does not notify form if given validator is already marked as invalid).
16321639
*
1633-
* This method should be called by validators - i.e. the parser or formatter functions.
1640+
* This method can be called within $parsers/$formatters. However, if possible, please use the
1641+
* `ngModel.$validators` pipeline which is designed to handle validations with true/false values.
16341642
*
16351643
* @param {string} validationErrorKey Name of the validator. the `validationErrorKey` will assign
16361644
* to `$error[validationErrorKey]=isValid` so that it is available for data-binding.
@@ -1747,6 +1755,24 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
17471755
ctrl.$render();
17481756
};
17491757

1758+
/**
1759+
* @ngdoc method
1760+
* @name ngModel.NgModelController#$validate
1761+
*
1762+
* @description
1763+
* Runs each of the registered validations set on the $validators object.
1764+
*/
1765+
this.$validate = function(modelValue, viewValue) {
1766+
if(arguments.length === 0) {
1767+
modelValue = ctrl.$modelValue;
1768+
viewValue = ctrl.$viewValue;
1769+
}
1770+
1771+
forEach(ctrl.$validators, function(fn, name) {
1772+
ctrl.$setValidity(name, fn(modelValue, viewValue));
1773+
});
1774+
};
1775+
17501776
/**
17511777
* @ngdoc method
17521778
* @name ngModel.NgModelController#$commitViewValue
@@ -1759,12 +1785,12 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
17591785
* usually handles calling this in response to input events.
17601786
*/
17611787
this.$commitViewValue = function() {
1762-
var value = ctrl.$viewValue;
1788+
var viewValue = ctrl.$viewValue;
17631789
$timeout.cancel(pendingDebounce);
1764-
if (ctrl.$$lastCommittedViewValue === value) {
1790+
if (ctrl.$$lastCommittedViewValue === viewValue) {
17651791
return;
17661792
}
1767-
ctrl.$$lastCommittedViewValue = value;
1793+
ctrl.$$lastCommittedViewValue = viewValue;
17681794

17691795
// change to dirty
17701796
if (ctrl.$pristine) {
@@ -1775,13 +1801,19 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
17751801
parentForm.$setDirty();
17761802
}
17771803

1804+
var modelValue = viewValue;
17781805
forEach(ctrl.$parsers, function(fn) {
1779-
value = fn(value);
1806+
modelValue = fn(modelValue);
17801807
});
17811808

1782-
if (ctrl.$modelValue !== value) {
1783-
ctrl.$modelValue = value;
1784-
ngModelSet($scope, value);
1809+
if (ctrl.$modelValue !== modelValue &&
1810+
(isUndefined(ctrl.$$invalidModelValue) || ctrl.$$invalidModelValue != modelValue)) {
1811+
1812+
ctrl.$validate(modelValue, viewValue);
1813+
ctrl.$modelValue = ctrl.$valid ? modelValue : undefined;
1814+
ctrl.$$invalidModelValue = ctrl.$valid ? undefined : modelValue;
1815+
1816+
ngModelSet($scope, ctrl.$modelValue);
17851817
forEach(ctrl.$viewChangeListeners, function(listener) {
17861818
try {
17871819
listener();
@@ -1855,26 +1887,31 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
18551887

18561888
// model -> value
18571889
$scope.$watch(function ngModelWatch() {
1858-
var value = ngModelGet($scope);
1890+
var modelValue = ngModelGet($scope);
18591891

18601892
// if scope model value and ngModel value are out of sync
1861-
if (ctrl.$modelValue !== value) {
1893+
if (ctrl.$modelValue !== modelValue &&
1894+
(isUndefined(ctrl.$$invalidModelValue) || ctrl.$$invalidModelValue != modelValue)) {
18621895

18631896
var formatters = ctrl.$formatters,
18641897
idx = formatters.length;
18651898

1866-
ctrl.$modelValue = value;
1899+
var viewValue = modelValue;
18671900
while(idx--) {
1868-
value = formatters[idx](value);
1901+
viewValue = formatters[idx](viewValue);
18691902
}
18701903

1871-
if (ctrl.$viewValue !== value) {
1872-
ctrl.$viewValue = ctrl.$$lastCommittedViewValue = value;
1904+
ctrl.$validate(modelValue, viewValue);
1905+
ctrl.$modelValue = ctrl.$valid ? modelValue : undefined;
1906+
ctrl.$$invalidModelValue = ctrl.$valid ? undefined : modelValue;
1907+
1908+
if (ctrl.$viewValue !== viewValue) {
1909+
ctrl.$viewValue = ctrl.$$lastCommittedViewValue = viewValue;
18731910
ctrl.$render();
18741911
}
18751912
}
18761913

1877-
return value;
1914+
return modelValue;
18781915
});
18791916
}];
18801917

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)