Grouping Nested ngRepeat Lists In AngularJS
The other day, I wanted to output a list of values in AngularJS using ngRepeat; however, I wanted that list to be grouped into sublists. Out of the box, ngRepeat doesn't have any group-by control, like ColdFusion does. But, nesting ngRepeats is fairly straightforward if you take on the responsibility of creating the groups yourself.
In the following demo, I have members of the cast from Arrested Development. This list can be grouped by gender and hair color. In order to create the grouping, we have to maintain a separate list of groups in parallel with the original list of cast members. Then, as the requirements of the grouping change, we have to manually rebuild the groups using the original list.
NOTE: Some of this code would be much easier with a library like LoDash.js.
<!doctype html>
<html ng-app="Demo" ng-controller="DemoController">
<head>
<meta charset="utf-8" />
<title>
Grouping Nested ngRepeat Lists In AngularJS
</title>
</head>
<body>
<h1>
Grouping Nested ngRepeat Lists In AngularJS
</h1>
<p>
Group by:
<a ng-click="groupBy( 'gender' )">Gender</a> -
<a ng-click="groupBy( 'hair' )">Hair</a>
</p>
<!-- BEGIN: Outer ngRepeat. -->
<div ng-repeat="group in groups">
<h2>
{{ group.label }}
</h2>
<ul>
<!-- BEGIN: Inner ngRepeat. -->
<li ng-repeat="friend in group.friends">
{{ friend.name }}
</li>
<!-- END: Inner ngRepeat. -->
</ul>
</div>
<!-- END: Outer ngRepeat. -->
<!-- Load jQuery and AngularJS from the CDN. -->
<script
type="text/javascript"
src="//code.jquery.com/jquery-1.9.1.min.js">
</script>
<script
type="text/javascript"
src="//ajax.googleapis.com/ajax/libs/angularjs/1.0.4/angular.min.js">
</script>
<!-- Load the app module and its classes. -->
<script type="text/javascript">
// Define our AngularJS application module.
var demo = angular.module( "Demo", [] );
// -------------------------------------------------- //
// -------------------------------------------------- //
// I am the main controller for the application.
demo.controller(
"DemoController",
function( $scope, $timeout ) {
// -- Define Controller Methods. ------------ //
// I sort the given collection on the given property.
function sortOn( collection, name ) {
collection.sort(
function( a, b ) {
if ( a[ name ] <= b[ name ] ) {
return( -1 );
}
return( 1 );
}
);
}
// -- Define Scope Methods. ----------------- //
// I group the friends list on the given property.
$scope.groupBy = function( attribute ) {
// First, reset the groups.
$scope.groups = [];
// Now, sort the collection of friend on the
// grouping-property. This just makes it easier
// to split the collection.
sortOn( $scope.friends, attribute );
// I determine which group we are currently in.
var groupValue = "_INVALID_GROUP_VALUE_";
// As we loop over each friend, add it to the
// current group - we'll create a NEW group every
// time we come across a new attribute value.
for ( var i = 0 ; i < $scope.friends.length ; i++ ) {
var friend = $scope.friends[ i ];
// Should we create a new group?
if ( friend[ attribute ] !== groupValue ) {
var group = {
label: friend[ attribute ],
friends: []
};
groupValue = group.label;
$scope.groups.push( group );
}
// Add the friend to the currently active
// grouping.
group.friends.push( friend );
}
};
// -- Define Scope Variables. --------------- //
// I am the raw collection of friends.
$scope.friends = [
{
name: "Michael",
gender: "Male",
hair: "Brunette"
},
{
name: "George Michael",
gender: "Male",
hair: "Brunette"
},
{
name: "Gob",
gender: "Male",
hair: "Brunette"
},
{
name: "Tobias",
gender: "Male",
hair: "Black"
},
{
name: "Lindsay",
gender: "Female",
hair: "Blonde"
},
{
name: "Maeby",
gender: "Female",
hair: "Black"
}
];
// I am the grouped collection. Each one of these
// will contain a sub-collection of friends.
$scope.groups = [];
}
);
</script>
</body>
</html>
As you can see, in order to group the list, I create a secondary, "groups" list. Each item in this list contains a sub-collection of the original friends collection. Then, we simply use ngRepeat to output one list within the other.
I've seen a lot of AngularJS code that seeks to do similar things using filters or inline method calls (ie. method calls located directly within the HTML templates). This approach works well for testing and small interfaces; but, even Misko Hevery - creator of AngularJS - states that filters should only be used with trivial examples. As such, manually grouping your data within your Controller is definitely the way to go.
Want to use code from this post? Check out the license.
Reader Comments
Thanks Ben! We were able to use this technique to group search results into into rows for styling purposes. Here is a plunker where I'm just grouping into sets of 3: http://plnkr.co/edit/pbHldxCkYBGbvL6j5tux?p=preview
@Ryan,
Very cool! One of the biggest mental hurdles for me, when it comes to AngularJS and rendering data, is the tight connection between the data itself and the resultant DOM.
By that, I mean that when I used to render things on the server (with ColdFusion, PHP, etc.), you had your data, but you also has 100% control over how that data was translated into HTML.
Now, with AngularJS, what you gain in "magic", you lose in some of the control. So, you can't just manually define HTML anymore - you gotta find a way to actually build a collection that represents your HTML, even if that collection is not your original data set.
@ben
I agree. On top of that your collections/models/scope objects can be any JavaScript object without predefining what that object is and what it's properties are. On one side I like this. It allows for a lot of flexibility and easy development. On the other end you might not realize if your model objects change which could be the case in creating a $resource from JSON generated by an api source.
With this in mind I have been trying hard to get a good testing practice down. For unit testing of Angular components Jasmine with the Karma test runner have been good: http://karma-runner.github.io/0.8/index.html
However preforming full integration testing (end to end testing) has proven more difficult. I have to factor in setting up and breaking down DB objects on the server. In my case I typically have a Rails application under my Angular client.
This might be suitable for another post but what are your testing methods for Angular projects?
@Ryan,
Unfortunately, my "testing" methods for AngularJS are all manual (ie. I use the app and try to break it). Testing, in general, whether client-side or server-side, is my biggest Achilles heel. I'm a total noob.
As for the model-objects, I try to make all the data passed to my controllers unique to that controller. So, if two different UIs on the same page have the same data, they actually have different references. My service layer does stuff like this:
Each controller gets a "copy" of the core data. This way, the controller can mess with it all it wants and the other controllers are unaffected.
That said, this approach is heavily colored by the fact that I didn't really understand using Controllers in ngRepeat statements and I ended up storing transient data in my "model" that was specific to a UI.
If I could do it all over again, I might not have to worry about the copy-aspect of the data.
Thank you for your post that really help me, but i have a question, if i want to add some new data(friends) to the exist data object, how to do that? Maybe i have selected someone in the list and i don't want to lose the selected status.
@Elicip,
Great question! Typically, when I have some sort of "breakdown" of a given collection and I need to add an item to the core collection, I have two options:
Approach One.
Provide some way in the UI to make sure that your "add" action is tied to a given group. So, imagine that in this demo, each group has a Form at the bottom with an ngSubmit action like:
ng-submit="addFriendToGroup( group )"
Here, since each group output (ie. each group-based ngRepeat) gets its own form, we can tell the Controller which group we are using when we "add" a new item.
Then, in the Controller, we can create the new Friend object, and add it to BOTH the core collection - Friends - and the given collection - group.
Approach Two.
This approach is a bit more brute force. When you add a new item to the core collection, you simply need to re-calculate the breakdown of the groups. So, you "add" method might look like this (pseudo code):
$scope.addFriend =>
1. Create friend.
2. Add friend to core collection.
3. Rebuild "group" collection using updated friends.
Since AngularJS is monitoring all your objects, it will implicitly rebuild the DOM based on the changes. And, as long as you haven't completely replaced the object references (which you probably won't), it won't re-create DOM items unnecessarily - meaning, you won't lose existing "selection" data, depending on how its stored.
But that's just my from-the-hip feedback; it would depend on exactly what you'd be trying to do. And, what data you're trying to maintain during the "add" process.
@Ben
Thanks for your reply,and i have used the Approach One.I think it's the simplest way to do that.
"Rebuild "group" collection using updated friends." in Approach Two is complicated :)
@Elicip,
Using the group-based "add" is definitely easier. But, there definitely are situations where the rebuild is nice. For example, maybe you can switch the "group" that an existing item belongs to. You could remove it from one group and then add it to the target group; but, sometimes the "brute force" approach to rebuilding the list makes life easier.
All just about what you need in the app!
Hi,
In my Code , The Class objects return 3 Lists and I have to bind that 3 list in different drop downs on page load using AngularJs.
Can you please help me .
Regards,
Ravindra
Hi Ravindra,
Without seeing code I am guessing a bit but this might help:
In your controller I would assign each list to a different model on the scope. Than you can use the select directive in your view. The specifics are explained here:
http://docs.angularjs.org/api/ng.directive:select
Cheers,
Ryan
@Ben,
I'm solving a slightly different problem in that I need to be able to sort by one attribute and group by another. I handled this by creating a groupBy filter. http://plnkr.co/edit/IxmsjBqLiBdDrki1sizc?p=preview
I like the idea of a filter so that it is accessible throughout the application, however, in the video you mentioned that you thought filters were a bad idea. Could you elaborate?
Hi Ben,
This is simple but a very effective solution. Thanks..
Tarun
For those that are wanting a way of doing this recursively:
http://codepen.io/iautomation/pen/bzGiw
Ben, thanks for your discussion of ng-repeat.
I'm wondering whether/how/if one could group at a further level, using the data as it stands? For example, Gender by Name => Gender by Hair Colour by Name; or Hair Colour by Name =>Haircolour by Gender by Name.
Ted
@Ted,
Use Underscore.js (http://documentcloud.github.io/underscore/) with methods like GroupBy and a whole bunch of other collection manipulators to implement Ben's scheme above efficiently.
I'm intrigued as to why Misko Hevery said to use the Controller and not a filter. Can you elaborate at all?
Personally, I prefer my controllers to stay ignorant to the view that is bound to it - more of an MVVM pattern than MVC. With that in mind, having a method on the controller that groups the model data into UI groups goes against this pattern. So instead, using a filter that can be easily tested would be my preference as to the responsibility of the controller stays simple - exposing the model to the view.
Thank you so much for this post it helped me a lot! Its quite a mind struggle to understand the code but when you do it is very logical.