brainstem-js 0.2.1 → 0.3.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.
Files changed (33) hide show
  1. data/.travis.yml +3 -6
  2. data/Gemfile.lock +1 -1
  3. data/README.md +3 -1
  4. data/Rakefile +2 -0
  5. data/lib/brainstem/js/version.rb +1 -1
  6. data/spec/brainstem-collection-spec.coffee +25 -0
  7. data/spec/{brianstem-expectation-spec.coffee → brainstem-expectation-spec.coffee} +132 -17
  8. data/spec/brainstem-model-spec.coffee +67 -27
  9. data/spec/brainstem-sync-spec.coffee +29 -6
  10. data/spec/brainstem-utils-spec.coffee +11 -1
  11. data/spec/helpers/builders.coffee +2 -2
  12. data/spec/helpers/models/post.coffee +1 -1
  13. data/spec/helpers/models/project.coffee +1 -1
  14. data/spec/helpers/models/task.coffee +2 -2
  15. data/spec/helpers/models/time-entry.coffee +1 -1
  16. data/spec/helpers/models/user.coffee +1 -1
  17. data/spec/helpers/spec-helper.coffee +21 -0
  18. data/spec/loaders/abstract-loader-shared-behavior.coffee +604 -0
  19. data/spec/loaders/abstract-loader-spec.coffee +3 -0
  20. data/spec/loaders/collection-loader-spec.coffee +146 -0
  21. data/spec/loaders/model-loader-spec.coffee +99 -0
  22. data/spec/storage-manager-spec.coffee +242 -56
  23. data/vendor/assets/javascripts/brainstem/brainstem-collection.coffee +16 -6
  24. data/vendor/assets/javascripts/brainstem/brainstem-expectation.coffee +70 -20
  25. data/vendor/assets/javascripts/brainstem/brainstem-model.coffee +13 -13
  26. data/vendor/assets/javascripts/brainstem/brainstem-sync.coffee +8 -3
  27. data/vendor/assets/javascripts/brainstem/loaders/abstract-loader.coffee +289 -0
  28. data/vendor/assets/javascripts/brainstem/loaders/collection-loader.coffee +68 -0
  29. data/vendor/assets/javascripts/brainstem/loaders/model-loader.coffee +35 -0
  30. data/vendor/assets/javascripts/brainstem/loading-mixin.coffee +1 -7
  31. data/vendor/assets/javascripts/brainstem/storage-manager.coffee +79 -196
  32. data/vendor/assets/javascripts/brainstem/utils.coffee +18 -4
  33. metadata +17 -6
