2015年9月21日星期一

Nested Table using AngularJS and Bootstrap

Last time I created a bootstrap like nested table. Now I'm going to show you that how it's going to work with AngularJS. Let's say I got some JSON data from a RESTful service. BTW, AngularJS doesn't really need JQuery. But I simply attached the JQuery and use it whenever I need it.

$scope.data =
[{
  name: 'test name',
  createDate: '2013-05-10T15:04:44.593',
  description: "Don't use data attributes from multiple plugins on the same element.",
  amount: '$20',
  quantity: '2',
  children: [{
    name: 'test name',
    createDate: '2013-05-10T15:04:44.593',
    description: "Don't use data attributes from multiple plugins on the same element.",
    amount: '$20',
    quantity: '2'
  },
  {
    name: 'test name',
    createDate: '2013-05-10T15:04:44.593',
    description: "Don't use data attributes from multiple plugins on the same element.",
    amount: '$20',
    quantity: '2'
  }]
},
{
  name: 'test name',
  createDate: '2013-05-10T15:04:44.593',
  description: "Don't use data attributes from multiple plugins on the same element.",
  amount: '$20',
  quantity: '2',
  children: []
}];

And the angular way of doing this would be basically like this.

<div class="table">
  <div class="header">
    <div class="line">
      <div ng-repeat="(key, value) in data[0]" >{{key}}</div>
    </div>
  </div>
  <div class="body">
    <div class="line" ng-repeat="row in data" >
      <div class="main">
     <div ng-show="row.children && row.children.length" ><button.../div>
        <div ng-repeat="(key, value) in row" >{{value}}</div>
      </div>
      <div class="sub">
        <div class="table">
          ...
        </div>
      </div>
    </div>
  </div>
</div>

There are two major problems about this solution. It needs two definitions for each data in the array and columns to show. For each data, there needs an object to store some status, functions and maybe the data also. For columns, there could also store some definitions such as width, formatter. Fortunately, Angular also developed a lot more advanced solution called Angular UI-Grid that provides some pretty good concepts I can use for my own. So I took a bit of its concepts and implemented them to my template.

The column concept

So in the controller, I created an option object in which I put column definitions. But unlike UI-Grid, I put the data in the scope and passed them separately to the template instead of just one option object. Because the nested table will need to bind with child objects which is different from its parent.

var app = angular.module('demoApp', ['ngRoute']);
app.controller('demoController', ['$scope', '$timeout', function ($scope, $timeout) {
  $scope.options = {
    colDefs: [
      { name: "name", displayName: "Name", width: '15%' },
      { name: "createDate", displayName: "Create Date", width: '15%', cellFilter: "date:'yyyy-MM-dd'" },
      { name: "description", displayName: "Description", width: '35%' },
      { name: "quantity", displayName: "Quantity", width: '10%' },
      { name: "amount", displayName: "Total", width: '10%' }
    ]
  };
  
  $timeout(function(){
    $scope.options.data = [...];
  });
}]);

The table template directives

Then I will need to create four directives for table, row, header and column. Here I'm going to explain a bit about the row directive first. ng-repeat creates a child scope and store the iteration in it. that gives the perfect place to store the row definitions. So in table directive I looped the data array and created a separate object for each element. And then here came a problem. Since I created an array of row objects, ng-repeat will not be able to notice whether there is something change in the data array. So I will need to watch the data manually, and see if there is any data insertion and removal. The header and column directives are relative simple. They just needs to read the column definitions from the option, display the value or name in a proper format.

