I ran into an interesting UI issue today. My solution may not be the best, but I thought others might be interested in it and be able to comment. First, let me describe the problem I was trying to solve. I'm working with a system that has Resource Types and Resources. A Resource Type is a high level definition of a group of content, like a Blog or a Forum. It contains various bits of a metadata that includes a name, description, types of content within it, and security settings. To keep things simple, imagine security is limited to logged in users and the general public. You can define then, for example, that the general public can read any content in the blog, but only logged in users can post content.
Below the Resource Type level we have Resources. A Resource is simply an instance of the Resource Type. For our example, let's say it belongs to a group, so it has a pointer to that. It also has it's own name. This allows me to create a Blog instance and give it a specific name. So instead of it being called "Blog" I could call it "Pepersnitchzel." (Yes, I want to call it that.)
Now here is where things get interesting. Along with being able to give a Resource a unique name, I also needed the ability to override security settings. So imagine we have a private blog. Unlike the normal blog where anyone can read the content, the private blog is restricted so that only logged in users can see it. So here was my problem. When editing the resource, I needed to allow the administrator to override security settings on a per role and per access level. Imagine the following table describes the settings for blogs in general:
In this screen shot the permissions for the resource type are listed. There is no edit control here. Instead, I make the admin go to a resource type edit page to make changes. (I should possibly add a quick a link to that.) Below this I have the form where the admin can decide to override permissions. My first problem was this: How do I allow the admin to say, "I want to override permissions for guests", and allow them to select nothing to mean guests have no rights? In other words, given a simple row of checkboxes, how do I differentiate between "I didn't pick crap because I don't want to override security" versus "I want to override security and this role can't do squat."
For my solution I decided on a simple additional checkbox. This checkbox would be labelled OVERRIDE (Yes, I used caps, thought it would make it more obvious. This is also why I don't make many UI decision). If the checkbox is enabled, then that row is "hot" and it implies an override. I wanted to make it even more clear though. I decided to use the disabled attribute when you weren't overriding. Here is how it turned out:
By default the boxes are all disabled. When I enable one, I can then select permissions for that role, or leave it blank to imply no permissions. Here is an example:
Ok, so enough pretty pictures. How do I actually code this in jQuery? Here is the event handler I used:
$(document).ready(function() {
$("input[name^='perm_']").change(function() {
if($(this).attr("checked")) $(this).closest("tr").children().children().removeAttr("disabled")
else {
//remove checks
$(this).closest("tr").children().children().removeAttr("checked")
//make all disabled
$(this).closest("tr").children().children().attr("disabled","true")
//but fix me
$(this).removeAttr("disabled")
}
})
})
Let's step through this line by line. My initial selector, input[name^='perm_'], is based on the name scheme I used for my OVERRIDE column. Each column's checkbox HTML looks like so:
<input type="checkbox" name="perm_groupadmin" >
My selector says, "Match inputs with a name that begins with perm_. Since each override checkbox is named perm_X, where X is the role, it basically matches that entire column of checkboxes. Next I simply figure out if I'm checking or not checking the box. If we are checking, I need to enable the checkboxes in the same TR row. I do this by going up to my nearest TR, and then back down my grandkids (ie, the checkboxes under the TDs). Unchecking is a bit more complex. I need to first remove the checkboxes. (I could ignore them server side, but I want this as obvious as possible to the end user.) I use removeAttr to do that. I then add in the disabled attribute. Finally, because my previous statement made all the checkboxes in the row disabled, I re-enable the main override checkbox I had just changed.
Thoughts? I kind of think the parent.child.child stuff feels a bit clunky. Perhaps I should use children("input") after the closest? That would remove one of the children() calls I believe.
Archived Comments
@Ray:
A couple of things:
1) For better efficiency, try specifying a scope to your main selector. This can speed up things when you have lots of input elements.
2) Cache you selectors. You keep doing things like: $(this).children("tr") it's expensive to keep doing all these look ups. Any time your reusing a collection make sure to store it in a variable.
2 makes sense. I kinda figured though as I was only doing 2-3 calls it didn't matter. Do you think it makes sense when only doing a few calls?
1 does _not_ make sense. Got an example?
Ray, I think Dan's talking about the ability to tell jQuery that you're only looking for matching elements within a certain container (like your permissions table). I think the syntax would be:
$("input[name^='perm_']","#myPermissionsTable").change()...
...with "myPermissionTable" as the id of the table.
I haven't touched jQuery in a few months, so I may be a bit off on that one...
To scope your selector simply add the parent id to the selector. E.g if your form has an id of myform then you can do this: $("input[id*='']","#myform").change...
You can also use the "find" method which doesn't depend on knowing how "deep" the checkbox is found. I have no idea if there is a much of a performance hit. I also based the selectors on attribute value (no need to remove checked attr from unchecked)
$(document).ready(function(){
$("input[name^='perm_']").change(function() {
if($(this).attr("checked")) $(this).closest("tr").find("[disabled=true]").removeAttr("disabled")
else {
//remove checks
$(this).closest("tr").find("[checked=true]").removeAttr("checked");
//make all disabled
$(this).closest("tr").find("[disabled!=true]").attr("disabled",true);
//but fix me
$(this).removeAttr("disabled")
}
})
});
Thanks all for clarifying the scoping. I knew of it - but for some reason, I didn't 'get it' when Dan mentioned it.
So question - in both tips, it felt a bit like overkill to me. For example, why cache the $(this).closest("tr").etc when I only run it twice? Not arguing against the performance benefits, just... well, ok, maybe I'm being lazy. :)
By the way, I'm also thinking of adding a bright yellow background to the TRs where OVERRIDE is enabled. Maybe a bit ugly, but even more obvious.
The main reason I proposed an alternative is that it avoids knowing the depth of the checkbox and having to use children().children() not for performance. Also it will enable/disable all controls in the row so it could be used if you had elements other than checkboxes.
@Don: Oh yeah - thats smart!
@Ray:
Sorry I didn't give examples, I posted from within TweetDeck on my iPhone, so I couldn't see the original post when typing up my response. Brian & Dave were correct with their responses. Scoping your selector can see huge performance increases if you have a DOM heavy page. For example, if you have 100s of input elements on a page (which is certainly possible on large forms) then selector $("input[name^='perm_']") has to go through each input element and examine it's name.
However, if you can limit searching the input elements to specific fields, then you've vastly reduced the overhead.
Another option is to add a class to your inputs. Modern browsers have built-in JS methods for CSS selection, so this can speed things up too.
As far as caching goes, you're actually creating 5 separate jQuery instances of $(this)--which is an expensive operation. Unless I'm only calling $(this) once, I always cache it. I never use $(this) more than once in the current running context.
Also, your code references $(this).closest("tr").children().children() 3 times. Not only would caching provide performance benefits, it's going to simplify your code if you ever change your markup.
Also, to future proof your code a bit, I'd probably change $(this).closest("tr").children().children() to $(this).closest("tr").find(":checkbox"). While this might be slightly slower, 1) you'd now be caching the results, 2) it's now safer to make changes to your DOM w/out breaking the code (obvious, if you stop using tables or make drastic layout changes the code may break, but it won't break if you have to add some additional markup,) 3) it's easier to read what's going on.
When I see $(this).closest("tr").children().children() I don't immediately know what DOM element I'm dealing with. However, $(this).closest("tr").find(":checkbox") is much more straightforward.
So, I'd add this to your code:
var $el = $(this), $checkbox = $el.closest("tr").find(":checkbox");
Juicy. Going to try to rewrite this today during lunch and repost the code as a comment. Thanks!