Tuesday, July 01, 2014

Changing the grouping template of the AngularJS grid



I had this AngularJS grid that I wanted to show totals for certain categories and types. To be more precise, I had a list of items with Category and Type and I want to know the total for all categories and, for each category, the total for each type. This works perfectly if I load all items as individual, but I had hundred of thousands of items so it was clearly impractical. The solution? Send totals for every category and type, then just add them in the grid. In order to do that, though, I had to change the template of the "grouping row", the one that in ngGrid has the ngAggregate class.

It seems that all that is required for that is to change the aggregate template in the grid options. If you are not interested in the details, jump directly to the solution.

There is already an aggregate template in ngGrid.js, one that (at this time) looks like this:
<div ng-click="row.toggleExpand()" ng-style="rowStyle(row)" class="ngAggregate">
    <span class="ngAggregateText">{{row.label CUSTOM_FILTERS}} ({{row.totalChildren()}} {{AggItemsLabel}})</span>
    <div class="{{row.aggClass()}}"></div>
</div>

So we see that that the number displayed in an aggregate row is coming from a function of the row object called totalChildren, which is defined in ngAggregate.prototype and looks like this:
ngAggregate.prototype.totalChildren = function () {
    if (this.aggChildren.length > 0) {
        var i = 0;
        var recurse = function (cur) {
            if (cur.aggChildren.length > 0) {
                angular.forEach(cur.aggChildren, function (a) {
                    recurse(a);
                });
            } else {
                i += cur.children.length;
            }
        };
        recurse(this);
        return i;
    } else {
        return this.children.length;
    }
};
Maybe one could change the function to cover specific types of objects and return a sum instead of a count, but that is not the scope of the current post.

The solution described here will involve a custom function and a custom template. Here is how you do it:
  • Define the options for the grid. I am sure you already have it defined somewhere, if not, it is advisable you would. Sooner or later you will want to customize the output and functionality.
  • Add a new property to the options called aggregateTemplate. This will look probably like the default template, but with another function instead of totalChildren.
  • Define the function that will aggregate the items.

Solution 1:

Define template:
$scope.gridOptions = {
        data: 'Data.Units',
        enableColumnResize: true,
        showColumnMenu: false,
        showFilter: true,
        multiSelect: false,
        showGroupPanel: true,
        enablePaging: false,
        showFooter: true,
        columnDefs: [
            { field: 'Country', displayName: 'Country', width: 190 },
            { field: 'Type', displayName: 'Unit Type' },
            { field: 'Count', displayName: 'Count', width: 180}
        ],
        aggregateTemplate: "<div ng-click=\"row.toggleExpand()\" ng-style=\"rowStyle(row)\" class=\"ngAggregate\">" +
    "    <span class=\"ngAggregateText\">{{row.label CUSTOM_FILTERS}} ({{aggFunc(row)}} {{AggItemsLabel}})</span>" +
    "    <div class=\"{{row.aggClass()}}\"></div>" +
    "</div>"
    };
Define function:
$scope.aggFunc = function (row) {
        var sumColumn='Count';
        var total = 0;
        angular.forEach(row.children, function(entry) {
            total+=entry.entity[sumColumn];
        });
        angular.forEach(row.aggChildren, function(entry) {
            total+=$scope.aggFunc(entry);
        });
        return total;
    };

What we did here is we replaced row.totalChildren() with aggFunc(row) which we defined in the scope. What it does is add to the total the value of 'Count' rather than just count the items. It goes through row.children, which contains normal row items, then through aggChildren, which contains aggregate rows, which we pass through the same function in order to get their total.

Well, this works perfectly, but doesn't that mean we need to use this for each grid? There is a lot of code duplication. Let's first put the template in the cache so we can reuse it:
module.run(["$templateCache", function($templateCache) {

  $templateCache.put("aggregateCountTemplate.html",
    "<div ng-click=\"row.toggleExpand()\" ng-style=\"rowStyle(row)\" class=\"ngAggregate\">" +
    "    <span class=\"ngAggregateText\">{{row.label CUSTOM_FILTERS}} ({{aggFunc(row)}} {{AggItemsLabel}})</span>" +
    "    <div class=\"{{row.aggClass()}}\"></div>" +
    "</div>"
  );

}]);
Now the gridOptions change to
$scope.gridOptions = {
        [...]
        aggregateTemplate: "aggregateCountTemplate.html"
    };
and we can reuse the template anytime we want. Alternatively we can create a file and reference it, without using the cache:
$scope.gridOptions = {
        [...]
        aggregateTemplate: "/templates/aggregateCountTemplate.html"
    };

Now, if we could replace the aggFunc function with a row function, adding it to ngAggregate.prototype. Unfortunately we cannot do that, since ngAggregate is a 'private' object. The only thing we can do is to add some sort of static function. The solution is to add it in the root scope, so that is available everywhere.

Solution 2:

Here is the content of the file aggregateCountTemplateCache.js, that I created and load every time in the site. It does two things: inject the function in the root scope of the application and add the template to the cache. The only other thing to do is to use the aggregateTemplate: "aggregateCountTemplate.html" grid options.
var module = angular.module('app', ['ngResource', 'ui.bootstrap', 'ui', 'ngGrid', 'importTreeFilter', 'ui.date', 'SharedServices', 'ui.autocomplete']);

module.run(["$templateCache","$rootScope", function($templateCache,$rootScope) {

  $rootScope.aggregateCountFunc = function (row) {
        var total = 0;
        angular.forEach(row.children, function(entry) {
            total+=entry.entity.Count;
        });
        angular.forEach(row.aggChildren, function(entry) {
            total+=$rootScope.aggregateCountFunc(entry);
        });
        return total;
    };

  $templateCache.put("aggregateCountTemplate.html",
    "<div ng-click=\"row.toggleExpand()\" ng-style=\"rowStyle(row)\" class=\"ngAggregate\">" +
    "    <span class=\"ngAggregateText\">{{row.label CUSTOM_FILTERS}} ({{aggregateCountFunc(row)}}{{AggItemsLabel}})</span>" +
    "    <div class=\"{{row.aggClass()}}\"></div>" +
    "</div>"
  );

}]);

Enjoy!

1 comments:

Or Ohev-Zion said...

Very helpful!
great guide keep it up!