Skip to content
This repository was archived by the owner on Apr 12, 2024. It is now read-only.

Commit 1451948

Browse files
committed
feat(ngModel.NgModelController): expose $processModelValue to run model -> view pipeline
Closes #3407 Closes #10764 Closes #16237
1 parent 569e906 commit 1451948

File tree

2 files changed

+261
-22
lines changed

2 files changed

+261
-22
lines changed

src/ng/directive/ngModel.js

+154-22
Original file line numberDiff line numberDiff line change
@@ -880,6 +880,154 @@ NgModelController.prototype = {
880880
*/
881881
$overrideModelOptions: function(options) {
882882
this.$options = this.$options.createChild(options);
883+
},
884+
885+
/**
886+
* @ngdoc method
887+
*
888+
* @name ngModel.NgModelController#$processModelValue
889+
890+
* @description
891+
*
892+
* Runs the model -> view pipeline on the current
893+
* {@link ngModel.NgModelController#$modelValue $modelValue}.
894+
*
895+
* The following actions are performed by this method:
896+
*
897+
* - the `$modelValue` is run through the {@link ngModel.NgModelController#$formatters $formatters}
898+
* and the result is set to the {@link ngModel.NgModelController#$viewValue $viewValue}
899+
* - the `ng-empty` or `ng-not-empty` class is set on the element
900+
* - if the `$viewValue` has changed:
901+
* - {@link ngModel.NgModelController#$render $render} is called on the control
902+
* - the {@link ngModel.NgModelController#$validators $validators} are run and
903+
* the validation status is set.
904+
*
905+
* This method is called by ngModel internally when the bound scope value changes.
906+
* Application developers usually do not have to call this function themselves.
907+
*
908+
* This function can be used when the `$viewValue` or the rendered DOM value are not correctly
909+
* formatted and the `$modelValue` must be run through the `$formatters` again.
910+
*
911+
* #### Example
912+
*
913+
* Consider a text input with an autocomplete list (for fruit), where the items are
914+
* objects with a name and an id.
915+
* A user enters `ap` and then selects `Apricot` from the list.
916+
* Based on this, the autocomplete widget will call `$setViewValue({name: 'Apricot', id: 443})`,
917+
* but the rendered value will still be `ap`.
918+
* The widget can then call `ctrl.$processModelValue()` to run the model -> view
919+
* pipeline again, which formats the object to the string `Apricot`,
920+
* then updates the `$viewValue`, and finally renders it in the DOM.
921+
*
922+
* <example module="inputExample" name="ng-model-process">
923+
<file name="index.html">
924+
<div ng-controller="inputController" style="display: flex;">
925+
<div style="margin-right: 30px;">
926+
Search Fruit:
927+
<basic-autocomplete items="items" on-select="selectedFruit = item"></basic-autocomplete>
928+
</div>
929+
<div>
930+
Model:<br>
931+
<pre>{{selectedFruit | json}}</pre>
932+
</div>
933+
</div>
934+
</file>
935+
<file name="app.js">
936+
angular.module('inputExample', [])
937+
.controller('inputController', function($scope) {
938+
$scope.items = [
939+
{name: 'Apricot', id: 443},
940+
{name: 'Clementine', id: 972},
941+
{name: 'Durian', id: 169},
942+
{name: 'Jackfruit', id: 982},
943+
{name: 'Strawberry', id: 863}
944+
];
945+
})
946+
.component('basicAutocomplete', {
947+
bindings: {
948+
items: '<',
949+
onSelect: '&'
950+
},
951+
templateUrl: 'autocomplete.html',
952+
controller: function($element, $scope) {
953+
var that = this;
954+
var ngModel;
955+
956+
that.$postLink = function() {
957+
ngModel = $element.find('input').controller('ngModel');
958+
959+
ngModel.$formatters.push(function(value) {
960+
return (value && value.name) || value;
961+
});
962+
963+
ngModel.$parsers.push(function(value) {
964+
var match = value;
965+
for (var i = 0; i < that.items.length; i++) {
966+
if (that.items[i].name === value) {
967+
match = that.items[i];
968+
break;
969+
}
970+
}
971+
972+
return match;
973+
});
974+
};
975+
976+
that.selectItem = function(item) {
977+
ngModel.$setViewValue(item);
978+
ngModel.$processModelValue();
979+
that.onSelect({item: item});
980+
};
981+
}
982+
});
983+
</file>
984+
<file name="autocomplete.html">
985+
<div>
986+
<input type="search" ng-model="$ctrl.searchTerm" />
987+
<ul>
988+
<li ng-repeat="item in $ctrl.items | filter:$ctrl.searchTerm">
989+
<button ng-click="$ctrl.selectItem(item)">{{ item.name }}</button>
990+
</li>
991+
</ul>
992+
</div>
993+
</file>
994+
* </example>
995+
*
996+
*/
997+
$processModelValue: function() {
998+
var viewValue = this.$$format();
999+
1000+
if (this.$viewValue !== viewValue) {
1001+
this.$$updateEmptyClasses(viewValue);
1002+
this.$viewValue = this.$$lastCommittedViewValue = viewValue;
1003+
this.$render();
1004+
// It is possible that model and view value have been updated during render
1005+
this.$$runValidators(this.$modelValue, this.$viewValue, noop);
1006+
}
1007+
},
1008+
1009+
/**
1010+
* This method is called internally to run the $formatters on the $modelValue
1011+
*/
1012+
$$format: function() {
1013+
var formatters = this.$formatters,
1014+
idx = formatters.length;
1015+
1016+
var viewValue = this.$modelValue;
1017+
while (idx--) {
1018+
viewValue = formatters[idx](viewValue);
1019+
}
1020+
1021+
return viewValue;
1022+
},
1023+
1024+
/**
1025+
* This method is called internally when the bound scope value changes.
1026+
*/
1027+
$$setModelValue: function(modelValue) {
1028+
this.$modelValue = this.$$rawModelValue = modelValue;
1029+
this.$$parserValid = undefined;
1030+
this.$processModelValue();
8831031
}
8841032
};
8851033

