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

в 9:59, , рубрики: AngularJS, javascript, Программирование, метки: , ,

Практикум AngularJS — разработка административной панелиПри создании сайтов часто возникает задача создания админки для редактирования контента. Задача, в общем, тривиальная, но сделать удобную админку не так-то просто.

Под удобством в первую очередь подразумевается возможность сортировки таблицы со списком материалов и работа без перезагрузки страницы. Если материалов в таблице становится много, то возникает задача разбивать её на страницы.

Всем известный jQuery-плагин tablesorter с tablesorterPager-ом и менее известный, но гораздо более функциональный DataTables хороши, но обладают некоторыми недостатками. Главный из них — сложность динамического добавления новых строк в таблицу (после добавления строки в таблицу, новая строка потеряется при следующем вызове сортировки). tablesorter вообще не даёт средств для добавления строки в свой кэш, DataTables предоставляет широкое и функциональное API для управления внутренним представлением таблицы, но это API довольно многословно и не очень гибко.

Хочу предоставить общественности реализацию админки на относительно новой javascript-фреймворке AngularJS. Будет создана страничка для редактирования списка вопросов, разбитых по категориям и отвечающим. В статье нет сравнения с другими подобными фреймворками, но нет и простого повторения официальной документации, я постараюсь поделиться своим опытом в использовании фреймворка и расскажу о нескольких интересных приёмах работы с ним.

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

Вступление

Несколько слов о фреймворке я всё-таки приведу. AngularJS представялет собой Javascript MVC-фреймворк, проект основан Google-ом. Включает в себя собственную высокоуровневую реализацию ajax, встроенные средства unit- и e2e-тестов (Jasmine для unit-тестирования, для end-to-end тестов запускается специальный сервер тестирования). Тестирование я рассматривать не буду, это тема отдельной статьи. Подробнее о фреймворке недавно написал aav в своём посте.

Впервые встретился с ним в статье «7 причин, почему AngularJS крут». К сожалению, кроме официальной документации (кстати, довольно неплохой), я нашёл только одну статью, описывающую работу с AngularJS (правда, не самую новую версию). Также для начального знакомства c фреймворком рекомендую пройти официальный тур.

Основы фреймворка AngularJS

Перейдём собственно к разработке админки. Индексный файл index.html загружается в браузер, и дальше мы с него никуда не уйдём, вся работа будет происходить с помощью динамической загрузки. Сам файл ничего особенного не содержит. В нём важно два момента – атрибут ng-app=«admin» тега <html> и раздел <div ng-view></div>, в который будут помещаться наши странички.

<!doctype html>
<html lang="ru" ng-app="admin">
<head>
  <meta charset="utf-8">
  <title>Admin page - Questions</title>
  <link rel="stylesheet" href="css/app.css"/>
  <link rel="stylesheet" href="css/bootstrap.css"/>
  <link rel="stylesheet" href="css/bootstrap-responsive.css"/>
</head>
<body>
  <div ng-view></div>
  <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min.js"></script>
  <script>!window.jQuery && document.write(unescape('%3Cscript src="/js/jquery.js"%3E%3C/script%3E'))</script>
  <script src="lib/angular/angular.js"></script>
  <script src="lib/angular/angular-resource.js"></script>
  <script src="js/app.js"></script>
  <script src="js/services.js"></script>
  <script src="js/controllers.js"></script>
  <script src="js/directives.js"></script>
</body>
</html>

Как можно увидеть, AngularJS оперирует расширенными атрибутами тегов – директивами. Директивы можно записывать несколькими способами, следующие записи идентичны: ng-app=”admin”, data-ng-app=”admin”, также существует ещё несколько методов. Также возможно разрабатывать свои директивы.

AngularJS предлагает разбивать код приложения по нескольким файлам. app.js – инициализация приложения, роутинг, services.js – создание различных сервисов, описание удалённых ресурсов (например, для ajax-загрузки данных), которые потом можно использовать в контроллерах, controllers.js – собственно контроллеры, filters.js – фильтры, используются при выводе данных, directives.js – создание собственных директив для html.
Файл app.js:

'use strict';

angular.module('admin', ['admin.services','admin.filters'])
  .config(['$routeProvider', function($routeProvider) {
    $routeProvider
      .when('/list', {template: 'views/list.html', controller: ListCtrl})
      .when('/new', {template: 'views/edit.html', controller: NewCtrl})
      .when('/edit/:id', {template: 'views/edit.html', controller: EditCtrl})
      .otherwise({redirectTo: '/list'});
  },
]);

