Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ngModel.NgModelController): expose $processModelValue to run mod… #15

Merged
merged 1 commit into from
Sep 30, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 154 additions & 22 deletions src/ng/directive/ngModel.js
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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