Using $compile to manipulate angular directives from a custom directive - angularjs

I'm creating a directive which would disable all elements inside the element to which it is applied. For simplicity, lets assume only buttons are disabled. In the directive link function I'm just setting the disabled attribute for the elements to be disabled. This is working fine, but the problem comes in scenarios where some of the buttons have ng-disabled attributes, In such cases, the buttons become disabled/enabled based on the condition in ng-disabled, thus ignoring the directive logic.
So I thought of making use of $compile and fix it with the following approach:
When buttons need to be disabled,
1) Add disabled attribute to the buttons.
2) Check if buttons have 'ng-disabled' attribute. If so,keep its value in the same DOM element under a different attribute name.
3) Delete the ng-disabled attribute.
4) Recompile to reflect the change in attributes (So there will not be any ngDisabled checks on recompilation).
When buttons are re-enabled,
1) Remove disabled attributes
2) Check for attribute created in step (2) of above and add it to the ng-disabled attribute
3) Remove the backup attribute
4) Recompile
But this is not working.The ngDisabled conditions are still evaluated even when the attribute is not there.
Directive Code:
function disableElements($compile) {
return {
restrict: "A",
scope: {
disableElements: "="
},
link: function (scope, elem, attr) {
scope.$watch('disableElements', function (newVal) {
var buttons;
var ngDisabled;
var backup;
buttons = elem.find('button');
if (newVal) {
buttons.attr('disabled', 'disabled');
for (var i = 0, j = buttons.length; i < j; i++) {
ngDisabled = $(buttons[i]).attr('ng-disabled');
if (typeof ngDisabled !== typeof undefined && ngDisabled !== false) {
$(buttons[i]).attr('backup', ngDisabled).removeAttr('ng-disabled');
}
}
$compile(elem.contents())(scope);
} else {
buttons.removeAttr('disabled');
for (var i = 0, j = buttons.length; i < j; i++) {
backup = $(buttons[i]).attr('backup');
if (typeof backup !== typeof undefined && backup !== false) {
$(buttons[i]).attr('ng-disabled', backup).removeAttr('backup');
}
}
$compile(elem.contents())(scope);
}
});
}
};
}
Sample View and Controller
<input type="text" ng-model="vm.val">
<div disable-elements="vm.disableAll">
<button type="button" ng-disabled="vm.val.length===0" ng-click="vm.buttonClicked()">With ng disabled</button>
<button type="button" ng-click="vm.buttonClicked()">Without ng disabled</button>
</div>
<label><input type="checkbox" ng-model="vm.disableAll">Disable all</label>
function SampleController() {
this.disableAll = true;
this.val = '';
this.buttonClicked = function () {
alert("click");
}
}
jsfiddle here
Please let me know what is wrong with this code or if it is the right approach to this issue.

re-compiling the content doesnt unlink the previously compiled directive.
There is, actually, no easy way to do what you wanna do. The only correct approach will be to stop using ng-disable to use your own custom-disable directive which would be aware of it's parent directive "globallyDisabled" state.

Related

AngularJS: Pause $digest & watchers on hidden DOM elements

