Permalink
Browse files

refactor(ngModelOptions): move debounce and updateOn logic into NgMod…

…elController

Move responsibility for pending and debouncing model updates into `NgModelController`.
Now input directives are only responsible for capturing changes to the input element's
value and then calling `$setViewValue` with the new value.

Calls to `$setViewValue(value)` change the `$viewValue` property but these changes are
not committed to the `$modelValue` until an `updateOn` trigger occurs (and any related
`debounce` has resolved).

The `$$lastCommittedViewValue` is now stored when `$setViewValue(value)` updates
the `$viewValue`, which allows the view to be "reset" by calling `$rollbackViewValue()`.

The new `$commitViewValue()` method allows developers to force the `$viewValue` to be
committed through to the `$modelValue` immediately, ignoring `updateOn` triggers and
`debounce` delays.

BREAKING CHANGE:

This commit changes the API on `NgModelController`, both semantically and
in terms of adding and renaming methods.

* `$setViewValue(value)` -
This method still changes the `$viewValue` but does not immediately commit this
change through to the `$modelValue` as it did previously.
Now the value is committed only when a trigger specified in an associated
`ngModelOptions` directive occurs. If `ngModelOptions` also has a `debounce` delay
specified for the trigger then the change will also be debounced before being
committed.
In most cases this should not have a significant impact on how `NgModelController`
is used: If `updateOn` includes `default` then `$setViewValue` will trigger
a (potentially debounced) commit immediately.
* `$cancelUpdate()` - is renamed to `$rollbackViewValue()` and has the same meaning,
which is to revert the current `$viewValue` back to the `$lastCommittedViewValue`,
to cancel any pending debounced updates and to re-render the input.

To migrate code that used `$cancelUpdate()` follow the example below:

Before:

```
  $scope.resetWithCancel = function (e) {
    if (e.keyCode == 27) {
      $scope.myForm.myInput1.$cancelUpdate();
      $scope.myValue = '';
    }
  };
```

After:

```
  $scope.resetWithCancel = function (e) {
    if (e.keyCode == 27) {
      $scope.myForm.myInput1.$rollbackViewValue();
      $scope.myValue = '';
    }
  }
```
  • Loading branch information...
shahata authored and petebacondarwin committed May 8, 2014
1 parent 0ef1727 commit adfc322b04a58158fb9697e5b99aab9ca63c80bb
Showing with 155 additions and 91 deletions.
  1. +92 −80 src/ng/directive/input.js
  2. +63 −11 test/ng/directive/inputSpec.js