@@ -0,0 +1,68 @@
1
+ window.Brainstem ?= {}
2
+
3
+ class Brainstem.CollectionLoader extends Brainstem.AbstractLoader
4
+ getCollection: ->
5
+ @externalObject
6
+
7
+ _getCollectionName: ->
8
+ @loadOptions.name
9
+
10
+ _getExpectationName: ->
11
+ @_getCollectionName()
12
+
13
+ _createObjects: ->
14
+ @internalObject = @storageManager.createNewCollection @loadOptions.name, []
15
+
16
+ @externalObject = @loadOptions.collection || @storageManager.createNewCollection @loadOptions.name, []
17
+ @externalObject.setLoaded false
18
+ @externalObject.reset([], silent: false) if @loadOptions.reset
19
+ @externalObject.lastFetchOptions = _.pick($.extend(true, {}, @loadOptions), 'name', 'filters', 'page', 'perPage', 'limit', 'offset', 'order', 'search')
20
+ @externalObject.lastFetchOptions.include = @originalOptions.include
21
+
22
+ _updateStorageManagerFromResponse: (resp) ->
23
+ # The server response should look something like this:
24
+ # {
25
+ # count: 200,
26
+ # results: [{ key: "tasks", id: 10 }, { key: "tasks", id: 11 }],
27
+ # time_entries: [{ id: 2, title: "te1", project_id: 6, task_id: [10, 11] }]
28
+ # projects: [{id: 6, title: "some project", time_entry_ids: [2] }]
29
+ # tasks: [{id: 10, title: "some task" }, {id: 11, title: "some other task" }]
30
+ # }
31
+ # Loop over all returned data types and update our local storage to represent any new data.
32
+
33
+ results = resp['results']
34
+ keys = _.reject(_.keys(resp), (key) -> key == 'count' || key == 'results')
35
+ unless _.isEmpty(results)
36
+ keys.splice(keys.indexOf(@loadOptions.name), 1) if keys.indexOf(@loadOptions.name) != -1
37
+ keys.push(@loadOptions.name)
38
+
39
+ for underscoredModelName in keys
40
+ @storageManager.storage(underscoredModelName).update _(resp[underscoredModelName]).values()
41
+
42
+ if @loadOptions.cache && !@loadOptions.only?
43
+ @storageManager.getCollectionDetails(@loadOptions.name).cache[@loadOptions.cacheKey] = results
44
+
45
+ if @loadOptions.only?
46
+ data = _.map(@loadOptions.only, (id) => @cachedCollection.get(id))
47
+ else
48
+ data = _.map(results, (result) => @storageManager.storage(result.key).get(result.id))
49
+
50
+ data
51
+
52
+ _updateObjects: (object, data, silent = false) ->
53
+ object.setLoaded true, trigger: false
54
+
55
+ if data
56
+ data = data.models if data.models?
57
+ if object.length
58
+ object.add data
59
+ else
60
+ object.reset data
61
+
62
+ object.setLoaded true unless silent
63
+
64
+ _getModel: ->
65
+ @internalObject.model
66
+
67
+ _getModelsForAssociation: (association) ->
68
+ @internalObject.map (m) => @_modelsOrObj(m.get(association))
@@ -0,0 +1,35 @@
1
+ window.Brainstem ?= {}
2
+
3
+ class Brainstem.ModelLoader extends Brainstem.AbstractLoader
4
+ getModel: ->
5
+ @externalObject
6
+
7
+ _getCollectionName: ->
8
+ @loadOptions.name.pluralize()
9
+
10
+ _getExpectationName: ->
11
+ @loadOptions.name
12
+
13
+ _createObjects: ->
14
+ id = @loadOptions.only[0]
15
+
16
+ @internalObject = @storageManager.storage(@_getCollectionName()).get(id) || @storageManager.createNewModel(@loadOptions.name, id: id)
17
+ @externalObject = @internalObject
18
+
19
+ _updateStorageManagerFromResponse: (resp) ->
20
+ @internalObject.parse(resp)
21
+
22
+ _updateObjects: (object, data) ->
23
+ if _.isArray(data) && data.length == 1
24
+ data = data[0]
25
+
26
+ if data instanceof Backbone.Model
27
+ data = data.attributes
28
+
29
+ object.set(data)
30
+
31
+ _getModel: ->
32
+ @internalObject.constructor
33
+
34
+ _getModelsForAssociation: (association) ->
35
+ @_modelsOrObj(@internalObject.get(association))
@@ -4,10 +4,4 @@ Brainstem.LoadingMixin =
4
4
  setLoaded: (state, options) ->
5
5
  options = { trigger: true } unless options? && options.trigger? && !options.trigger
6
6
  @loaded = state
7
- @trigger 'loaded', @ if state && options.trigger
8
-
9
- whenLoaded: (func) ->
10
- if @loaded
11
- func()
12
- else
13
- @bind "loaded", => func()
7
+ @trigger 'loaded', this if state && options.trigger
@@ -22,51 +22,44 @@ class window.Brainstem.StorageManager
22
22
 
23
23
  # Access the cache for a particular collection.
24
24
  # manager.storage("time_entries").get(12).get("title")
25
- storage: (name) =>
25
+ storage: (name) ->
26
26
  @getCollectionDetails(name).storage
27
27
 
28
- dataUsage: =>
28
+ dataUsage: ->
29
29
  sum = 0
30
30
  for dataType in @collectionNames()
31
31
  sum += @storage(dataType).length
32
32
  sum
33
33
 
34
- reset: =>
34
+ reset: ->
35
35
  for name, attributes of @collections
36
36
  attributes.storage.reset []
37
37
  attributes.cache = {}
38
38
 