We're building a single page application which has multiple pages loaded as tabs. Only the content of one tab is visible at any given time (much like a browser), so we want to temporarily pause $digest and watchers from executing on those DOM nodes of the hidden tabs, until the user switches to that tab.
Is there a way to achieve this, so that the model continues to be updated for the background tabs, but the view updates based on a condition.
The following code illustrates the problem:
<div ng-repeat="tab in tabs" ng-show="tab.id == current_tab.id">
<!-- tab content with bindings -->
</div>
The goal is optimization.
I'm already aware of Scalyr directives, but I want a more specific solution without the extra features contained in Scalyr.
After some trial and error I've figured out the following directive which pauses all the children's $$watchers if the expression on the attribute evaluates to true, on false it restores any backed up $$watchers
app.directive('pauseChildrenWatchersIf', function(){
return {
link: function (scope, element, attrs) {
scope.$watch(attrs.pauseChildrenWatchersIf, function (newVal) {
if (newVal === undefined) {
return;
}
if (newVal) {
toggleChildrenWatchers(element, true)
} else {
toggleChildrenWatchers(element, false)
}
});
function toggleChildrenWatchers(element, pause) {
angular.forEach(element.children(), function (childElement) {
toggleAllWatchers(angular.element(childElement), pause);
});
}
function toggleAllWatchers(element, pause) {
var data = element.data();
if (data.hasOwnProperty('$scope') && data.$scope.hasOwnProperty('$$watchers') && data.$scope.$$watchers) {
if (pause) {
data._bk_$$watchers = [];
$.each(data.$scope.$$watchers, function (i, watcher) {
data._bk_$$watchers.push($.extend(true, {}, watcher))
});
data.$scope.$$watchers = [];
} else {
if (data.hasOwnProperty('_bk_$$watchers')) {
$.each(data._bk_$$watchers, function (i, watcher) {
data.$scope.$$watchers.push($.extend(true, {}, watcher))
});
}
}
}
toggleChildrenWatchers(element, pause);
}
}
}
});
Ok, the reason I asked you to show some code was because of the reason #Rouby stated.
For performance purposes, you can use ng-if instead of ng-show. ng-if removes or restores the element from the DOM.
<div ng-repeat="tab in tabs" ng-if="tab.id == current_tab.id">
<!-- tab content with bindings -->
</div>
ng-show is good to use when you want to style the hiding differently. For instance, you might want that a hidden element would only have its "body" hidden, with the header still appearing. It is possible with ng-show, you just have to define a CSS style for the class ng-hide.
If you want to keep the values of your $scope, you can bind those with a parent scope who would keep your variables intact.

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'}" />

Adding ng-show from a directive without directly compiling or using templates

Given HTML in the form of
<p ng-repeat="item in items" my-directive="item">{{item.Description}}</p>
I would like to dynamically add ng-show from within my-directive. It seems it is not as easy as perhaps it could be. If my-directive was an element instead of an attribute it could simply be done through a template, but alas the same support does not exist for attributes.
My first attempt used the $compile service as described at Add directives from directive in AngularJS. However, that approach relies on the directive being a higher priority than any other, and is therefore applied before the ng-repeat.
My current approach is to manually copy the source for ng-show:
.directive('myDirective', ['$animate', function ($animate) {
function toBoolean(value) {
if (typeof value === 'function') {
value = true;
} else if (value && value.length !== 0) {
var v = angular.lowercase("" + value);
value = !(v == 'f' || v == '0' || v == 'false' || v == 'no' || v == 'n' || v == '[]');
} else {
value = false;
}
return value;
}
return {
scope: true,
link: function(scope, elem, attrs) {
scope.$watch("myWatchExpression",
function(isActive) {
$animate[toBoolean(isActive) ? 'removeClass' : 'addClass'](elem, 'ng-hide');
}
);
}
};
}])
However, that's obviously not a good long-term solution as it requires updating the custom directive whenever angular changes internally.
Is there any way to perhaps request a directive as an injectable and apply it to the element (without disrupting higher priority directives such as ng-repeat)?
Edit to give context
The directive I'm trying to create is for tabbed items. Ideally the markup looks like:
<tab-container>
<button ng-repeat="tab in tabs" tab-button="{{tab.id}}">{{tab.name}}</button>
<div ng-repeat="tab in tabs" tab-view="{{tab.id}}"><ng-include src="{{tab.template}}"/></div>
</tab-container>
Thus I would like ng-click and ng-class directives on a tab-button, and ng-show on a tab-view.

Enable/Disable Anchor Tags using AngularJS

