From 0192c2228d5be699625f87aa99188324ca79a85f Mon Sep 17 00:00:00 2001 From: Shahar Talmi Date: Sat, 14 Jun 2014 13:50:12 +0300 Subject: [PATCH] feat(ngModelOptions): add validateOn option Add option to set different triggers for validation than the triggers that actually update the model. A new method `$$validateViewValue` was extracted from `$commitViewValue` which can be called when a `validateOn` trigger occurs. This method sets the control as dirty and runs all `$parsers`, but doesn't actually change the model. The idea is that many times people want to pend or debounce the updates to the model in order to prevent a lot of invocations of watchers or just simply to update their model only when a form is submitted, but they would still like their users to get feedback immediately when the view is dirty or invalid. BREAKING CHANGE: This commit introduces the option to run `$parsers` without setting the `$modelValue` with the result. Closes #7016 --- src/ng/directive/input.js | 70 ++++++++++++++++++++++++---------- test/ng/directive/inputSpec.js | 38 ++++++++++++++++-- 2 files changed, 84 insertions(+), 24 deletions(-) diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index fa6fe55d9f5e..b7823961491b 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -1757,7 +1757,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ * Runs each of the registered validations set on the $validators object. */ this.$validate = function() { - this.$$runValidators(ctrl.$modelValue, ctrl.$viewValue); + this.$$runValidators(ctrl.$$validateValue, ctrl.$viewValue); }; this.$$runValidators = function(modelValue, viewValue) { @@ -1766,26 +1766,9 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ }); }; - /** - * @ngdoc method - * @name ngModel.NgModelController#$commitViewValue - * - * @description - * Commit a pending update to the `$modelValue`. - * - * Updates may be pending by a debounced event or because the input is waiting for a some future - * event defined in `ng-model-options`. this method is rarely needed as `NgModelController` - * usually handles calling this in response to input events. - */ - this.$commitViewValue = function() { + this.$$validateViewValue = function() { var viewValue = ctrl.$viewValue; - $timeout.cancel(pendingDebounce); - if (ctrl.$$lastCommittedViewValue === viewValue) { - return; - } - ctrl.$$lastCommittedViewValue = viewValue; - // change to dirty if (ctrl.$pristine) { ctrl.$dirty = true; @@ -1804,9 +1787,38 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ (isUndefined(ctrl.$$invalidModelValue) || ctrl.$$invalidModelValue != modelValue)) { ctrl.$$runValidators(modelValue, viewValue); - ctrl.$modelValue = ctrl.$valid ? modelValue : undefined; + ctrl.$$validateValue = ctrl.$valid ? modelValue : undefined; ctrl.$$invalidModelValue = ctrl.$valid ? undefined : modelValue; + return ctrl.$valid ? modelValue : undefined; + } + + return ctrl.$modelValue; + }; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$commitViewValue + * + * @description + * Commit a pending update to the `$modelValue`. + * + * Updates may be pending by a debounced event or because the input is waiting for a some future + * event defined in `ng-model-options`. this method is rarely needed as `NgModelController` + * usually handles calling this in response to input events. + */ + this.$commitViewValue = function() { + var viewValue = ctrl.$viewValue; + + $timeout.cancel(pendingDebounce); + if (ctrl.$$lastCommittedViewValue === viewValue) { + return; + } + ctrl.$$lastCommittedViewValue = viewValue; + + var modelValue = ctrl.$$validateViewValue(); + if (ctrl.$modelValue !== modelValue) { + ctrl.$modelValue = modelValue; ngModelSet($scope, ctrl.$modelValue); forEach(ctrl.$viewChangeListeners, function(listener) { try { @@ -1848,6 +1860,9 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ */ this.$setViewValue = function(value, trigger) { ctrl.$viewValue = value; + if (ctrl.$options && ctrl.$options.validateOnDefault) { + ctrl.$$validateViewValue(); + } if (!ctrl.$options || ctrl.$options.updateOnDefault) { ctrl.$$debounceViewValueCommit(trigger); } @@ -1897,6 +1912,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ ctrl.$$runValidators(modelValue, viewValue); ctrl.$modelValue = ctrl.$valid ? modelValue : undefined; + ctrl.$$validateValue = ctrl.$modelValue; ctrl.$$invalidModelValue = ctrl.$valid ? undefined : modelValue; if (ctrl.$viewValue !== viewValue) { @@ -2048,6 +2064,13 @@ var ngModelDirective = function() { }); }); } + if (modelCtrl.$options && modelCtrl.$options.validateOn) { + element.on(modelCtrl.$options.validateOn, function(ev) { + scope.$apply(function() { + modelCtrl.$$validateViewValue(); + }); + }); + } element.on('blur', function(ev) { scope.$apply(function() { @@ -2504,6 +2527,13 @@ var ngModelOptionsDirective = function() { that.$options.updateOnDefault = true; return ' '; })); + + if (this.$options.validateOn !== undefined) { + this.$options.validateOn = trim(this.$options.validateOn.replace(DEFAULT_REGEXP, function() { + that.$options.validateOnDefault = true; + return ' '; + })); + } } else { this.$options.updateOnDefault = true; } diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index e48a2a082672..310d83b8f3b5 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -293,12 +293,12 @@ describe('NgModelController', function() { return (/^[A-Z]+$/).test(value); }; - ctrl.$modelValue = 'test'; + ctrl.$$validateValue = 'test'; ctrl.$validate(); expect(ctrl.$valid).toBe(false); - ctrl.$modelValue = 'TEST'; + ctrl.$$validateValue = 'TEST'; ctrl.$validate(); expect(ctrl.$valid).toBe(true); @@ -309,12 +309,12 @@ describe('NgModelController', function() { return (/^[A-Z]+$/).test(value); }; - ctrl.$modelValue = 'test'; + ctrl.$$validateValue = 'test'; ctrl.$validate(); expect(ctrl.$valid).toBe(false); - ctrl.$modelValue = 'TEST'; + ctrl.$$validateValue = 'TEST'; ctrl.$validate(); expect(ctrl.$valid).toBe(true); @@ -846,6 +846,36 @@ describe('input', function() { expect(scope.name).toEqual('a'); }); + it('should allow validating before view value is committed', function() { + scope.value = 2; + compileInput( + ''); + changeInputValueTo('20'); + expect(scope.form.alias.$error.max).toBeTruthy(); + expect(formElm).toBeDirty(); + expect(scope.value).toEqual(2); + browserTrigger(inputElm, 'blur'); + expect(scope.value).toBeUndefined(); + }); + + it('should allow defining trigger for validation', function() { + scope.value = 2; + compileInput( + ''); + changeInputValueTo('20'); + expect(scope.form.alias.$error.max).toBeFalsy(); + browserTrigger(inputElm, 'blur'); + expect(scope.form.alias.$error.max).toBeTruthy(); + expect(formElm).toBeDirty(); + expect(scope.value).toEqual(2); + browserTrigger(formElm, 'submit'); + expect(scope.value).toBeUndefined(); + }); + it('should not dirty the input if nothing was changed before updateOn trigger', function() { compileInput( '