39
39
  # Access details of a collection. An error will be thrown if the collection cannot be found.
40
- getCollectionDetails: (name) =>
40
+ getCollectionDetails: (name) ->
41
41
  @collections[name] || @collectionError(name)
42
42
 
43
- collectionNames: =>
43
+ collectionNames: ->
44
44
  _.keys(@collections)
45
45
 
46
- collectionExists: (name) =>
46
+ collectionExists: (name) ->
47
47
  !!@collections[name]
48
48
 
49
- setErrorInterceptor: (interceptor) =>
50
- @errorInterceptor = interceptor || (handler, modelOrCollection, options, jqXHR, requestParams) -> handler?(modelOrCollection, jqXHR)
49
+ setErrorInterceptor: (interceptor) ->
50
+ @errorInterceptor = interceptor || (handler, modelOrCollection, options, jqXHR, requestParams) -> handler?(jqXHR)
51
51
 
52
- # Request a model to be loaded, optionally ensuring that associations be included as well. A collection is returned immediately and is reset
53
- # when the load, and any dependent loads, are complete.
54
- # model = manager.loadModel "time_entry"
55
- # model = manager.loadModel "time_entry", fields: ["title", "notes"]
56
- # model = manager.loadModel "time_entry", include: ["project", "task"]
57
- loadModel: (name, id, options) =>
58
- options = _.clone(options || {})
59
- oldSuccess = options.success
60
- collectionName = name.pluralize()
61
- model = new (@getCollectionDetails(collectionName).modelKlass)()
62
- @loadCollection collectionName, _.extend options,
63
- only: id
64
- success: (collection) ->
65
- model.setLoaded true, trigger: false
66
- model.set collection.get(id).attributes
67
- model.setLoaded true
68
- oldSuccess(model) if oldSuccess
69
- model
52
+ # Request a model to be loaded, optionally ensuring that associations be included as well. A loader (which is a jQuery promise) is returned immediately and is resolved
53
+ # with the model from the StorageManager when the load, and any dependent loads, are complete.
54
+ # loader = manager.loadModel "time_entry", 2
55
+ # loader = manager.loadModel "time_entry", 2, fields: ["title", "notes"]
56
+ # loader = manager.loadModel "time_entry", 2, include: ["project", "task"]
57
+ # manager.loadModel("time_entry", 2, include: ["project", "task"]).done (model) -> console.log model
58
+ loadModel: (name, id, options = {}) ->
59
+ return if not id
60
+
61
+ loader = @loadObject(name, $.extend({}, options, only: id), isCollection: false)
62
+ loader
70
63
 
71
64
  # Request a set of data to be loaded, optionally ensuring that associations be included as well. A collection is returned immediately and is reset
72
65
  # when the load, and any dependent loads, are complete.
@@ -77,183 +70,26 @@ class window.Brainstem.StorageManager
77
70
  # collection = manager.loadCollection "time_entries", include: ["project:title,description", "task:due_date"]
78
71
  # collection = manager.loadCollection "tasks", include: ["assets", { "assignees": "account" }, { "sub_tasks": ["assignees", "assets"] }]
79
72
  # collection = manager.loadCollection "time_entries", filters: ["project_id:6", "editable:true"], order: "updated_at:desc", page: 1, perPage: 20
