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.
- 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)
|