How to prevent model to be invalid? - javascript

I am a strong advocate of best practices, especially when it comes to angular but I can't manage to use the brand new $validators pipeline feature as it should be.
The case is quite simple: 1 input enhanced by a directive using $parser, $formatter and some $validators:
<input name="number" type="text" ng-model="number" number>
Here is the (simplified) directive:
myApp.directive('number', [function() {
return {
restrict: 'A',
require: 'ngModel',
/*
* Must have higher priority than ngModel directive to make
* number (post)link function run after ngModel's one.
* ngModel's priority is 1.
*/
priority: 2,
link: function($scope, $element, $attrs, $controller) {
$controller.$parsers.push(function (value) {
return isFinite(value)? parseInt(value): undefined;
});
$controller.$formatters.push(function (value) {
return value.toString() || '';
});
$controller.$validators.minNumber = function(value) {
return value && value >= 1;
};
$controller.$validators.maxNumber = function(value) {
return value && value <= 10;
};
}
};
}]);
I made a little plunk to play with :)
The behavior I am trying to achieve is: Considering that the initial value stored in the scope is valid, prevent it from being corrupted if the user input is invalid. Keep the old one until a new valid one is set.
NB: Before angular 1.3, I was able to do this using ngModelController API directly in $parser/$formatter. I can still do that with 1.3, but that would not be "angular-way".
NB2: In my app I am not really using numbers, but quantities.The problem remains the same.

It looks like you want some parsing to happen after validation, setting the model to the last valid value rather than one derived from the view. However, I think the 1.3 pipeline works the other way around: parsing happens before validation.
So my answer is to just do it as you would do it in 1.2: using $parsers to set the validation keys and to transform the user's input back to the most recent valid value.
The following directive does this, with an array of validators specified within the directive that are run in order. If any of the previous validators fails, then the later ones don't run: it assumes one validation error can happen at a time.
Most relevant to your question, is that it maintains the last valid value in the model, and only overwrites if there are no validation errors occur.
myApp.directive('number', [function() {
return {
restrict: 'A',
require: 'ngModel',
/*
* Must have higher priority than ngModel directive to make
* number (post)link function run after ngModel's one.
* ngModel's priority is 1.
*/
priority: 2,
link: function($scope, $element, $attrs, $controller) {
var lastValid;
$controller.$parsers.push(function(value) {
value = parseInt(value);
lastValid = $controller.$modelValue;
var skip = false;
validators.forEach(function(validator) {
var isValid = skip || validator.validatorFn(value);
$controller.$setValidity(validator.key, isValid);
skip = skip || !isValid;
});
if ($controller.$valid) {
lastValid = value;
}
return lastValid;
});
$controller.$formatters.push(function(value) {
return value.toString() || '';
});
var validators = [{
key: 'isNumber',
validatorFn: function(value) {
return isFinite(value);
}
}, {
key: 'minNumber',
validatorFn: function(value) {
return value >= 1;
}
}, {
key: 'maxNumber',
validatorFn: function(value) {
return value <= 10;
}
}];
}
};
}]);
This can be seen working at http://plnkr.co/edit/iUbUCfJYDesX6SNGsAcg?p=preview

I think you are over-thinking this in terms of Angular-way vs. not Angular-way. Before 1.3 using $parsers pipeline was the Angular-way and now it's not?
Well, the Angular-way is also that ng-model sets the model to undefined (by default) for invalid values. Follow that Angular-way direction and define another variable to store the "lastValid" value:
<input ng-model="foo" ng-maxlength="3"
ng-change="lastValidFoo = foo !== undefined ? foo : lastValidFoo"
ng-init="foo = lastValidFoo">
No need for a special directive and it works across the board in a way that doesn't try to circumvent what Angular is doing natively - i.e. the Angular-way. :)

