Custom directive not picking up model initialization - angularjs

I have written a custom AngularJS directive that implements a toggle checkbox based on Bootstrap Toggle. I added support for angular-translate, but that is beyond my actual proplem. Furthermore, I wanted to use angular-cookies to save and restore the current state of a particular checkbox.
However, my directive does not pick-up the initial value of the data model properly.
This is my directive:
app.directive('toggleCheckbox', ['$rootScope', '$translate', '$timeout', function($rootScope, $translate, $timeout) {
return {
restrict: 'A',
require: 'ngModel',
link: function(scope, element, attributes, ngModelController) {
// Change model, when checkbox is toggled
element.on('change.toggle', function(event) {
var checked = element.prop('checked');
console.log('change.toggle was called: ' + checked + ' vs. ' + ngModelController.$viewValue);
if (checked != ngModelController.$viewValue) {
scope.$apply(function changeViewModel() {
ngModelController.$setViewValue(checked);
console.log('change.toggle:', checked);
});
}
});
// Render element
ngModelController.$render = function() {
element.bootstrapToggle(ngModelController.$viewValue ? 'on' : 'off')
};
// Translate checkbox labels
var updateLabels = function() {
var offLabel = (attributes['off'] ? $translate.instant(attributes['off']) : 'Off');
var onLabel = (attributes['on'] ? $translate.instant(attributes['on']) : 'On');
angular.element(document).find('label.btn.toggle-off').html(offLabel);
angular.element(document).find('label.btn.toggle-on').html(onLabel);
};
// Update labels, when language is changed at runtime
$rootScope.$on('$translateChangeSuccess', function() {
updateLabels();
});
// Initialize labels for the first time
$timeout(function() {
updateLabels();
});
// Clean up properly
scope.$on('$destroy', function() {
element.off('change.toggle');
element.bootstrapToggle('destroy');
});
// Initialize element based on model
var initialValue = scope.$eval(attributes.ngModel);
console.log('initialValue:', initialValue);
element.prop('checked', initialValue);
}
};
}]);
This is how I initialize the data model from cookie:
mainController.controller('MainCtrl', ['$scope', '$cookies', 'Main', function($scope, $cookies, Main) {
this.$onInit = function() {
$scope.settings.foobar = $cookies.get('foobar');
console.log('$onInit(): ', $scope.settings.foobar);
};
// ...
}]);
And this is how I eventually use my directive:
<div id="foobar-switcher" ng-if="isAdmin()">
<label for="foobar_toggle"><span translate="foobar"></span>:</label>
<input id="foobar_toggle" type="checkbox"
ng-model="settings.foobar" ng-change="setFoobarCookie(settings.foobar)" toggle-checkbox
data-off="foo_label" data-offstyle="success"
data-on="bar_label" data-onstyle="danger" />
</div>
Ultimately, I get this debug output:
controllers.js:33 $onInit(): true
directives.js:76 initialValue: true
directives.js:37 change.toggle was called: false vs. false
So in case the value true is stored in the cookie, the model gets initialized properly in the global scope and even the directive uses the correct value to initialize element. The change.toggle event handler is triggered as well, but there the element's value is now false.
Why is that and how can I fix this problem?

It turned out that $cookies.get('foobar') returns a String and the checkbox cannot handle a ng-model of that type. Instead, the cookie value must be evaluated such, that a boolean value can be set in the data model:
var cookie = $cookies.get('foobar');
$scope.settings.foobar = (cookie === 'true');
The checkbox is then correctly initialized on page load.

Related

In angular, how to call a function defined inside a controller of directive?