Тут мы назначаем маршруты для наших вьюшек. Кстати, выглядеть это будет в виде myadmin.com/#/list (жалко, что не !#, который Гугл принял за стандарт для индексирования). Вьюшки я расположил в папку /views/ (в отличие от предлагаемого создателями /partials/). Интересно, что AngularJS предлагает везде включать строгий режим 'use strict' (в этой статье про use strict подробнее).

Дальше я приведу упрощённый вариант списка материалов, который по ходу статьи будет дополняться. Я считаю, что пошаговое развитие будет для читателей полезнее и понятнее.
Файл /views/list.html:

<div id="table-wrapper">
<div class="filter tools pull-right">
Фильтр <input ng-model="filterStr" class="search-query">
</div>
<div class="tools pull-left">
  <a href="#/new" class="btn btn-success">Создать новую запись</a>
</div>
<table class="table table-striped">
<thead>
  <tr>
    <th ng-repeat="head in tablehead" >{{head.title}}</th>
  </tr>
</thead>
<tbody>
  <tr ng-repeat="item in items | filter:filterStr">
    <td><a href="#/edit/{{item.id}}">{{item.title}}</a></td>
    <td>{{item.category}}</td>
    <td>{{item.answerer}}</td>
    <td>{{item.author}}</td>
    <td>{{item.created}}</td>
    <td>{{item.answered}}</td>
    <td><span class="disable-item" style="color:{{['red','green'][+item.shown]}};" ng-click="disableItem()">{{['выкл','вкл'][+item.shown]}}</span></td>
  </tr>
</tbody>
</table>
</div>

Здесь следует обратить внимание на конструкцию ['выкл','вкл'][+item.shown] – она подставляет строку из массива ['выкл','вкл'], в зависимости от значения item.shown (0 или 1), унарная операция «+» возвращает число – индекс массива. Пришлось записывать выбор нужной строки таким образом, так как AngularJS не позволяет использовать тренарный условный оператор (item.shown>0? 'вкл':'выкл') в фигурных скобках. Вместо выражения с массивом можно использовать выражение item.shown>0&&'вкл'||'выкл'. Надеюсь, в будущих версиях создатели добавят поддержку тренарных операторов. Конструкция item in items | filter:filterStr передаёт массив items во встроенную функцию filter, которая фильтрует переданные данные, возвращая только те элементы, в которых присутствует подстрока из переменной filterStr (определяемая элементом с атрибутом ng-model=«filterStr»).

Перейдём к контроллеру controllers.js:

'use strict';

function ListCtrl($scope, Items, Data) {
  $scope.items = Items.query(function(data){
    var i = 0;
    angular.forEach(data, function(v,k) { data[k]._id = i++; });
  });
  $scope.categories = Data('categories');
  $scope.answerers  = Data('answerers');

  $scope.tablehead = [
    {name:'title',    title:"Заголовок"},
    {name:'category', title:"Категория"},
    {name:'answerer', title:"Кому задан"},
    {name:'author',   title:"Автор"},
    {name:'created',  title:"Задан"},
    {name:'answered', title:"Отвечен"},
    {name:'shown',    title:"Опубликован"}
  ];

  $scope.disableItem = function() {
    var item = this.item;
    Items.toggle({id:item.id}, function() { if (data.ok) item.shown = item.shown>0?0:1; });
  };
}

В данной функции параметры: $scope – область видимости переменных в шаблоне, используемых в скобках {{}} и в директиве ng-model, Items и Data – сервисы, определённые в файле services.js. Соответственно, Items – модель вопросов, Data – инструмент для получения служебных списков (категории вопросов и отвечающие). $scope – переменная, склеивающая контроллер и вид. Нельзя передавать данные из контроллера в вид иначе, чем через эту переменную (иногда это даже раздражает). Массив tablehead содержит описывающие заголовок таблицы объекты. Позже мы его расширим.

Рассмотрим теперь файл services.js:

'use strict';

angular.module('admin.services', ['ngResource'])
  .factory('Items', function($resource){
    return $resource('back/questions/:id/:action', {}, {
      create: {method:'PUT'},
      saveData: {method:'POST'},
      toggle: {method:'GET', params:{action:'toggle'}}
    });
  })
  .factory('Data', function($resource){
    var load = $resource('back/list/:name', {});
    var loadList = ['answerers','categories'];
    var data = {};
    for (var i=0; i<loadList.length; i++)
      data[loadList[i]] = load.get({name:loadList[i]});
    return function(key){ return data[key]; };
  });