80
- loadCollection: (name, options) =>
81
- options = $.extend({}, options, name: name)
82
- @_checkPageSettings options
83
- include = @_wrapObjects(Brainstem.Utils.extractArray "include", options)
84
- if options.search
85
- options.cache = false
86
-
87
- collection = options.collection || @createNewCollection name, []
88
- collection.setLoaded false
89
- collection.reset([], silent: false) if options.reset
90
- collection.lastFetchOptions = _.pick($.extend(true, {}, options), 'name', 'filters', 'include', 'page', 'perPage', 'order', 'search')
91
-
92
- if @expectations?
93
- @handleExpectations name, collection, options
94
- else
95
- @_loadCollectionWithFirstLayer($.extend({}, options, include: include, success: ((firstLayerCollection) =>
96
- expectedAdditionalLoads = @_countRequiredServerRequests(include) - 1
97
- if expectedAdditionalLoads > 0
98
- timesCalled = 0
99
- @_handleNextLayer firstLayerCollection, include, =>
100
- timesCalled += 1
101
- if timesCalled == expectedAdditionalLoads
102
- @_success(options, collection, firstLayerCollection)
103
- else
104
- @_success(options, collection, firstLayerCollection)
105
- )))
106
-
107
- collection
108
-
109
- _handleNextLayer: (collection, include, callback) =>
110
- # Collection is a fully populated collection of tasks whose first layer of associations are loaded.
111
- # include is a hierarchical list of associations on those tasks:
112
- # [{ 'time_entries': ['project': [], 'task': [{ 'assignees': []}]] }, { 'project': [] }]
113
-
114
- _(include).each (hash) => # { 'time_entries': ['project': [], 'task': [{ 'assignees': []}]] }
115
- association = _.keys(hash)[0] # time_entries
116
- nextLevelInclude = hash[association] # ['project': [], 'task': [{ 'assignees': []}]]
117
- if nextLevelInclude.length
118
- association_ids = _(collection.models).chain().
119
- map((m) -> if (a = m.get(association)) instanceof Backbone.Collection then a.models else a).
120
- flatten().uniq().compact().pluck("id").sort().value()
121
- newCollectionName = collection.model.associationDetails(association).collectionName
122
- @_loadCollectionWithFirstLayer name: newCollectionName, only: association_ids, include: nextLevelInclude, success: (loadedAssociationCollection) =>
123
- @_handleNextLayer(loadedAssociationCollection, nextLevelInclude, callback)
124
- callback()
125
-
126
- _loadCollectionWithFirstLayer: (options) =>
127
- options = $.extend({}, options)
128
- name = options.name
129
- only = if options.only then _.map((Brainstem.Utils.extractArray "only", options), (id) -> String(id)) else null
130
- search = options.search
131
- include = _(options.include).map((i) -> _.keys(i)[0]) # pull off the top layer of includes
132
- filters = options.filters || {}
133
- order = options.order || "updated_at:desc"
134
- cacheKey = "#{order}|#{_.chain(filters).pairs().map(([k, v]) -> "#{k}:#{v}" ).value().join(",")}|#{options.page}|#{options.perPage}"
73
+ loadCollection: (name, options = {}) ->
74
+ loader = @loadObject(name, options)
75
+ loader.externalObject
135
76
 
