7

I would like to perform a group by function inside of an ng-repeat

Given the following data:

var items = [];
items.push({ id: 1, widgetId: 54, colorId: 45 });
items.push({ id: 2, widgetId: 54, colorId: 72 });
items.push({ id: 3, widgetId: 54, colorId: 29 });
items.push({ id: 4, widgetId: 55, colorId: 67 });
items.push({ id: 5, widgetId: 55, colorId: 29 });
items.push({ id: 6, widgetId: 56, colorId: 29 });
items.push({ id: 7, widgetId: 56, colorId: 72 });
items.push({ id: 8, widgetId: 57, colorId: 75 });

I would like an ng-repeat that results in the following presentation

widgetId 54    colorId: 45 colorId: 72 colorId 29
widgetId 55    colorId: 67 colorId: 29
widgetId 56    colorId: 29 colorId: 72
widgetId 57    colorId: 75

...and markup

<div class="container">
<div class="row">
    <div>widgetId: 54</div>
    <div>
        <div>colorId: 45</div>
        <div>colorId: 72</div>
        <div>colorId: 29</div>
    </div>
</div>
<div class="row">
    <div>widgetId: 55</div>
    <div>
        <div>colorId: 67</div>
        <div>colorId: 29</div>
    </div>
</div>
<div class="row">
    <div>widgetId: 56</div>
    <div>
        <div>colorId: 29</div>
        <div>colorId: 72</div>
    </div>
</div>
<div class="row">
    <div>widgetId: 57</div>
    <div>
        <div>colorId: 75</div>
    </div>
</div>
</div>

Any suggestions that don't include creating separate arrays? The data is coming to me this way and it would be nice to avoid manipulating it.

