![](https://habrastorage.org/files/88f/b6a/e18/88fb6ae1887d4fdab691e9e985a18894.png)
Доброго дня, на выходных от скуки и отсутствия работы решил себя развлечь написанием небольшого приложения, которое сгодится в качестве учебного метариала для изучения возможностей двух замечательных библиотек — ractive.js и sails.js
Постановка задачи
По работе часто приходится после выполенения очередного задания (я — фрилансер) выставлять заказчику счет на оплату услуг. Тем более если имеешь дело с юридическими лицами. Для этого я использовал простой html-шаблон, в который данные заносил руками, исправляя очередные …
Выглядит примерно так
![image](http://habrastorage.org/files/c2e/fb5/0d6/c2efb50d67ac4ae3ad6b90c9814b5285.png)
Признаюсь, стили и разметка угнаны с freshbooks.com, который я использовал в свое время. К сожалению, для русских клиентов он мне не подошел, да и простого html-шаблона мне хватало.
Выбор технологий
В текущем тренде популярности js-фреймворков всех мастей и серверной js разработки я хотел для этой задачи использовать нечто вкусное и реактивное, дабы немного побыть в этом потоке js счастья… И паралельно опробовать эти игрушки.
После недолгих изучений, сравнений и интуитивных озарений остановился на sails.js в качестве сервера. Выбирал между derby и sails — в итоге выбрал парусник, в основном из-за его простоты (дока читается легко и приятно), также в нем есть очень классный генератор rest api из коробки. Derby в плане изучения показался труднее и монструознее (для этого примера — явный оверхэд).
На клиенте решил поиграться с ractive.js. И уже позже решено было подключить backbone.js — в основном из-за удобной работы с моделями.
До этого примера опыта sails.js и ractive.js у меня не было. В работе использовал только бэкбон.
Приступим.,
Сервер
Для нашего примера будем использовать sails v0.10 — она еще в стадии бета, но по сравнению с текущей стабильной версией 0.9.x в ней есть несколько плюшек, которые пригодятся. В частности model assocoations, которые позволяют задавать one-to-many, many-to-many (и другие связи между моделями), также в 0.10 переработана система grunt тасков. В доке по 0.10 все довольно ясно написано
sails v0.10 можно поставить через npm (я ставил глобально)
sudo npm install -g "git://github.com/balderdashy/sails.git#v0.10"
проверяем
sails -v
0.10.0 — отлично
Создание скелета приложения sailsjs
Создаем новое приложение, например, invoicer и ставим зависимости
sails new invoicer
cd invoicer
npm install
Далее выполнив команду
sails lift
можно запустить встроенный express.js сервер на localhost:1337
Создание API сущностей (моделей)
Нам потребутеся 3 модели для приложения:
-
user
— для хранения данные о пользователе -
invoice
— для списка счетов -
task
— для задач в счете (инвойсе)
Создаем с помощью команды
sails generate api <api_name>
Генерация API
zaebee@zaeboo$ sails generate api user
debug: Generated a new model `User` at api/models/User.js!
debug: Generated a new controller `user` at api/controllers/UserController.js!
info: REST API generated @ http://localhost:1337/user
info: and will be available the next time you run `sails lift`.
zaebee@zaeboo$ sails generate api invoice
debug: Generated a new model `Invoice` at api/models/Invoice.js!
debug: Generated a new controller `invoice` at api/controllers/InvoiceController.js!
info: REST API generated @ http://localhost:1337/invoice
info: and will be available the next time you run `sails lift`.
zaebee@zaeboo$ sails generate api task
debug: Generated a new controller `task` at api/controllers/TaskController.js!
debug: Generated a new model `Task` at api/models/Task.js!
info: REST API generated @ http://localhost:1337/task
info: and will be available the next time you run `sails lift`.
после этого в папке api/controllers появятся 3 файла
-rw-r--r-- 1 146 Апр 28 17:15 InvoiceController.js
-rw-r--r-- 1 143 Апр 28 17:15 TaskController.js
-rw-r--r-- 1 143 Апр 28 17:15 UserController.js
также api/models
-rw-r--r-- 1 146 Апр 28 17:15 Invoice.js
-rw-r--r-- 1 143 Апр 28 17:15 Task.js
-rw-r--r-- 1 143 Апр 28 17:15 User.js
Легко и просто sails создал для нас 3 метода,
localhost:1337/user
localhost:1337/invoice
localhost:1337/task
которые поддерживают CRUD операции. Также есть алиасы для них, например,
localhost:1337/user/create?name=Andrey&address=Russia
— создаст новый инстанс юзера. Можно поиграться через postmanТакже советую ознакомится с документацией по контроллерам
Конфигурация хранилища (БД)
Где же хранятся созданные данные? По дефолту в качестве хранилища использутеся диск, что указано в настройках
config/connections.js
и config/models.js
код config/connections.js
module.exports.connections = {
localDiskDb: {
adapter: 'sails-disk'
},
someMysqlServer: {
adapter : 'sails-mysql',
host : 'YOUR_MYSQL_SERVER_HOSTNAME_OR_IP_ADDRESS',
user : 'YOUR_MYSQL_USER',
password: 'YOUR_MYSQL_PASSWORD',
database: 'YOUR_MYSQL_DB'
},
someMongodbServer: {
adapter : 'sails-mongo',
host : 'localhost',
port : 27017,
//user : 'username',
//password : 'password',
database : 'invoicer'
},
somePostgresqlServer: {
adapter : 'sails-postgresql',
host : 'YOUR_POSTGRES_SERVER_HOSTNAME_OR_IP_ADDRESS',
user : 'YOUR_POSTGRES_USER',
password : 'YOUR_POSTGRES_PASSWORD',
database : 'YOUR_POSTGRES_DB'
}
};
Мы же будет использовать mongo для хранения записей, для этого немного изменим config/models.js:
код config/models.js
/**
* Models
* (sails.config.models)
*
* Unless you override them, the following properties will be included
* in each of your models.
*/
module.exports.models = {
// Your app's default connection.
// i.e. the name of one of your app's connections (see `config/connections.js`)
//
// (defaults to localDiskDb)
connection: 'someMongodbServer'
};
Опишем нужные нам поля модели User, Invoice и Task
api/models/User.js
module.exports = {
attributes: {
name: 'string',
email: 'string',
avatar: 'string',
address: 'text',
account: 'text',
invoices: {
collection: 'invoice',
via: 'owner',
}
},
};
api/models/Invoice.js
module.exports = {
attributes: {
total_amount: 'float',
name: 'string',
address: 'text',
owner: {
required: false,
model: 'user',
},
tasks: {
required: false,
collection: 'task',
via: 'invoice',
}
},
};
api/models/Task.js
module.exports = {
attributes: {
name: 'string',
description: 'text',
hours: 'float',
rate: 'float',
invoice: {
required: false,
model: 'invoice',
via: 'tasks',
}
},
};
для использования монго адаптера нужно поставить пакет sails-mongo
npm install sails-mongo@0.10
Добавление `action` для контроллера, и шаблона (view) для него
Нам необходимо создать контроллер, который будет генерировать страничку для нашей основной задачи (создание инвойса):
sails generate controller main generate
Мы создали новый
MainController.js
, в котором создана одна функция generate
так называемый actionесли перейти по урлу
localhost:1337/main/generate
мы увидим то, что нам вернула функция generate
По умолчанию она вернет json
return res.json({
todo: 'Not implemented yet!'
});
Мы же хотим видеть в браузере html-страничку. Для этого вышеприведенный код заменим на
return res.view()
обновляем страничку в браузере и видим ошибку
{
"view": {
"name": "main/generate",
"root": "/home/zaebee/projects/invoicer/views",
"defaultEngine": "ejs",
"ext": ".ejs"
}
}
это значит что у нас не создан шбалон для view. Все html-шаблоны для контроллеров лежат в папке views и имеют следующую структуру
views/<controller_name>/<action_name>
создаем пустой шаблон views/main/generate
zaebee@zaeboo$ mkdir views/main
zaebee@zaeboo$ touch views/main/generate.ejs
По умолчанию в качестве шаблонного движка используется ejs. Sails поддерживает много шаблонизаторов и вы можете изменить его в файле config/views.js на ваш любимый:
ejs, jade, handlebars, mustache
underscore, hogan, haml, haml-coffee, dust
atpl, eco, ect, jazz, jqtpl, JUST, liquor, QEJS,
swig, templayed, toffee, walrus, & whiskers
ВНИМАНИЕ! в версии sails 0.10 поддержка лайоутов работает только с ejs. Вкратце, есть базовый лейоут
views/layout.ejs
, от которого наследуются все остальные вьюхи. И при использовании шаблонизатора отличного от ejs наследования не будет. Sails дает это понять, если изменить опцию engine в файле config/views.js
warn: Sails' built-in layout support only works with the `ejs` view engine.
warn: You're using `hogan`.
warn: Ignoring `sails.config.views.layout`...
Клиент
Сервер готов, приступим к написанию клиентской части нашего приложения по создания инвойсов.
Подключение статики
Вся статика (или публичный клиентский код) лежит в папке assets. для того, чтобы поключить новые файлы к вашему шаблону просто поместите их в соотвествующую папку (скрипты в assets/js, стили в assets/styles, клиентские шаблоны в assets/templates) и sails с помощью своих grunt тасков запишет их в ваш index/layout.ejs — в специальные секции:
Листинг исходного файла /views/layout.ejs
<!DOCTYPE html>
<html>
<head>
<title>New Sails App</title>
<!-- Viewport mobile tag for sensible mobile support -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<!--STYLES-->
<link rel="stylesheet" href="/styles/importer.css">
<!--STYLES END-->
</head>
<body>
<%- body %>
<!--TEMPLATES-->
<!--TEMPLATES END-->
<!--SCRIPTS-->
<script src="/js/dependencies/sails.io.js"></script>
<!--SCRIPTS END-->
</body>
</html>
Подключим в наш layout нужные библиотеки (Jquery, Underscore, Backbone, Ractive) через cdn, такжк поместим
bootstrap.min.css
и готовый файл app.css
в папку assets/styles
. Также разместим дополнительные js либы, которые понадобятся (bootstrap.min.css
, moment.ru.js
и moment.min.js
— библиотка для работы с датами) в папку assets/js/vendor
и пустой файл app.js
в папку assets/js
. Запустим sails lift
и посмотрим, что теперь у нас в файле views/layout.ejs
Листинг исходного файла /views/layout.ejs
<!DOCTYPE html>
<html>
<head>
<title>New Sails App</title>
<!-- Viewport mobile tag for sensible mobile support -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<!--STYLES-->
<link rel="stylesheet" href="/styles/app.css">
<link rel="stylesheet" href="/styles/bootstrap.min.css">
<link rel="stylesheet" href="/styles/importer.css">
<!--STYLES END-->
<script src="//cdnjs.cloudflare.com/ajax/libs/jquery/1.9.1/jquery.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/underscore.js/1.4.4/underscore-min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/backbone.js/1.0.0/backbone-min.js"></script>
<script src="//cdn.ractivejs.org/latest/ractive.min.js"></script>
<script src="//api.filepicker.io/v1/filepicker.js"></script
</head>
<body>
<%- body %>
<!--TEMPLATES-->
<!--TEMPLATES END-->
<!--SCRIPTS-->
<script src="/js/dependencies/sails.io.js"></script>
<script src="/js/app.js"></script>
<script src="/js/vendor/bootstrap.min.js"></script>
<script src="/js/vendor/moment.min.js"></script>
<script src="/js/vendor/moment.ru.js"></script>
<!--SCRIPTS END-->
</body>
</html>
Отлично, sails сделал за нас, все что нужно. Правда, есть один минус — вендорские скрипты подключены ниже нашего app.js. Исправим файл
tasks/pipeline.js
укажем grunt`у, что папку vendor нужно подключать раньше:Часть листинга файла tasks/pipeline.js
......
// CSS files to inject in order
//
// (if you're using LESS with the built-in default config, you'll want
// to change `assets/styles/importer.less` instead.)
var cssFilesToInject = [
'styles/**/*.css'
];
// Client-side javascript files to inject in order
// (uses Grunt-style wildcard/glob/splat expressions)
var jsFilesToInject = [
// Dependencies like sails.io.js, jQuery, or Angular
// are brought in here
'js/dependencies/**/*.js',
'js/vendor/**/*.js', // выносим папку vendor
// All of the rest of your client-side js files
// will be injected here in no particular order.
'js/**/*.js'
];
........
Подготовка клиенской части завершена — можем приступать непосредственно к написанию бизнес-логики приложения.
Создание скелета разметки страницы. Ractive.js шаблоны
Взглянем еще раз на наш макет. На нем я выделил блоки, которые мы будем привязывать к нашим динамическим данным
![](http://habrastorage.org/files/443/dd1/968/443dd19687dd434e8f1001551d8fd322.png)
Создадим базовую разметку в файле views/main/generate.ejs в которую будут инклюдиться наши клиентские шаблоны
листинг файла views/main/generate.ejs
<div class="main_bg">
<div class="container primary-content">
<div class="invoice-container rounded-container peel-shadows col-sm-8 col-sm-offset-2">
<h2 style="text-align:center;margin-bottom:30px;">Счет на оплату</h2>
<div class="invheader">
<div class="invheader-upper">
<!-- User модель. Сюда будем подключать шаблон с данными пользователя: имя, адрес, аватар -->
</div>
<div class="invheader-lower">
<!-- Invoice модель. Сюда будем подключать шаблон с данными нашего счета и данными заказчика -->
</div>
</div>
<div class="invbody">
<div class="invbody-tasks">
<!-- Task коллекция. Сюда будем подключать шаблон со списком задач -->
</div>
<div class="clearb" style="height: 1px; overflow: hidden;"></div>
<div class="invbody-account">
<!-- User модель снова. Сюда будем подключать реквизиты пользователя для оплаты -->
</div>
</div>
</div>
</div>
</div>
Итак, базовая разметка готова — пришло время для шаблонов ractive.js
Создадим для каждого нашего блока по шаблону (итого их будет четыре) и поместим их в assets/templates
листинг файла assets/templates/invheader-upper.html
<div class="invheader-address-account" on-hover="toggleBtn">
<a role="button" title="{{ .editing ? 'Сохранить' : 'Изменить исполнителя' }}"
class="hidden-print btn btn-primary btn-sm hide"
on-click="edit"><i class="glyphicon glyphicon-{{ .editing ? 'ok' : 'pencil' }}"></i>
</a>
<b>Исполнитель:</b>
<div class="user-name {{ .editing ? 'editing' : '' }}">
<span>{{^name}}Без имени{{/name}}{{name}}</span>
{{#.editing}}
<div class='edit-container'>
<input intro="select" value="{{name}}" class="form-control"
placeholder="Напишите ваше ФИО">
</div>
{{/.editing}}
</div>
<div class="user-address {{ .editing ? 'editing' : '' }}">
<span>{{^address}}Адрес не указан{{/address}}{{{address}}}</span>
{{#.editing}}
<div class='edit-container'>
<textarea value="{{address}}" class='edit form-control'
placeholder="Напишите ваш адрес">{{address}}</textarea>
</div>
{{/.editing}}
</div>
</div>
<div on-hover="togglePicker" class="invheader-logo-container">
<div class="invheader-logo">
{{#avatar}}
<img src="{{avatar}}/convert?h=110&w=250" alt="{{name}}">
{{/avatar}}
<div class="hidden-print BoardCreateRep {{ avatar ? 'hide' : '' }}">
<input type="filepicker-dragdrop" data-fp-mimetype="image/png"
data-fp-apikey="A3lXl09sRSejY4e0pOOSQz"
data-fp-button-class="btn btn-primary hidden-print"
data-fp-button-text="Загрузите аватар"
data-fp-drag-text="или бросьте сюда"
data-fp-drag-class="hidden-print drop-avatar"
onchange="app.user.fire('setAvatar', event)">
</div>
</div>
</div>
листинг файла assets/templates/invheader-lower.html
<div class="invheader-address-client" on-hover="toggleBtn">
<a role="button" title="{{ .editing ? 'Сохранить' : 'Изменить заказчика' }}"
class="hidden-print btn btn-primary btn-sm hide"
on-click="edit"><i class="glyphicon glyphicon-{{ .editing ? 'ok' : 'pencil' }}"></i>
</a>
<b>Заказчик:</b>
<div class="cleint-name {{ .editing ? 'editing' : '' }}">
<span>{{^invoice.name}}Без имени{{/invoice.name}}{{invoice.name}}</span>
{{#.editing}}
<div class='edit-container'>
<input intro="select" value="{{invoice.name}}"
class="form-control" placeholder="Напишите название компании клиента">
</div>
{{/.editing}}
</div>
<div class="client-address {{ .editing ? 'editing' : '' }}">
<span>{{^invoice.address}}Адрес не указан{{/invoice.address}}{{{invoice.address}}}</span>
{{#.editing}}
<div class='edit-container'>
<textarea value="{{invoice.address}}" class="edit form-control"
placeholder="Напишите адрес компании клиента">{{invoice.address}}</textarea>
</div>
{{/.editing}}
</div>
</div>
<div class="invheader-invoicedetails">
<table cellspacing="0">
<tbody>
<tr>
<th>Номер счета</th>
<td>#{{ lastFour(invoice.id) }}</td>
</tr>
<tr>
<th>Дата </th>
<td>{{ date(invoice.createdAt) }}</td>
</tr>
<tr class="invheader-invoicedetails-balance">
<th><div>Итого к оплате </div></th>
<td><div> {{^invoice.total_amount}}0.00{{/invoice.total_amount}}{{ invoice.total_amount }} руб </div></td>
</tr>
</tbody>
</table>
</div>
листинг файла assets/templates/invbody-tasks.html
<table class="invbody-items" cellspacing="0">
<thead>
<tr>
<th class="first"><div class="item">Наименование работ </div></th>
<th><div class="description">Описание, замечания по работам </div></th>
<th><div class="unitcost">Ставка (руб)</div></th>
<th><div class="quantity">Количество часов </div></th>
<th class="last"><div class="linetotal">Сумма (руб)</div></th>
</tr>
</thead>
<tbody>
{{#tasks}}
<tr>
<td style="width: 160px;">
<a on-tap="destroy:{{this}}" role="button" class='hidden-print destroy'></a>
<div on-click="edit" class="item">{{name}}</div>
<input intro="select" class="form-control hide" value="{{name}}" on-blur-enter="hide:{{this}}">
</td>
<td>
<div on-click="edit" class="description">{{description}}</div>
<textarea class="form-control hide" value="{{description}}" on-blur-enter="hide:{{this}}">{{description}}</textarea>
</td>
<td style="width: 85px;">
<div on-click="edit" class="unitcost">{{ format(rate) }}</div>
<input class="form-control hide" value="{{rate}}" on-blur-enter="hide:{{this}}">
</td>
<td style="width: 80px;">
<div on-click="edit" class="quantity">{{ format(hours) }}</div>
<input class="form-control hide" value="{{hours}}" on-blur-enter="hide:{{this}}">
</td>
<td style="width: 90px;">
<div class="linetotal">{{ format(rate * hours) }}</div>
</td>
</tr>
{{/tasks}}
<tr>
<td class="hidden-print text-center" colspan="5">
<button on-click="add" class="btn btn-primary btn-sm"><i class="glyphicon glyphicon-plus "></i> Добавить</button>
</td>
</tr>
</tbody>
</table>
<table class="invbody-summary" cellspacing="0">
<tbody>
<tr>
<td class="invbody-summary-clean"> </td>
<td style="width: 150px;"><strong>К оплате: </strong></td>
<td style="width: 120px;"><strong>
{{ total(tasks) }}
</strong></td>
</tr>
<tr class="invbody-summary-paid">
<td class="invbody-summary-clean"> </td>
<td style="width: 150px;">Оплачено </td>
<td style="width: 120px;">-0.00</td>
</tr>
<tr class="invbody-summary-total">
<td class="invbody-summary-clean"> </td>
<td style="width: 150px;"><div><strong>Итого к оплате: </strong></div></td>
<td style="width: 120px;"><div><strong>
{{ total(tasks) }}
</strong></div></td>
</tr>
</tbody>
</table>
листинг файла assets/templates/invbody-account.html
<div class="invbody-terms" on-hover="toggleBtn">
<a role="button" title="{{ .editing ? 'Сохранить' : 'Изменить реквизиты' }}"
class="hidden-print btn btn-primary btn-sm hide"
on-click="edit"><i class="glyphicon glyphicon-{{ .editing ? 'ok' : 'pencil' }}"></i>
</a>
<b>Реквизиты:</b>
<div class="user-account {{ .editing ? 'editing' : '' }}">
<span>{{^account}}Реквизиты не указаны{{/account}}{{{account}}}</span>
{{#.editing}}
<div class='edit-container'>
<textarea value="{{account}}" class='edit form-control' placeholder="Напишите ваши реквизиты для платежей">{{account}}</textarea>
</div>
{{/.editing}}
</div>
</div>
В целом это обычный html, c вкраплениями mustache-подобных тэгов
{{}}
, в которых ractive.js вставляет свои данные. Также вы можете заметить некоторые директивы on-click="edit"
— выполняет метод edit
по клику; on-hover="toggleBtn"
, on-tap="destroy:{{this}}"
этот момент осветим позже, можно пока изучить доку по евентам ractive.jsСобытия подключаются в ractive в виде плагинов — так называемые proxy-events. Чтобы события заработали, нужно скачать нужные нам (я скачал все плагины для событий) и поместить их в папку
assets/js/vendor
Поместим в эту же папку адаптер для Backbone, чтобы ractive.js смог использовать в качестве источника данных модели backbone.
Инициализация данных. Биндинг данных и шаблонов
Подведем промежуточный итог, что есть на данный момент и что мы хотим получить в итоге
- на сервере sails с помощью rest api позволяет создавать юзеров, инвойсы и задачи. Делать связи между ними за счет model associations. Данные хранятся в базе mongodb
- на клиенте backbone модели будут хранить введенные пользователем данные и сихнронизироваться с sails сервером через rest api
- на клиенте ractive будет осуществлять two-way биндинг между html-шаблонами и backbone моделями (за счет адаптера для Backbone)
- ....
- PROFIT?
для начала создадим нужные нам Backbone модели в нашем пустом файле
assets/js/app.js
:Листинг assets/js/app.js
var app = app || {};
(function (app) {
app.User = Backbone.Model.extend({
urlRoot: '/user',
});
app.Invoice = Backbone.Model.extend({
urlRoot: '/invoice',
});
app.Task = Backbone.Model.extend({
urlRoot: '/task',
});
app.Tasks = Backbone.Collection.extend({
url: '/task',
model: app.Task
});
})(app);
Хорошо, теперь создадим ractive инстанс, который будет привязан к нашей модели app.User и будет рендерить наш шаблон
assets/templates/invheader-upper.html
и assets/templates/invbody-account.html
Создадим файл assets/js/user.js
Листинг assets/js/user.js
var app = app || {};
(function (app) {
var backboneUser = new app.User;
// Здесь мы создаем ractive компонент через Ractive.extend
// вместо new Ractive({}), потому что у нас будет 2 однотипных блока
var RactiveUser = Ractive.extend({
init: function (options) {
this.data = options.data;
this.on({
// Обрабатываем нажатие на кнопку редактирования
// в шаблоне `on-click="edit"`
edit: function (event) {
var editing = this.get('editing');
this.set( 'editing', !editing );
if (editing) {
this.data.save(); // сохраняем модель на сервер
}
},
// Сохраняем аватар после успешной загрузки картинки
// на https://www.inkfilepicker.com
// в шаблоне `onchange="app.user.fire('setAvatar', event)"`
setAvatar: function (event) {
if (event.fpfile) {
var url = event.fpfile.url;
this.set('avatar', url);
} else {
this.set('avatar', null);
}
this.data.save(); // сохраняем модель на сервер
},
// Скрываем или показываем форму для загрузки аватара
// в шаблоне `on-hover="togglePicker"`
togglePicker: function (event) {
if (!this.get('avatar')) return;
if ( event.hover ) {
$(event.node).find('.BoardCreateRep').removeClass('hide');
} else {
$(event.node).find('.BoardCreateRep').addClass('hide');
}
},
// Показываем или скрываем кнопку для редактирования данных
// в шаблоне `on-hover="toggleBtn"`
toggleBtn: function (event) {
if ( event.hover ) {
$(event.node).find('[role=button]').removeClass('hide');
} else {
$(event.node).find('[role=button]').addClass('hide');
}
}
});
}
});
// Создаем RactiveUser компонент сверху страницы
// присоединяем к элементу с классом `.invheader-upper`
app.user = new RactiveUser({
el: '.invheader-upper',
template: JST['assets/templates/invheader-upper.html'](),
data: backboneUser,
adaptors: [ 'Backbone' ],
});
// Создаем RactiveUser компонент снизу страницы
// присоединяем к элементу с классом `.invheader-account`
app.account = new RactiveUser({
el: '.invbody-account',
template: JST['assets/templates/invbody-account.html'](),
data: backboneUser,
adaptors: [ 'Backbone' ],
});
// Подписываемся на измениния Id юзера
// если id изменилось (то есть юзера сохранили)
// привязваем инвойс к этому пользователю
app.user.observe('id', function(id){
if (id && app.invoice) {
app.invoice.data.invoice.set('owner', id);
app.invoice.data.invoice.save();
}
});
})(app);
Код достаточно прост. Здесь мы создаем базовый класс
RactiveUser
. Обычно можно создать инстанс через new Ractive({})
, но в частности здесь нам нужно 2 элемента для пользователя, которые привязаны к одной модели и которые подписны практически на одинаковые события. Сами события указываются в теле init
функции.Едем дальше, создадим по аналогии
assets/js/invoice.js
и assets/js/task.js
Листинг assets/js/invoice.js
var app = app || {};
(function (app) {
app.invoice = new Ractive({
el: '.invheader-lower',
template: JST['assets/templates/invheader-lower.html'](),
data: {
invoice: new app.Invoice, // наша Backbone модель
// хэлпер для красивой даты используется в шаблоне {{ date(createdAt) }}
date: function (date) {
return moment(date).format('D MMMM YYYY');
},
// хэлпер дв шаблоне {{ lastFour(id) }}
lastFour: function (str) {
return str.slice(-4);
}
},
adaptors: [ 'Backbone' ],
transitions: {
select: function ( t ) {
setTimeout( function () {
t.node.select();
t.complete();
}, 200 );
}
}
});
app.invoice.on({
// Обрабатываем нажатие на кнопку редактирования
// в шаблоне `on-click="edit"`
edit: function (event) {
console.log(event);
var editing = this.get('editing');
this.set( 'editing', !editing );
if (editing) {
this.data.invoice.save({owner: app.user.data.id});
}
},
// Показываем или скрываем кнопку для редактирования данных
// в шаблоне `on-hover="toggleBtn"`
toggleBtn: function (event) {
if ( event.hover ) {
$(event.node).find('[role=button]').removeClass('hide');
} else {
$(event.node).find('[role=button]').addClass('hide');
}
}
});
// сразу сохраняем инвойс на сервер
app.invoice.data.invoice.save();
})(app);
Листинг assets/js/task.js
var app = app || {};
(function (app) {
app.tasks = new Ractive({
el: '.invbody-tasks',
template: JST['assets/templates/invbody-tasks.html'](),
data: {
tasks: new app.Tasks, // наша Backbone модель
// хэлпер используется в шаблоне {{ format(price) }}
format: function ( num ) {
return num.toFixed( 2 );
},
// хэлпер используется в шаблоне {{ total(tasks) }}
total: function ( collection ) {
var total = collection.reduce(function( sum, el ) {
return el.get('rate') * el.get('hours') + sum;
}, 0 );
return total.toFixed( 2 );
},
},
adaptors: [ 'Backbone' ],
transitions: {
select: function ( t ) {
setTimeout( function () {
t.node.select();
t.complete();
}, 200 );
}
}
});
app.tasks.on({
// Обрабатываем нажатие на кнопку создания таска
// в шаблоне `on-click="add"`
add: function ( event ) {
var tasks = this.get('tasks');
var task = new app.Task({
name: 'Без названия',
description: 'Описания нет',
hours: 0,
rate: 0,
});
tasks.add(task);
task.save(null, {
// хак, чтобы привязать новый созданный таск к текущему инвойсу
success: function() {
task.set('invoice', app.invoice.data.invoice.id);
task.save();
}
});
},
// удаляем таск с сервера тоже
// в шаблоне `on-tap="destroy:{{this}}"`
destroy: function ( event, task ) {
task.destroy();
},
// показываем инпут для редактирования свойств таска
// в шаблоне `on-click="edit"`
edit: function ( event ) {
$(event.node).hide();
$(event.node).next().removeClass('hide').focus().select();
},
// сохраняем такс после изменения какого-либо поля
// в шаблоне `on-blur-enter="hide"`
hide: function ( event, task ) {
$(event.node).addClass('hide');
$(event.node).prev().show();
task.save({invoice: app.invoice.data.invoice.id});
},
});
// подписываемся на изменения параметров `hours` и `rate` для тасков
// чтобы пересчитивать сумму
// сумму также меняем у инвойса
// TODO нужно сохранять инвойс после изменения суммы
app.tasks.observe('tasks.*.hours tasks.*.rate', function(tasks, old, keypath){
var total = this.data.total(this.data.tasks);
app.invoice.data.invoice.set('total_amount', total);
});
})(app);
Здесь также код достаточно понятен, для эвентов добавил комментарии. По сути это весь клиентский код. Планировал еще прикрутить метод для генерации статического инвойса на основе id (например,
localhost:1337/main/generate/535ea7aa6113230d773fd160
) или использовать api pdfcrowd.com, благо у них есть модуль для node, который позволяет по урлу создавать pdf… Однако за выходные это сделать не успел. Сейчас я создаю pdf через ctrp+P (отправить на печать) -> «Печать в файл». А для того чтобы не вылезли ненужные html элементы (например, кнопки) — добавил для них класс hidden-print
.Деплой на сервер
На этом практически все — приложение готово. Данный пример находится на гитхабе
На сервере клонируем репозиторий, ставим зависимости и запускаем sails в продакшн режиме:
node app.js --port=8000 --prod
Запустил рабочее демо в продакшн режиме
Резюме
Итог работы как с sailsjs так и с ractive — очень порадовал.
Sailsjs — плюсы:
+ Понравилось, насколько просто в sails создается api
+ Очень классные возможности конфигурирования, начиная от шаблонного движка, БД, и используемого ОРМ (планирую прикрутить bookshelfjs.org на sails )
+ Очень понравилось, что есть готовые grunt таски, которые неплохо решают задачу генерации как прод так и дев бандлов.
+ есть команда (
sails www
) которая собирает только клиенский код — удобно для отделения работы фронта и сервера.+ поддержка мультиязычности (не юзал, но знаю, что есть)
Минусы:
— на данный момент багнутая работа model assocoations (понимаю, что v0.10 — еще бета, а в v0.9.x — этого вообще нет)
— поддержка лейоутов только для ejs шаблонов
Ractivejs — плюсы:
+ возможность привязки к backbone
+ расширяемость (можно писать свои плагины)
+ удобный шаблонизатор на основе mustache (не люблю ejs — очень громоздкий как по мне)
+ хорошая дока, примеры и туториал
Ractive — минусы за несколько дней использования не обнаружил.
Благодарю за внимание.