Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

fix(ngModelOptions): introduce $cancelUpdate to cancel pending updates #7014

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 88 additions & 52 deletions src/ng/directive/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -1577,7 +1577,8 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$

var ngModelGet = $parse($attr.ngModel),
ngModelSet = ngModelGet.assign,
pendingDebounce = null;
pendingDebounce = null,
ctrl = this;

if (!ngModelSet) {
throw minErr('ngModel')('nonassign', "Expression '{0}' is non-assignable. Element: {1}",
Expand Down Expand Up @@ -1658,20 +1659,20 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
if ($error[validationErrorKey]) invalidCount--;
if (!invalidCount) {
toggleValidCss(true);
this.$valid = true;
this.$invalid = false;
ctrl.$valid = true;
ctrl.$invalid = false;
}
} else {
toggleValidCss(false);
this.$invalid = true;
this.$valid = false;
ctrl.$invalid = true;
ctrl.$valid = false;
invalidCount++;
}

$error[validationErrorKey] = !isValid;
toggleValidCss(isValid, validationErrorKey);

parentForm.$setValidity(validationErrorKey, isValid, this);
parentForm.$setValidity(validationErrorKey, isValid, ctrl);
};

/**
Expand All @@ -1685,50 +1686,57 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
* state (ng-pristine class).
*/
this.$setPristine = function () {
this.$dirty = false;
this.$pristine = true;
ctrl.$dirty = false;
ctrl.$pristine = true;
$animate.removeClass($element, DIRTY_CLASS);
$animate.addClass($element, PRISTINE_CLASS);
};

/**
* @ngdoc method
* @name ngModel.NgModelController#$cancelDebounce
* @name ngModel.NgModelController#$cancelUpdate
*
* @description
* Cancel a pending debounced update.
* Cancel an update and reset the input element's value to prevent an update to the `$viewValue`,
* which may be caused by a pending debounced event or because the input is waiting for a some
* future event.
*
* 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.
* 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`. You can run into difficulties if you try to
* update the ngModel's `$modelValue` programmatically before these debounced/future events have
* completed, because Angular's dirty checking mechanism is not able to tell whether the model
* has actually changed or not. This method should be called before directly updating a model
* from the scope in case you have an input with `ng-model-options` that do not include immediate
* update of the default trigger. This is important in order to make sure that this input field
* will be updated with the new value and any pending operation will be canceled.
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this description would benefit from initially layout out the problem that this method solves. For instance:

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. You can run into difficulties if you try to update the ngModel's '$modelValue` programmatically before these debounced/future events have completed, because Angular's dirty checking mechanism is not able to tell whether the model has actually changed or not.

Then you can go on with how to use this method, as you have described already.

this.$cancelDebounce = function() {
if ( pendingDebounce ) {
$timeout.cancel(pendingDebounce);
pendingDebounce = null;
}
this.$cancelUpdate = function() {
$timeout.cancel(pendingDebounce);
ctrl.$render();
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love it when functions are this simple! It just makes the world seem right.


// update the view value
this.$$realSetViewValue = function(value) {
this.$viewValue = value;
ctrl.$viewValue = value;

// change to dirty
if (this.$pristine) {
this.$dirty = true;
this.$pristine = false;
if (ctrl.$pristine) {
ctrl.$dirty = true;
ctrl.$pristine = false;
$animate.removeClass($element, PRISTINE_CLASS);
$animate.addClass($element, DIRTY_CLASS);
parentForm.$setDirty();
}

forEach(this.$parsers, function(fn) {
forEach(ctrl.$parsers, function(fn) {
value = fn(value);
});

if (this.$modelValue !== value) {
this.$modelValue = value;
if (ctrl.$modelValue !== value) {
ctrl.$modelValue = value;
ngModelSet($scope, value);
forEach(this.$viewChangeListeners, function(listener) {
forEach(ctrl.$viewChangeListeners, function(listener) {
try {
listener();
} catch(e) {
Expand Down Expand Up @@ -1764,25 +1772,21 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
* @param {string} trigger Event that triggered the update.
*/
this.$setViewValue = function(value, trigger) {
var that = this;
var debounceDelay = this.$options && (isObject(this.$options.debounce)
? (this.$options.debounce[trigger] || this.$options.debounce['default'] || 0)
: this.$options.debounce) || 0;
var debounceDelay = ctrl.$options && (isObject(ctrl.$options.debounce)
? (ctrl.$options.debounce[trigger] || ctrl.$options.debounce['default'] || 0)
: ctrl.$options.debounce) || 0;

that.$cancelDebounce();
if ( debounceDelay ) {
$timeout.cancel(pendingDebounce);
if (debounceDelay) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good spot on the parenthesis styling

pendingDebounce = $timeout(function() {
pendingDebounce = null;
that.$$realSetViewValue(value);
ctrl.$$realSetViewValue(value);
}, debounceDelay);
} else {
that.$$realSetViewValue(value);
ctrl.$$realSetViewValue(value);
}
};

