При создании сайтов часто возникает задача создания админки для редактирования контента. Задача, в общем, тривиальная, но сделать удобную админку не так-то просто.
Под удобством в первую очередь подразумевается возможность сортировки таблицы со списком материалов и работа без перезагрузки страницы. Если материалов в таблице становится много, то возникает задача разбивать её на страницы.
Всем известный 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 (жалко, что не !#, который Гугл принял за стандарт для индексирования). Вьюшки я расположил в папку /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