As of Angular 1.3 you can use the ngModelOptions directive to have greater control as to when your model value updates. Take a look at this updated Plunker to show you how to achieve the functionality you are looking for: http://plnkr.co/edit/DoWbvlFMEtqF9gvJCjPF?p=preview
Basically you define the model as a getterSetter and only return the new value if it is valid:
$scope.validNumber = function(value) {
return angular.isDefined(value) ? ($scope.number = value) : $scope.number;
}
$scope.modelOptions = {
getterSetter: true,
allowInvalid: false
};
Then to use this code update your as follows:
<input name="number" type="text" ng-model="validNumber" ng-model-options="modelOptions" number>
I really hope this answers all of your questions, please let me know if I can help any more.
Leon.

Here is my plnkr with the relevant code:
$controller.$$runValidators = function(originalRun) {
var lastModelValue, lastViewValue;
return function() {
var ctrl = this;
var doneCallback = arguments[arguments.length-1];
arguments[arguments.length-1] = function(allValid) {
doneCallback(allValid);
console.log(allValid);
console.log('valid:' +allValid+ ' value:' +ctrl.$viewValue);
if (ctrl.$viewValue) {
lastViewValue= allValid ? ctrl.$viewValue : lastViewValue | '';
lastModelValue= allValid ? ctrl.$modelValue : lastModelValue;
ctrl.$modelValue = allValid ? ctrl.$modelValue : lastModelValue;
ctrl.$$writeModelToScope();
ctrl.$viewValue = ctrl.$$lastCommittedViewValue = lastViewValue;
ctrl.$render();
}
console.log(ctrl.$viewValue + ' '+lastViewValue);
// console.log( ctrl.$modelValue);
};
originalRun.apply(this, arguments);
}
}($controller.$$runValidators);
Can it be a valid solution?
the only way i think you can intercept the angular validation flow is override the $$runValidators. Maybe this code need a little bit of tweaking but works.

Related

How to implement reverse one time bind ng-if expression in AngularJS?

I have a custom AngularJS component which might be used on a single web page over 200 times. The page ends up implementing over 4000 watchers -- which is more than AngularJS's prefered maximum amount of watchers -- and makes the page really slow.
The actual problem is that there is a lot of unneeded watchers left from some ng-if and other AngularJS expressions inside the component template which no longer where going to change their values.
For normal ng-if's the fix was easy:
<div ng-if="::$ctrl.isInitialized()">Ready!</div>
...where $ctrl.isInitialized() would either return a true (when the component was initialized) or undefined (until it was).
Returning undefined here will make AngularJS keep the watcher active until it returns something else, in this case the value true, and then will add the div in the DOM.
There is no ng-not="expression" like there is ng-hide. This works well with ng-hide, except of course the div is still in the DOM after the controller has been initialized, which is not the perfect solution.
But how can you implement it so, that the <div> will be in the DOM until the controller has been initialized and will be removed after?
Although there is no ng-not directive, it was easy to implement from AngularJS source code:
var ngNotDirective = ['$animate', '$compile', function($animate, $compile) {
function getBlockNodes(nodes) {
// TODO(perf): update `nodes` instead of creating a new object?
var node = nodes[0];
var endNode = nodes[nodes.length - 1];
var blockNodes;
for (var i = 1; node !== endNode && (node = node.nextSibling); i++) {
if (blockNodes || nodes[i] !== node) {
if (!blockNodes) {
blockNodes = jqLite(slice.call(nodes, 0, i));
}
blockNodes.push(node);
}
}
return blockNodes || nodes;
}
return {
multiElement: true,
transclude: 'element',
priority: 600,
terminal: true,
restrict: 'A',
$$tlb: true,
link: function($scope, $element, $attr, ctrl, $transclude) {
var block, childScope, previousElements;
$scope.$watch($attr.ngNot, function ngNotWatchAction(value) {
if (!value) {
if (!childScope) {
$transclude(function(clone, newScope) {
childScope = newScope;
clone[clone.length++] = $compile.$$createComment('end ngNot', $attr.ngNot);
// Note: We only need the first/last node of the cloned nodes.
// However, we need to keep the reference to the jqlite wrapper as it might be changed later
// by a directive with templateUrl when its template arrives.
block = {
clone: clone
};
$animate.enter(clone, $element.parent(), $element);
});
}
} else {
if (previousElements) {
previousElements.remove();
previousElements = null;
}
if (childScope) {
childScope.$destroy();
childScope = null;
}
if (block) {
previousElements = getBlockNodes(block.clone);
$animate.leave(previousElements).done(function(response) {
if (response !== false) previousElements = null;
});
block = null;
}
}
});
}
};
}];
This is the same implementation as ng-if except it has reverted if (!value) check.
It can be used like this:
<div ng-not="::$ctrl.isInitialized() ? true : undefined">Loading...</div>
It is easy to verify that there is no useless watchers by adding a console.log() in $ctrl.isInitialized() -- this function will be called just few times until it returns true and the watcher is removed -- as well as the div, and anything inside it.
kind of quick patch: angular allows ternary operator in expressions after v1.1.5 I guess.
So you can make something like:
<div ng-if="::$ctrl.isInitialized() === undefined? undefined: !$ctrl.isInitialized()">
As far as I can see undefined does not have special meaning in angular expression - it's treated as another (not defined yet) variable in $scope. So I had to put it there explicitly:
$scope = undefined;
Alternative option is writing short helper:
function isDefined(val) {
return angular.isDefined(val) || undefined;
}
To use it later as
ng-if="::isDefined($ctrl.isInitialized()) && !$ctrl.isInitialized()"
But since you say there are too many places for doing that - for sure making own component as you coded above looks better

