При создании сайтов часто возникает задача создания админки для редактирования контента. Задача, в общем, тривиальная, но сделать удобную админку не так-то просто. Под удобством в первую очередь подразумевается возможность сортировки таблицы со списком материалов �� работа без перезагрузки страницы. Если материалов в таблице становится много, то возникает задача разбивать её на страницы.
Всем известный jQuery-плагин tablesorter с tablesorterPager-ом и менее известный, но гораздо более функциональный DataTables хороши, но обладают некоторыми недостатками. Главный из них — сложность динамического добавления новых строк в таблицу (после добавления строки в таблицу, новая строка потеряется при следующем вызове сортировки). tablesorter вообще не даёт средств для добавления строки в свой кэш, DataTables предоставляет широкое и функциональное API для управления внутренним представлением таблицы, но это API довольно многословно и не очень гибко.
Хочу предоставить общественности реализацию админки на относительно новой javascript-фреймворке 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 (можно сделать и #!, который Гугл принял за стандарт для индексирования, спасибо aav за комментарий). Вьюшки я расположил в папку /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/angular-admin
Вторая часть