Here is code:
Directive code:
angular.module('app', ['localytics.directives', 'ngLoadScript'])
.directive("home", function() {
return {
restrict: "A",
replace: true,
template: "<div ng-include='var'>{{var}}</div>",
controller: function($scope) {
//loading home page - as default
$scope.var = "/tfm/home.html"
//on change the page changed dynamically!
$scope.change = function(where) {
$scope.var = where;
}
}
}
})
I WANT TO CALL chanage(where) FUNCTION OF DIRECTIVE - DEFINED IN CONTROLLER OF DIRECTIVE.
Controller Code:
.controller('wToEatController', ['$scope', function($scope) {
$scope.submitInfoAboutTable = function() {
//validation for time selection
if($scope.idealTableTime == undefined || $scope.rightTableTime == undefined) {
return;
}
//Booking details to be updated - here users preference is updating!
var bookingDetails = {
idealTableWaitTime: $scope.idealTableTime,
rightTableWaitTime: $scope.rightTableTime
}
//Let's update booking information - the booking key will be same used while login, public/tfm.html
FirebaseDbService.updateBooking(function(isUpdated) {
console.log(isUpdated);
//I WANT TO CALL chanage(where) function of DIRECTIVE
$scope.change = "change('/tfm/home.html')";
}, bookingDetails, bookingKey);
}
}]);
Is it possible?
You have to create an attribute with which the link will be done (in this example customAttr):
<span yourDirectiveName customAttr="myFunctionLink(funcInDirective)"></span>
And into your directive controller just set the new attribute like in the following snippet( '&' two way data binding ) , and create a connection with your directive method :
scope : {customAttr : '&'},
link : function(scope,element,attrs){
scope.myDirectiveFunc = function(){
console.log("my directive function was called");}
}
scope.customAttr({funcInDirective : scope.myDirectiveFunc});
}
And in your controller :
$scope.myFunctionLink = function(funcInDirective){
$scope.callableDirectiveFunc = funcInDirective;}
Now you can call your directive function with $scope.callableDirectiveFunc();

Accessing parent directive scope

So, I am trying to have 2 directives (techincally 3) on one page, which looks like this:
<div kd-alert newsletter></div>
<div kd-alert cookie></div>
this is on the index page, so there are no controllers.
I have been playing around with isolating scopes with directives and I have found that even though within the link function, scopes are isolated, if your directives use controllers the templates can see both controllers and if both controllers have a property with the same name they can be overwritten by the other controller, which is a nightmare so I decided to create a parent directive with one controller that serves the other 2 directives.
The parent directive in this case is called kd-alert and looks like this:
.directive('kdAlert', function () {
return {
restrict: 'A',
controller: 'AlertController',
link: function (scope, element, attr, controller) {
// Have to use a watch because of issues with other directives
scope.$watch(function () {
// Watch the dismiss
return controller.dismiss;
// If the value changes
}, function (dismiss) {
// If our value is false
if (dismiss === false || dismiss === 'false') {
// Remove the class from the element
element.removeClass('ng-hide');
// Else, if the value is true (or anything else)
} else {
// Add the class to the element
element.addClass('ng-hide');
}
});
// Get our buttons
var buttons = element.find('button');
// Binds our close button
scope.bindCloseButton = function (cookieName) {
// If we have a button
for (var i = 0; i < buttons.length; i++) {
// Get our current button
var button = angular.element(buttons[i]);
// If our button is the close button
if (button.hasClass('close')) {
// If the button is clicked
button.on('click', function (e) {
console.log('clicked');
// Prevent any default actions
e.preventDefault();
// dismiss the alert
controller.dismissAlert(cookieName);
// Remove our element
element.remove();
});
}
}
};
}
};
})
The controller handles methods for both child directives but is still pretty thin. It looks like this:
.controller('AlertController', ['$cookies', 'SubscriberService', 'toastr', function ($cookies, subscriverService, toastr) {
var self = this;
// Set our dismiss to false
self.dismiss = false;
// Set the flag
self.getDismissValue = function (cookieName) {
// Set our cookie
self.dismiss = $cookies[cookieName] || false;
};
// Set the flag
self.dismissAlert = function (cookieName) {
// Set our cookie
self.dismiss = $cookies[cookieName] = true;
};
// Saves our email address
self.subscribe = function (email, cookieName) {
// Subscribe
subscriverService.subscribe(email).success(function () {
// If we succeed, display a message
toastr.success('You will now recieve occasional newsletters.');
// Dismiss the alert
self.dismissAlert(cookieName);
});
};
}])
Now I have a cookie directive which works fine...
.directive('cookie', function () {
return {
restrict: 'A',
require: '^kdAlert',
templateUrl: '/assets/tpl/directives/cookie.html',
link: function (scope, element, attr, controller) {
console.log(scope);
// Get our cookie name
var cookieName = 'cookieAlert';
// Get our dismiss value
controller.getDismissValue(cookieName);
// Bind our close button
scope.bindCloseButton(cookieName);
}
};
})
When I refresh my page I can clearly see the scope with the bindCloseButton method within that scope. So far so good.
The problem is with the newsletter directive, it looks like this:
.directive('newsletter', function () {
return {
restrict: 'A',
require: '^kdAlert',
templateUrl: '/assets/tpl/directives/newsletter.html',
link: function (scope, element, attr, controller) {
console.log(scope);
// Get our cookie name
var cookieName = 'newsletterAlert';
// Get our dismiss value
controller.getDismissValue(cookieName);
// Bind our close button
scope.bindCloseButton(cookieName);
// Saves our email address
scope.subscribe = function (valid) {
// If we are not valid
if (!valid) {
// Return from the function
return;
}
// Subscribe
controller.subscribe(scope.email, cookieName);
};
}
};
})
Again, if I refresh the page I can clearly see the bindCloseButton method within that scope, but for some reason I get this error:
scope.bindCloseButton is not a function
And that appears on the line within the newsletter directive.
If I remove the cookie directive off the page, I still get the error.
Can anyone explain why?
use controller.bindCloseButton instead of scope.bindCloseButton .
This is happening because of the isolation of scope. you are doing in this approach and that's why you are losing scopes here.

