From dc8a72576557987060e0caeb86ecd494c6e47c64 Mon Sep 17 00:00:00 2001 From: Martin Staffa Date: Wed, 20 Sep 2017 13:28:43 +0200 Subject: [PATCH] feat(ngModel): expose $processModelValue to run model -> view pipeline Closes #3407 Closes #10764 --- src/ng/directive/ngModel.js | 163 ++++++++++++++++++++++++++----- test/ng/directive/ngModelSpec.js | 107 ++++++++++++++++++++ 2 files changed, 248 insertions(+), 22 deletions(-) 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..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(); + }); + }); });