Detecting non-assignable values in angular directives - angularjs

I have a directive that will get/set focus on an element depending on a value.
When the element is focused or blurred the directive will update the boolean value. However, sometimes I only want to set the focus when a condition is met and I use a non-assignable value, which throws this error:
Error: [$compile:nonassign] Expression '$last && !domain.url' used with directive 'focusWhen' is non-assignable!
WORKING DEMO HERE (check the console for errors)
I understand why it's throwing an error, but how can I detect non-assignable values from inside the directive and then prevent it from attaching focus/blur events that attempt to assign the value when the focus changes?
Here's an example usage of the directive used with a non-assignable value. In this case, it would set the focus on the last item in the repeater if it is also a blank value
<div ng-repeat="domain in domainList">
<input type="text" ng-model="domain.url" focus-when="$last && !domain.url"/>
</div>
And here's the directive code;
testApp.directive("focusWhen", ["$timeout", function ($timeout) {
function getLink($scope, $element) {
$scope.$watch("focusWhen", function (val) {
//only focus when needed and when this element doesn't already have focus
if (val && $element[0] !== document.activeElement) {
//Small delay needed before we can get focus properly
$timeout(function () {
$element.focus();
});
}
});
$element.on("blur.focusWhen", function () {
if ($scope.focusWhen) {
$timeout(function () {
$scope.focusWhen = false;
});
}
});
$element.on("focus.focusWhen", function () {
if (!$scope.focusWhen) {
$timeout(function () {
$scope.focusWhen = true;
});
}
});
}
return {
restrict: "A",
scope: {
focusWhen: "="
},
link: getLink
};
}]);

Updated
Programmatic way to detect this (instead of having one more attribute) is using $parse fn. This is how angular does it to check and throw the error. It tries to parse the two-way binding attribute and if it does not have an assign attribute it throws the error. ($parse($attrs['focusWhen']).assign).
http://jsfiddle.net/cw7fftfx/
Old Answer:
How about one more attribute to indicate if you want to set back the focus value?
<input type="text" ng-model="domain.url" one-way="true" focus-when="$last && !domain.url"/>
$element.on("blur.focusWhen", function () {
if ($scope.focusWhen) {
$timeout(function () {
if (!$attr.oneWay) {
$scope.focusWhen = false;
}
});
}
});
http://jsfiddle.net/j1v6jk8k/3/

Related

Angular parser called on blur

I have a type of input mask using an angular directive. I'm using a formatters and the blur event to format the model value for display, and I'm using parsers and the focus event to remove the formatting when the user edits the textbox.
I'm getting strange behaviour in Internet Explorer where if you use the Tab key to lose focus, the parser event is (incorrectly) firing so the model value is being updated incorrectly.
Is this an angular bug? Or is there something I'm doing wrong?
Here is a fiddle: https://jsfiddle.net/capesean/htorwgs5/3/
Note that in IE, with your console window open, you will see the events logging out.
Also, testing this on an earlier Angular version, seems to work fine:
https://jsfiddle.net/htorwgs5/4/
The directive code is:
.directive("test", function () {
return {
restrict: "A",
require: 'ngModel',
link: function (scope, element, attr, ngModel) {
// for DOM -> model validation
ngModel.$parsers.unshift(function (value) {
console.log("parser");
ngModel.$setValidity('test', true);
return +value;
});
ngModel.$formatters.unshift(function (value) {
console.log("formatter");
ngModel.$setValidity('test', true);
return (value === undefined ? "" : value) + "!";
});
element.val(scope.minutes);
element.bind("blur", function () {
scope.$apply(function () {
console.log("blur");
element.val((scope.minutes === undefined ? "" : scope.minutes) + "#");
});
});
element.bind("focus", function () {
scope.$apply(function () {
console.log("focus");
element.val(scope.minutes);
});
});
}
};
})
This is known behaviour. I've posted a bug report here:
https://github.com/angular/angular.js/issues/14987
The solution was to use $timeout to delay the setting of the element value, as suggested in the reply to the bug report.