$parsers and $formatters are being called only once, instead of on every value update

I'm trying to create a directive named currency that appends a $ before the text in input. The dollar sign should be shown at all times and shouldn't be possible to remove.
Here's my code:
app.directive('currency', function() {
return {
restrict: 'A',
require: 'ngModel',
link: function (scope, elem, attrs, controller) {
// view -> model
controller.$parsers.push(function (viewValue) {
viewValue = viewValue.replace(/^\$/, '');
controller.$viewValue = viewValue;
return viewValue;
});
// model -> view
controller.$formatters.push(function (modelValue) {
modelValue = '$' + modelValue;
controller.$modelValue = modelValue;
return modelValue;
});
}
};
});
Working example: https://jsfiddle.net/U3pVM/29012/
As you can see, the dollar sign is appended initially, but can be deleted and won't be appended after that. It seems that the function I push to $formatters is only being called once. Is it supposed to work like that or am I missing something? How can I implement the desired behavior?
ok, i have tried a workaround, it works but i am not sure if this is the correct way to do it.
updated fiddle : https://jsfiddle.net/U3pVM/29014/
controller.$parsers.push(function (viewValue) {
//console.log(viewValue.substring(0,1));
if(viewValue.substring(0,1) != "$"){
var view_value = "$" + viewValue;
controller.$setViewValue(view_value);
controller.$render();
}
viewValue = viewValue.replace(/^\$/, '');
//controller.$viewValue = viewValue;
console.log(viewValue);
return viewValue;
});
P.S: i am not sure why you are injecting ngModel as controller in your link function. it might be a mistake.
I think you're not quite understanding what the $parsers and $formatters do. Whenever you enter something in the input field, the $parsers are responsible for converting this value into a model value. Formatters are responsible for converting a model value into a display value in your input field.
What you are attempting to do is to change the content of your input field ($formatter feature) when someone enters something into the field ($parser feature).
While I'm sure there are workarounds to making it work this way, you're misusing the concepts of $parsers and $formatters when you do. Instead you should be looking at a custom directive (or extend the one you have) to add to the input that does what you're trying to do, for instance by handing keyups.
Edit
See the following code example for a link function to give you some indication of what I mean:
link: function (scope, elem, attrs, controller) {
elem.bind('keyup', function(evt) {
// Change the displayed value after every keypress
// This function is an example and needs serious work...
// Perhaps you should even put this in a separate directive
var value = elem.val().replace(/[^$0-9]/g, '');
if (value && value.substring(0,1) !== '$') {
value = '$' + value;
}
elem.val(value);
});
// view -> model
controller.$parsers.push(function (viewValue) {
// Any time the view changes, remove the $ sign and interpret the rest as number for the model
var modelValue = viewValue.replace(/^\$/, '');
return parseFloat(modelValue);
});
// model -> view
controller.$formatters.push(function (modelValue) {
// Any time the model (number) changes, append it with a $ sign for the view
var viewValue = '$' + modelValue;
return viewValue;
});
}
Or check the entire fiddle: https://jsfiddle.net/cL0hpvp4/

