active-sync 0.1.1 → 0.2.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 05b5eaee6042b6879380d62ddb365e2437dab84c
4
- data.tar.gz: 7042c77f823b44739262be1e605efb38050b3fb0
2
+ SHA256:
3
+ metadata.gz: 7df1bee7e9b73969455663f528ffe6c0e6bdcf29c51da5fcfe3198435e9c90aa
4
+ data.tar.gz: d1e8df90405eab22953da90829644acac6c7f3e3003df836d8e32a12f7fb43cf
5
5
  SHA512:
6
- metadata.gz: 34e3e184be13d599ce39678b7ed1f10cadf85b75a19e4a0c0a33d5b2e1bdbe6bd4625cc2eb19800735a24c4915b8582d042a48b41c00cf0ae56b3e111e3b0873
7
- data.tar.gz: e091c678b10a1a8301042b8182a5fa351f57c798f746fc1a18f1810e1b02201f27053855ddbca7ff3377117882ecfcfaf6afc74622a7f0d2d8a0b6163c3f6c07
6
+ metadata.gz: c31d625e52ef4596916acaa468916c7ea767194a77049026ae1ebfc539ae7c2ff5e5ab05a241cb8ae643c9d376b2da49da5a389780c8ab491379d51d1d6f9609
7
+ data.tar.gz: 072dcb445cb5288c65cf073bb9dae6a152f393fba25e69031c0e8b7e144468bba4f612e5f2e038c27d5d1318856f4e263481eae2bafe0b0204f25daffaa721b9
data/README.md CHANGED
@@ -1,10 +1,20 @@
1
1
  # ActiveSync
2
- Short description and motivation.
2
+ Dynamically created and updated front end models.
3
+
4
+ Active record adds an active record interface to your front end. Configured models are available
5
+ within your Javascript application with many active record type functions such as 'where'.
6
+
7
+ Records are lazy loaded and then dynamically updated through actioncable. So any records looked up
8
+ through ActiveSync will be dynamically updated whether updated by the current user or any one else.
9
+
10
+ Currently the only Javascript framework supported is Vue
3
11
 
4
12
  ## Usage
5
13
  How to use my plugin.
6
14
 
7
15
  ## Installation
16
+
17
+ ### Install gem
8
18
  Add this line to your application's Gemfile:
9
19
 
10
20
  ```ruby
@@ -16,11 +26,26 @@ And then execute:
16
26
  $ bundle
17
27
  ```
18
28
 