select2, ng-model and angular

Using jquery-select2 (not ui-select) and angular, I'm trying to set the value to the ng-model.
I've tried using $watch and ng-change, but none seem to fire after selecting an item with select2.
Unfortunately, I am using a purchased template and cannot use angular-ui.
HTML:
<input type="hidden" class="form-control select2remote input-medium"
ng-model="contact.person.id"
value="{{ contact.person.id }}"
data-display-value="{{ contact.person.name }}"
data-remote-search-url="api_post_person_search"
data-remote-load-url="api_get_person"
ng-change="updatePerson(contact, contact.person)">
ClientController:
$scope.updatePerson = function (contact, person) {
console.log('ng change');
console.log(contact);
console.log(person);
} // not firing
$scope.$watch("client", function () {
console.log($scope.client);
}, true); // not firing either
JQuery integration:
var handleSelect2RemoteSelection = function () {
if ($().select2) {
var $elements = $('input[type=hidden].select2remote');
$elements.each(function(){
var $this = $(this);
if ($this.data('remote-search-url') && $this.data('remote-load-url')) {
$this.select2({
placeholder: "Select",
allowClear: true,
minimumInputLength: 1,
ajax: { // instead of writing the function to execute the request we use Select2's convenient helper
url: Routing.generate($this.data('remote-search-url'), {'_format': 'json'}),
type: 'post',
dataType: 'json',
delay: 250,
data: function (term, page) {
return {
query: term, // search term
};
},
results: function (data, page) { // parse the results into the format expected by Select2.
return {
results: $.map(data, function (datum) {
var result = {
'id': datum.id,
'text': datum.name
};
for (var prop in datum) {
if (datum.hasOwnProperty(prop)) {
result['data-' + prop] = datum[prop];
}
}
return result;
})
}
}
},
initSelection: function (element, callback) {
// the input tag has a value attribute preloaded that points to a preselected movie's id
// this function resolves that id attribute to an object that select2 can render
// using its formatResult renderer - that way the movie name is shown preselected
var id = $(element).val(),
displayValue = $(element).data('display-value');
if (id && id !== "") {
if (displayValue && displayValue !== "") {
callback({'id': $(element).val(), 'text': $(element).data('display-value')});
} else {
$.ajax(Routing.generate($this.data('remote-load-url'), {'id': id, '_format': 'json'}), {
dataType: "json"
}).done(function (data) {
callback({'id': data.id, 'text': data.name});
});
}
}
},
});
}
});
}
};
Any advice would be greatly appreciated! :)
UPDATE
I've managed to put together a plunk which seems to similarly reproduce the problem - it now appears as if the ng-watch and the $watch events are fired only when first changing the value.
Nevertheless, in my code (and when adding further complexity like dynamically adding and removing from the collection), it doesn't even seem to fire once.
Again, pointers in the right direction (or in any direction really) would be greatly appreciated!
There are a number of issues with your example. I'm not sure I am going to be able to provide an "answer", but hopefully the following suggestions and explanations will help you out.
First, you are "mixing" jQuery and Angular. In general, this really doesn't work. For example:
In script.js, you run
$(document).ready(function() {
var $elements = $('input[type=hidden].select2remote');
$elements.each(function() {
//...
});
});
This code is going to run once, when the DOM is initially ready. It will select hidden input elements with the select2remote class that are currently in the DOM and initialized the select2 plugin on them.
The problem is that any new input[type=hidden].select2remote elements added after this function is run will not be initialized at all. This would happen if you are loading data asynchronously and populating an ng-repeat, for example.
The fix is to move the select2 initialization code to a directive, and place this directive on each input element. Abridged, this directive might look like:
.directive('select2', function() {
return {
restrict: 'A',
require: 'ngModel',
link: function(scope, element, attr, ngModel) {
//$this becomes element
element.select2({
//options removed for clarity
});
element.on('change', function() {
console.log('on change event');
var val = $(this).value;
scope.$apply(function(){
//will cause the ng-model to be updated.
ngModel.setViewValue(val);
});
});
ngModel.$render = function() {
//if this is called, the model was changed outside of select, and we need to set the value
//not sure what the select2 api is, but something like:
element.value = ngModel.$viewValue;
}
}
}
});
I apologize that I'm not familiar enough with select2 to know the API for getting and setting the current value of the control. If you provide that to me in a comment I can modify the example.
Your markup would change to:
<input select2 type="hidden" class="form-control select2remote input-medium"
ng-model="contact.person.id"
value="{{ contact.person.id }}"
data-display-value="{{ contact.person.name }}"
data-remote-search-url="api_post_person_search"
data-remote-load-url="api_get_person"
ng-change="updatePerson(contact, contact.person)">
After implementing this directive, you could remove the entirety of script.js.
In your controller you have the following:
$('.select2remote').on('change', function () {
console.log('change');
var value = $(this).value;
$scope.$apply(function () {
$scope.contact.person.id = value;
});
});
There are two problems here:
First, you are using jQuery in a controller, which you really shouldn't do.
Second, this line of code is going to fire a change event on every element with the select2remote class in the entire application that was in the DOM when the controller was instatiated.
It is likely that elements added by Angular (i.e through ng-repeat) will not have the change listener registered on them because they will be added to the DOM after the controller is instantiated (at the next digest cycle).
Also, elements outside the scope of the controller that have change events will modify the state of the controller's $scope. The solution to this, again, is to move this functionality into the directive and rely on ng-model functionality.
Remember that anytime you leave Angular's context (i.e if you are using jQuery's $.ajax functionality), you have to use scope.$apply() to reenter Angular's execution context.
I hope these suggestions help you out.

