brainstem-js 0.2.1 → 0.3.0

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