Issues converting from AngularJS Version 1.0.8 to 1.4.2

I have a lovely date dropdown that works in AngularJS V1.0.8 and I am trying to run it using V1.4.2 but it doesn't seem to want to play ball.
What seems to be the issue? As I have researched changes in the versions but can't see the problem.
I have got a plunker where you can see it working using 1.0.8 and then when you change the version on lines 5 and 8 to 1.4.2 it doesn't work.
The fields should become invalid for dates like 31/02/2000 etc which is fine but not in 1.4.2
What can the matter be?
https://plnkr.co/edit/5ckBkzN6xYEvJvyoO0Ax?p=preview
angular.module('dateApp', []);
angular.module('dateApp').
directive('dateTypeMulti', function () {
return {
require: 'ngModel',
link: function (scope, element, attrs, ngModel) {
ngModel.$render = function () {
angular.extend(scope.$eval(attrs.dateTypeMulti), ngModel.$viewValue);
};
scope.$watch(attrs.dateTypeMulti, function (viewValue) {
ngModel.$setViewValue(viewValue);
}, true);
ngModel.$formatters.push(function (modelValue) {
if (!modelValue) return;
var parts = String(modelValue).split('/');
return {
year: parts[0],
month: parts[1],
day: parts[2]
};
});
ngModel.$parsers.unshift(function (viewValue) {
var isValid = true,
modelValue = '',
date;
if (viewValue) {
date = new Date(viewValue.year, viewValue.month - 1, viewValue.day);
modelValue = [viewValue.year, viewValue.month, viewValue.day].join('/');
if ('//' === modelValue) {
modelValue = '';
} else if (
date.getFullYear() != viewValue.year ||
date.getMonth() != viewValue.month - 1 ||
date.getDate() != viewValue.day) {
isValid = false;
}
}
ngModel.$setValidity('dateTypeMulti', isValid);
return isValid ? modelValue : undefined;
});
}
};
})
Thanks
Your directive stops working in 1.3.0-beta.10, probably because of the following change:
ngModel: do not dirty the input on $commitViewValue if nothing was
changed
Since you are using an object as the view value:
scope.$watch(attrs.dateTypeMulti, function (viewValue) {
ngModel.$setViewValue(viewValue);
}, true);
The same object reference will be used, $commitViewValue will deem nothing to have changed and abort before the parse and validate pipeline gets started.
The documentation for $setViewValue states:
When used with standard inputs, the view value will always be a string
(which is in some cases parsed into another type, such as a Date
object for input[date].) However, custom controls might also pass
objects to this method. In this case, we should make a copy of the
object before passing it to $setViewValue. This is because ngModel
does not perform a deep watch of objects, it only looks for a change
of identity. If you only change the property of the object then
ngModel will not realize that the object has changed and will not
invoke the $parsers and $validators pipelines. For this reason, you
should not change properties of the copy once it has been passed to
$setViewValue. Otherwise you may cause the model value on the scope to
change incorrectly.
Change to use angular.copy and it should work:
ngModel.$setViewValue(angular.copy(viewValue))
Demo: https://plnkr.co/edit/kSS56n6LlHej25vcjfQq?p=preview

AngularJS notation in input type range min attribute

