refactor(ngModelOptions): move debounce and updateOn logic into NgModelController
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 = '';
}
}
```
This commit is contained in:
committed by
Peter Bacon Darwin
parent
0ef17276e9
commit
adfc322b04
+94
-82
@@ -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);
|
||||
}
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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(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);
|
||||
var deferListener = function(ev) {
|
||||
if (!timeout) {
|
||||
timeout = $browser.defer(function() {
|
||||
listener(ev);
|
||||
timeout = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
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(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 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,11 +1724,11 @@ 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>
|
||||
@@ -1761,14 +1736,27 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
|
||||
* </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;
|
||||
}
|
||||
|
||||
@@ -885,11 +885,12 @@ describe('input', function() {
|
||||
'ng-model-options="{ debounce: 10000, updateOn: \'blur\' }" >' +
|
||||
'<input type="text" ng-model="name" name="alias" />'+
|
||||
'</form>')(scope);
|
||||
scope.$digest();
|
||||
|
||||
var input = doc.find('input').eq(0);
|
||||
input.val('a');
|
||||
inputElm = doc.find('input').eq(0);
|
||||
changeInputValueTo('a');
|
||||
expect(scope.name).toEqual(undefined);
|
||||
browserTrigger(input, 'blur');
|
||||
browserTrigger(inputElm, 'blur');
|
||||
expect(scope.name).toBe(undefined);
|
||||
$timeout.flush(2000);
|
||||
expect(scope.name).toBe(undefined);
|
||||
@@ -898,7 +899,58 @@ describe('input', function() {
|
||||
dealoc(doc);
|
||||
}));
|
||||
|
||||
it('should flush debounced events when calling $commitViewValue directly', function() {
|
||||
compileInput(
|
||||
'<input type="text" ng-model="name" name="alias" '+
|
||||
'ng-model-options="{ debounce: 1000 }" />');
|
||||
|
||||
changeInputValueTo('a');
|
||||
expect(scope.name).toEqual(undefined);
|
||||
scope.form.alias.$commitViewValue();
|
||||
expect(scope.name).toEqual('a');
|
||||
});
|
||||
|
||||
it('should cancel debounced events when calling $commitViewValue', inject(function($timeout) {
|
||||
compileInput(
|
||||
'<input type="text" ng-model="name" name="alias" '+
|
||||
'ng-model-options="{ debounce: 1000 }"/>');
|
||||
|
||||
changeInputValueTo('a');
|
||||
scope.form.alias.$commitViewValue();
|
||||
expect(scope.name).toEqual('a');
|
||||
|
||||
scope.form.alias.$setPristine();
|
||||
$timeout.flush(1000);
|
||||
expect(scope.form.alias.$pristine).toBeTruthy();
|
||||
}));
|
||||
|
||||
it('should reset input val if rollbackViewValue called during pending update', function() {
|
||||
compileInput(
|
||||
'<input type="text" ng-model="name" name="alias" '+
|
||||
'ng-model-options="{ updateOn: \'blur\' }" />');
|
||||
|
||||
changeInputValueTo('a');
|
||||
expect(inputElm.val()).toBe('a');
|
||||
scope.form.alias.$rollbackViewValue();
|
||||
expect(inputElm.val()).toBe('');
|
||||
browserTrigger(inputElm, 'blur');
|
||||
expect(inputElm.val()).toBe('');
|
||||
});
|
||||
|
||||
it('should allow canceling pending updates', inject(function($timeout) {
|
||||
compileInput(
|
||||
'<input type="text" ng-model="name" name="alias" '+
|
||||
'ng-model-options="{ updateOn: \'blur\' }" />');
|
||||
|
||||
changeInputValueTo('a');
|
||||
expect(scope.name).toEqual(undefined);
|
||||
scope.form.alias.$rollbackViewValue();
|
||||
expect(scope.name).toEqual(undefined);
|
||||
browserTrigger(inputElm, 'blur');
|
||||
expect(scope.name).toEqual(undefined);
|
||||
}));
|
||||
|
||||
it('should allow canceling debounced updates', inject(function($timeout) {
|
||||
compileInput(
|
||||
'<input type="text" ng-model="name" name="alias" '+
|
||||
'ng-model-options="{ debounce: 10000 }" />');
|
||||
@@ -906,33 +958,33 @@ describe('input', function() {
|
||||
changeInputValueTo('a');
|
||||
expect(scope.name).toEqual(undefined);
|
||||
$timeout.flush(2000);
|
||||
scope.form.alias.$cancelUpdate();
|
||||
scope.form.alias.$rollbackViewValue();
|
||||
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() {
|
||||
it('should handle model updates correctly even if rollbackViewValue is not invoked', function() {
|
||||
compileInput(
|
||||
'<input type="text" ng-model="name" name="alias" '+
|
||||
'ng-model-options="{ updateOn: \'blur\' }" />');
|
||||
|
||||
changeInputValueTo('a');
|
||||
expect(inputElm.val()).toBe('a');
|
||||
scope.form.alias.$cancelUpdate();
|
||||
expect(inputElm.val()).toBe('');
|
||||
scope.$apply(function() {
|
||||
scope.name = 'b';
|
||||
});
|
||||
browserTrigger(inputElm, 'blur');
|
||||
expect(inputElm.val()).toBe('');
|
||||
expect(scope.name).toBe('b');
|
||||
});
|
||||
|
||||
it('should reset input val if cancelUpdate called during debounce', inject(function($timeout) {
|
||||
it('should reset input val if rollbackViewValue called during debounce', inject(function($timeout) {
|
||||
compileInput(
|
||||
'<input type="text" ng-model="name" name="alias" '+
|
||||
'ng-model-options="{ debounce: 2000 }" />');
|
||||
|
||||
changeInputValueTo('a');
|
||||
expect(inputElm.val()).toBe('a');
|
||||
scope.form.alias.$cancelUpdate();
|
||||
scope.form.alias.$rollbackViewValue();
|
||||
expect(inputElm.val()).toBe('');
|
||||
$timeout.flush(3000);
|
||||
expect(inputElm.val()).toBe('');
|
||||
|
||||
Reference in New Issue
Block a user