Friday, April 3, 2015

AngularJS Expand/Collapse All Rows in a Hierarchical Table

I've been working with AngularJS lately as an alternative to the traditional way of displaying and editing data in SharePoint. In particular I've been using this framework with custom web services as a quick way of accessing and editing information not stored in SharePoint.

One of these external sources of data which I needed to display within my SharePoint site consists of dealership information, including the brands and product types that they are active in. So, to begin with, I created a web service that returned hierarchical JSON consisting of dealers and their brand/product combinations. A somewhat simplified example looked like the following:

[
  {
    "DealerID": 1,
    "Dealer Name": "Dealer 1",
    "Address": "123 Main St",
    "City": "Irvine",
    "State": "CA",
    "ZIP Code": 92603,
    "Brands": [
      {
        "Brand": "Brand A",
        "Product I": "",
        "Product II": "X",
        "Product III": "X",
        "Product IV": "X",
        "Product V": "X"
      },
      {
        "Brand": "Brand B",
        "Product I": "",
        "Product II": "",
        "Product III": "X",
        "Product IV": "",
        "Product V": "X"
      }
    ]
  },
  {
    "DealerID": 2,
    "Dealer Name": "Dealer 2",
    "Address": "456 2nd Av",
    "City": "Portland",
    "State": "OR",
    "ZIP Code": 97225,
    "Brands": [
      {
        "Brand": "Brand A",
        "Product I": "X",
        "Product II": "X",
        "Product III": "X",
        "Product IV": "",
        "Product V": "X"
      },
      {
        "Brand": "Brand C",
        "Product I": "",
        "Product II": "",
        "Product III": "X",
        "Product IV": "",
        "Product V": "X"
      },
      {
        "Brand": "Brand D",
        "Product I": "",
        "Product II": "",
        "Product III": "X",
        "Product IV": "X",
        "Product V": "X"
      }
    ]
  }, ...

In order to display this data I decided to use a hierarchical table layout, where one row would show the dealer information and the next row would contain a child table that would display the brand information. This was easily accomplished using Angular's ng-repeat-start for the first row and ng-repeat-end for the second, repeating this two-row pattern for all dealers.

Because ng-repeat produces a separate child scope for each dealer row, I could add a button element to a cell in the dealer row with an ng-click event that toggles an "expanded" property on the scope. The related brands table row could then be shown or hidden by setting the ng-show property equal to the scope's "expanded" property. In preparation for the expand/collapse all capability, I also added a button to the with an ng-click event that toggles an "allExpanded" property of the rootscope (which is largely impotent at this point). Something similar to the following:


<table>
    <tr>
        <th>
            <button type="button" ng-click="allExpanded = !allExpanded">
                <span ng-bind="allExpanded ? '-' : '+'"></span>
            </button>
        </th>
        <th>Dealer</th>
        <th>Address</th>
        <th>City</th>
        etc...
    </tr>
    <tr ng-repeat-start="dealer in ctrl.dealers>
        <td>
            <button ng-click="expanded = !expanded">
                <span ng-bind="expanded ? '-' : '+'"></span>
            </button>
         </td>
        <td>{{dealer.Dealer}}</td>
        <td>{{dealer.Address}}</td>
        <td>{{dealer.City}}</td>
        etc...
    </tr>
    <tr ng-repeat-end ng-show="expanded">
        <td colspan=5>
            <table>
                <tr>
                    <th>Brand</th>
                    <th>Product 1</th>
                    <th>Product 2</th>
                    etc..
                </tr>
                <tr ng-repeat="brand in dealer.Brands">
                    <td>{{brand.Brand}}</td>
                    <td>{{brand.Product1}}</td>
                    <td>{{brand.Product2}}</td>
                    etc..
                </tr>
            </table>
        </td>
    </tr>
</table>

So far, so easy with AngularJS!

Now for the part that wasn't so apparent and the reason for this post. How to create the expand/collapse all functionality. Googling mostly led to examples where the expanded state was being stored in the data model itself. In my example, this would mean adding an "expanded" attribute to each dealer object in my model which would make creating an expandAll function that could loop through all objects in the dealer model and setting the "expanded" attribute of each dealer object trivial. But I had no reason to pollute my dealer model with view state information. What I needed was a way to change the expanded property of each dealer row's scope based on the "allExpanded" property.


Enter Angular's $scope.broadcast and $scope.on


$scope.broadcast dispatches an event name down the scope hierarchy notifying the registered/subscribed listeners and takes an event name and an optional list of arguments. Using this method, I could create an expandAll function within my main controller (called by the ng-click event of the expand all button) to broadcast a change in the allExpanded property to all child scopes:


self.expandAll = function (expanded) {
    $scope.$broadcast('onExpandAll', {expanded: expanded});
};

On the receiving end, the $scope.on method listens for those broadcasted events. $scope.on takes the name of the event to listen for (e.g. "onExpandAll"), and an event listener function in the form of function(event, args). The "args" argument is used to access the arguments broadcasted in the $scope.$broadcast method. One way to accomplish this for the child scopes created by ng-repeat, is to create an "expand" directive.

.directive('expand', function () {
    function link(scope, element, attrs) {
        scope.$on('onExpandAll', function (event, args) {
            scope.expanded = args.expanded;
        });
    }
    return {
        link: link
    };
});

This directive can then be placed as an attribute on an element within the ng-repeat... I chose the expand button that is created for each dealer row. 

<tr ng-repeat-start="dealer in ctrl.dealers">
    <td>
        <button ng-click="expanded = !expanded" expand>
            <span ng-bind="expanded ? '-' : '+'"></span>
        </button>
    </td>
    ...

And viola...



For a complete listing of the code and a working example, check out the Plunker: 
http://plnkr.co/edit/qqNGSbw6oLUZbKqVQVpG?p=preview

Note, for my project (and this example), I used Angular 1.2.28 because I needed to support Internet Explorer 8.

Finally, although it wasn't required for my project, an interesting next step would be to convert this logic to work with hierarchical JSON that went deeper than a single child level, allowing each child to expand all of its children and so on.

6 comments:

  1. can you please give me this all function within the controller.

    ReplyDelete
  2. Any idea how to use it in Angular 4+

    ReplyDelete
    Replies
    1. Were you able to figure this out? I saw some options such as ag-grid. But it's paid.

      Delete
  3. This comment has been removed by the author.

    ReplyDelete