Sunday, September 29, 2013

Handlebars & jQuery UI Accordion (Sort Of) Part 2

We will be picking up from where we left off from the most recent post. Our friends in "the business" have come up with a new request since we went out for celebratory pizza. This is what they want now....

"We really like what we see but...there could be a lot of items on the screen. It would be really nice if a user entered a value in a text, and the text box stayed open. This would be a good indication to the user that they made a selection...a visual cue that they performed work."

Can we do this? Yes we can. The problem with this request is that now they are asking for a behavior that was not an intended behavior for the jQuery UI accordion. At times, we are at the mercy of the business and must give them what they want. This post will demonstrate how we can do this. Nevertheless, this is a very good exercise of creating a pseudo-accordion. It looks like a jQuery UI accordion, acts like a jQuery UI accordion, but there is no actual call to the jQuery accordion.

This example will be a little different from the previous as I will be showcasing a different screen mock up. Nevertheless, it's not much of a stretch. Let's dive in an start with our model class:
public class Item
{
   public string ItemId { get; set; }
   public string ItemDescription { get; set; }
   public string PackQuantity { get; set; }
   public string PackSize { get; set; }
}

Let's write the controller:
public class PanelController : Controller
{
   public ActionResult Index()
   {
      return View();
   }

   [HttpGet]
   public object GetItemData()
   {
      List<Item> items = new List<Item>()
      {
         new Item() { ItemId = "1", ItemDescription = "Item A", PackQuantity = "", PackSize = "5" },
         new Item() { ItemId = "2", ItemDescription = "Item B", PackQuantity = "", PackSize = "10" },
         new Item() { ItemId = "3", ItemDescription = "Item C", PackQuantity = "", PackSize = "15" }
      };

      var data = items.GroupBy(x => x.ItemDescription)
                      .Select(y => new { Headers = y.Key, Contents = y.Select(z => z).ToList() });
      var json = JsonConvert.SerializeObject(data);
      return json;
   }
}
The interesting thing to note is the return type on the GetItemData method. Usually we would write return Json(data, JsonRequestBehavior.AllowGet);, above is just showing a different approach. Let's now write the view for the Index method. We will be doing this in steps, first the template, followed by the css, finally, the JavaScript.
@{
   ViewBag.Title = "Panel";
}
<br />
<div id="tmplTarget"></div>