How to test a directive containing a text field in Angularjs

I have a directive containing a text field, and I want to test to make sure that the text entered into the field makes it to the model.
The directive:
define(function(require) {
'use strict';
var module = require('reporting/js/directives/app.directives');
var template = require('text!reporting/templates/text.box.tpl');
module.directive('textField', function () {
return {
restrict: 'A',
replace: true,
template:template,
scope: {
textField : "=",
textBoxResponses : "="
},
link: function(scope) {
scope.debug = function () {
scope;
// debugger;
};
}
};
});
return module;
});
The markup:
<div ng-form name="textBox">
<!-- <button ng-click="debug()">debug the text box button</button> -->
<h1>Text Box!</h1>
{{textField.label}} <input type="text" name="textBox" ng-model="textBoxResponses[textField.fieldName]">{{name}}
</div>
The test code:
/* global inject, expect, angular */
define(function(require){
'use strict';
require('angular');
require('angularMock');
require('reporting/js/directives/app.directives');
require('reporting/js/directives/text.box.directive');
describe("builder experimenter", function() {
var directive, scope;
beforeEach(module('app.directives'));
beforeEach(inject(function($compile, $rootScope) {
scope = $rootScope;
scope.textBoxResponses = {};
scope.textBoxField = {
fieldName : "textBox1"
};
directive = angular.element('<div text-field="textBoxField" text-box-responses="textBoxResponses"></div>');
$compile(directive)(scope);
scope.$digest();
}));
it('should put the text box value on the model', inject(function() {
directive.find(":text").val("something");
expect(scope.textBoxResponses.textBox1).toBe("something");
}));
});
});
So, what I'm trying to do in the last it block is to simulate typing in the text field, and then check to make sure that the new value of the text field makes it to the model. The issue is that the model is never updated with the new value.
The issue is ng-model is never informed that anything is in the textfield. ng-model is listening for the input event. All you have to do to fix your code is:
var text = directive.find(":text");
text.val("something");
text.trigger('input');
expect(scope.textBoxResponses.textBox1).toBe("something");
When the ng-model gets the event input, then check your scope and everything will be what you expect.
I got this done by using the sniffer service.
Your test will look like this:
var sniffer;
beforeEach(inject(function($compile, $rootScope, $sniffer) {
scope = $rootScope;
sniffer = $sniffer;
scope.textBoxResponses = {};
scope.textBoxField = {
fieldName : "textBox1"
};
directive = angular.element('<div text-field="textBoxField" text-box-responses="textBoxResponses"></div>');
$compile(directive)(scope);
scope.$digest();
}));
it('should put the text box value on the model', inject(function() {
directive.find(":text").val("something");
directive.find(":text").trigger(sniffer.hasEvent('input') ? 'input' : 'change');
expect(directive.isolateScope().textBoxResponses.textBox1).toBe("something");
}));
I found this pattern here: angular-ui-bootstrap typeahead test
The trigger basically makes the view value go into the model.
Hope this helps