I would expect the following expression to have the same outcome:
Case 1:
<input type="range" name="myRangeInput" ng-model="value.rangeInput" value="value.rangeInput" min="-55" max="55">
Case 2 (difference to case 1 is that I replaced 55 with AngularJS scope variables):
<input type="range" name="myRangeInput" ng-model="value.rangeInput" value="value.rangeInput" min="{{value.rangeInputMin}}" max="{{value.rangeInputMax}}">
with value.rangeInputMax equals 55 and value.rangeInputMin equals -55.
But they do not have the same output. For example let's says value.rangeInput is in both cases -10. Then in the 1st example the dot in the range slider is set at -10. But in the 2nd example the dot is set to 0.
I tried to convert value.rangeInputMin and value.rangeInputMax into numbers and change the statement (without double quotes) to this:
<input type="range" name="myRangeInput" ng-model="value.rangeInput" value="value.rangeInput" min={{value.rangeInputMin}} max={{value.rangeInputMax}}>
I also tried with different notations, e.g. value.rangeInputMin, "value.rangeInputMin", tried to set it with ng-init, create another scope variable and assign the value in this one, etc.
But it is still showing a different behaviour than in the 1st case.
Per my comments above, I think you've found a bug as I'd expect to be able to declaratively set this value in your template alongside your min and max values. This simply isn't the case. A typical workaround for this is to set the model value after you've set your min and max values using $timeout. It's not ideal but it works.
controller function
function($timeout) {
var vc = this
// vc.rangeInput = -10;
vc.rangeInputMin = -55;
vc.rangeInputMax = 55;
$timeout(function(){
vc.rangeInput = -10;
})
}
You can see it working here - http://codepen.io/jusopi/pen/vLQKJY?editors=1010
If you need to, you can write a simple directive to basically trigger ng-init-like functionality on the next $digest cycle. This might be a better solution if you run into this issue more than once of twice in your design.
callLater directive
.directive('callLater', [
'$timeout',
function($timeout) {
return {
restrict: 'A',
scope: {
callLater: '&'
},
link: function($scope, elem, attr) {
$timeout(function(){
$scope.callLater()
})
}
}
}
])
directive in template
<input type="range" name="myRangeInput" ng-model="vc.delayedInput"
min="{{vc.rangeInputMin || -55}}" max="{{vc.rangeInputMax || 55}}"
call-later="vc.delayedInput = -10;">
example - http://codepen.io/jusopi/pen/JGeKOz?editors=1010
The problem is commented here: https://github.com/driftyco/ionic/issues/1948
JWGmeligMeyling created the ngMax and ngMin directives and they seem to work pretty well:
.directive('ngMin', function() {
return {
restrict : 'A',
require : ['ngModel'],
compile: function($element, $attr) {
return function linkDateTimeSelect(scope, element, attrs, controllers) {
var ngModelController = controllers[0];
scope.$watch($attr.ngMin, function watchNgMin(value) {
element.attr('min', value);
ngModelController.$render();
})
}
}
}
})
.directive('ngMax', function() {
return {
restrict : 'A',
require : ['ngModel'],
compile: function($element, $attr) {
return function linkDateTimeSelect(scope, element, attrs, controllers) {
var ngModelController = controllers[0];
scope.$watch($attr.ngMax, function watchNgMax(value) {
element.attr('max', value);
ngModelController.$render();
})
}
}
}
})
Here's the codepen: http://codepen.io/anon/pen/MKzezB
Try removing value="value.rangeInput" from the markup.

$observe multiple attributes at the same time and fire callback only once

