brainstem-js 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. data/.gitignore +8 -0
  2. data/.pairs +21 -0
  3. data/.ruby-gemset +1 -0
  4. data/.ruby-version +1 -0
  5. data/.tm_properties +1 -0
  6. data/.travis.yml +12 -0
  7. data/Assetfile +79 -0
  8. data/Gemfile +6 -0
  9. data/Gemfile.lock +50 -0
  10. data/LICENSE.txt +22 -0
  11. data/README.md +143 -0
  12. data/Rakefile +25 -0
  13. data/brainstemjs.gemspec +24 -0
  14. data/lib/brainstem/js/engine.rb +5 -0
  15. data/lib/brainstem/js/version.rb +5 -0
  16. data/lib/brainstem/js.rb +10 -0
  17. data/spec/brainstem-collection-spec.coffee +141 -0
  18. data/spec/brainstem-model-spec.coffee +283 -0
  19. data/spec/brainstem-sync-spec.coffee +22 -0
  20. data/spec/brainstem-utils-spec.coffee +12 -0
  21. data/spec/brianstem-expectation-spec.coffee +209 -0
  22. data/spec/helpers/builders.coffee +80 -0
  23. data/spec/helpers/jquery-matchers.js +137 -0
  24. data/spec/helpers/models/post.coffee +14 -0
  25. data/spec/helpers/models/project.coffee +13 -0
  26. data/spec/helpers/models/task.coffee +14 -0
  27. data/spec/helpers/models/time-entry.coffee +13 -0
  28. data/spec/helpers/models/user.coffee +8 -0
  29. data/spec/helpers/spec-helper.coffee +79 -0
  30. data/spec/storage-manager-spec.coffee +613 -0
  31. data/spec/support/.DS_Store +0 -0
  32. data/spec/support/console-runner.js +103 -0
  33. data/spec/support/headless.coffee +47 -0
  34. data/spec/support/headless.html +60 -0
  35. data/spec/support/runner.html +85 -0
  36. data/spec/vendor/backbone-factory.js +62 -0
  37. data/spec/vendor/backbone.js +1571 -0
  38. data/spec/vendor/inflection.js +448 -0
  39. data/spec/vendor/jquery-1.7.js +9300 -0
  40. data/spec/vendor/jquery.cookie.js +47 -0
  41. data/spec/vendor/minispade.js +67 -0
  42. data/spec/vendor/sinon-1.3.4.js +3561 -0
  43. data/spec/vendor/underscore.js +1221 -0
  44. data/vendor/assets/.DS_Store +0 -0
  45. data/vendor/assets/javascripts/.DS_Store +0 -0
  46. data/vendor/assets/javascripts/brainstem/brainstem-collection.coffee +53 -0
  47. data/vendor/assets/javascripts/brainstem/brainstem-expectation.coffee +65 -0
  48. data/vendor/assets/javascripts/brainstem/brainstem-inflections.js +449 -0
  49. data/vendor/assets/javascripts/brainstem/brainstem-model.coffee +141 -0
  50. data/vendor/assets/javascripts/brainstem/brainstem-sync.coffee +76 -0
  51. data/vendor/assets/javascripts/brainstem/index.js +1 -0
  52. data/vendor/assets/javascripts/brainstem/iso8601.js +41 -0
  53. data/vendor/assets/javascripts/brainstem/loading-mixin.coffee +13 -0
  54. data/vendor/assets/javascripts/brainstem/storage-manager.coffee +275 -0
  55. data/vendor/assets/javascripts/brainstem/utils.coffee +35 -0
  56. metadata +198 -0
