From dbe381f29fc72490f8e3a5328d5c487b185fe652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Ram=C3=B3n=20L=C3=B3pez?= Date: Thu, 3 Apr 2014 22:01:34 +0200 Subject: [PATCH] feat(ngModelOptions): custom triggers and debounce of ngModel updates By default, any change to an input will trigger an immediate model update, form validation and run a $digest. This is not always desirable, especially when you have a large number of bindings to update. This PR implements a new directive `ngModelOptions`, which allow you to override this default behavior in several ways. It is implemented as an attribute, to which you pass an Angular expression, which evaluates to an **options** object. All inputs, using ngModel, will search for this directive in their ancestors and use it if found. This makes it easy to provide options for a whole form or even the whole page, as well as specifying exceptions for individual inputs. * You can specify what events trigger an update to the model by providing an `updateOn` property on the **options** object. This property takes a string containing a space separated list of events. For example, `ng-model-options="{ updateOn: 'blur' }"` will update the model only after the input loses focus. There is a special pseudo-event, called "default", which maps to the default event used by the input box normally. This is useful if you want to keep the default behavior and just add new events. * You can specify a debounce delay, how long to wait after the last triggering event before updating the model, by providing a `debounce` property on the **options** object. This property can be a simple number, the debounce delay for all events. For example, `ng-model-options="{ debounce: 500 }" will ensure the model is updated only when there has been a period 500ms since the last triggering event. The property can also be an object, where the keys map to events and the values are a corresponding debounce delay for that event. This can be useful to force immediate updates on some specific circumstances (like blur events). For example, `ng-model-options="{ updateOn: 'default blur', debounce: { default: 500, blur: 0} }"` This commit also brings to an end one of the longest running Pull Requests in the history of AngularJS (#2129)! A testament to the patience of @lrlopez. Closes #1285, #2129, #6945 --- docs/content/guide/forms.ngdoc | 76 +++++++++ src/AngularPublic.js | 4 +- src/ng/directive/input.js | 294 ++++++++++++++++++++++++++------- test/ng/directive/inputSpec.js | 257 ++++++++++++++++++++++++++++ 4 files changed, 568 insertions(+), 63 deletions(-) diff --git a/docs/content/guide/forms.ngdoc b/docs/content/guide/forms.ngdoc index 0a0eed47dce2..acd99b44541c 100644 --- a/docs/content/guide/forms.ngdoc +++ b/docs/content/guide/forms.ngdoc @@ -181,6 +181,82 @@ This allows us to extend the above example with these features: +# Custom triggers + +By default, any change to the content will trigger a model update and form validation. You can +override this behavior using the {@link ng.directive:ngModelOptions ngModelOptions} directive to +bind only to specified list of events. I.e. `ng-model-options="{ updateOn: "blur" }"` will update +and validate only after the control loses focus. You can set several events using a space delimited +list. I.e. `ng-model-options="{ updateOn: 'mousedown blur' }"` + +If you want to keep the default behavior and just add new events that may trigger the model update +and validation, add "default" as one of the specified events. + +I.e. `ng-model-options="{ updateOn: 'default blur' }"` + +The following example shows how to override immediate updates. Changes on the inputs within the form will update the model +only when the control loses focus (blur event). + + + +
+
+ Name: +
+ Other data: +
+
+
username = "{{user.name}}"
+
+
+ + function ControllerUpdateOn($scope) { + $scope.user = {}; + } + +
+ + + +# Non-immediate (debounced) model updates + +You can delay the model update/validation by using the `debounce` key with the +{@link ng.directive:ngModelOptions ngModelOptions} directive. This delay will also apply to +parsers, validators and model flags like `$dirty` or `$pristine`. + +I.e. `ng-model-options="{ debounce: 500 }"` will wait for half a second since +the last content change before triggering the model update and form validation. + +If custom triggers are used, custom debouncing timeouts can be set for each event using an object +in `debounce`. This can be useful to force immediate updates on some specific circumstances +(like blur events). + +I.e. `ng-model-options="{ updateOn: 'default blur', debounce: { default: 500, blur: 0 } }"` + +If those attributes are added to an element, they will be applied to all the child elements and controls that inherit from it unless they are +overridden. + +This example shows how to debounce model changes. Model will be updated only 250 milliseconds after last change. + + + +
+
+ Name: +
+
+
username = "{{user.name}}"
+
+
+ + function ControllerUpdateOn($scope) { + $scope.user = {}; + } + +
+ + + # Custom Validation Angular provides basic implementation for most common html5 {@link ng.directive:input input} diff --git a/src/AngularPublic.js b/src/AngularPublic.js index 0c02adeca685..e97723ef946d 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -46,6 +46,7 @@ requiredDirective, requiredDirective, ngValueDirective, + ngModelOptionsDirective, ngAttributeAliasDirectives, ngEventDirectives, @@ -183,7 +184,8 @@ function publishExternalAPI(angular){ ngChange: ngChangeDirective, required: requiredDirective, ngRequired: requiredDirective, - ngValue: ngValueDirective + ngValue: ngValueDirective, + ngModelOptions: ngModelOptionsDirective }). directive({ ngInclude: ngIncludeFillContentDirective diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index 2f78db049edd..206bc82ea294 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -16,6 +16,7 @@ var DATETIMELOCAL_REGEXP = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d)$/; var WEEK_REGEXP = /^(\d{4})-W(\d\d)$/; var MONTH_REGEXP = /^(\d{4})-(\d\d)$/; var TIME_REGEXP = /^(\d\d):(\d\d)$/; +var DEFAULT_REGEXP = /(\b|^)default(\b|$)/; var inputType = { @@ -879,6 +880,7 @@ function addNativeHtml5Validators(ctrl, validatorName, element) { function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { var validity = element.prop('validity'); + // In composition mode, users are still inputing intermediate text buffer, // hold the listener until composition is done. // More about composition events: https://developer.mozilla.org/en-US/docs/Web/API/CompositionEvent @@ -895,9 +897,10 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { }); } - var listener = function() { + var listener = function(ev) { if (composing) return; - var value = element.val(); + var value = element.val(), + event = ev && ev.type; // By default we will trim the value // If the attribute ng-trim exists we will avoid trimming @@ -912,50 +915,59 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { // even when the first character entered causes an error. (validity && value === '' && !validity.valueMissing)) { if (scope.$$phase) { - ctrl.$setViewValue(value); + ctrl.$setViewValue(value, event); } else { scope.$apply(function() { - ctrl.$setViewValue(value); + ctrl.$setViewValue(value, event); }); } } }; - // if the browser does support "input" event, we are fine - except on IE9 which doesn't fire the - // input event on backspace, delete or cut - if ($sniffer.hasEvent('input')) { - element.on('input', listener); - } else { - var timeout; + // Allow adding/overriding bound events + if (ctrl.$options && ctrl.$options.updateOn) { + // bind to user-defined events + element.on(ctrl.$options.updateOn, listener); + } - var deferListener = function() { - if (!timeout) { - timeout = $browser.defer(function() { - listener(); - timeout = null; - }); - } - }; + // setup default events if requested + if (!ctrl.$options || ctrl.$options.updateOnDefault) { + // if the browser does support "input" event, we are fine - except on IE9 which doesn't fire the + // input event on backspace, delete or cut + if ($sniffer.hasEvent('input')) { + element.on('input', listener); + } else { + var timeout; - element.on('keydown', function(event) { - var key = event.keyCode; + var deferListener = function(ev) { + if (!timeout) { + timeout = $browser.defer(function() { + listener(ev); + timeout = null; + }); + } + }; - // ignore - // command modifiers arrows - if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return; + element.on('keydown', function(event) { + var key = event.keyCode; - deferListener(); - }); + // ignore + // command modifiers arrows + if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return; - // if user modifies input value using context menu in IE, we need "paste" and "cut" events to catch it - if ($sniffer.hasEvent('paste')) { - element.on('paste cut', deferListener); + deferListener(event); + }); + + // if user modifies input value using context menu in IE, we need "paste" and "cut" events to catch it + if ($sniffer.hasEvent('paste')) { + element.on('paste cut', deferListener); + } } - } - // if user paste into input using mouse on older browser - // or form autocomplete on newer browser, we need "change" event to catch it - element.on('change', listener); + // if user paste into input using mouse on older browser + // or form autocomplete on newer browser, we need "change" event to catch it + element.on('change', listener); + } ctrl.$render = function() { element.val(ctrl.$isEmpty(ctrl.$viewValue) ? '' : ctrl.$viewValue); @@ -1191,13 +1203,23 @@ function radioInputType(scope, element, attr, ctrl) { element.attr('name', nextUid()); } - element.on('click', function() { + var listener = function(ev) { if (element[0].checked) { scope.$apply(function() { - ctrl.$setViewValue(attr.value); + ctrl.$setViewValue(attr.value, ev && ev.type); }); } - }); + }; + + // Allow adding/overriding bound events + if (ctrl.$options && ctrl.$options.updateOn) { + // bind to user-defined events + element.on(ctrl.$options.updateOn, listener); + } + + if (!ctrl.$options || ctrl.$options.updateOnDefault) { + element.on('click', listener); + } ctrl.$render = function() { var value = attr.value; @@ -1214,11 +1236,21 @@ function checkboxInputType(scope, element, attr, ctrl) { if (!isString(trueValue)) trueValue = true; if (!isString(falseValue)) falseValue = false; - element.on('click', function() { + var listener = function(ev) { scope.$apply(function() { - ctrl.$setViewValue(element[0].checked); + ctrl.$setViewValue(element[0].checked, ev && ev.type); }); - }); + }; + + // Allow adding/overriding bound events + if (ctrl.$options && ctrl.$options.updateOn) { + // bind to user-defined events + element.on(ctrl.$options.updateOn, listener); + } + + if (!ctrl.$options || ctrl.$options.updateOnDefault) { + element.on('click', listener); + } ctrl.$render = function() { element[0].checked = ctrl.$viewValue; @@ -1380,10 +1412,10 @@ function checkboxInputType(scope, element, attr, ctrl) { var inputDirective = ['$browser', '$sniffer', '$filter', function($browser, $sniffer, $filter) { return { restrict: 'E', - require: '?ngModel', - link: function(scope, element, attr, ctrl) { - if (ctrl) { - (inputType[lowercase(attr.type)] || inputType.text)(scope, element, attr, ctrl, $sniffer, + require: ['?ngModel'], + link: function(scope, element, attr, ctrls) { + if (ctrls[0]) { + (inputType[lowercase(attr.type)] || inputType.text)(scope, element, attr, ctrls[0], $sniffer, $browser, $filter); } } @@ -1529,8 +1561,8 @@ var VALID_CLASS = 'ng-valid', * * */ -var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$parse', '$animate', - function($scope, $exceptionHandler, $attr, $element, $parse, $animate) { +var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$parse', '$animate', '$timeout', + function($scope, $exceptionHandler, $attr, $element, $parse, $animate, $timeout) { this.$viewValue = Number.NaN; this.$modelValue = Number.NaN; this.$parsers = []; @@ -1542,8 +1574,10 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ this.$invalid = false; this.$name = $attr.name; + var ngModelGet = $parse($attr.ngModel), - ngModelSet = ngModelGet.assign; + ngModelSet = ngModelGet.assign, + pendingDebounce = null; if (!ngModelSet) { throw minErr('ngModel')('nonassign', "Expression '{0}' is non-assignable. Element: {1}", @@ -1659,26 +1693,23 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ /** * @ngdoc method - * @name ngModel.NgModelController#$setViewValue + * @name ngModel.NgModelController#$cancelDebounce * * @description - * Update the view value. - * - * This method should be called when the view value changes, typically from within a DOM event handler. - * For example {@link ng.directive:input input} and - * {@link ng.directive:select select} directives call it. - * - * It will update the $viewValue, then pass this value through each of the functions in `$parsers`, - * which includes any validators. The value that comes out of this `$parsers` pipeline, be applied to - * `$modelValue` and the **expression** specified in the `ng-model` attribute. - * - * Lastly, all the registered change listeners, in the `$viewChangeListeners` list, are called. - * - * Note that calling this function does not trigger a `$digest`. + * Cancel a pending debounced update. * - * @param {string} value Value from the view. + * This method should be called before directly update a debounced model from the scope in + * order to prevent unintended future changes of the model value because of a delayed event. */ - this.$setViewValue = function(value) { + this.$cancelDebounce = function() { + if ( pendingDebounce ) { + $timeout.cancel(pendingDebounce); + pendingDebounce = null; + } + }; + + // update the view value + this.$$realSetViewValue = function(value) { this.$viewValue = value; // change to dirty @@ -1707,6 +1738,48 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ } }; + /** + * @ngdoc method + * @name ngModel.NgModelController#$setViewValue + * + * @description + * Update the view value. + * + * This method should be called when the view value changes, typically from within a DOM event handler. + * For example {@link ng.directive:input input} and + * {@link ng.directive:select select} directives call it. + * + * It will update the $viewValue, then pass this value through each of the functions in `$parsers`, + * which includes any validators. The value that comes out of this `$parsers` pipeline, be applied to + * `$modelValue` and the **expression** specified in the `ng-model` attribute. + * + * Lastly, all the registered change listeners, in the `$viewChangeListeners` list, are called. + * + * All these actions will be debounced if the {@link ng.directive:ngModelOptions ngModelOptions} + * directive is used with a custom debounce for this particular event. + * + * Note that calling this function does not trigger a `$digest`. + * + * @param {string} value Value from the view. + * @param {string} trigger Event that triggered the update. + */ + this.$setViewValue = function(value, trigger) { + var that = this; + var debounceDelay = this.$options && (isObject(this.$options.debounce) + ? (this.$options.debounce[trigger] || this.$options.debounce['default'] || 0) + : this.$options.debounce) || 0; + + that.$cancelDebounce(); + if ( debounceDelay ) { + pendingDebounce = $timeout(function() { + pendingDebounce = null; + that.$$realSetViewValue(value); + }, debounceDelay); + } else { + that.$$realSetViewValue(value); + } + }; + // model -> value var ctrl = this; @@ -1844,7 +1917,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ */ var ngModelDirective = function() { return { - require: ['ngModel', '^?form'], + require: ['ngModel', '^?form', '^?ngModelOptions'], controller: NgModelController, link: function(scope, element, attr, ctrls) { // notify others, especially parent forms @@ -1854,6 +1927,11 @@ var ngModelDirective = function() { formCtrl.$addControl(modelCtrl); + // Pass the ng-model-options to the ng-model controller + if ( ctrls[2] ) { + modelCtrl.$options = ctrls[2].$options; + } + scope.$on('$destroy', function() { formCtrl.$removeControl(modelCtrl); }); @@ -2122,3 +2200,95 @@ var ngValueDirective = function() { } }; }; + +/** + * @ngdoc directive + * @name ngModelOptions + * + * @description + * Allows tuning how model updates are done. Using `ngModelOptions` you can specify a custom list of events + * that will trigger a model update and/or a debouncing delay so that the actual update only takes place + * when a timer expires; this timer will be reset after another change takes place. + * + * @param {Object=} Object that contains options to apply to the current model. Valid keys are: + * - updateOn: string specifying which event should be the input bound to. You can set several events + * using an space delimited list. There is a special event called `default` that + * matches the default events belonging of the control. + * - debounce: integer value which contains the debounce model update value in milliseconds. A value of 0 + * triggers an immediate update. If an object is supplied instead, you can specify a custom value + * for each event. I.e. + * `ngModelOptions="{ updateOn: 'default blur', debounce: {'default': 500, 'blur': 0} }"` + * + * @example + + The following example shows how to override immediate updates. Changes on the inputs within the form will update the model + only when the control loses focus (blur event). + + + + +
+
+ Name: +
+ Other data: +
+
+
user.name = 
+
+
+ + var model = element(by.binding('user.name')); + var input = element(by.model('user.name')); + var other = element(by.model('user.data')); + it('should allow custom events', function() { + input.sendKeys(' hello'); + expect(model.getText()).toEqual('say'); + other.click(); + expect(model.getText()).toEqual('say hello'); + }); + +
+ + This one shows how to debounce model changes. Model will be updated only 500 milliseconds after last change. + + + + +
+
+ Name: +
+
+
user.name = 
+
+
+
+ */ +var ngModelOptionsDirective = function() { + return { + controller: ['$scope', '$attrs', function($scope, $attrs) { + var that = this; + this.$options = $scope.$eval($attrs.ngModelOptions); + // Allow adding/overriding bound events + if (this.$options.updateOn) { + this.$options.updateOnDefault = false; + // extract "default" pseudo-event from list of events that can trigger a model update + this.$options.updateOn = this.$options.updateOn.replace(DEFAULT_REGEXP, function() { + that.$options.updateOnDefault = true; + return ' '; + }); + } else { + this.$options.updateOnDefault = true; + } + }] + }; +}; \ No newline at end of file diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index eba3028e7bce..5046d4788fe5 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -608,6 +608,263 @@ describe('input', function() { }); + describe('ngModelOptions attributes', function() { + + it('should allow overriding the model update trigger event on text inputs', function() { + compileInput( + ''); + + changeInputValueTo('a'); + expect(scope.name).toBeUndefined(); + browserTrigger(inputElm, 'blur'); + expect(scope.name).toEqual('a'); + }); + + + it('should bind the element to a list of events', function() { + compileInput( + ''); + + changeInputValueTo('a'); + expect(scope.name).toBeUndefined(); + browserTrigger(inputElm, 'blur'); + expect(scope.name).toEqual('a'); + + changeInputValueTo('b'); + expect(scope.name).toEqual('a'); + browserTrigger(inputElm, 'mousemove'); + expect(scope.name).toEqual('b'); + }); + + + it('should allow keeping the default update behavior on text inputs', function() { + compileInput( + ''); + + changeInputValueTo('a'); + expect(scope.name).toEqual('a'); + }); + + + it('should allow overriding the model update trigger event on checkboxes', function() { + compileInput( + ''); + + browserTrigger(inputElm, 'click'); + expect(scope.checkbox).toBe(undefined); + + browserTrigger(inputElm, 'blur'); + expect(scope.checkbox).toBe(true); + + browserTrigger(inputElm, 'click'); + expect(scope.checkbox).toBe(true); + }); + + + it('should allow keeping the default update behavior on checkboxes', function() { + compileInput( + ''); + + browserTrigger(inputElm, 'click'); + expect(scope.checkbox).toBe(true); + + browserTrigger(inputElm, 'click'); + expect(scope.checkbox).toBe(false); + }); + + + it('should allow overriding the model update trigger event on radio buttons', function() { + compileInput( + '' + + '' + + ''); + + scope.$apply(function() { + scope.color = 'white'; + }); + browserTrigger(inputElm[2], 'click'); + expect(scope.color).toBe('white'); + + browserTrigger(inputElm[2], 'blur'); + expect(scope.color).toBe('blue'); + + }); + + + it('should allow keeping the default update behavior on radio buttons', function() { + compileInput( + '' + + '' + + ''); + + scope.$apply(function() { + scope.color = 'white'; + }); + browserTrigger(inputElm[2], 'click'); + expect(scope.color).toBe('blue'); + }); + + + it('should trigger only after timeout in text inputs', inject(function($timeout) { + compileInput( + ''); + + changeInputValueTo('a'); + changeInputValueTo('b'); + changeInputValueTo('c'); + expect(scope.name).toEqual(undefined); + $timeout.flush(2000); + expect(scope.name).toEqual(undefined); + $timeout.flush(9000); + expect(scope.name).toEqual('c'); + })); + + + it('should trigger only after timeout in checkboxes', inject(function($timeout) { + compileInput( + ''); + + browserTrigger(inputElm, 'click'); + expect(scope.checkbox).toBe(undefined); + $timeout.flush(2000); + expect(scope.checkbox).toBe(undefined); + $timeout.flush(9000); + expect(scope.checkbox).toBe(true); + })); + + + it('should trigger only after timeout in radio buttons', inject(function($timeout) { + compileInput( + '' + + '' + + ''); + + browserTrigger(inputElm[0], 'click'); + expect(scope.color).toBe('white'); + browserTrigger(inputElm[1], 'click'); + expect(scope.color).toBe('white'); + $timeout.flush(12000); + expect(scope.color).toBe('white'); + $timeout.flush(10000); + expect(scope.color).toBe('red'); + + })); + + it('should allow selecting different debounce timeouts for each event', + inject(function($timeout) { + compileInput( + ''); + + changeInputValueTo('a'); + expect(scope.checkbox).toBe(undefined); + $timeout.flush(6000); + expect(scope.checkbox).toBe(undefined); + $timeout.flush(4000); + expect(scope.name).toEqual('a'); + changeInputValueTo('b'); + browserTrigger(inputElm, 'blur'); + $timeout.flush(4000); + expect(scope.name).toEqual('a'); + $timeout.flush(2000); + expect(scope.name).toEqual('b'); + })); + + + it('should allow selecting different debounce timeouts for each event on checkboxes', inject(function($timeout) { + compileInput(''); + + inputElm[0].checked = false; + browserTrigger(inputElm, 'click'); + expect(scope.checkbox).toBe(undefined); + $timeout.flush(8000); + expect(scope.checkbox).toBe(undefined); + $timeout.flush(3000); + expect(scope.checkbox).toBe(true); + inputElm[0].checked = true; + browserTrigger(inputElm, 'click'); + browserTrigger(inputElm, 'blur'); + $timeout.flush(3000); + expect(scope.checkbox).toBe(true); + $timeout.flush(3000); + expect(scope.checkbox).toBe(false); + })); + + + it('should inherit model update settings from ancestor elements', inject(function($timeout) { + var doc = $compile( + '
' + + ''+ + '
')(scope); + + var input = doc.find('input').eq(0); + input.val('a'); + expect(scope.name).toEqual(undefined); + browserTrigger(input, 'blur'); + expect(scope.name).toBe(undefined); + $timeout.flush(2000); + expect(scope.name).toBe(undefined); + $timeout.flush(9000); + expect(scope.name).toEqual('a'); + dealoc(doc); + })); + + + it('should allow cancelling pending updates', inject(function($timeout) { + compileInput( + '
'+ + ''+ + '
'); + changeInputValueTo('a'); + expect(scope.name).toEqual(undefined); + $timeout.flush(2000); + scope.test.alias.$cancelDebounce(); + expect(scope.name).toEqual(undefined); + $timeout.flush(10000); + expect(scope.name).toEqual(undefined); + })); + + }); + it('should allow complex reference binding', function() { compileInput('');