From 0637a2124c4515311a317e0e39926521228fc0af Mon Sep 17 00:00:00 2001 From: Martin Staffa Date: Wed, 5 Dec 2018 14:06:43 +0100 Subject: [PATCH] perf(input): prevent multiple validations on initialization This commit updates in-built validators with observers to prevent multiple calls to $validate that could happen on initial linking of the directives in certain circumstances: - when an input is wrapped in a transclude: element directive (e.g. ngRepeat), the order of execution between ngModel and the input / validation directives changes so that the initial observer call happens when ngModel has already been initalized, leading to another call to $validate, which calls *all* defined validators again. Without ngRepeat, ngModel hasn't been initialized yet, and $validate does not call the validators. When using validators with scope expressions, the expression value is not available when ngModel first runs the validators (e.g. ngMinlength="myMinlength"). Only in the first call to the observer does the value become available, making a call to $validate a necessity. This commit solves the first problem by storing the validation attribute value so we can compare the current value and the observed value - which will be the same after compilation. The second problem is solved by parsing the validation expression once in the link function, so the value is available when ngModel first validates. Closes #14691 Closes #16760 --- src/ng/directive/input.js | 98 ++++++--- src/ng/directive/ngModel.js | 1 + src/ng/directive/validators.js | 130 ++++++++---- test/helpers/testabilityPatch.js | 21 ++ test/ng/directive/inputSpec.js | 305 +++++++++++++++++++++++++++- test/ng/directive/ngModelSpec.js | 1 - test/ng/directive/validatorsSpec.js | 106 +++++++++- 7 files changed, 594 insertions(+), 68 deletions(-) diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index 2f75defe1944..0a9eacd9fd8b 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -1497,7 +1497,7 @@ function createDateParser(regexp, mapping) { } function createDateInputType(type, regexp, parseDate, format) { - return function dynamicDateInputType(scope, element, attr, ctrl, $sniffer, $browser, $filter) { + return function dynamicDateInputType(scope, element, attr, ctrl, $sniffer, $browser, $filter, $parse) { badInputChecker(scope, element, attr, ctrl, type); baseInputType(scope, element, attr, ctrl, $sniffer, $browser); @@ -1540,24 +1540,34 @@ function createDateInputType(type, regexp, parseDate, format) { }); if (isDefined(attr.min) || attr.ngMin) { - var minVal; + var minVal = attr.min || $parse(attr.ngMin)(scope); + var parsedMinVal = parseObservedDateValue(minVal); + ctrl.$validators.min = function(value) { - return !isValidDate(value) || isUndefined(minVal) || parseDate(value) >= minVal; + return !isValidDate(value) || isUndefined(parsedMinVal) || parseDate(value) >= parsedMinVal; }; attr.$observe('min', function(val) { - minVal = parseObservedDateValue(val); - ctrl.$validate(); + if (val !== minVal) { + parsedMinVal = parseObservedDateValue(val); + minVal = val; + ctrl.$validate(); + } }); } if (isDefined(attr.max) || attr.ngMax) { - var maxVal; + var maxVal = attr.max || $parse(attr.ngMax)(scope); + var parsedMaxVal = parseObservedDateValue(maxVal); + ctrl.$validators.max = function(value) { - return !isValidDate(value) || isUndefined(maxVal) || parseDate(value) <= maxVal; + return !isValidDate(value) || isUndefined(parsedMaxVal) || parseDate(value) <= parsedMaxVal; }; attr.$observe('max', function(val) { - maxVal = parseObservedDateValue(val); - ctrl.$validate(); + if (val !== maxVal) { + parsedMaxVal = parseObservedDateValue(val); + maxVal = val; + ctrl.$validate(); + } }); } @@ -1709,50 +1719,68 @@ function isValidForStep(viewValue, stepBase, step) { return (value - stepBase) % step === 0; } -function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { +function numberInputType(scope, element, attr, ctrl, $sniffer, $browser, $filter, $parse) { badInputChecker(scope, element, attr, ctrl, 'number'); numberFormatterParser(ctrl); baseInputType(scope, element, attr, ctrl, $sniffer, $browser); - var minVal; - var maxVal; + var parsedMinVal; if (isDefined(attr.min) || attr.ngMin) { + var minVal = attr.min || $parse(attr.ngMin)(scope); + parsedMinVal = parseNumberAttrVal(minVal); + ctrl.$validators.min = function(modelValue, viewValue) { - return ctrl.$isEmpty(viewValue) || isUndefined(minVal) || viewValue >= minVal; + return ctrl.$isEmpty(viewValue) || isUndefined(parsedMinVal) || viewValue >= parsedMinVal; }; attr.$observe('min', function(val) { - minVal = parseNumberAttrVal(val); - // TODO(matsko): implement validateLater to reduce number of validations - ctrl.$validate(); + if (val !== minVal) { + parsedMinVal = parseNumberAttrVal(val); + minVal = val; + // TODO(matsko): implement validateLater to reduce number of validations + ctrl.$validate(); + } }); } if (isDefined(attr.max) || attr.ngMax) { + var maxVal = attr.max || $parse(attr.ngMax)(scope); + var parsedMaxVal = parseNumberAttrVal(maxVal); + ctrl.$validators.max = function(modelValue, viewValue) { - return ctrl.$isEmpty(viewValue) || isUndefined(maxVal) || viewValue <= maxVal; + return ctrl.$isEmpty(viewValue) || isUndefined(parsedMaxVal) || viewValue <= parsedMaxVal; }; attr.$observe('max', function(val) { - maxVal = parseNumberAttrVal(val); - // TODO(matsko): implement validateLater to reduce number of validations - ctrl.$validate(); + if (val !== maxVal) { + parsedMaxVal = parseNumberAttrVal(val); + maxVal = val; + // TODO(matsko): implement validateLater to reduce number of validations + ctrl.$validate(); + } }); } if (isDefined(attr.step) || attr.ngStep) { - var stepVal; + var stepVal = attr.step || $parse(attr.ngStep)(scope); + var parsedStepVal = parseNumberAttrVal(stepVal); + ctrl.$validators.step = function(modelValue, viewValue) { - return ctrl.$isEmpty(viewValue) || isUndefined(stepVal) || - isValidForStep(viewValue, minVal || 0, stepVal); + return ctrl.$isEmpty(viewValue) || isUndefined(parsedStepVal) || + isValidForStep(viewValue, parsedMinVal || 0, parsedStepVal); }; attr.$observe('step', function(val) { - stepVal = parseNumberAttrVal(val); // TODO(matsko): implement validateLater to reduce number of validations - ctrl.$validate(); + if (val !== stepVal) { + parsedStepVal = parseNumberAttrVal(val); + stepVal = val; + ctrl.$validate(); + } + }); + } } @@ -1782,6 +1810,8 @@ function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) { originalRender; if (hasMinAttr) { + minVal = parseNumberAttrVal(attr.min); + ctrl.$validators.min = supportsRange ? // Since all browsers set the input to a valid value, we don't need to check validity function noopMinValidator() { return true; } : @@ -1794,6 +1824,8 @@ function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) { } if (hasMaxAttr) { + maxVal = parseNumberAttrVal(attr.max); + ctrl.$validators.max = supportsRange ? // Since all browsers set the input to a valid value, we don't need to check validity function noopMaxValidator() { return true; } : @@ -1806,6 +1838,8 @@ function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) { } if (hasStepAttr) { + stepVal = parseNumberAttrVal(attr.step); + ctrl.$validators.step = supportsRange ? function nativeStepValidator() { // Currently, only FF implements the spec on step change correctly (i.e. adjusting the @@ -1827,7 +1861,13 @@ function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) { // attribute value when the input is first rendered, so that the browser can adjust the // input value based on the min/max value element.attr(htmlAttrName, attr[htmlAttrName]); - attr.$observe(htmlAttrName, changeFn); + var oldVal = attr[htmlAttrName]; + attr.$observe(htmlAttrName, function wrappedObserver(val) { + if (val !== oldVal) { + oldVal = val; + changeFn(val); + } + }); } function minChange(val) { @@ -1881,11 +1921,11 @@ function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) { } // Some browsers don't adjust the input value correctly, but set the stepMismatch error - if (supportsRange && ctrl.$viewValue !== element.val()) { - ctrl.$setViewValue(element.val()); - } else { + if (!supportsRange) { // TODO(matsko): implement validateLater to reduce number of validations ctrl.$validate(); + } else if (ctrl.$viewValue !== element.val()) { + ctrl.$setViewValue(element.val()); } } } diff --git a/src/ng/directive/ngModel.js b/src/ng/directive/ngModel.js index 5d73c33ceb28..951d909342bc 100644 --- a/src/ng/directive/ngModel.js +++ b/src/ng/directive/ngModel.js @@ -562,6 +562,7 @@ NgModelController.prototype = { * `$modelValue`, i.e. either the last parsed value or the last value set from the scope. */ $validate: function() { + // ignore $validate before model is initialized if (isNumberNaN(this.$modelValue)) { return; diff --git a/src/ng/directive/validators.js b/src/ng/directive/validators.js index 1a07a5501015..85682fb07ea8 100644 --- a/src/ng/directive/validators.js +++ b/src/ng/directive/validators.js @@ -62,24 +62,29 @@ * * */ -var requiredDirective = function() { +var requiredDirective = ['$parse', function($parse) { return { restrict: 'A', require: '?ngModel', link: function(scope, elm, attr, ctrl) { if (!ctrl) return; + var oldVal = attr.required || $parse(attr.ngRequired)(scope); + attr.required = true; // force truthy in case we are on non input element ctrl.$validators.required = function(modelValue, viewValue) { return !attr.required || !ctrl.$isEmpty(viewValue); }; - attr.$observe('required', function() { - ctrl.$validate(); + attr.$observe('required', function(val) { + if (oldVal !== val) { + oldVal = val; + ctrl.$validate(); + } }); } }; -}; +}]; /** * @ngdoc directive @@ -162,36 +167,59 @@ var requiredDirective = function() { * * */ -var patternDirective = function() { +var patternDirective = ['$parse', function($parse) { return { restrict: 'A', require: '?ngModel', - link: function(scope, elm, attr, ctrl) { - if (!ctrl) return; + compile: function(tElm, tAttr) { + var patternExp; + var parseFn; + + if (tAttr.ngPattern) { + patternExp = tAttr.ngPattern; - var regexp, patternExp = attr.ngPattern || attr.pattern; - attr.$observe('pattern', function(regex) { - if (isString(regex) && regex.length > 0) { - regex = new RegExp('^' + regex + '$'); + // ngPattern might be a scope expression, or an inlined regex, which is not parsable. + // We get value of the attribute here, so we can compare the old and the new value + // in the observer to avoid unnecessary validations + if (tAttr.ngPattern.charAt(0) === '/' && REGEX_STRING_REGEXP.test(tAttr.ngPattern)) { + parseFn = function() { return tAttr.ngPattern; }; + } else { + parseFn = $parse(tAttr.ngPattern); } + } + + return function(scope, elm, attr, ctrl) { + if (!ctrl) return; - if (regex && !regex.test) { - throw minErr('ngPattern')('noregexp', - 'Expected {0} to be a RegExp but was {1}. Element: {2}', patternExp, - regex, startingTag(elm)); + var attrVal = attr.pattern; + + if (attr.ngPattern) { + attrVal = parseFn(scope); + } else { + patternExp = attr.pattern; } - regexp = regex || undefined; - ctrl.$validate(); - }); + var regexp = parsePatternAttr(attrVal, patternExp, elm); + + attr.$observe('pattern', function(newVal) { + var oldRegexp = regexp; - ctrl.$validators.pattern = function(modelValue, viewValue) { - // HTML5 pattern constraint validates the input value, so we validate the viewValue - return ctrl.$isEmpty(viewValue) || isUndefined(regexp) || regexp.test(viewValue); + regexp = parsePatternAttr(newVal, patternExp, elm); + + if ((oldRegexp && oldRegexp.toString()) !== (regexp && regexp.toString())) { + ctrl.$validate(); + } + }); + + ctrl.$validators.pattern = function(modelValue, viewValue) { + // HTML5 pattern constraint validates the input value, so we validate the viewValue + return ctrl.$isEmpty(viewValue) || isUndefined(regexp) || regexp.test(viewValue); + }; }; } + }; -}; +}]; /** * @ngdoc directive @@ -264,25 +292,29 @@ var patternDirective = function() { * * */ -var maxlengthDirective = function() { +var maxlengthDirective = ['$parse', function($parse) { return { restrict: 'A', require: '?ngModel', link: function(scope, elm, attr, ctrl) { if (!ctrl) return; - var maxlength = -1; + var maxlength = attr.maxlength || $parse(attr.ngMaxlength)(scope); + var maxlengthParsed = parseLength(maxlength); + attr.$observe('maxlength', function(value) { - var intVal = toInt(value); - maxlength = isNumberNaN(intVal) ? -1 : intVal; - ctrl.$validate(); + if (maxlength !== value) { + maxlengthParsed = parseLength(value); + maxlength = value; + ctrl.$validate(); + } }); ctrl.$validators.maxlength = function(modelValue, viewValue) { - return (maxlength < 0) || ctrl.$isEmpty(viewValue) || (viewValue.length <= maxlength); + return (maxlengthParsed < 0) || ctrl.$isEmpty(viewValue) || (viewValue.length <= maxlengthParsed); }; } }; -}; +}]; /** * @ngdoc directive @@ -353,21 +385,49 @@ var maxlengthDirective = function() { * * */ -var minlengthDirective = function() { +var minlengthDirective = ['$parse', function($parse) { return { restrict: 'A', require: '?ngModel', link: function(scope, elm, attr, ctrl) { if (!ctrl) return; - var minlength = 0; + var minlength = attr.minlength || $parse(attr.ngMinlength)(scope); + var minlengthParsed = parseLength(minlength) || -1; + attr.$observe('minlength', function(value) { - minlength = toInt(value) || 0; - ctrl.$validate(); + if (minlength !== value) { + minlengthParsed = parseLength(value) || -1; + minlength = value; + ctrl.$validate(); + } + }); ctrl.$validators.minlength = function(modelValue, viewValue) { - return ctrl.$isEmpty(viewValue) || viewValue.length >= minlength; + return ctrl.$isEmpty(viewValue) || viewValue.length >= minlengthParsed; }; } }; -}; +}]; + + +function parsePatternAttr(regex, patternExp, elm) { + if (!regex) return undefined; + + if (isString(regex)) { + regex = new RegExp('^' + regex + '$'); + } + + if (!regex.test) { + throw minErr('ngPattern')('noregexp', + 'Expected {0} to be a RegExp but was {1}. Element: {2}', patternExp, + regex, startingTag(elm)); + } + + return regex; +} + +function parseLength(val) { + var intVal = toInt(val); + return isNumberNaN(intVal) ? -1 : intVal; +} diff --git a/test/helpers/testabilityPatch.js b/test/helpers/testabilityPatch.js index 64544e586444..37d4ef694bad 100644 --- a/test/helpers/testabilityPatch.js +++ b/test/helpers/testabilityPatch.js @@ -312,7 +312,28 @@ window.dump = function() { function generateInputCompilerHelper(helper) { beforeEach(function() { + helper.validationCounter = {}; + module(function($compileProvider) { + $compileProvider.directive('validationSpy', function() { + return { + priority: 1, + require: 'ngModel', + link: function(scope, element, attrs, ctrl) { + var validationName = attrs.validationSpy; + + var originalValidator = ctrl.$validators[validationName]; + helper.validationCounter[validationName] = 0; + + ctrl.$validators[validationName] = function(modelValue, viewValue) { + helper.validationCounter[validationName]++; + + return originalValidator(modelValue, viewValue); + }; + } + }; + }); + $compileProvider.directive('attrCapture', function() { return function(scope, element, $attrs) { helper.attrs = $attrs; diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index 79b44c910170..e7159cba9ba7 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -839,6 +839,22 @@ describe('input', function() { expect($rootScope.form.alias.$error.min).toBeFalsy(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + }); }); describe('max', function() { @@ -898,6 +914,22 @@ describe('input', function() { expect($rootScope.form.alias.$error.max).toBeFalsy(); expect($rootScope.form.alias.$valid).toBeTruthy(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + }); }); }); @@ -1114,6 +1146,22 @@ describe('input', function() { expect($rootScope.form.alias.$error.min).toBeFalsy(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + }); }); describe('max', function() { @@ -1176,6 +1224,22 @@ describe('input', function() { expect($rootScope.form.alias.$error.max).toBeFalsy(); expect($rootScope.form.alias.$valid).toBeTruthy(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + }); }); }); @@ -1506,6 +1570,23 @@ describe('input', function() { expect($rootScope.form.alias.$error.min).toBeFalsy(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + }); + }); describe('max', function() { @@ -1565,6 +1646,22 @@ describe('input', function() { expect($rootScope.form.alias.$error.max).toBeFalsy(); expect($rootScope.form.alias.$valid).toBeTruthy(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + }); }); @@ -1972,6 +2069,22 @@ describe('input', function() { expect($rootScope.form.alias.$error.min).toBeFalsy(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + }); }); describe('max', function() { @@ -2019,6 +2132,22 @@ describe('input', function() { expect($rootScope.form.alias.$error.max).toBeFalsy(); expect($rootScope.form.alias.$valid).toBeTruthy(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + }); }); @@ -2361,6 +2490,26 @@ describe('input', function() { expect($rootScope.form.alias.$error.min).toBeFalsy(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.minVal = '2000-01-01'; + $rootScope.value = new Date(2010, 1, 1, 0, 0, 0); + + var inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + }); + }); describe('max', function() { @@ -2428,6 +2577,25 @@ describe('input', function() { expect($rootScope.form.alias.$error.max).toBeFalsy(); expect($rootScope.form.alias.$valid).toBeTruthy(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.maxVal = '2000-01-01'; + $rootScope.value = new Date(2020, 1, 1, 0, 0, 0); + + var inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + + inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + }); }); @@ -3063,6 +3231,18 @@ describe('input', function() { $rootScope.$digest(); expect(inputElm).toBeValid(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.value = 5; + $rootScope.minVal = 3; + var inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + }); + }); describe('ngMin', function() { @@ -3131,6 +3311,17 @@ describe('input', function() { $rootScope.$digest(); expect(inputElm).toBeValid(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.value = 5; + $rootScope.minVal = 3; + var inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + }); }); @@ -3200,6 +3391,18 @@ describe('input', function() { $rootScope.$digest(); expect(inputElm).toBeValid(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.value = 5; + $rootScope.maxVal = 3; + var inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + }); + }); describe('ngMax', function() { @@ -3268,6 +3471,17 @@ describe('input', function() { $rootScope.$digest(); expect(inputElm).toBeValid(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.value = 5; + $rootScope.maxVal = 3; + var inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + }); }); @@ -3364,7 +3578,7 @@ describe('input', function() { expect(inputElm.val()).toBe('10'); expect(inputElm).toBeInvalid(); expect(ngModel.$error.step).toBe(true); - expect($rootScope.value).toBeUndefined(); + expect($rootScope.value).toBe(10); // an initially invalid value should not be changed helper.changeInputValueTo('15'); expect(inputElm).toBeValid(); @@ -3444,6 +3658,17 @@ describe('input', function() { expect($rootScope.value).toBe(1.16); } ); + + it('should validate only once after compilation inside ngRepeat', function() { + $rootScope.step = 10; + $rootScope.value = 20; + var inputElm = helper.compileInput('
' + + '' + + '
'); + + expect(helper.validationCounter.step).toBe(1); + }); + }); }); @@ -3485,6 +3710,16 @@ describe('input', function() { expect(inputElm).toBeValid(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.value = 'text'; + var inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.required).toBe(1); + }); }); describe('ngRequired', function() { @@ -3534,6 +3769,17 @@ describe('input', function() { expect($rootScope.value).toBeUndefined(); expect($rootScope.form.numberInput.$error.required).toBeFalsy(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.value = 'text'; + $rootScope.isRequired = true; + var inputElm = helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.required).toBe(1); + }); }); describe('when the ngRequired expression initially evaluates to false', function() { @@ -3848,6 +4094,17 @@ describe('input', function() { expect(inputElm.val()).toBe('20'); }); + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.minVal = 5; + $rootScope.value = 10; + helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + }); + } else { // input[type=range] will become type=text in browsers that don't support it @@ -3926,6 +4183,16 @@ describe('input', function() { expect(inputElm.val()).toBe('15'); }); + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.minVal = 5; + $rootScope.value = 10; + helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.min).toBe(1); + }); } }); @@ -4006,6 +4273,17 @@ describe('input', function() { expect(inputElm.val()).toBe('0'); }); + it('should only validate once after compilation when inside ngRepeat and the value is valid', function() { + $rootScope.maxVal = 5; + $rootScope.value = 5; + helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + }); + } else { it('should validate if "range" is not implemented', function() { var inputElm = helper.compileInput(''); @@ -4081,6 +4359,17 @@ describe('input', function() { expect(scope.value).toBe(5); expect(inputElm.val()).toBe('5'); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.maxVal = 5; + $rootScope.value = 10; + helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.max).toBe(1); + }); } }); @@ -4183,6 +4472,18 @@ describe('input', function() { expect(scope.value).toBe(10); expect(scope.form.alias.$error.step).toBeFalsy(); }); + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.stepVal = 5; + $rootScope.value = 10; + helper.compileInput('
' + + '' + + '
'); + $rootScope.$digest(); + + expect(helper.validationCounter.step).toBe(1); + }); + } else { it('should validate if "range" is not implemented', function() { @@ -4269,7 +4570,7 @@ describe('input', function() { expect(inputElm.val()).toBe('10'); expect(inputElm).toBeInvalid(); expect(ngModel.$error.step).toBe(true); - expect($rootScope.value).toBeUndefined(); + expect($rootScope.value).toBe(10); helper.changeInputValueTo('15'); expect(inputElm).toBeValid(); diff --git a/test/ng/directive/ngModelSpec.js b/test/ng/directive/ngModelSpec.js index 825973bdcf2f..f8eda1fe76ff 100644 --- a/test/ng/directive/ngModelSpec.js +++ b/test/ng/directive/ngModelSpec.js @@ -863,7 +863,6 @@ describe('ngModel', function() { }); }); - describe('view -> model update', function() { it('should always perform validations using the parsed model value', function() { diff --git a/test/ng/directive/validatorsSpec.js b/test/ng/directive/validatorsSpec.js index 9b152da7f386..2d89ce8abfce 100644 --- a/test/ng/directive/validatorsSpec.js +++ b/test/ng/directive/validatorsSpec.js @@ -230,6 +230,29 @@ describe('validators', function() { expect(ctrl.$error.pattern).toBe(true); expect(ctrlNg.$error.pattern).toBe(true); })); + + it('should only validate once after compilation when inside ngRepeat', function() { + + $rootScope.pattern = /\d{4}/; + + helper.compileInput( + '
' + + '' + + '
'); + + $rootScope.$digest(); + + expect(helper.validationCounter.pattern).toBe(1); + + helper.compileInput( + '
' + + '' + + '
'); + + $rootScope.$digest(); + + expect(helper.validationCounter.pattern).toBe(1); + }); }); @@ -312,9 +335,31 @@ describe('validators', function() { expect(ctrl.$error.minlength).toBe(true); expect(ctrlNg.$error.minlength).toBe(true); })); - }); + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.minlength = 5; + + var element = helper.compileInput( + '
' + + '' + + '
'); + + $rootScope.$digest(); + + expect(helper.validationCounter.minlength).toBe(1); + + element = helper.compileInput( + '
' + + '' + + '
'); + + $rootScope.$digest(); + + expect(helper.validationCounter.minlength).toBe(1); + }); + }); + describe('maxlength', function() { it('should invalidate values that are longer than the given maxlength', function() { @@ -500,6 +545,29 @@ describe('validators', function() { expect(ctrl.$error.maxlength).toBe(true); expect(ctrlNg.$error.maxlength).toBe(true); })); + + + it('should only validate once after compilation when inside ngRepeat', function() { + $rootScope.maxlength = 5; + + var element = helper.compileInput( + '
' + + '' + + '
'); + + $rootScope.$digest(); + + expect(helper.validationCounter.maxlength).toBe(1); + + element = helper.compileInput( + '
' + + '' + + '
'); + + $rootScope.$digest(); + + expect(helper.validationCounter.maxlength).toBe(1); + }); }); @@ -626,5 +694,41 @@ describe('validators', function() { expect(ctrl.$error.required).toBe(true); expect(ctrlNg.$error.required).toBe(true); })); + + + it('should validate only once after compilation when inside ngRepeat', function() { + helper.compileInput( + '
' + + '' + + '
'); + + $rootScope.$digest(); + + expect(helper.validationCounter.required).toBe(1); + }); + + + it('should validate only once after compilation when inside ngRepeat and ngRequired is true', function() { + $rootScope.isRequired = true; + + helper.compileInput( + '
' + + '' + + '
'); + + expect(helper.validationCounter.required).toBe(1); + }); + + + it('should validate only once after compilation when inside ngRepeat and ngRequired is false', function() { + $rootScope.isRequired = false; + + helper.compileInput( + '
' + + '' + + '
'); + + expect(helper.validationCounter.required).toBe(1); + }); }); });