How do I enable/disable anchor tags using the directive approach?
Example:
while clicking on edit link, create & delete needs to be disabled or grayed out
while clicking on create link, edit & delete needs to be disabled or grayed out
JAVASCRIPT:
angular.module('ngApp', []).controller('ngCtrl',['$scope', function($scope){
$scope.create = function(){
console.log("inside create");
};
$scope.edit = function(){
console.log("inside edit");
};
$scope.delete = function(){
console.log("inside delete");
};
}]).directive('a', function() {
return {
restrict: 'E',
link: function(scope, elem, attrs) {
if(attrs.ngClick || attrs.href === '' || attrs.href === '#'){
elem.on('click', function(e){
e.preventDefault();
if(attrs.ngClick){
scope.$eval(attrs.ngClick);
}
});
}
}
};
});
LINK to CODE
Update:
Disabling the href works better in the link function return. Code below has been updated.
aDisabled naturally executes before ngClick because directives are sorted in alphabetical order. When aDisabled is renamed to tagDisabled, the directive does not work.
To "disable" the "a" tag, I'd want the following things:
href links not to be followed when clicked
ngClick events not to fire when clicked
styles changed by adding a disabled class
This directive does this by mimicking the ngDisabled directive. Based on the value of a-disabled directive, all of the above features are toggled.
myApp.directive('aDisabled', function() {
return {
compile: function(tElement, tAttrs, transclude) {
//Disable ngClick
tAttrs["ngClick"] = "!("+tAttrs["aDisabled"]+") && ("+tAttrs["ngClick"]+")";
//return a link function
return function (scope, iElement, iAttrs) {
//Toggle "disabled" to class when aDisabled becomes true
scope.$watch(iAttrs["aDisabled"], function(newValue) {
if (newValue !== undefined) {
iElement.toggleClass("disabled", newValue);
}
});
//Disable href on click
iElement.on("click", function(e) {
if (scope.$eval(iAttrs["aDisabled"])) {
e.preventDefault();
}
});
};
}
};
});
Here is a css style that might indicate a disabled tag:
a.disabled {
color: #AAAAAA;
cursor: default;
pointer-events: none;
text-decoration: none;
}
And here is the code in action, with your example
My problem was slightly different: I have anchor tags that define an href, and I want to use ng-disabled to prevent the link from going anywhere when clicked. The solution is to un-set the href when the link is disabled, like this:
<a ng-href="{{isDisabled ? '' : '#/foo'}}"
ng-disabled="isDisabled">Foo</a>
In this case, ng-disabled is only used for styling the element.
If you want to avoid using unofficial attributes, you'll need to style it yourself:
<style>
a.disabled {
color: #888;
}
</style>
<a ng-href="{{isDisabled ? '' : '#/foo'}}"
ng-class="{disabled: isDisabled}">Foo</a>
For people not wanting a complicated answer, I used Ng-If to solve this for something similar:
<div style="text-align: center;">
<a ng-if="ctrl.something != null" href="#" ng-click="ctrl.anchorClicked();">I'm An Anchor</a>
<span ng-if="ctrl.something == null">I'm just text</span>
</div>
Modifying #Nitin's answer to work with dynamic disabling:
angular.module('myApp').directive('a', function() {
return {
restrict: 'E',
link: function(scope, elem, attrs) {
elem.on('click', function(e) {
if (attrs.disabled) {
e.preventDefault(); // prevent link click
}
});
}
};
});
This checks the existence of disabled attribute and its value upon every click.
Disclaimer:
The OP has made this comment on another answer:
We can have ngDisabled for buttons or input tags; by using CSS we can
make the button to look like anchor tag but that doesn't help much! I
was more keen on looking how it can be done using directive approach
or angular way of doing it?
You can use a variable inside the scope of your controller to disable the links/buttons according to the last button/link that you've clicked on by using ng-click to set the variable at the correct value and ng-disabled to disable the button when needed according to the value in the variable.
I've updated your Plunker to give you an idea.
But basically, it's something like this:
<div>
<button ng-click="create()" ng-disabled="state === 'edit'">CREATE</button><br/>
<button ng-click="edit()" ng-disabled="state === 'create'">EDIT</button><br/>
<button href="" ng-click="delete()" ng-disabled="state === 'create' || state === 'edit'">DELETE</button>
</div>
Have you tried using lazy evaluation of expressions like disabled || someAction()?
Lets assume I defined something like so in my controller:
$scope.disabled = true;
Then I can disabling a link and apply inline styles like so:
<a data-ng-click="disabled || (GoTo('#/employer/'))" data-ng-style="disabled && { 'background-color': 'rgba(99, 99, 99, 0.5)', }">Higher Level</a>
Or better still disable a link and apply a class like so:
<a data-ng-click="disabled || (GoTo('#/employer/'))" data-ng-class="{ disabled: disabled }">Higher Level</a>
Note: that you will have a class="disabled" applied to DOM element by that statement.
At this stage you just need to handle what you action GoTo() will do. In my case its as simple as redirect to associated state:
$scope.GoTo = function (state) {
if (state != undefined && state.length > 0) {
$window.location.hash = state;
}
};
Rather than being limited by ngDisabled you are limited by what you decide to do.
With this technique I successfully applied permission level checking to enable or disable user access to certain part of my module.
Simple plunker to demonstrate the point
You can create a custom directive that is somehow similar to ng-disabled and disable a specific set of elements by:
watching the property changes of the custom directive, e.g. my-disabled.
clone the current element without the added event handlers.
add css properties to the cloned element and other attributes or event handlers that will
provide the disabled state of an element.
when changes are detected on the watched property, replace the current element with the cloned element.
HTML
<a my-disabled="disableCreate" href="#" ng-click="disableEdit = true">CREATE</a><br/>
<a my-disabled="disableEdit" href="#" ng-click="disableCreate = true">EDIT</a><br/>
<a my-disabled="disableCreate || disableEdit" href="#">DELETE</a><br/>
RESET
JAVASCRIPT
directive('myDisabled', function() {
return {
link: function(scope, elem, attr) {
var color = elem.css('color'),
textDecoration = elem.css('text-decoration'),
cursor = elem.css('cursor'),
// double negation for non-boolean attributes e.g. undefined
currentValue = !!scope.$eval(attr.myDisabled),
current = elem[0],
next = elem[0].cloneNode(true);
var nextElem = angular.element(next);
nextElem.on('click', function(e) {
e.preventDefault();
e.stopPropagation();
});
nextElem.css('color', 'gray');
nextElem.css('text-decoration', 'line-through');
nextElem.css('cursor', 'not-allowed');
nextElem.attr('tabindex', -1);
scope.$watch(attr.myDisabled, function(value) {
// double negation for non-boolean attributes e.g. undefined
value = !!value;
if(currentValue != value) {
currentValue = value;
current.parentNode.replaceChild(next, current);
var temp = current;
current = next;
next = temp;
}
})
}
}
});
Make a toggle function in the respective scope to grey out the link.
First,create the following CSS classes in your .css file.
.disabled {
pointer-events: none;
cursor: default;
}
.enabled {
pointer-events: visible;
cursor: auto;
}
Add a $scope.state and $scope.toggle variable. Edit your controller in the JS file like:
$scope.state='on';
$scope.toggle='enabled';
$scope.changeState = function () {
$scope.state = $scope.state === 'on' ? 'off' : 'on';
$scope.toggleEdit();
};
$scope.toggleEdit = function () {
if ($scope.state === 'on')
$scope.toggle = 'enabled';
else
$scope.toggle = 'disabled';
};
Now,in the HTML a tags edit as:
CREATE<br/>
EDIT<br/>
DELETE
To avoid the problem of the link disabling itself,
change the DOM CSS class at the end of the function.
document.getElementById("create").className = "enabled";
You may, redefine the a tag using angular directive:
angular.module('myApp').directive('a', function() {
return {
restrict: 'E',
link: function(scope, elem, attrs) {
if ('disabled' in attrs) {
elem.on('click', function(e) {
e.preventDefault(); // prevent link click
});
}
}
};
});
In html:
<a href="nextPage" disabled>Next</a>
I'd expect anchor tags to lead to a static page with a url. I think that a buttons suits more to your use case, and then you can use ngDisabled to disable it. From the docs: https://docs.angularjs.org/api/ng/directive/ngDisabled
ui-router v1.0.18 introduces support for ng-disabled on anchor tags
Example: <a ui-sref="go" ng-disabled="true">nogo</a>
https://github.com/angular-ui/ui-router/issues/2957
https://github.com/angular-ui/ui-router/pull/3692/commits/a59fcae300f9d8f73a5b91fa77c92b926e68281d

