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

Commit 05fdf91

Browse files
authored
feat(ngModel.NgModelController): expose $processModelValue to run model -> view pipeline
Closes #3407 Closes #10764 Closes #16237
1 parent 6eb15cb commit 05fdf91

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

@@ -894,30 +1042,14 @@ function setupModelWatcher(ctrl) {
8941042
var modelValue = ctrl.$$ngModelGet(scope);
8951043

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

9231055
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)