AngularJS Group By Directive without External Dependencies

51,505

Solution 1

This is a modification of Darryl's solution above, that allows multiple group by parameters. In addition it makes use of $parse to allow the use of nested properties as group by parameters.

Example using multiple, nested parameters

http://jsfiddle.net/4Dpzj/6/

HTML

<h1>Multiple Grouping Parameters</h1>
<div ng-repeat="item in MyList  | orderBy:'groupfield' | groupBy:['groupfield', 'deep.category']">
    <h2 ng-show="item.group_by_CHANGED">{{item.groupfield}} {{item.deep.category}}</h2>
     <ul>
        <li>{{item.whatever}}</li>
     </ul>
</div>  

Filter (Javascript)

app.filter('groupBy', ['$parse', function ($parse) {
    return function (list, group_by) {

        var filtered = [];
        var prev_item = null;
        var group_changed = false;
        // this is a new field which is added to each item where we append "_CHANGED"
        // to indicate a field change in the list
        //was var new_field = group_by + '_CHANGED'; - JB 12/17/2013
        var new_field = 'group_by_CHANGED';

        // loop through each item in the list
        angular.forEach(list, function (item) {

            group_changed = false;

            // if not the first item
            if (prev_item !== null) {

                // check if any of the group by field changed

                //force group_by into Array
                group_by = angular.isArray(group_by) ? group_by : [group_by];

                //check each group by parameter
                for (var i = 0, len = group_by.length; i < len; i++) {
                    if ($parse(group_by[i])(prev_item) !== $parse(group_by[i])(item)) {
                        group_changed = true;
                    }
                }


            }// otherwise we have the first item in the list which is new
            else {
                group_changed = true;
            }

            // if the group changed, then add a new field to the item
            // to indicate this
            if (group_changed) {
                item[new_field] = true;
            } else {
                item[new_field] = false;
            }

            filtered.push(item);
            prev_item = item;

        });

        return filtered;
    };
}]);

Solution 2

If you are already using LoDash/Underscore, or any functional library, you can do this using _.groupBy() (or similarly named) function.


In controller:

var movies = [{"movieId":"1","movieName":"Edge of Tomorrow","lang":"English"},
              {"movieId":"2","movieName":"X-MEN","lang":"English"},
              {"movieId":"3","movieName":"Gabbar Singh 2","lang":"Telugu"},
              {"movieId":"4","movieName":"Resu Gurram","lang":"Telugu"}];
$scope.movies = _.groupBy(movies, 'lang');

In template:

<ul ng-repeat="(lang, langMovs) in movies">{{lang}}
  <li ng-repeat="mov in langMovs">{{mov.movieName}}</li>
</ul>

This will renders:

English

  • Edge of Tomorrow
  • X-MEN

Telugu

  • Gabbar Singh 2
  • Resu Gurram

Even better, this can be also converted into a filter very easily, without much of boilerplate code to group elements by a property.

Update: Group by multiple keys

Often grouping using multiple keys is very useful. Ex, using LoDash (source):

$scope.movies = _.groupBy(movies, function(m) {
    return m.lang+ "-" + m.movieName;
});

Update on why I recommend this approach: Using filters on ng-repeat/ng-options causes serious perf issues unless that filter executes quickly. Google for the filters perf problem. You'll know!

Solution 3

Here's what I finally decided upon to handle groupings within ng-repeat. I read up more on directives and filters and while you can solve this problem with either, the filter approach seemed a better choice. The reason is that filters are better suited for situations where only the data needs to be manipulated. Directives are better when DOM manipulations are needed. In this example, I really only needed to manipulate the data and leave the DOM alone. I felt that this gave the greatest flexibility.

See my final approach to groupings working on jsFiddle. I also added a little form to demonstrate how the list will work when dynamically adding data.

Here's the HTML.

<div ng-app="myApp">
    <div ng-controller='TestGroupingCtlr'>
        <div ng-repeat="item in MyList  | orderBy:'groupfield' | groupBy:'groupfield'" >
            <h2 ng-show="item.groupfield_CHANGED">{{item.groupfield}}</h2>
            <ul>
                <li>{{item.whatever}}</li>
            </ul>
        </div>

        <form role="form" ng-submit="AddItem()">
            <input type="text" data-ng-model="item.groupfield" placeholder="Group">
            <input type="text" data-ng-model="item.whatever" placeholder="Item">
            <input class="btn" type="submit" value="Add Item">
        </form>
    </div>

