brainstem-js 0.2.1

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