[![Build Status](https://travis-ci.org/jrpolidario/live_record.svg?branch=master)](https://travis-ci.org/jrpolidario/live_record) [![Gem Version](https://badge.fury.io/rb/live_record.svg)](https://badge.fury.io/rb/live_record) ## About * Auto-syncs records in client-side JS (through a Model DSL) from changes (updates/destroy) in the backend Rails server through ActionCable. * Also supports streaming newly created records to client-side JS * Supports lost connection restreaming for both new records (create), and record-changes (updates/destroy). * Auto-updates DOM elements mapped to a record attribute, from changes (updates/destroy). **(Optional LiveDOM Plugin)** > `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. *New Version 0.2!* *See [Changelog below](#changelog)* ## Requirements * **>= Ruby 2.2.2** * **>= Rails 5.0** ## Demo * https://live-record-example.herokuapp.com/ ## Usage Example * say we have a `Book` model which has the following attributes: * `title:string` * `author:string` * `is_enabled:boolean` * on the JS client-side: ### Subscribing to Record Creation ```js // subscribe, and auto-receive newly created Book records from the Rails server LiveRecord.Model.all.Book.subscribe() // ...or only those which are enabled // LiveRecord.Model.all.Book.subscribe({where: {is_enabled_eq: true}}) // now, we can just simply add a "create" callback, to apply our own logic whenever a new Book record is streamed from the backend LiveRecord.Model.all.Book.addCallback('after:create', function() { // let's say you have a code here that adds this new Book on the page // `this` refers to the Book record that has been created console.log(this); }) ``` ### Subscribing to Record Updates/Destroy ```js // instantiate a Book object (only requirement is you pass the ID so it can be referenced when updates/destroy happen) var book = new LiveRecord.Model.all.Book({id: 1}) // ...or you can also initialise with other attributes // var book = new LiveRecord.Model.all.Book({id: 1, title: 'Harry Potter', created_at: '2017-08-02T12:39:49.238Z'}) // then 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 LiveRecord::ChangesChannel, which meant syncing (update / destroy) changes from the backend // All attributes automatically updates itself so you'll be sure that the following line (for example) is always up-to-date console.log(book.updated_at()) // you can also add a callback that will be invoked whenever the Book object has been updated (see all supported callbacks further below) // i.e. you might want to update DOM elements when the attributes have changed book.addCallback('after:update', function() { // `this` refers to the Book record that has been updated console.log(this.attributes); // this book record should have been updated with all other possible whitelisted attributes even if you just initally passed in only the ID; thus console.log above would output below // {id: 1, title: 'Harry Potter', author: 'J.K. Rowling', is_enabled: true, created_at: '2017-08-02T12:39:49.238Z', updated_at: '2017-08-02T12:39:49.238Z'} console.log(this.changes) // from above, you can also access what has changed, and would have an example output below // {title: ['Harry Potter', 'New Title'], updated_at: ['2017-08-02T12:39:49.238Z', 2017-08-02T13:00:00.047Z]} }); // 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() { console.log(this); }) ``` * on the backend-side, you can handle attributes authorisation: ```ruby # app/models/book.rb class Book < ApplicationRecord include LiveRecord::Model::Callbacks has_many :live_record_updates, as: :recordable, dependent: :destroy 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 created / updated / destroyed, there exists an `after_create_commit`, `after_update_commit` and an `after_destroy_commit` ActiveRecord callback that will broadcast changes to all subscribed JS clients ## Setup 1. Add the following to your `Gemfile`: ```ruby gem 'live_record', '~> 0.2.1' ``` 2. Run: ```bash bundle install ``` 3. 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 4. Run migration to create the `live_record_updates` table, which is going to be used for client reconnection resyncing: ```bash rake db:migrate ``` 5. 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 ``` 6. 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 include LiveRecord::Model::Callbacks has_many :live_record_updates, as: :recordable, dependent: :destroy 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 include LiveRecord::Model::Callbacks has_many :live_record_updates, as: :recordable, dependent: :destroy 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 ``` 7. 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'})` 8. 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 }) ``` 9. To automatically receive new Book records, you may subscribe: ```js // subscribe subscription = LiveRecord.Model.all.Book.subscribe(); // ...or subscribe only to certain conditions (i.e. when `is_enabled` attribute value is `true`) // For the list of supported operators (like `..._eq`), see JS API `MODEL.subscribe(CONFIG)` below // subscription = LiveRecord.Model.all.Book.subscribe({where: {is_enabled_eq: true}}); // now, we can just simply add a "create" callback, to apply our own logic whenever a new Book record is streamed from the backend LiveRecord.Model.all.Book.addCallback('after:create', function() { // let's say you have a code here that adds this new Book on the page // `this` refers to the Book record that has been created console.log(this); }) // you may also add callbacks specific to this `subscription`, as you may want to have multiple subscriptions. Then, see JS API `MODEL.subscribe(CONFIG)` below for information // then unsubscribe, as you wish LiveRecord.Model.all.Book.unsubscribe(subscription); ``` ### Ransack Search Queries (Optional) * If you need more complex queries to pass into the `.subscribe(where: { ... })` above, [ransack](https://github.com/activerecord-hackery/ransack) gem is supported. * For example you can then do: ```js // querying upon the `belongs_to :user` subscription = LiveRecord.Model.all.Book.subscribe({where: {user_is_admin_eq: true, is_enabled: true}}); // or querying "OR" conditions subscription = LiveRecord.Model.all.Book.subscribe({where: {title_eq: 'I am Batman', content_eq: 'I am Batman', m: 'or'}}); ``` #### Model File (w/ Ransack) Example ```ruby # app/models/book.rb class Book < ApplicationRecord include LiveRecord::Model::Callbacks has_many :live_record_updates, as: :recordable, dependent: :destroy def self.live_record_whitelisted_attributes(book, current_user) [:title, :is_enabled] end private # see ransack gem for more details: https://github.com/activerecord-hackery/ransack#authorization-whitelistingblacklisting # you can write your own columns here, but you may just simply allow ALL COLUMNS to be searchable, because the `live_record_whitelisted_attributes` method above will be also called anyway, and therefore just simply handle whitelisting there. # therefore you can actually remove the whole `self.ransackable_attributes` method below ## LiveRecord passes the `current_user` into `auth_object`, so you can access `current_user` inside below # def self.ransackable_attributes(auth_object = nil) # column_names + _ransackers.keys # end end ``` ### Reconnection Streaming (when client got disconnected) * Only requirement is that you should have a `created_at` attribute on your Models, which by default should already be there. However, to speed up queries, I highly suggest to add index on `created_at` with the following ```bash # this will create a file under db/migrate folder, then edit that file (see the ruby code below) rails generate migration add_created_at_index_to_MODELNAME ``` ```ruby # db/migrate/2017**********_add_created_at_index_to_MODELNAME.rb class AddCreatedAtIndexToMODELNAME < ActiveRecord::Migration[5.0] # or 5.1, etc def change add_index :TABLENAME, :created_at end end ``` ## 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.all` * Object of which properties are the models ### `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) * creates a `MODEL` and stores it into `LiveRecord.Model.all` array * returns the newly created `MODEL` ### `MODEL.all` * Object of which properties are IDs of the records ### `MODEL.subscribe(CONFIG)` * `CONFIG` (Object, Optional) * `where`: (Object) * `ATTRIBUTENAME_OPERATOR`: (Any Type) * `callbacks`: (Object) * `on:connect`: (function Object) * `on:disconnect`: (function Object) * `before:create`: (function Object) * `after:create`: (function Object) * subscribes to the `LiveRecord::PublicationsChannel`, which then automatically receives new records from the backend. * you can also pass in `callbacks` (see above). These callbacks is only applicable to this subscription, and is independent of the Model and Instance callbacks. * `ATTRIBUTENAME_OPERATOR` means something like (for example): `is_enabled_eq`, where `is_enabled` is the `ATTRIBUTENAME` and `eq` is the `OPERATOR`. * you can have as many `ATTRIBUTENAME_OPERATOR` as you like, but keep in mind that the logic applied to them is "AND", and not "OR". For "OR" conditions, use `ransack` #### List of Default Supported Query Operators > the following list only applies if you are NOT using the `ransack` gem. If you need more complex queries, `ransack` is supported and so see Setup's step 9 above * `eq` equals; i.e. `is_enabled_eq: true` * `not_eq` not equals; i.e. `title_not_eq: 'Harry Potter'` * `lt` less than; i.e. `created_at_lt: '2017-12-291T13:47:59.238Z'` * `lteq` less than or equal to; i.e. `created_at_lteq: '2017-12-291T13:47:59.238Z'` * `gt` greater than; i.e. `created_at_gt: '2017-12-291T13:47:59.238Z'` * `gteq` greater than or equal to; i.e. `created_at_gteq: '2017-12-291T13:47:59.238Z'` * `in` in Array; i.e. `id_in: [2, 56, 19, 68]` * `not_in` in Array; i.e. `id_not_in: [2, 56, 19, 68]` ### `MODEL.unsubscribe(SUBSCRIPTION)` * unsubscribes to the `LiveRecord::PublicationsChannel`, thereby will not be receiving new records anymore. ### `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 `LiveRecord::ChangesChannel`. 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.unsubscribe()` * unsubscribes to the `LiveRecord::ChangesChannel`, thereby will not be receiving changes (updates/destroy) anymore. ### `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 `LiveRecord::ChangesChannel` 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.changes` * you can **ONLY** access this inside the function callback for `before:update` and `after:update`, and is automatically cleared after * returns an object having the same format as [Rails's own `changes`](https://apidock.com/rails/ActiveModel/Dirty/changes) * i.e. `{title: ['Harry Potter', 'New Title'], updated_at: ['2017-08-02T12:39:49.238Z', 2017-08-02T13:00:00.047Z]}` ### `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 ## FAQ * How to remove the view templates being overriden by LiveRecord when generating a controller or scaffold? * amongst other things, `rails generate live_record:install` will override the default scaffold view templates: **show.html.erb** and **index.html.erb**; to revert back, just simply delete the following files (though you'll need to manually update or regenerate the view files that were already generated prior to deleting the following files): * **lib/templates/erb/scaffold/index.html.erb** * **lib/templates/erb/scaffold/show.html.erb** * How to support more complex queries / "where" conditions when subscribing to new records creation? * Please refer to [JS API's MODEL.subscribe(CONFIG) above ](#modelsubscribeconfig) ## 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 ## Changelog * 0.2 * Ability to subscribe to new records (supports lost connection auto-restreaming) * See [9th step of Setup above](#setup)