В данном файле используется функция factory(), в данном случае являющаяся генератором ресурсов. Ресурс $resource – встроенный объект, инкапсулирующий работу с XMLHttpRequest. Он содержит дефолтные методы get(), save(), delete() и даёт возможность определить свои методы. По сути, возвращаемые фабриками объекты являются моделью данных. Служба Items подгружает данные с сервера каждый раз при обращении. Служба Data при загрузке страницы кеширует загруженные списки и выдаёт их из кэша по мере запросов.

В принципе, то, что уже есть, будет обеспечивать работу списка, но есть существенные недостатки, которые мы устраним позже. Сейчас же перейдём к странице создания и редактирования вопроса.

Добавление и редактирование записей

Шаблон /views/edit.html достаточно тривиален (по крайней мере для тех, кто знаком с css-фреймворком Bootstrap):

<form name="saveForm" class="form-horizontal">
  <fieldset>
    <div class="control-group">
      <div class="controls">
        <h3>{{["Добавление","Изменение"][(item.id>0)+0]}} записи</h3>
      </div>
    </div>
    <div class="control-group" ng-class="{error: saveForm.category.$invalid}">
      <label class="control-label" for="category">Категория</label>
      <div class="controls">
        <select name="category" ng-model="item.category" required
          ng-options="key as value for (key, value) in categories"></select>
      </div>
    </div>
    <div class="control-group" ng-class="{error: saveForm.title.$invalid}">
      <label class="control-label" for="title">Заголовок</label>
      <div class="controls">
        <input name="title" ng-model="item.title" required>
      </div>
    </div>
    <div class="control-group" ng-class="{error: saveForm.author.$invalid}">
      <label class="control-label" for="author">Автор</label>
      <div class="controls">
        <input name="author" ng-model="item.author" required>
      </div>
    </div>
    <div class="control-group" ng-class="{error: saveForm.answerer.$invalid}">
      <label class="control-label" for="answerer">Кому задан</label>
      <div class="controls">
        <select name="answerer" ng-model="item.answerer" required
          ng-options="key as value for (key, value) in answerers"></select>
      </div>
    </div>
    <div class="control-group" ng-class="{error: saveForm.answerer.$invalid}">
      <label class="control-label" for="text">Текст</label>
      <div class="controls">
        <textarea id="text" ng-model="item.text" required></textarea>
      </div>
    </div>
    <div class="control-group">
      <label class="control-label" for="answer">Ответ</label>
      <div class="controls">
        <textarea id="answer" ng-model="item.answer"></textarea>
      </div>
    </div>
    <div class="form-actions">
      <input type="button" ng-disabled="saveForm.$invalid||saveForm.$pristine" href="#/list" ng-click="save()" class="btn btn-success" value="Сохранить">
      <a href="#/list" class="btn">Отмена</a>
    </div>
  </fieldset>
</form>

В этом шаблоне несколько интересных моментов. Директива, создающая опции списка <select> из объекта записывается так: ng-options=«key as value for (key, value) in categories». Часть после for относится к источнику, выражение до for определяет, какое значение использовать в качестве атрибута value опции, а какое в качестве текста опции.

Директива ng-class="{error: saveForm.title.$invalid}" выставляет тегу класс error при saveForm.title.$invalid == true. Вообще, здесь используется объект, ключами которого являются имена классов, которые установятся в случае, если его значение будет истиной. На кнопке «Сохранить» используется подобная директива ng-disabled=«saveForm.$invalid||saveForm.$pristine», которая устанавливает атрибут disabled кнопке в случае выполнения условия, в данном случае – если в форме есть неверные атрибуты (saveForm.$invalid) или форма ещё не была изменена (saveForm.$pristine). Надеюсь, внимательный читатель догадается о назначении выражения <h3>{{[«Добавление»,«Изменение»][(item.id>0)+0]}} записи</h3>…

К этому одному шаблону, как видно из файла app.js, подключается два контроллера, которые нужно разместить в файл controllers.js (можно и в другой, главное, чтобы они были подключены к странице). Вот код контроллеров (файл controllers.js):