Why is this ng-show directive not working in a template?

I am trying to write a directive that will do a simple in-place edit for an element. This is my code so far:
directive('clickEdit', function() {
return {
restrict: 'A',
template: '<span ng-show="inEdit"><input ng-model="editModel"/></span>' +
'<span ng-show="!inEdit" ng-click="edit()">{{ editModel }}</span>',
scope: {
editModel: "=",
inEdit: "#"
},
link: function(scope, element, attr) {
scope.inEdit = false;
var savedValue = scope.editModel;
var input = element.find('input');
input.bind('keyup', function(e) {
if ( e.keyCode === 13 ) {
scope.save();
} else if ( e.keyCode === 27 ) {
scope.cancel();
}
});
scope.edit = function() {
scope.inEdit = true;
setTimeout(function(){
input[0].focus();
input[0].select();
}, 0);
};
scope.save = function() {
scope.inEdit = false;
};
scope.cancel = function() {
scope.inEdit = false;
scope.editModel = savedValue;
};
}
}
})
The scope.edit function sets inEdit to true, and that works well - it hides the text and shows the input tag. However, the scope.save function, which sets scope.inEdit to false does not work at all. It does not hide the input tag and show the text.
Why?
You are calling scope.save() from a event handler reacting to the keyup event. However this event handler is not called by/through the AngularJS framework. AngularJS will only scan for changes of the model if it believes that changes might have occured in order to lessen the workload (AngularJS as of now does dirty-checking with is computational intensive).
Therefore you must make use of the scope.$apply feature to make AngularJS aware that you are doing changes to the scope. Change the scope.save function to this and it shall work:
scope.save = function(){
scope.$apply(function(){
scope.inEdit = false;
});
});
Also it appears that there is actually no need to bind this save function to a scope variable. So you might want to instead define a "normal" function or just integrate the code into your event handler.

AngularJS: How to set a controller property from a directive that uses a child scope?