I wonder is it possible to execute some callback only once after evaluation all (or only some) attributes of directive (without isolated scope). Attributes are really great to pass configuration to the directive. The thing is that you can observe each attribute separately and fire callback several times.
In the example we have a directive without isolated scope which observs two attributes: name and surname. After any change action callback is fired:
html
<button ng-click="name='John';surname='Brown'">Change all params</button>
<div person name="{{name}}" surname="{{surname}}"></div>
js
angular.module('app', []).
directive('person', function() {
return {
restrict: 'A',
link: function($scope, $elem, $attrs) {
var action = function() {
$elem.append('name: ' + $attrs.name + '<br/> surname: ' + $attrs.surname+'<br/><br/>');
}
$attrs.$observe('name', action);
$attrs.$observe('surname', action);
}
}
});
Plunker here.
So the effect is that after changing name and surname during one click, action callback is fired twice:
name:
surname: Brown
name: John
surname: Brown
So the question is: can action be fired only once with both name and surname values changed?
You can use $watch to evaluate a custom function rather than a specific model.
i.e.
$scope.$watch(function () {
return [$attrs.name, $attrs.surname];
}, action, true);
That will be run on all $digest cycles, and if $watch detects the return array (or however you want to structure your function's return value) doesn't match the old value, the callback argument to $watch will fire. If you do use an object as the return value though, make sure to leave the true value in for the last argument to $watch so that $watch will do a deep compare.
Underscore (or lo-dash) has a once function. If you wrap your function inside once you can ensure your function will be called only once.
angular.module('app', []).
directive('person', function() {
return {
restrict: 'A',
link: function($scope, $elem, $attrs) {
var action = function() {
$elem.append('name: ' + $attrs.name + '<br/> surname: ' + $attrs.surname+'<br/><br/>');
}
var once = _.once(action);
$attrs.$observe('name', once);
$attrs.$observe('surname', once);
}
}
});
So, I've ended up with my own implementation of observeAll method, which can wait for several changes of attributes during one call stack. It works however I'm not sure about performance.
Solution of #cmw seems to be simpler but performance can suffer for large number of parameters and multiple $digest phase runs, when object equality is evaluated many many times. However I decided to accept his answer.
Below you can find my approach:
angular.module('utils.observeAll', []).
factory('observeAll', ['$rootScope', function($rootScope) {
return function($attrs, callback) {
var o = {},
callQueued = false,
args = arguments,
observe = function(attr) {
$attrs.$observe(attr, function(value) {
o[attr] = value;
if (!callQueued) {
callQueued = true;
$rootScope.$evalAsync(function() {
var argArr = [];
for(var i = 2, max = args.length; i < max; i++) {
var attr = args[i];
argArr.push(o[attr]);
}
callback.apply(null, argArr);
callQueued = false;
});
}
});
};
for(var i = 2, max = args.length; i < max; i++) {
var attr = args[i];
if ($attrs.$attr[attr])
observe(attr);
}
};
}]);
And you can use it in your directive:
angular.module('app', ['utils.observeAll']).
directive('person', ['observeAll', function(observeAll) {
return {
restrict: 'A',
link: function($scope, $elem, $attrs) {
var action = function() {
$elem.append('name: ' + $attrs.name + '<br/> surname: ' + $attrs.surname+'<br/><br/>');
}
observeAll($attrs, action, 'name', 'surname');
}
}
}]);
Plunker here
I resolved the exact same problem that I had using another approach, though I was looking for different ideas. While cmw's suggestions is working, I compared its performance against mine, and saw that the $watch method is called far too many times, so I decided to keep things the way I had implemented.
I added $observe calls for both variables I wanted to track and bound them to a debounce call. Since they both are modified with very little time difference, both $observe methods trigger the same function call, which gets executed after a short delay:
var debounceUpdate = _.debounce(function () {
setMinAndMaxValue(attrs['minFieldName'], attrs['maxFieldName']);
}, 100);
attrs.$observe('minFieldName', function () {
debounceUpdate();
});
attrs.$observe('maxFieldName', function () {
debounceUpdate();
});
There are several ways presented to solve this problem. I liked the debounce solution a lot. However, here is my solution to this problem. This combines all the attributes in one single attribute and creates a JSON representation of the attributes that you are interested in. Now, you just need to $observe one attribute and have good perf too!
Here is a fork of the original plunkr with the implementation:
linkhttp://plnkr.co/edit/un3iPL2dfmSn1QJ4zWjQ

Categories

Resources