</div>

Here's the Javascript.

var app=angular.module('myApp',[]);

app.controller('TestGroupingCtlr',function($scope) {

        $scope.MyList = [
            {groupfield: 'Group 1', whatever: 'abc'},
            {groupfield: 'Group 1', whatever: 'def'},
            {groupfield: 'Group 2', whatever: 'ghi'},
            {groupfield: 'Group 2', whatever: 'jkl'},
            {groupfield: 'Group 2', whatever: 'mno'}
        ];

        $scope.AddItem = function() {

            // add to our js object array
            $scope.MyList.push({
            groupfield:$scope.item.groupfield,
                    whatever:$scope.item.whatever
            });
        };


    })


/*
 * groupBy
 *
 * Define when a group break occurs in a list of items
 *
 * @param {array}  the list of items
 * @param {String} then name of the field in the item from the list to group by
 * @returns {array} the list of items with an added field name named with "_new"
 *                  appended to the group by field name
 *
 * @example     <div ng-repeat="item in MyList  | groupBy:'groupfield'" >
 *              <h2 ng-if="item.groupfield_CHANGED">{{item.groupfield}}</h2>
 *
 *              Typically you'll want to include Angular's orderBy filter first
 */

app.filter('groupBy', function(){
    return function(list, group_by) {

    var filtered = [];
    var prev_item = null;
    var group_changed = false;
    // this is a new field which is added to each item where we append "_CHANGED"
    // to indicate a field change in the list
    var new_field = group_by + '_CHANGED';

    // loop through each item in the list
    angular.forEach(list, function(item) {

        group_changed = false;

        // if not the first item
        if (prev_item !== null) {

            // check if the group by field changed
            if (prev_item[group_by] !== item[group_by]) {
                group_changed = true;
            }

        // otherwise we have the first item in the list which is new
        } else {
            group_changed = true;
        }

        // if the group changed, then add a new field to the item
        // to indicate this
        if (group_changed) {
            item[new_field] = true;
        } else {
            item[new_field] = false;
        }

        filtered.push(item);
        prev_item = item;

    });

    return filtered;
    };
})

For the application I'm using this in, I setup the filter as a reusable filter throughout the app.

What I didn't like about the directive approach was that the HTML was in the directive, so it didn't feel reusable.

I liked the previous filter approach, but it didn't seem efficient since the list would have to be traversed twice on ever digest cycle. I deal with long lists, so it could be an issue. In addition it just didn't seem as intuitive as a simple check against the previous item to see if it changed. Plus I wanted to be able to use the filter against multiple fields easily, which this new filter handles just by piping to the filter again with another field name.

One other comment on my groupBy filter -- I do realize that multiple groupings would cause the array to be traversed multiple times, so I plan on revising it to accept an array of multiple group by fields so that it only has to traverse the array once.

Thanks so much for the inputs. It really helped me in learning more about directives and filters in Angular.

cheers, Darryl

Solution 4

AngularJS has three directives to help you display groups of information. Those directives are ngRepeat, ngRepeatStart and ngRepeatEnd. I found a blog post that shows how show groups in AngularJS. The gist of it is something like this:

<body ng-controller="OrdersCtrl">
  <div ng-repeat-start="customer in customers" class="header">{{customer.name}}</div>
  <div ng-repeat="order in customer.orders">{{order.total}} - {{order.description}}</div>
  <div ng-repeat-end><br /></div>
</body>

Pretty powerful directives once you learn how to use them.

Solution 5

The code by JoshMB will not work correctly of you have multiple filters on the same dataset in the same view. The second time you group a filtered version of the dataset, it will change the same attribute in the original object, thus breaking the group breaks in the previously filtered versions.

I solved this by adding the name of the "CHANGED" attribute as en extra filter parameter. Below is my updated version of the code.

/*
 * groupBy
 *
 * Define when a group break occurs in a list of items
 *
 * @param {array}  the list of items
 * @param {String} then name of the field in the item from the list to group by
 * @param {String} then name boolean attribute that indicated the group changed for this filtered version of the set

 * @returns {array} the list of items with an added field name named with "_new"
 *                  appended to the group by field name
 *
 * @example     <div ng-repeat="item in MyList | filter:'a' | groupBy:'groupfield':'Agroup_CHANGED'" >
 *              <h2 ng-if="item.Agroupfield_CHANGED">{{item.groupfield}}</h2>
 *              <!-- now a differen filtered subset -->
 *              <div ng-repeat="item in MyList | filter:'b' | groupBy:'groupfield':'Bgroup_CHANGED'" >
 *              <h2 ng-if="item.Bgroupfield_CHANGED">{{item.groupfield}}</h2>
 *
 *              Typically you'll want to include Angular's orderBy filter first
 */

