From 0efe03962bf0ac5e264037a860ee875277d51299 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 to 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 deferring 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 overridden. Closes #1285, #2129 --- docs/content/guide/forms.ngdoc | 77 +++++++++ src/ng/directive/input.js | 293 +++++++++++++++++++++++---------- test/ng/directive/inputSpec.js | 257 +++++++++++++++++++++++++++++ 3 files changed, 544 insertions(+), 83 deletions(-) diff --git a/docs/content/guide/forms.ngdoc b/docs/content/guide/forms.ngdoc index 0a0eed47dce2..4fbc47253399 100644 --- a/docs/content/guide/forms.ngdoc +++ b/docs/content/guide/forms.ngdoc @@ -181,6 +181,83 @@ 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 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"] }"` + +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/ng/directive/input.js b/src/ng/directive/input.js index 8306f8bebb4a..3758f9400300 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 = /\wdefault\w/; var inputType = { @@ -877,8 +878,9 @@ function addNativeHtml5Validators(ctrl, validatorName, element) { } } -function textInputType(scope, element, attr, ctrl, options, $sniffer, $browser) { +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, 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; // 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, 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 ((ctrl.$options.updateOn) && (ctrl.$options.updateOn.length)) { + // 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.updateOn || (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(); + }); + + // 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); @@ -1067,8 +1079,8 @@ function createDateParser(regexp, mapping) { } function createDateInputType(type, regexp, parseDate, format) { - return function dynamicDateInputType(scope, element, attr, ctrl, options, $sniffer, $browser, $filter) { - textInputType(scope, element, attr, ctrl, options, $sniffer, $browser); + return function dynamicDateInputType(scope, element, attr, ctrl, $sniffer, $browser, $filter) { + textInputType(scope, element, attr, ctrl, $sniffer, $browser); ctrl.$parsers.push(function(value) { if(ctrl.$isEmpty(value)) { @@ -1118,8 +1130,8 @@ function createDateInputType(type, regexp, parseDate, format) { }; } -function numberInputType(scope, element, attr, ctrl, options, $sniffer, $browser) { - textInputType(scope, element, attr, ctrl, options, $sniffer, $browser); +function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { + textInputType(scope, element, attr, ctrl, $sniffer, $browser); ctrl.$parsers.push(function(value) { var empty = ctrl.$isEmpty(value); @@ -1163,8 +1175,8 @@ function numberInputType(scope, element, attr, ctrl, options, $sniffer, $browser }); } -function urlInputType(scope, element, attr, ctrl, options, $sniffer, $browser) { - textInputType(scope, element, attr, ctrl, options, $sniffer, $browser); +function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) { + textInputType(scope, element, attr, ctrl, $sniffer, $browser); var urlValidator = function(value) { return validate(ctrl, 'url', ctrl.$isEmpty(value) || URL_REGEXP.test(value), value); @@ -1174,8 +1186,8 @@ function urlInputType(scope, element, attr, ctrl, options, $sniffer, $browser) { ctrl.$parsers.push(urlValidator); } -function emailInputType(scope, element, attr, ctrl, options, $sniffer, $browser) { - textInputType(scope, element, attr, ctrl, options, $sniffer, $browser); +function emailInputType(scope, element, attr, ctrl, $sniffer, $browser) { + textInputType(scope, element, attr, ctrl, $sniffer, $browser); var emailValidator = function(value) { return validate(ctrl, 'email', ctrl.$isEmpty(value) || EMAIL_REGEXP.test(value), value); @@ -1185,19 +1197,29 @@ function emailInputType(scope, element, attr, ctrl, options, $sniffer, $browser) ctrl.$parsers.push(emailValidator); } -function radioInputType(scope, element, attr, ctrl, options) { +function radioInputType(scope, element, attr, ctrl) { // make the name unique, if not defined if (isUndefined(attr.name)) { 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.updateOn) && (ctrl.$options.updateOn.length)) { + // bind to user-defined events + element.on(ctrl.$options.updateOn, listener); + } + + if (!ctrl.$options.updateOn || (ctrl.$options.updateOnDefault)) { + element.on('click', listener); + } ctrl.$render = function() { var value = attr.value; @@ -1207,18 +1229,28 @@ function radioInputType(scope, element, attr, ctrl, options) { attr.$observe('value', ctrl.$render); } -function checkboxInputType(scope, element, attr, ctrl, options) { +function checkboxInputType(scope, element, attr, ctrl) { var trueValue = attr.ngTrueValue, falseValue = attr.ngFalseValue; 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.updateOn) && (ctrl.$options.updateOn.length)) { + // bind to user-defined events + element.on(ctrl.$options.updateOn, listener); + } + + if (!ctrl.$options.updateOn || (ctrl.$options.updateOnDefault)) { + element.on('click', listener); + } ctrl.$render = function() { element[0].checked = ctrl.$viewValue; @@ -1383,7 +1415,7 @@ var inputDirective = ['$browser', '$sniffer', '$filter', function($browser, $sni require: ['?ngModel', '^?ngModelOptions'], link: function(scope, element, attr, ctrls) { if (ctrls[0]) { - (inputType[lowercase(attr.type)] || inputType.text)(scope, element, attr, ctrls[0], ctrls[1], $sniffer, + (inputType[lowercase(attr.type)] || inputType.text)(scope, element, attr, ctrls[0], $sniffer, $browser, $filter); } } @@ -1662,26 +1694,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.$realSetViewValue = 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 @@ -1709,22 +1738,46 @@ 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; - trigger = trigger || 'default'; - var debounceDelay = (isObject(this.$options.debounce) ? this.$options.debounce[trigger] : this.$options.debounce) || 0; + var debounceDelay = (isObject(this.$options.debounce) + ? (this.$options.debounce[trigger] || this.$options.debounce['default'] || 0) + : this.$options.debounce) || 0; - if ( pendingDebounce ) { - $timeout.cancel(pendingDebounce); - pendingDebounce = null; - } + that.$cancelDebounce(); if ( debounceDelay ) { pendingDebounce = $timeout(function() { pendingDebounce = null; - that.$realSetViewValue(value); + that.$$realSetViewValue(value); }, debounceDelay); } else { - that.$realSetViewValue(value); + that.$$realSetViewValue(value); } }; @@ -1737,12 +1790,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 +2202,91 @@ 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 ngModelOptionsDirective = function() { return { - controller: function($scope, $attrs) { + controller: ['$scope', '$attrs', function($scope, $attrs) { + var that = this; this.$options = $scope.$eval($attrs.ngModelOptions); - } + // Allow adding/overriding bound events + if (this.$options.updateOn) { + // look up for default in event list + this.$options.updateOnDefault = DEFAULT_REGEXP.test(this.$options.updateOn); + this.$options.updateOn = this.$options.updateOn.replace(DEFAULT_REGEXP, ''); + } 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..fe74540aab08 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('');