View
@@ -16,7 +16,7 @@ var DATETIMELOCAL_REGEXP = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d)$/;
var WEEK_REGEXP = /^(\d{4})-W(\d\d)$/;
var MONTH_REGEXP = /^(\d{4})-(\d\d)$/;
var TIME_REGEXP = /^(\d\d):(\d\d)$/;
var DEFAULT_REGEXP = /(\b|^)default(\b|$)/;
var DEFAULT_REGEXP = /(\s+|^)default(\s+|$)/;
var inputType = {
@@ -934,51 +934,42 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) {
}
};
// Allow adding/overriding bound events
if (ctrl.$options && ctrl.$options.updateOn) {
// bind to user-defined events
element.on(ctrl.$options.updateOn, listener);
}
// setup default events if requested
if (!ctrl.$options || ctrl.$options.updateOnDefault) {
// if the browser does support "input" event, we are fine - except on IE9 which doesn't fire the
// input event on backspace, delete or cut
if ($sniffer.hasEvent('input')) {
element.on('input', listener);
} else {
var timeout;
var deferListener = function(ev) {
if (!timeout) {
timeout = $browser.defer(function() {
listener(ev);
timeout = null;
});
}
};
// 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 deferListener = function(ev) {
if (!timeout) {
timeout = $browser.defer(function() {
listener(ev);
timeout = null;
});
}
};
element.on('keydown', function(event) {
var key = event.keyCode;
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;
// ignore
// command modifiers arrows
if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return;
deferListener(event);
});
deferListener(event);
});
// if user modifies input value using context menu in IE, we need "paste" and "cut" events to catch it
if ($sniffer.hasEvent('paste')) {
element.on('paste cut', deferListener);
}
// if user 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);
};
@@ -1221,15 +1212,7 @@ function radioInputType(scope, element, attr, ctrl) {
}
};
// Allow adding/overriding bound events
if (ctrl.$options && ctrl.$options.updateOn) {
// bind to user-defined events
element.on(ctrl.$options.updateOn, listener);
}
if (!ctrl.$options || ctrl.$options.updateOnDefault) {
element.on('click', listener);
}
element.on('click', listener);
ctrl.$render = function() {
var value = attr.value;
@@ -1252,15 +1235,7 @@ function checkboxInputType(scope, element, attr, ctrl) {
});
};
// Allow adding/overriding bound events
if (ctrl.$options && ctrl.$options.updateOn) {
// bind to user-defined events
element.on(ctrl.$options.updateOn, listener);
}
if (!ctrl.$options || ctrl.$options.updateOnDefault) {
element.on('click', listener);
}
element.on('click', listener);
ctrl.$render = function() {
element[0].checked = ctrl.$viewValue;
@@ -1704,22 +1679,22 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
/**
* @ngdoc method
* @name ngModel.NgModelController#$cancelUpdate
* @name ngModel.NgModelController#$rollbackViewValue
*
* @description
* Cancel an update and reset the input element's value to prevent an update to the `$viewValue`,
* Cancel an update and reset the input element's value to prevent an update to the `$modelValue`,
* which may be caused by a pending debounced event or because the input is waiting for a some
* future event.
*
* If you have an input that uses `ng-model-options` to set up debounced events or events such
* as blur you can have a situation where there is a period when the value of the input element
* is out of synch with the ngModel's `$viewValue`.
* as blur you can have a situation where there is a period when the `$viewValue`
* is out of synch with the ngModel's `$modelValue`.
*
* In this case, you can run into difficulties if you try to update the ngModel's `$modelValue`
* programmatically before these debounced/future events have resolved/occurred, because Angular's
* dirty checking mechanism is not able to tell whether the model has actually changed or not.
*
* The `$cancelUpdate()` method should be called before programmatically changing the model of an
* The `$rollbackViewValue()` method should be called before programmatically changing the model of an
* input which may have such events pending. This is important in order to make sure that the
* input field will be updated with the new model value and any pending operations are cancelled.
*
@@ -1730,7 +1705,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
* .controller('CancelUpdateCtrl', function($scope) {
* $scope.resetWithCancel = function (e) {
* if (e.keyCode == 27) {
* $scope.myForm.myInput1.$cancelUpdate();
* $scope.myForm.myInput1.$rollbackViewValue();
* $scope.myValue = '';
* }
* };
@@ -1749,26 +1724,39 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
* <p>Now see what happens if you start typing then press the Escape key</p>
*
* <form name="myForm" ng-model-options="{ updateOn: 'blur' }">
* <p>With $cancelUpdate()</p>
* <p>With $rollbackViewValue()</p>
* <input name="myInput1" ng-model="myValue" ng-keydown="resetWithCancel($event)"><br/>
* myValue: "{{ myValue }}"
*
* <p>Without $cancelUpdate()</p>
* <p>Without $rollbackViewValue()</p>
* <input name="myInput2" ng-model="myValue" ng-keydown="resetWithoutCancel($event)"><br/>
* myValue: "{{ myValue }}"
* </form>
* </div>
* </file>
* </example>
*/
this.$cancelUpdate = function() {
this.$rollbackViewValue = function() {
$timeout.cancel(pendingDebounce);
ctrl.$viewValue = ctrl.$$lastCommittedViewValue;
ctrl.$render();
};
// update the view value
this.$$realSetViewValue = function(value) {
ctrl.$viewValue = value;
/**
* @ngdoc method
* @name ngModel.NgModelController#$commitViewValue
*
* @description
* Commit a pending update to the `$modelValue`.
*
* Updates may be pending by a debounced event or because the input is waiting for a some future
* event defined in `ng-model-options`. this method is rarely needed as `NgModelController`
* usually handles calling this in response to input events.
*/
this.$commitViewValue = function() {
var value = ctrl.$viewValue;
ctrl.$$lastCommittedViewValue = value;
$timeout.cancel(pendingDebounce);
// change to dirty
if (ctrl.$pristine) {
@@ -1813,6 +1801,9 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
*
* Lastly, all the registered change listeners, in the `$viewChangeListeners` list, are called.
*
* In case the {@link ng.directive:ngModelOptions ngModelOptions} directive is used with `updateOn`
* and the `default` trigger is not listed, all those actions will remain pending until one of the
* `updateOn` events is triggered on the DOM element.
* All these actions will be debounced if the {@link ng.directive:ngModelOptions ngModelOptions}
* directive is used with a custom debounce for this particular event.
*
@@ -1822,6 +1813,13 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
* @param {string} trigger Event that triggered the update.
*/
this.$setViewValue = function(value, trigger) {
ctrl.$viewValue = value;
if (!ctrl.$options || ctrl.$options.updateOnDefault) {
ctrl.$$debounceViewValueCommit(trigger);
}
};
this.$$debounceViewValueCommit = function(trigger) {
var debounceDelay = 0,
options = ctrl.$options,
debounce;
@@ -1840,10 +1838,10 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
$timeout.cancel(pendingDebounce);
if (debounceDelay) {
pendingDebounce = $timeout(function() {
ctrl.$$realSetViewValue(value);
ctrl.$commitViewValue();
}, debounceDelay);
} else {
ctrl.$$realSetViewValue(value);
ctrl.$commitViewValue();
}
};
@@ -1863,7 +1861,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
}
if (ctrl.$viewValue !== value) {
ctrl.$viewValue = value;
ctrl.$viewValue = ctrl.$$lastCommittedViewValue = value;
ctrl.$render();
}
}
@@ -2001,6 +1999,16 @@ var ngModelDirective = function() {
scope.$on('$destroy', function() {
formCtrl.$removeControl(modelCtrl);
});
},
post: function(scope, element, attr, ctrls) {
var modelCtrl = ctrls[0];
if (modelCtrl.$options && modelCtrl.$options.updateOn) {
element.on(modelCtrl.$options.updateOn, function(ev) {
scope.$apply(function() {
modelCtrl.$$debounceViewValueCommit(ev && ev.type);
});
});
}
}
}
};
@@ -2279,14 +2287,18 @@ var ngValueDirective = function() {
*
* Given the nature of `ngModelOptions`, the value displayed inside input fields in the view might
* be different than the value in the actual model. This means that if you update the model you
* should also invoke {@link ngModel.NgModelController `$cancelUpdate`} on the relevant input field in
* should also invoke {@link ngModel.NgModelController `$rollbackViewValue`} on the relevant input field in
* order to make sure it is synchronized with the model and that any debounced action is canceled.
*
* The easiest way to reference the control's {@link ngModel.NgModelController `$cancelUpdate`}
* The easiest way to reference the control's {@link ngModel.NgModelController `$rollbackViewValue`}
* method is by making sure the input is placed inside a form that has a `name` attribute. This is
* important because `form` controllers are published to the related scope under the name in their
* `name` attribute.
*
* Any pending changes will take place immediately when an enclosing form is submitted via the
* `submit` event. Note that `ngClick` events will occur before the model is updated. Use `ngSubmit`
* to have access to the updated model.
*
* @param {Object} ngModelOptions options to apply to the current model. Valid keys are:
* - `updateOn`: string specifying which event should be the input bound to. You can set several
* events using an space delimited list. There is a special event called `default` that
@@ -2324,7 +2336,7 @@ var ngValueDirective = function() {
$scope.cancel = function (e) {
if (e.keyCode == 27) {
$scope.userForm.userName.$cancelUpdate();
$scope.userForm.userName.$rollbackViewValue();
}
};
}
@@ -2342,7 +2354,7 @@ var ngValueDirective = function() {
expect(model.getText()).toEqual('say hello');
});
it('should $cancelUpdate when model changes', function() {
it('should $rollbackViewValue when model changes', function() {
input.sendKeys(' hello');
expect(input.getAttribute('value')).toEqual('say hello');
input.sendKeys(protractor.Key.ESCAPE);
@@ -2364,7 +2376,7 @@ var ngValueDirective = function() {
<input type="text" name="userName"
ng-model="user.name"
ng-model-options="{ debounce: 1000 }" />
<button ng-click="userForm.userName.$cancelUpdate(); user.name=''">Clear</button><br />
<button ng-click="userForm.userName.$rollbackViewValue(); user.name=''">Clear</button><br />
</form>
<pre>user.name = <span ng-bind="user.name"></span></pre>
</div>
@@ -2382,13 +2394,13 @@ var ngModelOptionsDirective = function() {
var that = this;
this.$options = $scope.$eval($attrs.ngModelOptions);
// Allow adding/overriding bound events
if (this.$options.updateOn) {
if (this.$options.updateOn !== undefined) {
this.$options.updateOnDefault = false;
// extract "default" pseudo-event from list of events that can trigger a model update
this.$options.updateOn = this.$options.updateOn.replace(DEFAULT_REGEXP, function() {
this.$options.updateOn = trim(this.$options.updateOn.replace(DEFAULT_REGEXP, function() {
that.$options.updateOnDefault = true;
return ' ';
});
}));
} else {
this.$options.updateOnDefault = true;
}
Oops, something went wrong.

2 comments on commit adfc322

@jonasflesch

This comment has been minimized.

Show comment
Hide comment
@jonasflesch

jonasflesch May 30, 2014

I was using the code above to make my fields dirty inside a directive. It just stopped working after this change.

How can I do it instead?

form[field].$setViewValue(form[field].$viewValue);

I was using the code above to make my fields dirty inside a directive. It just stopped working after this change.

How can I do it instead?

form[field].$setViewValue(form[field].$viewValue);
@shahata

This comment has been minimized.

Show comment
Hide comment
@shahata

shahata May 30, 2014

Contributor

Not sure what you mean, can you open an issue with a plunkr example of the problem?

Contributor

shahata replied May 30, 2014

Not sure what you mean, can you open an issue with a plunkr example of the problem?

Please sign in to comment.