// model -> value
var ctrl = this;

$scope.$watch(function ngModelWatch() {
var value = ngModelGet($scope);

Expand Down Expand Up @@ -2210,6 +2214,15 @@ var ngValueDirective = function() {
* 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.
*
* Given the nature of `ngModelOptions`, the value displayed inside input fields in the view might
* be different then the value in the actual model. This means that if you update the model you
* should also invoke `$cancelUpdate` 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 `$cancelUpdate` 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.
*
* @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
Expand All @@ -2222,49 +2235,72 @@ var ngValueDirective = function() {
* @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).
form will update the model only when the control loses focus (blur event). If `escape` key is
pressed while the input field is focused, the value is reset to the value in the current model.

<example name="ngModelOptions-directive-blur">
<file name="index.html">
<div ng-controller="Ctrl">
Name:
<input type="text"
ng-model="user.name"
ng-model-options="{ updateOn: 'blur' }" /><br />

Other data:
<input type="text" ng-model="user.data" /><br />

<form name="userForm">
Name:
<input type="text" name="userName"
ng-model="user.name"
ng-model-options="{ updateOn: 'blur' }"
ng-keyup="cancel($event)" /><br />

Other data:
<input type="text" ng-model="user.data" /><br />
</form>
<pre>user.name = <span ng-bind="user.name"></span></pre>
</div>
</file>
<file name="app.js">
function Ctrl($scope) {
$scope.user = { name: 'say', data: '' };
$scope.user = { name: 'say', data: '' };

$scope.cancel = function (e) {
if (e.keyCode == 27) {
$scope.userForm.userName.$cancelUpdate();
}
};
}
</file>
<file name="protractor.js" type="protractor">
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');
});

it('should $cancelUpdate when model changes', function() {
input.sendKeys(' hello');
expect(input.getAttribute('value')).toEqual('say hello');
input.sendKeys(protractor.Key.ESCAPE);
expect(input.getAttribute('value')).toEqual('say');
other.click();
expect(model.getText()).toEqual('say');
});
</file>
</example>

This one shows how to debounce model changes. Model will be updated only 500 milliseconds after last change.
This one shows how to debounce model changes. Model will be updated only 1 sec after last change.
If the `Clear` button is pressed, any debounced action is canceled and the value becomes empty.

<example name="ngModelOptions-directive-debounce">
<file name="index.html">
<div ng-controller="Ctrl">
Name:
<input type="text"
ng-model="user.name"
ng-model-options="{ debounce: 500 }" /><br />
<form name="userForm">
Name:
<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 />
</form>
<pre>user.name = <span ng-bind="user.name"></span></pre>
</div>
</file>
Expand Down Expand Up @@ -2293,4 +2329,4 @@ var ngModelOptionsDirective = function() {
}
}]
};
};
};
40 changes: 33 additions & 7 deletions test/ng/directive/inputSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -847,22 +847,48 @@ describe('input', function() {
dealoc(doc);
}));


it('should allow cancelling pending updates', inject(function($timeout) {
it('should allow canceling pending updates', inject(function($timeout) {
compileInput(
'<form name="test">'+
'<input type="text" ng-model="name" name="alias" '+
'ng-model-options="{ debounce: 10000 }" />'+
'</form>');
'<input type="text" ng-model="name" name="alias" '+
'ng-model-options="{ debounce: 10000 }" />');

changeInputValueTo('a');
expect(scope.name).toEqual(undefined);
$timeout.flush(2000);
scope.test.alias.$cancelDebounce();
scope.form.alias.$cancelUpdate();
expect(scope.name).toEqual(undefined);
$timeout.flush(10000);
expect(scope.name).toEqual(undefined);
}));

it('should reset input val if cancelUpdate called during pending update', function() {
compileInput(
'<input type="text" ng-model="name" name="alias" '+
'ng-model-options="{ updateOn: \'blur\' }" />');
scope.$digest();

changeInputValueTo('a');
expect(inputElm.val()).toBe('a');
scope.form.alias.$cancelUpdate();
expect(inputElm.val()).toBe('');
browserTrigger(inputElm, 'blur');
expect(inputElm.val()).toBe('');
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic! I guess that this fails before your fix?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does.


it('should reset input val if cancelUpdate called during debounce', inject(function($timeout) {
compileInput(
'<input type="text" ng-model="name" name="alias" '+
'ng-model-options="{ debounce: 2000 }" />');
scope.$digest();

changeInputValueTo('a');
expect(inputElm.val()).toBe('a');
scope.form.alias.$cancelUpdate();
expect(inputElm.val()).toBe('');
$timeout.flush(3000);
expect(inputElm.val()).toBe('');
}));

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need another unit test that works over the second case, where there is no debounce but an event that has not yet occurred before the programmatic reset occurs.

});

it('should allow complex reference binding', function() {
Expand Down