Практикум AngularJS — разработка административной панели (часть 2)

    В первой части был реализован базовый функционал админки на javascript-фреймворке AngularJS — загрузка данных из бэкенда, добавление/изменение записей. Во второй части мы рассмотрим реализацию сортировки таблицы и разбитию по страницам, удаление записей.


    Сортировка


    Дополним массив tablehead в контроллере ListCtrl в файле controllers.js для установки порядка сортировки по-умолчанию. Числа больше 0 — сортировка по возрастанию, меньше 0 — по убыванию. Модуль числа показывает порядок сортировки по столбцам.
    ...
      $scope.tablehead = [
        {name:'title',    title:"Заголовок",  sort:-2},
        {name:'category', title:"Категория",  list:$scope.categories,  sort:1},
        {name:'answerer', title:"Кому задан", list:$scope.answerers},
        {name:'author',   title:"Автор"},
        {name:'created',  title:"Задан"},
        {name:'answered', title:"Отвечен"},
        {name:'shown',    title:"Опубликован"}
      ];
    ...
    

    Сортировка должна работать при щелчке по заголовку, значит, к нему и будем прицеплять функционал. На AngularJS это очень просто. Заменим заголовок таблицы в шаблоне list.html:
    ...
    <thead>
      <tr ng-mousedown="$event.preventDefault()" onselectstart="return false">
        <th ng-repeat="head in tablehead" ng-click="sortReorder(head.name,$event)" ng-class="{'sort-asc':head.sort>0,'sort-desc':head.sort<0}">{{head.title}}</th>
      </tr>
    </thead>
    <tbody>
      <tr ng-repeat="item in items | filterEx:tablehead:filter | orderByEx:tablehead:sortBy()">
    ...
    

    И добавим в контроллер функции сортировки:
    ...
    $scope.sortBy = function() {
      var order = [];
      angular.forEach($scope.tablehead, function(h){
        if (h.sort>0) order[h.sort-1] = h.name;
        if (h.sort<0) order[Math.abs(h.sort)-1] = '-'+h.name;
      });
      return order;
    };
    $scope.sortReorder = function(col,e) {
      if (e.shiftKey) {
        var sortIndex = 0;
        angular.forEach($scope.tablehead, function(el) {
          if (Math.abs(el.sort)>sortIndex) sortIndex = Math.abs(el.sort);
        });
        angular.forEach($scope.tablehead, function(el) {
          if (el.name==col) el.sort = el.sort?-el.sort:sortIndex+1;
        });
      } else {
        angular.forEach($scope.tablehead, function(el) {
          if (el.name==col) el.sort = el.sort>0?-1:1; else el.sort = null;
        });
      }
    };
    ...
    

    Функция sortBy() вызывается непосредственно при сортировке фильтром orderByEx, и возвращает названия столбцов в нужном порядке, знак минус указывает на обратную сортировку. Функция sortReorder() переупорядочивает сортировку, с зажатой клавишей Shift можно добавлять новый столбец, повторное нажатие на выбранный столбец меняет порядок сортировки этого столбца.
    В данной задаче я опять столкнулся с тем, что встроенный фильтр orderBy берёт для сортировки исходные данные колонок до подстановки, и сортирует столбцы Категория и Отвечающий некорректно. Я выдрал скопировал из кода AngularJS код фильтра orderBy и внёс в него изменения. Изменения незначительны (добавлены 4 строчки, вызывающие компаратор с нужными данными), поэтому приводить его здесь не буду (можно посмотреть код фильтра на GitHub-е).

    Разбиение на страницы


    Важной функцией является разбитие большой таблицы на страницы. Для этого дополним контроллер ListCtrl (файл /js/controllers.js):
    ...
    $scope.paginator = {
      count: 5, // кол-во записей на странице
      page:  1, 
      pages: 1,
      setPages: function(itemsCount){ this.pages = Math.ceil(itemsCount/this.count); }
    };
    
    $scope.items = Items.query(function(data){
      $scope.paginator.setPages($scope.items.length); // добавлена эта строчка
      var i = 0;
      angular.forEach(data, function(v,k) { data[k]._id = i++; });
    });
    
    $scope.$watch('items',function() {
      $scope.paginator.setPages($scope.items.length);
    });
    $scope.$watch('paginator.page',function() {
      if ($scope.paginator.page<1) $scope.paginator.page = 1;
      if ($scope.paginator.page>$scope.paginator.pages)
        $scope.paginator.page = $scope.paginator.pages;
      angular.forEach($scope.items, function(v,k) { $scope.items[k].selected = false; });
    });
    ...
    

    В данном коде интересна функция $watch() — она вызывается при любом изменении указанного в ней выражения (подробнее о функции $watch). Интересно было поэкспериментировать с переменной this внутри функции…
    Обратите внимание, что метод paginator.setPages() вызывается дважды — колбэке загрузки Items и в функции $watch('items'). Дело в том, что $scope.items = Items.query() возвращает promise-объект, на присвоение которого $watch срабатывает, а вот далее при подгрузке в него данных — уже нет, так как происходят внутренние изменения promise-объекта.
    Добавим несколько строк в шаблон list.html. Дополним итератор рядов таблицы:
    ...
    <tr ng-repeat="item in items | filterEx:tablehead:filter | orderByEx:tablehead:sortBy() | showPage:paginator">
    ...
    

    И добавим кнопочки управления страницами после таблицы:
    ...
    <div id="table-tools">
      <div class="pull-left">
      Показано {{Math.min(paginator.count,items.length)}} записей из {{items.length}}
      </div>
      <div class="controls input-append pull-right">
        <input type="button" ng-click="paginator.page=1" class="btn btn-small" value="<<">
        <input type="button" ng-click="paginator.page=paginator.page-1 || 1" class="btn btn-small" value="<">
        <input ng-model="paginator.page" class="paginator-page"><span class="add-on">из {{paginator.pages}} стр.</span>
        <input type="button" ng-click="paginator.page=Math.min(paginator.page+1,paginator.pages)" class="btn btn-small" value=">">
        <input type="button" ng-click="paginator.page=paginator.pages" class="btn btn-small" value=">>">
      </div>
      <div class="clear"></div>
    </div>
    ...
    

    В обработчиках нажатия кнопок стоят условные выражения, ограничивающие диапазон изменения страниц и уменьшающие количество вызовов функции-обработчика $watch().
    Ну и код фильтра showPage, в файле filters.js:
    ...
      .filter('showPage', function() {
        return function(list, paginator) {
          if (paginator.page<1) paginator.page = 1;
          if (paginator.count<1) paginator.count = 1;
          if (paginator.pages && paginator.page>paginator.pages) paginator.page = paginator.pages;
          return list.slice(paginator.count*(paginator.page-1), paginator.count*paginator.page);
        };
      });
    

    Код достаточно очевиден, пояснять его не вижу смысла.

    Выделение и удаление строк


    Осталось совсем чуть-чуть. Напишем код, позволяющий выделять и удалять строки. В шаблоне list.html (в последний раз) поменяем итератор строк, добавив обработчик кликов и добавление класса для визуального выделения записи:
    ...
    <tr ng-repeat="item in items | filterEx:tablehead:filter | orderByEx:tablehead:sortBy() | showPage:paginator"
        ng-click="selectItem($event)" ng-class="item.selected && 'selected'">
    ...
    

    Добавим пару кнопок перед таблицей (в раздел div.tools):
    ...
    <button ng-click="deleteItem(1)" class="btn btn-danger" ng-show="selected.length==1">Удалить выделенную запись</button>
    <button ng-click="deleteItem()" class="btn btn-danger" ng-show="selected.length>1">Удалить выделенные записи ({{selected.length}})</button>
    ...
    

    Эти кнопки показываются (ng-show==true), если выбрана одна или несколько записей.

    И добавим пару функций в контроллер ListCtrl:
    ...
      $scope.selected  = [];
      $scope.deleteItem = function(one) {
        if (one) {
          var _id = $scope.selected[0];
          Items['delete']({id:$scope.items[_id].id}, function() {
            $scope.items.splice(_id,1);
            $scope.selected = [];
          });
        } else {
            var ids = [];
            angular.forEach($scope.selected, function(_id) { ids.push($scope.items[_id].id); });
            Items['delete']({ids:ids}, function(){
              angular.forEach($scope.selected, function(_id) { $scope.items.splice(_id,1); });
              $scope.selected = [];
            });
          }
      };
      $scope.selectItem = function(e) {
        if ((e.target||e.srcElement).tagName!='TD') return;
        var state = this.item.selected = !this.item.selected, _id = this.item._id;
        if (state) $scope.selected.push(_id);
          else angular.forEach($scope.selected, function(v,k) {
                 if (v==_id) { $scope.selected.splice(k,1); return false; }
               });
      };
    ...
    

    Функция selectItem() устанавливает свойство selected элемента и добавляет его номер в специальный массив $scope.selected. Кстати, номер элемента находится в его свойстве _id, которое мы заполняем при получении элементов от бэкенда, сам AngularJS его не добавляет. Функция deleteItem() удаляет, соответственно, элементы, перечисленные в массиве $scope.selected. Используется встроенный в объект $resource метод delete(). (Вызывается он с помощью выражения Items['delete'](), а не Items.delete(), потому что моя IDE считает, что delete — встроенный оператор Javascript-а, и некрасиво показывает ошибку… Но все ведь знают, что в случае объектов Items.delete===Items['delete'])

    Прочее


    Дополню рассказ теми моментами, которые не вошли в основной текст.
    1. В шаблонах в {{фигурных скобках}} доступны переменные (и функции), объявленные как свойства объекта $scope конструктора.
    2. Глобальные объекты в шаблоне не доступны, для доступа к ним приходится явно присваивать их отдельному свойству, например, так: $scope.Math = Math; и потом в шаблоне использовать так: {{Math.min(a,b)}}.
    3. Не очень понятно, как обращаться к области видимости ($scope) другого контроллера. Наверняка, можно, но я пока не нашёл, как…
    4. Не очень понятно, как обращаться к области видимости контроллеров из кода, не помещённого в контроллер, например, из графических библиотек. Но так делать и не надо, ведь весь код должен содержаться в контроллерах…
    5. Функция $scope.$watch() не срабатывает, когда свойства-массивы обрабатываются функциями splice/push и подобными.

    Результат


    Рабочее демо доступно здесь: http://lexxpavlov.com/ng-admin/v2/ (read-only)
    Исходники можно посмотреть на GitHub: https://github.com/lexxpavlov/angular-admin/
    Поделиться публикацией

    Комментарии 10

      +1
      Вот что плохо в ангуларе, что такие простые вещи надо писать кучей кода. Неужели нет готовой таблицы с сортировкой и т.д.?
      Это же не суперкастомное приложение в конце концов.
        –1
        Если бы не подстановочные поля, то всё из коробки работает. Здесь можно было бы поступить проще — подставлять поля на сервере или при получении данных, и тогда можно было бы не городить с кастомными фильтрами. Но в этом случае (частично) потеряется эффективность работы с данными через $resource. Ну и в образовательных целях всё это сделано было.
        Ну и не стоит забывать про извечное противостояние гибкости и простоты…
          +1
          Ну вот, на мой взгляд, Вы перемудрили со сложностью. Немного отпугивают нагромождения кода, AngularJS как таковой теряется. А все из-за этих «подстановочных полей»…
        0
        1. и 2. Может быть это и не очень принципиально, но, на мой взгляд, было бы корректнее сказать, что внутри {{ }}, а также многих директив используются angular expressions, которые в общем-то не тоже самое, что и JS expressions.
        3. А зачем? Хорошим тоном является взаимодействие контроллеров через сервисы.
        4. Если я правильно понял вопрос, то вот.
        5. Обратите внимание на третий параметр $watch — objectEquality. Пример.
          0
          я немного с опозданием наткнулся на статью, но не проще ли было сделать сортировку как здесь jsFiddle?
            0
            там сортировка только по одному столбцу
              0
              вчера сделал по нескольким, все еще проще: angular api и найти там orderBy
                0
                Не всё так просто. Нативный orderBy оперирует теми данными, что стоят в ng-repeat, и сортирует по полям полученных записей. В моём примере в полях «Категория» и «Кому задан» стоят числа — индексы массивов категорий и отвечающих. Нативная сортировка проходит по этим числовым индексам, а не по значениям из массивов (он же про них ничего не знает).
                Можно, конечно, при получении данных подставлять строки вместо их индексов, и нативная сортировка будет работать. Иногда так даже и лучше сделать. Но я в примере не делал так, чтобы не менять формат исходных полученных с сервера данных. Такой подход позволяет обновлять записи на сервере изменением значения полей записи и простым вызовом item.$save().
            0
            angular-ui.github.com/ А вот это кто нибудь юзал?
              0
              Не все решения там хороши, но заглянуть в реализации очень полезно — узнал для себя много нового из того, чего нет или невозможно найти в документации.

              И да, вообще на данный момент документация — одна из главных проблем фреймворка. Код документирован прекрасно, но на офф.сайте есть далеко не все.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое