From f9eb1d838902b42ac08828f8a998dcba970870ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Ram=C3=B3n=20L=C3=B3pez?= Date: Mon, 31 Mar 2014 01:26:54 +0200 Subject: [PATCH] feat(ngModelOptions): Model update behavior can now be customized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By default, any change on the content will trigger an immediate model update and form validation. This PR implements a new directive `ng-model-options` that allow you to override this default behavior in several ways. You should specify an object with the different parameters. For example, it allows to trigger an update only when a particular event or list of events is received by the input using the `updateOn` key. Should you need multiple events, just assign an array to it. I.e. `ng-model-options="{ updateOn: 'blur' }"` will update and validate only after the control loses focus. 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','submit'] }"` Also, with the `debounce` option, `ng-model-options` will allow defering the actual model update until a timer expires. The timer will be reset each time an event is triggered. I.e. `ng-model-options="{ debounce: 500 }" for 500ms after the latest event. Custom timeouts for each event can be set for each event if you use 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} }"` You can use the directive in any tag so its contents became the default settings for any child control, although they can be overriden. Closes #1285, #2129 --- docs/content/guide/forms.ngdoc | 63 +++++++ src/ng/directive/input.js | 293 +++++++++++++++++++++++++-------- test/ng/directive/inputSpec.js | 203 +++++++++++++++++++++++ 3 files changed, 491 insertions(+), 68 deletions(-) diff --git a/docs/content/guide/forms.ngdoc b/docs/content/guide/forms.ngdoc index 0a0eed47dce2..77b143fa4a89 100644 --- a/docs/content/guide/forms.ngdoc +++ b/docs/content/guide/forms.ngdoc @@ -181,6 +181,69 @@ This allows us to extend the above example with these features: +# Non-immediate (debounced) or custom triggered model updates + +By default, any change on 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 a single event using an array instead of a string. 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"] }"` + +You can delay the model update/validation by writing a `debounce` key. 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. + +Custom debouncing timeouts can be set for each event for each event if you use 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 +overriden. + +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 = "{{username}}"
+
+
+ + function ControllerUpdateOn($scope) { + $scope.username = ""; + } + +
+ +This one shows how to debounce model changes. Model will be updated only 250 milliseconds after last change. + + + +
+
+ Name: +
+
+
username = "{{user.name}}"
+
+
+ + function ControllerUpdateOn($scope) { + $scope.username = ""; + } + +
+ + + # Custom Validation Angular provides basic implementation for most common html5 {@link ng.directive:input input} diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index 8306f8bebb4a..f765a096a569 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -879,6 +879,7 @@ function addNativeHtml5Validators(ctrl, validatorName, element) { function textInputType(scope, element, attr, ctrl, options, $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 +896,10 @@ function textInputType(scope, element, attr, ctrl, options, $sniffer, $browser) }); } - var listener = function() { + var listener = function(ev) { if (composing) return; - var value = element.val(); + var value = element.val(), + event = ev ? ev.type : undefined; // By default we will trim the value // If the attribute ng-trim exists we will avoid trimming @@ -912,50 +914,61 @@ function textInputType(scope, element, attr, ctrl, options, $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 ((options) && (options.$eventList.length)) { + // bind to user-defined events + forEach(options.$eventList, function(ev) { + element.on(ev, listener); + }); + } - var deferListener = function() { - if (!timeout) { - timeout = $browser.defer(function() { - listener(); - timeout = null; - }); - } - }; + // setup default events if requested + if (!options || (options.$defaultEvents)) { + // 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(); + }); + + // 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 +1204,25 @@ function radioInputType(scope, element, attr, ctrl, options) { 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 : undefined); }); } - }); + }; + + // Allow adding/overriding bound events + if ((options) && (options.$eventList.length)) { + // bind to user-defined events + forEach(options.$eventList, function(ev) { + element.on(ev, listener); + }); + } + + if (!options || (options.$defaultEvents)) { + element.on('click', listener); + } ctrl.$render = function() { var value = attr.value; @@ -1214,11 +1239,23 @@ function checkboxInputType(scope, element, attr, ctrl, options) { 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 : undefined); + }); + }; + + // Allow adding/overriding bound events + if ((options) && (options.$eventList.length)) { + // bind to user-defined events + forEach(options.$eventList, function(ev) { + element.on(ev, listener); }); - }); + } + + if (!options || (options.$defaultEvents)) { + element.on('click', listener); + } ctrl.$render = function() { element[0].checked = ctrl.$viewValue; @@ -1662,25 +1699,22 @@ 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. + * Cancel a pending debounced update. * - * 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`. - * - * @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.$cancelDebounce = function() { + if ( pendingDebounce ) { + $timeout.cancel(pendingDebounce); + pendingDebounce = null; + } + }; + + // update the view value this.$realSetViewValue = function(value) { this.$viewValue = value; @@ -1709,15 +1743,32 @@ 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. + * + * Note that calling this function does not trigger a `$digest`. + * + * @param {string} value Value from the view. + */ this.$setViewValue = function(value, trigger) { var that = this; - trigger = trigger || 'default'; - var debounceDelay = (isObject(this.$options.debounce) ? this.$options.debounce[trigger] : this.$options.debounce) || 0; - - if ( pendingDebounce ) { - $timeout.cancel(pendingDebounce); - pendingDebounce = null; - } + var debounceDelay = (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; @@ -1737,12 +1788,6 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$ // if scope model value and ngModel value are out of sync if (ctrl.$modelValue !== value) { - // Cancel any pending debounced update - if ( pendingDebounce ) { - $timeout.cancel(pendingDebounce); - pendingDebounce = null; - } - var formatters = ctrl.$formatters, idx = formatters.length; @@ -2155,11 +2200,123 @@ 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. If an array is supplied instead, + * multiple events can be specified. 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 model = element(by.binding('user.name')); + var input = element(by.model('user.name')); + var ptor = protractor.getInstance(); + it('should delay model update', function() { + // We need to tell Protractor not to wait for the debounce timeout to resolve + browser.ignoreSynchronization = true; + expect(model.getText()).toEqual('say'); + input.sendKeys(' he'); + ptor.sleep(100); + expect(model.getText()).toEqual('say'); + input.sendKeys('llo'); + expect(model.getText()).toEqual('say'); + ptor.sleep(600); + expect(model.getText()).toEqual('say hello'); + }); + afterEach(function() { + // Don't forget to turn synchronization back on + browser.ignoreSynchronization = false; + }); + +
+ */ var ngModelOptionsDirective = function() { return { - controller: function($scope, $attrs) { + controller: ['$scope', '$attrs', function($scope, $attrs) { + var that = this; this.$options = $scope.$eval($attrs.ngModelOptions); - } + this.$eventList = []; + // Allow adding/overriding bound events + if (this.$options.updateOn) { + if (!isArray(this.$options.updateOn)) { + this.$options.updateOn = [ this.$options.updateOn ]; + } + this.$defaultEvents = false; + // prepare a list of user-defined events + forEach(this.$options.updateOn, function(ev) { + ev = trim(ev).toLowerCase(); + if (ev === 'default') { + that.$defaultEvents = true; + } else { + that.$eventList.push(ev); + } + }); + } else { + this.$defaultEvents = true; + } + }] }; }; \ No newline at end of file diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index eba3028e7bce..1e7df5c5beaf 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -608,6 +608,209 @@ describe('input', function() { }); + describe('ng-model-options 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 checkboxes 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('');