diff --git a/src/ng/directive/ngModel.js b/src/ng/directive/ngModel.js
index cdba3c11de28..447802541518 100644
--- a/src/ng/directive/ngModel.js
+++ b/src/ng/directive/ngModel.js
@@ -878,6 +878,154 @@ NgModelController.prototype = {
*/
$overrideModelOptions: function(options) {
this.$options = this.$options.createChild(options);
+ },
+
+ /**
+ * @ngdoc method
+ *
+ * @name ngModel.NgModelController#$processModelValue
+
+ * @description
+ *
+ * Runs the model -> view pipeline on the current
+ * {@link ngModel.NgModelController#$modelValue $modelValue}.
+ *
+ * The following actions are performed by this method:
+ *
+ * - the `$modelValue` is run through the {@link ngModel.NgModelController#$formatters $formatters}
+ * and the result is set to the {@link ngModel.NgModelController#$viewValue $viewValue}
+ * - the `ng-empty` or `ng-not-empty` class is set on the element
+ * - if the `$viewValue` has changed:
+ * - {@link ngModel.NgModelController#$render $render} is called on the control
+ * - the {@link ngModel.NgModelController#$validators $validators} are run and
+ * the validation status is set.
+ *
+ * This method is called by ngModel internally when the bound scope value changes.
+ * Application developers usually do not have to call this function themselves.
+ *
+ * This function can be used when the `$viewValue` or the rendered DOM value are not correctly
+ * formatted and the `$modelValue` must be run through the `$formatters` again.
+ *
+ * #### Example
+ *
+ * Consider a text input with an autocomplete list (for fruit), where the items are
+ * objects with a name and an id.
+ * A user enters `ap` and then selects `Apricot` from the list.
+ * Based on this, the autocomplete widget will call `$setViewValue({name: 'Apricot', id: 443})`,
+ * but the rendered value will still be `ap`.
+ * The widget can then call `ctrl.$processModelValue()` to run the model -> view
+ * pipeline again, which formats the object to the string `Apricot`,
+ * then updates the `$viewValue`, and finally renders it in the DOM.
+ *
+ *
+
+
+
+ *
+ *
+ */
+ $processModelValue: function() {
+ var viewValue = this.$$format();
+
+ if (this.$viewValue !== viewValue) {
+ this.$$updateEmptyClasses(viewValue);
+ this.$viewValue = this.$$lastCommittedViewValue = viewValue;
+ this.$render();
+ // It is possible that model and view value have been updated during render
+ this.$$runValidators(this.$modelValue, this.$viewValue, noop);
+ }
+ },
+
+ /**
+ * This method is called internally to run the $formatters on the $modelValue
+ */
+ $$format: function() {
+ var formatters = this.$formatters,
+ idx = formatters.length;
+
+ var viewValue = this.$modelValue;
+ while (idx--) {
+ viewValue = formatters[idx](viewValue);
+ }
+
+ return viewValue;
+ },
+
+ /**
+ * This method is called internally when the bound scope value changes.
+ */
+ $$setModelValue: function(modelValue) {
+ this.$modelValue = this.$$rawModelValue = modelValue;
+ this.$$parserValid = undefined;
+ this.$processModelValue();
}
};
@@ -894,30 +1042,14 @@ function setupModelWatcher(ctrl) {
var modelValue = ctrl.$$ngModelGet(scope);
// if scope model value and ngModel value are out of sync
- // TODO(perf): why not move this to the action fn?
+ // This cannot be moved to the action function, because it would not catch the
+ // case where the model is changed in the ngChange function or the model setter
if (modelValue !== ctrl.$modelValue &&
- // checks for NaN is needed to allow setting the model to NaN when there's an asyncValidator
- // eslint-disable-next-line no-self-compare
- (ctrl.$modelValue === ctrl.$modelValue || modelValue === modelValue)
+ // checks for NaN is needed to allow setting the model to NaN when there's an asyncValidator
+ // eslint-disable-next-line no-self-compare
+ (ctrl.$modelValue === ctrl.$modelValue || modelValue === modelValue)
) {
- ctrl.$modelValue = ctrl.$$rawModelValue = modelValue;
- ctrl.$$parserValid = undefined;
-
- var formatters = ctrl.$formatters,
- idx = formatters.length;
-
- var viewValue = modelValue;
- while (idx--) {
- viewValue = formatters[idx](viewValue);
- }
- if (ctrl.$viewValue !== viewValue) {
- ctrl.$$updateEmptyClasses(viewValue);
- ctrl.$viewValue = ctrl.$$lastCommittedViewValue = viewValue;
- ctrl.$render();
-
- // It is possible that model and view value have been updated during render
- ctrl.$$runValidators(ctrl.$modelValue, ctrl.$viewValue, noop);
- }
+ ctrl.$$setModelValue(modelValue);
}
return modelValue;
diff --git a/test/ng/directive/ngModelSpec.js b/test/ng/directive/ngModelSpec.js
index 7157d4aded87..7e839e962865 100644
--- a/test/ng/directive/ngModelSpec.js
+++ b/test/ng/directive/ngModelSpec.js
@@ -603,6 +603,113 @@ describe('ngModel', function() {
expect(ctrl.$modelValue).toBeNaN();
}));
+
+ describe('$processModelValue', function() {
+ // Emulate setting the model on the scope
+ function setModelValue(ctrl, value) {
+ ctrl.$modelValue = ctrl.$$rawModelValue = value;
+ ctrl.$$parserValid = undefined;
+ }
+
+ it('should run the model -> view pipeline', function() {
+ var log = [];
+ var input = ctrl.$$element;
+
+ ctrl.$formatters.unshift(function(value) {
+ log.push(value);
+ return value + 2;
+ });
+
+ ctrl.$formatters.unshift(function(value) {
+ log.push(value);
+ return value + '';
+ });
+
+ spyOn(ctrl, '$render');
+
+ setModelValue(ctrl, 3);
+
+ expect(ctrl.$modelValue).toBe(3);
+
+ ctrl.$processModelValue();
+
+ expect(ctrl.$modelValue).toBe(3);
+ expect(log).toEqual([3, 5]);
+ expect(ctrl.$viewValue).toBe('5');
+ expect(ctrl.$render).toHaveBeenCalledOnce();
+ });
+
+ it('should add the validation and empty-state classes',
+ inject(function($compile, $rootScope, $animate) {
+ var input = $compile('')($rootScope);
+ $rootScope.$digest();
+
+ spyOn($animate, 'addClass');
+ spyOn($animate, 'removeClass');
+
+ var ctrl = input.controller('ngModel');
+
+ expect(input).toHaveClass('ng-empty');
+ expect(input).toHaveClass('ng-valid');
+
+ setModelValue(ctrl, 3);
+ ctrl.$processModelValue();
+
+ // $animate adds / removes classes in the $$postDigest, which
+ // we cannot trigger with $digest, because that would set the model from the scope,
+ // so we simply check if the functions have been called
+ expect($animate.removeClass.calls.mostRecent().args[0][0]).toBe(input[0]);
+ expect($animate.removeClass.calls.mostRecent().args[1]).toBe('ng-empty');
+
+ expect($animate.addClass.calls.mostRecent().args[0][0]).toBe(input[0]);
+ expect($animate.addClass.calls.mostRecent().args[1]).toBe('ng-not-empty');
+
+ $animate.removeClass.calls.reset();
+ $animate.addClass.calls.reset();
+
+ setModelValue(ctrl, 35);
+ ctrl.$processModelValue();
+
+ expect($animate.addClass.calls.argsFor(1)[0][0]).toBe(input[0]);
+ expect($animate.addClass.calls.argsFor(1)[1]).toBe('ng-invalid');
+
+ expect($animate.addClass.calls.argsFor(2)[0][0]).toBe(input[0]);
+ expect($animate.addClass.calls.argsFor(2)[1]).toBe('ng-invalid-maxlength');
+ })
+ );
+
+ // this is analogue to $setViewValue
+ it('should run the model -> view pipeline even if the value has not changed', function() {
+ var log = [];
+
+ ctrl.$formatters.unshift(function(value) {
+ log.push(value);
+ return value + 2;
+ });
+
+ ctrl.$formatters.unshift(function(value) {
+ log.push(value);
+ return value + '';
+ });
+
+ spyOn(ctrl, '$render');
+
+ setModelValue(ctrl, 3);
+ ctrl.$processModelValue();
+
+ expect(ctrl.$modelValue).toBe(3);
+ expect(ctrl.$viewValue).toBe('5');
+ expect(log).toEqual([3, 5]);
+ expect(ctrl.$render).toHaveBeenCalledOnce();
+
+ ctrl.$processModelValue();
+ expect(ctrl.$modelValue).toBe(3);
+ expect(ctrl.$viewValue).toBe('5');
+ expect(log).toEqual([3, 5, 3, 5]);
+ // $render() is not called if the viewValue didn't change
+ expect(ctrl.$render).toHaveBeenCalledOnce();
+ });
+ });
});