From 1f9d7305fcd7cd520f9cc3bdbd26166a8e4e807f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Ram=C3=B3n=20L=C3=B3pez?= Date: Sun, 5 May 2013 02:18:28 +0200 Subject: [PATCH 1/3] feat($timeout): Add debouncing support to $timeout service This feature allows resetting the timer on an ongoing `$timeout` promise. A fourth optional argument is added to `$timeout` which is the old promise to be reset. I.e.: `promise = $timeout(fn, 2000, true, promise);` This will call `fn()` 2 seconds after the last call to the `$timeout()` function. --- src/ng/timeout.js | 30 +++++++++++++++++++++++++++++- test/ng/timeoutSpec.js | 30 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/ng/timeout.js b/src/ng/timeout.js index 33a4dcde81ed..0986bb685ae3 100644 --- a/src/ng/timeout.js +++ b/src/ng/timeout.js @@ -24,20 +24,48 @@ function $TimeoutProvider() { * In tests you can use {@link ngMock.$timeout `$timeout.flush()`} to * synchronously flush the queue of deferred functions. * + * You can also use `$timeout` to debounce the call of a function using the returned promise + * as the fourth parameter in the next call. See the following example: + * + *
+      *   var debounce;
+      *   var doRealSave = function() {
+      *      // Save model to DB
+      *   }
+      *   $scope.save = function() {
+      *      // debounce call for 2 seconds
+      *      debounce = $timeout(doRealSave, 2000, false, debounce);
+      *   }
+      * 
+ * + * And in the form: + * + *
+      *   
+      * 
+ * * @param {function()} fn A function, whose execution should be delayed. * @param {number=} [delay=0] Delay in milliseconds. * @param {boolean=} [invokeApply=true] If set to `false` skips model dirty checking, otherwise * will invoke `fn` within the {@link ng.$rootScope.Scope#$apply $apply} block. + * @param {Promise=} debounce If set to an outgoing promise, it will reject it before creating + * the new one. This allows debouncing the execution of the function. * @returns {Promise} Promise that will be resolved when the timeout is reached. The value this * promise will be resolved with is the return value of the `fn` function. * */ - function timeout(fn, delay, invokeApply) { + function timeout(fn, delay, invokeApply, debounce) { var deferred = $q.defer(), promise = deferred.promise, skipApply = (isDefined(invokeApply) && !invokeApply), timeoutId; + // debouncing support + if (debounce && debounce.$$timeoutId in deferreds) { + deferreds[debounce.$$timeoutId].reject('debounced'); + $browser.defer.cancel(debounce.$$timeoutId); + } + timeoutId = $browser.defer(function() { try { deferred.resolve(fn()); diff --git a/test/ng/timeoutSpec.js b/test/ng/timeoutSpec.js index 97c8448eedce..6747865cc2b7 100644 --- a/test/ng/timeoutSpec.js +++ b/test/ng/timeoutSpec.js @@ -213,4 +213,34 @@ describe('$timeout', function() { expect(cancelSpy).toHaveBeenCalledOnce(); })); }); + + + describe('debouncing', function() { + it('should allow debouncing tasks', inject(function($timeout) { + var task = jasmine.createSpy('task'), + successtask = jasmine.createSpy('successtask'), + errortask = jasmine.createSpy('errortask'), + promise = null; + + promise = $timeout(task, 10000, true, promise); + promise.then(successtask, errortask); + + expect(task).not.toHaveBeenCalled(); + expect(successtask).not.toHaveBeenCalled(); + expect(errortask).not.toHaveBeenCalled(); + + promise = $timeout(task, 10000, true, promise); + expect(task).not.toHaveBeenCalled(); + expect(successtask).not.toHaveBeenCalled(); + expect(errortask).not.toHaveBeenCalled(); + + $timeout.flush(); + + expect(task).toHaveBeenCalled(); + // it's a different promise, so 'successtask' should not be called but 'errortask' should + expect(successtask).not.toHaveBeenCalled(); + expect(errortask).toHaveBeenCalled(); + })); + + }); }); From cf8c4635743af88a52ca7b3da97f5c1cbe8cf084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Ram=C3=B3n=20L=C3=B3pez?= Date: Wed, 22 May 2013 00:23:13 +0200 Subject: [PATCH 2/3] fix(ngScenario): Restrict radio/checkbox check switch in IE<9 Only flip the element `checked` value when triggering `click` events in IE8 and lower. --- src/ngScenario/browserTrigger.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ngScenario/browserTrigger.js b/src/ngScenario/browserTrigger.js index f74a04c15110..c44713fd33dd 100644 --- a/src/ngScenario/browserTrigger.js +++ b/src/ngScenario/browserTrigger.js @@ -65,7 +65,7 @@ } if (msie < 9) { - if (inputType == 'radio' || inputType == 'checkbox') { + if ((inputType == 'radio' || inputType == 'checkbox') && (eventType == 'click')) { element.checked = !element.checked; } From f3bd71639afca59b80c577f79ecd05bb6746c71c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Ram=C3=B3n=20L=C3=B3pez?= Date: Thu, 30 May 2013 21:16:36 +0200 Subject: [PATCH 3/3] feat(input): Allow custom events and timeouts to trigger model updates By default, any change on the content will trigger an immediate model update and form validation. Now you can override this behavior using the `ng-update-model-on` attribute to bind only to a comma-delimited list of events. I.e. `ng-update-model-on="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-update-model-on="default,mousedown"` Also, a `ng-update-model-debounce` attribute will allow defering the actual model update some time after the last trigger event takes place (debouncing). This feature is not available in radio buttons. I.e. `ng-update-model-debounce="500"` Custom debouncing timeouts can be set for each event if you use an object in `ng-update-model-on`. I.e. `ng-update-model-on="{default: 500, blur: 0}"` You can specify both attributes in any tag so they became the default settings for any child control, although they can be overriden. Closes #1285 --- angularFiles.js | 1 + docs/content/guide/forms.ngdoc | 40 +++++ src/AngularPublic.js | 4 + src/ng/directive/input.js | 249 +++++++++++++++++++++++------- src/ng/directive/ngUpdateModel.js | 97 ++++++++++++ src/ng/timeout.js | 10 +- test/ng/directive/inputSpec.js | 169 ++++++++++++++++++++ test/ng/timeoutSpec.js | 4 +- 8 files changed, 511 insertions(+), 63 deletions(-) create mode 100644 src/ng/directive/ngUpdateModel.js diff --git a/angularFiles.js b/angularFiles.js index 1647ba48481a..ffe6dc7ef936 100755 --- a/angularFiles.js +++ b/angularFiles.js @@ -63,6 +63,7 @@ angularFiles = { 'src/ng/directive/ngStyle.js', 'src/ng/directive/ngSwitch.js', 'src/ng/directive/ngTransclude.js', + 'src/ng/directive/ngUpdateModel.js', 'src/ng/directive/script.js', 'src/ng/directive/select.js', 'src/ng/directive/style.js' diff --git a/docs/content/guide/forms.ngdoc b/docs/content/guide/forms.ngdoc index 0b91fc61f8ee..62ce6744f9d0 100644 --- a/docs/content/guide/forms.ngdoc +++ b/docs/content/guide/forms.ngdoc @@ -180,6 +180,46 @@ 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 `ng-update-model-on` +attribute to bind only to a comma-delimited list of events. I.e. `ng-update-model-on="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-update-model-on="default,mousedown"` + +You can delay the model update/validation using `ng-update-model-debounce`. I.e. `ng-update-model-debounce="500"` will wait for half a second since +the last content change before triggering the model update and form validation. This debouncing feature is not available on radio buttons. + +Custom debouncing timeouts can be set for each event for each event if you use an object in `ng-update-model-on`. +I.e. `ng-update-model-on="{default: 500, blur: 0}"` + +Using the object notation allows any valid Angular expression to be used inside, including data and function calls from the scope. + +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: +
+
+
model = {{user | json}}
+
+ +
+
+ # Custom Validation diff --git a/src/AngularPublic.js b/src/AngularPublic.js index 0c02adeca685..67da68e7b5b5 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -48,6 +48,8 @@ ngValueDirective, ngAttributeAliasDirectives, ngEventDirectives, + ngUpdateModelOnDirective, + ngUpdateModelDebounceDirective, $AnchorScrollProvider, $AnimateProvider, @@ -183,6 +185,8 @@ function publishExternalAPI(angular){ ngChange: ngChangeDirective, required: requiredDirective, ngRequired: requiredDirective, + ngUpdateModelOn: ngUpdateModelOnDirective, + ngUpdateModelDebounce: ngUpdateModelDebounceDirective, ngValue: ngValueDirective }). directive({ diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index cb432c52a5c7..75103f52e29e 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -42,6 +42,12 @@ var inputType = { * @param {string=} ngChange Angular expression to be executed when input changes due to user * interaction with the input element. * @param {boolean=} [ngTrim=true] If set to false Angular will not automatically trim the input. + * @param {integer=} ngUpdateModelDebounce Time in milliseconds to wait since the last registered + * content change before triggering a model update (debouncing). + * @param {string=} ngUpdateModelOn Allows specifying an event or a comma-delimited list of events + * that will trigger a model update. If it is not set, it defaults to any inmediate change. If + * the list contains "default", the original behavior is also kept. You can also specify an + * object in which the key is the event and the value the particular timeout to be applied to it. * * @example @@ -541,6 +547,11 @@ var inputType = { * patterns defined as scope expressions. * @param {string=} ngChange Angular expression to be executed when input changes due to user * interaction with the input element. + * @param {integer=} ngUpdateModelDebounce Time in milliseconds to wait since the last registered + * content change before triggering a model update. + * @param {string=} ngUpdateModelOn Allows specifying an event or a comma-delimited list of events + * that will trigger a model update. If it is not set, it defaults to any inmediate change. If + * the list contains "default", the original behavior is also kept. * * @example @@ -616,6 +627,11 @@ var inputType = { * patterns defined as scope expressions. * @param {string=} ngChange Angular expression to be executed when input changes due to user * interaction with the input element. + * @param {integer=} ngUpdateModelDebounce Time in milliseconds to wait since the last registered + * content change before triggering a model update. + * @param {string=} ngUpdateModelOn Allows specifying an event or a comma-delimited list of events + * that will trigger a model update. If it is not set, it defaults to any inmediate change. If + * the list contains "default", the original behavior is also kept. * * @example @@ -692,6 +708,11 @@ var inputType = { * patterns defined as scope expressions. * @param {string=} ngChange Angular expression to be executed when input changes due to user * interaction with the input element. + * @param {integer=} ngUpdateModelDebounce Time in milliseconds to wait since the last registered + * content change before triggering a model update. + * @param {string=} ngUpdateModelOn Allows specifying an event or a comma-delimited list of events + * that will trigger a model update. If it is not set, it defaults to any inmediate change. If + * the list contains "default", the original behavior is also kept. * * @example @@ -758,6 +779,9 @@ var inputType = { * interaction with the input element. * @param {string} ngValue Angular expression which sets the value to which the expression should * be set when selected. + * @param {string=} ngUpdateModelOn Allows specifying an event or a comma-delimited list of events + * that will trigger a model update. If it is not set, it defaults to any inmediate change. If + * the list contains "default", the original behavior is also kept. * * @example @@ -808,6 +832,11 @@ var inputType = { * @param {string=} ngFalseValue The value to which the expression should be set when not selected. * @param {string=} ngChange Angular expression to be executed when input changes due to user * interaction with the input element. + * @param {integer=} ngUpdateModelDebounce Time in milliseconds to wait since the last registered + * content change before triggering a model update. + * @param {string=} ngUpdateModelOn Allows specifying an event or a comma-delimited list of events + * that will trigger a model update. If it is not set, it defaults to any inmediate change. If + * the list contains "default", the original behavior is also kept. * * @example @@ -878,7 +907,7 @@ function addNativeHtml5Validators(ctrl, validatorName, element) { } } -function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { +function textInputType(scope, element, attr, ctrl, updModOnCtrl, updModlTimCtrl, $timeout, $sniffer, $browser) { var validity = element.prop('validity'); // In composition mode, users are still inputing intermediate text buffer, // hold the listener until composition is done. @@ -896,8 +925,26 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { }); } - var listener = function() { - if (composing) return; + var timeout = null, + eventList, + updateTimeout, + updateDefaultTimeout; + + var isEmpty = function(value) { + return isUndefined(value) || value === '' || value === null || value !== value; + }; + + // Get update model details from controllers + if (isDefined(updModOnCtrl)) { + eventList = updModOnCtrl.$getEventList(); + updateTimeout = updModOnCtrl.$getDebounceTimeout(); + } + + if (isDefined(updModlTimCtrl)) { + updateDefaultTimeout = updModlTimCtrl.$getDefaultTimeout(); + } + + var update = function() { var value = element.val(); // By default we will trim the value @@ -922,41 +969,73 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { } }; - // 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; + var listener = function(event) { + if (composing) return; - var deferListener = function() { - if (!timeout) { - timeout = $browser.defer(function() { - listener(); - timeout = null; - }); - } - }; + var callbackTimeout = (!isEmpty(updateTimeout)) + ? updateTimeout[event.type] || updateTimeout['default'] || updateDefaultTimeout || 0 + : updateDefaultTimeout || 0; - element.on('keydown', function(event) { - var key = event.keyCode; + if (callbackTimeout>0) { + timeout = $timeout(update, callbackTimeout, false, timeout); + } + else { + update(); + } + }; - // ignore - // command modifiers arrows - if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return; + var deferListener = function(ev) { + $browser.defer(function() { + listener(ev); + }); + }; + + var defaultEvents = true; - deferListener(); + // Allow adding/overriding bound events + if (!isEmpty(eventList)) { + defaultEvents = false; + // bind to user-defined events + forEach(eventList.split(','), function(ev) { + ev = trim(ev).toLowerCase(); + if (ev === 'default') { + defaultEvents = true; + } + else { + element.on(ev, listener); + } }); + } + + if (defaultEvents) { - // 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); + // default behavior: bind to input events or keydown+change + + // 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.bind('input', listener); + } else { + element.on('keydown', function(event) { + var key = event.keyCode; + + // ignore + // command modifiers arrows + if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return; + + deferListener('keydown'); + }); + + // 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); @@ -1068,8 +1147,8 @@ function createDateParser(regexp, mapping) { } function createDateInputType(type, regexp, parseDate, format) { - return function dynamicDateInputType(scope, element, attr, ctrl, $sniffer, $browser, $filter) { - textInputType(scope, element, attr, ctrl, $sniffer, $browser); + return function dynamicDateInputType(scope, element, attr, ctrl, updModOnCtrl, updModlTimCtrl, $timeout, $sniffer, $browser, $filter) { + textInputType(scope, element, attr, ctrl, updModOnCtrl, updModlTimCtrl, $timeout, $sniffer, $browser); ctrl.$parsers.push(function(value) { if(ctrl.$isEmpty(value)) { @@ -1119,8 +1198,8 @@ function createDateInputType(type, regexp, parseDate, format) { }; } -function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { - textInputType(scope, element, attr, ctrl, $sniffer, $browser); +function numberInputType(scope, element, attr, ctrl, updModOnCtrl, updModlTimCtrl, $timeout, $sniffer, $browser) { + textInputType(scope, element, attr, ctrl, updModOnCtrl, updModlTimCtrl, $timeout, $sniffer, $browser); ctrl.$parsers.push(function(value) { var empty = ctrl.$isEmpty(value); @@ -1164,8 +1243,8 @@ function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { }); } -function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) { - textInputType(scope, element, attr, ctrl, $sniffer, $browser); +function urlInputType(scope, element, attr, ctrl, updModOnCtrl, updModlTimCtrl, $timeout, $sniffer, $browser) { + textInputType(scope, element, attr, ctrl, updModOnCtrl, updModlTimCtrl, $timeout, $sniffer, $browser); var urlValidator = function(value) { return validate(ctrl, 'url', ctrl.$isEmpty(value) || URL_REGEXP.test(value), value); @@ -1175,8 +1254,8 @@ function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) { ctrl.$parsers.push(urlValidator); } -function emailInputType(scope, element, attr, ctrl, $sniffer, $browser) { - textInputType(scope, element, attr, ctrl, $sniffer, $browser); +function emailInputType(scope, element, attr, ctrl, updModOnCtrl, updModlTimCtrl, $timeout, $sniffer, $browser) { + textInputType(scope, element, attr, ctrl, updModOnCtrl, updModlTimCtrl, $timeout, $sniffer, $browser); var emailValidator = function(value) { return validate(ctrl, 'email', ctrl.$isEmpty(value) || EMAIL_REGEXP.test(value), value); @@ -1186,18 +1265,31 @@ function emailInputType(scope, element, attr, ctrl, $sniffer, $browser) { ctrl.$parsers.push(emailValidator); } -function radioInputType(scope, element, attr, ctrl) { - // make the name unique, if not defined - if (isUndefined(attr.name)) { - element.attr('name', nextUid()); - } +function radioInputType(scope, element, attr, ctrl, updModOnCtrl, updModlTimCtrl, $timeout) { - element.on('click', function() { + // Get update model details from controllers + var eventList = (isDefined(updModOnCtrl)) ? updModOnCtrl.$getEventList() : 'click'; + + var listener = function() { if (element[0].checked) { scope.$apply(function() { ctrl.$setViewValue(attr.value); }); } + }; + + // make the name unique, if not defined + if (isUndefined(attr.name)) { + element.attr('name', nextUid()); + } + + // bind to user-defined/default events + forEach(eventList.split(','), function(ev) { + ev = trim(ev).toLowerCase(); + if (ev === 'default') { + ev = 'click'; + } + element.bind(ev, listener); }); ctrl.$render = function() { @@ -1208,17 +1300,61 @@ function radioInputType(scope, element, attr, ctrl) { attr.$observe('value', ctrl.$render); } -function checkboxInputType(scope, element, attr, ctrl) { - var trueValue = attr.ngTrueValue, - falseValue = attr.ngFalseValue; +function checkboxInputType(scope, element, attr, ctrl, updModOnCtrl, updModlTimCtrl, $timeout) { + var timeout = null, + trueValue = attr.ngTrueValue, + falseValue = attr.ngFalseValue, + eventList, + updateDefaultTimeout, + updateTimeout; + + // Get update model details from controllers + eventList = 'click'; + + // Get update model details from controllers + if (isDefined(updModOnCtrl)) { + eventList = updModOnCtrl.$getEventList(); + updateTimeout = updModOnCtrl.$getDebounceTimeout(); + } - if (!isString(trueValue)) trueValue = true; - if (!isString(falseValue)) falseValue = false; + if (isDefined(updModlTimCtrl)) { + updateDefaultTimeout = updModlTimCtrl.$getDefaultTimeout(); + } - element.on('click', function() { + var update = function() { scope.$apply(function() { ctrl.$setViewValue(element[0].checked); }); + }; + + var listener = function(event) { + + var isEmpty = function(value) { + return isUndefined(value) || value === '' || value === null || value !== value; + }; + + var callbackTimeout = (!isEmpty(updateTimeout)) + ? updateTimeout[event.type] || updateTimeout['default'] || updateDefaultTimeout || 0 + : updateDefaultTimeout || 0; + + if (callbackTimeout>0) { + timeout = $timeout(update, callbackTimeout, false, timeout); + } + else { + update(); + } + }; + + if (!isString(trueValue)) trueValue = true; + if (!isString(falseValue)) falseValue = false; + + // bind to user-defined/default events + forEach(eventList.split(','), function(ev) { + ev = trim(ev).toLowerCase(); + if (ev === 'default') { + ev = 'click'; + } + element.bind(ev, listener); }); ctrl.$render = function() { @@ -1378,14 +1514,15 @@ function checkboxInputType(scope, element, attr, ctrl) { */ -var inputDirective = ['$browser', '$sniffer', '$filter', function($browser, $sniffer, $filter) { + +var inputDirective = ['$browser', '$sniffer', '$timeout', '$filter', function($browser, $sniffer, $timeout, $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, - $browser, $filter); + require: ['?ngModel', '^?ngUpdateModelOn', '^?ngUpdateModelDebounce'], + link: function(scope, element, attr, ctrls) { + if (ctrls[0]) { + (inputType[lowercase(attr.type)] || inputType.text)(scope, element, attr, ctrls[0], ctrls[1], ctrls[2], $timeout, + $sniffer, $browser, $filter); } } }; diff --git a/src/ng/directive/ngUpdateModel.js b/src/ng/directive/ngUpdateModel.js new file mode 100644 index 000000000000..f9e1b97fd373 --- /dev/null +++ b/src/ng/directive/ngUpdateModel.js @@ -0,0 +1,97 @@ +'use strict'; + +/** + * @ngdoc directive + * @name ng.directive:ngUpdateModelOn + * @restrict A + * + * @description + * The `ngUpdateModelOn` directive changes default behavior of model updates. You can customize + * which events will be bound to the `input` elements so that the model update will + * only be triggered when they occur. + * + * This option will be applicable to those `input` elements that descend from the + * element containing the directive. So, if you use `ngUpdateModelOn` on a `form` + * element, the default behavior will be used on the `input` elements within. + * + * See {@link guide/forms this link} for more information about debouncing and custom + * events. + * + * @element ANY + * @param {string} ngUpdateModelOn Allows specifying an event or a comma-delimited list of events + * that will trigger a model update. If it is not set, it defaults to any inmediate change. If + * the list contains "default", the original behavior is also kept. You can also specify an + * object in which the key is the event and the value the particular debouncing timeout to be + * applied to it. + */ + +var SIMPLEOBJECT_TEST = /^\s*?\{(.*)\}\s*?$/; + +var NgUpdateModelOnController = ['$attrs', '$scope', + function UpdateModelOnController($attrs, $scope) { + + var attr = $attrs['ngUpdateModelOn']; + var updateModelOnValue; + var updateModelDebounceValue; + + if (SIMPLEOBJECT_TEST.test(attr)) { + updateModelDebounceValue = $scope.$eval(attr); + var keys = []; + for(var k in updateModelDebounceValue) { + keys.push(k); + } + updateModelOnValue = keys.join(','); + } + else { + updateModelOnValue = attr; + } + + this.$getEventList = function() { + return updateModelOnValue; + }; + + this.$getDebounceTimeout = function() { + return updateModelDebounceValue; + }; +}]; + +var ngUpdateModelOnDirective = [function() { + return { + restrict: 'A', + controller: NgUpdateModelOnController + }; +}]; + + +/** + * @ngdoc directive + * @name ng.directive:ngUpdateModelDebounce + * @restrict A + * + * @description + * The `ngUpdateModelDebounce` directive allows specifying a debounced timeout to model updates so they + * are not triggerer instantly but after the timer has expired. + * + * If you need to specify different timeouts for each event, you can use + * {@link module:ng.directive:ng.directive:ngUpdateModelOn ngUpdateModelOn} directive which the object notation. + * + * @element ANY + * @param {integer} ngUpdateModelDebounce Time in milliseconds to wait since the last registered + * content change before triggering a model update. + */ +var NgUpdateModelDebounceController = ['$attrs', + function UpdateModelDebounceController($attrs) { + + var updateModelDefaultTimeoutValue = $attrs['ngUpdateModelDebounce']; + + this.$getDefaultTimeout = function() { + return updateModelDefaultTimeoutValue; + }; +}]; + +var ngUpdateModelDebounceDirective = [function() { + return { + restrict: 'A', + controller: NgUpdateModelDebounceController + }; +}]; diff --git a/src/ng/timeout.js b/src/ng/timeout.js index 0986bb685ae3..5c2152cdac05 100644 --- a/src/ng/timeout.js +++ b/src/ng/timeout.js @@ -26,8 +26,8 @@ function $TimeoutProvider() { * * You can also use `$timeout` to debounce the call of a function using the returned promise * as the fourth parameter in the next call. See the following example: - * - *
+      *
+      * ```js
       *   var debounce;
       *   var doRealSave = function() {
       *      // Save model to DB
@@ -36,13 +36,13 @@ function $TimeoutProvider() {
       *      // debounce call for 2 seconds
       *      debounce = $timeout(doRealSave, 2000, false, debounce);
       *   }
-      * 
+ * ``` * * And in the form: * - *
+      * ```html
       *   
-      * 
+ * ``` * * @param {function()} fn A function, whose execution should be delayed. * @param {number=} [delay=0] Delay in milliseconds. diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index eba3028e7bce..b58dd1b6b80f 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -608,6 +608,175 @@ describe('input', function() { }); + describe('ng-update-model 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 on text inputs', 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(); + expect(scope.name).toEqual('c'); + })); + + + it('should trigger only after timeout in checkboxes', inject(function($timeout) { + compileInput(''); + + browserTrigger(inputElm, 'click'); + browserTrigger(inputElm, 'click'); + expect(scope.checkbox).toBe(undefined); + $timeout.flush(); + expect(scope.checkbox).toBe(false); + })); + + + it('should allow selecting different debounce timeouts for each event', inject(function($timeout) { + compileInput(''); + + changeInputValueTo('a'); + expect(scope.checkbox).toBe(undefined); + $timeout.flush(4000); + expect(scope.checkbox).toBe(undefined); + $timeout.flush(7000); + 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(''); + + browserTrigger(inputElm, 'click'); + expect(scope.checkbox).toBe(undefined); + $timeout.flush(8000); + expect(scope.checkbox).toBe(undefined); + $timeout.flush(3000); + expect(scope.checkbox).toBe(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(); + expect(scope.name).toEqual('a'); + dealoc(doc); + })); + + }); + + it('should allow complex reference binding', function() { compileInput(''); diff --git a/test/ng/timeoutSpec.js b/test/ng/timeoutSpec.js index 6747865cc2b7..02f31de9fbd9 100644 --- a/test/ng/timeoutSpec.js +++ b/test/ng/timeoutSpec.js @@ -218,7 +218,7 @@ describe('$timeout', function() { describe('debouncing', function() { it('should allow debouncing tasks', inject(function($timeout) { var task = jasmine.createSpy('task'), - successtask = jasmine.createSpy('successtask'), + successtask = jasmine.createSpy('successtask'), errortask = jasmine.createSpy('errortask'), promise = null; @@ -228,7 +228,7 @@ describe('$timeout', function() { expect(task).not.toHaveBeenCalled(); expect(successtask).not.toHaveBeenCalled(); expect(errortask).not.toHaveBeenCalled(); - + promise = $timeout(task, 10000, true, promise); expect(task).not.toHaveBeenCalled(); expect(successtask).not.toHaveBeenCalled();