19
- Or install it yourself as:
20
- ```bash
21
- $ gem install active_sync
29
+ ### Import package
30
+ In packs/application.js import active-sync and create an instance with a list of Models that you
31
+ want available.
32
+
33
+ ```javascript
34
+ import ActiveSync from 'active-sync'
35
+
36
+ let activeSync = new ActiveSync({ modelNames: ['Customer', 'Site'] })
37
+ ```
38
+
39
+ ### Vue setup
40
+
41
+ Before creating your Vue instance :
42
+
43
+ ```javascript
44
+ Vue.use( activeSync )
22
45
  ```
23
46
 
47
+ Then any new Vue instances will have Models globally available
48
+
24
49
  ## Contributing
25
50
  Contribution directions go here.
26
51
 
@@ -0,0 +1,27 @@
1
+ # Rails currently doesn't allow namespacing channels in an engine
2
+ # module ActiveSync
3
+ class ModelsChannel < ActionCable::Channel::Base
4
+ # To change the data sent implement sync_record in the respective model
5
+
6
+ def subscribed
7
+ transmit(subscription_model.where(params[:filter]).map(&:sync_record))
8
+ stream_from params[:model], coder: ActiveSupport::JSON do |message|
9
+ if (params[:filter].to_a - message.to_a).blank?
10
+ transmit([message])
11
+ end
12
+ end
13
+ end
14
+
15
+ def unsubscribed
16
+ # Any cleanup needed when channel is unsubscribed
17
+ end
18
+
19
+ private
20
+
21
+ def subscription_model
22
+ model = params[:model].singularize.camelize.constantize
23
+ raise "Model '#{params[:model]}' is not set up for syncing model" unless model.sync_model?
24
+ model
25
+ end
26
+ end
27
+ # end
@@ -1,5 +1,5 @@
1
1
  module ActiveSync
2
2
  class ApplicationController < ActionController::Base
3
- protect_from_forgery with: :exception
3
+ # protect_from_forgery with: :exception
4
4
  end
5
5
  end
@@ -1,10 +1,22 @@
1
1
  module ActiveSync
2
2
  class ModelsController < ApplicationController
3
3
 
4
- def index
4
+ def update
5
+ #TODO some oversite on what can be edited for sync records
6
+ model.find(params[:id]).update(params.permit(model.sync_attributes))
7
+ head :no_content
8
+ end
5
9
 
6
- render json: ActiveSync::Sync.model_descriptions
10
+ def create
11
+ #TODO some oversite on what can be created for sync records
12
+ render json: model.create(params.permit(model.sync_attributes)).id
13
+ end
7
14
 
15
+ private
16
+ def model
17
+ m = params[:model].singularize.camelize.safe_constantize || params[:model].camelize.safe_constantize
18
+ raise "Cannot edit #{params[:model]} as it is not a sync model" unless m.sync_model?
19
+ m
8
20
  end
9
21
  end
10
22
  end
@@ -0,0 +1,9 @@
1
+ class BroadcastChangeJob < ApplicationJob
2
+ queue_as :active_sync
3
+
4
+ include ActionCable::Channel::Broadcasting
5
+
6
+ def perform record
7
+ ActionCable.server.broadcast(record.class.name, record.sync_record)
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ module ActiveSync
2
+ class Broadcaster
3
+ include 'singleton'
4
+ end
5
+ end
@@ -0,0 +1,63 @@
1
+ module ActiveSync
2
+ module Model
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ # after_update :sync_update
7
+ after_commit :sync_change
8
+ end
9
+
10
+ def sync_change
11
+ if sync_model?
12
+ BroadcastChangeJob.perform_later(self)
13
+ end
14
+ end
15
+
16
+ def sync_model?
17
+ true
18
+ end
19
+
20
+
21
+ class_methods do
22
+
23
+ def sync_model?
24
+ true
25
+ end
26
+
27
+ # #sync sets the #sync_record method that renders the hash to create the JSON object that is broadcast and sets
28
+ # #sync_associations which returns a list of associations that are permitted to be broadcast for this model.
29
+ # define these methods directly in your model if the record sent to the font end needs to be different to what's
30
+ # available with the below configurations
31
+ #
32
+ # This can be passed the following options:
33
+ #
34
+ # Example use in Model:
35
+ # sync :all_attributes, associations: [ :sites ]
36
+
37
+ # ATTRIBUTE OPTIONS
38
+ # Attributes are data that is sent in the actual sync data (this will always include the ID)
39
+
40
+ # :all_attributes - sync data will have all attributes
41
+ # :attributes - an array of symbols that will be called on the record and sent as attributes
42
+
43
+ # ASSOCIATION OPTIONS
44
+ # Associations are lazy loaded, data will not go with the record but if the front end has the association described
45
+ # then records can be subscribed to through the association.
46
+
47
+ # :all_associations - sync data will be associated
48
+ # :associations - an array of symbols
49
+
50
+ def sync *attributes
51
+ self.class.define_method(:sync_attributes) do
52
+ ActiveSync::Sync.sync_attributes(self, attributes)
53
+ end
54
+ define_method(:sync_record) do
55
+ ActiveSync::Sync.sync_record(self, attributes)
56
+ end
57
+ define_method(:sync_associations) do
58
+ ActiveSync::Sync.sync_associations(self, attributes)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -1,125 +1,45 @@
1
1
  module ActiveSync
2
2
  class Sync
3
3
 
4
- # Describes what of each model should be sent as the sync records,
5
- # this is populated through calls to 'sync' in the model class
6
- @@model_descriptions = {}
7
- @@loaded = false
8
-
9
- def self.model_descriptions
10
- ( Rails.application.eager_load! && @@loaded = true ) unless Rails.application.config.cache_classes || @@loaded
11
- @@model_descriptions
12
- end
13
-
14
- def self.is_sync_model? model
15
-
16
- model_name = model.class == String ? model : model.name
17
-
18
- model_descriptions.keys.include? model_name
19
-
20
- end
21
-
22
-
23
- #Hash used in all general sync communication for a given model.
24
- def self.sync_record record
25
- @@model_descriptions[ record.class.name ][ :attributes ].reduce( {} ) do | hash, attribute |
26
- hash[ attribute ] = record.send( attribute )
27
- hash
28
- end
29
- end
30
-
31
- def self.configure_model_description model, options
32
-
33
- @@model_descriptions[ model.name ] = {
34
- attributes: [],
35
- associations: []
36
- }
37
-
38
- options.each do | option |
39
-
40
- case
41
- when option == :all_attributes_and_associations
42
-
43
- add_attributes_to_description model, model.attribute_names
44
- add_associations_to_description model, model.reflect_on_all_associations.map( &:name )
45
-
46
- when option == :all_attributes
47
-
48
- add_attributes_to_description model, model.attribute_names
49
-
50
- when first_key( option ) == :attributes
51
-
52
- add_attributes_to_description model, option[:attributes].map(&:to_s)
53
-
54
- when option == :all_associations
55
-
56
- add_associations_to_description model, model.reflect_on_all_associations.map( &:name )
57
-
58
- when first_key( option ) == :associations
59
-
60
- add_associations_to_description model, option[ :associations ]
61
-
4
+ def self.sync_attributes model, args
5
+ @@sync_attributes ||= args.reduce([]) do |array, option|
6
+ case option
7
+ when :all_attributes_and_associations, :all_attributes
8
+ array + model.column_names.map(&:to_sym)
9
+ when ->(option){ option.is_a?(Hash) }
10
+ array + option[:attributes]
62
11
  else
63
-
64
- throw "Unknown sync option: #{option}"
65
-
12
+ raise "Unknown sync record option #{option.inspect}"
66
13
  end
67
14
  end
68
15
  end
69
16
 
70
- def self.add_attributes_to_description model, attributes
71
-
72
- attributes.each{ |attribute| @@model_descriptions[ model.name ][ :attributes ] << attribute.to_s }
73
-
74
- end
75
-
76
- def self.add_associations_to_description model, association_names
77
- association_names.each do |association_name|
78
-
79
- association = model.reflect_on_all_associations.find{ |a| a.name == association_name }
80
-
81
- unless association.nil?
82
- begin
83
-
84
- @@model_descriptions[ model.name ][ :associations ] << { name: association.name.to_s, class: association.class_name, type: association.association_class.name }
85
-
86
- rescue NotImplementedError
87
-
88
- @@model_descriptions[ model.name ][ :associations ] << { name: association.name.to_s, class: association.class_name, type: 'ActiveRecord::Associations::HasManyThroughAssociation' }
89
-
90
- end
91
-
17
+ #Hash used in all general sync communication for a given model.
18
+ def self.sync_record record, args
19
+ args.reduce({}) do |hash, option|
20
+ case option
21
+ when :all_attributes_and_associations, :all_attributes
22
+ hash.merge(record.attributes)
23
+ when ->(option){ option.is_a?(Hash) }
24
+ option[:attributes]&.reduce(hash) { |h, attr| h[attr] = record.call(attr) }
92
25
  else
93
-
94
- throw "Association #{ association_name } not found for #{ model.name }"
95
-
26
+ raise "Unknown sync record option #{option.inspect}"
96
27
  end
97
28
  end
98
29
  end
99
30
 
100
- def self.association_record model_association, record
101
- {
102
- IsReference: true,
103
- id: record.id,
104
- model_association[:name] => associated_ids( record, model_association )
105
- }
106
- end
107
-
108
- def self.associated_ids record, model_association
109
- if defined? record.send( model_association[:name] ).pluck
110
-
111
- record.send( model_association[:name] ).pluck(:id)
112
- else
113
- record.send( model_association[:name] ).id
31
+ def self.sync_associations record, args
32
+ args.reduce([]) do |associations, option|
33
+ case option
34
+ when :all_attributes_and_associations, :all_attributes
35
+ associations + record.class.reflect_on_all_associations.map { |a| a.name }
36
+ when ->(option){ option.is_a?(Hash) }
37
+ associations + option[:associations]
38
+ else
39
+ raise "Unknown sync associations option #{option.inspect}"
40
+ end
41
+ associations
114
42
  end
115
43
  end
116
-
117
- def self.get_model_association model, association_name
118
- @@model_descriptions[ model.name ][:associations].find{ |a| a[:name] == association_name }
119
- end
120
-
121
- def self.first_key obj
122
- obj.respond_to?( :keys ) ? obj.keys.first : nil
123
- end
124
44
  end
125
45
  end
data/config/routes.rb CHANGED
@@ -1,3 +1,4 @@
1
1
  ActiveSync::Engine.routes.draw do
2
- resources :models, only: [ :index ]
2
+ put '/:model/:id', to: 'models#update'
3
+ post '/:model', to: 'models#create'
3
4
  end
@@ -7,7 +7,6 @@ module ActiveSync
7
7
  mount ActiveSync::Engine, at: "/active_sync"
8
8
  end
9
9
 
10
- ActiveRecord::Base.class_eval { include ActiveSync::ActiveRecordExtension }
11
10
  end
12
11
  end
13
12
  end
@@ -1,3 +1,3 @@
1
1
  module ActiveSync
2
- VERSION = '0.1.1'
2
+ VERSION = '0.2.0'
3
3
  end
@@ -1,68 +1,95 @@
1
- import ActionCable from 'actioncable'
2
1
  import Model from './model.js'
2
+ import CamelCase from 'camelcase'
3
+ import Pluralize from 'pluralize'
4
+ import SnakeCase from "snake-case";
3
5
 
4
6
  export default class ActiveSync {
5
- constructor( args ){
6
-
7
- this._cable = ActionCable.createConsumer()
8
- this._models = []
9
- this._customModels = args.customModels || []
10
7
 
11
- this._modelOptions = {
12
- addNewRecord: args.addNewRecord,
13
- afterFind: args.afterFind
14
- }
8
+ static models = []
9
+
10
+ // ActiveSync dynamically creates model classes when a new instance of it is created
11
+ // Valid arguments are:
12
+ // models: Used to define custom model. Pass an array of classes in and they will be set up as ActiveSync models.
13
+ // It's expected that models passed in extend ActiveSync's Model class. They will then receive extra functions
14
+ // as per anything defined in the modelDescriptions argument (such as associations).
15
+ //
16
+ // modelDescriptions: An object that describes all the models for ActiveSync to create. If the model is not defined in
17
+ // the model then an empty model is created. Then all the described associations are added to their
18
+ // respective models.
19
+ // IE { Customer: { hasMany: ['sites']}, Site: { belongsTo: 'customer' }
20
+ // Will add a 'sites' method to the Customer class and a 'customer' method to Site.
21
+ //
15
22
 
16
- var modelDescriptions = this.requestModelDescriptions()
17
-
18
- Object.keys( modelDescriptions ).forEach( ( modelName ) =>{
19
- modelDescriptions[modelName].name = modelName
20
- this.setupModel( modelDescriptions[modelName] )
21
- })
23
+ constructor( args ){
24
+ this._models = args.models || [];
25
+ this.buildModels(args.modelDescriptions)
26
+ }
22
27
 
23
- this._models.forEach( ( model ) => model.setAssociatedModels( this._models))
24
- args.afterSetup( this._models )
28
+ install( vue ){
29
+ this._models.forEach((model)=> vue.prototype["$" + model.className] = model)
30
+ }
25
31
 
32
+ models(){
33
+ return this._models
26
34
  }
27
35
 
28
- static install( Vue, options ){
29
- var rs = new ActiveSync({
30
- addNewRecord: ( records, id, record ) => {
31
- if( records[ id ] ){
32
- Object.keys( record ).forEach( (key) => Vue.set( records[ id ], key, record[key] ) )
33
- } else {
34
- Vue.set( records, id, record )
35
- }
36
- },
36
+ // Creates the models from the modelDescription arg passed in to the constructor
37
+ buildModels(modelDescriptions){
38
+ let modelNames = Object.keys(modelDescriptions || {});
37
39
 
38
- afterSetup: ( models ) => {
39
- models.forEach( ( model ) => {
40
- Vue.prototype[ '$' + model.name ] = model
41
- })
42
- },
40
+ modelNames.forEach((modelName) => {
41
+ if(!this._models.find((model) => model.className === modelName)){
42
+ this._models.push(this.createModel(modelName))
43
+ }
44
+ })
43
45
 
44
- customModels: (options || {}).customModels
46
+ this._models.forEach((model) => {
47
+ this.addBelongsToModel(modelDescriptions[model.className], model)
48
+ this.addHasManyToModel(modelDescriptions[model.className], model)
45
49
  })
46
50
  }
47
51
 
48
- requestModelDescriptions(){
49
- var xmlHttp = new XMLHttpRequest()
50
- xmlHttp.open( "GET", 'active_sync/models', false ) // false for synchronous request
51
- xmlHttp.send( null )
52
- return JSON.parse(xmlHttp.responseText)
52
+ createModel(name){
53
+ let modelClass = Model
54
+ return eval(`(class ${name} extends modelClass { static className = '${name}' })`)
53
55
  }
56
+
57
+ addBelongsToModel(modelDescription, model){
58
+ ((modelDescription || {}).belongsTo || []).forEach((association) => {
59
+ let associatedModel = this._models.find((model) => model.className === association)
60
+ model[association] = function () {
61
+ return associatedModel.find(this[association + '_id'])
62
+ }
63
+ });
64
+ }
65
+
66
+ addHasManyToModel(modelDescription, model){
67
+ ((modelDescription || {}).hasMany || []).forEach((association) => {
68
+ let associatedModel = this._models.find((model) => model.className === CamelCase(Pluralize.singular(association), {pascalCase: true}))
69
+ model.prototype[association] = function () {
70
+ let associationQuery = {}
71
+ associationQuery[SnakeCase(model.className) + '_id'] = this.id
72
+ return associatedModel.where(associationQuery)
73
+ }
74
+ });
75
+ }
76
+ }
54
77
 
55
- setupModel( modelDescription ){
56
- var CustomModel = this._customModels.find(( m ) => m.name == modelDescription.name )
57
-
58
- if( CustomModel ){
59
-
60
- this._models.push( new CustomModel( modelDescription, this._cable, this._modelOptions ) )
61
78
 
62
- } else {
63
79
 
64
- this._models.push( new Model( modelDescription, this._cable, this._modelOptions ) )
80
+ // Code for dynamically requesting Model names and associations.
81
+ //
82
+ // Object.keys( modelDescriptions ).forEach( ( modelName ) =>{
83
+ // modelDescriptions[modelName].name = modelName
84
+ // this.setupModel( modelDescriptions[modelName] )
85
+ // })
65
86
 
66
- }
67
- }
68
- }
87
+ // var modelDescriptions = this.requestModelDescriptions()
88
+ // this._models.forEach( ( model ) => model.setAssociatedModels( this._models))
89
+ // args.afterSetup( this._models )
90
+ // requestModelDescriptions(){
91
+ // var xmlHttp = new XMLHttpRequest()
92
+ // xmlHttp.open( "GET", 'active_sync/models', false ) // false for synchronous request
93
+ // xmlHttp.send( null )
94
+ // return JSON.parse(xmlHttp.responseText)
95
+ // }
@@ -1,63 +1,128 @@
1
- import Records from './records.js'
2
-
3
- export default class Model{
4
- constructor( description, cable, options ){
5
- this._name = description.name
6
- this._afterFind = options.afterFind || (( record ) => {})
7
- this._records = new Records( {
8
- cable: cable,
9
- modelName: description.name,
10
- addNewRecord: options.addNewRecord,
11
- references: new Records( {
12
- cable: cable,
13
- addNewRecord: options.addNewRecord
14
- }),
15
- associations: description.associations
16
- })
17
- }
1
+ import Axios from 'axios'
2
+ import SnakeCase from 'snake-case'
3
+ import CamelCase from 'camelcase'
4
+ import Pluralize from 'pluralize'
18
5
 
19
- setAssociatedModels( models ){
20
- this._records.setAssociatedModels( models )
21
- }
6
+ import Actioncable from "actioncable"
7
+
8
+ import Util from './util'
22
9
 
23
- get name(){
24
- return this._name
10
+ export default class Model {
11
+
12
+ // static records = {}
13
+ static recordsLoaded = false
14
+ static urlPathBase = 'active_sync/'
15
+
16
+ static consumer = Actioncable.createConsumer()
17
+
18
+ constructor(args){
19
+ if(!args.id) throw 'Can not create record without an id'
20
+ if(this.constructor.records[args.id]){
21
+ this.constructor.records[args.id].setProperties(args)
22
+ } else {
23
+ this.setProperties(args)
24
+ this.constructor.records[this.id] = this
25
+ }
25
26
  }
26
27
 
27
- get all() {
28
- var allRecords = []
29
- this._records.loadRecords().then( () => {
30
- this._records.forEach( ( record ) => {
31
- allRecords.push( this._records.getRecord( record.id ) )
32
- })
28
+ setProperties(args){
29
+ Object.keys(args).forEach((property)=>{
30
+ this[property] = args[property]
33
31
  })
34
-
35
- return allRecords
36
32
  }
37
33
 
38
- find( id ){
34
+ static get records(){
35
+ if(!this.recordsObject) this.recordsObject = {}
36
+ return this.recordsObject
37
+ }
39
38
 
40
- if( !this._records.getRecord( id ) ){
39
+ static get all(){
40
+ return this.loadOrSearch()
41
+ }
41
42
 
42
- this._records.push( { id: id } )
43
- this._records.loadRecords( { id: id } )
44
- // .then(()=> this._afterFind( this._records.getRecord( id ) ))
43
+ static modelUrlPath(singular = false){
44
+ if(singular) {
45
+ return this.urlPathBase + SnakeCase(this.className)
46
+ } else {
47
+ return this.urlPathBase + SnakeCase(Pluralize(this.className))
45
48
  }
49
+ }
46
50
 
51
+ static find(id){
52
+ return new Promise((resolve,reject)=>{
53
+ if(!this.records[id]){
54
+ resolve(this.loadRecords({ id: id }).then(()=> this.records[id]))
55
+ } else {
56
+ resolve(this.records[id])
57
+ }
58
+ })
59
+ }
60
+
61
+ static where(args){
62
+ return this.loadOrSearch(Util.snakeCaseKeys(args))
63
+ }
47
64
 
48
- return this._records.getRecord( id )
65
+ static through(model, args){
66
+ return model.loadRecords(args).then(()=>{
67
+ var linkingIds = [...new Set(model.searchRecords(args).map((record)=> record[CamelCase(this.className)+'Id']))]
68
+ return this.where({id: linkingIds})
69
+ })
49
70
  }
50
71
 
51
- where( properties ){
72
+ static create(data){
73
+ return Axios.post(this.modelUrlPath(), Util.snakeCaseKeys(data))
74
+ .then((response) => {
75
+ new this(response.data)
76
+ return response
77
+ })
78
+ }
52
79
 
53
- var records = []
80
+ static update(data){
81
+ return Axios.put( `${this.modelUrlPath(true)}/${data.id}`, Util.snakeCaseKeys(data))
82
+ .then((response) => {
83
+ // new this(response.data)
84
+ return response
85
+ })
86
+ }
54
87
 
55
- this._records.loadRecords( properties ).then( () => {
56
- this._records.forEachMatch( properties, (record) => {
57
- records.push( this._records.getRecord( record.id ) )
88
+ //Intended as private below here
89
+ static loadOrSearch(args={}){
90
+ let subscriptionParams = { channel: 'ModelsChannel', model: this.className, filter: args }
91
+ if(this.consumer.subscriptions.findAll(JSON.stringify(subscriptionParams)).length === 0){
92
+ return this.loadRecords(subscriptionParams)
93
+ } else {
94
+ return new Promise((resolve, reject) => { resolve(this.searchRecords(args)) } )
95
+ }
96
+ }
97
+
98
+ static loadRecords(args){
99
+ return new Promise((resolve, reject) => {
100
+ this.consumer.subscriptions.create(args, {
101
+ received: (data) => {
102
+ let records = []
103
+ data.forEach((datum) => {
104
+ records.push(new this(datum))
105
+ })
106
+ resolve(records)
107
+ }
58
108
  })
59
109
  })
110
+ }
60
111
 
61
- return records
112
+ static searchRecords(args){
113
+ var results = []
114
+ Object.keys(this.records).forEach((id)=>{
115
+ // Its a match if none of the keys don't match (IE terminates when a property doesn't match), if a property is an array it still matches if the corresponding arg is within it.
116
+ var match = !Object.keys(args).some((arg)=> {
117
+ return !(this.records[id][arg] == args[arg] ||
118
+ (Array.isArray(this.records[id][arg]) && this.records[id][arg].some((i)=> typeof i == 'object' && i.id == args[arg])) ||
119
+ (Array.isArray(args[arg]) && args[arg].some((a)=> typeof a == 'object' && a.id == this.records[id][arg] || a == this.records[id][arg]))
120
+ )
121
+ })
122
+ if(match) {
123
+ results.push(this.records[id])
124
+ }
125
+ })
126
+ return results
62
127
  }
63
- }
128
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rails-active-sync",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Javascript for interacting with active-sync ruby gem.",
5
5
  "main": "active-sync.js",
6
6
  "scripts": {
@@ -20,7 +20,8 @@
20
20
  "dependencies": {
21
21
  "actioncable": "^5.2.2-1",
22
22
  "camelcase": "^5.2.0",
23
- "snake-case": "^2.1.0"
23
+ "snake-case": "^2.1.0",
24
+ "axios": "^0.21.1"
24
25
  },
25
26
  "author": "Crammaman",
26
27
  "license": "MIT"
@@ -0,0 +1,10 @@
1
+
2
+ import SnakeCase from 'snake-case'
3
+ export default{
4
+ snakeCaseKeys: function( object){
5
+ return Object.keys( object ).reduce( ( acc, key ) => {
6
+ acc[ SnakeCase(key) ] = object[key]
7
+ return acc
8
+ }, {})
9
+ },
10
+ }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active-sync
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - crammaman
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-05-17 00:00:00.000000000 Z
11
+ date: 2021-12-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -25,47 +25,61 @@ dependencies:
25
25
  - !ruby/object:Gem::Version
26
26
  version: 5.1.3
27
27
  - !ruby/object:Gem::Dependency
28
- name: puma
28
+ name: webpacker
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: '3.11'
34
- type: :runtime
33
+ version: 3.5.5
34
+ type: :development
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: '3.11'
40
+ version: 3.5.5
41
41
  - !ruby/object:Gem::Dependency
42
- name: webpacker
42
+ name: sqlite3
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.4'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.4'
55
+ - !ruby/object:Gem::Dependency
56
+ name: foreman
43
57
  requirement: !ruby/object:Gem::Requirement
44
58
  requirements:
45
59
  - - ">="
46
60
  - !ruby/object:Gem::Version
47
- version: 3.5.5
61
+ version: '0'
48
62
  type: :development
49
63
  prerelease: false
50
64
  version_requirements: !ruby/object:Gem::Requirement
51
65
  requirements:
52
66
  - - ">="
53
67
  - !ruby/object:Gem::Version
54
- version: 3.5.5
68
+ version: '0'
55
69
  - !ruby/object:Gem::Dependency
56
- name: sqlite3
70
+ name: puma
57
71
  requirement: !ruby/object:Gem::Requirement
58
72
  requirements:
59
- - - "~>"
73
+ - - ">="
60
74
  - !ruby/object:Gem::Version
61
- version: 1.3.6
75
+ version: '0'
62
76
  type: :development
63
77
  prerelease: false
64
78
  version_requirements: !ruby/object:Gem::Requirement
65
79
  requirements:
66
- - - "~>"
80
+ - - ">="
67
81
  - !ruby/object:Gem::Version
68
- version: 1.3.6
82
+ version: '0'
69
83
  description: With minimal set up ActiveSync presents limited rails model interfaces
70
84
  within the JS font end. Records accessed are kept updated through action cable.
71
85
  email:
@@ -77,11 +91,13 @@ files:
77
91
  - MIT-LICENSE
78
92
  - README.md
79
93
  - Rakefile
80
- - app/channels/active_sync_channel.rb
94
+ - app/channels/models_channel.rb
81
95
  - app/controllers/active_sync/application_controller.rb
82
96
  - app/controllers/active_sync/models_controller.rb
83
97
  - app/helpers/active_sync/models_helper.rb
84
- - app/models/active_sync/active_record_extension.rb
98
+ - app/jobs/broadcast_change_job.rb
99
+ - app/models/active_sync/broadcaster.rb
100
+ - app/models/active_sync/model.rb
85
101
  - app/models/active_sync/sync.rb
86
102
  - config/routes.rb
87
103
  - lib/active-sync.rb
@@ -90,7 +106,7 @@ files:
90
106
  - lib/javascript/active-sync.js
91
107
  - lib/javascript/model.js
92
108
  - lib/javascript/package.json
93
- - lib/javascript/records.js
109
+ - lib/javascript/util.js
94
110
  - lib/tasks/rails_sync_tasks.rake
95
111
  homepage: https://github.com/Crammaman/rails-sync
96
112
  licenses:
@@ -111,8 +127,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
111
127
  - !ruby/object:Gem::Version
112
128
  version: '0'
113
129
  requirements: []
114
- rubyforge_project:
115
- rubygems_version: 2.6.10
130
+ rubygems_version: 3.1.6
116
131
  signing_key:
117
132
  specification_version: 4
118
133
  summary: Live updated JS objects for use in reactive JS frameworks
@@ -1,89 +0,0 @@
1
- # Rails currently doesn't allow namespacing channels in an engine
2
- # module ActiveSync
3
- class ActiveSyncChannel < ActionCable::Channel::Base
4
- # For providing DashData with data from rails models
5
- # To change the data sent (like reducing how much is sent)
6
- # implement broadcast_model in the respective modelc
7
-
8
- def subscribed
9
-
10
- if filter && filter[:IsReference]
11
-
12
- subscribe_references
13
-
14
- else
15
-
16
- subscribe_models
17
-
18
- end
19
- end
20
-
21
- def unsubscribed
22
- # Any cleanup needed when channel is unsubscribed
23
- end
24
-
25
- private
26
- def subscribe_models
27
- if filter.nil?
28
-
29
- stream_from "#{subscription_model.name}_All"
30
- transmit( subscription_model.sync_all )
31
-
32
- else
33
-
34
- subscription_model.register_sync_subscription "#{subscription_model.name}_#{checksum}", filter
35
- stream_from "#{subscription_model.name}_#{checksum}"
36
-
37
- # TODO ensure that params are safe to pass to the model then register for syncing to.
38
- transmit( subscription_model.sync_filtered( filter.to_h ) )
39
-
40
- end
41
- end
42
-
43
- def subscribe_references
44
-
45
- record = subscription_model.find( filter[:record_id] )
46
-
47
- if model_association
48
-
49
- transmit( ActiveSync::Sync.association_record( model_association, record) )
50
-
51
- else
52
-
53
- raise "#{subscription_model} does not reference #{ filter[:association_name] }"
54
-
55
- end
56
-
57
- subscription_model.register_sync_subscription "#{subscription_model.name}_#{checksum}", filter.merge( subscribed_model: subscription_model )
58
- eval( model_association[:class] ).register_sync_subscription "#{subscription_model.name}_#{checksum}", filter.merge( subscribed_model: subscription_model )
59
- stream_from "#{subscription_model.name}_#{checksum}"
60
-
61
- end
62
-
63
- def subscription_model
64
-
65
- if ActiveSync::Sync.is_sync_model?( params[:model] )
66
-
67
- eval( params[:model] )
68
-
69
- else
70
-
71
- raise "Model parameter: #{params[:model]} is not a registered sync model"
72
-
73
- end
74
- end
75
-
76
- def model_association
77
- ActiveSync::Sync.get_model_association( subscription_model, filter[:association_name] )
78
- end
79
-
80
- def filter
81
- params[:filter]
82
- end
83
-
84
- def checksum
85
- # A checksum is generated and used in the stream name so all of the same filtered subscriptions should be on the same Stream
86
- Digest::MD5.hexdigest( Marshal::dump( filter ) )
87
- end
88
- end
89
- # end
@@ -1,110 +0,0 @@
1
- module ActiveSync
2
- module ActiveRecordExtension
3
- extend ActiveSupport::Concern
4
-
5
- included do
6
- # after_update :sync_update
7
- after_commit :sync_change
8
-
9
- @@sync_record_subscriptions = {}
10
- end
11
-
12
- def sync_update
13
- sync_change if saved_changes?
14
- end
15
-
16
- def sync_change
17
- if ActiveSync::Sync.is_sync_model? self.class
18
- #TODO properly accommodate multi process environment, since sync sync_subscriptions
19
- # exists only in one process, if one process has a filter sub but another does not
20
- # not all users will necessarily get broadcast to.
21
- ActionCable.server.broadcast("#{self.class}_All", ActiveSync::Sync.sync_record( self ) )
22
- self.class.sync_record_subscriptions.each do | stream, filter |
23
- unless filter[:IsReference]
24
-
25
- match = true
26
- filter.each do | key, value |
27
- unless self.send( key ) == value
28
- match = false
29
- break
30
- end
31
- end
32
-
33
- ActionCable.server.broadcast( stream, ActiveSync::Sync.sync_record( self ) ) if match
34
-
35
- else
36
-
37
- model_association = ActiveSync::Sync.get_model_association( filter[:subscribed_model], filter[:association_name] )
38
-
39
- record = filter[:subscribed_model].find( filter[:record_id] )
40
-
41
- if defined? record.send( model_association[:name] ).pluck
42
-
43
- referenced = record.send( model_association[:name] ).pluck(:id).include? id
44
- else
45
- referenced = record.send( model_association[:name] ).id == id
46
- end
47
-
48
- if referenced
49
- ActionCable.server.broadcast( stream, ActiveSync::Sync.association_record( model_association, record ))
50
- end
51
- end
52
- end
53
- end
54
- end
55
-
56
-
57
- class_methods do
58
-
59
- def register_sync_subscription stream, filter
60
- @@sync_record_subscriptions[ self.name ] = {} if @@sync_record_subscriptions[ self.name ].nil?
61
- @@sync_record_subscriptions[ self.name ][ stream ] = filter
62
- end
63
-
64
- def sync_record_subscriptions
65
- @@sync_record_subscriptions[ self.name ] || {}
66
- end
67
-
68
- # Sync configures the data that is used in general sync communication
69
- # This can be passed the following options:
70
- #
71
- # Example use in Model:
72
- # sync :all_references, associations: [ :sites ]
73
-
74
- # ATTRIBUTE OPTIONS
75
- # Attributes are data that is sent in the actual sync data (this will always include the ID)
76
-
77
- # :all_attributes - sync data will have all attributes
78
- # :attributes - an array of symbols that will be called on the record and sent as attributes
79
-
80
- # ASSOCIATION OPTIONS
81
- # Associations are lazy loaded, data will not go with the record but the front end will be told that
82
- # there is an association to load the data of when accessed.
83
-
84
- # :all_associations - sync data will be associated
85
- # :associations - an array of symbols
86
-
87
- def sync *attributes
88
-
89
- ActiveSync::Sync.configure_model_description self, attributes
90
-
91
- end
92
-
93
- # Sync hash for all of self records
94
- def sync_all
95
-
96
- self.all.map do |record|
97
- ActiveSync::Sync.sync_record record
98
- end
99
-
100
- end
101
-
102
- def sync_filtered filter
103
-
104
- self.where( filter ).map do |record|
105
- ActiveSync::Sync.sync_record record
106
- end
107
- end
108
- end
109
- end
110
- end
@@ -1,216 +0,0 @@
1
- import CamelCase from 'camelcase'
2
- import SnakeCase from 'snake-case'
3
-
4
- export default class Records {
5
- constructor( args = {} ){
6
- this._records = {}
7
- this._addNewRecord = args.addNewRecord || this.addNewRecord
8
- this._associations = args.associations || []
9
- this._cable = args.cable
10
- this._modelName = args.modelName
11
- this._references = args.references
12
- // A subscription is just it's filter{}
13
- this._subscriptions = []
14
- // An ugly object of { filter{}: boolean }
15
- this._dataLoading = {}
16
- }
17
-
18
- setAssociatedModels( models ){
19
- this._associations.forEach( ( association ) => {
20
- var model = models.find( ( model ) => model.name == association.class )
21
- if( model ){
22
- association.model = model
23
- } else {
24
- throw `Model ${this._modelName} is set up for association with ${association.class} but ${association.class} is not available through Active Sync`
25
- }
26
- } )
27
- }
28
-
29
- getRecord( id ){
30
- return this._records[id]
31
- }
32
-
33
- push( record ){
34
-
35
- this._associations.forEach( ( association ) => {
36
- switch(association.type){
37
- case 'ActiveRecord::Associations::HasManyAssociation':
38
- case 'ActiveRecord::Associations::HasManyThroughAssociation':
39
- record[ association.name ] = [1]
40
- break
41
- case 'ActiveRecord::Associations::BelongsToAssociation':
42
- var standIn = {}
43
- Object.defineProperty(standIn, "$count", {
44
- enumerable: false,
45
- writable: true
46
- })
47
- standIn.$count = 1
48
- //this._addNewRecord( record, association.name, standIn )
49
- record[ association.name ] = standIn
50
- break
51
-
52
- default:
53
- console.log('Unknown know association type ' + association.type)
54
- }
55
- } )
56
-
57
- var newRecord = new Proxy( this.camelCaseKeys( record ), {
58
- get: ( record, property )=> this.getFromRecord( record, property, this )
59
- } )
60
-
61
- this._addNewRecord( this._records, newRecord.id, newRecord )
62
- }
63
-
64
- addNewRecord( records, recordId, record ){
65
- if( records[ recordId ] ){
66
- Object.keys( record ).forEach( (key) => records[ recordId ][ key ] = record[ key ] )
67
- } else {
68
- records[ recordId ] = record
69
- }
70
- }
71
-
72
- forEach( func ){
73
- Object.keys( this._records ).forEach(( id ) => func( this._records[id] ) )
74
- }
75
-
76
- camelCaseKeys( record ){
77
- return Object.keys( record ).reduce( ( camelRecord, key ) => {
78
- camelRecord[ CamelCase(key) ] = record[key]
79
- return camelRecord
80
- }, {} )
81
- }
82
- snakeCaseKeys( record ){
83
- return Object.keys( record ).reduce( ( camelRecord, key ) => {
84
- camelRecord[ SnakeCase(key) ] = record[key]
85
- return camelRecord
86
- }, {} )
87
- }
88
-
89
- getFromRecord( record, property, self ){
90
- self.loadIfAssociation( record, property )
91
- return record[ property ]
92
- }
93
- //If the records already exist it will return an instantaneously resolving promise
94
- loadRecords( filter = null ){
95
- if( !this.isSubscribed( filter ) ){
96
-
97
- this._subscriptions.push( filter ? filter : 'all' )
98
- this.subscribeToRecords( filter )
99
-
100
- }
101
-
102
- return new Promise( (resolve, reject)=> this.awaitData(resolve, reject, filter) )
103
- }
104
-
105
- awaitData( resolve, reject, filter ){
106
- setTimeout( ()=>{
107
- if( this._dataLoading[ filter ] ){
108
- this.awaitData( resolve, reject, filter )
109
- } else {
110
- resolve()
111
- }
112
- //TODO there's something wrong with _dataLoading not working so this wait time has been cranked up.
113
- }, 200 )
114
- }
115
-
116
- // Adds records that match properties into records
117
- forEachMatch( properties, func ){
118
-
119
- let records = []
120
-
121
- this.forEach( ( record ) => {
122
-
123
- var match = true
124
- Object.keys( properties ).forEach( ( property ) => {
125
- if( properties[ property ] != record[ property ] ){
126
- match = false
127
- }
128
- })
129
-
130
- if( match ){
131
- func(record)
132
- }
133
- })
134
-
135
- return records
136
- }
137
-
138
- isSubscribed( filter ){
139
- filter = filter ? filter : 'all'
140
- if( this._subscriptions.includes( 'all' ) && !filter.IsReference ) {
141
- return true
142
- } else {
143
- return !!this._subscriptions.find( ( sub ) => JSON.stringify(sub) == JSON.stringify(filter) )
144
- }
145
- }
146
-
147
- // Subscribing to a record is the source of all data communication. With no filter
148
- // all records are subscribed to, this is done without checking for existing subscriptions
149
- // so that needs to be done before getting here.
150
- subscribeToRecords( filter = null ){
151
-
152
- let subscriptionParameters = { channel: 'ActiveSyncChannel', model: this._modelName }
153
- this._dataLoading[ filter ] = true
154
-
155
- if ( filter !== null ) {
156
- subscriptionParameters.filter = filter.IsReference ? filter : this.snakeCaseKeys(filter)
157
- }
158
-
159
- this._cable.subscriptions.create( subscriptionParameters ,{
160
- received: (data) => {
161
- var records = data.IsReference ? this._references : this
162
- // Will find records and update them, if not found will add them to
163
- // _records or references.
164
- if( data.length > 0){
165
-
166
- // data is a promise so might not have anything at this point,
167
- // adding with a forEach allows promises to be handled (is there a better way?)
168
- data.forEach((addModel) => {
169
- records.push(addModel)
170
- })
171
-
172
-
173
- } else {
174
-
175
- records.push( data )
176
-
177
- }
178
-
179
- this._dataLoading[ filter ] = false
180
-
181
- }
182
- })
183
- }
184
-
185
- loadIfAssociation( record, property ){
186
- var association = this._associations.find( ( a ) => a.name == property )
187
-
188
- if( association ){
189
-
190
- var referencedRecords = []
191
-
192
- if( record[property][0] == 1 ){
193
- record[property].pop()
194
- } else if( record[property].$count > 0 ){
195
- record[property].$count--
196
- } else {
197
- this.loadRecords({ IsReference: true, record_id: record.id, association_name: property })
198
- .then( () => {
199
- var references = this._references.getRecord( record.id )[ property ]
200
-
201
- if( references.length > 0 && references.length !== record[property].length ){
202
- record[property] = []
203
- references.forEach( ( reference ) => {
204
- record[property].push( association.model.find( reference ))
205
- } )
206
-
207
- } else if( typeof references.length === 'undefined' && record[property].$count == 0 ) {
208
-
209
- this._addNewRecord(record,property, association.model.find( references ))
210
-
211
- }
212
- })
213
- }
214
- }
215
- }
216
- }