One-off rendering of an angular template string

3.8k views Asked by At

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);
        }
      });
    }
  };
}]);
2

There are 2 answers

3
Antoine Jaussoin On BEST ANSWER

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();
        };
    });
0
Mika On

I haven't tried to use a template, but I use the formatter in angular.

In the columns definition I used a string for the formatter:

// Column definition: 
{id: 'money', name: 'Money', field: 'money', sortable: true, formatter: 'money'}

In the directive (or service [It depends of your architecture of your slickgrid implementation]) you could use for example:

var val = columns.formatter; // Get the string from the columns definition. Here: 'money'
columns.formatter = that.formatter[val]; // Set the method

// Method in directive or service
this.formatter = {
  //function(row, cell, value, columnDef, dataContext)
  money: function(row, cell, value){
    // Using accounting.js
    return accounting.formatNumber(value, 2, '.', ',');
  }
}

I think when you use the same way in a directive to implement a template it just runs fine.
Btw: You could implement slick.grid.editors the same way...

Statement to the Comment from 'Simple As Could Be': In my experience when you use a directive with a css class (Columns definition: cssClass) you have to use $compile everytime an event happen (onScroll, aso)... The performance is terrible with this solution...

My solution of implementing formatters and editors in angular is not great but there is no big performance bottleneck.