Здраствуйте! Хотел бы поделиться с вами разработкой подтаблиц для нашего веб проекта. Цель заключалась в воссоздании веб модуля, имитирующего таблиц и подтаблиц (subdatasheet) созданных на базе Аccess. Наш клиент привык работать на Access'e, но времена меняются, и теперь наша задача заключается в гладком переходе на веб платформу, с минимальной разницей.


Почему AngularJS?


Имея не много опыта с различными javascript библиотеками, пришел к выводу что AngularJS изначально принуждает вашему проекту быть маленьким, чистым, изолированным и легко расширяемым. Также, используя directive со своим изолированным скопам (scope), дает возможность многоразового применения, даже внутри себя. Что и будет продемонстрировано под катом.


Как наш итоговый directive будет применяться


Так как в нашем проекте будет много таких подтаблиц, нам нужно сделать удобным нашу утилиту в применении. Должно быть примерно таким:


<div ng-controller="ctrl1">
    <subgrid config="config1"> </subgrid>
</div>

Посмотрите демо здесь. Кому интересно прошу под кат.



.controller('ctrl1', function(){
   ///...

   $scope.config1 = {
    t1:{
      subgrid:true,
      width:300, 
      height:200,
      config:[
        {
          title:"Filed 1",
          map:"field1"
        },
        {
          title:"Filed 2",
          map:"field2"
        },
        {
          title:"Filed 3",
          map:"field3"
        },
        {
          title:"Filed 4",
          map:"field4"
        },
        {
          title:"Filed 5",
          map:"field5"
        },
        {
          title:"Filed 6",
          map:"field6"
        }
        ],
        t:"",
        load:function(id, idx){
          $p.ajax($mock.data1,200).then(function(d){
            $scope.config1.t2.init(d); 
          },function(d){
            $scope.config1.t2.timeout(d);
          });
        }

      },
    t2: {
      subgrid:true,
      width:200, 
      height:100,
      config:[
      {
        title:"Filed 1",
        map:"field1"
      },
      {
        title:"Filed 2",
        map:"field2"
      }
      ],
      t:"",
      load:function(id, idx){
        $p.ajax($mock.data1,200).then(function(d){
          $scope.config1.t3.init(d); 
        },function(d){
            $scope.config1.t3.timeout(d);
          });
      }
    },
    t3: {
      subgrid:false,
      width:200, 
      height:100,
      config:[
      {
        title:"Filed 1",
        map:"field1"
      },
      {
        title:"Filed 2",
        map:"field2"
      }
      ],
      t:""
    }
  };

   /// ---
});

Все просто! Задаем директив, присоединяем объект конфигурации к нему, и таблица с гнездом подтаблиц готова.


Теперь о самом коде


Рассмотрим наш директив:


.directive('subgrid', ['$timeout','$compile',function($timeout,$compile) {
    return {
      restrict: 'E',
      scope: {
        config: '=',
        count: '='
      },

      templateUrl: 'subgrid.html',
      link: function(scope, elem, attr, ngModelCtrl) {
          scope.endrender=function(){
            $timeout(function(){
              scope.render = false;
            },1);
          }
          scope.expanded = false;
          scope.expandedid = null;
          scope.cnt = scope.count?scope.count:1;
          scope.cnf = scope.config["t"+scope.cnt];
          scope.guid = guid();
          scope.$watch('cnf.t',function() {
              scope.render = true;
          }, true);
          scope.cnf.timeout = function(error){
              scope.cnf.subgrid = false;
              scope.cnf.config = [{title:"Message",map:"field1"}];
              scope.cnf.t = {RowCount:1, field1:[error],index:[1]};
          }
          scope.cnf.init = function(d){
            scope.cnf.t = "";
            $timeout(function(){
              scope.cnf.t = d;
            },1);
          }

          scope.expander = function(id, idx){
            //if not same row
            if(scope.cnf.subgrid)
            if(id!==scope.expandedid){
                angular.element(elem[0].querySelector("#"+scope.expandedid)).children().eq(0).children().text("+");
                angular.element(elem[0].querySelector("#"+scope.guid+'sub')).remove();
                scope.expandedid = id;
                var count = scope.cnt + 1;
                var tr = angular.element(elem[0].querySelector("#"+id));
                tr.children().eq(0).children().text("-");
                var exid = scope.guid+'sub';
                tr.after($compile("<tr id='"+exid+"'><td colspan ='{{cnf.config.length+1}}' style='padding:10px;'><subgrid count='"+count +"' config='config'></subgrid></td></tr>")(scope));
                if (typeof scope.cnf.load === "function") { 
                    scope.config["t"+count].t = "";
                    scope.cnf.load(id, idx);
                }
                scope.expanded = true;
            }
            else{
              if(scope.expanded){
                angular.element(elem[0].querySelector("#"+id)).children().eq(0).children().text("+");
                angular.element(elem[0].querySelector("#"+scope.guid+'sub')).remove();
                scope.expanded = false;
                scope.expandedid = null;
              }
              else{
                scope.expanded = true;
                scope.expandedid = id;
                var count = scope.cnt + 1;
                var tr = angular.element(elem[0].querySelector("#"+id));
                tr.children().eq(0).children().text("-");
                var exid = scope.guid+'sub';
                tr.after($compile("<tr id='"+exid+"'><td colspan ='{{cnf.config.length+1}}' style='padding:10px;'><subgrid count='"+count +"' config='config'></subgrid></td></tr>")(scope));
                if (typeof scope.cnf.load === "function") { 
                    scope.config["t"+count].t = "";
                    scope.cnf.load(id, idx);
                }
              }
            }
          }

          function guid() {
              function s4() {
                return Math.floor((1 + Math.random()) * 0x10000)
                  .toString(16)
                  .substring(1);
              }
              return "id"+s4() + s4();

          }

      }
    };
  }]);