...
function EditCtrl($scope, $routeParams, $location, Items, Data) {
  $scope.item = Items.get({id:$routeParams.id});
  $scope.categories = Data('categories');
  $scope.answerers  = Data('answerers');
  $scope.save = function() {
    $scope.item.$save({id:$scope.item.id}, function(){ $location.path('/list'); });
  };
}

function NewCtrl($scope, $location, Items, Data) {
  $scope.item = {id:0,category:'',answerer:'',title:'',text:'',answer:'',author:''};
  $scope.categories = Data('categories');
  $scope.answerers  = Data('answerers');
  $scope.save = function() {
    Items.create($scope.item, function(){ $location.path('/list'); });
  };
}

Оба контроллера очень похожи, используют встроенный провайдер $routeParams для получения данных из адреса страницы (их имена обозначены в роуте в app.js) и функцию $location.path('/list') для перехода на другую страницу. Обратите внимание! В этой функции не надо использовать символ #, а вот в ссылках в атрибуте href его ставить обязательно.

То, что мы уже сделали, можно посмотреть на этой страничке. Но в текущей реализации вместо названия категории выводится её номер. Устраним этот недостаток.

Подстановка данных из списков

Первым делом настроим, чтобы в столбцы Категории и Кому задан выводились данные из полученных с сервера списков. Для этого создадим специальный модуль admin.filters, в котором будем размещать наши фильтры.
Файл filters.js:

'use strict';

angular.module('admin.filters', [])
  .filter('list', function() {
    return function(value,list) {
      return list?list[value]: value;
    };
  })
...

На вход функция получает значение текущего (фильтруемого) элемента и дополнительный параметр, заданный в шаблоне через двоеточие. Для подключения фильтров к приложению, нужно добавить модуль, их содержащий, в список зависимостей приложения (файл app.js):

...
angular.module('admin', ['admin.services','admin.filters'])
...

В шаблон list.html добавим вызов фильтра с параметром – нужным списком:

...
    <td>{{item.category|list:categories}}</td>
    <td>{{item.answerer|list:answerers}}</td>
...

Теперь, если запустить страницу с внесёнными изменениями, можно увидеть, что на месте числовых индексов появились нужные строки, но вот незадача — стандартный фильтр filter в элементе <tr> ничего не знает об этих строках, так как ему на вход даются нефильтрованные нашим новым фильтром данные. Для правильной фильтрации напишем ещё один фильтр, добавив его также в файл filters.js:

...
  .filter('filterEx', function() {
    var find = function(arr,name) {
      for(var i=0; i<arr.length; i++)
        if (arr[i].name==name) return arr[i].list;
    };
    return function(items,tablehead,str) {
      if (!str) return items;
      var result = [], list, ok, regexp = new RegExp(str,'i');
      for (var i in items) {
        ok = false;
        for (var k in items[i])
          if (items[i].hasOwnProperty(k) && k[0]!='$') {
            list = find(tablehead,k);
            if (list && regexp.test(list[items[i][k]])
                     || regexp.test(items[i][k])) {ok = true; break;}
          }
        if (ok) result.push(items[i]);
      }
      return result;
    };
  });

И добавим вызов этого фильтра в шаблон list.html:

  ...
  <tr ng-repeat="item in items | filterEx:tablehead:filterStr">
  ...

Код фильтра достаточно прост, он принимает три параметра – массив строк и две переменные, приведённые в шаблоне – tablehead и строка для поиска. Затем в цикле перебирает все элементы массива и все ключи в записи, и проверяет через регулярку наличие искомой строки во всех элементах записи, причём, если для элемента в массиве tablehead задан список, то используется значение из него. Также необходимо не забыть внести изменения в массив tablehead, добавив ключ list с массивом строк к нужным элементам (файл controllers.js):

...
  $scope.tablehead = [
    {name:'title',    title:"Заголовок"},
    {name:'category', title:"Категория",  list:$scope.categories},
    {name:'answerer', title:"Кому задан", list:$scope.answerers},
...

На этом базовая часть закончилась, приложение уже достаточно функционально. Разработку бэкенда оставлю за скобками, там всё достаточно тривиально.

Таким образом, мы рассмотрели создание базовых функций административной странички с помощью фреймворка AngularJs. За пределами статьи осталась сортировка таблицы и разбитие на страницы. Об этом я хотел написать следующую статью, если уважаемое читатели поддержит моё желание.

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

Автор: lexxpavlov

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js