@@ -896,30 +1044,14 @@ function setupModelWatcher(ctrl) {
8961044
var modelValue = ctrl.$$ngModelGet(scope);
8971045

8981046
// if scope model value and ngModel value are out of sync
899-
// TODO(perf): why not move this to the action fn?
1047+
// This cannot be moved to the action function, because it would not catch the
1048+
// case where the model is changed in the ngChange function or the model setter
9001049
if (modelValue !== ctrl.$modelValue &&
901-
// checks for NaN is needed to allow setting the model to NaN when there's an asyncValidator
902-
// eslint-disable-next-line no-self-compare
903-
(ctrl.$modelValue === ctrl.$modelValue || modelValue === modelValue)
1050+
// checks for NaN is needed to allow setting the model to NaN when there's an asyncValidator
1051+
// eslint-disable-next-line no-self-compare
1052+
(ctrl.$modelValue === ctrl.$modelValue || modelValue === modelValue)
9041053
) {
905-
ctrl.$modelValue = ctrl.$$rawModelValue = modelValue;
906-
ctrl.$$parserValid = undefined;
907-
908-
var formatters = ctrl.$formatters,
909-
idx = formatters.length;
910-
911-
var viewValue = modelValue;
912-
while (idx--) {
913-
viewValue = formatters[idx](viewValue);
914-
}
915-
if (ctrl.$viewValue !== viewValue) {
916-
ctrl.$$updateEmptyClasses(viewValue);
917-
ctrl.$viewValue = ctrl.$$lastCommittedViewValue = viewValue;
918-
ctrl.$render();
919-
920-
// It is possible that model and view value have been updated during render
921-
ctrl.$$runValidators(ctrl.$modelValue, ctrl.$viewValue, noop);
922-
}
1054+
ctrl.$$setModelValue(modelValue);
9231055
}
9241056

9251057
return modelValue;

test/ng/directive/ngModelSpec.js

+107
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,113 @@ describe('ngModel', function() {
603603
expect(ctrl.$modelValue).toBeNaN();
604604

605605
}));
606+
607+
describe('$processModelValue', function() {
608+
// Emulate setting the model on the scope
609+
function setModelValue(ctrl, value) {
610+
ctrl.$modelValue = ctrl.$$rawModelValue = value;
611+
ctrl.$$parserValid = undefined;
612+
}
613+
614+
it('should run the model -> view pipeline', function() {
615+
var log = [];
616+
var input = ctrl.$$element;
617+
618+
ctrl.$formatters.unshift(function(value) {
619+
log.push(value);
620+
return value + 2;
621+
});
622+
623+
ctrl.$formatters.unshift(function(value) {
624+
log.push(value);
625+
return value + '';
626+
});
627+
628+
spyOn(ctrl, '$render');
629+
630+
setModelValue(ctrl, 3);
631+
632+
expect(ctrl.$modelValue).toBe(3);
633+
634+
ctrl.$processModelValue();
635+
636+
expect(ctrl.$modelValue).toBe(3);
637+
expect(log).toEqual([3, 5]);
638+
expect(ctrl.$viewValue).toBe('5');
639+
expect(ctrl.$render).toHaveBeenCalledOnce();
640+
});
641+
642+
it('should add the validation and empty-state classes',
643+
inject(function($compile, $rootScope, $animate) {
644+
var input = $compile('<input name="myControl" maxlength="1" ng-model="value" >')($rootScope);
645+
$rootScope.$digest();
646+
647+
spyOn($animate, 'addClass');
648+
spyOn($animate, 'removeClass');
649+
650+
var ctrl = input.controller('ngModel');
651+
652+
expect(input).toHaveClass('ng-empty');
653+
expect(input).toHaveClass('ng-valid');
654+
655+
setModelValue(ctrl, 3);
656+
ctrl.$processModelValue();
657+
658+
// $animate adds / removes classes in the $$postDigest, which
659+
// we cannot trigger with $digest, because that would set the model from the scope,
660+
// so we simply check if the functions have been called
661+
expect($animate.removeClass.calls.mostRecent().args[0][0]).toBe(input[0]);
662+
expect($animate.removeClass.calls.mostRecent().args[1]).toBe('ng-empty');
663+
664+
expect($animate.addClass.calls.mostRecent().args[0][0]).toBe(input[0]);
665+
expect($animate.addClass.calls.mostRecent().args[1]).toBe('ng-not-empty');
666+
667+
$animate.removeClass.calls.reset();
668+
$animate.addClass.calls.reset();
669+
670+
setModelValue(ctrl, 35);
671+
ctrl.$processModelValue();
672+
673+
expect($animate.addClass.calls.argsFor(1)[0][0]).toBe(input[0]);
674+
expect($animate.addClass.calls.argsFor(1)[1]).toBe('ng-invalid');
675+
676+
expect($animate.addClass.calls.argsFor(2)[0][0]).toBe(input[0]);
677+
expect($animate.addClass.calls.argsFor(2)[1]).toBe('ng-invalid-maxlength');
678+
})
679+
);
680+
681+
// this is analogue to $setViewValue
682+
it('should run the model -> view pipeline even if the value has not changed', function() {
683+
var log = [];
684+
685+
ctrl.$formatters.unshift(function(value) {
686+
log.push(value);
687+
return value + 2;
688+
});
689+
690+
ctrl.$formatters.unshift(function(value) {
691+
log.push(value);
692+
return value + '';
693+
});
694+
695+
spyOn(ctrl, '$render');
696+
697+
setModelValue(ctrl, 3);
698+
ctrl.$processModelValue();
699+
700+
expect(ctrl.$modelValue).toBe(3);
701+
expect(ctrl.$viewValue).toBe('5');
702+
expect(log).toEqual([3, 5]);
703+
expect(ctrl.$render).toHaveBeenCalledOnce();
704+
705+
ctrl.$processModelValue();
706+
expect(ctrl.$modelValue).toBe(3);
707+
expect(ctrl.$viewValue).toBe('5');
708+
expect(log).toEqual([3, 5, 3, 5]);
709+
// $render() is not called if the viewValue didn't change
710+
expect(ctrl.$render).toHaveBeenCalledOnce();
711+
});
712+
});
606713
});
607714

608715

0 commit comments

Comments
 (0)