Greg Grater
  • 2,001
  • 5
  • 18
  • 24
  • possible duplicate of [how to split the ng-repeat data with three columns using bootstrap](http://stackoverflow.com/questions/21644493/how-to-split-the-ng-repeat-data-with-three-columns-using-bootstrap) – m59 Oct 03 '14 at 20:43
  • 1
    Hmm, actually I was wrong about the duplicate. Just a sec and I'll have a demo/solution. – m59 Oct 03 '14 at 20:49
  • @M59, thanks for the response, but the dup you refer to simply takes and array and creates two separate arrays (one for the row and one for each of the columns). Moreover, it's fixed to a given number of columns. That's not what I'm asking for. I want to intelligently (and simply) "break" on when a repeated value changes. Each break would create a new row containing one instance of the repeated column followed by a separate column with non-repeating data. I'll edit my question to make it a little clearer. – Greg Grater Oct 03 '14 at 20:55
  • 1
    Yes, as I said in my above comment, I realized I misunderstood. I'm almost done with a solution for your problem. – m59 Oct 03 '14 at 21:01
  • Sorry...didn't see your comment before I posted mine... :-) Thanks for the help...I've edited the question for clarity. – Greg Grater Oct 03 '14 at 21:02
  • 1
    possible duplicate of [How can I group data with an Angular filter?](http://stackoverflow.com/questions/14800862/how-can-i-group-data-with-an-angular-filter) – sylwester Oct 03 '14 at 21:16

2 Answers2

9

Update - the simple, clean way:

Use npm modules! Lodash can handle the groupBy and the memoization needed to avoid an infinite loop as an Angular filter.

npm install lodash
var memoize = require('lodash/function/memoize');
var groupBy = require('lodash/collection/groupBy');
app.filter('groupBy', function() {
  return memoize(groupBy);
});

You may need to use the resolver function of lodash's memoize:

app.filter('groupBy', function() {
  return memoize(function() {
    return groupBy.apply(null, arguments);
  }, function() {
    return JSON.stringify([].slice.call(arguments));
  });
});

But, I really believe you should just simplify all of this and filter in the controller:

$scope.foo = function() { // run this when user clicked button, etc
  $scope.groupedItems = groupBy($scope.items, 'stuff');
};

Old Answer:

I suggest a groupBy filter to modify the data used in the view on the fly. Here's what I came up with. This filter returns a new object each time which will cause an infinite digest cycle, so I wrapped it in my service that fixes those kinds of problems. This one is simply fixed by memoization. Memoization meeans that given the same parameters (input, prop), the exact same output will be returned from a cache, so the same object is returned again, rather than creating a new one that looks the same. This filter also supports nested property names, so you can easily group by a property nested within the objects.

Live Demo

<div class="container">
  <div class="row" ng-repeat="(setKey, set) in items | groupBy:'widgetId'">
    WidgetId: {{setKey}}
    <div ng-repeat="item in set">
      ColorId: {{item.colorId}}
    </div>
  </div>
</div>

The filter:

.filter('groupBy', [
  '$parse', 
  'pmkr.filterStabilize', 
  function($parse, filterStabilize) {

    function groupBy(input, prop) {

      if (!input) { return; }

      var grouped = {};

      input.forEach(function(item) {
        var key = $parse(prop)(item);
        grouped[key] = grouped[key] || [];
        grouped[key].push(item);
      });

      return grouped;

    }

    return filterStabilize(groupBy);

 }])

My filterStabilize service should fix any filter, but any good memoize function will do fine in this case (and most cases).

m59
  • 43,214
  • 14
  • 119
  • 136
  • Thanks m59...essentially, you're telling me that I *must* create a secondary array. Not excited about it, but that's the only solution I could find. Couple of questions on your filter. 1) where is pmkr.filterStabilize coming from? Is this a library I need to include? 2) $parse is injected, but never used. Is it necessary. ...sorry for the newbie questions – Greg Grater Oct 03 '14 at 21:27
  • 1
    @GregGrater `$parse` is used and it adds support for nested property names for grouping. `filterStabilize` is a service for fixing filters, the code is in the demo and I linked to my GitHub repo where you can read more about it. Use any `memoize` function instead if you want. – m59 Oct 03 '14 at 21:28
  • @GregGrater In this case, I don't see why "creating a secondary array" is a bad choice at all. All of that is abstracted away in the filter and you'd never really know you created anything new. Include this code in your app and you'd never have to consider having created a new array. That's just details behind the scenes. – m59 Oct 03 '14 at 21:35
  • My concern is more around the management of the secondary array with regards to updates in the primary array. The actual data is being supplied through a service that manages CRUD operations. I was hoping to avoid having to maintain both sets of data through those CRUD operations. Does that make sense, or am I missing something. – Greg Grater Oct 04 '14 at 16:53
  • Also, can your provide a quality link to an explanation of the "filter will cause an infinite digest cycle" comment you made in the answer? – Greg Grater Oct 04 '14 at 16:56
  • 1
    @GregGrater You're missing something :) The filter is going to take care of all of that automatically whenever something changes. As I said, you never have to think of it at all. The reason for the infinite digest is VERY simple. Something on $scope changes, so angular runs a digest cycle, which would then run filters. After each digest, another digest cycle fires to ensure the values won't change again, if they do, then another fires and so on until everything is stable (no changes affect other changes). If a filter returns a new value or object each time, the cycle repeats forever. – m59 Oct 04 '14 at 17:17
  • m59, thanks for the explanation. Essentially, angular filters on an ng-repeat always run twice. Once for the $$watchers which triggers another $digest because the filter changes the array. (see Limit DOM Filters section of http://www.meanstack.co/speeding-up-angularjs-apps-with-simple-optimizations/). Your filterStablize/memoize functions cache the first run of the filter and return the cached array on the second run of the filter. Makes sense. – Greg Grater Oct 06 '14 at 13:28
  • m59, I have one additional (likely newbie) question about filterStablize/memoize. The cache object is using the stringify'ed array as it's key. This ensures that *if* data in the array changes, the key changes and a new cache is created. Right? What happens on large arrays or arrays that contain properties with lots of data? What happens when the data in the array is changed frequently? Also, because the cache is contained in a factory, will it be around for the life of the application? Apologize for the detailed discussion. I do appreciate your answers. – Greg Grater Oct 06 '14 at 13:45
  • @GregGrater there are a few approaches to memoization. Mine is the simplest, but may not perform well if used in extreme circumstances. You might prefer the one from lodash. – m59 Oct 06 '14 at 16:09
  • How can we perform some arithmetic operations like sum on the grouped data..Lets say we group by date and we need to sum all the qty in that particular date? Thanks – codingbbq Feb 16 '15 at 11:25
  • I just made the filter as per your instructions. The problem is that when I update the data source the list is not updated. If I remove memoize it works, but as you say. It causes an infinity loop. Any ideas how to fix? – Jompis Sep 02 '15 at 12:03
  • @Jompis Can you make a live demo and post the link? – m59 Sep 02 '15 at 12:22
  • I might be able to do that. But meanwhile I can add another fact. If my data collection (array) contains less or more objects than currently angular updates the view. It's like it doesn't care what the object contains. Just that it is an object. I guess this is what it sees: [Object,Object,Object]. So If I filter the collection and get 4 items, I will see the filtered result. – Jompis Sep 02 '15 at 12:58
  • @m59 http://plnkr.co/edit/99MXuNvCzZWjMEZyLw0v?p=preview First button should update collection with new names. But doesn't. – Jompis Sep 02 '15 at 13:41
  • @m59 You can reset the cache by using the resolver. I pass an anonymous function that returns a key. So I just change that key when I change the data. Not nice at all. But it works. Please let me know if you figure out a better solution. – Jompis Sep 02 '15 at 14:30
  • 1
    I would `return JSON.stringify([].slice.call(arguments));` for the resolver. http://plnkr.co/edit/jzmdxshZq5Isho00YzHs?p=preview – m59 Sep 02 '15 at 16:17
  • 2
    @Jompis Actually, just forget using a filter and filter in the controller :) http://plnkr.co/edit/3HMqHMThcwrdXAKVr3L0?p=preview – m59 Sep 02 '15 at 16:29
  • Filter in the controller was my plan b. But it's nice to move the logic to the repeater. I'll see which solution I will use. Both of your examples works great. Thanks! – Jompis Sep 03 '15 at 06:23
  • I created a simple example that has helped me understand the answer. http://jsbin.com/sevexu/7/edit?html,js,output Very helpful, @m59! – ryanm Sep 03 '15 at 16:18
1

Try like this:

  <div class="container">
      <div  class="row" ng-repeat="(key, value) in items| groupBy: 'widgetId'">
        <div>widgetId: {{ key }}</div>
        <div>  
          <div ng-repeat="color in value">
            colorId: {{color.colorId}} 
          </div>
        </div>
      </div>
    </div>
AvgustinTomsic
  • 1,809
  • 16
  • 22