136
- cachedCollection = @storage name
137
- collection = @createNewCollection name, []
138
-
139
- unless options.cache == false
140
- if only?
141
- alreadyLoadedIds = _.select only, (id) => cachedCollection.get(id)?.associationsAreLoaded(include)
142
- if alreadyLoadedIds.length == only.length
143
- # We've already seen every id that is being asked for and have all the associated data.
144
- @_success options, collection, _.map only, (id) => cachedCollection.get(id)
145
- return collection
146
- else
147
- # Check if we have, at some point, requested enough records with this this order and filter(s).
148
- if @getCollectionDetails(name).cache[cacheKey]
149
- subset = _(@getCollectionDetails(name).cache[cacheKey]).map (result) -> base.data.storage(result.key).get(result.id)
150
- if (_.all(subset, (model) => model.associationsAreLoaded(include)))
151
- @_success options, collection, subset
152
- return collection
153
-
154
- # If we haven't returned yet, we need to go to the server to load some missing data.
155
- syncOptions =
156
- data: {}
157
- parse: true
158
- error: options.error
159
- success: (resp, status, xhr) =>
160
- # The server response should look something like this:
161
- # {
162
- # count: 200,
163
- # results: [{ key: "tasks", id: 10 }, { key: "tasks", id: 11 }],
164
- # time_entries: [{ id: 2, title: "te1", project_id: 6, task_id: [10, 11] }]
165
- # projects: [{id: 6, title: "some project", time_entry_ids: [2] }]
166
- # tasks: [{id: 10, title: "some task" }, {id: 11, title: "some other task" }]
167
- # }
168
- # Loop over all returned data types and update our local storage to represent any new data.
169
-
170
- results = resp['results']
171
- keys = _.reject(_.keys(resp), (key) -> key == 'count' || key == 'results')
172
- unless _.isEmpty(results)
173
- keys.splice(keys.indexOf(name), 1) if keys.indexOf(name) != -1
174
- keys.push(name)
175
-
176
- for underscoredModelName in keys
177
- @storage(underscoredModelName).update _(resp[underscoredModelName]).values()
178
-
179
- unless options.cache == false || only?
180
- @getCollectionDetails(name).cache[cacheKey] = results
181
-
182
- if only?
183
- @_success options, collection, _.map(only, (id) -> cachedCollection.get(id))
184
- else
185
- @_success options, collection, _(results).map (result) -> base.data.storage(result.key).get(result.id)
186
-
187
-
188
- syncOptions.data.include = include.join(",") if include.length
189
- syncOptions.data.only = _.difference(only, alreadyLoadedIds).join(",") if only?
190
- syncOptions.data.order = options.order if options.order?
191
- _.extend(syncOptions.data, _(filters).omit('include', 'only', 'order', 'per_page', 'page', 'search')) if _(filters).keys().length
192
- syncOptions.data.per_page = options.perPage unless only?
193
- syncOptions.data.page = options.page unless only?
194
- syncOptions.data.search = search if search
195
-
196
- Backbone.sync.call collection, 'read', collection, syncOptions
197
-
198
- collection
199
-
200
- _success: (options, collection, data) =>
201
- if data
202
- data = data.models if data.models?
203
- collection.setLoaded true, trigger: false
204
- if collection.length
205
- collection.add data
206
- else
207
- collection.reset data
208
- collection.setLoaded true
209
- options.success(collection) if options.success?
210
-
211
- _checkPageSettings: (options) =>
212
- options.perPage = options.perPage || 20
213
- options.perPage = 1 if options.perPage < 1
214
- options.page = options.page || 1
215
- options.page = 1 if options.page < 1
216
-
217
- collectionError: (name) =>
77
+ collectionError: (name) ->
218
78
  Brainstem.Utils.throwError("Unknown collection #{name} in StorageManager. Known collections: #{_(@collections).keys().join(", ")}")
219
79
 
220
- createNewCollection: (collectionName, models = [], options = {}) =>
80
+ createNewCollection: (collectionName, models = [], options = {}) ->
221
81
  loaded = options.loaded
222
82
  delete options.loaded
223
83
  collection = new (@getCollectionDetails(collectionName).klass)(models, options)
224
84
  collection.setLoaded(true, trigger: false) if loaded
225
85
  collection
226
86
 
227
- createNewModel: (modelName, options) =>
87
+ createNewModel: (modelName, options) ->
228
88
  new (@getCollectionDetails(modelName.pluralize()).modelKlass)(options || {})
229
89
 
230
- _wrapObjects: (array) =>
231
- output = []
232
- _(array).each (elem) =>
233
- if elem.constructor == Object
234
- for key, value of elem
235
- o = {}
236
- o[key] = @_wrapObjects(if value instanceof Array then value else [value])
237
- output.push o
238
- else
239
- o = {}
240
- o[elem] = []
241
- output.push o
242
- output
243
-
244
- _countRequiredServerRequests: (array, wrapped = false) =>
245
- if array?.length
246
- array = @_wrapObjects(array) unless wrapped
247
- sum = 1
248
- _(array).each (elem) =>
249
- sum += @_countRequiredServerRequests(_(elem).values()[0], true)
250
- sum
251
- else
252
- 0
253
-
254
90
  # Expectations and stubbing
255
91
 
256
- stub: (collectionName, options) =>
92
+ stub: (collectionName, options = {}) ->
257
93
  if @expectations?
258
94
  expectation = new Brainstem.Expectation(collectionName, options, @)
259
95
  @expectations.push expectation
@@ -261,15 +97,62 @@ class window.Brainstem.StorageManager
261
97
  else
262
98
  throw "You must call #enableExpectations on your instance of Brainstem.StorageManager before you can set expectations."
263
99
 
264
- stubImmediate: (collectionName, options) =>
100
+ stubModel: (modelName, modelId, options = {}) ->
101
+ @stub(modelName, $.extend({}, options, only: modelId))
102
+
103
+ stubImmediate: (collectionName, options) ->
265
104
  @stub collectionName, $.extend({}, options, immediate: true)
