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('');