Skip to content

Commit

Permalink
feat(ngModel): expose $processModelValue to run model -> view pipeline
Browse files Browse the repository at this point in the history
  • Loading branch information
Narretz committed Sep 29, 2017
1 parent 20590c0 commit e895e27
Show file tree
Hide file tree
Showing 2 changed files with 261 additions and 22 deletions.
176 changes: 154 additions & 22 deletions src/ng/directive/ngModel.js
Expand Up @@ -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.
*
* <example module="inputExample" name="ng-model-process">
<file name="index.html">
<div ng-controller="inputController" style="display: flex;">
<div style="margin-right: 30px;">
Search Fruit:
<basic-autocomplete items="items" on-select="selectedFruit = item"></basic-autocomplete>
</div>
<div>
Model:<br>
<pre>{{selectedFruit | json}}</pre>
</div>
</div>
</file>
<file name="app.js">
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});
};
}
});
</file>
<file name="autocomplete.html">
<div>
<input type="search" ng-model="$ctrl.searchTerm" />
<ul>
<li ng-repeat="item in $ctrl.items | filter:$ctrl.searchTerm">
<button ng-click="$ctrl.selectItem(item)">{{ item.name }}</button>
</li>
</ul>
</div>
</file>
* </example>
*
*/
$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();
}
};

Expand All @@ -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;
Expand Down
107 changes: 107 additions & 0 deletions test/ng/directive/ngModelSpec.js
Expand Up @@ -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('<input name="myControl" maxlength="1" ng-model="value" >')($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();
});
});
});


Expand Down

0 comments on commit e895e27

Please sign in to comment.