Skip to content

Commit dc8a725

Browse files
committed
feat(ngModel): expose $processModelValue to run model -> view pipeline
Closes angular#3407 Closes angular#10764
1 parent 20590c0 commit dc8a725

File tree

2 files changed

+248
-22
lines changed

2 files changed

+248
-22
lines changed

src/ng/directive/ngModel.js

+141-22
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,141 @@ 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 of the control should
907+
* be updated after a user input.
908+
*
909+
* For example, consider a text input with an autocomplete list (for fruit, where the items are
910+
* objects with a name and an id.
911+
* A user enters `ap` and then selects `Apricot` from the list.
912+
* Based on this, the autocomplete widget will call $setViewValue({name: 'Apricot', id: 443}),
913+
* but the rendered value will still be `ap`.
914+
* The widget can then call ctrl.$processModelValue() to run the model -> view
915+
* pipeline again, which includes a formatter that converts the object into the string `Apricot`
916+
* which is set to the $viewValue and rendered in the DOM.
917+
*
918+
* @example
919+
* <example module="inputExample" name="ng-model-process">
920+
<file name="app.js">
921+
angular.module('inputExample', [])
922+
.directive('processModel', function() {
923+
return {
924+
require: 'ngModel',
925+
link: function(scope, element, attrs, ngModel) {
926+
927+
ngModel.$formatters.push(function(value) {
928+
if (angular.isObject(value) && value.name) {
929+
return value.name;
930+
}
931+
932+
return value;
933+
});
934+
935+
ngModel.$parsers.push(function(value) {
936+
if (angular.isString(value)) {
937+
return scope.items.find(function(item) {
938+
return item.name === value;
939+
}) || value;
940+
}
941+
942+
return value;
943+
});
944+
945+
scope.items = [
946+
{name: 'Apricot', id: 443},
947+
{name: 'Clementine', id: 972},
948+
{name: 'Durian', id: 169},
949+
{name: 'Fig', id: 298},
950+
{name: 'Jackfruit', id: 982},
951+
{name: 'Kiwi', id: 151},
952+
{name: 'Strawberry', id: 863}
953+
];
954+
955+
scope.select = function(item) {
956+
ngModel.$setViewValue(item);
957+
ngModel.$processModelValue();
958+
};
959+
}
960+
};
961+
});
962+
</file>
963+
<file name="index.html">
964+
<div style="display: flex;">
965+
<div margin-right: 30px;">
966+
Search Fruit:
967+
<input ng-model="val" process-model />
968+
969+
<ul>
970+
<li ng-repeat="item in items | filter:val"><button ng-click="select(item)">{{item.name}}</li>
971+
</ul>
972+
</div>
973+
<div>
974+
Model:<br>
975+
<pre>{{val | json}}</pre>
976+
</div>
977+
</div>
978+
</file>
979+
* </example>
980+
*
981+
*/
982+
$processModelValue: function() {
983+
var viewValue = this.$$format();
984+
985+
if (this.$viewValue !== viewValue) {
986+
this.$$updateEmptyClasses(viewValue);
987+
this.$viewValue = this.$$lastCommittedViewValue = viewValue;
988+
this.$render();
989+
// It is possible that model and view value have been updated during render
990+
this.$$runValidators(this.$modelValue, this.$viewValue, noop);
991+
}
992+
},
993+
994+
/**
995+
* This method is called internally to run the $formatters on the $modelValue
996+
*/
997+
$$format: function() {
998+
var formatters = this.$formatters,
999+
idx = formatters.length;
1000+
1001+
var viewValue = this.$modelValue;
1002+
while (idx--) {
1003+
viewValue = formatters[idx](viewValue);
1004+
}
1005+
1006+
return viewValue;
1007+
},
1008+
1009+
/**
1010+
* This method is called internally when the bound scope value changes.
1011+
*/
1012+
$$setModelValue: function(modelValue) {
1013+
this.$modelValue = this.$$rawModelValue = modelValue;
1014+
this.$$parserValid = undefined;
1015+
this.$processModelValue();
8811016
}
8821017
};
8831018

@@ -894,30 +1029,14 @@ function setupModelWatcher(ctrl) {
8941029
var modelValue = ctrl.$$ngModelGet(scope);
8951030

8961031
// if scope model value and ngModel value are out of sync
897-
// TODO(perf): why not move this to the action fn?
1032+
// This cannot be moved to the action function, because it would not catch the
1033+
// case where the model is changed in the ngChange function or the model setter
8981034
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)
1035+
// checks for NaN is needed to allow setting the model to NaN when there's an asyncValidator
1036+
// eslint-disable-next-line no-self-compare
1037+
(ctrl.$modelValue === ctrl.$modelValue || modelValue === modelValue)
9021038
) {
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-
}
1039+
ctrl.$$setModelValue(modelValue);
9211040
}
9221041

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