7

I'm running into an issue where I want to bind to the output of a function inside of an ng-repeat loop. I'm finding that the function is being called twice per item rather than once as I'd expect. Here's the ng-repeat section (notice the calcRowTotal() call at the end):

<tr ng-repeat="row in timesheetRows">
    <td>
        <select ng-model="row.categoryID">
            <option ng-repeat="category in categories" value="{{category.id}}">
                {{category.title}}
            </option>
        </select>
    </td>
    <td ng-repeat="day in row.dayValues">
        <input type="text" ng-model="day.hours" />
    </td>
    <td>{{calcRowTotal($index, row)}}</td>
</tr>

The calcRowTotal() function is shown next:

$scope.calcRowTotal = function (index, row) {
    console.log('calcRowTotal - Index: ' + index);
    var total = 0;
    for (var i = 0; i < row.dayValues.length; i++) {
        var num = parseFloat(row.dayValues[i].hours);
        if (isNaN(num)) {
            num = 0;
            //this.dayValues[i].hours = 0;
        }
        total += num;
    }
    //updateDayTotals();
    return total;
}

An example of one of the items being iterated through is shown next:

{
    categoryID: 2,
    dayValues: [
                    { day: $scope.days[0], hours: 5 },
                    { day: $scope.days[1], hours: 0 },
                    { day: $scope.days[2], hours: 3 },
                    { day: $scope.days[3], hours: 0 },
                    { day: $scope.days[4], hours: 2 },
                    { day: $scope.days[5], hours: 5 },
                    { day: $scope.days[6], hours: 8 }
    ]
}

I'm seeing the following in the console (two items are currently in the collection I'm looping through):

calcRowTotal - Index: 0 
calcRowTotal - Index: 1 
calcRowTotal - Index: 0 
calcRowTotal - Index: 1 

I could certainly make a "rowTotal" property but would prefer to bind to "live" data provided by the function shown above. Hopefully the duplication is something simple I'm missing so I appreciate any feedback on why I'm seeing the duplication. As a side note, as data in one of the textboxes changes I need to update the row totals as well so it may be I need a different approach. Interested in understanding this particular situation first though....definitely don't want the duplication because there could be a lot of rows potentially.

Here's an example: http://jsfiddle.net/dwahlin/Y7XbY/2/

Dan Wahlin
  • 188
  • 1
  • 1
  • 6

3 Answers3

13

It's because you're binding to a function expression here:

<td>{{calcRowTotal($index, row)}}</td>

What that does it force that function to be reevaluated on every item, on every digest. What you'll want to do to prevent that is pre-calculate that value and put it in your array to begin with.

One way to do that is to set up a watch on your array:

$scope.$watch('timesheetRows', function(rows) {
   for(var i = 0; i < value.length; i++) {
     var row = rows[i];
     row.rowTotal = $scope.calcRowTotal(row, i);
   }
}, true);

Then all you have to do is bind to that new value:

<td>{{row.rowTotal}}</td>
Ben Lesh
  • 107,825
  • 47
  • 247
  • 232
  • Thanks blesh - didn't realize it would do that but it makes sense. – Dan Wahlin Feb 20 '13 at 18:36
  • It's honestly probably not a "big deal". Since it's a client side app, and there's only one user, if it's not causing performance issues, you can leave it if it's easier for you to maintain. Regardless, I have some psuedo-code for a proposed fix in my answer. – Ben Lesh Feb 20 '13 at 18:38
  • +1 But why didn't you use `angular.forEach` here? Seems like it may have cut the code down a little bit. Also, you should mention that expressions will be called *at least once* during every digest and we cannot guarantee how many times. – Josh David Miller Feb 20 '13 at 18:45
  • Perfect - thanks again blesh (and Josh for the alternatives to look into). I was going down the $watch road but being relatively new to the framework the hardest part is knowing the best practice to follow. In some cases there are multiple approaches which is good - but challenging at the same time. – Dan Wahlin Feb 20 '13 at 20:41
  • @JoshDavidMiller "Why didn't you use angular.forEach here?" 1) I needed the index. 2) If I were really trying to cut down code I'd have done `for(i = rows.length; i--;)` 3) one less function closure. 4) because? 5) I didn't need to change the context. – Ben Lesh Feb 20 '13 at 21:31
  • I think the index is irrelevant, but it's available as a second parameter: `forEach(rows,function(row,idx){})`. – Josh David Miller Feb 20 '13 at 21:42
  • 2
    I have the new $watch mentioned above added into the controller but am still seeing the calcRowTotal() function called 2 times per item. I'm assuming based on what has been mentioned (digests) that this is just expected behavior and something to get used to? I'm definitely used to ensuring that duplication is removed but it doesn't appear I can control that in this type of scenario? – Dan Wahlin Feb 20 '13 at 23:08
  • Yes, it's something that is going to happen in some cases. Even with the watch. Each time there's a digest triggered (and they're triggered by a lot of things), and anything under timesheetRows has been altered, it's going to be called. – Ben Lesh Feb 21 '13 at 13:39
1

Its totally because you're binding to a function expression as @Ben Lesh suggested. Somehow using $watch also got me executing the same function twice. Solution to refrain from double execution that we used is by using ng-init for function call. Simply create an init variable with function return as value and use that in ur expression as below:

<tr ng-repeat="row in timesheetRows" ng-init="rowTotalValue=calcRowTotal($index, row)">
<td>
    <select ng-model="row.categoryID">
        <option ng-repeat="category in categories" value="{{category.id}}">
            {{category.title}}
        </option>
    </select>
</td>
<td ng-repeat="day in row.dayValues">
    <input type="text" ng-model="day.hours" />
</td>
<td>{{rowTotalValue}}</td>

This worked for me where I was passing a single attribute of row. Not tried with $index though. But I assume it should work.

saurabh
  • 729
  • 1
  • 7
  • 16
0

I actually had a similar problem recently.

Ended up iterating through each sub-object and binding the totals to a model. Shouldn't be an issue, especially if you're only using the total for display purposes.

eg.

// Reset to Zero
$scope.rowTotal = 0;

jquery.each($scope.row, function(key, value) {
 $scope.totalRow += value.hours;
});

and iterate through each row.

Christopher Marshall
  • 10,678
  • 10
  • 55
  • 94