Checkbox Grids in ASP.NET
A simple checkbox grid is often the best user interface for mapping multiple selections in multiple categories:
Food | Cabernet | Zinfandel | Pinot |
---|---|---|---|
Salmon | |||
Steak | |||
Chicken | |||
Chocolate |
You'd think this would be easy to do with a GridView and some CheckBoxField columns, but the GridView control's underlying assumption is that you'll only be editing one row at a time. Because of that, all the checkboxes are disabled by default; only the row which has been set to "Edit" mode can be checked and unchecked.
Aside - CheckBoxField only binds to bit fields; it doesn't bind to string values which are convertible to booleans. If you're binding to a field which isn't a bit, you'll need to convert to a bit. If you have a non-bit numeric field, you can likely use (CONVERT(bit,0) as Selected).
So we can't a CheckBoxField, what do we do?
I think the best solution is to ditch the CheckBoxField control for a simple CheckBox control, either in a GridView TemplateField or a Repeater with a Checkbox control in the ItemTemplate. The CheckBox has an OnCheckedChanged event which fires for each bound control which changes status (from checked to unchecked, or from unchecked to checked).
My rule of thumb with GridView vs. Repeater: use the GridView if the GridView will do everything I need to do without tweaking it. Once I start having to have to start hacking the GridView, I switch to the Repeater. Usually once a grid needs something a little out of the ordinary, there's a good chance I'll need to make further changes to it, and customized GridViews tend to get complicated quickly. So, here's the sample code I'd use for the above grid:
<table> <asp:Repeater ID="FoodPairings" runat="server" OnItemDataBound="FoodPairings_ItemDataBound" > <HeaderTemplate> <thead> <tr> <th>Food</th> <th>Cabernet</th> <th>Zinfandel</th> <th>Pinot</th> </tr> </thead> </HeaderTemplate> <ItemTemplate> <tr> <td> <asp:Label ID="Food" runat="server" Text='<%# Eval("Food") %>' /> </td> <td> <asp:CheckBox ID="Cabernet" OnCheckedChanged="OnCheckChangedEvent" runat="server" Checked='<%# Eval("Cabernet") %>' /> </td> <td> <asp:CheckBox ID="Zinfandel" OnCheckedChanged="OnCheckChangedEvent" runat="server" Checked='<%# Eval("Zinfandel") %>' /> </td> <td> <asp:CheckBox ID="Pinot" OnCheckedChanged="OnCheckChangedEvent" runat="server" Checked='<%# Eval("Pinot") %>' /> </td> </tr> </ItemTemplate> </asp:Repeater> </table>
But how do I determine the ID for the selected checkbox?
The CheckBox OnCheckedChanged event doesn't work too well when it's databound, because the OnCheckedChanged event EventArgs doesn't have an ID which corresponds to the row and column. Handling the save requires you to know both the row and column; it's easy to determine the column by the name of CheckBox control, but there's no simple way to determine the row. The OnCheckedChanged event EventArgs Sender argument passes in the Checkbox control, but the Checkbox control doesn't give you any properties which let you stash an ID field.
If we were only working with a single Checkbox per row it wouldn't be a problem, but you've got a grid of checkboxes, there's no built-in way to get the id of the selected checkbox. CheckBox doesn't have anywhere to stash a value. I thought about some goofy hack in which I hid the ID in the Text or Tooltip, but both of those values are displayed by default.
I think a better solution, though, is to add an attribute to the checkbox control. You can add an attribute to any control using the ControlAttributes.Add(name,value) syntax, and that's a handy way to associate these values.
public void OnCheckChangedEvent(object sender, EventArgs e) { CheckBox c = (CheckBox)sender; string wineID = ((Control)c).ID; int FoodID = int.Parse(c.Attributes["FoodID"]); if (c.Checked) { //Add pairing } else { //Remove pairing } } protected void FoodPairings_ItemDataBound( object sender, RepeaterItemEventArgs e) { if (e.Item.ItemType == ListItemType.Item || e.Item.ItemType == ListItemType.AlternatingItem) { DbDataRecord row = e.Item.DataItem as DbDataRecord; CheckBox checkbox; checkbox = e.Item.FindControl("Cabernet") as CheckBox; checkbox.Attributes.Add("FoodID", row["FoodID"].ToString()); checkbox = e.Item.FindControl("Zinfandel") as CheckBox; checkbox.Attributes.Add("FoodID", row["FoodID"].ToString()); checkbox = e.Item.FindControl("Pinot") as CheckBox; checkbox.Attributes.Add("FoodID", row["FoodID"].ToString()); } }
Is this the best solution?
I'm not sure. There are a few different ways to go about this. My approach worked for me, but I'm not sure it's the best. Here are pro's and con's to the approach I used:
Pro:
- Reasonably simple
- Fits with the general control approach you'd expect
- Handling the OnCheckedChanged event keeps your save logic really simple, because you don't need to implement any special logic to check if the checkbox state has changed.
Cons:
The generated HTML is a little wacky. The checkbox is wrapped in a <span>:
<span FoodID="2"><input id="..." type="checkbox" name="..." checked="checked" /></span>
Here are some other ideas I considered:
- Use an AJAX save method which is called for each checkbox click.
- Hide the row id in a hidden label and get to it via the CheckBox's NamingContainer.
What do you think?