brainstem-js 0.2.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +8 -0
- data/.pairs +21 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.tm_properties +1 -0
- data/.travis.yml +12 -0
- data/Assetfile +79 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +50 -0
- data/LICENSE.txt +22 -0
- data/README.md +143 -0
- data/Rakefile +25 -0
- data/brainstemjs.gemspec +24 -0
- data/lib/brainstem/js/engine.rb +5 -0
- data/lib/brainstem/js/version.rb +5 -0
- data/lib/brainstem/js.rb +10 -0
- data/spec/brainstem-collection-spec.coffee +141 -0
- data/spec/brainstem-model-spec.coffee +283 -0
- data/spec/brainstem-sync-spec.coffee +22 -0
- data/spec/brainstem-utils-spec.coffee +12 -0
- data/spec/brianstem-expectation-spec.coffee +209 -0
- data/spec/helpers/builders.coffee +80 -0
- data/spec/helpers/jquery-matchers.js +137 -0
- data/spec/helpers/models/post.coffee +14 -0
- data/spec/helpers/models/project.coffee +13 -0
- data/spec/helpers/models/task.coffee +14 -0
- data/spec/helpers/models/time-entry.coffee +13 -0
- data/spec/helpers/models/user.coffee +8 -0
- data/spec/helpers/spec-helper.coffee +79 -0
- data/spec/storage-manager-spec.coffee +613 -0
- data/spec/support/.DS_Store +0 -0
- data/spec/support/console-runner.js +103 -0
- data/spec/support/headless.coffee +47 -0
- data/spec/support/headless.html +60 -0
- data/spec/support/runner.html +85 -0
- data/spec/vendor/backbone-factory.js +62 -0
- data/spec/vendor/backbone.js +1571 -0
- data/spec/vendor/inflection.js +448 -0
- data/spec/vendor/jquery-1.7.js +9300 -0
- data/spec/vendor/jquery.cookie.js +47 -0
- data/spec/vendor/minispade.js +67 -0
- data/spec/vendor/sinon-1.3.4.js +3561 -0
- data/spec/vendor/underscore.js +1221 -0
- data/vendor/assets/.DS_Store +0 -0
- data/vendor/assets/javascripts/.DS_Store +0 -0
- data/vendor/assets/javascripts/brainstem/brainstem-collection.coffee +53 -0
- data/vendor/assets/javascripts/brainstem/brainstem-expectation.coffee +65 -0
- data/vendor/assets/javascripts/brainstem/brainstem-inflections.js +449 -0
- data/vendor/assets/javascripts/brainstem/brainstem-model.coffee +141 -0
- data/vendor/assets/javascripts/brainstem/brainstem-sync.coffee +76 -0
- data/vendor/assets/javascripts/brainstem/index.js +1 -0
- data/vendor/assets/javascripts/brainstem/iso8601.js +41 -0
- data/vendor/assets/javascripts/brainstem/loading-mixin.coffee +13 -0
- data/vendor/assets/javascripts/brainstem/storage-manager.coffee +275 -0
- data/vendor/assets/javascripts/brainstem/utils.coffee +35 -0
- 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)
|