LIVE DEMO
Consider the following spinner-click directive:
Directive Use:
<button class="btn btn-mini"
ng-class="{'btn-warning': person.active, disabled: !person.active}"
spinner-click="deleteItem($index)"
spinner-text="Please wait..."
spinner-errors="alerts">
Delete
</button>
Directive:
app.directive('spinnerClick', function() {
return {
restrict: 'A',
scope: true,
link: function(scope, element, attrs) {
var originalHTML = element.html();
var spinnerHTML = "<i class='icon-refresh icon-spin'></i> " + attrs.spinnerText;
element.click(function() {
if (element.is('.disabled')) {
return;
}
element.html(spinnerHTML).addClass('disabled');
scope.$apply(attrs.spinnerClick).then(function() {
element.html(originalHTML).removeClass('disabled');
}, function(errors) {
element.html(originalHTML).removeClass('disabled');
// This is ugly! Is there a better way?
var e = scope[attrs.spinnerErrors];
e.length = 0;
e.push.apply(e, errors);
});
});
}
};
});
Controller:
app.controller('MainCtrl', function($scope, $q, $timeout) {
$scope.alerts = ['First alert'];
$scope.people = [
{ name: 'David', active: true },
{ name: 'Layla', active: false }
];
$scope.deleteItem = function(index) {
var defer = $q.defer();
$timeout(function() {
defer.reject(["Something 'bad' happened.", "Check your logs."]);
}, 2000);
return defer.promise;
};
});
Note: spinner-click can be used with other directives (e.g. ng-class in this example).
As you can see, I set $scope.alerts in the directive using a very nasty way. Can you find a better way to do this?
UPDATE: (DEMO)
I tried to use $parse like this:
var errorsModel = $parse(attrs.spinnerErrors);
errorsModel.assign(scope, errors);
and this doesn't work.
BUT, if I have spinner-errors="wrapper.alerts" rather than spinner-errors="alerts", it does work!
Is there a way to avoid using the wrapper?
I think you can do it more simply using an isolate scope.
Instead of scope: true,, you should put:
scope:{
spinnerClick:"&",
spinnerText : "#",
spinnerErrors: "="
}
And then, in your directive use scope.spinnerClick, scope.spinnerText , scope.spinnerErrors directly.
The & is used to bind function expression defined in your attribute and pass it to your directive's scope, the # will bind the text value of the attribute and the = will set a double binding with the expression passed in the attribute.
You can fine a more precise explanation here http://docs.angularjs.org/guide/directive (look at the long version), and a much clearer explanation here http://www.egghead.io/ (look at the isolate scope videos, it only takes a few minutes and makes it look so simple).
To answer your original question regarding the ugliness of
// This is ugly! Is there a better way?
var e = scope[attrs.spinnerErrors];
e.length = 0;
e.push.apply(e, errors);
You can use angular.copy to achieve the same results
angular.copy(errors, scope[attrs.spinnerErrors]);
The reason this is so ugly in your directive is due to your use of a child scope. If you did not create a child scope, or were willing to create an isolation scope this would not be a problem. You can't use $scope.alerts because
The child scope gets its own property that hides/shadows the parent
property of the same name. Your workarounds are
define objects in the parent for your model, then reference a property of that object in the child: parentObj.someProp
use $parent.parentScopeProperty (not always possible, but easier than 1. where possible)
define a function on the parent scope, and call it from the child (not always possible)
There is a detailed explanation that can be found here.
One option is to create a setter function in the controller that you can call in the directive. The reference to the function in the child scope could then be used set the value in the parent scope. Another option is to create an isolation scope and then pass in a setter function using & binding.
You had the right idea with $parse. The problem is that you're assigning the new array of errors to the child scope, which hides (but doesn't replace) the array on the parent/controller scope.
What you have to do is get a reference to the parent array and then replace the contents (like you were doing in the original version). See here.
I question the need to put the error logic within the directive. You can simply handle the error as part of the controller. Unless you absolutely need to have the html replaced and class removed before manipulating the alerts array, your code could be rewritten as:
app.directive('spinnerClick', function() {
return {
restrict: 'A',
scope: true,
link: function(scope, element, attrs) {
var originalHTML = element.html();
var spinnerHTML = "<i class='icon-refresh icon-spin'></i> " + attrs.spinnerText;
function onClickDone(){
element.html(originalHTML).removeClass('disabled');
}
element.click(function() {
if (element.is('.disabled')) {
return;
}
element.html(spinnerHTML).addClass('disabled');
scope.$apply(attrs.spinnerClick).then(onClickDone, onClickDone);
});
}
};
});
app.controller('MainCtrl', function($scope, $q, $timeout) {
$scope.alerts = ['First alert'];
$scope.people = [
{ name: 'David', active: true },
{ name: 'Layla', active: false }
];
$scope.deleteItem = function(index) {
var defer = $q.defer();
$timeout(function() {
defer.reject(["Something 'bad' happened.", "Check your logs."]);
}, 2000);
return defer.promise.then(function(){
//Success handler
}, function(error){
$scope.alerts.length = 0;
$scope.alerts.push(error);
});
};
});

Resources