angularjsclient-side-templating

One-off rendering of an angular template string


I am writing a directive to integrate SlickGrid with my angular app. I want to be able to configure SlickGrid columns with an angular template (instead of a formatter function). To achieve this, I need the directive to dynamically create formatter functions that return HTML as a string.

My approach has been to create a temporary scope, link the template against that, capture the html, and then destroy the scope. This works, but complains that $digest already in progress. Is there a way I can render an angular template in this fashion, isolated from the global $digest cycle?

BTW: I tried using $interpolate, which works great, but doesn't support ng-repeat or other directives.

var columnsConfig = [
  {
    id: "name", 
    name: "Name", 
    field: "name", 
    template: '<a href="{{context.url}}">{{value}}</a>'
  },
  {
    id: "members", 
    name: "Members", 
    field: "members", 
    template: '<div ng-repeat="m in value">{{m}}</div>'
  }
];

myModule.directive('SlickGrid', ['$compile', function($compile) {
  return {
    restrict: 'E',
    scope: {
      model: '='
    },
    link: function(scope, element, attrs) {
      var columns = angular.copy(columnsConfig);

      // Special Sauce: Allow columns to have an angular template
      // in place of a regular slick grid formatter function
      angular.forEach(columns, function(column){
        var linker;

        if (angular.isDefined(column.template)) {
          linker = $compile(angular.element('<div>' + column.template + '</div>'));
          delete column.template;

          column.formatter = function(row, cell, value, columnDef, dataContext) {
            var cellScope = scope.$new(true);
            cellScope.value = value;
            cellScope.context = dataContext;

            var e = linker(cellScope);
            cellScope.$apply();
            cellScope.$destroy();

            return e.html();
          };
        }
      });

      var options = {
        enableColumnReorder: false,
        enableTextSelectionOnCells: true,
        autoHeight: true
      };

      var dataView = new Slick.Data.DataView();
      var grid = new Slick.Grid(element, dataView, columns, options);

      dataView.onRowCountChanged.subscribe(function (e, args) {
        grid.updateRowCount();
        grid.render();
      });

      dataView.onRowsChanged.subscribe(function (e, args) {
        grid.invalidateRows(args.rows);
        grid.render();
      });

      scope.$watch('model', function(data) {
        if (angular.isArray(data)) {
          dataView.setItems(data);
        }
      });
    }
  };
}]);

