diff --git a/src/ng/directive/ngModel.js b/src/ng/directive/ngModel.js
index cdba3c11de28..2b5f98063623 100644
--- a/src/ng/directive/ngModel.js
+++ b/src/ng/directive/ngModel.js
@@ -878,6 +878,158 @@ 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.
+ *
+ * For 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.
+ *
+ * @example
+ *
+
+ angular.module('inputExample', [])
+ .controller('inputController', function($scope) {
+ $scope.items = [
+ {name: 'Apricot', id: 443},
+ {name: 'Clementine', id: 972},
+ {name: 'Durian', id: 169},
+ {name: 'Jackfruit', id: 982},
+ {name: 'Strawberry', id: 863}
+ ];
+ })
+ .component('basicAutocomplete', {
+ bindings: {
+ items: '<',
+ onSelect: '&'
+ },
+ templateUrl: 'autocomplete.html',
+ controller: function($element, $scope) {
+ var that = this;
+ var ngModel;
+
+ that.$postLink = function() {
+ ngModel = $element.find('input').controller('ngModel');
+
+ ngModel.$formatters.push(function(value) {
+ return (value && value.name) || value;
+ });
+
+ ngModel.$parsers.push(function(value) {
+ var match = value;
+ for (var i = 0; i < that.items.length; i++) {
+ if (that.items[i].name === value) {
+ match = that.items[i];
+ break;
+ }
+ }
+
+ return match;
+ });
+ };
+
+ that.setOnMatch = function() {
+ if (angular.isObject(that.searchTerm)) {
+ that.onSelect({item: that.searchTerm});
+ }
+ };
+
+ that.selectItem = function(item) {
+ ngModel.$setViewValue(item);
+ ngModel.$processModelValue();
+ };
+ }
+ });
+
+
+
+
+ Search Fruit:
+
+
+
+ Model:
+
{{selectedFruit | json}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ *
+ *
+ */
+ $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 +1046,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();
+ });
+ });
});