live_record 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: []