<script id="itemsTmpl" type="text/x-handlebars-template">
   <div id="itemsContent">
      {{#each this}}
         <div>
            <h3>
               <span class="ui-icon ui-icon-plus" style="display: inline-block;" />
               <a href="#">{{this.Headers}}</a>
            </h3>
            <div class="tblDiv">
               <table class="itemsContentTbl">
                  <tr>
                     <th class="itemsContentHeader"></th>
                     <th></th>
                     <th>Pack Qty</th>
                     <th>Pack Size</th>
                  </tr>
                  {{#each this.Contents}}
                  <tr>
                     <td class="itemDescription">{{this.ItemDescription}}</td>
                     <td class="tdCkItemDesc">
                        <input type="checkbox" id="ck-{{this.ItemDescription}}"
                                  name="ck-{{this.ItemDescription}}" />
                     </td>
                     <td class="packQty" style="text-align: center">
                        <input type="text" id="txtPackQuantity-oi-{{this.ItemDescription}}
                                  name="txtPackQuantity-oi-{{this.ItemDescription}} />
                     </td>
                     <td class="packSize">{{this.PackSize}}</td>
                     <input type="hidden" id="hdnPackSize-oi-{{this.ItemDescription}}-ps-{{this.PackSize}}"
                               name="hdnPackSize-oi-{{this.ItemDescription}}-ps-{{this.PackSize}}"
                               value="{{this.PackSize}}" />
                  </tr>
                  {{/each}}
               </table>
            </div>
         </div>
      {{/each}}
   </div>
</script>

Not all of the classes will be styled, some are just used as hooks for jQuery. However, note the span tag in the template. We need all of the headers to have the plus icon when the page loads. Since we are not using the accordion we need to leverage the jQuery UI css. The ui-icon and ui-icon-plus classes will allow us to achieve this. Also, in order for the icon to be displayed correctly, the span element must be rendered as an inline-block. It is quite common when using the accordion to see the icons smashed into the text, inline-block is the solution. And again, just inlining the css for illustrative purposes.
Let's take care of the AJAX call and the template so we can see what we are working with.
<script src="~/Scripts/jquery-1.8.2.js"></script>
<script src="~/Scripts/jquery-ui-1.8.24.js"></script>
<script src="~/Scripts/handlebars.js"></script>
<script type="text/javascript">
   $(function() {
      $.ajax({
         url: '@Url.Action("GetItemData")',
         contentType: 'application/json; charset=utf-8',
         dataType: 'json',
         cache: false,
         success: function(data) {
            var source = $('#itemsTmpl').html();
            var template = Handlebars.compile(source);
            $('#tmplTarget').html(template(data));
         },
         error: function(jqXHR, textStatus, errorThrown) {
            alert(errorThrown);
         }
      });
   });
</script>

 
We'll come back to the jQuery code later. For now, let's take care of some styling.
.itemsContentTbl {
   border-collapse: collapse;
   /* center table */
   margin: 10px auto 10px auto
}

.itemsContentTbl th {
   background: #002a5f;
   color: white;
   padding-left: 20px;
   padding-right: 20px;
   border: 1px solid black;
   text-align: center;
}

.itemsContentTbl td {
   border: 1px solid black;
   text-align: center;
   vertical-align: middle;
}
Let's have a look....

 
Ok we have two issues we need to clean up. The checkbox has a border around it, which was introduced by the css class itemsContentTbl td. Also, we should center the textbox.
In the Handlebars template, we just need to add classes to the checkbox and textbox inputs. Add the classes ckItemDesc for the checkbox and txtPackQty for the textbox. The css for those classes:
.ckItemDesc {
   border: none !important;
   padding-left: 30px;
}

.txtPackQty {
   width: auto;
   margin-left: 60px;
   margin-right: 30px;
}


 
Everything else will be done with jQuery. All of the following code should be placed in the AJAX success callback. We do not want to display the item descriptions in the html table so we have to hide those.
$('.itemsContentHeader, .itemDescription').hide();

 
Style the headers for our panel/accordion.
$('h3').addClass('ui-accordion-header ui-helper-reset ui-state-default ui-corner-all');

 
Style the divs below the header.
$('.tblDiv').addClass('ui-accordion-content ui-helper-reset ui-widget-content ui-corner-bottom');

 
The divs that we just styled have to be hidden upon page load so the accordion/panel appears to be closed.
$('.tblDiv').hide();
 
Now we need to take care of the checkbox. When a user enters a value into the textbox, we would like that to automatically check the check box. If a checkbox is clicked, we will use that as an indication to keep that panel open, essentially freezing it. You may say, well, if a user manually checks the checkbox and does not enter a value into the textbox, it will still freeze the panel right? Correct. This may not be ideal but a user could check it as a reminder to come back and decide if they want to enter a value into the checkbox. The checkbox is optional, things will work just as well without it. Anyway, the code:
$('input:text[id^="txtPackQuantity"]').change(function () {
   $(this).parent().prev().children().attr('checked', 'checked');
});

Right now the panel control is not responding to mouse clicks. The next part is where all the heavy lifting comes in. The toggle function in jQuery is exactly what we need to make this work. The toggle function is passed in two functions, one for the first mouse click, and the second function for the second mouse click. As we have been doing, we will be harnessing jQuery UI css classes to make all the magic happen. Since the span element in the template has the icon, that span will be the target for the toggling.
$('span').toggle(
   function() {
      $(this).addClass('ui-icon-minus');
      $(this).parent().next().removeClass('ui-accordion-header ui-helper-reset ui-state-default ui-corner-all').addClass('ui-accordion-header ui-helper-reset ui-state-default ui-state-active ui-corner-top');
      $(this).parent().next().show('slow');
   },
   function() {
      var ckBox = $(this).parent().parent().find('.ckItemDesc');
      if ($(ckBox).is(':checked')) {
         $(this).removeClass('ui-icon-plus');
         $(this).addClass('ui-icon ui-icon-minus');
         return;
      } else {
         $(this).removeClass('ui-icon-minus');
         $(this).addClass('ui-icon-plus');
         $(this).parent().next().removeClass('ui-accordion-header ui-helper-reset ui-state-default ui-state-active ui-corner-top').addClass('ui-accordion-header ui-helper-reset ui-state-default ui-corner-all');
         $(this).parent().next().hide('slow');
      }
   }
);

The first time the plus icon is clicked, we add the minus icon. Then we remove the css classes that are used when a panel is not displayed and add the css classes for when a panel is displayed/active. Finally, since that div is hidden, we just show it.

On the second click, if the checkbox is checked, we need to freeze the panel. To do that, it's just simply a matter of removing the plus icon css class and adding the minus icon css class. Using the return keyword will break out of the code.

If the checkbox is not clicked, we need to remove the minus icon css class, add the plus icon css class, remove the css classes indicating the panel is active, add the css classes indicating the panel is not active, and finally, hide that div.

Now we should be able to have multiple panels open, freeze panels, and expand collapse panels where the text box does not have any data.
 

Below is all the jQuery code together.
<link href="~/Content/themes/base/jquery.ui.all.css" rel="stylesheet" />
<script src="~/Scripts/jquery-1.8.2.js"></script>
<script src="~/Scripts/jquery-ui-1.8.24.js"></script>
<script src="~/Scripts/handlebars.js"></script>
<script type="text/javascript">
   $(function() {
      $.ajax({
         url: '@Url.Action("GetItemData")',
         contentType: 'application/json; charset=utf-8',
         dataType: 'json',
         cache: false,
         success: function(data) {
            var source = $('#itemsTmpl').html();
            var template = Handlebars.compile(source);
            $('#tmplTarget').html(template(data));

            // do not want to show content headers and descriptions
            $('.itemsContentHeader, .itemDescription').hide();

            // style the headers
            $('h3').addClass('ui-accordion-header ui-helper-reset ui-state-default ui-corner-all');

            // style divs below header
            $('.tblDiv').addClass('ui-accordion-content ui-helper-reset ui-widget-content ui-corner-bottom');

            // close the divs
            $('.tblDiv').hide();

            // checking checkbox will be cue to freeze panel, check when field changes
            $('input:text[id^="txtPackQuantity"]').change(function() {
               $(this).parent().prev().children().attr('checked', 'checked');
            });

            // toggle plus minus
            $('span').toggle(
               function() {
                  $(this).addClass('ui-icon-minus');
                  $(this).parent().next().removeClass('ui-accordion-header ui-helper-reset u-state-default ui-corner-all').addClass('ui-accordion-header ui-helper-reset ui-state-default ui-state-active ui-corner-top');
                  $(this).parent().next().show('slow');
               },
               function() {
                 var ckBox = $(this).parent().parent().find('.ckItemDesc');
                 if ($(ckBox).is(':checked')) {
                   $(this).removeClass('ui-icon-plus');
                   $(this).addClass('ui-icon ui-icon-minus');
                   return;
                 } else {
                    $(this).removeClass('ui-icon-minus');
                    $(this).addClass('ui-icon-plus');
                    $(this).parent().next().removeClass('ui-accordion-header ui-helper-reset ui-state-default ui-state-active ui-corner-top').addClass('ui-accordion-header ui-helper-reset ui-state-default ui-corner-all');
                    $(this).parent().next().hide('slow');
                 }
               }
            );
         },
         error: function(jqXHR, textStatus, errorThrown) {
            alert(errorThrown);
         }
      });
   });
</script>

Monday, September 23, 2013

Handlebars & jQuery UI Accordion Part 1

This post is modeled after something I have recently done at work. Here is the rough description....

The application is an ASP.NET Web Forms application that is at least five years old. The DevExpress tool suite is used heavily throughout the site. The business wanted to make a presentation change of a grid, thing is, the DevExpress grid could not do it. I was tasked with handling this so I went with Handlebars.js and the Accordion control from jQuery UI. It was necessary to use templating as the data was dynamic. This ended up being a very nasty task due to various button click handlers and the nasty postback behavior of ASP.NET.

For this walkthrough, I'm going with ASP.NET MVC just to simplify things. Make sure to add Handlebars through NuGet to the solution. Let's start with the models we will be using.....
public class Material
{
   public string MaterialDescription { get; set; }
   public int RequestQty { get; set; }
   public int PackQty { get; set; }
   public int PackSize { get; set; }
   public int TotalApproved { get; set; }
   public int AvailableInv { get; set; }
}

I'm adding a view model to send to the client because generally, I do not like returning anonymous objects. When I can, I always like to be explicit and declare the type. The view model:
public class IndexViewModel
{
   public string Headers { get; set; }
   public IEnumerable<Material> Contents { get; set; }
}

The Index method of whatever controller you are using can be left as is. Add the view. The view will contain the target div for the accordion as well as the Handlebars template. The view:
@model IEnumerable<YourSolution.Models.IndexViewModel>
@{
   ViewBag.Title = "Handlebars Accordion";
}


<div id="target"></div>

<script id="testTmpl" type="text/x-handlebars-template">
   <div id="test">
      {{#each this}}
         <h3><a href="#">{{this.Headers}}</a><h3>
         <div>
            <table class="table">
               <tr>
                  <th>Request Qty</th>
                  <th>Pack Qty</th>
                  <th>Pack Size</th>
                  <th>Total Approved</th>
                  <th>Available Inv</th>
               </tr>
               {{#each this.Contents}}
                  <tr>
                     <td>{{this.RequestQty}}</td>
                     <td>{{this.PackQty}}</td>
                     <td>{{this.PackSize}}</td>
                     <td>{{this.TotalApproved}}</td>
                     <td>{{this.AvailableIn}}</td>
                  </tr>
               {{/each}}
            </table>
         </div>
      {{/each}}
   </div>
</script>

Handlebars is pretty slick and easy to use. To loop through a collection is fairly straightforward, just use #each and this, where this of course refers to the current item. This also shows a nested data structure and how to perform nested looping. We have two properties in the view model, Headers and contents. In the first each, we are just writing out the Headers property. To access any properties you just use dot syntax. When you want to access the nested object, just embed another each, and you will have access to the nested property/object.
Let's move on to the JavaScript. For simplicity, I am putting it in the view as well.
<script type="text/javascript">
   $(function() {
      $.ajax({
         url: '/AccordionTmpl/GetData',
         type: 'GET',
         contentType: 'application/json; charset=utf-8',
         dataType: 'json',
         success: function(data) {
            var source = $('#testTmpl')html();
            var template = Handlebars.compile(source);
            $('#target').html(template(data));

            $('#test').accordion({
               active: false,
               header: 'h3',
               collapsible: true,
               icons: {
                  "header": "ui-icon-plus",
                  "headerSelected": "ui-icon-minus"
               }
            });
         },
         error: function(response) {
            alert(response.responseText);
         }
      });
   });
</script>

If you've done any templating this should look familiar. Grab the html of the template, compile it, feed the data into the template, take the html and add it to the target. The accordion configuration is pretty basic as well.
There is one css class in our template. We will add some styling to the table, headers and rows.
.tbl {
   border-collapse: collapse;
   /* center the table */
   margin: 10px auto 10px auto;
}

.tbl th {
   background: #002a5f;
   color: white;
   padding-left: 20px;
   padding-right: 20px;
   border: 1px solid black;
}

.tbl td {
   border: 1px solid black;
   text-align: center;
}
We have to go back to the server and write the method to return data:
[HttpGet]
public ActionResult GetData()
{
   List<Material> materials = new List<Material>()
   {
      new Material(){MaterialDescription = "Big Bang Theory Sheldon Shirt", RequestQty = 3, PackQty = 3, PackSize = 3, TotalApproved = 3, AvailableInv = 6},
      new Material(){MaterialDescription = "Big Bang Theory Leonard Shirt", RequestQty = 2, PackQty = 2, PackSize = 2, TotalApproved = 2, AvailableInv = 7},
      new Material(){MaterialDescription = "Big Bang Theory Penny Shirt", RequestQty = 1, PackQty = 1, PackSize = 1, TotalApproved = 1, AvailableInv = 9},
      new Material(){MaterialDescription = "Big Bang Theory Kripke Shirt", RequestQty = 1, PackQty = 1, PackSize = 1, TotalApproved = 1, AvailableInv = 10},
   };

   IEnumerable<IndexViewModel> data = materials
                                            .GroupBy(x => x.MaterialDescription)
                                            .Select(y => new IndexViewModel()
                                            {
                                               Headers = y.Key, 
                                               Contents = y.Select(z => z).ToList()
                                            });

   return Json(data, JsonRequestBehavior.AllowGet);
}

Let's run and see what we got...
 

Not too shabby. We did some good work, should probably reward ourselves with some pizza.

*One hour later*

It seems our friends in the business saw what we did and they have a request. Since the list of items can be quite a few, they would like it if more than one panel could stay open so a user can remember where they were.

This can be done even though the accordion was not designed to do this. The solution, initially, seems very simple. The answer lies in the structure of the accordion. You have to change the target of the accordion. If you look at the documentation for the accordion, the structure of your html has to look like this:

<div id="accordion">
   <h3>First Header</h3>
   <div>First Content Panel</div>
   <h3>Second Header</h3>
   <div>Second Content Panel</div>
</div>

Our structure is different but still worked. We are going to change the target for the accordion from $('#test) to $('#test > div'). Our target is now the child div of the test div. The only other change we need to make is to add some css to the anchor tag, right after the accordion declaration. Make the changes:
$('#test > div').accordion({
   active: false,
   header: 'h3',
   collapsible: true,
   icons: {
      "header": "ui-icon-plus",
      "headerSelected": "ui-icon-minus"
   }
});

$('a').css({ "display": "inline-block" });

Now to run...
 
Looks good! The css that was added to the anchor tag is very, very important. If you are ever using the accordion and your icons are mashed in with the text, applying that css to the anchor will solve it. Another solution is the following:
<h3>
   <span style="display: inline-block;" />
   <a href="#">{{this.Headers}}</a>
</h3>

Really the only difference is to what element you apply the css. Again, just inlining the css for illustrative purposes.

In the next post, we will revisit this scenario as our friends from the business have another request.