AngularJS : directive two way data binding not working

I have a controller with following code snippet,
...
$scope.selected_contents = [];
$scope.$watch('selected_contents', function (sel_contents) {
console.log(sel_contents, 'selected contents');
}, true);
...
a directive,
commonDirectives.directive('chkbox', function() {
return {
restrict: 'A',
require: '?ngModel',
scope : {
item : '=item',
selection_pool: '=selectionPool'
},
link: function(scope, elem, attrs, ngModel) {
console.log('selected contents are', scope.selection_pool);
// watch selection_pool
scope.$watch('selection_pool', function (pool) {
console.log(pool, scope.selection_pool, 'pool updated');
if (_.contains(pool, scope.item)) {
elem.prop('checked', true);
}
else {
elem.prop('checked', false);
}
});
// toggle the selection of this component
var toggle_selection = function () {
if(_.indexOf(scope.selection_pool, scope.item) != -1) {
scope.selection_pool = _.without(scope.selection_pool , scope.item);
}
else {
scope.selection_pool.push(scope.item);
}
};
elem.on('click', toggle_selection);
}
};
});
and a template which uses the directive,
<tr ng-repeat="content in contents">
<td><input type="checkbox" selection_pool="selected_contents" item="content" chkbox></td>
</tr>
The problem is, changes in selection_pool in the directive is not reflected to selected_contents in the controller. What am i missing?
Update 1:
Following the suggestion from #mohamedrias I wrapped the changes in scope with scope.$apply. Doing so updates selected_contents in controller only while adding the content but not while removing it.
...
// toggle the selection of this component
var toggle_selection = function () {
if(_.indexOf(scope.selection_pool, scope.item) != -1) {
scope.$apply(function () {
scope.selection_pool = _.without(scope.selection_pool , scope.item);
});
}
else {
scope.$apply(function () {
scope.selection_pool.push(scope.item);
});
}
};
...
Angular uses name-with-dashes for attribute names and camelCase for
the corresponding directive name
From here.
The variable should be changed from this selection_pool:
<input type="checkbox" selection_pool="selected_contents" item="content" chkbox>
to selection-pool:
<input type="checkbox" selection-pool="selected_contents" item="content" chkbox>
And this selectionPool into the directive:
scope : {
item : '=item',
selectionPool: '=selectionPool'
}
EDIT: Because the selectionPool is an array, you should use $watchCollection:
scope.$watchCollection('selectionPool', function (pool)
And when you add/remove values from the array in toggle_selection function, should be wrapped within the $timeout function:
$timeout(function () {
if (_.indexOf(scope.selectionPool, scope.item) != -1) {
scope.selectionPool = _.without(scope.selectionPool, scope.item);
} else {
scope.selectionPool.push(scope.item);
}
});
This is to assure that a digest cycle is going to be applied afterwards.
Here's the code working on a jsfiddle: http://jsfiddle.net/0rvcguz0/3/
After researching for entire day, I ended up here. If someone is having any trouble with Angularjs scope, I highly encourage to read it.
The proper solution in my case was to wrap selected_contents by an object. e.g.
$scope.selected = {};
$scope.selected.contents = [];
Then in the template replace selcted_contents with selected.contents.
But still what I don't understand is, [] or an Array is also an object. My earlier code should have worked according to the information I found in the wiki. If anyone could explain me why I would really appreciate it :).

Handling IE's clear button with AngularJS binding

IE has an "X" in each text input that will clear the input. However, when clicking this button, while it clears the textbox, it does not update the Angular model that the input is bound to.
<input type="text" ng-model="name" />
See http://jsfiddle.net/p5x1zwr9/ for an example of the behavior.
See http://youtu.be/LFaEwliTzpQ for a video of the behavior.
I am using IE 11.
EDIT: There does seem to be a solution for Knockout, but I don't know how to apply it to AngularJS: Handle IE 9 & 10's clear button with Knockout binding
UPDATE: Jonathan Sampson helped me realize that this actually worked in AngularJS versions prior to 1.3.6 so this may be a new Angular bug.
UPDATE: Opened issue: https://github.com/angular/angular.js/issues/11193
The X button in input forms is native for IE10+ and you can`t do anything about it, but only hide it with CSS:
input[type=text]::-ms-clear {
display: none;
}
Then you can create your own directive to mimic this kind of behaviour. Just create a span, position it inside of an input and add ng-click to it, which will clear the model value of the input.
I created this Angular directive for input text elements, which manually calls the element's change() event when the clear ('X') button is clicked. This fixed the problem on our project. I hope it helps others.
angular.module('app')
.directive('input', function () {
return {
restrict: 'E',
scope: {},
link: function (scope, elem, attrs) {
// Only care about textboxes, not radio, checkbox, etc.
var validTypes = /^(search|email|url|tel|number|text)$/i;
if (!validTypes.test(attrs.type)) return;
// Bind to the mouseup event of the input textbox.
elem.bind('mouseup', function () {
// Get the old value (before click) and return if it's already empty
// as there's nothing to do.
var $input = $(this), oldValue = $input.val();
if (oldValue === '') return;
// Check new value after click, and if it's now empty it means the
// clear button was clicked. Manually trigger element's change() event.
setTimeout(function () {
var newValue = $input.val();
if (newValue === '') {
angular.element($input).change();
}
}, 1);
});
}
}
});
With thanks to this answer (Event fired when clearing text input on IE10 with clear icon) for the JavaScript code to detect the clear button click.
I was able to solve this using the following directive - derived from 0x783e's answer above. It may provide better compatibility with later versions of angular. It should work with $watches or parsers in addition to ng-change.
angular
.module('yourModuleName')
.directive('input', FixIEClearButton);
FixIEClearButton.$inject = ['$timeout', '$sniffer'];
function FixIEClearButton($timeout, $sniffer) {
var directive = {
restrict: 'E',
require: '?ngModel',
link: Link,
controller: function () { }
};
return directive;
function Link(scope, elem, attr, controller) {
var type = elem[0].type;
//ie11 doesn't seem to support the input event, at least according to angular
if (type !== 'text' || !controller || $sniffer.hasEvent('input')) {
return;
}
elem.on("mouseup", function (event) {
var oldValue = elem.val();
if (oldValue == "") {
return;
}
$timeout(function () {
var newValue = elem.val();
if (newValue !== oldValue) {
elem.val(oldValue);
elem.triggerHandler('keydown');
elem.val(newValue);
elem.triggerHandler('focus');
}
}, 0, false);
});
scope.$on('$destroy', destroy);
elem.on('$destroy', destroy);
function destroy() {
elem.off('mouseup');
}
}
}
While hiding using CSS
Instead of 'type=text' use 'type=search' in search fields.By doing this only inputs marked as 'type=search' will not have 'X' but other inputs will still have 'X' which is required on many other fields in IE.
input[type=search]::-ms-clear {
display: none;
}
<input type="text" ng-model="name" id="search" />
This solution works for me
$("#search").bind("mouseup", function(e){
var $input = $(this),
oldValue = $input.val();
if (oldValue == "") return;
// When this event is fired after clicking on the clear button
// the value is not cleared yet. We have to wait for it.
setTimeout(function(){
var newValue = $input.val();
if (newValue == ""){
$scope.name="";
$scope.$apply();
}
}, 1);
});
The solution I came up with, while doesn't update the model immediately like removing the X and implementing you own solution, It does solve for what i needed. All I did was add ng-model-options to include blur. So when the input is blurred it will update the scope value.
<input type="text" ng-model="name" ng-model-options="{ updateOn: 'default blur'}" />

Angular ng-blur not working with ng-hide

Using a directive focus-me="inTextModeInput" in a text input
app.directive('focusMe', function($timeout) {
/*focuses on input
<input type="text" focus-me="focusInput">
*/
return {
scope: { trigger: '=focusMe' },
link: function(scope, element) {
scope.$watch('trigger', function(value) {
if(value === true) {
$timeout(function() {
element[0].focus();
scope.trigger = false;
});
}
});
}
};
});
Actually having 2 inputs, both uses focus-me
When i programatically set the value to focus on an input the ng-blur of other is not called.
NOTE : i am also using this in an ng-repeat.
Isolated scope
The blur is called, but you're not seeing that because you've created a directive with an isolated scope. The ng-blur is executed on the $parent scope. You should only use an isolated scope when the directive is implementing re-useable templates.
Two way binding on trigger
The line 'scope.trigger = false' is also setting a different boolean value because it's on a different scope. If you want to assign a value to a variable from a directive you should always wrap the value inside another object: var focus = { me: true } and set it like trigger=focus.me.
A better solution
But I wouldn't set the trigger to false at all. AngularJS is a MVC/MVVM based framework which has a model state for the user interface. This state should be idempotent; meaning that if you store the current state, reload the page and restore the state the user interface should be in the exact same situation as before.
So what you probably need is a directive that
Has no isolated scope (which allows all other directives to work: ng-blur, ng-focus, ...)
Keeps track of a boolean, which indicates the focus state
Sets this boolean to false when the element has lost focus
It's probably easier to see this thing in action: working plunker.
Maybe this (other) plunker will give you some more insight on scopes and directives.
Code
myApp.directive('myFocus', function($parse, $timeout) {
return {
restrict: 'A',
link: function myFocusLink($scope, $element, $attrs, ctrls) {
var e = $element[0];
// Grab a parser from the provided expression so we can
// read and assign a value to it.
var getModel = $parse($attrs.myFocus);
var setModel = getModel.assign;
// Watch the parser -- and focus if true or blur otherwise.
$scope.$watch(getModel, function(value) {
if(value) {
e.focus();
} else {
e.blur();
}
});
function onBlur() {
$timeout(function() {
setModel($scope, false);
});
}
function onFocus() {
$timeout(function() {
setModel($scope, true);
});
}
$element.on('focus', onFocus);
$element.on('blur', onBlur);
// Cleanup event registration if the scope is destroyed
$scope.$on('$destroy', function() {
$element.off('focus', onFocus);
$element.off('blur', onBlur);
});
}
};
});

Resources