[![Build Status](https://travis-ci.org/jrpolidario/live_record.svg?branch=master)](https://travis-ci.org/jrpolidario/live_record)
## About
* Auto-syncs records in client-side JS (through a Model DSL) from changes in the backend Rails server through ActionCable
* Auto-updates DOM elements mapped to a record attribute, from changes. **(Optional LiveDOM Plugin)**
* Automatically resyncs after client-side reconnection.
> `live_record` is intentionally designed for read-only one-way syncing from the backend server, and does not support pushing changes to the Rails server from the client-side JS. Updates from client-side then is intended to use the normal HTTP REST requests.
## Requirements
* **>= Ruby 2.2.2**
* **>= Rails 5.0**
## Demo
* https://live-record-example.herokuapp.com/
## Usage Example
* on the client-side:
```js
// instantiate a Book object
var book = new LiveRecord.Model.all.Book({
id: 1,
title: 'Harry Potter',
author: 'J. K. Rowling',
created_at: '2017-08-02T12:39:49.238Z',
updated_at: '2017-08-02T12:39:49.238Z'
});
// store this Book object into the JS store
book.create();
// the store is accessible through
LiveRecord.Model.all.Book.all;
// all records in the JS store are automatically subscribed to the backend LiveRecordChannel, which meant syncing (update / destroy) changes from the backend
// you can add a callback that will be invoked whenever the Book object has been updated (see all supported callbacks further below)
book.addCallback('after:update', function() {
// let's say you update the DOM elements here when the attributes have changed
// `this` refers to the Book record that has been updated
console.log(this);
});
// or you can add a Model-wide callback that will be invoked whenever ANY Book object has been updated
LiveRecord.Model.all.Book.addCallback('after:update', function() {
// let's say you update the DOM elements here when the attributes have changed
// `this` refers to the Book record that has been updated
console.log(this);
})
```
* on the backend-side, you can handle attributes authorisation:
```ruby
# app/models/book.rb
class Book < ApplicationRecord
include LiveRecord::Model::Callbacks
def self.live_record_whitelisted_attributes(book, current_user)
# Add attributes to this array that you would like `current_user` to have access to when syncing this particular `book`
# empty array means not-authorised
if book.user == current_user
[:title, :author, :created_at, :updated_at, :reference_id, :origin_address]
elsif current_user.present?
[:title, :author, :created_at, :updated_at]
else
[]
end
end
end
```
* whenever a Book (or any other Model record that you specified) has been updated / destroyed, there exists an `after_update_commit` and an `after_destroy_commit` ActiveRecord callback that will broadcast changes to all subscribed JS clients
## Setup
* Add the following to your `Gemfile`:
```ruby
gem 'live_record', '~> 0.1.0'
```
* Run:
```bash
bundle install
```
* Install by running:
```bash
rails generate live_record:install
```
> `rails generate live_record:install --live_dom=false` if you do not need the `LiveDOM` plugin; `--live_dom=true` by default
* Run migration to create the `live_record_updates` table, which is going to be used for client reconnection resyncing:
```bash
rake db:migrate
```
* Update your **app/channels/application_cable/connection.rb**, and add `current_user` method, unless you already have it:
```ruby
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def current_user
# write something here if you have a current_user, or you may just leave this blank. Example below when using `devise` gem:
# User.find_by(id: cookies.signed[:user_id])
end
end
end
```
* Update your **model** files (only those you would want to be synced), and insert the following public method:
> automatically updated if you use Rails scaffold or model generator
### Example 1 - Simple Usage
```ruby
# app/models/book.rb (example 1)
class Book < ApplicationRecord
def self.live_record_whitelisted_attributes(book, current_user)
# Add attributes to this array that you would like current_user to have access to when syncing.
# Defaults to empty array, thereby blocking everything by default, only unless explicitly stated here so.
[:title, :author, :created_at, :updated_at]
end
end
```
### Example 2 - Advanced Usage
```ruby
# app/models/book.rb (example 1)
class Book < ApplicationRecord
def self.live_record_whitelisted_attributes(book, current_user)
# Notice that from above, you also have access to `book` (the record currently requested by the client to be synced),
# and the `current_user`, the current user who is trying to sync the `book` record.
if book.user == current_user
[:title, :author, :created_at, :updated_at, :reference_id, :origin_address]
elsif current_user.present?
[:title, :author, :created_at, :updated_at]
else
[]
end
end
end
```
* For each Model you want to sync, insert the following in your Javascript files.
> automatically updated if you use Rails scaffold or controller generator
### Example 1 - Model
```js
// app/assets/javascripts/books.js
LiveRecord.Model.create(
{
modelName: 'Book' // should match the Rails model name
plugins: {
LiveDOM: true // remove this if you do not need `LiveDOM`
}
}
)
```
### Example 2 - Model + Callbacks
```js
// app/assets/javascripts/books.js
LiveRecord.Model.create(
{
modelName: 'Book',
callbacks: {
'on:connect': [
function() {
console.log(this); // `this` refers to the current `Book` record that has just connected for syncing
}
],
'after:update': [
function() {
console.log(this); // `this` refers to the current `Book` record that has just been updated with changes synced from the backend
}
]
}
}
)
```
#### Model Callbacks supported:
* `on:connect`
* `on:disconnect`
* `on:response_error`
* `before:create`
* `after:create`
* `before:update`
* `after:update`
* `before:destroy`
* `after:destroy`
> Each callback should map to an array of functions
* `on:response_error` supports a function argument: The "Error Code". i.e.
### Example 3 - Handling Response Error
```js
LiveRecord.Model.create(
{
modelName: 'Book',
callbacks: {
'on:response_error': [
function(errorCode) {
console.log(errorCode); // errorCode is a string, representing the type of error. See Response Error Codes below:
}
]
}
}
)
```
#### Response Error Codes:
* `"forbidden"` - Current User is not authorized to sync record changes. Happens when Model's `live_record_whitelisted_attributes` method returns empty array.
* `"bad_request"` - Happens when `LiveRecord.Model.create({modelName: 'INCORRECTMODELNAME'})`
* Load the records into the JS Model-store through JSON REST (i.e.):
### Example 1 - Using Default Loader (Requires JQuery)
> Your controller must also support responding with JSON in addition to HTML. If you used scaffold or controller generator, this should already work immediately.
```html
```
```html
```
```html
```
### Example 2 - Using Custom Loader
```js
// do something here that will fetch Book record attributes...
// as an example, say you already have the following attributes:
var book1Attributes = { id: 1, title: 'Noli Me Tangere', author: 'José Rizal' }
var book2Attributes = { id: 2, title: 'ABNKKBSNPLAko?!', author: 'Bob Ong' }
// then we instantiate a Book object
var book1 = new LiveRecord.Model.all.Book(book1Attributes);
// then we push this Book object to the Book store, which then automatically subscribes them to changes in the backend
book1.create();
var book2 = new LiveRecord.Model.all.Book(book2Attributes);
book2.create();
// you can also add Instance callbacks specific only to this Object (supported callbacks are the same as the Model callbacks)
book2.addCallback('after:update', function() {
// do something when book2 has been updated after syncing
})
```
## Plugins
### LiveDOM (Requires JQuery)
* enabled by default, unless explicitly removed.
* `LiveDOM` allows DOM elements' text content to be automatically updated, whenever the mapped record-attribute has been updated.
> text content is safely escaped using JQuery's `.text()` function
#### Example 1 (Mapping to a Record-Attribute: `after:update`)
```html
Harry Potter
```
* `data-live-record-update-from` format should be `MODELNAME-RECORDID-RECORDATTRIBUTE`
* whenever `LiveRecord.all.Book.all[24]` has been updated/synced from backend, "Harry Potter" text above changes accordingly.
* this does not apply to only `` elements. You can use whatever elements you like.
#### Example 2 (Mapping to a Record: `after:destroy`)
```html
This example element is a container for the Book-31 record which can also contain children elements
```
* `data-live-record-destroy-from` format should be `MODELNAME-RECORDID`
* whenever `LiveRecord.all.Book.all[31]` has been destroyed/synced from backend, the `` element above is removed, and thus all of its children elements.
* this does not apply to only `` elements. You can use whatever elements you like.
* You may combine `data-live-record-destroy-from` and `data-live-record-update-from` within the same element.
## JS API
`LiveRecord.Model.create(CONFIG)`
* `CONFIG` (Object)
* `modelName`: (String, Required)
* `callbacks`: (Object)
* `on:connect`: (Array of functions)
* `on:disconnect`: (Array of functions)
* `on:response_error`: (Array of functions; function argument = ERROR_CODE (String))
* `before:create`: (Array of functions)
* `after:create`: (Array of functions)
* `before:update`: (Array of functions)
* `after:update`: (Array of functions)
* `before:destroy`: (Array of functions)
* `after:destroy`: (Array of functions)
* `plugins`: (Object)
* `LiveDOM`: (Boolean)
* returns the newly create `MODEL`
`new LiveRecord.Model.all.MODELNAME(ATTRIBUTES)`
* `ATTRIBUTES` (Object)
* returns a `MODELINSTANCE` of the the Model having `ATTRIBUTES` attributes
`MODELINSTANCE.modelName()`
* returns the model name (i.e. 'Book')
`MODELINSTANCE.attributes`
* the attributes object
`MODELINSTANCE.ATTRIBUTENAME()`
* returns the attribute value of corresponding to `ATTRIBUTENAME`. (i.e. `bookInstance.id()`, `bookInstance.created_at()`)
`MODELINSTANCE.subscribe()`
* subscribes to the `LiveRecordChannel`. This instance should already be subscribed by default after being stored, unless there is a `on:response_error` or manually `unsubscribed()` which then you should manually call this `subscribe()` function after correctly handling the response error, or whenever desired.
* returns the `subscription` object (the ActionCable subscription object itself)
`MODELINSTANCE.isSubscribed()`
* returns `true` or `false` accordingly if the instance is subscribed
`MODELINSTANCE.subscription`
* the `subscription` object (the ActionCable subscription object itself)
`MODELINSTANCE.create()`
* stores the instance to the store, and then `subscribe()` to the `LiveRecordChannel` for syncing
* returns the instance
`MODELINSTANCE.update(ATTRIBUTES)`
* `ATTRIBUTES` (Object)
* updates the attributes of the instance
* returns the instance
`MODELINSTANCE.destroy()`
* removes the instance from the store, and then `unsubscribe()`
* returns the instance
`MODELINSTANCE.addCallback(CALLBACKKEY, CALLBACKFUNCTION)`
* `CALLBACKKEY` (String) see supported callbacks above
* `CALLBACKFUNCTION` (function Object)
* returns the function Object if successfuly added, else returns `false` if callback already added
`MODELINSTANCE.removeCallback(CALLBACKKEY, CALLBACKFUNCTION)`
* `CALLBACKKEY` (String) see supported callbacks above
* `CALLBACKFUNCTION` (function Object) the function callback that will be removed
* returns the function Object if successfully removed, else returns `false` if callback is already removed
## TODOs
* Change `feature` specs into `system` specs after [this rspec-rails pull request](https://github.com/rspec/rspec-rails/pull/1813) gets merged.
## Contributing
* pull requests and forks are very much welcomed! :) Let me know if you find any bug! Thanks
## License
* MIT