AngularJS Group By Directive without External Dependencies
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
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;
};
}]);
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!
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
Below is a directive-based solution, as well as a link to a JSFiddle demoing it. The directive allows each instance to specify the field name of the items it should group by, so there is an example using two different fields. It has linear run-time in the number of items.
JSFiddle
<div ng-app='myApp'>
<div ng-controller='TestGroupingCtlr'>
<h1>Grouping by FirstFieldName</h1>
<div group-with-headers to-group="MyList" group-by="FirstFieldName">
</div>
<h1>Grouping by SecondFieldName</h1>
<div group-with-headers to-group="MyList" group-by="SecondFieldName">
</div>
</div>
</div>
angular.module('myApp', []).directive('groupWithHeaders', function() {
return {
template: "<div ng-repeat='(group, items) in groups'>" +
"<h2>{{group}}</h2>" +
"<div ng-repeat='item in items'>" +
"{{item.whatever}}" +
"</div>" +
"</div>",
scope: true,
link: function(scope, element, attrs) {
var to_group = scope.$eval(attrs.toGroup);
scope.groups = {};
for (var i = 0; i < to_group.length; i++) {
var group = to_group[i][attrs.groupBy];
if (group) {
if (scope.groups[group]) {
scope.groups[group].push(to_group[i]);
} else {
scope.groups[group] = [to_group[i]];
}
}
}
}
};
});
function TestGroupingCtlr($scope) {
$scope.MyList = [
{FirstFieldName:'Group 1', SecondFieldName:'Group a', whatever:'abc'},
{FirstFieldName:'Group 1', SecondFieldName:'Group b', whatever:'def'},
{FirstFieldName:'Group 2', SecondFieldName:'Group c', whatever:'ghi'},
{FirstFieldName:'Group 2', SecondFieldName:'Group a', whatever:'jkl'},
{FirstFieldName:'Group 2', SecondFieldName:'Group b', whatever:'mno'}
];
}
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.