app.filter('groupBy', ['$parse', function ($parse) {
    return function (list, group_by, group_changed_attr) {

        var filtered = [];
        var prev_item = null;
        var group_changed = false;
        // this is a new field which is added to each item where we append "_CHANGED"
        // to indicate a field change in the list
        //var new_field = group_by + '_CHANGED'; //- JB 12/17/2013
        var new_field = 'group_by_CHANGED';
        if(group_changed_attr != undefined) new_field = group_changed_attr;  // we need this of we want to group different filtered versions of the same set of objects !

        // loop through each item in the list
        angular.forEach(list, function (item) {

            group_changed = false;

            // if not the first item
            if (prev_item !== null) {

                // check if any of the group by field changed

                //force group_by into Array
                group_by = angular.isArray(group_by) ? group_by : [group_by];

                //check each group by parameter
                for (var i = 0, len = group_by.length; i < len; i++) {
                    if ($parse(group_by[i])(prev_item) !== $parse(group_by[i])(item)) {
                        group_changed = true;
                    }
                }


            }// otherwise we have the first item in the list which is new
            else {
                group_changed = true;
            }

            // if the group changed, then add a new field to the item
            // to indicate this
            if (group_changed) {
                item[new_field] = true;
            } else {
                item[new_field] = false;
            }

            filtered.push(item);
            prev_item = item;

        });

        return filtered;
    };
}]);
Share:
51,505

Related videos on Youtube

Darryl
Author by

Darryl

Updated on July 09, 2022

