Счет на оплату. Рабочее приложение на sails.js, ractive.js, Backbone.js

88fb6ae1887d4fdab691e9e985a18894.pngДоброго дня, на выходных от скуки и отсутствия работы решил себя развлечь написанием небольшого приложения, которое сгодится в качестве учебного метариала для изучения возможностей двух замечательных библиотек — ractive.js и sails.js

Постановка задачи По работе часто приходится после выполенения очередного задания (я — фрилансер) выставлять заказчику счет на оплату услуг. Тем более если имеешь дело с юридическими лицами. Для этого я использовал простой html-шаблон, в который данные заносил руками, исправляя очередные …Выглядит примерно такimage

Признаюсь, стили и разметка угнаны с 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 сервер на http://localhost:1337Создание API сущностей (моделей) Нам потребутеся 3 модели для приложения: user — для хранения данные о пользователе invoice — для списка счетов task — для задач в счете (инвойсе) Создаем с помощью команды sails generate api Генерация 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 метода, http://localhost:1337/userhttp://localhost:1337/invoicehttp://localhost:1337/taskкоторые поддерживают CRUD операции. Также есть алиасы для них, например, http://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 и Taskapi/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если перейти по урлу http://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. Все htnl-шаблоны для контроллеров лежат в папке views и имеют следующую структуру views//создаем пустой шаблон 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 New Sails App <%- body %>

Подключим в наш 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 New Sails App

<%- body %> Отлично, 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 шаблоны Взглянем еще раз на наш макет. На нем я выделил блоки, которые мы будем привязывать к нашим динамическим данным443dd19687dd434e8f1001551d8fd322.pngСоздадим базовую разметку в файле views/main/generate.ejs в которую будут инклюдиться наши клиентские шаблонылистинг файла views/main/generate.ejs

Счет на оплату

Итак, базовая разметка готова — пришло время для шаблонов ractive.jsСоздадим для каждого нашего блока по шаблону (итого их будет четыре) и поместим их в assets/templates

листинг файла assets/templates/invheader-upper.html

листинг файла assets/templates/invheader-lower.html
Заказчик:
{{^invoice.name}}Без имени{{/invoice.name}}{{invoice.name}} {{#.editing}}
{{/.editing}}
{{^invoice.address}}Адрес не указан{{/invoice.address}}{{{invoice.address}}} {{#.editing}}
{{/.editing}}
Номер счета #{{ lastFour (invoice.id) }}
Дата {{ date (invoice.createdAt) }}
Итого к оплате
{{^invoice.total_amount}}0.00{{/invoice.total_amount}}{{ invoice.total_amount }} руб
листинг файла assets/templates/invbody-tasks.html {{#tasks}} {{/tasks}}
Наименование работ
Описание, замечания по работам
Ставка (руб)
Количество часов
Сумма (руб)
{{name}}
{{description}}
{{ format (rate) }}
{{ format (hours) }}
{{ format (rate * hours) }}
К оплате: {{ total (tasks) }}
Оплачено -0.00
Итого к оплате:
{{ total (tasks) }}
листинг файла assets/templates/invbody-account.html
Реквизиты:
В целом это обычный 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 (например, http://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 — минусы за несколько дней использования не обнаружил.

Благодарю за внимание.

© Habrahabr.ru