live_record 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: '00451863c5ada349c3b74b4c4ad8fd6332e748fe'
4
+ data.tar.gz: ed18de46bdb44a11f5d54fa7ff76af65202381d9
5
+ SHA512:
6
+ metadata.gz: 064c14f91640de8fb7ef723fc147c7ee475d4c2223469b1eb4fa0c2c0131e95a9b2df308f195f688bd833bdf7dd110807f15d7cceb71a6a355d87da53edf6d72
7
+ data.tar.gz: 0a4f1d3fc4312cd88757c2f4dbb434944bd7cc52d2f665d0a8a5e3d4bf58296030cae6fe6334ca31daf1005cc3e0f43dae64b1eac92f924638ecbcc4829d5bd7
@@ -0,0 +1,7 @@
1
+ /.bundle
2
+ /db/*.sqlite3
3
+ /db/*.sqlite3-journal
4
+ /log/*
5
+ /tmp/*
6
+ .byebug_history
7
+ *.gem
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
4
+
5
+ gem 'rails', '~> 5.0', '< 5.2'
@@ -0,0 +1,11 @@
1
+ GEM
2
+ remote: https://rubygems.org/
3
+ specs:
4
+
5
+ PLATFORMS
6
+ ruby
7
+
8
+ DEPENDENCIES
9
+
10
+ BUNDLED WITH
11
+ 1.12.5
@@ -0,0 +1,369 @@
1
+ *WIP! (TODO: Tests)*
2
+
3
+ ## About
4
+
5
+ * Auto-syncs records in client-side JS (through a Model DSL) from changes in the backend Rails server through ActionCable
6
+ * Auto-updates DOM elements mapped to a record attribute, from changes. **(Optional Plugin)**
7
+ * Automatically resyncs after client-side reconnection.
8
+
9
+ > `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.
10
+
11
+ ## Requirements
12
+
13
+ * **>= Rails 5.0**
14
+
15
+ ## Demo
16
+
17
+ * https://obscure-refuge-63797.herokuapp.com/
18
+
19
+ ## Usage Example
20
+
21
+ * on the client-side:
22
+
23
+ ```js
24
+ // instantiate a Book object
25
+ var book = new LiveRecord.Model.all.Book({
26
+ id: 1,
27
+ title: 'Harry Potter',
28
+ author: 'J. K. Rowling',
29
+ created_at: '2017-08-02T12:39:49.238Z',
30
+ updated_at: '2017-08-02T12:39:49.238Z'
31
+ });
32
+ // store this Book object into the JS store
33
+ book.create();
34
+
35
+ // the store is accessible through
36
+ LiveRecord.Model.all.Book.all;
37
+
38
+ // all records in the JS store are automatically subscribed to the backend LiveRecordChannel, which meant syncing (update / destroy) changes from the backend
39
+
40
+ // you can add a callback that will be invoked whenever the Book object has been updated
41
+ book.addCallback('after:update', function() {
42
+ // let's say you update the DOM elements here when the attributes have changed
43
+ });
44
+ ```
45
+
46
+ * on the backend-side, you can handle attributes authorisation:
47
+
48
+ ```ruby
49
+ # app/models/book.rb
50
+ class Book < ApplicationRecord
51
+ def self.live_record_whitelisted_attributes(book, current_user)
52
+ # Add attributes to this array that you would like `current_user` to have access to when syncing this particular `book`
53
+ # empty array means not-authorised
54
+ if book.user == current_user
55
+ [:title, :author, :created_at, :updated_at, :reference_id, :origin_address]
56
+ elsif current_user.present?
57
+ [:title, :author, :created_at, :updated_at]
58
+ else
59
+ []
60
+ end
61
+ end
62
+ end
63
+ ```
64
+
65
+ * 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
66
+
67
+ ## Setup
68
+ * Add the following to your `Gemfile`:
69
+
70
+ ```ruby
71
+ gem 'live_record', '~> 0.0.1'
72
+ ```
73
+
74
+ * Run:
75
+
76
+ ```bash
77
+ bundle install
78
+ ```
79
+
80
+ * Install by running:
81
+
82
+ ```bash
83
+ rails generate live_record:install
84
+ ```
85
+
86
+ > `rails generate live_record:install --live_dom=false` if you do not need the `LiveDom` plugin; `--live_dom=true` by default
87
+
88
+ * Run migration to create the `live_record_updates` table, which is going to be used for client reconnection resyncing:
89
+
90
+ ```bash
91
+ rake db:migrate
92
+ ```
93
+
94
+ * Update your **app/channels/application_cable/connection.rb**, and add `current_user` method, unless you already have it:
95
+
96
+ ```ruby
97
+ module ApplicationCable
98
+ class Connection < ActionCable::Connection::Base
99
+ identified_by :current_user
100
+
101
+ def current_user
102
+ # write something here if you have a current_user, or you may just leave this blank. Example below when using `devise` gem:
103
+ # User.find_by(id: cookies.signed[:user_id])
104
+ end
105
+ end
106
+ end
107
+ ```
108
+
109
+ * Update your **model** files (only those you would want to be synced), and insert the following public method:
110
+
111
+ > automatically updated if you use Rails scaffold or model generator
112
+
113
+ ### Example 1 - Simple Usage
114
+
115
+ ```ruby
116
+ # app/models/book.rb (example 1)
117
+ class Book < ApplicationRecord
118
+ def self.live_record_whitelisted_attributes(book, current_user)
119
+ # Add attributes to this array that you would like current_user to have access to when syncing.
120
+ # Defaults to empty array, thereby blocking everything by default, only unless explicitly stated here so.
121
+ [:title, :author, :created_at, :updated_at]
122
+ end
123
+ end
124
+ ```
125
+
126
+ ### Example 2 - Advanced Usage
127
+
128
+ ```ruby
129
+ # app/models/book.rb (example 1)
130
+ class Book < ApplicationRecord
131
+ def self.live_record_whitelisted_attributes(book, current_user)
132
+ # Notice that from above, you also have access to `book` (the record currently requested by the client to be synced),
133
+ # and the `current_user`, the current user who is trying to sync the `book` record.
134
+ if book.user == current_user
135
+ [:title, :author, :created_at, :updated_at, :reference_id, :origin_address]
136
+ elsif current_user.present?
137
+ [:title, :author, :created_at, :updated_at]
138
+ else
139
+ []
140
+ end
141
+ end
142
+ end
143
+ ```
144
+
145
+ * For each Model you want to sync, insert the following in your Javascript files.
146
+
147
+ > automatically updated if you use Rails scaffold or controller generator
148
+
149
+ ### Example 1 - Model
150
+
151
+ ```js
152
+ // app/assets/javascripts/books.js
153
+ LiveRecord.Model.create(
154
+ {
155
+ modelName: 'Book' // should match the Rails model name
156
+ plugins: {
157
+ LiveDOM: true // remove this if you do not need `LiveDom`
158
+ }
159
+ }
160
+ )
161
+ ```
162
+
163
+ ### Example 2 - Model + Callbacks
164
+
165
+ ```js
166
+ // app/assets/javascripts/books.js
167
+ LiveRecord.Model.create(
168
+ {
169
+ modelName: 'Book',
170
+ callbacks: {
171
+ 'on:connect': [
172
+ function() {
173
+ console.log(this); // `this` refers to the current `Book` record that has just connected for syncing
174
+ }
175
+ ],
176
+ 'after:update': [
177
+ function() {
178
+ console.log(this); // `this` refers to the current `Book` record that has just been updated with changes synced from the backend
179
+ }
180
+ ]
181
+ }
182
+ }
183
+ )
184
+ ```
185
+
186
+ #### Model Callbacks supported:
187
+ * `on:connect`
188
+ * `on:disconnect`
189
+ * `on:response_error`
190
+ * `before:create`
191
+ * `after:create`
192
+ * `before:update`
193
+ * `after:update`
194
+ * `before:destroy`
195
+ * `after:destroy`
196
+
197
+ > Each callback should map to an array of functions
198
+
199
+ * `on:response_error` supports a function argument: The "Error Code". i.e.
200
+
201
+ ### Example 3 - Handling Response Error
202
+
203
+ ```js
204
+ LiveRecord.Model.create(
205
+ {
206
+ modelName: 'Book',
207
+ callbacks: {
208
+ 'on:response_error': [
209
+ function(errorCode) {
210
+ console.log(errorCode); // errorCode is a string, representing the type of error. See Response Error Codes below:
211
+ }
212
+ ]
213
+ }
214
+ }
215
+ )
216
+ ```
217
+
218
+ #### Response Error Codes:
219
+ * `"forbidden"` - Current User is not authorized to sync record changes. Happens when Model's `live_record_whitelisted_attributes` method returns empty array.
220
+ * `"bad_request"` - Happens when `LiveRecord.Model.create({modelName: 'INCORRECTMODELNAME'})`
221
+
222
+ * Load the records into the JS Model-store through JSON REST (i.e.):
223
+
224
+ ### Example 1 - Using Default Loader (Requires JQuery)
225
+
226
+ > Your controller must also support responding with JSON in addition to HTML. If you used scaffold or controller generator, this should already work immediately.
227
+
228
+ ```html
229
+ <!-- app/views/books/index.html.erb -->
230
+ <script>
231
+ // `loadRecords` asynchronously loads all records (using the current URL) to the store, through a JSON AJAX request.
232
+ // in this example, `loadRecords` will load JSON from the current URL which is /books
233
+ LiveRecord.helpers.loadRecords({modelName: 'Book'})
234
+ </script>
235
+ ```
236
+
237
+ ```html
238
+ <!-- app/views/posts/index.html.erb -->
239
+ <script>
240
+ // You may also pass in a callback for synchronous logic
241
+ LiveRecord.helpers.loadRecords({
242
+ modelName: 'Book',
243
+ onLoad: function(records) {
244
+ // ...
245
+ },
246
+ onError: function(jqxhr, textStatus, error) {
247
+ // ...
248
+ }
249
+ })
250
+ </script>
251
+ ```
252
+
253
+ ### Example 2 - Using Custom Loader
254
+
255
+ ```js
256
+ // do something here that will fetch Book record attributes...
257
+ // as an example, say you already have the following attributes:
258
+ var book1Attributes = { id: 1, title: 'Noli Me Tangere', author: 'José Rizal' }
259
+ var book2Attributes = { id: 2, title: 'ABNKKBSNPLAko?!', author: 'Bob Ong' }
260
+
261
+ // then we instantiate a Book object
262
+ var book1 = new LiveRecord.Model.all.Book(book1Attributes);
263
+ // then we push this Book object to the Book store, which then automatically subscribes them to changes in the backend
264
+ book1.create();
265
+
266
+ var book2 = new LiveRecord.Model.all.Book(book2Attributes);
267
+ book2.create();
268
+
269
+ // you can also add Instance callbacks specific only to this Object (supported callbacks are the same as the Model callbacks)
270
+ book2.addCallback('after:update', function() {
271
+ // do something when book2 has been updated after syncing
272
+ })
273
+ ```
274
+
275
+
276
+ ## Plugins
277
+
278
+ ### LiveDom (Requires JQuery)
279
+
280
+ * enabled by default, unless explicitly removed.
281
+ * `LiveDom` allows DOM elements' text content to be automatically updated, whenever the mapped record-attribute has been updated.
282
+
283
+ > text content is safely escaped using JQuery's `.text()` function
284
+
285
+ #### Example 1 (Mapping to a Record-Attribute: `after:update`)
286
+ ```html
287
+ <span data-live-record-update-from='Book-24-title'>Harry Potter</span>
288
+ ```
289
+
290
+ * `data-live-record-update-from` format should be `MODELNAME-RECORDID-RECORDATTRIBUTE`
291
+ * whenever `LiveRecord.all.Book.all[24]` has been updated/synced from backend, "Harry Potter" text above changes accordingly.
292
+ * this does not apply to only `<span>` elements. You can use whatever elements you like.
293
+
294
+ #### Example 2 (Mapping to a Record: `after:destroy`)
295
+
296
+ ```html
297
+ <section data-live-record-destroy-from='Book-31'>This example element is a container for the Book-31 record which can also contain children elements</section>
298
+ ```
299
+
300
+ * `data-live-record-destroy-from` format should be `MODELNAME-RECORDID`
301
+ * whenever `LiveRecord.all.Book.all[31]` has been destroyed/synced from backend, the `<section>` element above is removed, and thus all of its children elements.
302
+ * this does not apply to only `<section>` elements. You can use whatever elements you like.
303
+
304
+ * You may combine `data-live-record-destroy-from` and `data-live-record-update-from` within the same element.
305
+
306
+ ## JS API
307
+
308
+ `LiveRecord.Model.create(CONFIG)`
309
+ * `CONFIG` (Object)
310
+ * `modelName`: (String, Required)
311
+ * `callbacks`: (Object)
312
+ * `on:connect`: (Array of functions)
313
+ * `on:disconnect`: (Array of functions)
314
+ * `on:response_error`: (Array of functions; function argument = ERROR_CODE (String))
315
+ * `before:create`: (Array of functions)
316
+ * `after:create`: (Array of functions)
317
+ * `before:update`: (Array of functions)
318
+ * `after:update`: (Array of functions)
319
+ * `before:destroy`: (Array of functions)
320
+ * `after:destroy`: (Array of functions)
321
+ * `plugins`: (Object)
322
+ * `LiveDom`: (Boolean)
323
+ * returns the newly create `MODEL`
324
+
325
+ `new LiveRecord.Model.all.MODELNAME(ATTRIBUTES)`
326
+ * `ATTRIBUTES` (Object)
327
+ * returns a `MODELINSTANCE` of the the Model having `ATTRIBUTES` attributes
328
+
329
+ `MODELINSTANCE.modelName()`
330
+ * returns the model name (i.e. 'Book')
331
+
332
+ `MODELINSTANCE.attributes`
333
+ * the attributes object
334
+
335
+ `MODELINSTANCE.ATTRIBUTENAME()`
336
+ * returns the attribute value of corresponding to `ATTRIBUTENAME`. (i.e. `bookInstance.id()`, `bookInstance.created_at()`)
337
+
338
+ `MODELINSTANCE.subscribe()`
339
+ * 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.
340
+ * returns the `subscription` object (the ActionCable subscription object itself)
341
+
342
+ `MODELINSTANCE.isSubscribed()`
343
+ * returns `true` or `false` accordingly if the instance is subscribed
344
+
345
+ `MODELINSTANCE.subscription`
346
+ * the `subscription` object (the ActionCable subscription object itself)
347
+
348
+ `MODELINSTANCE.create()`
349
+ * stores the instance to the store, and then `subscribe()` to the `LiveRecordChannel` for syncing
350
+ * returns the instance
351
+
352
+ `MODELINSTANCE.update(ATTRIBUTES)`
353
+ * `ATTRIBUTES` (Object)
354
+ * updates the attributes of the instance
355
+ * returns the instance
356
+
357
+ `MODELINSTANCE.destroy()`
358
+ * removes the instance from the store, and then `unsubscribe()`
359
+ * returns the instance
360
+
361
+ `MODELINSTANCE.addCallback(CALLBACKKEY, CALLBACKFUNCTION)`
362
+ * `CALLBACKKEY` (String) see supported callbacks above
363
+ * `CALLBACKFUNCTION` (function Object)
364
+ * returns the function Object if successfuly added, else returns `false` if callback already added
365
+
366
+ `MODELINSTANCE.removeCallback(CALLBACKKEY, CALLBACKFUNCTION)`
367
+ * `CALLBACKKEY` (String) see supported callbacks above
368
+ * `CALLBACKFUNCTION` (function Object) the function callback that will be removed
369
+ * returns the function Object if successfully removed, else returns `false` if callback is already removed
@@ -0,0 +1,8 @@
1
+ //= require_self
2
+ //= require_directory ./live_record/
3
+
4
+ this.LiveRecord || (this.LiveRecord = {});
5
+
6
+ if (window.jQuery === undefined) {
7
+ throw new Error('jQuery is not loaded yet, and is a dependency of LiveRecord')
8
+ }
@@ -0,0 +1,4 @@
1
+ //= require_self
2
+ //= require_directory ./helpers/
3
+
4
+ this.LiveRecord.helpers || (this.LiveRecord.helpers = {});
@@ -0,0 +1,31 @@
1
+ LiveRecord.helpers.loadRecords = (args) ->
2
+ args['modelName'] || throw new Error(':modelName argument required')
3
+ throw new Error(':modelName is not defined in LiveRecord.Model.all') if LiveRecord.Model.all[args['modelName']] == undefined
4
+
5
+ args['url'] ||= window.location.href
6
+
7
+ $.getJSON(
8
+ args['url']
9
+ ).done(
10
+ (data) ->
11
+ # Array JSON
12
+ if $.isArray(data)
13
+ records_attributes = data;
14
+ records = []
15
+
16
+ for record_attributes in records_attributes
17
+ record = new LiveRecord.Model.all[args['modelName']](record_attributes);
18
+ record.create();
19
+ records << record
20
+
21
+ # Single-Record JSON
22
+ else
23
+ record_attributes = data
24
+ record = new LiveRecord.Model.all[args['modelName']](record_attributes);
25
+ record.create();
26
+
27
+ args['onLoad'].call(this, records) if args['onLoad']
28
+ ).fail(
29
+ (jqxhr, textStatus, error) ->
30
+ args['onError'].call(this, jqxhr, textStatus, error) if args['onError']
31
+ )
@@ -0,0 +1,4 @@
1
+ //= require_self
2
+ //= require_directory ./model/
3
+
4
+ this.LiveRecord.Model || (this.LiveRecord.Model = {});
@@ -0,0 +1 @@
1
+ LiveRecord.Model.all = {}
@@ -0,0 +1,192 @@
1
+ LiveRecord.Model.create = (config) ->
2
+ config.modelName != undefined || throw new Error('missing :modelName argument')
3
+ config.callbacks != undefined || config.callbacks = {}
4
+ config.plugins != undefined || config.callbacks = {}
5
+
6
+ # NEW
7
+ Model = (attributes) ->
8
+ this.attributes = attributes
9
+ # instance callbacks
10
+ this._callbacks = {
11
+ 'on:connect': [],
12
+ 'on:disconnect': [],
13
+ 'on:response_error': [],
14
+ 'before:create': [],
15
+ 'after:create': [],
16
+ 'before:update': [],
17
+ 'after:update': [],
18
+ 'before:destroy': [],
19
+ 'after:destroy': []
20
+ }
21
+
22
+ Object.keys(this.attributes).forEach (attribute_key) ->
23
+ if Model.prototype[attribute_key] == undefined
24
+ Model.prototype[attribute_key] = ->
25
+ this.attributes[attribute_key]
26
+ this
27
+
28
+ Model.modelName = config.modelName
29
+
30
+ Model.prototype.modelName = ->
31
+ Model.modelName
32
+
33
+ Model.prototype.subscribe = ->
34
+ return this.subscription if this.subscription != undefined
35
+
36
+ # listen for record changes (update / destroy)
37
+ subscription = App['live_record_' + this.modelName() + '_' + this.id()] = App.cable.subscriptions.create({
38
+ channel: 'LiveRecordChannel'
39
+ model_name: this.modelName()
40
+ record_id: this.id()
41
+ },
42
+ record: ->
43
+ return @_record if @_record
44
+ identifier = JSON.parse(this.identifier)
45
+ @_record = Model.all[identifier.record_id]
46
+
47
+ # on: connect
48
+ connected: ->
49
+ if @record()._staleSince != undefined
50
+ @syncRecord(@record())
51
+
52
+ @record()._callCallbacks('on:connect', undefined)
53
+
54
+ # on: disconnect
55
+ disconnected: ->
56
+ @record()._staleSince = (new Date()).toISOString() unless @record()._staleSince
57
+ @record()._callCallbacks('on:disconnect', undefined)
58
+
59
+ # on: receive
60
+ received: (data) ->
61
+ if data.error
62
+ @record()._staleSince = (new Date()).toISOString() unless @record()._staleSince
63
+ @onError[data.error.code].call(this, data)
64
+ @record()._callCallbacks('on:response_error', [data.error.code])
65
+ delete @record()['subscription']
66
+ else
67
+ @onAction[data.action].call(this, data)
68
+
69
+ # handler for received() callback above
70
+ onAction:
71
+ update: (data) ->
72
+ @record().update(data.attributes)
73
+
74
+ destroy: (data) ->
75
+ @record().destroy()
76
+
77
+ # handler for received() callback above
78
+ onError:
79
+ forbidden: (data) ->
80
+ console.error('[LiveRecord Response Error]', data.error.code, ':', data.error.message, 'for', @record())
81
+ bad_request: (data) ->
82
+ console.error('[LiveRecord Response Error]', data.error.code, ':', data.error.message, 'for', @record())
83
+
84
+ # syncs local record from remote record
85
+ syncRecord: ->
86
+ @perform(
87
+ 'sync_record',
88
+ model_name: @record().modelName(),
89
+ record_id: @record().id(),
90
+ stale_since: @record()._staleSince
91
+ )
92
+ @record()._staleSince = undefined
93
+ )
94
+
95
+ this.subscription = subscription
96
+
97
+ Model.prototype.unsubscribe = ->
98
+ return if this.subscription == undefined
99
+ App.cable.subscriptions.remove(this.subscription)
100
+ delete this['subscription']
101
+
102
+ Model.prototype.isSubscribed = ->
103
+ this.subscription != undefined
104
+
105
+ # ALL
106
+ Model.all = {}
107
+
108
+ # CREATE
109
+ Model.prototype.create = () ->
110
+ this._callCallbacks('before:create', undefined)
111
+
112
+ Model.all[this.attributes.id] = this
113
+ this.subscribe()
114
+
115
+ this._callCallbacks('after:create', undefined)
116
+ this
117
+
118
+ # UPDATE
119
+ Model.prototype.update = (attributes) ->
120
+ this._callCallbacks('before:update', undefined)
121
+
122
+ self = this
123
+ Object.keys(attributes).forEach (attribute_key) ->
124
+ self.attributes[attribute_key] = attributes[attribute_key]
125
+
126
+ this._callCallbacks('after:update', undefined)
127
+ true
128
+
129
+ # DESTROY
130
+ Model.prototype.destroy = ->
131
+ this._callCallbacks('before:destroy', undefined)
132
+
133
+ this.unsubscribe()
134
+ delete Model.all[this.attributes.id]
135
+
136
+ this._callCallbacks('after:destroy', undefined)
137
+ this
138
+
139
+ # CALLBACKS
140
+
141
+ ## class callbacks
142
+ Model._callbacks = {
143
+ 'on:connect': [],
144
+ 'on:disconnect': [],
145
+ 'on:response_error': [],
146
+ 'before:create': [],
147
+ 'after:create': [],
148
+ 'before:update': [],
149
+ 'after:update': [],
150
+ 'before:destroy': [],
151
+ 'after:destroy': []
152
+ }
153
+
154
+ Model.addCallback = Model.prototype.addCallback = (callbackKey, callbackFunction) ->
155
+ index = this._callbacks[callbackKey].indexOf(callbackFunction)
156
+ if index == -1
157
+ this._callbacks[callbackKey].push(callbackFunction)
158
+ return callbackFunction
159
+
160
+ Model.removeCallback = Model.prototype.removeCallback = (callbackKey, callbackFunction) ->
161
+ index = this._callbacks[callbackKey].indexOf(callbackFunction)
162
+ if index != -1
163
+ this._callbacks[callbackKey].splice(index, 1)
164
+ return callbackFunction
165
+
166
+ Model.prototype._callCallbacks = (callbackKey, args) ->
167
+ # call class callbacks
168
+ for callback in Model._callbacks[callbackKey]
169
+ callback.apply(this, args)
170
+
171
+ # call instance callbacks
172
+ for callback in this._callbacks[callbackKey]
173
+ callback.apply(this, args)
174
+
175
+ # AFTER MODEL INITIALISATION
176
+
177
+ # add callbacks from arguments
178
+ for callbackKey, callbackFunctions of config.callbacks
179
+ for callbackFunction in callbackFunctions
180
+ Model.addCallback(callbackKey, callbackFunction)
181
+
182
+
183
+ # enable plugins from arguments
184
+ for pluginKey, pluginValue of config.plugins
185
+ if LiveRecord.plugins
186
+ index = Object.keys(LiveRecord.plugins).indexOf(pluginKey)
187
+ LiveRecord.plugins[pluginKey].applyToModel(Model, pluginValue) if index != -1
188
+
189
+ # add new Model to collection
190
+ LiveRecord.Model.all[config.modelName] = Model
191
+
192
+ Model
@@ -0,0 +1,3 @@
1
+ //= require_self
2
+
3
+ this.LiveRecord.plugins || (this.LiveRecord.plugins = {});
@@ -0,0 +1,18 @@
1
+ this.LiveRecord.plugins.LiveDOM || (this.LiveRecord.plugins.LiveDOM = {});
2
+
3
+ LiveRecord.plugins.LiveDOM.applyToModel = (Model, pluginValue) ->
4
+ return if pluginValue != true
5
+
6
+ # DOM callbacks
7
+
8
+ Model._updateDomCallback = ->
9
+ $updateableElements = $('[data-live-record-update-from]')
10
+
11
+ for key, value of this.attributes
12
+ $updateableElements.filter('[data-live-record-update-from="' + Model.modelName + '-' + this.id() + '-' + key + '"]').text(this[key]())
13
+
14
+ Model._destroyDomCallback = ->
15
+ $('[data-live-record-destroy-from="' + Model.modelName + '-' + this.id() + '"]').remove()
16
+
17
+ Model.addCallback('after:update', Model._updateDomCallback)
18
+ Model.addCallback('after:destroy', Model._destroyDomCallback)
@@ -0,0 +1,8 @@
1
+ require 'rails'
2
+ require 'active_support/concern'
3
+
4
+ Dir[__dir__ + '/live_record/*.rb'].each {|file| require file }
5
+ Dir[__dir__ + '/live_record/generators/*.rb'].each {|file| require file }
6
+
7
+ module LiveRecord
8
+ end
@@ -0,0 +1,99 @@
1
+ module LiveRecord
2
+ module Channel
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ def subscribed
7
+ find_record_from_params(params) do |record|
8
+ authorised_attributes = authorised_attributes(record, current_user)
9
+
10
+ if authorised_attributes.present?
11
+ stream_for record, coder: ActiveSupport::JSON do |message|
12
+ record.reload
13
+ authorised_attributes = authorised_attributes(record, current_user)
14
+
15
+ if authorised_attributes.present?
16
+ response = filtered_message(message, authorised_attributes)
17
+ transmit response if response.present?
18
+ else
19
+ respond_with_error(:forbidden)
20
+ reject_subscription
21
+ end
22
+ end
23
+ else
24
+ respond_with_error(:forbidden)
25
+ reject
26
+ end
27
+ end
28
+ end
29
+
30
+ def sync_record(data)
31
+ find_record_from_params(data.symbolize_keys) do |record|
32
+ authorised_attributes = authorised_attributes(record, current_user)
33
+
34
+ if authorised_attributes.present?
35
+ live_record_update = LiveRecordUpdate.where(
36
+ recordable_type: record.class.name,
37
+ recordable_id: record.id
38
+ ).where(
39
+ 'created_at >= ?', DateTime.parse(data['stale_since']) - 1.minute
40
+ ).order(id: :asc)
41
+
42
+ if live_record_update.exists?
43
+ message = { 'action' => 'update', 'attributes' => record.attributes }
44
+ response = filtered_message(message, authorised_attributes)
45
+ transmit response if response.present?
46
+ end
47
+ else
48
+ respond_with_error(:forbidden)
49
+ reject_subscription
50
+ end
51
+ end
52
+ end
53
+
54
+ def unsubscribed
55
+ # Any cleanup needed when channel is unsubscribed
56
+ end
57
+
58
+ private
59
+
60
+ def authorised_attributes(record, current_user)
61
+ return record.class.live_record_whitelisted_attributes(record, current_user).map(&:to_s)
62
+ end
63
+
64
+ def filtered_message(message, filters)
65
+ if message['attributes'].present?
66
+ message['attributes'].slice!(*filters)
67
+ end
68
+ message
69
+ end
70
+
71
+ def find_record_from_params(params)
72
+ model_class = params[:model_name].safe_constantize
73
+
74
+ if model_class && model_class < ApplicationRecord
75
+ record = model_class.find_by(id: params[:record_id])
76
+
77
+ if record.present?
78
+ yield record
79
+ else
80
+ transmit 'action' => 'destroy'
81
+ end
82
+ else
83
+ respond_with_error(:bad_request, 'Not a correct model name')
84
+ reject_subscription
85
+ end
86
+ end
87
+
88
+ def respond_with_error(type, message = nil)
89
+ case type
90
+ when :forbidden
91
+ transmit error: { 'code' => 'forbidden', 'message' => (message || 'You are not authorised') }
92
+ when :bad_request
93
+ transmit error: { 'code' => 'bad_request', 'message' => (message || 'Invalid request parameters') }
94
+ end
95
+ end
96
+
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,4 @@
1
+ module LiveRecord
2
+ class Engine < Rails::Engine
3
+ end
4
+ end
@@ -0,0 +1,72 @@
1
+ module LiveRecord
2
+ module Generators
3
+ class InstallGenerator < Rails::Generators::Base
4
+ include Rails::Generators::Migration
5
+
6
+ desc 'Copy LiveRecord Javascript template'
7
+ source_root File.expand_path('../templates', __FILE__)
8
+
9
+ class_option :live_dom, desc: 'Enables LiveDom plugin: [true, false]', default: 'true'
10
+ class_option :javascript_engine, desc: 'JS engine to be used: [js, coffee]'
11
+ class_option :template_engine, desc: 'Template engine to be used (if LiveDom plugin enabled): [erb, slim, haml]'
12
+
13
+ def copy_assets_javascript_template
14
+ copy_file "javascript.#{javascript_engine}.rb", "lib/templates/#{javascript_engine}/assets/javascript.#{javascript_engine}"
15
+ end
16
+
17
+ def copy_model_template
18
+ copy_file "model.rb.rb", "lib/templates/active_record/model/model.rb"
19
+ end
20
+
21
+ def copy_scaffold_index_template
22
+ copy_file "index.html.#{template_engine}", "lib/templates/#{template_engine}/scaffold/index.html.#{template_engine}" if live_dom
23
+ end
24
+
25
+ def copy_scaffold_show_template
26
+ copy_file "show.html.#{template_engine}", "lib/templates/#{template_engine}/scaffold/show.html.#{template_engine}" if live_dom
27
+ end
28
+
29
+ def copy_live_record_update_model_template
30
+ class_collisions 'LiveRecordUpdate'
31
+ template 'live_record_update.rb', File.join('app/models', 'live_record_update.rb')
32
+ migration_template 'create_live_record_updates.rb', 'db/migrate/create_live_record_updates.rb'
33
+ end
34
+
35
+ def copy_live_record_channel_template
36
+ class_collisions 'LiveRecordChannel'
37
+ template 'live_record_channel.rb', File.join('app/channels', 'live_record_channel.rb')
38
+ end
39
+
40
+ def update_application_record
41
+ in_root do
42
+ insert_into_file 'app/models/application_record.rb', " include LiveRecord::Model\n", after: "self.abstract_class = true\n"
43
+ end
44
+ end
45
+
46
+ def update_application_javascript
47
+ in_root do
48
+ insert_into_file 'app/assets/javascripts/application.js', "//= require live_record\n", before: "//= require_tree ."
49
+ insert_into_file 'app/assets/javascripts/application.js', "//= require live_record/plugins/live_dom\n", before: "//= require_tree ." if live_dom
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def self.next_migration_number(dir)
56
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
57
+ end
58
+
59
+ def javascript_engine
60
+ Rails.application.config.generators.options[:rails][:javascript_engine] || options[:javascript_engine]
61
+ end
62
+
63
+ def template_engine
64
+ Rails.application.config.generators.options[:rails][:template_engine] || options[:template_engine]
65
+ end
66
+
67
+ def live_dom
68
+ options[:live_dom] == 'true' ? true : options[:live_dom] == 'false' ? false : raise(ArgumentError, 'invalid value for --live_dom. Can only be `true` or `false`')
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,8 @@
1
+ class CreateLiveRecordUpdates < ActiveRecord::Migration[5.0]
2
+ def change
3
+ create_table :live_record_updates do |t|
4
+ t.references :recordable, polymorphic: true
5
+ t.datetime :created_at, null: false, index: true
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,34 @@
1
+ <script>
2
+ LiveRecord.helpers.loadRecords({modelName: '<%= singular_table_name.camelcase %>'})
3
+ </script>
4
+ <p id="notice"><%%= notice %></p>
5
+
6
+ <h1><%= plural_table_name.titleize %></h1>
7
+
8
+ <table>
9
+ <thead>
10
+ <tr>
11
+ <% attributes.reject(&:password_digest?).each do |attribute| -%>
12
+ <th><%= attribute.human_name %></th>
13
+ <% end -%>
14
+ <th colspan="3"></th>
15
+ </tr>
16
+ </thead>
17
+
18
+ <tbody>
19
+ <%% @<%= plural_table_name %>.each do |<%= singular_table_name %>| %>
20
+ <tr data-live-record-destroy-from='<%= singular_table_name.camelcase %>-<%%= <%= singular_table_name %>.id %>'>
21
+ <% attributes.reject(&:password_digest?).each do |attribute| -%>
22
+ <td data-live-record-update-from='<%= singular_table_name.camelcase %>-<%%= <%= singular_table_name %>.id %>-<%= attribute.name %>'><%%= <%= singular_table_name %>.<%= attribute.name %> %></td>
23
+ <% end -%>
24
+ <td><%%= link_to 'Show', <%= singular_table_name %> %></td>
25
+ <td><%%= link_to 'Edit', edit_<%= singular_table_name %>_path(<%= singular_table_name %>) %></td>
26
+ <td><%%= link_to 'Destroy', <%= singular_table_name %>, method: :delete, data: { confirm: 'Are you sure?' } %></td>
27
+ </tr>
28
+ <%% end %>
29
+ </tbody>
30
+ </table>
31
+
32
+ <br>
33
+
34
+ <%%= link_to 'New <%= singular_table_name.titleize %>', new_<%= singular_table_name %>_path %>
@@ -0,0 +1,16 @@
1
+ <% module_namespacing do -%>
2
+ LiveRecord.Model.create(
3
+ {
4
+ modelName: '<%= singular_table_name.camelcase %>',
5
+ plugins: {
6
+ LiveDOM: true
7
+ },
8
+ # See TODO: URL_TO_DOCUMENTATION for supported callbacks
9
+ # Add Callbacks (callback name => array of functions)
10
+ # callbacks: {
11
+ # 'on:disconnect': [],
12
+ # 'after:update': [],
13
+ # }
14
+ }
15
+ )
16
+ <% end -%>
@@ -0,0 +1,16 @@
1
+ <% module_namespacing do -%>
2
+ LiveRecord.Model.create(
3
+ {
4
+ modelName: '<%= file_name.singularize.camelcase %>',
5
+ plugins: {
6
+ LiveDOM: true
7
+ },
8
+ // See TODO: URL_TO_DOCUMENTATION for supported callbacks
9
+ // Add Callbacks (callback name => array of functions)
10
+ // callbacks: {
11
+ // 'on:disconnect': [],
12
+ // 'after:update': [],
13
+ // }
14
+ }
15
+ )
16
+ <% end -%>
@@ -0,0 +1,4 @@
1
+ class LiveRecordChannel < ApplicationCable::Channel
2
+ include ActiveSupport::Rescuable
3
+ include LiveRecord::Channel
4
+ end
@@ -0,0 +1,3 @@
1
+ class LiveRecordUpdate < ApplicationRecord
2
+ belongs_to :recordable, polymorphic: true
3
+ end
@@ -0,0 +1,19 @@
1
+ <% module_namespacing do -%>
2
+ class <%= class_name %> < <%= parent_class_name.classify %>
3
+ <% attributes.select(&:reference?).each do |attribute| -%>
4
+ belongs_to :<%= attribute.name %><%= ', polymorphic: true' if attribute.polymorphic? %><%= ', required: true' if attribute.required? %>
5
+ <% end -%>
6
+ <% attributes.select(&:token?).each do |attribute| -%>
7
+ has_secure_token<% if attribute.name != "token" %> :<%= attribute.name %><% end %>
8
+ <% end -%>
9
+ <% if attributes.any?(&:password_digest?) -%>
10
+ has_secure_password
11
+ <% end -%>
12
+
13
+ def self.live_record_whitelisted_attributes(<%= class_name.underscore %>, current_user)
14
+ # Add attributes to this array that you would like current_user to have access to.
15
+ # Defaults to empty array, thereby blocking everything by default, only unless explicitly stated here so.
16
+ []
17
+ end
18
+ end
19
+ <% end -%>
@@ -0,0 +1,16 @@
1
+ <script>
2
+ LiveRecord.helpers.loadRecords({modelName: '<%= singular_table_name.camelcase %>'})
3
+ </script>
4
+ <p id="notice"><%%= notice %></p>
5
+
6
+ <% attributes.reject(&:password_digest?).each do |attribute| -%>
7
+ <p>
8
+ <strong><%= attribute.human_name %>:</strong>
9
+ <span data-live-record-update-from='<%= singular_table_name.camelcase %>-<%%= @<%= singular_table_name %>.id %>-<%= attribute.name %>'>
10
+ <%%= @<%= singular_table_name %>.<%= attribute.name %> %>
11
+ </span>
12
+ </p>
13
+
14
+ <% end -%>
15
+ <%%= link_to 'Edit', edit_<%= singular_table_name %>_path(@<%= singular_table_name %>) %> |
16
+ <%%= link_to 'Back', <%= index_helper %>_path %>
@@ -0,0 +1,36 @@
1
+ module LiveRecord
2
+ module Model
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ has_many :live_record_updates, as: :recordable
7
+
8
+ after_update :__live_record_reference_changed_attributes__
9
+ after_update_commit :__live_record_broadcast_record_update__
10
+ after_destroy_commit :__live_record_broadcast_record_destroy__
11
+
12
+ def self.live_record_whitelisted_attributes(record, current_user)
13
+ []
14
+ end
15
+
16
+ private
17
+
18
+ def __live_record_reference_changed_attributes__
19
+ @_live_record_changed_attributes = changed
20
+ end
21
+
22
+ def __live_record_broadcast_record_update__
23
+ included_attributes = attributes.slice(*@_live_record_changed_attributes)
24
+ @_live_record_changed_attributes = nil
25
+ message_data = { 'action' => 'update', 'attributes' => included_attributes }
26
+ LiveRecordChannel.broadcast_to(self, message_data)
27
+ LiveRecordUpdate.create!(recordable: self)
28
+ end
29
+
30
+ def __live_record_broadcast_record_destroy__
31
+ message_data = { 'action' => 'destroy' }
32
+ LiveRecordChannel.broadcast_to(self, message_data)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,3 @@
1
+ module LiveRecord
2
+ VERSION = '0.0.1'.freeze
3
+ end
@@ -0,0 +1,20 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'live_record/version'
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = 'live_record'
7
+ s.version = LiveRecord::VERSION
8
+ s.date = '2017-08-03'
9
+ s.summary = 'Rails 5 ActionCable Live JS Objects and DOM Elements'
10
+ s.description = "Auto-syncs records in client-side JS (through a Model DSL) from changes in the backend Rails server through ActionCable.\nAuto-updates DOM elements mapped to a record attribute, from changes.\nAutomatically resyncs after client-side reconnection."
11
+ s.authors = ['Jules Roman B. Polidario']
12
+ s.email = 'jrpolidario@gmail.com'
13
+ s.files = `git ls-files`.split("\n")
14
+ s.homepage = 'https://github.com/jrpolidario/live_record'
15
+ s.license = 'MIT'
16
+ s.required_ruby_version = '~> 2.0'
17
+
18
+ s.add_dependency 'rails', '~> 5.0', '< 5.2'
19
+ s.add_development_dependency 'byebug', '~> 9.0'
20
+ end
metadata ADDED
@@ -0,0 +1,107 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: live_record
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Jules Roman B. Polidario
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-08-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '5.2'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '5.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.2'
33
+ - !ruby/object:Gem::Dependency
34
+ name: byebug
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '9.0'
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '9.0'
47
+ description: |-
48
+ Auto-syncs records in client-side JS (through a Model DSL) from changes in the backend Rails server through ActionCable.
49
+ Auto-updates DOM elements mapped to a record attribute, from changes.
50
+ Automatically resyncs after client-side reconnection.
51
+ email: jrpolidario@gmail.com
52
+ executables: []
53
+ extensions: []
54
+ extra_rdoc_files: []
55
+ files:
56
+ - ".gitignore"
57
+ - Gemfile
58
+ - Gemfile.lock
59
+ - README.md
60
+ - app/assets/javascripts/live_record.js
61
+ - app/assets/javascripts/live_record/helpers.js
62
+ - app/assets/javascripts/live_record/helpers/load_records.coffee
63
+ - app/assets/javascripts/live_record/model.js
64
+ - app/assets/javascripts/live_record/model/all.coffee
65
+ - app/assets/javascripts/live_record/model/create.coffee
66
+ - app/assets/javascripts/live_record/plugins.js
67
+ - app/assets/javascripts/live_record/plugins/live_dom.coffee
68
+ - lib/live_record.rb
69
+ - lib/live_record/channel.rb
70
+ - lib/live_record/engine.rb
71
+ - lib/live_record/generators/install_generator.rb
72
+ - lib/live_record/generators/templates/create_live_record_updates.rb
73
+ - lib/live_record/generators/templates/index.html.erb
74
+ - lib/live_record/generators/templates/javascript.coffee.rb
75
+ - lib/live_record/generators/templates/javascript.js.rb
76
+ - lib/live_record/generators/templates/live_record_channel.rb
77
+ - lib/live_record/generators/templates/live_record_update.rb
78
+ - lib/live_record/generators/templates/model.rb.rb
79
+ - lib/live_record/generators/templates/show.html.erb
80
+ - lib/live_record/model.rb
81
+ - lib/live_record/version.rb
82
+ - live_record.gemspec
83
+ homepage: https://github.com/jrpolidario/live_record
84
+ licenses:
85
+ - MIT
86
+ metadata: {}
87
+ post_install_message:
88
+ rdoc_options: []
89
+ require_paths:
90
+ - lib
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '2.0'
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ requirements: []
102
+ rubyforge_project:
103
+ rubygems_version: 2.6.11
104
+ signing_key:
105
+ specification_version: 4
106
+ summary: Rails 5 ActionCable Live JS Objects and DOM Elements
107
+ test_files: []