@@ -0,0 +1,141 @@
1
+ #= require ./loading-mixin
2
+
3
+ # Extend Backbone.Model to include associations.
4
+ class window.Brainstem.Model extends Backbone.Model
5
+ constructor: ->
6
+ super
7
+ @setLoaded false
8
+
9
+ # Parse ISO8601 attribute strings into date objects
10
+ @parse: (modelObject) ->
11
+ for k,v of modelObject
12
+ # Date.parse will parse ISO 8601 in ECMAScript 5, but we include a shim for now
13
+ if /\d{4}-\d{2}-\d{2}T\d{2}\:\d{2}\:\d{2}[-+]\d{2}:\d{2}/.test(v)
14
+ modelObject[k] = Date.parse(v)
15
+ return modelObject
16
+
17
+ # Handle create and update responses with JSON root keys
18
+ parse: (resp, xhr) =>
19
+ @updateStorageManager(resp)
20
+ modelObject = @_parseResultsResponse(resp)
21
+ super(@constructor.parse(modelObject), xhr)
22
+
23
+ updateStorageManager: (resp) ->
24
+ results = resp['results']
25
+ return if _.isEmpty(results)
26
+
27
+ keys = _.reject(_.keys(resp), (key) -> key == 'count' || key == 'results')
28
+ primaryModelKey = results[0]['key']
29
+ keys.splice(keys.indexOf(primaryModelKey), 1)
30
+ keys.push(primaryModelKey)
31
+
32
+ for underscoredModelName in keys
33
+ models = resp[underscoredModelName]
34
+ for id, attributes of models
35
+ @constructor.parse(attributes)
36
+ collection = base.data.storage(underscoredModelName)
37
+ collectionModel = collection.get(id)
38
+ if collectionModel
39
+ collectionModel.set(attributes)
40
+ else
41
+ collection.add(attributes)
42
+
43
+ _parseResultsResponse: (resp) ->
44
+ return resp unless resp['results']
45
+
46
+ if resp['results'].length
47
+ key = resp['results'][0].key
48
+ id = resp['results'][0].id
49
+ resp[key][id]
50
+ else
51
+ {}
52
+
53
+
54
+ # Retreive details about a named association. This is a class method.
55
+ # Model.associationDetails("project") # => {}
56
+ # timeEntry.constructor.associationDetails("project") # => {}
57
+ @associationDetails: (association) ->
58
+ @associationDetailsCache ||= {}
59
+ if @associations && @associations[association]
60
+ @associationDetailsCache[association] ||= do =>
61
+ if @associations[association] instanceof Array
62
+ {
63
+ type: "HasMany"
64
+ collectionName: @associations[association][0]
65
+ key: "#{association.singularize()}_ids"
66
+ }
67
+ else
68
+ {
69
+ type: "BelongsTo"
70
+ collectionName: @associations[association]
71
+ key: "#{association}_id"
72
+ }
73
+
74
+ # This method determines if all of the provided associations have been loaded for this model. If no associations are
75
+ # provided, all associations are assumed.
76
+ # model.associationsAreLoaded(["project", "task"]) # => true|false
77
+ # model.associationsAreLoaded() # => true|false
78
+ associationsAreLoaded: (associations) =>
79
+ associations ||= _.keys(@constructor.associations)
80
+ _.all associations, (association) =>
81
+ details = @constructor.associationDetails(association)
82
+ if details.type == "BelongsTo"
83
+ @attributes.hasOwnProperty(details.key) && (@attributes[details.key] == null || base.data.storage(details.collectionName).get(@attributes[details.key]))
84
+ else
85
+ @attributes.hasOwnProperty(details.key) && _.all(@attributes[details.key], (id) -> base.data.storage(details.collectionName).get(id))
86
+
87
+ # Override Model#get to access associations as well as fields.
88
+ get: (field, options = {}) =>
89
+ if details = @constructor.associationDetails(field)
90
+ if details.type == "BelongsTo"
91
+ id = @get(details.key) # project_id
92
+ if id?
93
+ base.data.storage(details.collectionName).get(id) || (Brainstem.Utils.throwError("Unable to find #{field} with id #{id} in our cached #{details.collectionName} collection. We know about #{base.data.storage(details.collectionName).pluck("id").join(", ")}"))
94
+ else
95
+ ids = @get(details.key) # time_entry_ids
96
+ models = []
97
+ notFoundIds = []
98
+ if ids
99
+ for id in ids
100
+ model = base.data.storage(details.collectionName).get(id)
101
+ models.push(model)
102
+ notFoundIds.push(id) unless model
103
+ if notFoundIds.length
104
+ Brainstem.Utils.throwError("Unable to find #{field} with ids #{notFoundIds.join(", ")} in our cached #{details.collectionName} collection. We know about #{base.data.storage(details.collectionName).pluck("id").join(", ")}")
105
+ if options.order
106
+ comparator = base.data.getCollectionDetails(details.collectionName).klass.getComparatorWithIdFailover(options.order)
107
+ collectionOptions = { comparator: comparator }
108
+ else
109
+ collectionOptions = {}
110
+ base.data.createNewCollection(details.collectionName, models, collectionOptions)
111
+ else
112
+ super(field)
113
+
114
+ className: =>
115
+ @paramRoot
116
+
117
+ defaultJSONBlacklist: ->
118
+ ['id', 'created_at', 'updated_at']
119
+
120
+ createJSONBlacklist: ->
121
+ []
122
+
123
+ updateJSONBlacklist: ->
124
+ []
125
+
126
+ toServerJSON: (method, options) =>
127
+ json = @toJSON(options)
128
+ blacklist = @defaultJSONBlacklist()
129
+
130
+ switch method
131
+ when "create"
132
+ blacklist = blacklist.concat @createJSONBlacklist()
133
+ when "update"
134
+ blacklist = blacklist.concat @updateJSONBlacklist()
135
+
136
+ for blacklistKey in blacklist
137
+ delete json[blacklistKey]
138
+
139
+ json
140
+
141
+ _.extend(Brainstem.Model.prototype, Brainstem.LoadingMixin);
@@ -0,0 +1,76 @@
1
+ Backbone.sync = (method, model, options) ->
2
+ methodMap =
3
+ create: 'POST'
4
+ update: 'PUT'
5
+ patch: 'PATCH'
6
+ delete: 'DELETE'
7
+ read: 'GET'
8
+
9
+ type = methodMap[method];
10
+
11
+ # Default options, unless specified.
12
+ _.defaults(options || (options = {}), {
13
+ emulateHTTP: Backbone.emulateHTTP,
14
+ emulateJSON: Backbone.emulateJSON
15
+ })
16
+
17
+ # Default JSON-request options.
18
+ params = { type: type, dataType: 'json' }
19
+
20
+ # Ensure that we have a URL.
21
+ if (!options.url)
22
+ params.url = _.result(model, 'url') || urlError()
23
+
24
+ # Ensure that we have the appropriate request data.
25
+ if !options.data? && model && (method == 'create' || method == 'update' || method == 'patch')
26
+ params.contentType = 'application/json'
27
+ data = options.attrs || {}
28
+
29
+ if model.toServerJSON?
30
+ json = model.toServerJSON(method, options)
31
+ else
32
+ json = model.toJSON(options)
33
+
34
+ if model.paramRoot
35
+ data[model.paramRoot] = json
36
+ else
37
+ data = json
38
+
39
+ data.include = Brainstem.Utils.extractArray("include", options).join(";")
40
+ data.filters = Brainstem.Utils.extractArray("filters", options).join(",")
41
+ params.data = JSON.stringify(data)
42
+
43
+ # For older servers, emulate JSON by encoding the request into an HTML-form.
44
+ if options.emulateJSON
45
+ params.contentType = 'application/x-www-form-urlencoded'
46
+ params.data = if params.data then {model: params.data} else {}
47
+
48
+ # For older servers, emulate HTTP by mimicking the HTTP method with `_method`
49
+ # And an `X-HTTP-Method-Override` header.
50
+ if options.emulateHTTP && (type == 'PUT' || type == 'DELETE' || type == 'PATCH')
51
+ params.type = 'POST'
52
+ if options.emulateJSON
53
+ params.data._method = type
54
+ beforeSend = options.beforeSend
55
+ options.beforeSend = (xhr) ->
56
+ xhr.setRequestHeader 'X-HTTP-Method-Override', type
57
+ if beforeSend
58
+ beforeSend.apply this, arguments
59
+
60
+ # Don't process data on a non-GET request.
61
+ if params.type != 'GET' && !options.emulateJSON
62
+ params.processData = false
63
+
64
+ # If we're sending a `PATCH` request, and we're in an old Internet Explorer
65
+ # that still has ActiveX enabled by default, override jQuery to use that
66
+ # for XHR instead. Remove this line when jQuery supports `PATCH` on IE8.
67
+ if params.type == 'PATCH' && window.ActiveXObject && !(window.external && window.external.msActiveXFilteringEnabled)
68
+ params.xhr = -> new ActiveXObject("Microsoft.XMLHTTP")
69
+
70
+ errorHandler = options.error
71
+ options.error = (jqXHR, textStatus, errorThrown) -> base?.data?.errorInterceptor?(errorHandler, model, options, jqXHR, params)
72
+
73
+ # Make the request, allowing the user to override any Ajax options.
74
+ xhr = options.xhr = Backbone.ajax(_.extend(params, options))
75
+ model.trigger 'request', model, xhr, options
76
+ xhr
@@ -0,0 +1 @@
1
+ //= require_tree .
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Date.parse with progressive enhancement for ISO 8601 <https://github.com/csnover/js-iso8601>
3
+ * © 2011 Colin Snover <http://zetafleet.com>
4
+ * Released under MIT license.
5
+ */
6
+ (function (Date, undefined) {
7
+ var origParse = Date.parse, numericKeys = [ 1, 4, 5, 6, 7, 10, 11 ];
8
+ Date.parse = function (date) {
9
+ var timestamp, struct, minutesOffset = 0;
10
+
11
+ // ES5 §15.9.4.2 states that the string should attempt to be parsed as a Date Time String Format string
12
+ // before falling back to any implementation-specific date parsing, so that’s what we do, even if native
13
+ // implementations could be faster
14
+ // 1 YYYY 2 MM 3 DD 4 HH 5 mm 6 ss 7 msec 8 Z 9 ± 10 tzHH 11 tzmm
15
+ if ((struct = /^(\d{4}|[+\-]\d{6})(?:-(\d{2})(?:-(\d{2}))?)?(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{3}))?)?(?:(Z)|([+\-])(\d{2})(?::(\d{2}))?)?)?$/.exec(date))) {
16
+ // avoid NaN timestamps caused by “undefined” values being passed to Date.UTC
17
+ for (var i = 0, k; (k = numericKeys[i]); ++i) {
18
+ struct[k] = +struct[k] || 0;
19
+ }
20
+
21
+ // allow undefined days and months
22
+ struct[2] = (+struct[2] || 1) - 1;
23
+ struct[3] = +struct[3] || 1;
24
+
25
+ if (struct[8] !== 'Z' && struct[9] !== undefined) {
26
+ minutesOffset = struct[10] * 60 + struct[11];
27
+
28
+ if (struct[9] === '+') {
29
+ minutesOffset = 0 - minutesOffset;
30
+ }
31
+ }
32
+
33
+ timestamp = Date.UTC(struct[1], struct[2], struct[3], struct[4], struct[5] + minutesOffset, struct[6], struct[7]);
34
+ }
35
+ else {
36
+ timestamp = origParse ? origParse(date) : NaN;
37
+ }
38
+
39
+ return timestamp;
40
+ };
41
+ }(Date));
@@ -0,0 +1,13 @@
1
+ window.Brainstem ?= {}
2
+
3
+ Brainstem.LoadingMixin =
4
+ setLoaded: (state, options) ->
5
+ options = { trigger: true } unless options? && options.trigger? && !options.trigger
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()
@@ -0,0 +1,275 @@
1
+ window.Brainstem ?= {}
2
+
3
+ # Todo: Record access timestamps on all Brainstem.Models by overloading #get and #set. Keep a sorted list (Heap?) of model references.
4
+ # clean up the oldest ones if memory is low
5
+ # allow passing a recency parameter to the StorageManager
6
+
7
+ # The StorageManager class is used to manage a set of Brainstem.Collections. It is responsible for loading data and
8
+ # maintaining caches.
9
+ class window.Brainstem.StorageManager
10
+ constructor: (options = {}) ->
11
+ @collections = {}
12
+ @setErrorInterceptor(options.errorInterceptor)
13
+
14
+ # Add a collection to the StorageManager. All collections that will be loaded or used in associations must be added.
15
+ # manager.addCollection "time_entries", App.Collections.TimeEntries
16
+ addCollection: (name, collectionClass) ->
17
+ @collections[name] =
18
+ klass: collectionClass
19
+ modelKlass: collectionClass.prototype.model
20
+ storage: new collectionClass()
21
+ cache: {}
22
+
23
+ # Access the cache for a particular collection.
24
+ # manager.storage("time_entries").get(12).get("title")
25
+ storage: (name) =>
26
+ @getCollectionDetails(name).storage
27
+
28
+ dataUsage: =>
29
+ sum = 0
30
+ for dataType in @collectionNames()
31
+ sum += @storage(dataType).length
32
+ sum
33
+
34
+ reset: =>
35
+ for name, attributes of @collections
36
+ attributes.storage.reset []
37
+ attributes.cache = {}
38
+
39
+ # Access details of a collection. An error will be thrown if the collection cannot be found.
40
+ getCollectionDetails: (name) =>
41
+ @collections[name] || @collectionError(name)
42
+
43
+ collectionNames: =>
44
+ _.keys(@collections)
45
+
46
+ collectionExists: (name) =>
47
+ !!@collections[name]
48
+
49
+ setErrorInterceptor: (interceptor) =>
50
+ @errorInterceptor = interceptor || (handler, modelOrCollection, options, jqXHR, requestParams) -> handler?(modelOrCollection, jqXHR)
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
70
+
71
+ # 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
+ # when the load, and any dependent loads, are complete.
73
+ # collection = manager.loadCollection "time_entries"
74
+ # collection = manager.loadCollection "time_entries", only: [2, 6]
75
+ # collection = manager.loadCollection "time_entries", fields: ["title", "notes"]
76
+ # collection = manager.loadCollection "time_entries", include: ["project", "task"]
77
+ # collection = manager.loadCollection "time_entries", include: ["project:title,description", "task:due_date"]
78
+ # collection = manager.loadCollection "tasks", include: ["assets", { "assignees": "account" }, { "sub_tasks": ["assignees", "assets"] }]
79
+ # 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}"
135
+
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) =>
218
+ Brainstem.Utils.throwError("Unknown collection #{name} in StorageManager. Known collections: #{_(@collections).keys().join(", ")}")
219
+
220
+ createNewCollection: (collectionName, models = [], options = {}) =>
221
+ loaded = options.loaded
222
+ delete options.loaded
223
+ collection = new (@getCollectionDetails(collectionName).klass)(models, options)
224
+ collection.setLoaded(true, trigger: false) if loaded
225
+ collection
226
+
227
+ createNewModel: (modelName, options) =>
228
+ new (@getCollectionDetails(modelName.pluralize()).modelKlass)(options || {})
229
+
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
+ # Expectations and stubbing
255
+
256
+ stub: (collectionName, options) =>
257
+ if @expectations?
258
+ expectation = new Brainstem.Expectation(collectionName, options, @)
259
+ @expectations.push expectation
260
+ expectation
261
+ else
262
+ throw "You must call #enableExpectations on your instance of Brainstem.StorageManager before you can set expectations."
263
+
264
+ stubImmediate: (collectionName, options) =>
265
+ @stub collectionName, $.extend({}, options, immediate: true)
266
+
267
+ enableExpectations: =>
268
+ @expectations = []
269
+
270
+ handleExpectations: (name, collection, options) =>
271
+ for expectation in @expectations
272
+ if expectation.optionsMatch(name, options)
273
+ expectation.recordRequest(collection, options)
274
+ return
275
+ throw "No expectation matched #{name} with #{JSON.stringify options}"
@@ -0,0 +1,35 @@
1
+ window.Brainstem ?= {}
2
+
3
+ class window.Brainstem.Utils
4
+ @warn: (args...) ->
5
+ console?.log "Error:", args...
6
+
7
+ @throwError: (message) =>
8
+ throw new Error("#{Backbone.history.getFragment()}: #{message}")
9
+
10
+ @matches: (obj1, obj2) =>
11
+ if @empty(obj1) && @empty(obj2)
12
+ true
13
+ else if obj1 instanceof Array && obj2 instanceof Array
14
+ obj1.length == obj2.length && _.every obj1, (value, index) => @matches(value, obj2[index])
15
+ else if obj1 instanceof Object && obj2 instanceof Object
16
+ obj1Keys = _(obj1).keys()
17
+ obj2Keys = _(obj2).keys()
18
+ obj1Keys.length == obj2Keys.length && _.every obj1Keys, (key) => @matches(obj1[key], obj2[key])
19
+ else
20
+ String(obj1) == String(obj2)
21
+
22
+ @empty: (thing) =>
23
+ if thing == null || thing == undefined || thing == ""
24
+ true
25
+ if thing instanceof Array
26
+ thing.length == 0 || thing.length == 1 && @empty(thing[0])
27
+ else if thing instanceof Object
28
+ _.keys(thing).length == 0
29
+ else
30
+ false
31
+
32
+ @extractArray: (option, options) =>
33
+ result = options[option]
34
+ result = [result] unless result instanceof Array
35
+ _.compact(result)