diff --git a/src/.jshintrc b/src/.jshintrc
index fc37b31ec226..1227ec50b7c7 100644
--- a/src/.jshintrc
+++ b/src/.jshintrc
@@ -34,7 +34,6 @@
"nodeName_": false,
"uid": false,
- "REGEX_STRING_REGEXP" : false,
"lowercase": false,
"uppercase": false,
"manualLowercase": false,
diff --git a/src/Angular.js b/src/Angular.js
index 725164808d9a..a81fe1c9aca7 100644
--- a/src/Angular.js
+++ b/src/Angular.js
@@ -13,7 +13,6 @@
-angularModule,
-nodeName_,
-uid,
- -REGEX_STRING_REGEXP,
-lowercase,
-uppercase,
@@ -103,8 +102,6 @@
*
*/
-var REGEX_STRING_REGEXP = /^\/(.+)\/([a-z]*)$/;
-
/**
* @ngdoc function
* @name angular.lowercase
diff --git a/src/AngularPublic.js b/src/AngularPublic.js
index e97723ef946d..4f2474fba9f7 100644
--- a/src/AngularPublic.js
+++ b/src/AngularPublic.js
@@ -43,8 +43,14 @@
ngModelDirective,
ngListDirective,
ngChangeDirective,
+ patternDirective,
+ patternDirective,
requiredDirective,
requiredDirective,
+ minlengthDirective,
+ minlengthDirective,
+ maxlengthDirective,
+ maxlengthDirective,
ngValueDirective,
ngModelOptionsDirective,
ngAttributeAliasDirectives,
@@ -182,8 +188,14 @@ function publishExternalAPI(angular){
ngModel: ngModelDirective,
ngList: ngListDirective,
ngChange: ngChangeDirective,
+ pattern: patternDirective,
+ ngPattern: patternDirective,
required: requiredDirective,
ngRequired: requiredDirective,
+ minlength: minlengthDirective,
+ ngMinlength: minlengthDirective,
+ maxlength: maxlengthDirective,
+ ngMaxlength: maxlengthDirective,
ngValue: ngValueDirective,
ngModelOptions: ngModelOptionsDirective
}).
diff --git a/src/ng/directive/attrs.js b/src/ng/directive/attrs.js
index 9fe40d34b6ff..0ea1e200c81c 100644
--- a/src/ng/directive/attrs.js
+++ b/src/ng/directive/attrs.js
@@ -370,7 +370,7 @@ forEach(ALIASED_ATTR, function(htmlAttr, ngAttr) {
//special case ngPattern when a literal regular expression value
//is used as the expression (this way we don't have to watch anything).
if (ngAttr === "ngPattern" && attr.ngPattern.charAt(0) == "/") {
- var match = attr.ngPattern.match(REGEX_STRING_REGEXP);
+ var match = attr.ngPattern.match(/^\/(.+)\/([a-z]*)$/);
if (match) {
attr.$set("ngPattern", new RegExp(match[1], match[2]));
return;
diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js
index 554e930e5dbf..39c0b7733aea 100644
--- a/src/ng/directive/input.js
+++ b/src/ng/directive/input.js
@@ -973,56 +973,6 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) {
ctrl.$render = function() {
element.val(ctrl.$isEmpty(ctrl.$viewValue) ? '' : ctrl.$viewValue);
};
-
- // pattern validator
- if (attr.ngPattern) {
- var regexp, patternExp = attr.ngPattern;
- attr.$observe('pattern', function(regex) {
- if(isString(regex)) {
- var match = regex.match(REGEX_STRING_REGEXP);
- if(match) {
- regex = new RegExp(match[1], match[2]);
- }
- }
-
- if (regex && !regex.test) {
- throw minErr('ngPattern')('noregexp',
- 'Expected {0} to be a RegExp but was {1}. Element: {2}', patternExp,
- regex, startingTag(element));
- }
-
- regexp = regex || undefined;
- });
-
- var patternValidator = function(value) {
- return validate(ctrl, 'pattern', ctrl.$isEmpty(value) || isUndefined(regexp) || regexp.test(value), value);
- };
-
- ctrl.$formatters.push(patternValidator);
- ctrl.$parsers.push(patternValidator);
- }
-
- // min length validator
- if (attr.ngMinlength) {
- var minlength = int(attr.ngMinlength);
- var minLengthValidator = function(value) {
- return validate(ctrl, 'minlength', ctrl.$isEmpty(value) || value.length >= minlength, value);
- };
-
- ctrl.$parsers.push(minLengthValidator);
- ctrl.$formatters.push(minLengthValidator);
- }
-
- // max length validator
- if (attr.ngMaxlength) {
- var maxlength = int(attr.ngMaxlength);
- var maxLengthValidator = function(value) {
- return validate(ctrl, 'maxlength', ctrl.$isEmpty(value) || value.length <= maxlength, value);
- };
-
- ctrl.$parsers.push(maxLengthValidator);
- ctrl.$formatters.push(maxLengthValidator);
- }
}
function weekParser(isoWeek) {
@@ -1435,6 +1385,12 @@ var VALID_CLASS = 'ng-valid',
* ngModel.$formatters.push(formatter);
* ```
*
+ * @property {Object.} $validators A collection of validators that are applied
+ * whenever the model value changes. The key value within the object refers to the name of the
+ * validator while the function refers to the validation operation. The validation operation is
+ * provided with the model value as an argument and must return a true or false value depending
+ * on the response of that validation.
+ *
* @property {Array.} $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.
@@ -1551,6 +1507,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
function($scope, $exceptionHandler, $attr, $element, $parse, $animate, $timeout) {
this.$viewValue = Number.NaN;
this.$modelValue = Number.NaN;
+ this.$validators = {};
this.$parsers = [];
this.$formatters = [];
this.$viewChangeListeners = [];
@@ -1626,7 +1583,8 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
* Change the validity state, and notifies the form when the control changes validity. (i.e. it
* does not notify form if given validator is already marked as invalid).
*
- * This method should be called by validators - i.e. the parser or formatter functions.
+ * This method can be called within $parsers/$formatters. However, if possible, please use the
+ * `ngModel.$validators` pipeline which is designed to handle validations with true/false values.
*
* @param {string} validationErrorKey Name of the validator. the `validationErrorKey` will assign
* to `$error[validationErrorKey]=isValid` so that it is available for data-binding.
@@ -1743,6 +1701,23 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
ctrl.$render();
};
+ /**
+ * @ngdoc method
+ * @name ngModel.NgModelController#$validate
+ *
+ * @description
+ * Runs each of the registered validations set on the $validators object.
+ */
+ this.$validate = function() {
+ this.$$runValidators(ctrl.$modelValue, ctrl.$viewValue);
+ };
+
+ this.$$runValidators = function(modelValue, viewValue) {
+ forEach(ctrl.$validators, function(fn, name) {
+ ctrl.$setValidity(name, fn(modelValue, viewValue));
+ });
+ };
+
/**
* @ngdoc method
* @name ngModel.NgModelController#$commitViewValue
@@ -1755,12 +1730,13 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
* usually handles calling this in response to input events.
*/
this.$commitViewValue = function() {
- var value = ctrl.$viewValue;
+ var viewValue = ctrl.$viewValue;
+
$timeout.cancel(pendingDebounce);
- if (ctrl.$$lastCommittedViewValue === value) {
+ if (ctrl.$$lastCommittedViewValue === viewValue) {
return;
}
- ctrl.$$lastCommittedViewValue = value;
+ ctrl.$$lastCommittedViewValue = viewValue;
// change to dirty
if (ctrl.$pristine) {
@@ -1771,13 +1747,19 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
parentForm.$setDirty();
}
+ var modelValue = viewValue;
forEach(ctrl.$parsers, function(fn) {
- value = fn(value);
+ modelValue = fn(modelValue);
});
- if (ctrl.$modelValue !== value) {
- ctrl.$modelValue = value;
- ngModelSet($scope, value);
+ if (ctrl.$modelValue !== modelValue &&
+ (isUndefined(ctrl.$$invalidModelValue) || ctrl.$$invalidModelValue != modelValue)) {
+
+ ctrl.$$runValidators(modelValue, viewValue);
+ ctrl.$modelValue = ctrl.$valid ? modelValue : undefined;
+ ctrl.$$invalidModelValue = ctrl.$valid ? undefined : modelValue;
+
+ ngModelSet($scope, ctrl.$modelValue);
forEach(ctrl.$viewChangeListeners, function(listener) {
try {
listener();
@@ -1851,26 +1833,31 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
// model -> value
$scope.$watch(function ngModelWatch() {
- var value = ngModelGet($scope);
+ var modelValue = ngModelGet($scope);
// if scope model value and ngModel value are out of sync
- if (ctrl.$modelValue !== value) {
+ if (ctrl.$modelValue !== modelValue &&
+ (isUndefined(ctrl.$$invalidModelValue) || ctrl.$$invalidModelValue != modelValue)) {
var formatters = ctrl.$formatters,
idx = formatters.length;
- ctrl.$modelValue = value;
+ var viewValue = modelValue;
while(idx--) {
- value = formatters[idx](value);
+ viewValue = formatters[idx](viewValue);
}
- if (ctrl.$viewValue !== value) {
- ctrl.$viewValue = ctrl.$$lastCommittedViewValue = value;
+ ctrl.$$runValidators(modelValue, viewValue);
+ ctrl.$modelValue = ctrl.$valid ? modelValue : undefined;
+ ctrl.$$invalidModelValue = ctrl.$valid ? undefined : modelValue;
+
+ if (ctrl.$viewValue !== viewValue) {
+ ctrl.$viewValue = ctrl.$$lastCommittedViewValue = viewValue;
ctrl.$render();
}
}
- return value;
+ return modelValue;
});
}];
@@ -2094,22 +2081,80 @@ var requiredDirective = function() {
if (!ctrl) return;
attr.required = true; // force truthy in case we are on non input element
- var validator = function(value) {
- if (attr.required && ctrl.$isEmpty(value)) {
- ctrl.$setValidity('required', false);
- return;
- } else {
- ctrl.$setValidity('required', true);
- return value;
+ ctrl.$validators.required = function(modelValue, viewValue) {
+ return !attr.required || !ctrl.$isEmpty(viewValue);
+ };
+
+ attr.$observe('required', function() {
+ ctrl.$validate();
+ });
+ }
+ };
+};
+
+
+var patternDirective = function() {
+ return {
+ require: '?ngModel',
+ link: function(scope, elm, attr, ctrl) {
+ if (!ctrl) return;
+
+ var regexp, patternExp = attr.ngPattern || attr.pattern;
+ attr.$observe('pattern', function(regex) {
+ if(isString(regex) && regex.length > 0) {
+ regex = new RegExp(regex);
+ }
+
+ if (regex && !regex.test) {
+ throw minErr('ngPattern')('noregexp',
+ 'Expected {0} to be a RegExp but was {1}. Element: {2}', patternExp,
+ regex, startingTag(elm));
}
+
+ regexp = regex || undefined;
+ ctrl.$validate();
+ });
+
+ ctrl.$validators.pattern = function(value) {
+ return ctrl.$isEmpty(value) || isUndefined(regexp) || regexp.test(value);
};
+ }
+ };
+};
- ctrl.$formatters.push(validator);
- ctrl.$parsers.unshift(validator);
- attr.$observe('required', function() {
- validator(ctrl.$viewValue);
+var maxlengthDirective = function() {
+ return {
+ require: '?ngModel',
+ link: function(scope, elm, attr, ctrl) {
+ if (!ctrl) return;
+
+ var maxlength = 0;
+ attr.$observe('maxlength', function(value) {
+ maxlength = int(value) || 0;
+ ctrl.$validate();
+ });
+ ctrl.$validators.maxlength = function(value) {
+ return ctrl.$isEmpty(value) || value.length <= maxlength;
+ };
+ }
+ };
+};
+
+var minlengthDirective = function() {
+ return {
+ require: '?ngModel',
+ link: function(scope, elm, attr, ctrl) {
+ if (!ctrl) return;
+
+ var minlength = 0;
+ attr.$observe('minlength', function(value) {
+ minlength = int(value) || 0;
+ ctrl.$validate();
});
+ ctrl.$validators.minlength = function(value) {
+ return ctrl.$isEmpty(value) || value.length >= minlength;
+ };
}
};
};
diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js
index 4c2f62274485..8115566f125f 100644
--- a/test/ng/directive/inputSpec.js
+++ b/test/ng/directive/inputSpec.js
@@ -261,6 +261,155 @@ describe('NgModelController', function() {
expect(ctrl.$render).toHaveBeenCalledOnce();
});
});
+
+ describe('$validators', function() {
+
+ it('should perform validations when $validate() is called', function() {
+ ctrl.$validators.uppercase = function(value) {
+ return (/^[A-Z]+$/).test(value);
+ };
+
+ ctrl.$modelValue = 'test';
+ ctrl.$validate();
+
+ expect(ctrl.$valid).toBe(false);
+
+ ctrl.$modelValue = 'TEST';
+ ctrl.$validate();
+
+ expect(ctrl.$valid).toBe(true);
+ });
+
+ it('should perform validations when $validate() is called', function() {
+ ctrl.$validators.uppercase = function(value) {
+ return (/^[A-Z]+$/).test(value);
+ };
+
+ ctrl.$modelValue = 'test';
+ ctrl.$validate();
+
+ expect(ctrl.$valid).toBe(false);
+
+ ctrl.$modelValue = 'TEST';
+ ctrl.$validate();
+
+ expect(ctrl.$valid).toBe(true);
+ });
+
+ it('should always perform validations using the parsed model value', function() {
+ var captures;
+ ctrl.$validators.raw = function() {
+ captures = arguments;
+ return captures[0];
+ };
+
+ ctrl.$parsers.push(function(value) {
+ return value.toUpperCase();
+ });
+
+ ctrl.$setViewValue('my-value');
+
+ expect(captures).toEqual(['MY-VALUE', 'my-value']);
+ });
+
+ it('should always perform validations using the formatted view value', function() {
+ var captures;
+ ctrl.$validators.raw = function() {
+ captures = arguments;
+ return captures[0];
+ };
+
+ ctrl.$formatters.push(function(value) {
+ return value + '...';
+ });
+
+ scope.$apply(function() {
+ scope.value = 'matias';
+ });
+
+ expect(captures).toEqual(['matias', 'matias...']);
+ });
+
+ it('should only perform validations if the view value is different', function() {
+ var count = 0;
+ ctrl.$validators.countMe = function() {
+ count++;
+ };
+
+ ctrl.$setViewValue('my-value');
+ expect(count).toBe(1);
+
+ ctrl.$setViewValue('my-value');
+ expect(count).toBe(1);
+
+ ctrl.$setViewValue('your-value');
+ expect(count).toBe(2);
+ });
+
+ it('should perform validations twice each time the model value changes within a digest', function() {
+ var count = 0;
+ ctrl.$validators.number = function(value) {
+ count++;
+ return (/^\d+$/).test(value);
+ };
+
+ function val(v) {
+ scope.$apply(function() {
+ scope.value = v;
+ });
+ }
+
+ val('');
+ expect(count).toBe(1);
+
+ val(1);
+ expect(count).toBe(2);
+
+ val(1);
+ expect(count).toBe(2);
+
+ val('');
+ expect(count).toBe(3);
+ });
+
+ it('should only validate to true if all validations are true', function() {
+ var curry = function(v) {
+ return function() {
+ return v;
+ };
+ };
+
+ ctrl.$validators.a = curry(true);
+ ctrl.$validators.b = curry(true);
+ ctrl.$validators.c = curry(false);
+
+ ctrl.$validate();
+ expect(ctrl.$valid).toBe(false);
+
+ ctrl.$validators.c = curry(true);
+
+ ctrl.$validate();
+ expect(ctrl.$valid).toBe(true);
+ });
+
+ it('should register invalid validations on the $error object', function() {
+ var curry = function(v) {
+ return function() {
+ return v;
+ };
+ };
+
+ ctrl.$validators.unique = curry(false);
+ ctrl.$validators.tooLong = curry(false);
+ ctrl.$validators.notNumeric = curry(true);
+
+ ctrl.$validate();
+
+ expect(ctrl.$error.unique).toBe(true);
+ expect(ctrl.$error.tooLong).toBe(true);
+ expect(ctrl.$error.notNumeric).not.toBe(true);
+ });
+ });
});
describe('ngModel', function() {
@@ -1137,12 +1286,59 @@ describe('input', function() {
expect(inputElm).toBeInvalid();
});
+ it('should perform validations when the ngPattern scope value changes', function() {
+ scope.regexp = /^[a-z]+$/;
+ compileInput('');
+
+ changeInputValueTo('abcdef');
+ expect(inputElm).toBeValid();
+
+ changeInputValueTo('123');
+ expect(inputElm).toBeInvalid();
+
+ scope.$apply(function() {
+ scope.regexp = /^\d+$/;
+ });
+
+ expect(inputElm).toBeValid();
+
+ changeInputValueTo('abcdef');
+ expect(inputElm).toBeInvalid();
- it('should throw an error when scope pattern is invalid', function() {
+ scope.$apply(function() {
+ scope.regexp = '';
+ });
+
+ expect(inputElm).toBeValid();
+ });
+
+ it('should register "pattern" with the model validations when the pattern attribute is used', function() {
+ compileInput('');
+
+ changeInputValueTo('abcd');
+ expect(inputElm).toBeInvalid();
+ expect(scope.form.input.$error.pattern).toBe(true);
+
+ changeInputValueTo('12345');
+ expect(inputElm).toBeValid();
+ expect(scope.form.input.$error.pattern).not.toBe(true);
+ });
+
+ it('should not throw an error when scope pattern can\'t be found', function() {
expect(function() {
compileInput('');
scope.$apply(function() {
- scope.fooRegexp = '/...';
+ scope.foo = 'bar';
+ });
+ }).not.toThrowMatching(/^\[ngPattern:noregexp\] Expected fooRegexp to be a RegExp but was/);
+ });
+
+ it('should throw an error when the scope pattern is not a regular expression', function() {
+ expect(function() {
+ compileInput('');
+ scope.$apply(function() {
+ scope.fooRegexp = {};
+ scope.foo = 'bar';
});
}).toThrowMatching(/^\[ngPattern:noregexp\] Expected fooRegexp to be a RegExp but was/);
});
@@ -1151,14 +1347,14 @@ describe('input', function() {
describe('minlength', function() {
- it('should invalid shorter than given minlength', function() {
+ it('should invalidate values that are shorter than the given minlength', function() {
compileInput('');
changeInputValueTo('aa');
- expect(scope.value).toBeUndefined();
+ expect(inputElm).toBeInvalid();
changeInputValueTo('aaa');
- expect(scope.value).toBe('aaa');
+ expect(inputElm).toBeValid();
});
it('should listen on ng-minlength when minlength is observed', function() {
@@ -1174,19 +1370,37 @@ describe('input', function() {
expect(value).toBe(5);
});
+
+ it('should observe the standard minlength attribute and register it as a validator on the model', function() {
+ compileInput('');
+ scope.$apply(function() {
+ scope.min = 10;
+ });
+
+ changeInputValueTo('12345');
+ expect(inputElm).toBeInvalid();
+ expect(scope.form.input.$error.minlength).toBe(true);
+
+ scope.$apply(function() {
+ scope.min = 5;
+ });
+
+ expect(inputElm).toBeValid();
+ expect(scope.form.input.$error.minlength).not.toBe(true);
+ });
});
describe('maxlength', function() {
- it('should invalid shorter than given maxlength', function() {
+ it('should invalidate values that are longer than the given maxlength', function() {
compileInput('');
changeInputValueTo('aaaaaaaa');
- expect(scope.value).toBeUndefined();
+ expect(inputElm).toBeInvalid();
changeInputValueTo('aaa');
- expect(scope.value).toBe('aaa');
+ expect(inputElm).toBeValid();
});
it('should listen on ng-maxlength when maxlength is observed', function() {
@@ -1202,6 +1416,24 @@ describe('input', function() {
expect(value).toBe(10);
});
+
+ it('should observe the standard maxlength attribute and register it as a validator on the model', function() {
+ compileInput('');
+ scope.$apply(function() {
+ scope.max = 1;
+ });
+
+ changeInputValueTo('12345');
+ expect(inputElm).toBeInvalid();
+ expect(scope.form.input.$error.maxlength).toBe(true);
+
+ scope.$apply(function() {
+ scope.max = 6;
+ });
+
+ expect(inputElm).toBeValid();
+ expect(scope.form.input.$error.maxlength).not.toBe(true);
+ });
});
@@ -2415,7 +2647,7 @@ describe('input', function() {
compileInput('');
scope.$apply(function() {
- scope.name = '';
+ scope.name = null;
});
expect(inputElm).toBeInvalid();