266
105
 
267
- enableExpectations: =>
106
+ enableExpectations: ->
268
107
  @expectations = []
269
108
 
270
- handleExpectations: (name, collection, options) =>
109
+ handleExpectations: (loader) ->
271
110
  for expectation in @expectations
272
- if expectation.optionsMatch(name, options)
273
- expectation.recordRequest(collection, options)
111
+ if expectation.loaderOptionsMatch(loader)
112
+ expectation.recordRequest(loader)
274
113
  return
275
- throw "No expectation matched #{name} with #{JSON.stringify options}"
114
+ throw "No expectation matched #{name} with #{JSON.stringify loader.originalOptions}"
115
+
116
+ # Helpers
117
+ loadObject: (name, loadOptions, options = {}) ->
118
+ options = $.extend({}, { isCollection: true }, options)
119
+
120
+ successCallback = loadOptions.success
121
+ loadOptions = _.omit(loadOptions, 'success')
122
+ loadOptions = $.extend({}, loadOptions, name: name)
123
+
124
+ if options.isCollection
125
+ loaderClass = Brainstem.CollectionLoader
126
+ else
127
+ loaderClass = Brainstem.ModelLoader
128
+
129
+ @_checkPageSettings loadOptions
130
+
131
+ loader = new loaderClass(storageManager: this)
132
+ loader.setup(loadOptions)
133
+ loader.done(successCallback) if successCallback? && _.isFunction(successCallback)
134
+
135
+ if @expectations?
136
+ @handleExpectations(loader)
137
+ else
138
+ loader.load()
139
+
140
+ loader
141
+
142
+ _checkPageSettings: (options) ->
143
+ if options.limit? && options.limit != '' && options.offset? && options.offset != ''
144
+ options.perPage = options.page = undefined
145
+ else
146
+ options.limit = options.offset = undefined
147
+
148
+ @_setDefaultPageSettings(options)
149
+
150
+ _setDefaultPageSettings: (options) ->
151
+ if options.limit? && options.offset?
152
+ options.limit = 1 if options.limit < 1
153
+ options.offset = 0 if options.offset < 0
154
+ else
155
+ options.perPage = options.perPage || 20
156
+ options.perPage = 1 if options.perPage < 1
157
+ options.page = options.page || 1
158
+ options.page = 1 if options.page < 1
@@ -4,10 +4,10 @@ class window.Brainstem.Utils
4
4
  @warn: (args...) ->
5
5
  console?.log "Error:", args...
6
6
 
7
- @throwError: (message) =>
7
+ @throwError: (message) ->
8
8
  throw new Error("#{Backbone.history.getFragment()}: #{message}")
9
9
 
10
- @matches: (obj1, obj2) =>
10
+ @matches: (obj1, obj2) ->
11
11
  if @empty(obj1) && @empty(obj2)
12
12
  true
13
13
  else if obj1 instanceof Array && obj2 instanceof Array
@@ -19,7 +19,7 @@ class window.Brainstem.Utils
19
19
  else
20
20
  String(obj1) == String(obj2)
21
21
 
22
- @empty: (thing) =>
22
+ @empty: (thing) ->
23
23
  if thing == null || thing == undefined || thing == ""
24
24
  true
25
25
  if thing instanceof Array
@@ -29,7 +29,21 @@ class window.Brainstem.Utils
29
29
  else
30
30
  false
31
31
 
32
- @extractArray: (option, options) =>
32
+ @extractArray: (option, options) ->
33
33
  result = options[option]
34
34
  result = [result] unless result instanceof Array
35
35
  _.compact(result)
36
+
37
+ @wrapObjects: (array) ->
38
+ output = []
39
+ _(array).each (elem) =>
40
+ if elem.constructor == Object
41
+ for key, value of elem
42
+ o = {}
43
+ o[key] = @wrapObjects(if value instanceof Array then value else [value])
44
+ output.push o
45
+ else
46
+ o = {}
47
+ o[elem] = []
48
+ output.push o
49
+ output