├── .gitignore ├── .ruby-gemset ├── .ruby-version ├── .travis.yml ├── Gemfile ├── LICENSE ├── LICENSE.txt ├── README.md ├── Rakefile ├── backbone-filtered-collection.gemspec ├── lib ├── backbone-filtered-collection.rb └── backbone │ ├── filtered_collection.rb │ └── filtered_collection │ ├── engine.rb │ └── version.rb ├── spec └── javascripts │ ├── backbone-filtered-collection-spec.js │ ├── dependencies │ ├── backbone.js │ └── underscore.js │ ├── helpers │ └── SpecHelper.js │ └── support │ └── jasmine.yml └── vendor └── assets └── javascripts └── backbone-filtered-collection.js /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | .idea 7 | Gemfile.lock 8 | InstalledFiles 9 | _yardoc 10 | coverage 11 | doc/ 12 | lib/bundler/man 13 | pkg 14 | rdoc 15 | spec/reports 16 | test/tmp 17 | test/version_tmp 18 | tmp 19 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | filtered-collection 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-2.0.0-p353 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.0.0 4 | before_install: 5 | - "export DISPLAY=:99.0" 6 | - "sh -e /etc/init.d/xvfb start" 7 | install: bundle 8 | script: rake jasmine:ci 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in backbone_filtered_collection.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Dmitriy Likhten 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Dmitriy Likhten 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Filtered Collection 2 | 3 | [![Build Status](https://travis-ci.org/dlikhten/filtered-collection.png?branch=master)](https://travis-ci.org/dlikhten/filtered-collection) 4 | 5 | This is a simple filtered collection implemented using 6 | Backbone.Collection. The goal here is to create a collection which, 7 | given a filter function, will just contain elements of the original 8 | which pass the filter. Supports add/remove/reset events of the original 9 | to modify the filtered version. 10 | 11 | # Why not just extend backbone? 12 | 13 | The main reason I did not just extend backbone is because by extending 14 | it, you shove all behaviors into one model, making it a 15 | jack-of-all-trades and potentially conflicting with behaviors of other 16 | extentions, not to mention making normal operaitons potentially slower. 17 | So the intention is to compose a filter chain pattern using 18 | these guys. 19 | 20 | # Installation to rails 21 | 22 | With bundler 23 | 24 | gem 'backbone-filtered-collection' 25 | 26 | Inside your sprockets file: 27 | 28 | //= require backbone-filtered-collection 29 | 30 | # Installation anywhere else 31 | 32 | Download the [source][1], minify as you see fit by your minification strategy. 33 | 34 | # Usage 35 | 36 | var YourCollection = Backbone.Collection.extend({model: YourModel}); 37 | var YourFilteredCollection = Backbone.FilteredCollection.extend({model: YourModel}); 38 | 39 | var allItems = new YourCollection(...); 40 | 41 | // note the null, backbone collections want the pre-populated model here 42 | // we can't do that since this collection does not accept mutations, it 43 | // only mutates as a proxy for the underlying collection 44 | var filteredItems = new YourFilteredCollection(null, {collection: allItems}); 45 | 46 | var filteredItems.setFilter(function(item) { return item.get('included') == true;}); 47 | 48 | And now filteredItems contains only those items that pass the filter. 49 | You can still manipulate the original: 50 | 51 | allItems.add(..., {at: 5}); // at is supported too... 52 | 53 | However, if you invoke {silent: true} on the original model, then you 54 | must reset the filter by invoking: 55 | 56 | filteredItems.setFilter(); // no args = just re-filter 57 | 58 | Same goes for remove and reset. 59 | 60 | To clear the filtering completely, pass the value false to setFilter. 61 | 62 | ## Filters 63 | 64 | - `setFilter(function() {})`: Set the given function as the filter. Same api as `_.each`. 65 | - `setFilter(false)`: Turn filtering off. This collection will have all elements of the original collection. 66 | - `setFilter()`: Re-filter. Don't change the filter function but execute it on all elements. Useful after the original collection was modified via `silent: true` 67 | 68 | ## Events 69 | 70 | The collection will create events much like a regular collection. There are a few to note: 71 | 72 | - `add`: An object was added to the collection (via filter OR via orig collection) 73 | - `remove`: An object was removed from the collection (via filter OR via orig collection) 74 | - `reset`: The original collection was reset, filtering happened 75 | - `sort`: The collection was sorted. No changes in models represented. 76 | - `change`: An object in the collection was changed. The object was already accepted by the filter, and is still. 77 | - `filter-complete`: Filtering was completed. If you are not listening to add/remove then just listen to filter-complete and reset your views. 78 | 79 | ## Change Collection 80 | 81 | You can change the underlying collection if you really need to by invoking `#resetWith(newCollection)`, only one `reset` 82 | event will be triggered with the new data. 83 | 84 | # Testing 85 | 86 | bundle install 87 | rake jasmine 88 | 89 | I also included a .rvmrc file incase you have rvm installed. 90 | 91 | # Contributing 92 | 93 | Please, do not contribute without a spec. Being tested is critically important 94 | to this project, as it's a framework level component, and so its failure 95 | will be damn hard to detect. 96 | 97 | Also, no tab characters, 2 spaces only. Minifiers can handle this stuff for you. 98 | 99 | [1]: https://raw.github.com/dlikhten/filtered-collection/master/vendor/assets/javascripts/backbone-filtered-collection.js 100 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | require 'jasmine' 4 | load 'jasmine/tasks/jasmine.rake' 5 | -------------------------------------------------------------------------------- /backbone-filtered-collection.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require File.expand_path('../lib/backbone/filtered_collection/version', __FILE__) 3 | 4 | Gem::Specification.new do |gem| 5 | gem.name = "backbone-filtered-collection" 6 | gem.version = Backbone::FilteredCollection::VERSION 7 | gem.platform = Gem::Platform::RUBY 8 | gem.authors = ["Dmitriy Likhten"] 9 | gem.email = ["dlikhten@gmail.com"] 10 | gem.description = %q{A filtered collection for backbone.js} 11 | gem.summary = %q{Allowing implementation of a chain-of-responsibility pattern in backbone's collection filtering} 12 | gem.homepage = "http://github.com/dlikhten/filtered-collection" 13 | gem.license = "MIT" 14 | 15 | gem.files = `git ls-files`.split($/) 16 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 17 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 18 | gem.require_paths = ["lib"] 19 | 20 | gem.add_dependency "railties", ">= 3.0", "< 5.0" 21 | 22 | gem.add_development_dependency 'rake' 23 | gem.add_development_dependency 'jasmine', '< 2.0' 24 | end 25 | -------------------------------------------------------------------------------- /lib/backbone-filtered-collection.rb: -------------------------------------------------------------------------------- 1 | require "backbone/filtered_collection" 2 | -------------------------------------------------------------------------------- /lib/backbone/filtered_collection.rb: -------------------------------------------------------------------------------- 1 | require 'backbone/filtered_collection/engine' if ::Rails.version >= '3.1' 2 | require 'backbone/filtered_collection/version' 3 | 4 | module Backbone 5 | module FilteredCollection 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/backbone/filtered_collection/engine.rb: -------------------------------------------------------------------------------- 1 | module Backbone 2 | module FilteredCollection 3 | class Engine < ::Rails::Engine 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/backbone/filtered_collection/version.rb: -------------------------------------------------------------------------------- 1 | module Backbone 2 | module FilteredCollection 3 | VERSION = "1.2.1" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/javascripts/backbone-filtered-collection-spec.js: -------------------------------------------------------------------------------- 1 | describe("Backbone.FilteredCollection", function() { 2 | var TehModel = Backbone.Model.extend({ 3 | defaults: {value: -1} 4 | }); 5 | 6 | var RegularModelCollection = Backbone.Collection.extend({ 7 | model: TehModel 8 | }); 9 | 10 | var allModels; 11 | var collection; 12 | 13 | var createLessthanFilter = function(lessThan) { 14 | return function(model) { 15 | return model.get('value') < lessThan; 16 | } 17 | }; 18 | 19 | var evenFilter = function(model) { 20 | return model.get("value") % 2 == 0; 21 | }; 22 | 23 | beforeEach(function() { 24 | allModels = new RegularModelCollection(); 25 | for(var i = 0; i < 10; i++) { 26 | allModels.add(new TehModel({id: i, value: i})); 27 | } 28 | 29 | collection = new Backbone.FilteredCollection(null, {collection: allModels}); 30 | }); 31 | 32 | afterEach(function() { 33 | if (collection) collection.off(null, null, null); 34 | }); 35 | 36 | describe("#setFilter", function() { 37 | it("filters the given model", function() { 38 | collection.setFilter(createLessthanFilter(5)); 39 | 40 | expect(collection.length).toEqual(5); 41 | expect(collection.at(0).get('value')).toEqual(0); 42 | }); 43 | 44 | it("uses the given filter", function() { 45 | collection.setFilter(createLessthanFilter(5)); 46 | collection.setFilter(function(model) { 47 | return model.get('value') > 7; 48 | }); 49 | 50 | expect(collection.length).toEqual(2); 51 | expect(collection.at(0).get('value')).toEqual(8); 52 | }); 53 | 54 | it("accepts false to remove all filters", function() { 55 | collection.setFilter(createLessthanFilter(5)); 56 | expect(collection.length).toEqual(5); 57 | collection.setFilter(undefined); // no change 58 | expect(collection.length).toEqual(5); 59 | collection.setFilter(null); // no change 60 | expect(collection.length).toEqual(5); 61 | collection.setFilter(false); // filter reset 62 | expect(collection.length).toEqual(10); 63 | }); 64 | 65 | it("works correctly after filtering is changed constantly", function() { 66 | collection.setFilter(createLessthanFilter(0)); 67 | expect(collection.models.length).toEqual(0); 68 | 69 | collection.setFilter(createLessthanFilter(3)); 70 | expect(collection.models.length).toEqual(3); 71 | expect(collection.at(0).get("value")).toEqual(0); 72 | expect(collection.at(1).get("value")).toEqual(1); 73 | expect(collection.at(2).get("value")).toEqual(2); 74 | 75 | collection.setFilter(evenFilter); 76 | expect(collection.models.length).toEqual(5); 77 | expect(collection.at(0).get("value")).toEqual(0); 78 | expect(collection.at(1).get("value")).toEqual(2); 79 | expect(collection.at(2).get("value")).toEqual(4); 80 | expect(collection.at(3).get("value")).toEqual(6); 81 | expect(collection.at(4).get("value")).toEqual(8); 82 | }); 83 | 84 | it("does not trigger a filter-complete event if options.silent is true", function() { 85 | var eventSpy = jasmine.createSpy('filter-complete listener'); 86 | collection.on("filter-complete", eventSpy); 87 | 88 | collection.setFilter(createLessthanFilter(0), {silent: true}); 89 | expect(eventSpy).not.toHaveBeenCalled(); 90 | }); 91 | }); 92 | 93 | describe("event:add", function() { 94 | it("does not add an already filtered out new object", function() { 95 | collection.setFilter(createLessthanFilter(5)); 96 | expect(collection.length).toEqual(5); 97 | allModels.add(new TehModel({value: 6})); 98 | expect(collection.length).toEqual(5); 99 | }); 100 | 101 | it("adds the new object, since it passes the filter", function() { 102 | collection.setFilter(createLessthanFilter(5)); 103 | expect(collection.length).toEqual(5); 104 | allModels.add(new TehModel({value: 1})); 105 | expect(collection.length).toEqual(6); 106 | expect(collection.at(5).get('value')).toEqual(1); 107 | }); 108 | 109 | it("adds the new object to the correct location", function() { 110 | collection.setFilter(createLessthanFilter(5)); 111 | expect(collection.length).toEqual(5); 112 | allModels.add(new TehModel({value: 4}), {at: 0}); 113 | expect(collection.length).toEqual(6); 114 | expect(collection.at(0).get('value')).toEqual(4); 115 | }); 116 | 117 | it("triggers an add event if the object was added", function() { 118 | collection.setFilter(createLessthanFilter(5)); 119 | var newModel = new TehModel({value: 3}); 120 | var eventSpy = jasmine.createSpy('reset event listener'); 121 | 122 | collection.on("add", eventSpy); 123 | allModels.add(newModel, {at: 0}); 124 | 125 | expect(eventSpy).toHaveBeenCalledWith(newModel, collection, {index: 0}) 126 | }); 127 | 128 | it("re-numbers elements properly in the mapping according to what the atual indices are in the original collection", function() { 129 | collection.setFilter(createLessthanFilter(10)); 130 | expect(collection.length).toEqual(10); 131 | 132 | allModels.add(new TehModel({value: 4}), {at: 6}); 133 | 134 | expect(collection._mapping).toEqual([0,1,2,3,4,5,6,7,8,9,10]) 135 | }); 136 | }); 137 | 138 | describe("event:remove", function() { 139 | it("is a no-op if the item removed is already not filtered", function() { 140 | collection.setFilter(createLessthanFilter(5)); 141 | expect(collection.length).toEqual(5); 142 | allModels.remove(allModels.at(6)); 143 | expect(collection.length).toEqual(5); 144 | }); 145 | 146 | it("removes the model that was removed from the underlying collection", function() { 147 | collection.setFilter(createLessthanFilter(5)); 148 | expect(collection.length).toEqual(5); 149 | allModels.remove(allModels.at(4)); 150 | expect(collection.length).toEqual(4); 151 | expect(collection.at(collection.length - 1).get('value')).toEqual(3); 152 | }); 153 | 154 | it("should re-number elements properly in the mapping according to what the actual indices are in the original collection", function() { 155 | collection.setFilter(createLessthanFilter(10)); 156 | expect(collection.length).toEqual(10); 157 | 158 | allModels.remove(allModels.at(4)); 159 | 160 | expect(collection._mapping).toEqual([0,1,2,3,4,5,6,7,8]) 161 | }); 162 | }); 163 | 164 | describe("event:reset", function() { 165 | it("resets all models to those in the underlying collection", function() { 166 | collection.setFilter(createLessthanFilter(15)); 167 | var newAll = []; 168 | for (var i = 10; i < 20; i++) { 169 | newAll.push(new TehModel({value: i})); 170 | } 171 | allModels.reset(newAll); 172 | expect(collection.length).toEqual(5); 173 | expect(collection.at(4).get('value')).toEqual(14); 174 | }); 175 | 176 | it("triggers exactly one reset event", function() { 177 | var eventSpy = jasmine.createSpy('reset event listener'); 178 | collection.on('reset', eventSpy); 179 | 180 | allModels.reset([{id: 11, value: 11}]); 181 | 182 | expect(eventSpy).toHaveBeenCalledWith(collection); 183 | expect(eventSpy.callCount).toEqual(1); 184 | }); 185 | }); 186 | 187 | describe("event:sort", function() { 188 | it("continues to filter the collection, except with a new order", function() { 189 | collection.setFilter(createLessthanFilter(5)); 190 | allModels.comparator = function(v1, v2) { 191 | return v2.get("value") - v1.get("value"); 192 | }; 193 | allModels.sort(); 194 | 195 | expect(collection.length).toEqual(5); 196 | expect(collection.at(0).get('value')).toEqual(4); 197 | expect(collection.at(1).get('value')).toEqual(3); 198 | expect(collection.at(2).get('value')).toEqual(2); 199 | expect(collection.at(3).get('value')).toEqual(1); 200 | expect(collection.at(4).get('value')).toEqual(0); 201 | }); 202 | }); 203 | 204 | describe("event:filter-complete", function() { 205 | it("triggers when the underlying collection triggers it (thus we're done filtering too)", function() { 206 | var eventSpy = jasmine.createSpy('filter-complete event listener'); 207 | collection.on("filter-complete", eventSpy); 208 | 209 | allModels.trigger("filter-complete"); 210 | 211 | expect(eventSpy).toHaveBeenCalled(); 212 | }); 213 | 214 | it("triggers once only at the end of a filter", function() { 215 | var eventSpy = jasmine.createSpy('filter-complete event listener'); 216 | collection.on("filter-complete", eventSpy); 217 | 218 | collection.setFilter(createLessthanFilter(3)); 219 | 220 | expect(eventSpy).toHaveBeenCalled(); 221 | expect(eventSpy.callCount).toEqual(1); 222 | }); 223 | 224 | it("triggers once when a change is propagated from an underlying model", function() { 225 | var filterFired = 0; 226 | collection.on("filter-complete", function() { 227 | filterFired += 1; 228 | }); 229 | collection.setFilter(createLessthanFilter(3)); 230 | filterFired = 0; 231 | 232 | collection.at(0).trigger("change", collection.at(0), allModels) 233 | expect(filterFired).toEqual(1); 234 | }); 235 | }); 236 | 237 | describe("model - event:destroy", function() { 238 | it("just removes the model from the base collection like normal, and raise no problems with the filter", function() { 239 | collection.setFilter(createLessthanFilter(5)); 240 | origModelZero = collection.at(0); 241 | // simulate an ajax destroy 242 | origModelZero.trigger("destroy", origModelZero, origModelZero.collection); 243 | 244 | expect(collection.at(0).get("value")).toEqual(1); 245 | }); 246 | 247 | it("removes elements from the model as events occur", function() { 248 | collection.setFilter(createLessthanFilter(10)); 249 | 250 | // start removing in weird orders, make sure vents are done properly 251 | model = collection.at(0); 252 | model.trigger("destroy", model, model.collection); 253 | expect(collection.at(0).get("value")).toEqual(1); 254 | 255 | model = collection.at(3); 256 | model.trigger("destroy", model, model.collection); 257 | expect(collection.at(3).get("value")).toEqual(5); 258 | 259 | model = collection.at(3); 260 | model.trigger("destroy", model, model.collection); 261 | expect(collection.at(3).get("value")).toEqual(6); 262 | 263 | model = collection.at(3); 264 | model.trigger("destroy", model, model.collection); 265 | expect(collection.at(3).get("value")).toEqual(7); 266 | 267 | model = collection.at(2); 268 | model.trigger("destroy", model, model.collection); 269 | expect(collection.at(2).get("value")).toEqual(7); 270 | 271 | model = collection.at(1); 272 | model.trigger("destroy", model, model.collection); 273 | expect(collection.at(1).get("value")).toEqual(7); 274 | }); 275 | 276 | it("triggers remove events for every deleted model", function() { 277 | collection.setFilter(createLessthanFilter(10)); 278 | var lastModelRemoved = null; 279 | var count = 0; 280 | collection.on("remove", function(removedModel) { 281 | lastModelRemoved = removedModel; 282 | count += 1; 283 | }); 284 | 285 | // start removing in weird orders, make sure vents are done properly 286 | count = 0; 287 | model = collection.at(0); 288 | model.trigger("destroy", model, model.collection) 289 | expect(lastModelRemoved).toEqual(model); 290 | expect(count).toEqual(1); 291 | 292 | count = 0; 293 | model = collection.at(3); 294 | model.trigger("destroy", model, model.collection) 295 | expect(lastModelRemoved).toEqual(model); 296 | expect(count).toEqual(1); 297 | 298 | count = 0; 299 | model = collection.at(3); 300 | model.trigger("destroy", model, model.collection) 301 | expect(lastModelRemoved).toEqual(model); 302 | expect(count).toEqual(1); 303 | 304 | count = 0; 305 | model = collection.at(3); 306 | model.trigger("destroy", model, model.collection) 307 | expect(lastModelRemoved).toEqual(model); 308 | expect(count).toEqual(1); 309 | 310 | count = 0; 311 | model = collection.at(2); 312 | model.trigger("destroy", model, model.collection) 313 | expect(lastModelRemoved).toEqual(model); 314 | expect(count).toEqual(1); 315 | 316 | count = 0; 317 | model = collection.at(1); 318 | model.trigger("destroy", model, model.collection) 319 | expect(lastModelRemoved).toEqual(model); 320 | expect(count).toEqual(1); 321 | }); 322 | }); 323 | 324 | describe("model - event:change", function() { 325 | var changeSpy, addSpy, removeSpy; 326 | 327 | beforeEach(function() { 328 | changeSpy = jasmine.createSpy("change listener"); 329 | addSpy = jasmine.createSpy("add listener"); 330 | removeSpy = jasmine.createSpy("remove listener"); 331 | }); 332 | 333 | it("removes the model because it failed the filter post change, triggers remove event", function() { 334 | collection.setFilter(createLessthanFilter(5)); 335 | collection.on("change", changeSpy); 336 | collection.on("add", addSpy); 337 | collection.on("remove", removeSpy); 338 | 339 | origModelZero = collection.at(0); 340 | origModelZero.set("value", 10) 341 | 342 | expect(collection.models.length).toEqual(4) 343 | expect(collection.at(0).get("value")).toEqual(1) 344 | expect(changeSpy).not.toHaveBeenCalled(); 345 | expect(addSpy).not.toHaveBeenCalled(); 346 | expect(removeSpy).toHaveBeenCalledWith(origModelZero, collection, jasmine.any(Object)); 347 | }); 348 | 349 | it("does not alter the collection if the model is still passing, triggers change event", function() { 350 | collection.setFilter(createLessthanFilter(5)); 351 | collection.on("change", changeSpy); 352 | collection.on("add", addSpy); 353 | collection.on("remove", removeSpy); 354 | 355 | origModelZero = collection.at(0); 356 | origModelZero.set("value", 3) 357 | 358 | expect(collection.models.length).toEqual(5) 359 | expect(collection.at(0).get("value")).toEqual(3) 360 | expect(changeSpy).toHaveBeenCalledWith(origModelZero, collection); 361 | expect(addSpy).not.toHaveBeenCalled(); 362 | expect(removeSpy).not.toHaveBeenCalled(); 363 | }); 364 | 365 | it("adds the model that is now passing the filter, triggers add event", function() { 366 | collection.setFilter(createLessthanFilter(5)); 367 | collection.on("change", changeSpy); 368 | collection.on("add", addSpy); 369 | collection.on("remove", removeSpy); 370 | 371 | origModelZero = allModels.at(9); 372 | origModelZero.set("value", 2) 373 | 374 | expect(collection.models.length).toEqual(6) 375 | expect(collection.at(5).get("value")).toEqual(2) 376 | expect(changeSpy).not.toHaveBeenCalled(); 377 | expect(addSpy).toHaveBeenCalledWith(origModelZero, collection, jasmine.any(Object)); 378 | expect(removeSpy).not.toHaveBeenCalled(); 379 | }); 380 | 381 | it("property change events are fired on filtered collection", function() { 382 | var collection = new Backbone.Collection([ {id: 1, difficulty: 1}, {id: 2, difficulty: 2}, {id: 3, difficulty: 1}, {id: 4, difficulty: 3} ]); 383 | var filteredCollection = new Backbone.FilteredCollection(null, {collection: collection}); 384 | filteredCollection.setFilter(function(d) { return (d.get('difficulty') === 1) }); 385 | 386 | var changeCount = 0; 387 | var newModel = new Backbone.Model({id: 5, difficulty: 1}); 388 | collection.add(newModel); 389 | 390 | filteredCollection.on("change:blah", function() { changeCount = changeCount + 1; }); 391 | filteredCollection.on("change:blubb", function() { changeCount = changeCount + 1; }); 392 | newModel.set({"blah": "blah"}); 393 | 394 | expect(changeCount).toEqual(1); 395 | }); 396 | 397 | it("property change events are not fired if it does not fit filter", function() { 398 | var collection = new Backbone.Collection([ {id: 1, difficulty: 1}, {id: 2, difficulty: 2}, {id: 3, difficulty: 1}, {id: 4, difficulty: 3} ]); 399 | var filteredCollection = new Backbone.FilteredCollection(null, {collection: collection}); 400 | filteredCollection.setFilter(function(d) { return (d.get('difficulty') === 1) }); 401 | 402 | var changeCount = 0; 403 | var newModel = new Backbone.Model({id: 5, difficulty: 1}); 404 | collection.add(newModel); 405 | 406 | filteredCollection.on("change:difficulty", function() { changeCount = changeCount + 1; }); 407 | newModel.set("difficulty", 2); 408 | 409 | expect(changeCount).toEqual(0); 410 | }); 411 | }); 412 | 413 | describe("#resetWith", function() { 414 | var moreModels; 415 | 416 | beforeEach(function(){ 417 | moreModels = new RegularModelCollection(); 418 | for(var i = 10; i < 16; i++) { 419 | moreModels.add(new TehModel({id: i, value: i})); 420 | } 421 | collection.setFilter(evenFilter); 422 | }); 423 | 424 | it("updates the collection's length to match that of the new collection", function() { 425 | var lengthBefore = collection.length; 426 | collection.resetWith(moreModels); 427 | var lengthAfter = collection.length; 428 | 429 | expect(lengthBefore).toEqual(5); // even models 430 | expect(lengthAfter).toEqual(3); // even models 431 | }); 432 | 433 | it("handles add events on the new collection", function() { 434 | collection.resetWith(moreModels); 435 | 436 | var lengthBefore = collection.length; 437 | newModel = new TehModel({id: 16, value: 16}); 438 | moreModels.add(newModel); 439 | var lengthAfter = collection.length; 440 | 441 | expect(lengthAfter).toEqual(lengthBefore + 1); 442 | expect(collection.indexOf(newModel)).toBeGreaterThan(-1); 443 | }); 444 | 445 | it("handles remove events on the new collection", function() { 446 | collection.resetWith(moreModels); 447 | 448 | var lengthBefore = collection.length; 449 | var toRemove = moreModels.get(12); 450 | moreModels.remove(toRemove); 451 | var lengthAfter = collection.length; 452 | 453 | expect(lengthAfter).toEqual(lengthBefore - 1); 454 | expect(collection.indexOf(toRemove)).toEqual(-1) 455 | }); 456 | 457 | it("handles change events on the new collection", function() { 458 | collection.resetWith(moreModels); 459 | 460 | var lengthBefore = collection.length; 461 | var changeModel = moreModels.get(10); 462 | changeModel.set('value', 11); 463 | var lengthAfter = collection.length; 464 | 465 | expect(lengthAfter).toEqual(lengthBefore - 1); 466 | expect(collection.indexOf(changeModel)).toEqual(-1); 467 | }); 468 | 469 | it("ignores the old collection events", function() { 470 | var eventSpy = jasmine.createSpy('all event listener'); 471 | 472 | collection.resetWith(moreModels); 473 | 474 | collection.on('all', eventSpy); 475 | 476 | allModels.add(new TehModel({id: 16, value: 16})); 477 | allModels.remove(allModels.at(0)); 478 | allModels.get(2).set("value", "3"); 479 | allModels.comparator = function(a, b) { return 0 }; 480 | allModels.sort(); 481 | allModels.reset([{value: 5}]); 482 | 483 | expect(eventSpy).not.toHaveBeenCalled(); 484 | }); 485 | 486 | it("fire a reset with the new filtered data, exactly once", function() { 487 | var eventSpy = jasmine.createSpy('reset event listener'); 488 | 489 | collection.on('reset', eventSpy); 490 | 491 | collection.resetWith(moreModels); 492 | 493 | expect(eventSpy).toHaveBeenCalledWith(collection); 494 | expect(eventSpy.callCount).toEqual(1); 495 | }); 496 | }); 497 | }); 498 | -------------------------------------------------------------------------------- /spec/javascripts/dependencies/backbone.js: -------------------------------------------------------------------------------- 1 | // Backbone.js 0.9.9 2 | 3 | // (c) 2010-2012 Jeremy Ashkenas, DocumentCloud Inc. 4 | // Backbone may be freely distributed under the MIT license. 5 | // For all details and documentation: 6 | // http://backbonejs.org 7 | 8 | (function(){ 9 | 10 | // Initial Setup 11 | // ------------- 12 | 13 | // Save a reference to the global object (`window` in the browser, `exports` 14 | // on the server). 15 | var root = this; 16 | 17 | // Save the previous value of the `Backbone` variable, so that it can be 18 | // restored later on, if `noConflict` is used. 19 | var previousBackbone = root.Backbone; 20 | 21 | // Create a local reference to array methods. 22 | var array = []; 23 | var push = array.push; 24 | var slice = array.slice; 25 | var splice = array.splice; 26 | 27 | // The top-level namespace. All public Backbone classes and modules will 28 | // be attached to this. Exported for both CommonJS and the browser. 29 | var Backbone; 30 | if (typeof exports !== 'undefined') { 31 | Backbone = exports; 32 | } else { 33 | Backbone = root.Backbone = {}; 34 | } 35 | 36 | // Current version of the library. Keep in sync with `package.json`. 37 | Backbone.VERSION = '0.9.9'; 38 | 39 | // Require Underscore, if we're on the server, and it's not already present. 40 | var _ = root._; 41 | if (!_ && (typeof require !== 'undefined')) _ = require('underscore'); 42 | 43 | // For Backbone's purposes, jQuery, Zepto, or Ender owns the `$` variable. 44 | Backbone.$ = root.jQuery || root.Zepto || root.ender; 45 | 46 | // Runs Backbone.js in *noConflict* mode, returning the `Backbone` variable 47 | // to its previous owner. Returns a reference to this Backbone object. 48 | Backbone.noConflict = function() { 49 | root.Backbone = previousBackbone; 50 | return this; 51 | }; 52 | 53 | // Turn on `emulateHTTP` to support legacy HTTP servers. Setting this option 54 | // will fake `"PUT"` and `"DELETE"` requests via the `_method` parameter and 55 | // set a `X-Http-Method-Override` header. 56 | Backbone.emulateHTTP = false; 57 | 58 | // Turn on `emulateJSON` to support legacy servers that can't deal with direct 59 | // `application/json` requests ... will encode the body as 60 | // `application/x-www-form-urlencoded` instead and will send the model in a 61 | // form param named `model`. 62 | Backbone.emulateJSON = false; 63 | 64 | // Backbone.Events 65 | // --------------- 66 | 67 | // Regular expression used to split event strings. 68 | var eventSplitter = /\s+/; 69 | 70 | // Implement fancy features of the Events API such as multiple event 71 | // names `"change blur"` and jQuery-style event maps `{change: action}` 72 | // in terms of the existing API. 73 | var eventsApi = function(obj, action, name, rest) { 74 | if (!name) return true; 75 | if (typeof name === 'object') { 76 | for (var key in name) { 77 | obj[action].apply(obj, [key, name[key]].concat(rest)); 78 | } 79 | } else if (eventSplitter.test(name)) { 80 | var names = name.split(eventSplitter); 81 | for (var i = 0, l = names.length; i < l; i++) { 82 | obj[action].apply(obj, [names[i]].concat(rest)); 83 | } 84 | } else { 85 | return true; 86 | } 87 | }; 88 | 89 | // Optimized internal dispatch function for triggering events. Tries to 90 | // keep the usual cases speedy (most Backbone events have 3 arguments). 91 | var triggerEvents = function(obj, events, args) { 92 | var ev, i = -1, l = events.length; 93 | switch (args.length) { 94 | case 0: while (++i < l) (ev = events[i]).callback.call(ev.ctx); 95 | return; 96 | case 1: while (++i < l) (ev = events[i]).callback.call(ev.ctx, args[0]); 97 | return; 98 | case 2: while (++i < l) (ev = events[i]).callback.call(ev.ctx, args[0], args[1]); 99 | return; 100 | case 3: while (++i < l) (ev = events[i]).callback.call(ev.ctx, args[0], args[1], args[2]); 101 | return; 102 | default: while (++i < l) (ev = events[i]).callback.apply(ev.ctx, args); 103 | } 104 | }; 105 | 106 | // A module that can be mixed in to *any object* in order to provide it with 107 | // custom events. You may bind with `on` or remove with `off` callback 108 | // functions to an event; `trigger`-ing an event fires all callbacks in 109 | // succession. 110 | // 111 | // var object = {}; 112 | // _.extend(object, Backbone.Events); 113 | // object.on('expand', function(){ alert('expanded'); }); 114 | // object.trigger('expand'); 115 | // 116 | var Events = Backbone.Events = { 117 | 118 | // Bind one or more space separated events, or an events map, 119 | // to a `callback` function. Passing `"all"` will bind the callback to 120 | // all events fired. 121 | on: function(name, callback, context) { 122 | if (!(eventsApi(this, 'on', name, [callback, context]) && callback)) return this; 123 | this._events || (this._events = {}); 124 | var list = this._events[name] || (this._events[name] = []); 125 | list.push({callback: callback, context: context, ctx: context || this}); 126 | return this; 127 | }, 128 | 129 | // Bind events to only be triggered a single time. After the first time 130 | // the callback is invoked, it will be removed. 131 | once: function(name, callback, context) { 132 | if (!(eventsApi(this, 'once', name, [callback, context]) && callback)) return this; 133 | var self = this; 134 | var once = _.once(function() { 135 | self.off(name, once); 136 | callback.apply(this, arguments); 137 | }); 138 | once._callback = callback; 139 | this.on(name, once, context); 140 | return this; 141 | }, 142 | 143 | // Remove one or many callbacks. If `context` is null, removes all 144 | // callbacks with that function. If `callback` is null, removes all 145 | // callbacks for the event. If `events` is null, removes all bound 146 | // callbacks for all events. 147 | off: function(name, callback, context) { 148 | var list, ev, events, names, i, l, j, k; 149 | if (!this._events || !eventsApi(this, 'off', name, [callback, context])) return this; 150 | if (!name && !callback && !context) { 151 | this._events = {}; 152 | return this; 153 | } 154 | 155 | names = name ? [name] : _.keys(this._events); 156 | for (i = 0, l = names.length; i < l; i++) { 157 | name = names[i]; 158 | if (list = this._events[name]) { 159 | events = []; 160 | if (callback || context) { 161 | for (j = 0, k = list.length; j < k; j++) { 162 | ev = list[j]; 163 | if ((callback && callback !== (ev.callback._callback || ev.callback)) || 164 | (context && context !== ev.context)) { 165 | events.push(ev); 166 | } 167 | } 168 | } 169 | this._events[name] = events; 170 | } 171 | } 172 | 173 | return this; 174 | }, 175 | 176 | // Trigger one or many events, firing all bound callbacks. Callbacks are 177 | // passed the same arguments as `trigger` is, apart from the event name 178 | // (unless you're listening on `"all"`, which will cause your callback to 179 | // receive the true name of the event as the first argument). 180 | trigger: function(name) { 181 | if (!this._events) return this; 182 | var args = slice.call(arguments, 1); 183 | if (!eventsApi(this, 'trigger', name, args)) return this; 184 | var events = this._events[name]; 185 | var allEvents = this._events.all; 186 | if (events) triggerEvents(this, events, args); 187 | if (allEvents) triggerEvents(this, allEvents, arguments); 188 | return this; 189 | }, 190 | 191 | // An inversion-of-control version of `on`. Tell *this* object to listen to 192 | // an event in another object ... keeping track of what it's listening to. 193 | listenTo: function(object, events, callback) { 194 | var listeners = this._listeners || (this._listeners = {}); 195 | var id = object._listenerId || (object._listenerId = _.uniqueId('l')); 196 | listeners[id] = object; 197 | object.on(events, callback || this, this); 198 | return this; 199 | }, 200 | 201 | // Tell this object to stop listening to either specific events ... or 202 | // to every object it's currently listening to. 203 | stopListening: function(object, events, callback) { 204 | var listeners = this._listeners; 205 | if (!listeners) return; 206 | if (object) { 207 | object.off(events, callback, this); 208 | if (!events && !callback) delete listeners[object._listenerId]; 209 | } else { 210 | for (var id in listeners) { 211 | listeners[id].off(null, null, this); 212 | } 213 | this._listeners = {}; 214 | } 215 | return this; 216 | } 217 | }; 218 | 219 | // Aliases for backwards compatibility. 220 | Events.bind = Events.on; 221 | Events.unbind = Events.off; 222 | 223 | // Allow the `Backbone` object to serve as a global event bus, for folks who 224 | // want global "pubsub" in a convenient place. 225 | _.extend(Backbone, Events); 226 | 227 | // Backbone.Model 228 | // -------------- 229 | 230 | // Create a new model, with defined attributes. A client id (`cid`) 231 | // is automatically generated and assigned for you. 232 | var Model = Backbone.Model = function(attributes, options) { 233 | var defaults; 234 | var attrs = attributes || {}; 235 | this.cid = _.uniqueId('c'); 236 | this.changed = {}; 237 | this.attributes = {}; 238 | this._changes = []; 239 | if (options && options.collection) this.collection = options.collection; 240 | if (options && options.parse) attrs = this.parse(attrs); 241 | if (defaults = _.result(this, 'defaults')) _.defaults(attrs, defaults); 242 | this.set(attrs, {silent: true}); 243 | this._currentAttributes = _.clone(this.attributes); 244 | this._previousAttributes = _.clone(this.attributes); 245 | this.initialize.apply(this, arguments); 246 | }; 247 | 248 | // Attach all inheritable methods to the Model prototype. 249 | _.extend(Model.prototype, Events, { 250 | 251 | // A hash of attributes whose current and previous value differ. 252 | changed: null, 253 | 254 | // The default name for the JSON `id` attribute is `"id"`. MongoDB and 255 | // CouchDB users may want to set this to `"_id"`. 256 | idAttribute: 'id', 257 | 258 | // Initialize is an empty function by default. Override it with your own 259 | // initialization logic. 260 | initialize: function(){}, 261 | 262 | // Return a copy of the model's `attributes` object. 263 | toJSON: function(options) { 264 | return _.clone(this.attributes); 265 | }, 266 | 267 | // Proxy `Backbone.sync` by default. 268 | sync: function() { 269 | return Backbone.sync.apply(this, arguments); 270 | }, 271 | 272 | // Get the value of an attribute. 273 | get: function(attr) { 274 | return this.attributes[attr]; 275 | }, 276 | 277 | // Get the HTML-escaped value of an attribute. 278 | escape: function(attr) { 279 | return _.escape(this.get(attr)); 280 | }, 281 | 282 | // Returns `true` if the attribute contains a value that is not null 283 | // or undefined. 284 | has: function(attr) { 285 | return this.get(attr) != null; 286 | }, 287 | 288 | // Set a hash of model attributes on the object, firing `"change"` unless 289 | // you choose to silence it. 290 | set: function(key, val, options) { 291 | var attr, attrs; 292 | if (key == null) return this; 293 | 294 | // Handle both `"key", value` and `{key: value}` -style arguments. 295 | if (_.isObject(key)) { 296 | attrs = key; 297 | options = val; 298 | } else { 299 | (attrs = {})[key] = val; 300 | } 301 | 302 | // Extract attributes and options. 303 | var silent = options && options.silent; 304 | var unset = options && options.unset; 305 | 306 | // Run validation. 307 | if (!this._validate(attrs, options)) return false; 308 | 309 | // Check for changes of `id`. 310 | if (this.idAttribute in attrs) this.id = attrs[this.idAttribute]; 311 | 312 | var now = this.attributes; 313 | 314 | // For each `set` attribute... 315 | for (attr in attrs) { 316 | val = attrs[attr]; 317 | 318 | // Update or delete the current value, and track the change. 319 | unset ? delete now[attr] : now[attr] = val; 320 | this._changes.push(attr, val); 321 | } 322 | 323 | // Signal that the model's state has potentially changed, and we need 324 | // to recompute the actual changes. 325 | this._hasComputed = false; 326 | 327 | // Fire the `"change"` events. 328 | if (!silent) this.change(options); 329 | return this; 330 | }, 331 | 332 | // Remove an attribute from the model, firing `"change"` unless you choose 333 | // to silence it. `unset` is a noop if the attribute doesn't exist. 334 | unset: function(attr, options) { 335 | return this.set(attr, void 0, _.extend({}, options, {unset: true})); 336 | }, 337 | 338 | // Clear all attributes on the model, firing `"change"` unless you choose 339 | // to silence it. 340 | clear: function(options) { 341 | var attrs = {}; 342 | for (var key in this.attributes) attrs[key] = void 0; 343 | return this.set(attrs, _.extend({}, options, {unset: true})); 344 | }, 345 | 346 | // Fetch the model from the server. If the server's representation of the 347 | // model differs from its current attributes, they will be overriden, 348 | // triggering a `"change"` event. 349 | fetch: function(options) { 350 | options = options ? _.clone(options) : {}; 351 | if (options.parse === void 0) options.parse = true; 352 | var model = this; 353 | var success = options.success; 354 | options.success = function(resp, status, xhr) { 355 | if (!model.set(model.parse(resp), options)) return false; 356 | if (success) success(model, resp, options); 357 | }; 358 | return this.sync('read', this, options); 359 | }, 360 | 361 | // Set a hash of model attributes, and sync the model to the server. 362 | // If the server returns an attributes hash that differs, the model's 363 | // state will be `set` again. 364 | save: function(key, val, options) { 365 | var attrs, current, done; 366 | 367 | // Handle both `"key", value` and `{key: value}` -style arguments. 368 | if (key == null || _.isObject(key)) { 369 | attrs = key; 370 | options = val; 371 | } else if (key != null) { 372 | (attrs = {})[key] = val; 373 | } 374 | options = options ? _.clone(options) : {}; 375 | 376 | // If we're "wait"-ing to set changed attributes, validate early. 377 | if (options.wait) { 378 | if (attrs && !this._validate(attrs, options)) return false; 379 | current = _.clone(this.attributes); 380 | } 381 | 382 | // Regular saves `set` attributes before persisting to the server. 383 | var silentOptions = _.extend({}, options, {silent: true}); 384 | if (attrs && !this.set(attrs, options.wait ? silentOptions : options)) { 385 | return false; 386 | } 387 | 388 | // Do not persist invalid models. 389 | if (!attrs && !this._validate(null, options)) return false; 390 | 391 | // After a successful server-side save, the client is (optionally) 392 | // updated with the server-side state. 393 | var model = this; 394 | var success = options.success; 395 | options.success = function(resp, status, xhr) { 396 | done = true; 397 | var serverAttrs = model.parse(resp); 398 | if (options.wait) serverAttrs = _.extend(attrs || {}, serverAttrs); 399 | if (!model.set(serverAttrs, options)) return false; 400 | if (success) success(model, resp, options); 401 | }; 402 | 403 | // Finish configuring and sending the Ajax request. 404 | var method = this.isNew() ? 'create' : (options.patch ? 'patch' : 'update'); 405 | if (method == 'patch') options.attrs = attrs; 406 | var xhr = this.sync(method, this, options); 407 | 408 | // When using `wait`, reset attributes to original values unless 409 | // `success` has been called already. 410 | if (!done && options.wait) { 411 | this.clear(silentOptions); 412 | this.set(current, silentOptions); 413 | } 414 | 415 | return xhr; 416 | }, 417 | 418 | // Destroy this model on the server if it was already persisted. 419 | // Optimistically removes the model from its collection, if it has one. 420 | // If `wait: true` is passed, waits for the server to respond before removal. 421 | destroy: function(options) { 422 | options = options ? _.clone(options) : {}; 423 | var model = this; 424 | var success = options.success; 425 | 426 | var destroy = function() { 427 | model.trigger('destroy', model, model.collection, options); 428 | }; 429 | 430 | options.success = function(resp) { 431 | if (options.wait || model.isNew()) destroy(); 432 | if (success) success(model, resp, options); 433 | }; 434 | 435 | if (this.isNew()) { 436 | options.success(); 437 | return false; 438 | } 439 | 440 | var xhr = this.sync('delete', this, options); 441 | if (!options.wait) destroy(); 442 | return xhr; 443 | }, 444 | 445 | // Default URL for the model's representation on the server -- if you're 446 | // using Backbone's restful methods, override this to change the endpoint 447 | // that will be called. 448 | url: function() { 449 | var base = _.result(this, 'urlRoot') || _.result(this.collection, 'url') || urlError(); 450 | if (this.isNew()) return base; 451 | return base + (base.charAt(base.length - 1) === '/' ? '' : '/') + encodeURIComponent(this.id); 452 | }, 453 | 454 | // **parse** converts a response into the hash of attributes to be `set` on 455 | // the model. The default implementation is just to pass the response along. 456 | parse: function(resp) { 457 | return resp; 458 | }, 459 | 460 | // Create a new model with identical attributes to this one. 461 | clone: function() { 462 | return new this.constructor(this.attributes); 463 | }, 464 | 465 | // A model is new if it has never been saved to the server, and lacks an id. 466 | isNew: function() { 467 | return this.id == null; 468 | }, 469 | 470 | // Call this method to manually fire a `"change"` event for this model and 471 | // a `"change:attribute"` event for each changed attribute. 472 | // Calling this will cause all objects observing the model to update. 473 | change: function(options) { 474 | var changing = this._changing; 475 | this._changing = true; 476 | 477 | // Generate the changes to be triggered on the model. 478 | var triggers = this._computeChanges(true); 479 | 480 | this._pending = !!triggers.length; 481 | 482 | for (var i = triggers.length - 2; i >= 0; i -= 2) { 483 | this.trigger('change:' + triggers[i], this, triggers[i + 1], options); 484 | } 485 | 486 | if (changing) return this; 487 | 488 | // Trigger a `change` while there have been changes. 489 | while (this._pending) { 490 | this._pending = false; 491 | this.trigger('change', this, options); 492 | this._previousAttributes = _.clone(this.attributes); 493 | } 494 | 495 | this._changing = false; 496 | return this; 497 | }, 498 | 499 | // Determine if the model has changed since the last `"change"` event. 500 | // If you specify an attribute name, determine if that attribute has changed. 501 | hasChanged: function(attr) { 502 | if (!this._hasComputed) this._computeChanges(); 503 | if (attr == null) return !_.isEmpty(this.changed); 504 | return _.has(this.changed, attr); 505 | }, 506 | 507 | // Return an object containing all the attributes that have changed, or 508 | // false if there are no changed attributes. Useful for determining what 509 | // parts of a view need to be updated and/or what attributes need to be 510 | // persisted to the server. Unset attributes will be set to undefined. 511 | // You can also pass an attributes object to diff against the model, 512 | // determining if there *would be* a change. 513 | changedAttributes: function(diff) { 514 | if (!diff) return this.hasChanged() ? _.clone(this.changed) : false; 515 | var val, changed = false, old = this._previousAttributes; 516 | for (var attr in diff) { 517 | if (_.isEqual(old[attr], (val = diff[attr]))) continue; 518 | (changed || (changed = {}))[attr] = val; 519 | } 520 | return changed; 521 | }, 522 | 523 | // Looking at the built up list of `set` attribute changes, compute how 524 | // many of the attributes have actually changed. If `loud`, return a 525 | // boiled-down list of only the real changes. 526 | _computeChanges: function(loud) { 527 | this.changed = {}; 528 | var already = {}; 529 | var triggers = []; 530 | var current = this._currentAttributes; 531 | var changes = this._changes; 532 | 533 | // Loop through the current queue of potential model changes. 534 | for (var i = changes.length - 2; i >= 0; i -= 2) { 535 | var key = changes[i], val = changes[i + 1]; 536 | if (already[key]) continue; 537 | already[key] = true; 538 | 539 | // Check if the attribute has been modified since the last change, 540 | // and update `this.changed` accordingly. If we're inside of a `change` 541 | // call, also add a trigger to the list. 542 | if (current[key] !== val) { 543 | this.changed[key] = val; 544 | if (!loud) continue; 545 | triggers.push(key, val); 546 | current[key] = val; 547 | } 548 | } 549 | if (loud) this._changes = []; 550 | 551 | // Signals `this.changed` is current to prevent duplicate calls from `this.hasChanged`. 552 | this._hasComputed = true; 553 | return triggers; 554 | }, 555 | 556 | // Get the previous value of an attribute, recorded at the time the last 557 | // `"change"` event was fired. 558 | previous: function(attr) { 559 | if (attr == null || !this._previousAttributes) return null; 560 | return this._previousAttributes[attr]; 561 | }, 562 | 563 | // Get all of the attributes of the model at the time of the previous 564 | // `"change"` event. 565 | previousAttributes: function() { 566 | return _.clone(this._previousAttributes); 567 | }, 568 | 569 | // Run validation against the next complete set of model attributes, 570 | // returning `true` if all is well. If a specific `error` callback has 571 | // been passed, call that instead of firing the general `"error"` event. 572 | _validate: function(attrs, options) { 573 | if (!this.validate) return true; 574 | attrs = _.extend({}, this.attributes, attrs); 575 | var error = this.validate(attrs, options); 576 | if (!error) return true; 577 | if (options && options.error) options.error(this, error, options); 578 | this.trigger('error', this, error, options); 579 | return false; 580 | } 581 | 582 | }); 583 | 584 | // Backbone.Collection 585 | // ------------------- 586 | 587 | // Provides a standard collection class for our sets of models, ordered 588 | // or unordered. If a `comparator` is specified, the Collection will maintain 589 | // its models in sort order, as they're added and removed. 590 | var Collection = Backbone.Collection = function(models, options) { 591 | options || (options = {}); 592 | if (options.model) this.model = options.model; 593 | if (options.comparator !== void 0) this.comparator = options.comparator; 594 | this._reset(); 595 | this.initialize.apply(this, arguments); 596 | if (models) this.reset(models, _.extend({silent: true}, options)); 597 | }; 598 | 599 | // Define the Collection's inheritable methods. 600 | _.extend(Collection.prototype, Events, { 601 | 602 | // The default model for a collection is just a **Backbone.Model**. 603 | // This should be overridden in most cases. 604 | model: Model, 605 | 606 | // Initialize is an empty function by default. Override it with your own 607 | // initialization logic. 608 | initialize: function(){}, 609 | 610 | // The JSON representation of a Collection is an array of the 611 | // models' attributes. 612 | toJSON: function(options) { 613 | return this.map(function(model){ return model.toJSON(options); }); 614 | }, 615 | 616 | // Proxy `Backbone.sync` by default. 617 | sync: function() { 618 | return Backbone.sync.apply(this, arguments); 619 | }, 620 | 621 | // Add a model, or list of models to the set. Pass **silent** to avoid 622 | // firing the `add` event for every new model. 623 | add: function(models, options) { 624 | var i, args, length, model, existing, needsSort; 625 | var at = options && options.at; 626 | var sort = ((options && options.sort) == null ? true : options.sort); 627 | models = _.isArray(models) ? models.slice() : [models]; 628 | 629 | // Turn bare objects into model references, and prevent invalid models 630 | // from being added. 631 | for (i = models.length - 1; i >= 0; i--) { 632 | if(!(model = this._prepareModel(models[i], options))) { 633 | this.trigger("error", this, models[i], options); 634 | models.splice(i, 1); 635 | continue; 636 | } 637 | models[i] = model; 638 | 639 | existing = model.id != null && this._byId[model.id]; 640 | // If a duplicate is found, prevent it from being added and 641 | // optionally merge it into the existing model. 642 | if (existing || this._byCid[model.cid]) { 643 | if (options && options.merge && existing) { 644 | existing.set(model.attributes, options); 645 | needsSort = sort; 646 | } 647 | models.splice(i, 1); 648 | continue; 649 | } 650 | 651 | // Listen to added models' events, and index models for lookup by 652 | // `id` and by `cid`. 653 | model.on('all', this._onModelEvent, this); 654 | this._byCid[model.cid] = model; 655 | if (model.id != null) this._byId[model.id] = model; 656 | } 657 | 658 | // See if sorting is needed, update `length` and splice in new models. 659 | if (models.length) needsSort = sort; 660 | this.length += models.length; 661 | args = [at != null ? at : this.models.length, 0]; 662 | push.apply(args, models); 663 | splice.apply(this.models, args); 664 | 665 | // Sort the collection if appropriate. 666 | if (needsSort && this.comparator && at == null) this.sort({silent: true}); 667 | 668 | if (options && options.silent) return this; 669 | 670 | // Trigger `add` events. 671 | while (model = models.shift()) { 672 | model.trigger('add', model, this, options); 673 | } 674 | 675 | return this; 676 | }, 677 | 678 | // Remove a model, or a list of models from the set. Pass silent to avoid 679 | // firing the `remove` event for every model removed. 680 | remove: function(models, options) { 681 | var i, l, index, model; 682 | options || (options = {}); 683 | models = _.isArray(models) ? models.slice() : [models]; 684 | for (i = 0, l = models.length; i < l; i++) { 685 | model = this.get(models[i]); 686 | if (!model) continue; 687 | delete this._byId[model.id]; 688 | delete this._byCid[model.cid]; 689 | index = this.indexOf(model); 690 | this.models.splice(index, 1); 691 | this.length--; 692 | if (!options.silent) { 693 | options.index = index; 694 | model.trigger('remove', model, this, options); 695 | } 696 | this._removeReference(model); 697 | } 698 | return this; 699 | }, 700 | 701 | // Add a model to the end of the collection. 702 | push: function(model, options) { 703 | model = this._prepareModel(model, options); 704 | this.add(model, _.extend({at: this.length}, options)); 705 | return model; 706 | }, 707 | 708 | // Remove a model from the end of the collection. 709 | pop: function(options) { 710 | var model = this.at(this.length - 1); 711 | this.remove(model, options); 712 | return model; 713 | }, 714 | 715 | // Add a model to the beginning of the collection. 716 | unshift: function(model, options) { 717 | model = this._prepareModel(model, options); 718 | this.add(model, _.extend({at: 0}, options)); 719 | return model; 720 | }, 721 | 722 | // Remove a model from the beginning of the collection. 723 | shift: function(options) { 724 | var model = this.at(0); 725 | this.remove(model, options); 726 | return model; 727 | }, 728 | 729 | // Slice out a sub-array of models from the collection. 730 | slice: function(begin, end) { 731 | return this.models.slice(begin, end); 732 | }, 733 | 734 | // Get a model from the set by id. 735 | get: function(obj) { 736 | if (obj == null) return void 0; 737 | return this._byId[obj.id != null ? obj.id : obj] || this._byCid[obj.cid || obj]; 738 | }, 739 | 740 | // Get the model at the given index. 741 | at: function(index) { 742 | return this.models[index]; 743 | }, 744 | 745 | // Return models with matching attributes. Useful for simple cases of `filter`. 746 | where: function(attrs) { 747 | if (_.isEmpty(attrs)) return []; 748 | return this.filter(function(model) { 749 | for (var key in attrs) { 750 | if (attrs[key] !== model.get(key)) return false; 751 | } 752 | return true; 753 | }); 754 | }, 755 | 756 | // Force the collection to re-sort itself. You don't need to call this under 757 | // normal circumstances, as the set will maintain sort order as each item 758 | // is added. 759 | sort: function(options) { 760 | if (!this.comparator) { 761 | throw new Error('Cannot sort a set without a comparator'); 762 | } 763 | 764 | if (_.isString(this.comparator) || this.comparator.length === 1) { 765 | this.models = this.sortBy(this.comparator, this); 766 | } else { 767 | this.models.sort(_.bind(this.comparator, this)); 768 | } 769 | 770 | if (!options || !options.silent) this.trigger('sort', this, options); 771 | return this; 772 | }, 773 | 774 | // Pluck an attribute from each model in the collection. 775 | pluck: function(attr) { 776 | return _.invoke(this.models, 'get', attr); 777 | }, 778 | 779 | // Smartly update a collection with a change set of models, adding, 780 | // removing, and merging as necessary. 781 | update: function(models, options) { 782 | var model, i, l, existing; 783 | var add = [], remove = [], modelMap = {}; 784 | var idAttr = this.model.prototype.idAttribute; 785 | options = _.extend({add: true, merge: true, remove: true}, options); 786 | if (options.parse) models = this.parse(models); 787 | 788 | // Allow a single model (or no argument) to be passed. 789 | if (!_.isArray(models)) models = models ? [models] : []; 790 | 791 | // Proxy to `add` for this case, no need to iterate... 792 | if (options.add && !options.remove) return this.add(models, options); 793 | 794 | // Determine which models to add and merge, and which to remove. 795 | for (i = 0, l = models.length; i < l; i++) { 796 | model = models[i]; 797 | existing = this.get(model.id || model.cid || model[idAttr]); 798 | if (options.remove && existing) modelMap[existing.cid] = true; 799 | if ((options.add && !existing) || (options.merge && existing)) { 800 | add.push(model); 801 | } 802 | } 803 | if (options.remove) { 804 | for (i = 0, l = this.models.length; i < l; i++) { 805 | model = this.models[i]; 806 | if (!modelMap[model.cid]) remove.push(model); 807 | } 808 | } 809 | 810 | // Remove models (if applicable) before we add and merge the rest. 811 | if (remove.length) this.remove(remove, options); 812 | if (add.length) this.add(add, options); 813 | return this; 814 | }, 815 | 816 | // When you have more items than you want to add or remove individually, 817 | // you can reset the entire set with a new list of models, without firing 818 | // any `add` or `remove` events. Fires `reset` when finished. 819 | reset: function(models, options) { 820 | options || (options = {}); 821 | if (options.parse) models = this.parse(models); 822 | for (var i = 0, l = this.models.length; i < l; i++) { 823 | this._removeReference(this.models[i]); 824 | } 825 | options.previousModels = this.models; 826 | this._reset(); 827 | if (models) this.add(models, _.extend({silent: true}, options)); 828 | if (!options.silent) this.trigger('reset', this, options); 829 | return this; 830 | }, 831 | 832 | // Fetch the default set of models for this collection, resetting the 833 | // collection when they arrive. If `add: true` is passed, appends the 834 | // models to the collection instead of resetting. 835 | fetch: function(options) { 836 | options = options ? _.clone(options) : {}; 837 | if (options.parse === void 0) options.parse = true; 838 | var collection = this; 839 | var success = options.success; 840 | options.success = function(resp, status, xhr) { 841 | var method = options.update ? 'update' : 'reset'; 842 | collection[method](resp, options); 843 | if (success) success(collection, resp, options); 844 | }; 845 | return this.sync('read', this, options); 846 | }, 847 | 848 | // Create a new instance of a model in this collection. Add the model to the 849 | // collection immediately, unless `wait: true` is passed, in which case we 850 | // wait for the server to agree. 851 | create: function(model, options) { 852 | var collection = this; 853 | options = options ? _.clone(options) : {}; 854 | model = this._prepareModel(model, options); 855 | if (!model) return false; 856 | if (!options.wait) collection.add(model, options); 857 | var success = options.success; 858 | options.success = function(model, resp, options) { 859 | if (options.wait) collection.add(model, options); 860 | if (success) success(model, resp, options); 861 | }; 862 | model.save(null, options); 863 | return model; 864 | }, 865 | 866 | // **parse** converts a response into a list of models to be added to the 867 | // collection. The default implementation is just to pass it through. 868 | parse: function(resp) { 869 | return resp; 870 | }, 871 | 872 | // Create a new collection with an identical list of models as this one. 873 | clone: function() { 874 | return new this.constructor(this.models); 875 | }, 876 | 877 | // Proxy to _'s chain. Can't be proxied the same way the rest of the 878 | // underscore methods are proxied because it relies on the underscore 879 | // constructor. 880 | chain: function() { 881 | return _(this.models).chain(); 882 | }, 883 | 884 | // Reset all internal state. Called when the collection is reset. 885 | _reset: function() { 886 | this.length = 0; 887 | this.models = []; 888 | this._byId = {}; 889 | this._byCid = {}; 890 | }, 891 | 892 | // Prepare a model or hash of attributes to be added to this collection. 893 | _prepareModel: function(attrs, options) { 894 | if (attrs instanceof Model) { 895 | if (!attrs.collection) attrs.collection = this; 896 | return attrs; 897 | } 898 | options || (options = {}); 899 | options.collection = this; 900 | var model = new this.model(attrs, options); 901 | if (!model._validate(attrs, options)) return false; 902 | return model; 903 | }, 904 | 905 | // Internal method to remove a model's ties to a collection. 906 | _removeReference: function(model) { 907 | if (this === model.collection) delete model.collection; 908 | model.off('all', this._onModelEvent, this); 909 | }, 910 | 911 | // Internal method called every time a model in the set fires an event. 912 | // Sets need to update their indexes when models change ids. All other 913 | // events simply proxy through. "add" and "remove" events that originate 914 | // in other collections are ignored. 915 | _onModelEvent: function(event, model, collection, options) { 916 | if ((event === 'add' || event === 'remove') && collection !== this) return; 917 | if (event === 'destroy') this.remove(model, options); 918 | if (model && event === 'change:' + model.idAttribute) { 919 | delete this._byId[model.previous(model.idAttribute)]; 920 | if (model.id != null) this._byId[model.id] = model; 921 | } 922 | this.trigger.apply(this, arguments); 923 | } 924 | 925 | }); 926 | 927 | // Underscore methods that we want to implement on the Collection. 928 | var methods = ['forEach', 'each', 'map', 'collect', 'reduce', 'foldl', 929 | 'inject', 'reduceRight', 'foldr', 'find', 'detect', 'filter', 'select', 930 | 'reject', 'every', 'all', 'some', 'any', 'include', 'contains', 'invoke', 931 | 'max', 'min', 'sortedIndex', 'toArray', 'size', 'first', 'head', 'take', 932 | 'initial', 'rest', 'tail', 'last', 'without', 'indexOf', 'shuffle', 933 | 'lastIndexOf', 'isEmpty']; 934 | 935 | // Mix in each Underscore method as a proxy to `Collection#models`. 936 | _.each(methods, function(method) { 937 | Collection.prototype[method] = function() { 938 | var args = slice.call(arguments); 939 | args.unshift(this.models); 940 | return _[method].apply(_, args); 941 | }; 942 | }); 943 | 944 | // Underscore methods that take a property name as an argument. 945 | var attributeMethods = ['groupBy', 'countBy', 'sortBy']; 946 | 947 | // Use attributes instead of properties. 948 | _.each(attributeMethods, function(method) { 949 | Collection.prototype[method] = function(value, context) { 950 | var iterator = _.isFunction(value) ? value : function(model) { 951 | return model.get(value); 952 | }; 953 | return _[method](this.models, iterator, context); 954 | }; 955 | }); 956 | 957 | // Backbone.Router 958 | // --------------- 959 | 960 | // Routers map faux-URLs to actions, and fire events when routes are 961 | // matched. Creating a new one sets its `routes` hash, if not set statically. 962 | var Router = Backbone.Router = function(options) { 963 | options || (options = {}); 964 | if (options.routes) this.routes = options.routes; 965 | this._bindRoutes(); 966 | this.initialize.apply(this, arguments); 967 | }; 968 | 969 | // Cached regular expressions for matching named param parts and splatted 970 | // parts of route strings. 971 | var optionalParam = /\((.*?)\)/g; 972 | var namedParam = /:\w+/g; 973 | var splatParam = /\*\w+/g; 974 | var escapeRegExp = /[\-{}\[\]+?.,\\\^$|#\s]/g; 975 | 976 | // Set up all inheritable **Backbone.Router** properties and methods. 977 | _.extend(Router.prototype, Events, { 978 | 979 | // Initialize is an empty function by default. Override it with your own 980 | // initialization logic. 981 | initialize: function(){}, 982 | 983 | // Manually bind a single named route to a callback. For example: 984 | // 985 | // this.route('search/:query/p:num', 'search', function(query, num) { 986 | // ... 987 | // }); 988 | // 989 | route: function(route, name, callback) { 990 | if (!_.isRegExp(route)) route = this._routeToRegExp(route); 991 | if (!callback) callback = this[name]; 992 | Backbone.history.route(route, _.bind(function(fragment) { 993 | var args = this._extractParameters(route, fragment); 994 | callback && callback.apply(this, args); 995 | this.trigger.apply(this, ['route:' + name].concat(args)); 996 | Backbone.history.trigger('route', this, name, args); 997 | }, this)); 998 | return this; 999 | }, 1000 | 1001 | // Simple proxy to `Backbone.history` to save a fragment into the history. 1002 | navigate: function(fragment, options) { 1003 | Backbone.history.navigate(fragment, options); 1004 | return this; 1005 | }, 1006 | 1007 | // Bind all defined routes to `Backbone.history`. We have to reverse the 1008 | // order of the routes here to support behavior where the most general 1009 | // routes can be defined at the bottom of the route map. 1010 | _bindRoutes: function() { 1011 | if (!this.routes) return; 1012 | var route, routes = _.keys(this.routes); 1013 | while ((route = routes.pop()) != null) { 1014 | this.route(route, this.routes[route]); 1015 | } 1016 | }, 1017 | 1018 | // Convert a route string into a regular expression, suitable for matching 1019 | // against the current location hash. 1020 | _routeToRegExp: function(route) { 1021 | route = route.replace(escapeRegExp, '\\$&') 1022 | .replace(optionalParam, '(?:$1)?') 1023 | .replace(namedParam, '([^\/]+)') 1024 | .replace(splatParam, '(.*?)'); 1025 | return new RegExp('^' + route + '$'); 1026 | }, 1027 | 1028 | // Given a route, and a URL fragment that it matches, return the array of 1029 | // extracted parameters. 1030 | _extractParameters: function(route, fragment) { 1031 | return route.exec(fragment).slice(1); 1032 | } 1033 | 1034 | }); 1035 | 1036 | // Backbone.History 1037 | // ---------------- 1038 | 1039 | // Handles cross-browser history management, based on URL fragments. If the 1040 | // browser does not support `onhashchange`, falls back to polling. 1041 | var History = Backbone.History = function() { 1042 | this.handlers = []; 1043 | _.bindAll(this, 'checkUrl'); 1044 | 1045 | // Ensure that `History` can be used outside of the browser. 1046 | if (typeof window !== 'undefined') { 1047 | this.location = window.location; 1048 | this.history = window.history; 1049 | } 1050 | }; 1051 | 1052 | // Cached regex for stripping a leading hash/slash and trailing space. 1053 | var routeStripper = /^[#\/]|\s+$/g; 1054 | 1055 | // Cached regex for stripping leading and trailing slashes. 1056 | var rootStripper = /^\/+|\/+$/g; 1057 | 1058 | // Cached regex for detecting MSIE. 1059 | var isExplorer = /msie [\w.]+/; 1060 | 1061 | // Cached regex for removing a trailing slash. 1062 | var trailingSlash = /\/$/; 1063 | 1064 | // Has the history handling already been started? 1065 | History.started = false; 1066 | 1067 | // Set up all inheritable **Backbone.History** properties and methods. 1068 | _.extend(History.prototype, Events, { 1069 | 1070 | // The default interval to poll for hash changes, if necessary, is 1071 | // twenty times a second. 1072 | interval: 50, 1073 | 1074 | // Gets the true hash value. Cannot use location.hash directly due to bug 1075 | // in Firefox where location.hash will always be decoded. 1076 | getHash: function(window) { 1077 | var match = (window || this).location.href.match(/#(.*)$/); 1078 | return match ? match[1] : ''; 1079 | }, 1080 | 1081 | // Get the cross-browser normalized URL fragment, either from the URL, 1082 | // the hash, or the override. 1083 | getFragment: function(fragment, forcePushState) { 1084 | if (fragment == null) { 1085 | if (this._hasPushState || !this._wantsHashChange || forcePushState) { 1086 | fragment = this.location.pathname; 1087 | var root = this.root.replace(trailingSlash, ''); 1088 | if (!fragment.indexOf(root)) fragment = fragment.substr(root.length); 1089 | } else { 1090 | fragment = this.getHash(); 1091 | } 1092 | } 1093 | return fragment.replace(routeStripper, ''); 1094 | }, 1095 | 1096 | // Start the hash change handling, returning `true` if the current URL matches 1097 | // an existing route, and `false` otherwise. 1098 | start: function(options) { 1099 | if (History.started) throw new Error("Backbone.history has already been started"); 1100 | History.started = true; 1101 | 1102 | // Figure out the initial configuration. Do we need an iframe? 1103 | // Is pushState desired ... is it available? 1104 | this.options = _.extend({}, {root: '/'}, this.options, options); 1105 | this.root = this.options.root; 1106 | this._wantsHashChange = this.options.hashChange !== false; 1107 | this._wantsPushState = !!this.options.pushState; 1108 | this._hasPushState = !!(this.options.pushState && this.history && this.history.pushState); 1109 | var fragment = this.getFragment(); 1110 | var docMode = document.documentMode; 1111 | var oldIE = (isExplorer.exec(navigator.userAgent.toLowerCase()) && (!docMode || docMode <= 7)); 1112 | 1113 | // Normalize root to always include a leading and trailing slash. 1114 | this.root = ('/' + this.root + '/').replace(rootStripper, '/'); 1115 | 1116 | if (oldIE && this._wantsHashChange) { 1117 | this.iframe = Backbone.$('