Tuesday, April 21, 2015

AngularJS: Executing JavaScript in ng-repeat templates/iterations

By design, ngRepeat does not execute any <script> tags in its template. Unfortunately, there are instances where this starts becoming limiting. I faced one such scenario recently.

In one of my pet projects, there is a dashboard which looks as below. Each row in the tree grid is rendered using ngRepeat directive (of course). The challenge is that each row also contains a painted HTML5 Canvas which renders a stacked graph of the numbers.

Since the Canvas can only be rendered by JavaScript, I will have to either set a time out function, hoping that ngRepeat render will finish before the timeout. This is not an elegant solution since I have no clue how deep or huge my dashboard tree is going to be. To add to the convolution, ngRepeat does not provide a call back hook for each iteration.

An elegant solution can be crafted by using custom directives and $emit, $on scope event propagation methods. Let me demonstrate using a simple example.

.
<!DOCTYPE html>
<html lang="en" ng-app="app">
<head>
    <script src="/lib-ext/angular/angular.min.js"></script>
    <script src="mycontroller.js"></script>
</head>
<body>

<div ng-controller="MyController">
    <div>
        <div ng-repeat="id in data track by $index" on-row-render>
            {{id}}
            <div id="div-{{$index}}"></div>
        </div>
    </div>
</div>
</body>
</html>
.

In the above HTML, I want the contents of the div inside the ng-repeat template to be populated by a JavaScript function which should be executed within the context of the ng-repeat iteration such that all the scope variables are accessible.

The following snippet shows my controller:

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

app.controller( 'MyController', function( $scope ) {
    $scope.data = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ] ;

    $scope.$on( 'onRowRender', function( scope, index ){
           document.getElementById( "div-" + index )
                .innerHTML = "DIV-" + index ;
    });
} ) ;

app.directive('onRowRender', function() {
    return function( scope, element, attrs ) {
        setTimeout( function(){
            scope.$emit( 'onRowRender', scope.$index );
        }, 1);
    };
} ) ;
.

An extremely simple model, but helps explain the concept behind the solution. I have created a custom directive 'onRowRender', which I embed as an attribute in my ng-repeat template. This directive will get called for each iteration and passed the iteration scope.

I can choose to customize my interception point by reading the scope variables $index, $first, $last, $even, $odd etc. In this case, I choose to intercept every row render.

The directive, creates an event and propagates it up the event handling tree. Note that I decouple the event propagation from the directive execution by using a timeout. This ensures that we don't contend with Angular for manipulating the DIV. If we don't decouple this, we will end up with our event handlers not finding some of the DOM elements which we had intended to be rendered in our ngRepeat template.

The last part, the onRowRender event is caught by our controller (event propagation starts at the same module level and moves up), which (simplistically) finds the div DOM element and sets some content inside it. It is here that I will call on the JavaScript method to render my custom stacked graph.

The resultant output.

No comments: