diff --git a/src/ng/directive/ngModel.js b/src/ng/directive/ngModel.js
index cdba3c11de28..0e449c8b75b0 100644
--- a/src/ng/directive/ngModel.js
+++ b/src/ng/directive/ngModel.js
@@ -878,6 +878,141 @@ 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 of the control should
+ * be updated after a user input.
+ *
+ * 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 includes a formatter that converts the object into the string `Apricot`
+ * which is set to the $viewValue and rendered in the DOM.
+ *
+ * @example
+ *
+
+ angular.module('inputExample', [])
+ .directive('processModel', function() {
+ return {
+ require: 'ngModel',
+ link: function(scope, element, attrs, ngModel) {
+
+ ngModel.$formatters.push(function(value) {
+ if (angular.isObject(value) && value.name) {
+ return value.name;
+ }
+
+ return value;
+ });
+
+ ngModel.$parsers.push(function(value) {
+ if (angular.isString(value)) {
+ return scope.items.find(function(item) {
+ return item.name === value;
+ }) || value;
+ }
+
+ return value;
+ });
+
+ scope.items = [
+ {name: 'Apricot', id: 443},
+ {name: 'Clementine', id: 972},
+ {name: 'Durian', id: 169},
+ {name: 'Fig', id: 298},
+ {name: 'Jackfruit', id: 982},
+ {name: 'Kiwi', id: 151},
+ {name: 'Strawberry', id: 863}
+ ];
+
+ scope.select = function(item) {
+ ngModel.$setViewValue(item);
+ ngModel.$processModelValue();
+ };
+ }
+ };
+ });
+
+
+
+
+ Search Fruit:
+
+
+
+
+
+
+
+ Model:
+
{{val | 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 +1029,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..01c35ada3bf1 100644
--- a/test/ng/directive/ngModelSpec.js
+++ b/test/ng/directive/ngModelSpec.js
@@ -603,6 +603,104 @@ 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).toHaveBeenCalledWith(input, 'ng-empty');
+ expect($animate.addClass).toHaveBeenCalledWith(input, 'ng-not-empty');
+
+ setModelValue(ctrl, 35);
+ ctrl.$processModelValue();
+
+ expect($animate.addClass).toHaveBeenCalledWith(input, 'ng-invalid-maxlength');
+ expect($animate.addClass).toHaveBeenCalledWith(input, 'ng-invalid');
+ })
+ );
+
+ // 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();
+ });
+ });
});