app.directive('uiTable', ['$rootScope', '$compile', '$parse', function ($rootScope, $compile, $parse) {
  return {
    restrict: 'A',
    scope: {
      options: "=uiTable",
      items: "=uiTableModel"
    },
    controller: ["$scope", "$element", "$attrs", function ($scope, $element, $attrs) {
      //template
      var template =  "<div class=\"table\">\n" +
              "  <div class=\"header\">\n" +
              "    <div class=\"line\">\n" +
              "      <div ng-repeat=\"colDef in options.colDefs\" grid-header-render ></div>\n" +
              "    </div>\n" +
              "  </div>\n" +
              "  <div class=\"body\" ng-show=\"rows.length\" >\n" +
              "    <div class=\"line\" ng-class=\"{ 'open': row.isOpened }\" ng-repeat=\"row in rows\" >\n" +
              "      <div class=\"main\" grid-row-render ></div>\n" +
              "      <div class=\"sub\" data-ui-table=\"options\" data-ui-table-model=\"row.entity[options.children]\" ></div>\n" +
              "    </div>\n" +
              "  </div>\n" +
              "   <div ng-hide=\"rows.length\" >No Data</div>\n" +
              "</div>";

      var row = function (entity) {
        this.entity = entity;
      };
      row.prototype.expand = function (e) {
        e.preventDefault();
        this.isOpened = !this.isOpened;
      };

      $scope.rows = [];
      $scope.options.children = $scope.options.children || 'children';
      //watch data
      var deregFunctions = [];
      var dataWatchFunction = function () {
        var newData = $scope.items, newRows = [];
        $.each(newData, function (i, entity) {
          var oldRows = $scope.rows.filter(function (o) { return o.entity == entity; });
          if (oldRows.length) {
            newRows.push(oldRows[0]);
          } else {
            newRows.push(new row(entity));
          }
        });
        $scope.rows = newRows;
      };
      if (!angular.isString($scope.options.data)) {
        deregFunctions.push($scope.$watch(function () { return $scope.items; }, dataWatchFunction));
        deregFunctions.push($scope.$watch(function () { return $scope.items.length; }, dataWatchFunction));
      }

      var $table = $compile(template)($scope);

      if ($scope.options.cssClass) {
        $table.addClass($scope.options.cssClass);
      }

      //Rendering template.
      $element.html('').append($table);
      //destory watch
      $scope.$on('$destroy', function () {
        deregFunctions.forEach(function (deregFn) { deregFn(); });
      });
    }]
  };
}])
.directive('gridRowRender', ['$rootScope', '$compile', '$parse', function ($rootScope, $compile, $parse) {
  return {
    restrict: 'A',
    link: function ($scope, $element, $attrs) {
      var template = $scope.options.rowTemplate || "<div ng-repeat=\"colDef in options.colDefs\" grid-col-render></div>";
      var $row = $compile(template)($scope);

      if ($scope.options.rowCssClass) {
        $row.addClass($scope.options.rowCssClass);
      }

      $element.append($row);
    }
  };
}])
.directive('gridColRender', ['$rootScope', '$compile', '$parse', function ($rootScope, $compile, $parse) {
  return {
    restrict: 'A',
    link: function ($scope, $element, $attrs) {
      var template = $scope.colDef.cellTemplate || "<div><span class=\"view\">{{row.entity[colDef.name] CELLFILTER }}</span></div>";
      template = template.replace("CELLFILTER", $scope.colDef.cellFilter ? "| " + $scope.colDef.cellFilter : "");
      var $cell = $compile(template)($scope);

      if ($scope.colDef.cellClass) {
        $cell.addClass($scope.colDef.cellClass);
      }

      if ($scope.colDef.width) {
        $cell.css('width', $scope.colDef.width);
      }

      $element.replaceWith($cell);
    }
  };
}])
.directive('gridHeaderRender', ['$rootScope', '$compile', '$parse', function ($rootScope, $compile, $parse) {
  return {
    restrict: 'A',
    link: function ($scope, $element, $attrs) {
      var template = $scope.colDef.cellHeaderTemplate || $scope.options.headerTemplate || "<div>{{ colDef.displayName || colDef.name }}</div>";

      var $row = $compile(template)($scope);

      if ($scope.colDef.headerClass) {
        $row.addClass($scope.colDef.cellClass);
      }

      if ($scope.colDef.width) {
        $row.css('width', $scope.colDef.width);
      }
        
      $element.replaceWith($row);
    }
  };
}]);

Sample

没有评论:

发表评论