Пройдусь по порядку в крации:


{
      restrict: 'E',
      scope: {
        config: '=',
        count: '='
}

И так, лимитируем тип нашего директива как элемент Е. Нет нужды делать его разно-типным, чтоб было меньше конфузии.


config:'=', count: '='

нужны для инъекции объектов из контроллера (controller) в определенный директив.


scope.endrender=function(){
            $timeout(function(){
              scope.render = false;
            },1);
          }
          scope.$watch('cnf.t',function() {
              scope.render = true;
          }, true);

endrender используется для того чтобы спрятать процесс строения самой таблицы, так как запоздалость запросов с дальних серверов дает некрасивые эффектные последствия, которые легче заменить красивым спинером. scope.render активируется после того как последняя строка таблицы сконструировалась. scope.$watch слушает каждое изменение таблицы и деактивирует scope.render до его окончания.


scope.expander = function(id, idx){
            //if not same row
            if(scope.cnf.subgrid)
            if(id!==scope.expandedid){
                angular.element(elem[0].querySelector("#"+scope.expandedid)).children().eq(0).children().text("+");
                angular.element(elem[0].querySelector("#"+scope.guid+'sub')).remove();
                scope.expandedid = id;
                var count = scope.cnt + 1;
                var tr = angular.element(elem[0].querySelector("#"+id));
                tr.children().eq(0).children().text("-");
                var exid = scope.guid+'sub';
                tr.after($compile("<tr id='"+exid+"'><td colspan ='{{cnf.config.length+1}}' style='padding:10px;'><subgrid count='"+count +"' config='config'></subgrid></td></tr>")(scope));
                if (typeof scope.cnf.load === "function") { 
                    scope.config["t"+count].t = "";
                    scope.cnf.load(id, idx);
                }
                scope.expanded = true;
            }
            else{
              if(scope.expanded){
                angular.element(elem[0].querySelector("#"+id)).children().eq(0).children().text("+");
                angular.element(elem[0].querySelector("#"+scope.guid+'sub')).remove();
                scope.expanded = false;
                scope.expandedid = null;
              }
              else{
                scope.expanded = true;
                scope.expandedid = id;
                var count = scope.cnt + 1;
                var tr = angular.element(elem[0].querySelector("#"+id));
                tr.children().eq(0).children().text("-");
                var exid = scope.guid+'sub';
                tr.after($compile("<tr id='"+exid+"'><td colspan ='{{cnf.config.length+1}}' style='padding:10px;'><subgrid count='"+count +"' config='config'></subgrid></td></tr>")(scope));
                if (typeof scope.cnf.load === "function") { 
                    scope.config["t"+count].t = "";
                    scope.cnf.load(id, idx);
                }
              }
            }
          }

          function guid() {
              function s4() {
                return Math.floor((1 + Math.random()) * 0x10000)
                  .toString(16)
                  .substring(1);
              }
              return "id"+s4() + s4();

          }

expander является главным хэндлером при развертки таблицы на подтаблицу. Главной фишкой для генерирования новой подтаблицы, это динамически внедрять наш собственный элемент внутри себя. Но стоит заметить, что при внутреннем использовании мы добавляем атрибуту count. Это для того чтобы различать какую таблицу из нашей конфигурации config1, директив должен использовать какую таблицу t1, t2 и т.д..


guid просто напросто назначает уникальный ID на каждую строку в каждой таблице. Чтоб мы уверенно могли менять/удалять нужную нами строку в нужной нами таблице.


Шаблон


<div class="t-datasheet" ng-class="{'spinner':render}" ng-style="{'width':cnf.width+'px','height':cnf.height+'px'}">
<table ng-hide="render">
  <thead >
    <tr>
        <td>#</td>
        <td  ng-repeat="c in cnf.config" ng-cloak>{{c.title}}</td>
    </tr>
  </thead>
  <tbody >

   <tr id="{{guid+i}}" ng-repeat="i in cnf.t.index" ng-init="($last && endrender())">
       <td ng-click="expander(guid+i,i)" ><span ng-show="cnf.subgrid">+</span></td>
       <td ng-repeat="c in cnf.config" ng-cloak>{{cnf.t[c.map][i-1]}}</td>
    </tr>
  </tbody>
</table>
</div>

Шаблон получился простым и динамичным, где таблица имеет возможность динамически расширять столбцы по мере конфигурации config1.


Все остальное что не описано здесь, это утилиты использованные для тестирования этого кода (например: вместо реальных http запросов я использовал промисы, и задал каждому из них различный таймаут).


Данный проект является прототипом моей дальнейшей разработки. И делюсь с целью узнать о ваших мнениях и советах как усовершенствовать его.


> Демо

Поделиться с друзьями
-->

Комментарии (17)


  1. svekl
    02.03.2017 18:45
    +2

    Простите за занудство, но после выхода angular 1.6 не следует использовать ng-controller, $scope и директивы, сегодня лучше использовать компоненты, плюс очень много кода можно заменить просто темплейтом, так же очень рекомендую почитать

    https://github.com/johnpapa/angular-styleguide/blob/master/a1/README.md

    очень дельные советы по поводу хорошего стиля angular приложения с пояснениями, почему так.


    1. alik_explore
      02.03.2017 21:17

      Спасибо за ссылку. Вы правы насчет Angular 2, но на данный момент наша база построена на старой версии. А рефакторить займет не мало времени.


      1. raveclassic
        02.03.2017 22:45
        +1

        Вы не сможете отрефакториться на ng2, придется все переписывать


    1. CripToniT
      02.03.2017 22:53

      Уточню, что компоненты появились в 1.5


      1. alik_explore
        02.03.2017 22:54

        Надо же даже не и не знал ), Похож ли он на концепт Angular 2?
        Спасибо


        1. CripToniT
          02.03.2017 23:03

          Подход один и тот же, но я бы не назвал это концептом. Сейчас мы используем связку Typescript + AngularJS 1.5, что очень похоже на Angular 2


        1. some_x
          03.03.2017 08:49
          +1

          Как можно писать на ангуляре, писать по нему статьи и при этом не читать его release notes?


  1. Trixon
    02.03.2017 21:15

    Имея не много опыта с различными javascript библиотеками, пришел к выводу что AngularJS изначально принуждает вашему проекту быть маленьким, чистым, изолированным и легко расширяемым.


    В голос. Далее в статье идёт тонна костылей с раздуванием ангуляровской абстракции, повествуя о том, как левой рукой почесать правое ухо. Почему не jQuery? Не «модно»?


    1. alik_explore
      02.03.2017 21:16

      При всем уважении к jQuery, в моем малом опыте я увидел большую разницу при рефакторинге одного из модулей написанных на jQuery. Tам, я заботился о каждом элементе в ручную, что делает мой код огромным по мере возрастания, особенно если у меня много параметров (смотря что вы пишите). В angular'e двух стороннее оповещение делает все за меня. Также, все мои итерации для менюшек и таблиц перешли в сам шаблон. А так, angular достаточно дружелюбен с jQuery, и они прекрасно работаю вместе. Даже если вы не подключите jQuery в ваш проект, Angular имеет свой встроенный jQLite с необходимыми методами. link


  1. raveclassic
    02.03.2017 22:38

    Вот, действительно,

    принуждает вашему проекту быть маленьким, чистым, изолированным и легко расширяемым
    — вы серьезно? Посмотрите в сторону React, что составить для себя полную картину, и иметь полное представление.


    1. alik_explore
      02.03.2017 22:52

      имхо. React не пробовал, но наслышен много


      1. raveclassic
        03.03.2017 01:02

        Рекомендую для расширения кругозора. Вы удивитесь насколько легко и элегантно решается описанная вами задача.


        1. Odrin
          03.03.2017 09:58
          +2

          На angular эта задача решается не менее легко и красиво, просто автор поста решил выстрелить себе в ногу.


    1. Odrin
      03.03.2017 09:55
      +1

      Посмотрите в сторону React

      — вы серьезно? React прекрасен сам по себе, но на практике бесполезен без redux, redux-thunk, react-redux. Любое простейшее действие вроде fetch'а данных текущего пользователя превращается в ад с написанием кода в 5(!) файлах (условно — actionType, reducer, action, ComponentContainer, Component). Это по Вашему маленький, чистый и понятный код?


      1. raveclassic
        03.03.2017 14:30

        Что-то я не понял, что вам мешает фетчить данные прямо в компоненте? Что вам мешает не использовать redux? Или Вы в проекте на ангуляре не разносите сущности по файлам?
        Если использовать ng2, ngrx/store вместе с ngrx/effects это превращается в такой же ад. Различие лишь в том, что в реакте я могу себе выбрать другой котел, а в ng — нет.


  1. jbubsk
    02.03.2017 22:55

    $scope и директивы? Вы шагаете назад? уж 2017 на дворе


    1. CripToniT
      02.03.2017 23:04

      Components + $ctrl?