Pro/con of using Angular directives for complex form validation/ GUI manipulation

I am building a new SPA front end to replace an existing enterprise's legacy hodgepodge of systems that are outdated and in need of updating. I am new to angular, and wanted to see if the community could give me some perspective. I'll state my problem, and then ask my question.
I have to generate several series of check boxes based on data from a .js include, with data like this:
$scope.fieldMappings.investmentObjectiveMap = [
{'id':"CAPITAL PRESERVATION", 'name':"Capital Preservation"},
{'id':"STABLE", 'name':"Moderate"},
{'id':"BALANCED", 'name':"Moderate Growth"},
// etc
{'id':"NONE", 'name':"None"}
];
The checkboxes are created using an ng-repeat, like this:
<div ng-repeat="investmentObjective in fieldMappings.investmentObjectiveMap">
...
</div>
However, I needed the values represented by the checkboxes to map to a different model (not just 2-way-bound to the fieldmappings object). To accomplish this, I created a directive, which accepts a destination array destarray which is eventually mapped to the model. I also know I need to handle some very specific gui controls, such as unchecking "None" if anything else gets checked, or checking "None" if everything else gets unchecked. Also, "None" won't be an option in every group of checkboxes, so the directive needs to be generic enough to accept a validation function that can fiddle with the checked state of the checkbox group's inputs based on what's already clicked, but smart enough not to break if there is no option called "NONE". I started to do that by adding an ng-click which invoked a function in the controller, but in looking around stack overflow, I read people saying that its bad to put DOM manipulation code inside your controller - it should go in directives. So do I need another directive?
So far:
(html):
<input my-checkbox-group
type="checkbox"
fieldobj="investmentObjective"
ng-click="validationfunc()"
validationfunc="clearOnNone()"
destarray="investor.investmentObjective" />
Directive code:
.directive("myCheckboxGroup", function () {
return {
restrict: "A",
scope: {
destarray: "=", // the source of all the checkbox values
fieldobj: "=", // the array the values came from
validationfunc: "&" // the function to be called for validation (optional)
},
link: function (scope, elem, attrs) {
if (scope.destarray.indexOf(scope.fieldobj.id) !== -1) {
elem[0].checked = true;
}
elem.bind('click', function () {
var index = scope.destarray.indexOf(scope.fieldobj.id);
if (elem[0].checked) {
if (index === -1) {
scope.destarray.push(scope.fieldobj.id);
}
}
else {
if (index !== -1) {
scope.destarray.splice(index, 1);
}
}
});
}
};
})
.js controller snippet:
.controller( 'SuitabilityCtrl', ['$scope', function ( $scope ) {
$scope.clearOnNone = function() {
// naughty jQuery DOM manipulation code that
// looks at checkboxes and checks/unchecks as needed
};
The above code is done and works fine, except the naughty jquery code in clearOnNone(), which is why I wrote this question.
And here is my question: after ALL this, I think to myself - I could be done already if I just manually handled all this GUI logic and validation junk with jQuery written in my controller. At what point does it become foolish to write these complicated directives that future developers will have to puzzle over more than if I had just written jQuery code that 99% of us would understand with a glance? How do other developers draw the line?
I see this all over stack overflow. For example, this question seems like it could be answered with a dozen lines of straightforward jQuery, yet he has opted to do it the angular way, with a directive and a partial... it seems like a lot of work for a simple problem.
I don't want this question to violate the rules, so specifically, I suppose I would like to know: how SHOULD I be writing the code that checks whether "None" has been selected (if it exists as an option in this group of checkboxes), and then check/uncheck the other boxes accordingly? A more complex directive? I can't believe I'm the only developer that is having to implement code that is more complex than needed just to satisfy an opinionated framework. Is there another util library I need to be using?
I posted this on Programmers.StackExchange.com as per Jim's suggestion. In the meantime, I settled on a solution for handling all the tricky DOM manipulations.
I tried it both ways - handling the DOM event in the controller, and handling it via a directive:
(Via Controller) - .js code:
$scope.clearOnNone = function(groupName, $event) {
var chkboxArr = $('input[name^=' + groupName + ']'),
nonNoneValChecked = false,
targetElem = null,
labelText = "";
// get the target of the click event by looking at the <label> sibling's text
targetElem = event.target.nextElementSibling.textContent.trim();
// if target was the None option, uncheck all others
if (targetElem === "None") {
chkboxArr.each(function() {
labelText = this.nextElementSibling.textContent.trim();
if (labelText !== "None") {
this.checked = false;
}
});
}
// if the target was anything BUT the None option, uncheck None
else {
chkboxArr.each(function() {
labelText = this.nextElementSibling.textContent.trim();
if (labelText === "None") {
this.checked = false;
}
});
}
};
(Via Controller) - html code:
<div ng-repeat="investmentObjective in fieldMappings.secondaryInvestmentObjectiveMap">
<input checkbox-group
type="checkbox"
name="secondaryInvestmentObjective"
ng-click="validationfunc('secondaryInvestmentObjective', $event)"
validationfunc="clearOnNone('secondaryInvestmentObjective', $event)"
fieldobj="investmentObjective"
destarray="suitabilityHolder.suitability.secondaryInvestmentObjective" />
<label class="checkbox-label"
popover-title="{{investmentObjective.name}}"
popover="{{investmentObjective.help}}"
popover-trigger="mouseenter">{{investmentObjective.name}}
</label>
</div>
(Via Controller) - directive code:
.directive("checkboxGroup", function () {
return {
restrict: "A",
scope: {
destarray: "=", // the source of all the checkbox values
fieldobj: "=", // the array the values came from
validationfunc: "&" // the function to be called for validation (optional)
},
link: function (scope, elem, attrs) {
if (scope.destarray.indexOf(scope.fieldobj.id) !== -1) {
elem[0].checked = true;
}
elem.bind('click', function () {
var index = scope.destarray.indexOf(scope.fieldobj.id);
if (elem[0].checked) {
if (index === -1) {
scope.destarray.push(scope.fieldobj.id);
}
}
else {
if (index !== -1) {
scope.destarray.splice(index, 1);
}
}
});
}
};
})
I then decided that I hated the event.target.nextElementSibling.textContent.trim() lines... I feel like I should be double checking that all of those methods exist, or using a try/catch. So I rewrote the directive to include the logic from the controller:
(via directive) - html code:
<div ng-repeat="otherInvestment in fieldMappings.otherInvestmentsMap">
<input type="checkbox"
checkbox-group
groupname="otherInvestment"
labelvalue="{{otherInvestment.name}}"
fieldobj="otherInvestment"
destarray="suitabilityHolder.suitability.otherInvestment" />
<label class="checkbox-label"
popover-title="{{otherInvestment.name}}"
popover="{{otherInvestment.help}}"
popover-trigger="mouseenter">{{otherInvestment.name}}
</label>
</div>
(via directive) - directive code:
.directive("checkboxGroup", function () {
return {
restrict: "A",
scope: {
destarray: "=", // the source of all the checkbox values
fieldobj: "=", // the array the values came from
groupname: "#", // the logical name of the group of checkboxes
labelvalue: "#" // the value that corresponds to this checkbox
},
link: function (scope, elem, attrs) {
// Determine initial checked boxes
// if the fieldobj.id exists in the destarray, check this checkbox
if (scope.destarray.indexOf(scope.fieldobj.id) !== -1) {
elem[0].checked = true;
}
// Update array on click
elem.bind('click', function () {
// store the index where the fieldobj.id exists in the destarray
var index = scope.destarray.indexOf(scope.fieldobj.id),
// get the array of checkboxes that form this checkbox group
chkboxArr = $('input[groupname^=' + scope.groupname + ']');
// Add if checked
if (elem[0].checked) {
if (scope.labelvalue === "None") {
// loop through checkboxes and uncheck all the ones that are not "None"
chkboxArr.each(function() {
// have to noodle through the checkbox DOM element to get at its attribute list
// - is there a cleaner way?
var tmpLabelValue = this.attributes.labelvalue.nodeValue.trim();
if (tmpLabelValue !== "None") {
this.checked = false;
}
});
}
// if the target was anything BUT the None option, uncheck None
else {
chkboxArr.each(function() {
var tmpLabelValue = this.attributes.labelvalue.nodeValue.trim();
if (tmpLabelValue === "None") {
this.checked = false;
}
});
}
if (index === -1) {
// add the id to the end of the dest array
// **will not maintain original order if several are unchecked then rechecked**
scope.destarray.push(scope.fieldobj.id);
}
}
// Remove if unchecked
else {
if (index !== -1) {
scope.destarray.splice(index, 1);
}
}
});
}
};
})
In retrospect, I suppose I prefer to house all the code in a directive, even though I think it's less intuitive and more complex than tossing all the handling in the controller via jQuery. It cuts out the clearOnNone() function from the controller, meaning that all the code that deals with this functionality it in the html markup and the directive.
I am not a fan of code like this.attributes.labelvalue.nodeValue.trim(), which I still ended up with in my directive. For scenarios like mine, where the business unit has certain requirements that (no other way to put it) are tedious and cumbersome, I don't know that there is really a 'clean' way to code it all up.
I'm still new to AngularJS but I this case I think I would solve it by using either an ng-click handler or $scope.$watch to update the state of the NONE model whenever the other models change.
Using ng-click
I've whipped up a jsFiddle that shows how it could work with ng-click:
http://jsfiddle.net/Dzj6K/1/
HTML:
<div ng-controller="myCtrl">
<div ng-repeat="objective in objectives">
<label><input type="checkbox" ng-model="objective.selected" ng-click="click(objective)" /> {{objective.name}}</label>
</div>
</div>
JavaScript:
var app = angular.module('myApp', []);
function myCtrl($scope) {
$scope.objectives = [
{'id':"CAPITAL PRESERVATION", 'name':"Capital Preservation"},
{'id':"STABLE", 'name':"Moderate"},
{'id':"BALANCED", 'name':"Moderate Growth"},
{'id':"NONE", 'name':"None"}
];
$scope.click = function(objective) {
if (objective.id === "NONE") {
if (objective.selected) {
angular.forEach($scope.objectives, function(objective) {
if (objective.id !== "NONE") {
objective.selected = false;
}
});
}
} else {
angular.forEach($scope.objectives, function(objective) {
if (objective.id === "NONE") {
objective.selected = false;
}
});
}
};
}
Using $scope.$watch
And a version of the jsFiddle that shows how it could work with $scope.$watch:
http://jsfiddle.net/Dzj6K/
HTML:
<div ng-controller="myCtrl">
<div ng-repeat="objective in objectives">
<label><input type="checkbox" ng-model="objective.selected" /> {{objective.name}}</label>
</div>
</div>
JavaScript:
var app = angular.module('myApp', []);
function myCtrl($scope) {
$scope.objectives = [
{'id':"CAPITAL PRESERVATION", 'name':"Capital Preservation"},
{'id':"STABLE", 'name':"Moderate"},
{'id':"BALANCED", 'name':"Moderate Growth"},
{'id':"NONE", 'name':"None"}
];
$scope.$watch('objectives', function() {
var anySelected = false;
var noneModel = null;
angular.forEach($scope.objectives, function(objective) {
if (objective.id === "NONE") {
noneModel = objective;
} else {
anySelected = anySelected || objective.selected;
}
});
if (noneModel) {
noneModel.selected = !anySelected;
}
}, true);
}

Resources