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

от автора

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

Постановка задачи

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

Выглядит примерно так

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_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 метода,
http://localhost:1337/user
http://localhost:1337/invoice
http://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 и 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
если перейти по урлу 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/<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 шаблоны

Взглянем еще раз на наш макет. На нем я выделил блоки, которые мы будем привязывать к нашим динамическим данным

Создадим базовую разметку в файле 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 (например, 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 -плюсы:
+ решает свою задачу two-way биндинга
+ возможность привязки к backbone
+ расширяемость (можно писать свои плагины)
+ удобный шаблонизатор на основе mustache (не люблю ejs — очень громоздкий как по мне)
+ хорошая дока, примеры и туториал

Минусов за несколько дней использования не обнаружил…

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

ссылка на оригинал статьи http://habrahabr.ru/post/221171/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *