From 05fdf918d97a379634c8b0e15da8615edfc6fb8d Mon Sep 17 00:00:00 2001 From: Martin Staffa Date: Fri, 29 Sep 2017 13:12:18 +0200 Subject: [PATCH] feat(ngModel.NgModelController): expose $processModelValue to run model -> view pipeline Closes #3407 Closes #10764 Closes #16237 --- src/ng/directive/ngModel.js | 176 +++++++++++++++++++++++++++---- test/ng/directive/ngModelSpec.js | 107 +++++++++++++++++++ 2 files changed, 261 insertions(+), 22 deletions(-) diff --git a/src/ng/directive/ngModel.js b/src/ng/directive/ngModel.js index cdba3c11de2..44780254151 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. + * + * + +
+
+ Search Fruit: + +
+
+ Model:
+
{{selectedFruit | json}}
+
+
+
+ + 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.selectItem = function(item) { + ngModel.$setViewValue(item); + ngModel.$processModelValue(); + that.onSelect({item: item}); + }; + } + }); + + +
+ +
    +
  • + +
  • +
+
+
+ *
+ * + */ + $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 7157d4aded8..7e839e96286 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(); + }); + }); });