Solution

  • Ok so I needed to do pretty much the same thing, and came up with a solution that could be considered a bit of a hack (but there's no other way AFAIK, since SlickGrid only deals with html string, not html/jquery objects).

    In a nutshell, it involves compiling the template in the formatter (as you did), but in addition to that, stores the generated object (not the HTML string) into a dictionnary, and use it to replace the cell content by using asyncPostRender (http://mleibman.github.io/SlickGrid/examples/example10-async-post-render.html).

    Here is the part of the link function that is of interest here:

    var cols = angular.copy(scope.columns);
    var templates = new Array();
    
    // Special Sauce: Allow columns to have an angular template
    // in place of a regular slick grid formatter function
    angular.forEach(cols, function (col) {
    
        if (angular.isDefined(col.template)) {
    
            col.formatter = function (row, cell, value, columnDef, dataContext) {
    
                // Create a new scope, for each cell
                var cellScope = scope.$parent.$new(false);
                cellScope.value = value;
                cellScope.context = dataContext;
    
                // Interpolate (i.e. turns {{context.myProp}} into its value)
                var interpolated = $interpolate(col.template)(cellScope);
    
                // Compile the interpolated string into an angular object
                var linker = $compile(interpolated);
                var o = linker(cellScope);
    
                // Create a guid to identify this object
                var guid = guidGenerator.create();
    
                // Set this guid to that object as an attribute
                o.attr("guid", guid);
    
                // Store that Angular object into a dictionary
                templates[guid] = o;
    
                // Returns the generated HTML: this is just so the grid displays the generated template right away, but if any event is bound to it, they won't work just yet
                return o[0].outerHTML;
            };
    
            col.asyncPostRender = function(cellNode, row, dataContext, colDef) {
    
                // From the cell, get the guid generated on the formatter above
                var guid = $(cellNode.firstChild).attr("guid");
    
                // Get the actual Angular object that matches that guid
                var template = templates[guid];
    
                // Remove it from the dictionary to free some memory, we only need it once
                delete templates[guid];
    
                if (template) {
                    // Empty the cell node...
                    $(cellNode).empty();
                    // ...and replace its content by the object (visually this won't make any difference, no flicker, but this one has event bound to it!)
                    $(cellNode).append(template);
    
                } else {
                    console.log("Error: template not found");
                }
            };
        }
    });
    

    The column can be defined as such:

    { name: '', template: '<button ng-click="delete(context)" class="btn btn-danger btn-mini">Delete {{context.user}}</button>', width:80}
    

    The context.user will be properly interpolated (thanks to $interpolate) and the ng-click will be working thanks to $compile and the fact that we use the real object and not the HTML on the asyncPostRender.

    This is the full directive, followed by the HTML and the controller:

    Directive:

    (function() {
        'use strict';
    
        var app = angular.module('xweb.common');
    
        // Slick Grid Directive
        app.directive('slickGrid', function ($compile, $interpolate, guidGenerator) {
            return {
                restrict: 'E',
                replace: true,
                template: '<div></div>',
                scope: {
                    data:'=',
                    options: '=',
                    columns: '='
                },
                link: function (scope, element, attrs) {
    
                    var cols = angular.copy(scope.columns);
                    var templates = new Array();
    
                    // Special Sauce: Allow columns to have an angular template
                    // in place of a regular slick grid formatter function
                    angular.forEach(cols, function (col) {
    
                        if (angular.isDefined(col.template)) {
    
                            col.formatter = function (row, cell, value, columnDef, dataContext) {
    
                                // Create a new scope, for each cell
                                var cellScope = scope.$parent.$new(false);
                                cellScope.value = value;
                                cellScope.context = dataContext;
    
                                // Interpolate (i.e. turns {{context.myProp}} into its value)
                                var interpolated = $interpolate(col.template)(cellScope);
    
                                // Compile the interpolated string into an angular object
                                var linker = $compile(interpolated);
                                var o = linker(cellScope);
    
                                // Create a guid to identify this object
                                var guid = guidGenerator.create();
    
                                // Set this guid to that object as an attribute
                                o.attr("guid", guid);
    
                                // Store that Angular object into a dictionary
                                templates[guid] = o;
    
                                // Returns the generated HTML: this is just so the grid displays the generated template right away, but if any event is bound to it, they won't work just yet
                                return o[0].outerHTML;
                            };
    
                            col.asyncPostRender = function(cellNode, row, dataContext, colDef) {
    
                                // From the cell, get the guid generated on the formatter above
                                var guid = $(cellNode.firstChild).attr("guid");
    
                                // Get the actual Angular object that matches that guid
                                var template = templates[guid];
    
                                // Remove it from the dictionary to free some memory, we only need it once
                                delete templates[guid];
    
                                if (template) {
                                    // Empty the cell node...
                                    $(cellNode).empty();
                                    // ...and replace its content by the object (visually this won't make any difference, no flicker, but this one has event bound to it!)
                                    $(cellNode).append(template);
    
                                } else {
                                    console.log("Error: template not found");
                                }
                            };
                        }
                    });
    
                    var container = element;
                    var slickGrid = null;
                    var dataView = new Slick.Data.DataView();
    
                    var bindDataView = function() {
                        templates = new Array();
    
                        var index = 0;
                        for (var j = 0; j < scope.data.length; j++) {
                            scope.data[j].data_view_id = index;
                            index++;
                        }
    
                        dataView.setItems(scope.data, 'data_view_id');
                    };
    
                    var rebind = function() {
    
                        bindDataView();
    
                        scope.options.enableAsyncPostRender = true;
    
                        slickGrid = new Slick.Grid(container, dataView, cols, scope.options);
                        slickGrid.onSort.subscribe(function(e, args) {
                            console.log('Sort clicked...');
    
                            var comparer = function(a, b) {
                                return a[args.sortCol.field] > b[args.sortCol.field];
                            };
    
                            dataView.sort(comparer, args.sortAsc);
                            scope.$apply();
                        });
    
                        slickGrid.onCellChange.subscribe(function(e, args) {
                            console.log('Cell changed');
                            console.log(e);
                            console.log(args);
                            args.item.isDirty = true;
                            scope.$apply();
                        });
                    };
    
                    rebind();
    
                    scope.$watch('data', function (val, prev) {
                        console.log('SlickGrid ngModel updated');
                        bindDataView();
                        slickGrid.invalidate();
                    }, true);
    
                    scope.$watch('columns', function (val, prev) {
                        console.log('SlickGrid columns updated');
                        rebind();
                    }, true);
    
                    scope.$watch('options', function (val, prev) {
                        console.log('SlickGrid options updated');
                        rebind();
                    }, true);
                }
            };
        });
    
    })();
    

    The HTML:

    <slick-grid id="slick" class="gridStyle"  data="data" columns="columns" options="options" ></slick-grid>
    

    The controller:

    $scope.data = [
                { spreadMultiplier: 1, supAmount: 2, from: "01/01/2013", to: "31/12/2013", user: "jaussan", id: 1000 },
                { spreadMultiplier: 2, supAmount: 3, from: "01/01/2014", to: "31/12/2014", user: "camerond", id: 1001 },
                { spreadMultiplier: 3, supAmount: 4, from: "01/01/2015", to: "31/12/2015", user: "sarkozyn", id: 1002 }
            ];
    
    // SlickGrid Columns definitions
    $scope.columns = [
        { name: "Spread Multiplier", field: "spreadMultiplier", id: "spreadMultiplier", sortable: true, width: 100, editor: Slick.Editors.Decimal },
        { name: "Sup Amount", field: "supAmount", id: "supAmount", sortable: true, width: 100, editor: Slick.Editors.Decimal },
        { name: "From", field: "from", id: "from", sortable: true, width: 130, editor: Slick.Editors.Date },
        { name: "To", field: "to", id: "to", sortable: true, width: 130, editor: Slick.Editors.Date },
        { name: "Added By", field: "user", id: "user", sortable: true, width: 200 },
        { name: '', template: '<button ng-click="delete(context)" class="btn btn-danger btn-mini">Delete</button>', width:80}
    ];
    
    // SlickGrid Options
    $scope.options = {
        fullWidthRows: true,
        editable: true,
        selectable: true,
        enableCellNavigation: true,
        rowHeight:30
    };
    

    Important:

    on the rebind() method, notice the

    scope.options.enableAsyncPostRender = true;
    

    This is very important to have that, otherwise the asyncPostRender is never called.

    Also, for the sake of completeness, here is the GuidGenerator service:

    app.service('guidGenerator', function() {
            this.create = function () {
    
                function s4() {
                    return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
                }
    
                function guid() {
                    return (s4() + s4() + "-" + s4() + "-" + s4() + "-" + s4() + "-" + s4() + s4() + s4());
                }
    
                return guid();
            };
        });