Comments

  • Darryl
    Darryl almost 2 years

    I'm new to Angular and would like to learn the best way to handle a problem. My goal is to have a reusable means to create group by headers. I created a solution which works, but I think this should be a directive instead of a scope function within my controller, but I'm not sure how to accomplish this, or if a directive is even the right way to go. Any inputs would be greatly appreciated.

    See my current approach working on jsFiddle

    In the HTML it's a simple list using ng-repeat where I call my newGrouping() function on ng-show. The function passes a reference to the full list, the field I want to group by, and the current index.

    <div ng-app>
    <div ng-controller='TestGroupingCtlr'>
        <div ng-repeat='item in MyList'>
            <div ng-show="newGrouping($parent.MyList, 'GroupByFieldName', $index);">
                <h2>{{item.GroupByFieldName}}</h2>
            </div>
            {{item.whatever}}
        </div>
    </div>
    </div>
    

    In my controller I have my newGrouping() function which simply compares the current to the previous, except on the first item, and returns true or false depending upon a match.

    function TestGroupingCtlr($scope) {
    
      $scope.MyList = [
        {GroupByFieldName:'Group 1', whatever:'abc'},
        {GroupByFieldName:'Group 1', whatever:'def'},
        {GroupByFieldName:'Group 2', whatever:'ghi'},
        {GroupByFieldName:'Group 2', whatever:'jkl'},
        {GroupByFieldName:'Group 2', whatever:'mno'}
      ];
    
      $scope.newGrouping = function(group_list, group_by, index) {
      if (index > 0) {
        prev = index - 1;
        if (group_list[prev][group_by] !== group_list[index][group_by]) {
          return true;
        } else {
          return false;
        }
      } else {
        return true;
      }
      };
    }
    

    The output will look like this.

    Group 1

    • abc
    • def

    Group 2

    • ghi
    • jkl
    • mno

    It feels like there should be a better way. I want this to be a common utility function that I can reuse. Should this be a directive? Is there a better way to reference the previous item in the list than my method of passing the full list and the current index? How would I approach a directive for this?

    Any advice is greatly appreciated.

    UPDATE: Looking for an answer that does not require external dependencies. There are good solutions using underscore/lodash or the angular-filter module.

    Darryl

    • jelinson
      jelinson over 10 years
      This is an interesting problem. Your provided example will create redundant groups if the groupings are not contiguous. In other words if we, for example, appended another element at the end which is in Group 1, an additional Group 1 header will be created. Is this intentional or should they in fact be grouped, as the name would suggest? If you clarify this, I can provide a directive-based solution. You are right that a directive is the way to go.
    • Darryl
      Darryl over 10 years
      Whenever a group-by is used in programming the order of the data will of course affect it as you described. I made my example as simple as possible, so presume the data is sorted correctly.
    • hcarreras
      hcarreras over 9 years
      have you checked angular-filter? github.com/a8m/angular-filter#groupby
    • Darryl
      Darryl over 9 years
      I hadn't seen this. It looks like a very useful library. Submit this as an answer and I'll up-vote it.
    • Alexis King
      Alexis King almost 9 years
    • Darryl
      Darryl almost 9 years
      The answer selected in the referenced question requires an external library. The selected answer here does not. Granted the questions posted are similar.
  • Darryl
    Darryl over 10 years
    I considered that, but then if later you want to group by a different field you'd have to re-map your data again. Group-by is a common need when presenting data, so I'd like to find a means to deal with this. I'm also hoping to understand how an experienced Angular developer would see this issue. Is this something that should be solved with a directive, with a filter, with a RootScope function, or something else?
  • charlietfl
    charlietfl over 10 years
    remapping data is also common
  • Darryl
    Darryl over 10 years
    Is there a way to do it without remapping the data? And/or with a function that would be reusable?
  • charlietfl
    charlietfl over 10 years
    have another way... give me a few minutes
  • charlietfl
    charlietfl over 10 years
    added a much better solution, was tired last night, not thinking as clear as could have been
  • Darryl
    Darryl over 10 years
    I like this approach. You create a list of groups from the data, then use that to create your group headings. Within the headings you simply filter the data based on the current group. The only downside is that there could be performance issues with large data sets (I'll give it a try on a data set I have with 4500 records and a few hundred groups). I like that you don't have to worry as much about sorting your data and it would be easy to implement multiple levels of groups. If you wanted to make this reusable across many controllers, what would you do?
  • charlietfl
    charlietfl over 10 years
    depends what you want to reuse...can store data and getGroups in a service which makes it accessable anywhere in app by injecting service. Might also consider setting a route up that uses route param to filer group...either from stored data, or make http request per group. Not sure what application needs are. Be pretty rare need 4500 records all at once IMO
  • jelinson
    jelinson over 10 years
    This is very inefficient. For k groups and n items, your filter is called on a list of size n, k times. Moreover, by putting the filter in the ng-repeat which uses a watch under the hood, it will be evaluated many times as part of the digest cycles.
  • charlietfl
    charlietfl over 10 years
    @jelinson thus why I originally mapped data into groups with no filter in markup but OP didn't want that. This is second version
  • Darryl
    Darryl over 10 years
    Awesome! Thanks for the enhancement. Why did you change "var new_field = group_by + '_CHANGED';" to "var new_field = 'group_by_CHANGED';"? It seems that you won't easily see which field changed in the view.
  • JoshMB
    JoshMB over 10 years
    In my situation, I didn't care which field was changed... If any of them were changed I wanted a new header.
  • Darryl
    Darryl over 10 years
    This won't work if you add an orderBy to the repeat. The reason is that the index matches the original array order, not the orderBy filter order, so this solution is pretty limited.
  • Darryl
    Darryl about 10 years
    a similar solution was proposed above. the problem is that it won't work if you add an orderBy or any type of filter since the array maintains the original index.
  • kartik
    kartik about 10 years
    this method's complexity is lesser, and is it mentioned that the ordering has to be maintained? If so ,why?
  • Darryl
    Darryl about 10 years
    My need was for a reusable library function which would work throughout the application, so for it to work with filtered data was a basic requirement. I down-voted your solution simply because it seems that you didn't read the prior solutions to see that it was almost identical to a previous post (sans the ng-init to sort first).
  • Matthijs
    Matthijs about 10 years
    Today I was working with this code and found that it does not work well on multiple filtered versions of the same data set. This can be solved by adding an extra filter parameter with the attribute name where this filtered version group_CHANGED should be stored (see the complete code below in my answer).
  • Darryl
    Darryl almost 10 years
    I do use LoDash and that's a very useful and practical answer.
  • Sukesh Chand
    Sukesh Chand about 9 years
    How to impliment group footer concept with the above logic?
  • dreftymac
    dreftymac almost 9 years
    plus one for underscore.js. It is an excellent library and well-recommended