显示标签为“Nested Table”的博文。显示所有博文
显示标签为“Nested Table”的博文。显示所有博文

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

2015年9月16日星期三

Bootstrap Nested Table

Bootstrap Nested Table

In my recent project, I came across a problem to make bootstrap like table that can fit in for a nested table to display some sub items in each row.The problem for a normal <table> is that it doesn't provide to much flexibility for hierarchy structured data.It will need to create a separate row with a spanned column to store a nested table. So I came up with this <div> solution.

Name
Date
Description
Amount
Quantity
Test Name
2013-05-10T15:04:44.593
Don't use data attributes from multiple plugins on the same element.
$20
2
Name
Date
Description
Amount
Quantity
Test Name
2013-05-10T15:04:44.593
Don't use data attributes from multiple plugins on the same element.
$20
2
Test Name
2013-05-10T15:04:44.593
Don't use data attributes from multiple plugins on the same element.
$20
2
Test Name
2013-05-10T15:04:44.593
Don't use data attributes from multiple plugins on the same element.
$20
2

Table Template

The first thing is to create table like template which has similar table structure. And each line has two containers, "main" container for certain columns and "sub" for nested table.

<div class="table">
  <div class="header">
    <div class="line">
      headers...
    </div>
  </div>
  <div class="body">
    <div class="line">
      <div class="main">
        columns...
      </div>
      <div class="sub">
        <div class="table">
          ...
        </div>
      </div>
    </div>
  </div>
</div>

CSS

The major problem of the <div> table approach is, the column elements cannot auto align and adjust their widths. So a width value must be manually set to each element, which can be a fixed value or precentage value. For fixed value, "overflow:auto" must be set in case of the total width exceed its parent element. For precentage value, it's better to have to total value to be 100%. And also, an indent value needs to be set. It's better but not necessary to be the width of the first column.And then, to make to be consistent with Bootstrap table style and configurable for further table styles. The following styles are needed. I've also add some styles to make it to be fitting to the basic Bootstrap table style.

.header>.line>div:nth-child(1), .body>.line>.main>div:nth-child(1){
  width:5%;
}
.header>.line>div:nth-child(2), .body>.line>.main>div:nth-child(2){
  width:15%;
}
.header>.line>div:nth-child(3), .body>.line>.main>div:nth-child(3){
  width:15%;
}
.header>.line>div:nth-child(4), .body>.line>.main>div:nth-child(4){
  width:45%;
}
.header>.line>div:nth-child(5), .body>.line>.main>div:nth-child(5){
  width:10%;
}
.header>.line>div:nth-child(6), .body>.line>.main>div:nth-child(6){
  width:10%;
}
.sub{
  padding-left:5%;
}
.body>.line a{
  color:#222;
  font-size:12px;
}
.header{
  font-weight:bold;
}
.header>.line{
  border-bottom:solid 2px #AAA;
}
.header>.line>div, .body>.line>.main>div{
  float:left;
  padding:8px;
}
.header>.line:before, .body>.line>.main:before, .header>.line:after, .body>.line>.main:after{
  content:' ';
  display:table;
}
.header>.line:after, .body>.line>.main:after{
  clear:both;
}
.table>.body>.line:not(:first-child)>.main{
  border-top:solid 1px #AAA;
}
.table>.body>.line.open>.main{
  border-bottom:solid 1px #AAA;
}

Javascript

Here it needs a little javascript to expand and unexpand nested tables. In real practice, it will also need to define whether the button shows or hides.

function togglecollapse(e){
  e.preventDefault();
  $(e.currentTarget.parentNode.parentNode.parentNode).toggleClass('open');
}

Next time I will write an article to demonstrate how I implement this template with some MVC approach.