├── .gitignore ├── .ruby-gemset ├── .ruby-version ├── .travis.yml ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── app └── assets │ └── javascripts │ └── backbone-nested-attributes │ ├── all.js.erb │ ├── model.js │ └── undoable.js ├── backbone-nested-attributes.gemspec ├── backbone-nested-attributes.js ├── bower.json ├── lib ├── backbone-nested-attributes.rb └── backbone-nested-attributes │ ├── engine.rb │ └── version.rb └── spec ├── dummy ├── README.rdoc ├── Rakefile ├── app │ ├── assets │ │ ├── javascripts │ │ │ └── application.js │ │ └── stylesheets │ │ │ └── application.css │ ├── controllers │ │ └── application_controller.rb │ ├── helpers │ │ └── application_helper.rb │ ├── mailers │ │ └── .gitkeep │ ├── models │ │ └── .gitkeep │ └── views │ │ └── layouts │ │ └── application.html.erb ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ ├── production.rb │ │ └── test.rb │ ├── initializers │ │ ├── backtrace_silencers.rb │ │ ├── inflections.rb │ │ ├── mime_types.rb │ │ ├── secret_token.rb │ │ ├── session_store.rb │ │ └── wrap_parameters.rb │ ├── locales │ │ └── en.yml │ └── routes.rb ├── db │ └── .gitkeep ├── lib │ └── assets │ │ └── .gitkeep ├── log │ └── .gitkeep ├── public │ ├── 404.html │ ├── 422.html │ ├── 500.html │ └── favicon.ico └── script │ └── rails └── javascripts ├── backbone-nested-attributes ├── ModelSpec.js └── UndoableSpec.js ├── helpers ├── SpecHelper.js └── mock-ajax.js └── support └── jasmine.yml /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | InstalledFiles 7 | _yardoc 8 | coverage 9 | doc/ 10 | lib/bundler/man 11 | pkg 12 | rdoc 13 | spec/reports 14 | test/tmp 15 | test/version_tmp 16 | tmp 17 | spec/dummy/db/*.sqlite3 18 | spec/dummy/log/*.log 19 | spec/dummy/tmp/ 20 | spec/dummy/.sass-cache 21 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | backbone-nested-attributes 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 1.9.3 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.9.3 4 | before_install: 5 | - "export DISPLAY=:99.0" 6 | - "sh -e /etc/init.d/xvfb start" 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in backbone-nested-attributes.gemspec 4 | gemspec 5 | 6 | gem 'rails', "~> 3.2.x" 7 | 8 | gem 'sqlite3' 9 | 10 | gem 'jquery-rails' 11 | 12 | gem 'backbone-on-rails' 13 | 14 | gem 'jasmine' 15 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | backbone-nested-attributes (0.5.0) 5 | rails (>= 3.2.8) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | actionmailer (3.2.15) 11 | actionpack (= 3.2.15) 12 | mail (~> 2.5.4) 13 | actionpack (3.2.15) 14 | activemodel (= 3.2.15) 15 | activesupport (= 3.2.15) 16 | builder (~> 3.0.0) 17 | erubis (~> 2.7.0) 18 | journey (~> 1.0.4) 19 | rack (~> 1.4.5) 20 | rack-cache (~> 1.2) 21 | rack-test (~> 0.6.1) 22 | sprockets (~> 2.2.1) 23 | activemodel (3.2.15) 24 | activesupport (= 3.2.15) 25 | builder (~> 3.0.0) 26 | activerecord (3.2.15) 27 | activemodel (= 3.2.15) 28 | activesupport (= 3.2.15) 29 | arel (~> 3.0.2) 30 | tzinfo (~> 0.3.29) 31 | activeresource (3.2.15) 32 | activemodel (= 3.2.15) 33 | activesupport (= 3.2.15) 34 | activesupport (3.2.15) 35 | i18n (~> 0.6, >= 0.6.4) 36 | multi_json (~> 1.0) 37 | arel (3.0.3) 38 | backbone-on-rails (1.1.0.0) 39 | eco 40 | ejs 41 | jquery-rails 42 | rails (>= 3.1) 43 | builder (3.0.4) 44 | childprocess (0.5.5) 45 | ffi (~> 1.0, >= 1.0.11) 46 | coffee-script (2.2.0) 47 | coffee-script-source 48 | execjs 49 | coffee-script-source (1.6.3) 50 | diff-lcs (1.2.5) 51 | eco (1.0.0) 52 | coffee-script 53 | eco-source 54 | execjs 55 | eco-source (1.1.0.rc.1) 56 | ejs (1.1.1) 57 | erubis (2.7.0) 58 | execjs (2.0.2) 59 | ffi (1.9.6) 60 | hike (1.2.3) 61 | i18n (0.6.5) 62 | jasmine (1.3.2) 63 | jasmine-core (~> 1.3.1) 64 | rack (~> 1.0) 65 | rspec (>= 1.3.1) 66 | selenium-webdriver (>= 0.1.3) 67 | jasmine-core (1.3.1) 68 | journey (1.0.4) 69 | jquery-rails (3.0.4) 70 | railties (>= 3.0, < 5.0) 71 | thor (>= 0.14, < 2.0) 72 | json (1.8.1) 73 | mail (2.5.4) 74 | mime-types (~> 1.16) 75 | treetop (~> 1.4.8) 76 | mime-types (1.25) 77 | multi_json (1.10.1) 78 | polyglot (0.3.3) 79 | rack (1.4.5) 80 | rack-cache (1.2) 81 | rack (>= 0.4) 82 | rack-ssl (1.3.3) 83 | rack 84 | rack-test (0.6.2) 85 | rack (>= 1.0) 86 | rails (3.2.15) 87 | actionmailer (= 3.2.15) 88 | actionpack (= 3.2.15) 89 | activerecord (= 3.2.15) 90 | activeresource (= 3.2.15) 91 | activesupport (= 3.2.15) 92 | bundler (~> 1.0) 93 | railties (= 3.2.15) 94 | railties (3.2.15) 95 | actionpack (= 3.2.15) 96 | activesupport (= 3.2.15) 97 | rack-ssl (~> 1.3.2) 98 | rake (>= 0.8.7) 99 | rdoc (~> 3.4) 100 | thor (>= 0.14.6, < 2.0) 101 | rake (10.1.0) 102 | rdoc (3.12.2) 103 | json (~> 1.4) 104 | rspec (2.14.1) 105 | rspec-core (~> 2.14.0) 106 | rspec-expectations (~> 2.14.0) 107 | rspec-mocks (~> 2.14.0) 108 | rspec-core (2.14.7) 109 | rspec-expectations (2.14.4) 110 | diff-lcs (>= 1.1.3, < 2.0) 111 | rspec-mocks (2.14.4) 112 | rubyzip (1.1.6) 113 | selenium-webdriver (2.43.0) 114 | childprocess (~> 0.5) 115 | multi_json (~> 1.0) 116 | rubyzip (~> 1.0) 117 | websocket (~> 1.0) 118 | sprockets (2.2.2) 119 | hike (~> 1.2) 120 | multi_json (~> 1.0) 121 | rack (~> 1.0) 122 | tilt (~> 1.1, != 1.3.0) 123 | sqlite3 (1.3.8) 124 | thor (0.18.1) 125 | tilt (1.4.1) 126 | treetop (1.4.15) 127 | polyglot 128 | polyglot (>= 0.3.1) 129 | tzinfo (0.3.38) 130 | websocket (1.2.1) 131 | 132 | PLATFORMS 133 | ruby 134 | 135 | DEPENDENCIES 136 | backbone-nested-attributes! 137 | backbone-on-rails 138 | jasmine 139 | jquery-rails 140 | rails (~> 3.2.x) 141 | sqlite3 142 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Vicente Mundim 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Backbone.NestedAttributesModel 2 | 3 | [![build status][1]][2] 4 | [![Bitdeli Badge](https://d2weczhvl823v0.cloudfront.net/dtmtec/backbone-nested-attributes/trend.png)](https://bitdeli.com/free "Bitdeli Badge") 5 | 6 | [1]: https://travis-ci.org/dtmtec/backbone-nested-attributes.png 7 | [2]: http://travis-ci.org/dtmtec/backbone-nested-attributes 8 | 9 | Add Rails-like nested attributes support for Backbone.Model. 10 | 11 | ## Installation 12 | 13 | Add this line to your application's Gemfile: 14 | 15 | gem 'backbone-nested-attributes' 16 | 17 | And then execute: 18 | 19 | $ bundle 20 | 21 | Or install it yourself as: 22 | 23 | $ gem install backbone-nested-attributes 24 | 25 | Then, add this line in your `application.js`: 26 | 27 | //= require backbone-nested-attributes/all 28 | 29 | ## Usage 30 | 31 | Make your model extend from `Backbone.NestedAttributesModel`, instead of `Backbone.Model` and declare your relationships: 32 | 33 | ```javascript 34 | var Post = Backbone.NestedAttributesModel.extend({ 35 | relations: [ 36 | { 37 | type: 'one', 38 | key: 'author', 39 | relatedModel: function () { return Person } 40 | }, 41 | { 42 | key: 'comments', 43 | relatedModel: function () { return Comment } 44 | } 45 | ] 46 | }) 47 | 48 | var Comment = Backbone.NestedAttributesModel.extend({}) 49 | var Person = Backbone.NestedAttributesModel.extend({}) 50 | ``` 51 | 52 | Now you can create your posts like this: 53 | 54 | ```javascript 55 | var post = new Post({ 56 | id: 123, 57 | title: 'My Title', 58 | author: { id: 987, name: "Vicente Mundim" }, 59 | comments: [ 60 | { 61 | id: 765, 62 | body: "Nice writeup!" 63 | }, 64 | { 65 | id: 766, 66 | body: "Keep it going!" 67 | } 68 | ] 69 | }) 70 | 71 | post.get('author') // returns a Person model 72 | post.get('comments') // returns a Backbone.Collection of Comment models 73 | ``` 74 | 75 | When saving data, you can choose whether to send attributes as usual, or with nested attributes support by giving `{ nested: true }` to `save`: 76 | 77 | ```javascript 78 | post.save({}, { nested: true }) 79 | ``` 80 | 81 | This will send data to the server like this: 82 | 83 | ```javascript 84 | { 85 | id: 123, 86 | title: 'My Title', 87 | author_attributes: { id: 987, name: "Vicente Mundim" }, 88 | comments_attributes: [ 89 | { 90 | id: 765, 91 | body: "Nice writeup!" 92 | }, 93 | { 94 | id: 766, 95 | body: "Keep it going!" 96 | } 97 | ] 98 | } 99 | ``` 100 | 101 | It keeps track of deleted models in `1-N` relations: 102 | 103 | ```javascript 104 | var comment = post.get('comments').at(0) 105 | post.get('comments').remove(comment) 106 | 107 | post.save({}, { nested: true }) 108 | ``` 109 | 110 | Send this data to the server: 111 | 112 | ```javascript 113 | { 114 | id: 123, 115 | title: 'My Title', 116 | author_attributes: { id: 987, name: "Vicente Mundim" }, 117 | comments_attributes: [ 118 | { 119 | id: 765, 120 | body: "Nice writeup!", 121 | _destroy: true 122 | }, 123 | { 124 | id: 766, 125 | body: "Keep it going!" 126 | } 127 | ] 128 | } 129 | ``` 130 | 131 | You can whitelist attributes to serialize using `serialize_keys` 132 | ```javascript 133 | var Post = Backbone.NestedAttributesModel.extend({ 134 | relations: [ 135 | { 136 | key: 'comments', 137 | serialize_keys: ['author', 'content'], 138 | relatedModel: function () { return Comment } 139 | } 140 | ] 141 | }) 142 | ``` 143 | 144 | The name of the attribute set on a destroyed model can be changed using `destroy_action` 145 | ```javascript 146 | var Post = Backbone.NestedAttributesModel.extend({ 147 | relations: [ 148 | { 149 | key: 'comments', 150 | destroy_action: '_remove', 151 | relatedModel: function () { return Comment } 152 | } 153 | ] 154 | }) 155 | ``` 156 | This allow you to call another method on the model instead of destroy. 157 | 158 | ## Backbone.UndoableModel 159 | 160 | If you're using some [bind](https://github.com/NYTimes/backbone.stickit) plugin and you want to cancel changes that were made without reloading the page or hitting the backend you'll definitively want to take a look at Backbone.UndoableModel: 161 | 162 | ```javascript 163 | var Post = Backbone.UndoableModel.extend({ 164 | relations: [ // UndoableModel is a NestedAttributesModel, so it can have relations 165 | { 166 | type: 'one', 167 | key: 'author', 168 | relatedModel: function () { return Person } 169 | }, 170 | { 171 | key: 'comments', 172 | relatedModel: function () { return Comment } 173 | } 174 | ] 175 | }) 176 | 177 | var Comment = Backbone.UndoableModel.extend({}) 178 | var Person = Backbone.UndoableModel.extend({}) 179 | 180 | var post = new Post({ 181 | id: 123, 182 | title: 'My Title', 183 | author: { id: 987, name: "Vicente Mundim" }, 184 | comments: [ 185 | { 186 | id: 765, 187 | body: "Nice writeup!" 188 | }, 189 | { 190 | id: 766, 191 | body: "Keep it going!" 192 | } 193 | ] 194 | }) 195 | 196 | post.set({ title: 'My new title' }) 197 | post.get('author').set({ name: 'Jon Snow' }) 198 | post.get('comments').at(0).set({ body: 'Great post!' }) 199 | 200 | post.undo() // that's it, post is now reverted to its initial attributes, as well as its relations 201 | 202 | post.get('title') // 'My Title' 203 | post.get('author').get('name') // 'Vicente Mundim' 204 | post.get('comments').at(0).get('body') // "Nice writeup!" 205 | ``` 206 | 207 | ## More info 208 | 209 | Check out the specs: 210 | 211 | * [Backbone.NestedAttributesModel](https://github.com/dtmconsultoria/backbone-nested-attributes/blob/master/spec/javascripts/backbone-nested-attributes/ModelSpec.js) 212 | * [Backbone.UndoableModel](https://github.com/dtmconsultoria/backbone-nested-attributes/blob/master/spec/javascripts/backbone-nested-attributes/UndoableSpec.js) 213 | 214 | ## Contributing 215 | 216 | 1. Fork it 217 | 2. Create your feature branch (`git checkout -b my-new-feature`) 218 | 3. Commit your changes (`git commit -am 'Add some feature'`) 219 | 4. Push to the branch (`git push origin my-new-feature`) 220 | 5. Create new Pull Request 221 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | begin 3 | require 'bundler/setup' 4 | rescue LoadError 5 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks' 6 | end 7 | 8 | APP_RAKEFILE = File.expand_path("../spec/dummy/Rakefile", __FILE__) 9 | load 'rails/tasks/engine.rake' 10 | 11 | Bundler::GemHelper.install_tasks 12 | 13 | task :build_js do 14 | require 'sprockets' 15 | 16 | Sprockets::Environment.new do |environment| 17 | environment.append_path "app/assets/javascripts" 18 | 19 | environment['backbone-nested-attributes/all'].write_to "backbone-nested-attributes.js" 20 | end 21 | end 22 | 23 | task :travis do 24 | puts "Starting to run app:jasmine:ci..." 25 | system("export DISPLAY=:99.0 && bundle exec rake app:jasmine:ci") 26 | raise "#{cmd} failed!" unless $?.exitstatus == 0 27 | end 28 | 29 | task :release => :build_js 30 | 31 | task :jasmine => 'app:jasmine' 32 | 33 | task :default => :travis 34 | -------------------------------------------------------------------------------- /app/assets/javascripts/backbone-nested-attributes/all.js.erb: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2013-2014 Vicente Mundim 3 | * 4 | * Version: <%= Backbone::NestedAttributes::VERSION %> 5 | * 6 | * MIT License 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining 9 | * a copy of this software and associated documentation files (the 10 | * "Software"), to deal in the Software without restriction, including 11 | * without limitation the rights to use, copy, modify, merge, publish, 12 | * distribute, sublicense, and/or sell copies of the Software, and to 13 | * permit persons to whom the Software is furnished to do so, subject to 14 | * the following conditions: 15 | * 16 | * The above copyright notice and this permission notice shall be 17 | * included in all copies or substantial portions of the Software. 18 | * 19 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 20 | * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 21 | * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 22 | * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 23 | * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 24 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 25 | * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 26 | */ 27 | 28 | //= require_self 29 | //= require backbone-nested-attributes/model 30 | //= require backbone-nested-attributes/undoable 31 | 32 | 33 | -------------------------------------------------------------------------------- /app/assets/javascripts/backbone-nested-attributes/model.js: -------------------------------------------------------------------------------- 1 | (function(Backbone, _) { 2 | var BackboneModelPrototype = Backbone.Model.prototype 3 | 4 | function setNestedAttributes(model, attributes) { 5 | if (attributes) { 6 | _(model.relations).each(function (relation) { 7 | if (relation.type == 'one') { 8 | setHasOneNestedAttributeFor(model, relation, attributes) 9 | } else { 10 | setNestedAttributeFor(model, relation, attributes) 11 | } 12 | }) 13 | } 14 | 15 | configureNestedAttributesEvents(model) 16 | 17 | return attributes 18 | } 19 | 20 | function setHasOneNestedAttributeFor(model, relation, attributes) { 21 | var key = relation.key, 22 | value = attributes[key], 23 | ModelClass = _(relation).result('relatedModel') 24 | 25 | if (value) { 26 | value = value instanceof Backbone.Model ? value : new ModelClass(value) 27 | 28 | configureEventBubbling(model, value, relation) 29 | attributes[key] = value 30 | } 31 | } 32 | 33 | function setNestedAttributeFor(model, relation, attributes) { 34 | var key = relation.key, 35 | value = attributes[key], 36 | deletedValue = attributes['deleted_' + key], 37 | currentValue = model.get(key), 38 | nested = currentValue || createNestedAttributeCollection(relation) 39 | 40 | value = valueOrSliceCollection(value) 41 | 42 | configureEventBubbling(model, nested, relation) 43 | 44 | if (value) { 45 | nested.set(value) 46 | } 47 | 48 | if (deletedValue) { 49 | delete attributes['deleted_' + key] 50 | 51 | deletedValue = valueOrSliceCollection(deletedValue) 52 | nested.deletedModels.set(deletedValue) 53 | } 54 | 55 | attributes[key] = nested 56 | 57 | return attributes 58 | } 59 | 60 | function valueOrSliceCollection(value) { 61 | return value instanceof Backbone.Collection ? value.slice() : value 62 | } 63 | 64 | function clearDeletedModelsFor(model) { 65 | _(model.relations).each(function (relation) { 66 | var collectionOrModel = model.get(relation.key) 67 | 68 | if (collectionOrModel && collectionOrModel.each) { 69 | collectionOrModel.each(function (nestedModel) { 70 | clearDeletedModelsFor(nestedModel) 71 | }) 72 | 73 | if (collectionOrModel.deletedModels) { 74 | collectionOrModel.deletedModels.reset() 75 | } 76 | } 77 | }) 78 | } 79 | 80 | function configureNestedAttributesEvents(model) { 81 | if (!model._hasNestedAttributesEventsConfigured) { 82 | model.on('sync', clearDeletedModelsFor) 83 | model._hasNestedAttributesEventsConfigured = true 84 | } 85 | } 86 | 87 | function configureEventBubbling(model, nested, relation) { 88 | if (!nested._hasEventBubblingConfigured) { 89 | model.listenTo(nested, 'add change nested:change remove', function (nestedModel, options) { 90 | model.trigger('nested:change change:' + relation.key, nestedModel, options) 91 | }) 92 | 93 | nested._hasEventBubblingConfigured = true 94 | } 95 | } 96 | 97 | function clearNestedEvents(model) { 98 | _(model.relations).each(function (relation) { 99 | var nested = model.get(relation.key) 100 | 101 | model.stopListening(nested) 102 | nested.off('remove', nestedModelRemoved, nested) 103 | 104 | if (nested.deletedModels) { 105 | nested.deletedModels.reset() 106 | } 107 | }, model) 108 | } 109 | 110 | function nestedToJson(json, relations, options) { 111 | _(relations).each(function (relation) { 112 | var key = relation.key, 113 | value = json[key], 114 | deleted = [], 115 | jsonValue 116 | 117 | if (value) { 118 | if (options) { 119 | if (options.withDeleted) { 120 | if (value.deletedModels) { 121 | json['deleted_' + key] = value.deletedModels.toJSON(options) 122 | } 123 | } 124 | 125 | if (options.nested) { 126 | if (value.deletedModels) { 127 | deleted = value.deletedModels.toJSON(options) 128 | } 129 | 130 | delete json[key] 131 | key = key + '_attributes' 132 | } 133 | } 134 | 135 | jsonValue = value.toJSON(options) 136 | 137 | if (_(jsonValue).isArray()) { 138 | jsonValue = jsonValue.concat(deleted) 139 | } 140 | 141 | if (relation.serialize_keys) { 142 | if (_(jsonValue).isArray()) { 143 | jsonValue = _.map(jsonValue, function(j) { return _.pick(j, relation.serialize_keys) }) 144 | } else { 145 | jsonValue = _.pick(jsonValue, relation.serialize_keys) 146 | } 147 | } 148 | 149 | json[key] = jsonValue 150 | } 151 | }) 152 | 153 | return json 154 | } 155 | 156 | function createNestedAttributeCollection(relation) { 157 | var CollectionType = _(relation).result('collectionType') || Backbone.Collection, 158 | collection = new CollectionType 159 | 160 | collection.model = _(relation).result('relatedModel') || collection.model 161 | collection.destroy_action = relation.destroy_action || '_destroy' 162 | 163 | if (relation.serialize_keys) { 164 | relation.serialize_keys.push(collection.destroy_action) 165 | } 166 | 167 | collection.deletedModels = new Backbone.Collection 168 | collection.deletedModels.model = collection.model 169 | collection.on('add', nestedModelAdded) 170 | collection.on('remove', nestedModelRemoved) 171 | 172 | return collection 173 | } 174 | 175 | function nestedModelAdded(model, collection) { 176 | if (model.get(collection.destroy_action)) { 177 | collection.remove(model) 178 | } 179 | } 180 | 181 | function nestedModelRemoved(model, collection) { 182 | if (!model.isNew()) { 183 | param = {} 184 | param[collection.destroy_action] = true 185 | model.set(param) 186 | collection.deletedModels.add(model) 187 | } 188 | } 189 | 190 | function attributesFor(key, value, options) { 191 | var attributes 192 | 193 | // Duplicate backbone's behavior to allow separate key/value parameters, 194 | // instead of a single 'attributes' object. 195 | if (_.isObject(key) || key == null) { 196 | attributes = key 197 | options = value 198 | } else { 199 | attributes = {} 200 | attributes[key] = value 201 | } 202 | 203 | return attributes 204 | } 205 | 206 | Backbone.NestedAttributesModel = Backbone.Model.extend({ 207 | set: function (key, value, options) { 208 | var attributes 209 | 210 | // Duplicate backbone's behavior to allow separate key/value parameters, 211 | // instead of a single 'attributes' object. 212 | if (_.isObject(key) || key == null) { 213 | attributes = key 214 | options = value 215 | } else { 216 | attributes = {} 217 | attributes[key] = value 218 | } 219 | 220 | return BackboneModelPrototype.set.call(this, setNestedAttributes(this, attributes), options) 221 | }, 222 | 223 | toJSON: function (options) { 224 | return nestedToJson(BackboneModelPrototype.toJSON.apply(this, arguments), this.relations, options) 225 | }, 226 | 227 | clone: function() { 228 | return new this.constructor(this.toJSON()); 229 | }, 230 | 231 | clear: function (options) { 232 | clearNestedEvents(this) 233 | return BackboneModelPrototype.clear.apply(this, arguments) 234 | } 235 | }) 236 | })(Backbone, _); 237 | -------------------------------------------------------------------------------- /app/assets/javascripts/backbone-nested-attributes/undoable.js: -------------------------------------------------------------------------------- 1 | (function(Backbone, _) { 2 | var NestedAttributesModel = Backbone.NestedAttributesModel, 3 | NestedAttributesModelProto = NestedAttributesModel.prototype 4 | 5 | function UndoableState(model) { 6 | this.model = model 7 | 8 | model.on('nested:change', this.change, this) 9 | model.on('change', this.change, this) 10 | model.on('sync', this.save, this) 11 | } 12 | 13 | _.extend(UndoableState.prototype, Backbone.Events, { 14 | change: function () { 15 | this.changed = true 16 | }, 17 | 18 | save: function () { 19 | this.updateAttributes() 20 | 21 | this.model.trigger('state:store') 22 | }, 23 | 24 | undo: function () { 25 | this.attributesToUnset().each(function (attribute) { 26 | this.model.unset(attribute) 27 | }, this) 28 | 29 | this.model.set(this.attributes) 30 | 31 | this.updateAttributes() 32 | 33 | this.model.trigger('state:restore') 34 | }, 35 | 36 | attributesToUnset: function () { 37 | var previousAttributes = _(this.attributes || {}).keys() 38 | 39 | return _(this.model.attributes).chain().keys().select(function (attribute) { 40 | return !_(previousAttributes).include(attribute) 41 | }) 42 | }, 43 | 44 | updateAttributes: function () { 45 | this.attributes = this.model.toJSON({ withDeleted: true }) 46 | this.changed = false 47 | } 48 | }) 49 | 50 | Backbone.UndoableModel = NestedAttributesModel.extend({ 51 | initialize: function () { 52 | NestedAttributesModelProto.initialize.apply(this, arguments) 53 | 54 | this.undoable() 55 | }, 56 | 57 | undoable: function () { 58 | this.saveState() 59 | }, 60 | 61 | undo: function () { 62 | this.undoableState().undo() 63 | }, 64 | 65 | hasChangedSinceSync: function () { 66 | return this.undoableState().changed === true 67 | }, 68 | 69 | saveState: function () { 70 | this.undoableState().save() 71 | }, 72 | 73 | undoableState: function () { 74 | return this._undoableState = this._undoableState || new UndoableState(this) 75 | } 76 | }) 77 | })(Backbone, _); 78 | -------------------------------------------------------------------------------- /backbone-nested-attributes.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'backbone-nested-attributes/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = "backbone-nested-attributes" 8 | gem.version = Backbone::NestedAttributes::VERSION 9 | gem.authors = ["Vicente Mundim"] 10 | gem.email = ["vicente.mundim@gmail.com"] 11 | gem.description = %q{Add nested attributes to your Backbone models} 12 | gem.summary = %q{Add nested attributes to your Backbone models} 13 | 14 | gem.files = `git ls-files`.split($/) 15 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 16 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 17 | gem.require_paths = ["lib"] 18 | 19 | gem.add_dependency "rails", ">= 3.2.8" 20 | end 21 | -------------------------------------------------------------------------------- /backbone-nested-attributes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2013-2014 Vicente Mundim 3 | * 4 | * Version: 0.5.0 5 | * 6 | * MIT License 7 | * 8 | * Permission is hereby granted, free of charge, to any person obtaining 9 | * a copy of this software and associated documentation files (the 10 | * "Software"), to deal in the Software without restriction, including 11 | * without limitation the rights to use, copy, modify, merge, publish, 12 | * distribute, sublicense, and/or sell copies of the Software, and to 13 | * permit persons to whom the Software is furnished to do so, subject to 14 | * the following conditions: 15 | * 16 | * The above copyright notice and this permission notice shall be 17 | * included in all copies or substantial portions of the Software. 18 | * 19 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 20 | * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 21 | * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 22 | * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 23 | * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 24 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 25 | * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 26 | */ 27 | 28 | 29 | 30 | 31 | 32 | 33 | ; 34 | (function(Backbone, _) { 35 | var BackboneModelPrototype = Backbone.Model.prototype 36 | 37 | function setNestedAttributes(model, attributes) { 38 | if (attributes) { 39 | _(model.relations).each(function (relation) { 40 | if (relation.type == 'one') { 41 | setHasOneNestedAttributeFor(model, relation, attributes) 42 | } else { 43 | setNestedAttributeFor(model, relation, attributes) 44 | } 45 | }) 46 | } 47 | 48 | configureNestedAttributesEvents(model) 49 | 50 | return attributes 51 | } 52 | 53 | function setHasOneNestedAttributeFor(model, relation, attributes) { 54 | var key = relation.key, 55 | value = attributes[key], 56 | ModelClass = _(relation).result('relatedModel') 57 | 58 | if (value) { 59 | value = value instanceof Backbone.Model ? value : new ModelClass(value) 60 | 61 | configureEventBubbling(model, value, relation) 62 | attributes[key] = value 63 | } 64 | } 65 | 66 | function setNestedAttributeFor(model, relation, attributes) { 67 | var key = relation.key, 68 | value = attributes[key], 69 | deletedValue = attributes['deleted_' + key], 70 | currentValue = model.get(key), 71 | nested = currentValue || createNestedAttributeCollection(relation) 72 | 73 | value = valueOrSliceCollection(value) 74 | 75 | configureEventBubbling(model, nested, relation) 76 | 77 | if (value) { 78 | nested.set(value) 79 | } 80 | 81 | if (deletedValue) { 82 | delete attributes['deleted_' + key] 83 | 84 | deletedValue = valueOrSliceCollection(deletedValue) 85 | nested.deletedModels.set(deletedValue) 86 | } 87 | 88 | attributes[key] = nested 89 | 90 | return attributes 91 | } 92 | 93 | function valueOrSliceCollection(value) { 94 | return value instanceof Backbone.Collection ? value.slice() : value 95 | } 96 | 97 | function clearDeletedModelsFor(model) { 98 | _(model.relations).each(function (relation) { 99 | var collectionOrModel = model.get(relation.key) 100 | 101 | if (collectionOrModel && collectionOrModel.each) { 102 | collectionOrModel.each(function (nestedModel) { 103 | clearDeletedModelsFor(nestedModel) 104 | }) 105 | 106 | if (collectionOrModel.deletedModels) { 107 | collectionOrModel.deletedModels.reset() 108 | } 109 | } 110 | }) 111 | } 112 | 113 | function configureNestedAttributesEvents(model) { 114 | if (!model._hasNestedAttributesEventsConfigured) { 115 | model.on('sync', clearDeletedModelsFor) 116 | model._hasNestedAttributesEventsConfigured = true 117 | } 118 | } 119 | 120 | function configureEventBubbling(model, nested, relation) { 121 | if (!nested._hasEventBubblingConfigured) { 122 | model.listenTo(nested, 'add change nested:change remove', function (nestedModel, options) { 123 | model.trigger('nested:change change:' + relation.key, nestedModel, options) 124 | }) 125 | 126 | nested._hasEventBubblingConfigured = true 127 | } 128 | } 129 | 130 | function clearNestedEvents(model) { 131 | _(model.relations).each(function (relation) { 132 | var nested = model.get(relation.key) 133 | 134 | model.stopListening(nested) 135 | nested.off('remove', nestedModelRemoved, nested) 136 | 137 | if (nested.deletedModels) { 138 | nested.deletedModels.reset() 139 | } 140 | }, model) 141 | } 142 | 143 | function nestedToJson(json, relations, options) { 144 | _(relations).each(function (relation) { 145 | var key = relation.key, 146 | value = json[key], 147 | deleted = [], 148 | jsonValue 149 | 150 | if (value) { 151 | if (options) { 152 | if (options.withDeleted) { 153 | if (value.deletedModels) { 154 | json['deleted_' + key] = value.deletedModels.toJSON(options) 155 | } 156 | } 157 | 158 | if (options.nested) { 159 | if (value.deletedModels) { 160 | deleted = value.deletedModels.toJSON(options) 161 | } 162 | 163 | delete json[key] 164 | key = key + '_attributes' 165 | } 166 | } 167 | 168 | jsonValue = value.toJSON(options) 169 | 170 | if (_(jsonValue).isArray()) { 171 | jsonValue = jsonValue.concat(deleted) 172 | } 173 | 174 | if (relation.serialize_keys) { 175 | if (_(jsonValue).isArray()) { 176 | jsonValue = _.map(jsonValue, function(j) { return _.pick(j, relation.serialize_keys) }) 177 | } else { 178 | jsonValue = _.pick(jsonValue, relation.serialize_keys) 179 | } 180 | } 181 | 182 | json[key] = jsonValue 183 | } 184 | }) 185 | 186 | return json 187 | } 188 | 189 | function createNestedAttributeCollection(relation) { 190 | var CollectionType = _(relation).result('collectionType') || Backbone.Collection, 191 | collection = new CollectionType 192 | 193 | collection.model = _(relation).result('relatedModel') || collection.model 194 | collection.destroy_action = relation.destroy_action || '_destroy' 195 | 196 | if (relation.serialize_keys) { 197 | relation.serialize_keys.push(collection.destroy_action) 198 | } 199 | 200 | collection.deletedModels = new Backbone.Collection 201 | collection.deletedModels.model = collection.model 202 | collection.on('add', nestedModelAdded) 203 | collection.on('remove', nestedModelRemoved) 204 | 205 | return collection 206 | } 207 | 208 | function nestedModelAdded(model, collection) { 209 | if (model.get(collection.destroy_action)) { 210 | collection.remove(model) 211 | } 212 | } 213 | 214 | function nestedModelRemoved(model, collection) { 215 | if (!model.isNew()) { 216 | param = {} 217 | param[collection.destroy_action] = true 218 | model.set(param) 219 | collection.deletedModels.add(model) 220 | } 221 | } 222 | 223 | function attributesFor(key, value, options) { 224 | var attributes 225 | 226 | // Duplicate backbone's behavior to allow separate key/value parameters, 227 | // instead of a single 'attributes' object. 228 | if (_.isObject(key) || key == null) { 229 | attributes = key 230 | options = value 231 | } else { 232 | attributes = {} 233 | attributes[key] = value 234 | } 235 | 236 | return attributes 237 | } 238 | 239 | Backbone.NestedAttributesModel = Backbone.Model.extend({ 240 | set: function (key, value, options) { 241 | var attributes 242 | 243 | // Duplicate backbone's behavior to allow separate key/value parameters, 244 | // instead of a single 'attributes' object. 245 | if (_.isObject(key) || key == null) { 246 | attributes = key 247 | options = value 248 | } else { 249 | attributes = {} 250 | attributes[key] = value 251 | } 252 | 253 | return BackboneModelPrototype.set.call(this, setNestedAttributes(this, attributes), options) 254 | }, 255 | 256 | toJSON: function (options) { 257 | return nestedToJson(BackboneModelPrototype.toJSON.apply(this, arguments), this.relations, options) 258 | }, 259 | 260 | clone: function() { 261 | return new this.constructor(this.toJSON()); 262 | }, 263 | 264 | clear: function (options) { 265 | clearNestedEvents(this) 266 | return BackboneModelPrototype.clear.apply(this, arguments) 267 | } 268 | }) 269 | })(Backbone, _); 270 | (function(Backbone, _) { 271 | var NestedAttributesModel = Backbone.NestedAttributesModel, 272 | NestedAttributesModelProto = NestedAttributesModel.prototype 273 | 274 | function UndoableState(model) { 275 | this.model = model 276 | 277 | model.on('nested:change', this.change, this) 278 | model.on('change', this.change, this) 279 | model.on('sync', this.save, this) 280 | } 281 | 282 | _.extend(UndoableState.prototype, Backbone.Events, { 283 | change: function () { 284 | this.changed = true 285 | }, 286 | 287 | save: function () { 288 | this.updateAttributes() 289 | 290 | this.model.trigger('state:store') 291 | }, 292 | 293 | undo: function () { 294 | this.attributesToUnset().each(function (attribute) { 295 | this.model.unset(attribute) 296 | }, this) 297 | 298 | this.model.set(this.attributes) 299 | 300 | this.updateAttributes() 301 | 302 | this.model.trigger('state:restore') 303 | }, 304 | 305 | attributesToUnset: function () { 306 | var previousAttributes = _(this.attributes || {}).keys() 307 | 308 | return _(this.model.attributes).chain().keys().select(function (attribute) { 309 | return !_(previousAttributes).include(attribute) 310 | }) 311 | }, 312 | 313 | updateAttributes: function () { 314 | this.attributes = this.model.toJSON({ withDeleted: true }) 315 | this.changed = false 316 | } 317 | }) 318 | 319 | Backbone.UndoableModel = NestedAttributesModel.extend({ 320 | initialize: function () { 321 | NestedAttributesModelProto.initialize.apply(this, arguments) 322 | 323 | this.undoable() 324 | }, 325 | 326 | undoable: function () { 327 | this.saveState() 328 | }, 329 | 330 | undo: function () { 331 | this.undoableState().undo() 332 | }, 333 | 334 | hasChangedSinceSync: function () { 335 | return this.undoableState().changed === true 336 | }, 337 | 338 | saveState: function () { 339 | this.undoableState().save() 340 | }, 341 | 342 | undoableState: function () { 343 | return this._undoableState = this._undoableState || new UndoableState(this) 344 | } 345 | }) 346 | })(Backbone, _); 347 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backbone-nested-attributes", 3 | "version": "0.5.0", 4 | "main": "backbone-nested-attributes.js", 5 | "description": "Add Rails-like nested attributes support for Backbone.Model.", 6 | "license": "MIT", 7 | "ignore": [ 8 | "**/.*", 9 | "*.gemspec", 10 | "Gemfile", 11 | "Gemfile.lock", 12 | "Rakefile", 13 | "app", 14 | "lib", 15 | "pkg", 16 | "spec" 17 | ], 18 | "dependencies": { 19 | "underscore": ">=1.5.2", 20 | "backbone": ">=1.0.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/backbone-nested-attributes.rb: -------------------------------------------------------------------------------- 1 | require "backbone-nested-attributes/version" 2 | require "backbone-nested-attributes/engine" 3 | 4 | module Backbone 5 | module NestedAttributes 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/backbone-nested-attributes/engine.rb: -------------------------------------------------------------------------------- 1 | module Backbone 2 | module NestedAttributes 3 | class Engine < ::Rails::Engine 4 | isolate_namespace Backbone::NestedAttributes 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/backbone-nested-attributes/version.rb: -------------------------------------------------------------------------------- 1 | module Backbone 2 | module NestedAttributes 3 | VERSION = "0.5.0" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/README.rdoc: -------------------------------------------------------------------------------- 1 | == Welcome to Rails 2 | 3 | Rails is a web-application framework that includes everything needed to create 4 | database-backed web applications according to the Model-View-Control pattern. 5 | 6 | This pattern splits the view (also called the presentation) into "dumb" 7 | templates that are primarily responsible for inserting pre-built data in between 8 | HTML tags. The model contains the "smart" domain objects (such as Account, 9 | Product, Person, Post) that holds all the business logic and knows how to 10 | persist themselves to a database. The controller handles the incoming requests 11 | (such as Save New Account, Update Product, Show Post) by manipulating the model 12 | and directing data to the view. 13 | 14 | In Rails, the model is handled by what's called an object-relational mapping 15 | layer entitled Active Record. This layer allows you to present the data from 16 | database rows as objects and embellish these data objects with business logic 17 | methods. You can read more about Active Record in 18 | link:files/vendor/rails/activerecord/README.html. 19 | 20 | The controller and view are handled by the Action Pack, which handles both 21 | layers by its two parts: Action View and Action Controller. These two layers 22 | are bundled in a single package due to their heavy interdependence. This is 23 | unlike the relationship between the Active Record and Action Pack that is much 24 | more separate. Each of these packages can be used independently outside of 25 | Rails. You can read more about Action Pack in 26 | link:files/vendor/rails/actionpack/README.html. 27 | 28 | 29 | == Getting Started 30 | 31 | 1. At the command prompt, create a new Rails application: 32 | rails new myapp (where myapp is the application name) 33 | 34 | 2. Change directory to myapp and start the web server: 35 | cd myapp; rails server (run with --help for options) 36 | 37 | 3. Go to http://localhost:3000/ and you'll see: 38 | "Welcome aboard: You're riding Ruby on Rails!" 39 | 40 | 4. Follow the guidelines to start developing your application. You can find 41 | the following resources handy: 42 | 43 | * The Getting Started Guide: http://guides.rubyonrails.org/getting_started.html 44 | * Ruby on Rails Tutorial Book: http://www.railstutorial.org/ 45 | 46 | 47 | == Debugging Rails 48 | 49 | Sometimes your application goes wrong. Fortunately there are a lot of tools that 50 | will help you debug it and get it back on the rails. 51 | 52 | First area to check is the application log files. Have "tail -f" commands 53 | running on the server.log and development.log. Rails will automatically display 54 | debugging and runtime information to these files. Debugging info will also be 55 | shown in the browser on requests from 127.0.0.1. 56 | 57 | You can also log your own messages directly into the log file from your code 58 | using the Ruby logger class from inside your controllers. Example: 59 | 60 | class WeblogController < ActionController::Base 61 | def destroy 62 | @weblog = Weblog.find(params[:id]) 63 | @weblog.destroy 64 | logger.info("#{Time.now} Destroyed Weblog ID ##{@weblog.id}!") 65 | end 66 | end 67 | 68 | The result will be a message in your log file along the lines of: 69 | 70 | Mon Oct 08 14:22:29 +1000 2007 Destroyed Weblog ID #1! 71 | 72 | More information on how to use the logger is at http://www.ruby-doc.org/core/ 73 | 74 | Also, Ruby documentation can be found at http://www.ruby-lang.org/. There are 75 | several books available online as well: 76 | 77 | * Programming Ruby: http://www.ruby-doc.org/docs/ProgrammingRuby/ (Pickaxe) 78 | * Learn to Program: http://pine.fm/LearnToProgram/ (a beginners guide) 79 | 80 | These two books will bring you up to speed on the Ruby language and also on 81 | programming in general. 82 | 83 | 84 | == Debugger 85 | 86 | Debugger support is available through the debugger command when you start your 87 | Mongrel or WEBrick server with --debugger. This means that you can break out of 88 | execution at any point in the code, investigate and change the model, and then, 89 | resume execution! You need to install ruby-debug to run the server in debugging 90 | mode. With gems, use sudo gem install ruby-debug. Example: 91 | 92 | class WeblogController < ActionController::Base 93 | def index 94 | @posts = Post.all 95 | debugger 96 | end 97 | end 98 | 99 | So the controller will accept the action, run the first line, then present you 100 | with a IRB prompt in the server window. Here you can do things like: 101 | 102 | >> @posts.inspect 103 | => "[#nil, "body"=>nil, "id"=>"1"}>, 105 | #"Rails", "body"=>"Only ten..", "id"=>"2"}>]" 107 | >> @posts.first.title = "hello from a debugger" 108 | => "hello from a debugger" 109 | 110 | ...and even better, you can examine how your runtime objects actually work: 111 | 112 | >> f = @posts.first 113 | => #nil, "body"=>nil, "id"=>"1"}> 114 | >> f. 115 | Display all 152 possibilities? (y or n) 116 | 117 | Finally, when you're ready to resume execution, you can enter "cont". 118 | 119 | 120 | == Console 121 | 122 | The console is a Ruby shell, which allows you to interact with your 123 | application's domain model. Here you'll have all parts of the application 124 | configured, just like it is when the application is running. You can inspect 125 | domain models, change values, and save to the database. Starting the script 126 | without arguments will launch it in the development environment. 127 | 128 | To start the console, run rails console from the application 129 | directory. 130 | 131 | Options: 132 | 133 | * Passing the -s, --sandbox argument will rollback any modifications 134 | made to the database. 135 | * Passing an environment name as an argument will load the corresponding 136 | environment. Example: rails console production. 137 | 138 | To reload your controllers and models after launching the console run 139 | reload! 140 | 141 | More information about irb can be found at: 142 | link:http://www.rubycentral.org/pickaxe/irb.html 143 | 144 | 145 | == dbconsole 146 | 147 | You can go to the command line of your database directly through rails 148 | dbconsole. You would be connected to the database with the credentials 149 | defined in database.yml. Starting the script without arguments will connect you 150 | to the development database. Passing an argument will connect you to a different 151 | database, like rails dbconsole production. Currently works for MySQL, 152 | PostgreSQL and SQLite 3. 153 | 154 | == Description of Contents 155 | 156 | The default directory structure of a generated Ruby on Rails application: 157 | 158 | |-- app 159 | | |-- assets 160 | | |-- images 161 | | |-- javascripts 162 | | `-- stylesheets 163 | | |-- controllers 164 | | |-- helpers 165 | | |-- mailers 166 | | |-- models 167 | | `-- views 168 | | `-- layouts 169 | |-- config 170 | | |-- environments 171 | | |-- initializers 172 | | `-- locales 173 | |-- db 174 | |-- doc 175 | |-- lib 176 | | `-- tasks 177 | |-- log 178 | |-- public 179 | |-- script 180 | |-- test 181 | | |-- fixtures 182 | | |-- functional 183 | | |-- integration 184 | | |-- performance 185 | | `-- unit 186 | |-- tmp 187 | | |-- cache 188 | | |-- pids 189 | | |-- sessions 190 | | `-- sockets 191 | `-- vendor 192 | |-- assets 193 | `-- stylesheets 194 | `-- plugins 195 | 196 | app 197 | Holds all the code that's specific to this particular application. 198 | 199 | app/assets 200 | Contains subdirectories for images, stylesheets, and JavaScript files. 201 | 202 | app/controllers 203 | Holds controllers that should be named like weblogs_controller.rb for 204 | automated URL mapping. All controllers should descend from 205 | ApplicationController which itself descends from ActionController::Base. 206 | 207 | app/models 208 | Holds models that should be named like post.rb. Models descend from 209 | ActiveRecord::Base by default. 210 | 211 | app/views 212 | Holds the template files for the view that should be named like 213 | weblogs/index.html.erb for the WeblogsController#index action. All views use 214 | eRuby syntax by default. 215 | 216 | app/views/layouts 217 | Holds the template files for layouts to be used with views. This models the 218 | common header/footer method of wrapping views. In your views, define a layout 219 | using the layout :default and create a file named default.html.erb. 220 | Inside default.html.erb, call <% yield %> to render the view using this 221 | layout. 222 | 223 | app/helpers 224 | Holds view helpers that should be named like weblogs_helper.rb. These are 225 | generated for you automatically when using generators for controllers. 226 | Helpers can be used to wrap functionality for your views into methods. 227 | 228 | config 229 | Configuration files for the Rails environment, the routing map, the database, 230 | and other dependencies. 231 | 232 | db 233 | Contains the database schema in schema.rb. db/migrate contains all the 234 | sequence of Migrations for your schema. 235 | 236 | doc 237 | This directory is where your application documentation will be stored when 238 | generated using rake doc:app 239 | 240 | lib 241 | Application specific libraries. Basically, any kind of custom code that 242 | doesn't belong under controllers, models, or helpers. This directory is in 243 | the load path. 244 | 245 | public 246 | The directory available for the web server. Also contains the dispatchers and the 247 | default HTML files. This should be set as the DOCUMENT_ROOT of your web 248 | server. 249 | 250 | script 251 | Helper scripts for automation and generation. 252 | 253 | test 254 | Unit and functional tests along with fixtures. When using the rails generate 255 | command, template test files will be generated for you and placed in this 256 | directory. 257 | 258 | vendor 259 | External libraries that the application depends on. Also includes the plugins 260 | subdirectory. If the app has frozen rails, those gems also go here, under 261 | vendor/rails/. This directory is in the load path. 262 | -------------------------------------------------------------------------------- /spec/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | # Add your own tasks in files placed in lib/tasks ending in .rake, 3 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 4 | 5 | require File.expand_path('../config/application', __FILE__) 6 | 7 | Dummy::Application.load_tasks 8 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // the compiled file. 9 | // 10 | // WARNING: THE FIRST BLANK LINE MARKS THE END OF WHAT'S TO BE PROCESSED, ANY BLANK LINE SHOULD 11 | // GO AFTER THE REQUIRES BELOW. 12 | // 13 | //= require jquery 14 | //= require jquery_ujs 15 | //= require underscore 16 | //= require backbone 17 | //= require backbone-nested-attributes/all 18 | //= require_tree . 19 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the top of the 9 | * compiled file, but it's generally better to create a new file per style scope. 10 | * 11 | *= require_self 12 | *= require_tree . 13 | */ 14 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | protect_from_forgery 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /spec/dummy/app/mailers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dtmtec/backbone-nested-attributes/4af6ad8b207ed3632c6b81f9bd6460e1c06ba5bd/spec/dummy/app/mailers/.gitkeep -------------------------------------------------------------------------------- /spec/dummy/app/models/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dtmtec/backbone-nested-attributes/4af6ad8b207ed3632c6b81f9bd6460e1c06ba5bd/spec/dummy/app/models/.gitkeep -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | <%= stylesheet_link_tag "application", :media => "all" %> 6 | <%= javascript_include_tag "application" %> 7 | <%= csrf_meta_tags %> 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path('../config/environment', __FILE__) 4 | run Dummy::Application 5 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../boot', __FILE__) 2 | 3 | # Pick the frameworks you want: 4 | require "active_record/railtie" 5 | require "action_controller/railtie" 6 | require "action_mailer/railtie" 7 | require "sprockets/railtie" 8 | 9 | Bundler.require 10 | require "backbone-nested-attributes" 11 | 12 | module Dummy 13 | class Application < Rails::Application 14 | # Settings in config/environments/* take precedence over those specified here. 15 | # Application configuration should go into files in config/initializers 16 | # -- all .rb files in that directory are automatically loaded. 17 | 18 | # Custom directories with classes and modules you want to be autoloadable. 19 | # config.autoload_paths += %W(#{config.root}/extras) 20 | 21 | # Only load the plugins named here, in the order given (default is alphabetical). 22 | # :all can be used as a placeholder for all plugins not explicitly named. 23 | # config.plugins = [ :exception_notification, :ssl_requirement, :all ] 24 | 25 | # Activate observers that should always be running. 26 | # config.active_record.observers = :cacher, :garbage_collector, :forum_observer 27 | 28 | # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. 29 | # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. 30 | # config.time_zone = 'Central Time (US & Canada)' 31 | 32 | # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. 33 | # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] 34 | # config.i18n.default_locale = :de 35 | 36 | # Configure the default encoding used in templates for Ruby 1.9. 37 | config.encoding = "utf-8" 38 | 39 | # Configure sensitive parameters which will be filtered from the log file. 40 | config.filter_parameters += [:password] 41 | 42 | # Enable escaping HTML in JSON. 43 | config.active_support.escape_html_entities_in_json = true 44 | 45 | # Use SQL instead of Active Record's schema dumper when creating the database. 46 | # This is necessary if your schema can't be completely dumped by the schema dumper, 47 | # like if you have constraints or database-specific column types 48 | # config.active_record.schema_format = :sql 49 | 50 | # Enforce whitelist mode for mass assignment. 51 | # This will create an empty whitelist of attributes available for mass-assignment for all models 52 | # in your app. As such, your models will need to explicitly whitelist or blacklist accessible 53 | # parameters by using an attr_accessible or attr_protected declaration. 54 | config.active_record.whitelist_attributes = true 55 | 56 | # Enable the asset pipeline 57 | config.assets.enabled = true 58 | 59 | # Version of your assets, change this if you want to expire all your assets 60 | config.assets.version = '1.0' 61 | 62 | config.after_initialize do 63 | assets.context_class.instance_eval do 64 | include Sprockets::Helpers::RailsHelper 65 | end 66 | 67 | assets.context_class.class_eval do 68 | def config 69 | Rails.application.config.action_controller 70 | end 71 | 72 | def controller 73 | end 74 | end 75 | end 76 | end 77 | end 78 | 79 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | gemfile = File.expand_path('../../../../Gemfile', __FILE__) 3 | 4 | if File.exist?(gemfile) 5 | ENV['BUNDLE_GEMFILE'] = gemfile 6 | require 'bundler' 7 | Bundler.setup 8 | end 9 | 10 | $:.unshift File.expand_path('../../../../lib', __FILE__) -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite version 3.x 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | development: 7 | adapter: sqlite3 8 | database: db/development.sqlite3 9 | pool: 5 10 | timeout: 5000 11 | 12 | # Warning: The database defined as "test" will be erased and 13 | # re-generated from your development database when you run "rake". 14 | # Do not set this db to the same as development or production. 15 | test: 16 | adapter: sqlite3 17 | database: db/test.sqlite3 18 | pool: 5 19 | timeout: 5000 20 | 21 | production: 22 | adapter: sqlite3 23 | database: db/production.sqlite3 24 | pool: 5 25 | timeout: 5000 26 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the rails application 2 | require File.expand_path('../application', __FILE__) 3 | 4 | # Initialize the rails application 5 | Dummy::Application.initialize! 6 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Log error messages when you accidentally call methods on nil. 10 | config.whiny_nils = true 11 | 12 | # Show full error reports and disable caching 13 | config.consider_all_requests_local = true 14 | config.action_controller.perform_caching = false 15 | 16 | # Don't care if the mailer can't send 17 | config.action_mailer.raise_delivery_errors = false 18 | 19 | # Print deprecation notices to the Rails logger 20 | config.active_support.deprecation = :log 21 | 22 | # Only use best-standards-support built into browsers 23 | config.action_dispatch.best_standards_support = :builtin 24 | 25 | # Raise exception on mass assignment protection for Active Record models 26 | config.active_record.mass_assignment_sanitizer = :strict 27 | 28 | # Log the query plan for queries taking more than this (works 29 | # with SQLite, MySQL, and PostgreSQL) 30 | config.active_record.auto_explain_threshold_in_seconds = 0.5 31 | 32 | # Do not compress assets 33 | config.assets.compress = false 34 | 35 | # Expands the lines which load the assets 36 | config.assets.debug = true 37 | end 38 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb 3 | 4 | # Code is not reloaded between requests 5 | config.cache_classes = true 6 | 7 | # Full error reports are disabled and caching is turned on 8 | config.consider_all_requests_local = false 9 | config.action_controller.perform_caching = true 10 | 11 | # Disable Rails's static asset server (Apache or nginx will already do this) 12 | config.serve_static_assets = false 13 | 14 | # Compress JavaScripts and CSS 15 | config.assets.compress = true 16 | 17 | # Don't fallback to assets pipeline if a precompiled asset is missed 18 | config.assets.compile = false 19 | 20 | # Generate digests for assets URLs 21 | config.assets.digest = true 22 | 23 | # Defaults to nil and saved in location specified by config.assets.prefix 24 | # config.assets.manifest = YOUR_PATH 25 | 26 | # Specifies the header that your server uses for sending files 27 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for apache 28 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx 29 | 30 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 31 | # config.force_ssl = true 32 | 33 | # See everything in the log (default is :info) 34 | # config.log_level = :debug 35 | 36 | # Prepend all log lines with the following tags 37 | # config.log_tags = [ :subdomain, :uuid ] 38 | 39 | # Use a different logger for distributed setups 40 | # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new) 41 | 42 | # Use a different cache store in production 43 | # config.cache_store = :mem_cache_store 44 | 45 | # Enable serving of images, stylesheets, and JavaScripts from an asset server 46 | # config.action_controller.asset_host = "http://assets.example.com" 47 | 48 | # Precompile additional assets (application.js, application.css, and all non-JS/CSS are already added) 49 | # config.assets.precompile += %w( search.js ) 50 | 51 | # Disable delivery errors, bad email addresses will be ignored 52 | # config.action_mailer.raise_delivery_errors = false 53 | 54 | # Enable threaded mode 55 | # config.threadsafe! 56 | 57 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 58 | # the I18n.default_locale when a translation can not be found) 59 | config.i18n.fallbacks = true 60 | 61 | # Send deprecation notices to registered listeners 62 | config.active_support.deprecation = :notify 63 | 64 | # Log the query plan for queries taking more than this (works 65 | # with SQLite, MySQL, and PostgreSQL) 66 | # config.active_record.auto_explain_threshold_in_seconds = 0.5 67 | end 68 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Configure static asset server for tests with Cache-Control for performance 11 | config.serve_static_assets = true 12 | config.static_cache_control = "public, max-age=3600" 13 | 14 | # Log error messages when you accidentally call methods on nil 15 | config.whiny_nils = true 16 | 17 | # Show full error reports and disable caching 18 | config.consider_all_requests_local = true 19 | config.action_controller.perform_caching = false 20 | 21 | # Raise exceptions instead of rendering exception templates 22 | config.action_dispatch.show_exceptions = false 23 | 24 | # Disable request forgery protection in test environment 25 | config.action_controller.allow_forgery_protection = false 26 | 27 | # Tell Action Mailer not to deliver emails to the real world. 28 | # The :test delivery method accumulates sent emails in the 29 | # ActionMailer::Base.deliveries array. 30 | config.action_mailer.delivery_method = :test 31 | 32 | # Raise exception on mass assignment protection for Active Record models 33 | config.active_record.mass_assignment_sanitizer = :strict 34 | 35 | # Print deprecation notices to the stderr 36 | config.active_support.deprecation = :stderr 37 | end 38 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format 4 | # (all these examples are active by default): 5 | # ActiveSupport::Inflector.inflections do |inflect| 6 | # inflect.plural /^(ox)$/i, '\1en' 7 | # inflect.singular /^(ox)en/i, '\1' 8 | # inflect.irregular 'person', 'people' 9 | # inflect.uncountable %w( fish sheep ) 10 | # end 11 | # 12 | # These inflection rules are supported but not enabled by default: 13 | # ActiveSupport::Inflector.inflections do |inflect| 14 | # inflect.acronym 'RESTful' 15 | # end 16 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | # Mime::Type.register_alias "text/html", :iphone 6 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/secret_token.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | # Make sure the secret is at least 30 characters and all random, 6 | # no regular words or you'll be exposed to dictionary attacks. 7 | Dummy::Application.config.secret_token = '8da2d7e7be4e39fccec25df55dd041bcf4741675e34b18226b1b331f298fc6ae69ea404502c9a0ecc23783baca32f119611393dda645516f8d4524f20eb6ff38' 8 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Dummy::Application.config.session_store :cookie_store, key: '_dummy_session' 4 | 5 | # Use the database for sessions instead of the cookie-based default, 6 | # which shouldn't be used to store highly confidential information 7 | # (create the session table with "rails generate session_migration") 8 | # Dummy::Application.config.session_store :active_record_store 9 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | # 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # Disable root element in JSON by default. 12 | ActiveSupport.on_load(:active_record) do 13 | self.include_root_in_json = false 14 | end 15 | -------------------------------------------------------------------------------- /spec/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Sample localization file for English. Add more files in this directory for other locales. 2 | # See https://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points. 3 | 4 | en: 5 | hello: "Hello world" 6 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.routes.draw do 2 | # The priority is based upon order of creation: 3 | # first created -> highest priority. 4 | 5 | # Sample of regular route: 6 | # match 'products/:id' => 'catalog#view' 7 | # Keep in mind you can assign values other than :controller and :action 8 | 9 | # Sample of named route: 10 | # match 'products/:id/purchase' => 'catalog#purchase', :as => :purchase 11 | # This route can be invoked with purchase_url(:id => product.id) 12 | 13 | # Sample resource route (maps HTTP verbs to controller actions automatically): 14 | # resources :products 15 | 16 | # Sample resource route with options: 17 | # resources :products do 18 | # member do 19 | # get 'short' 20 | # post 'toggle' 21 | # end 22 | # 23 | # collection do 24 | # get 'sold' 25 | # end 26 | # end 27 | 28 | # Sample resource route with sub-resources: 29 | # resources :products do 30 | # resources :comments, :sales 31 | # resource :seller 32 | # end 33 | 34 | # Sample resource route with more complex sub-resources 35 | # resources :products do 36 | # resources :comments 37 | # resources :sales do 38 | # get 'recent', :on => :collection 39 | # end 40 | # end 41 | 42 | # Sample resource route within a namespace: 43 | # namespace :admin do 44 | # # Directs /admin/products/* to Admin::ProductsController 45 | # # (app/controllers/admin/products_controller.rb) 46 | # resources :products 47 | # end 48 | 49 | # You can have the root of your site routed with "root" 50 | # just remember to delete public/index.html. 51 | # root :to => 'welcome#index' 52 | 53 | # See how all your routes lay out with "rake routes" 54 | 55 | # This is a legacy wild controller route that's not recommended for RESTful applications. 56 | # Note: This route will make all actions in every controller accessible via GET requests. 57 | # match ':controller(/:action(/:id))(.:format)' 58 | end 59 | -------------------------------------------------------------------------------- /spec/dummy/db/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dtmtec/backbone-nested-attributes/4af6ad8b207ed3632c6b81f9bd6460e1c06ba5bd/spec/dummy/db/.gitkeep -------------------------------------------------------------------------------- /spec/dummy/lib/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dtmtec/backbone-nested-attributes/4af6ad8b207ed3632c6b81f9bd6460e1c06ba5bd/spec/dummy/lib/assets/.gitkeep -------------------------------------------------------------------------------- /spec/dummy/log/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dtmtec/backbone-nested-attributes/4af6ad8b207ed3632c6b81f9bd6460e1c06ba5bd/spec/dummy/log/.gitkeep -------------------------------------------------------------------------------- /spec/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 17 | 18 | 19 | 20 | 21 |
22 |

The page you were looking for doesn't exist.

23 |

You may have mistyped the address or the page may have moved.

24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /spec/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 17 | 18 | 19 | 20 | 21 |
22 |

The change you wanted was rejected.

23 |

Maybe you tried to change something you didn't have access to.

24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /spec/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 17 | 18 | 19 | 20 | 21 |
22 |

We're sorry, but something went wrong.

23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /spec/dummy/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dtmtec/backbone-nested-attributes/4af6ad8b207ed3632c6b81f9bd6460e1c06ba5bd/spec/dummy/public/favicon.ico -------------------------------------------------------------------------------- /spec/dummy/script/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails 3 gems installed from the root of your application. 3 | 4 | APP_PATH = File.expand_path('../../config/application', __FILE__) 5 | require File.expand_path('../../config/boot', __FILE__) 6 | require 'rails/commands' 7 | -------------------------------------------------------------------------------- /spec/javascripts/backbone-nested-attributes/ModelSpec.js: -------------------------------------------------------------------------------- 1 | describe("Backbone.NestedAttributesModel", function() { 2 | var model, 3 | Post, 4 | post, 5 | Comment, 6 | comment, 7 | Person, 8 | author 9 | 10 | beforeEach(function() { 11 | model = new Backbone.NestedAttributesModel({}) 12 | }) 13 | 14 | it("should be a Backbone.Model", function() { 15 | expect(model).toBeAnInstanceOf(Backbone.Model) 16 | }) 17 | 18 | describe("with a has one relationship", function() { 19 | beforeEach(function() { 20 | Post = Backbone.NestedAttributesModel.extend({ 21 | relations: [ 22 | { 23 | type: 'one', 24 | key: 'author', 25 | relatedModel: function () { return Person } 26 | } 27 | ] 28 | }) 29 | 30 | Person = Backbone.NestedAttributesModel.extend({}) 31 | }) 32 | 33 | describe("when creating", function() { 34 | it("does not initializes the author attribute", function() { 35 | model = new Post 36 | expect(model.get('author')).not.toBeDefined() 37 | }); 38 | 39 | describe("while setting attributes", function() { 40 | beforeEach(function() { 41 | model = new Post({ title: 'Some Title', author: { name: 'Jon Snow'} }) 42 | }) 43 | 44 | it("sets the normal attributes", function() { 45 | expect(model.get('title')).toEqual('Some Title') 46 | }) 47 | 48 | it("creates the author model", function() { 49 | author = model.get('author') 50 | 51 | expect(author).toBeAnInstanceOf(Person) 52 | expect(author.get('name')).toEqual('Jon Snow') 53 | }) 54 | 55 | describe("passing a model to the relation attribute", function() { 56 | beforeEach(function() { 57 | author = new Person({ name: 'Jon Snow' }) 58 | model = new Post({ title: 'Some Title', author: author }) 59 | }) 60 | 61 | it("store the given model in the relation attribute", function() { 62 | expect(model.get('author')).toBe(author) 63 | }) 64 | }) 65 | }) 66 | }) 67 | 68 | describe("when updating", function() { 69 | beforeEach(function() { 70 | model = new Post 71 | }) 72 | 73 | it("store the given model in the relation attribute", function() { 74 | model.set({ author: { name: 'Jon Snow' } }) 75 | 76 | author = model.get('author') 77 | 78 | expect(author).toBeAnInstanceOf(Person) 79 | expect(author.get('name')).toEqual('Jon Snow') 80 | }) 81 | 82 | it("allows passing key, value when setting a relation attribute", function() { 83 | model.set('author', { name: 'Jon Snow' }) 84 | 85 | author = model.get('author') 86 | 87 | expect(author).toBeAnInstanceOf(Person) 88 | expect(author.get('name')).toEqual('Jon Snow') 89 | }) 90 | 91 | it('pass options to original set', function (done) { 92 | var called = false 93 | model.on('change:title', function () { called = true }) 94 | model.set('title', 'My Title', { silent: true }) 95 | expect(called).toBeFalsy() 96 | 97 | model.set({title: 'My New Title'}, { silent: true }) 98 | expect(called).toBeFalsy() 99 | }); 100 | 101 | describe("passing a model to the relation attribute", function() { 102 | beforeEach(function() { 103 | author = new Person({ name: 'Jon Snow' }) 104 | model = new Post 105 | }) 106 | 107 | it("store the given model in the relation attribute", function() { 108 | model.set({ title: 'Some title', author: author }) 109 | expect(model.get('author')).toBe(author) 110 | }) 111 | }) 112 | }) 113 | 114 | describe("cloning", function() { 115 | beforeEach(function() { 116 | post = new Post({ title: 'Some Title', author: { name: 'Jon Snow' } }) 117 | }) 118 | 119 | it("creates a new post object, with the same attributes, including nested has one relations, that are not shared", function() { 120 | var newPost = post.clone() 121 | 122 | expect(newPost).not.toBe(post) 123 | expect(newPost.get('author')).not.toBe(post.get('author')) 124 | 125 | expect(newPost.get('title')).toEqual(post.get('title')) 126 | expect(newPost.get('author').get('name')).toEqual(post.get('author').get('name')) 127 | }) 128 | }) 129 | 130 | describe("event bubbling", function() { 131 | var changedModel, changedCount 132 | 133 | beforeEach(function() { 134 | changedCount = 0 135 | changedModel = undefined 136 | 137 | model = new Post({ title: 'Some Title', author: { name: 'Jon Snow' } }) 138 | author = model.get('author') 139 | }) 140 | 141 | describe("when a nested model is changed", function() { 142 | it("triggers a nested:change event on the parent, with the changed model as an argument", function() { 143 | model.on('nested:change', function (model) { 144 | changedModel = model 145 | }) 146 | 147 | author.set({ name: 'Robb Stark' }) 148 | expect(changedModel).toBe(author) 149 | }) 150 | 151 | it("triggers a nested:change only once, after setting nested attributes again", function() { 152 | model.set({ title: 'Some Title', author: { name: 'Jon Snow' } }) 153 | author = model.get('author') 154 | 155 | model.on('nested:change', function (model) { 156 | changedModel = model 157 | changedCount += 1 158 | }) 159 | 160 | author.set({ name: 'Robb Stark' }) 161 | expect(changedModel).toBe(author) 162 | expect(changedCount).toEqual(1) 163 | }) 164 | 165 | it("triggers a change: event on the parent, with the changed model as an argument", function() { 166 | model.on('change:author', function (model) { 167 | changedModel = model 168 | }) 169 | 170 | author.set({ name: 'Robb Stark' }) 171 | expect(changedModel).toBe(author) 172 | }) 173 | 174 | it("triggers a change: only once, after setting nested attributes again", function() { 175 | model.set({ title: 'Some Title', author: { body: 'Jon Snow' } }) 176 | author = model.get('author') 177 | 178 | model.on('change:author', function (model) { 179 | changedModel = model 180 | changedCount += 1 181 | }) 182 | 183 | author.set({ name: 'Robb Stark' }) 184 | expect(changedModel).toBe(author) 185 | expect(changedCount).toEqual(1) 186 | }) 187 | 188 | describe("and the nested model triggers a nested:change", function() { 189 | it("triggers a nested:change on the parent", function() { 190 | model.on('nested:change', function (model) { 191 | changedModel = model 192 | }) 193 | 194 | author.trigger('nested:change', author) 195 | expect(changedModel).toBe(author) 196 | }) 197 | }) 198 | }) 199 | 200 | describe("when clearing", function() { 201 | it("stops listening to relation nested:change events", function() { 202 | model.on('nested:change', function (model) { 203 | changedModel = model 204 | }) 205 | 206 | model.clear() 207 | author.set({ name: 'Robb Stark' }) 208 | expect(changedModel).toBeUndefined() 209 | }) 210 | 211 | it("stops listening to relation change: events", function() { 212 | model.clear() 213 | 214 | model.on('change:author', function (model) { 215 | changedModel = model 216 | }) 217 | 218 | author.set({ name: 'Robb Stark' }) 219 | expect(changedModel).toBeUndefined() 220 | }) 221 | }) 222 | }) 223 | 224 | describe("when synchronizing", function() { 225 | beforeEach(function() { 226 | jasmine.Ajax.useMock() 227 | 228 | model = new Post({ title: 'Some Title', author: { name: 'Jon Snow' } }) 229 | model.url = 'http://someapi.com' 230 | }) 231 | 232 | it("does not raise errors", function() { 233 | model.save() 234 | 235 | var request = mostRecentAjaxRequest(); 236 | request.response({status: 200, responseText: { title: 'Some Title', comments: [] }}) // would raise error 237 | }) 238 | }) 239 | 240 | describe("toJSON", function() { 241 | beforeEach(function() { 242 | model = new Post({ title: 'Some Title', author: { name: 'Jon Snow' } }) 243 | }) 244 | 245 | it("serializes its own attributes, as well as the relation ones", function() { 246 | expect(model.toJSON()).toEqual({ title: 'Some Title', author: { name: 'Jon Snow' } }) 247 | }) 248 | 249 | describe("with nested attributes support", function() { 250 | it("serializes the relation attributes with a _attributes suffix", function() { 251 | expect(model.toJSON({ nested: true })).toEqual({ 252 | title: 'Some Title', 253 | author_attributes: { name: 'Jon Snow' } 254 | }) 255 | }) 256 | }) 257 | }) 258 | 259 | describe("with a whitelist of attributes to serialize", function() { 260 | beforeEach(function() { 261 | PostWhitelisted = Backbone.NestedAttributesModel.extend({ 262 | relations: [ 263 | { 264 | type: 'one', 265 | key: 'author', 266 | serialize_keys: ['name'], 267 | relatedModel: function() { return Person } 268 | } 269 | ] 270 | }) 271 | model = new PostWhitelisted({ title: 'Some Title', author: { name: 'Jon Snow', adress: 'Some Adress' } }) 272 | }) 273 | 274 | it("only serialize whitelisted attributes", function() { 275 | expect(model.toJSON({ nested: true })).toEqual({ 276 | title: 'Some Title', 277 | author_attributes: { name: 'Jon Snow' } 278 | }) 279 | }) 280 | }) 281 | }) 282 | 283 | describe("with a has many relationship", function() { 284 | beforeEach(function() { 285 | Post = Backbone.NestedAttributesModel.extend({ 286 | relations: [ 287 | { 288 | key: 'comments', 289 | relatedModel: function () { return Comment } 290 | } 291 | ] 292 | }) 293 | 294 | Comment = Backbone.NestedAttributesModel.extend({}) 295 | }) 296 | 297 | describe("when creating", function() { 298 | it("initializes the comments attribute with an empty collection", function() { 299 | model = new Post 300 | expect(model.get('comments')).toBeDefined() 301 | expect(model.get('comments')).toBeAnInstanceOf(Backbone.Collection) 302 | }); 303 | 304 | describe("while setting attributes", function() { 305 | beforeEach(function() { 306 | model = new Post({ title: 'Some Title', comments: [{ body: 'some comment'} ] }) 307 | }) 308 | 309 | it("sets the normal attributes", function() { 310 | expect(model.get('title')).toEqual('Some Title') 311 | }) 312 | 313 | it("creates the comment inside comments collection", function() { 314 | comment = model.get('comments').at(0) 315 | 316 | expect(comment).toBeAnInstanceOf(Comment) 317 | expect(comment.get('body')).toEqual('some comment') 318 | }) 319 | 320 | describe("passing a collection to a relation attribute", function() { 321 | var comments, Comments 322 | 323 | beforeEach(function() { 324 | Comments = Backbone.Collection.extend({ model: Comment }) 325 | comments = new Comments({ body: 'some comment' }, { body: 'some other comment' }) 326 | model = new Post({ title: 'Some Title', comments: comments }) 327 | }) 328 | 329 | it("does not store the given collection, but instead creates a new one with models from the given collection", function() { 330 | expect(model.get('comments')).not.toBe(comments) 331 | expect(model.get('comments')).not.toBeAnInstanceOf(Comments) 332 | expect(model.get('comments')).toBeAnInstanceOf(Backbone.Collection) 333 | expect(model.get('comments').at(0)).toBe(comments.at(0)) 334 | expect(model.get('comments').at(1)).toBe(comments.at(1)) 335 | }) 336 | }) 337 | 338 | describe('passing a model in a collection with { _destroy: true }', function () { 339 | beforeEach(function() { 340 | model = new Post({ title: 'Some Title', comments: [{ body: 'some content' }, { id: '123', body: 'other content', _destroy: true }] }) 341 | }) 342 | 343 | it('do not add the model with { _destroy: true } to the relation collection', function () { 344 | expect(model.get('comments').length).toBe(1) 345 | expect(model.get('comments').at(0).get('body')).toBe('some content') 346 | }) 347 | 348 | it('adds them to the deletedModels collection inside the relation collection', function () { 349 | expect(model.get('comments').deletedModels.length).toBe(1) 350 | expect(model.get('comments').deletedModels.at(0).get('body')).toBe('other content') 351 | }) 352 | }) 353 | 354 | describe("passing a deleted_ attribute", function() { 355 | var comments 356 | 357 | beforeEach(function() { 358 | model = new Post({ title: 'Some Title', deleted_comments: [{ id: 123, body: "some deleted comment", _destroy: true }] }) 359 | }) 360 | 361 | it("does not save this key as an attribute", function() { 362 | expect(model.get('deleted_comments')).toBeUndefined() 363 | }) 364 | 365 | it("adds the models in the deleted_comments attribute to the deletedModels collection inside the relation collection", function() { 366 | comments = model.get('comments') 367 | expect(comments.deletedModels.at(0)).toBeAnInstanceOf(Comment) 368 | expect(comments.deletedModels.at(0).get('id')).toEqual(123) 369 | expect(comments.deletedModels.at(0).get('body')).toEqual('some deleted comment') 370 | expect(comments.deletedModels.at(0).get('_destroy')).toBeTruthy() 371 | }) 372 | }) 373 | }) 374 | }) 375 | 376 | describe("when updating", function() { 377 | beforeEach(function() { 378 | model = new Post 379 | }) 380 | 381 | it("creates the comment inside comments collection", function() { 382 | model.set({ comments: [{ body: 'some comment' }] }) 383 | 384 | comment = model.get('comments').at(0) 385 | 386 | expect(comment).toBeAnInstanceOf(Comment) 387 | expect(comment.get('body')).toEqual('some comment') 388 | }) 389 | 390 | it("does not creates a new collection, but updates it instead", function() { 391 | var originalCollection = model.get('comments') 392 | 393 | model.set({ comments: [{ body: 'some comment' }] }) 394 | expect(model.get('comments')).toBe(originalCollection) 395 | }) 396 | 397 | it("updates existing models in the collection", function() { 398 | var existingComment 399 | 400 | model.set({ comments: [{ id: '123', body: 'some comment' }] }) 401 | existingComment = model.get('comments').at(0) 402 | 403 | model.set({ comments: [{ id: existingComment.id, body: 'some other comment' }] }) 404 | 405 | expect(model.get('comments').at(0).get('body')).toEqual('some other comment') 406 | }) 407 | 408 | it("allows passing key, value when setting a relation attribute", function() { 409 | model.set('comments', [{ body: 'some comment' }]) 410 | 411 | comment = model.get('comments').at(0) 412 | 413 | expect(comment).toBeAnInstanceOf(Comment) 414 | expect(comment.get('body')).toEqual('some comment') 415 | }) 416 | 417 | it("allows one to update non-nested attributes without modifying nested ones", function() { 418 | model = new Post({ title: 'new title', comments: [{ body: 'some comment' }] }) 419 | model.set('title', 'other title') 420 | expect(model.get('title')).toEqual('other title') 421 | expect(model.get('comments').length).toEqual(1) 422 | }) 423 | 424 | describe("passing a collection to a relation attribute", function() { 425 | var comments, Comments 426 | 427 | beforeEach(function() { 428 | Comments = Backbone.Collection.extend({ model: Comment }) 429 | comments = new Comments({ body: 'some comment' }, { body: 'some other comment' }) 430 | model = new Post 431 | }) 432 | 433 | it("does not store the given collection, but instead updates the existing one with models from the given collection", function() { 434 | var originalCollection = model.get('comments') 435 | 436 | model.set({ title: 'Some Title', comments: comments }) 437 | 438 | expect(model.get('comments')).toBe(originalCollection) 439 | expect(model.get('comments').at(0)).toBe(comments.at(0)) 440 | expect(model.get('comments').at(1)).toBe(comments.at(1)) 441 | }) 442 | }) 443 | 444 | describe("passing a deleted_ attribute", function() { 445 | var comments 446 | 447 | beforeEach(function() { 448 | model = new Post({ title: 'Some Title' }) 449 | }) 450 | 451 | it("does not save this key as an attribute", function() { 452 | model.set({ deleted_comments: [{ id: 123, body: "some deleted comment", _destroy: true }] }) 453 | expect(model.get('deleted_comments')).toBeUndefined() 454 | }) 455 | 456 | it("adds the models in the deleted_comments attribute to the deletedModels collection inside the relation collection", function() { 457 | model.set({ deleted_comments: [{ id: 123, body: "some deleted comment", _destroy: true }] }) 458 | comments = model.get('comments') 459 | 460 | expect(comments.deletedModels.at(0)).toBeAnInstanceOf(Comment) 461 | expect(comments.deletedModels.at(0).get('id')).toEqual(123) 462 | expect(comments.deletedModels.at(0).get('body')).toEqual('some deleted comment') 463 | expect(comments.deletedModels.at(0).get('_destroy')).toBeTruthy() 464 | }) 465 | }) 466 | }) 467 | 468 | describe("cloning", function() { 469 | beforeEach(function() { 470 | post = new Post({ title: 'Some Title', comments: [{ body: 'some comment' }] }) 471 | }) 472 | 473 | it("creates a new post object, with the same attributes, including nested, but do not share the nested collections", function() { 474 | var newPost = post.clone() 475 | 476 | expect(newPost).not.toBe(post) 477 | expect(newPost.get('comments')).not.toBe(post.get('comments')) 478 | expect(newPost.get('comments').at(0)).not.toBe(post.get('comments').at(0)) 479 | 480 | expect(newPost.get('title')).toEqual(post.get('title')) 481 | expect(newPost.get('comments').at(0).get('body')).toEqual(post.get('comments').at(0).get('body')) 482 | }) 483 | }) 484 | 485 | describe("specifying the collection type", function() { 486 | var Comments 487 | 488 | beforeEach(function() { 489 | Comments = Backbone.Collection.extend({ model: Comment }) 490 | 491 | Post = Backbone.NestedAttributesModel.extend({ 492 | relations: [ 493 | { 494 | key: 'comments', 495 | collectionType: function () { return Comments } 496 | } 497 | ] 498 | }) 499 | 500 | model = new Post({ title: 'Some Title', comments: [{ body: 'some comment' }] }) 501 | }) 502 | 503 | it("uses the given collection as the relation attribute", function() { 504 | expect(model.get('comments')).toBeAnInstanceOf(Comments) 505 | expect(model.get('comments').at(0)).toBeAnInstanceOf(Comment) 506 | }) 507 | }) 508 | 509 | describe("event bubbling", function() { 510 | var changedModel, changedCount 511 | 512 | beforeEach(function() { 513 | changedCount = 0 514 | changedModel = undefined 515 | 516 | model = new Post({ title: 'Some Title', comments: [{ body: 'some comment' }] }) 517 | comment = model.get('comments').at(0) 518 | }) 519 | 520 | describe("when a nested model is changed", function() { 521 | it("triggers a nested:change event on the parent, with the changed model as an argument", function() { 522 | model.on('nested:change', function (model) { 523 | changedModel = model 524 | }) 525 | 526 | comment.set({ body: 'some new body' }) 527 | expect(changedModel).toBe(comment) 528 | }) 529 | 530 | it("triggers a nested:change only once, after setting nested attributes again", function() { 531 | model.set({ title: 'Some Title', comments: [{ body: 'some other comment' }] }) 532 | comment = model.get('comments').at(0) 533 | 534 | model.on('nested:change', function (model) { 535 | changedModel = model 536 | changedCount += 1 537 | }) 538 | 539 | comment.set({ body: 'some new body' }) 540 | expect(changedModel).toBe(comment) 541 | expect(changedCount).toEqual(1) 542 | }) 543 | 544 | it("triggers a change: event on the parent, with the changed model as an argument", function() { 545 | model.on('change:comments', function (model) { 546 | changedModel = model 547 | }) 548 | 549 | comment.set({ body: 'some new body' }) 550 | expect(changedModel).toBe(comment) 551 | }) 552 | 553 | it("triggers a change: only once, after setting nested attributes again", function() { 554 | model.set({ title: 'Some Title', comments: [{ body: 'some other comment' }] }) 555 | comment = model.get('comments').at(0) 556 | 557 | model.on('change:comments', function (model) { 558 | changedModel = model 559 | changedCount += 1 560 | }) 561 | 562 | comment.set({ body: 'some new body' }) 563 | expect(changedModel).toBe(comment) 564 | expect(changedCount).toEqual(1) 565 | }) 566 | 567 | describe("and the nested model triggers a nested:change", function() { 568 | it("triggers a nested:change on the parent", function() { 569 | model.on('nested:change', function (model) { 570 | changedModel = model 571 | }) 572 | 573 | comment.trigger('nested:change', comment) 574 | expect(changedModel).toBe(comment) 575 | }) 576 | }) 577 | }) 578 | 579 | describe("when adding a new model to a nested relation", function() { 580 | it("triggers a nested:change event on the parent, with the added model as an argument", function() { 581 | model.on('nested:change', function (model) { 582 | changedModel = model 583 | }) 584 | 585 | model.get('comments').add({ body: 'other comment' }) 586 | comment = model.get('comments').at(1) 587 | expect(changedModel).toBe(comment) 588 | }) 589 | 590 | it("triggers a change: event on the parent, with the added model as an argument", function() { 591 | model.on('change:comments', function (model) { 592 | changedModel = model 593 | }) 594 | 595 | model.get('comments').add({ body: 'other comment' }) 596 | comment = model.get('comments').at(1) 597 | expect(changedModel).toBe(comment) 598 | }) 599 | }) 600 | 601 | describe("when removing a new model to a nested relation", function() { 602 | it("triggers a nested:change event on the parent, with the removed model as an argument", function() { 603 | model.on('nested:change', function (model) { 604 | changedModel = model 605 | }) 606 | 607 | comment = model.get('comments').at(0) 608 | model.get('comments').remove(comment) 609 | expect(changedModel).toBe(comment) 610 | }) 611 | 612 | it("triggers a change: event on the parent, with the removed model as an argument", function() { 613 | model.on('change:comments', function (model) { 614 | changedModel = model 615 | }) 616 | 617 | comment = model.get('comments').at(0) 618 | model.get('comments').remove(comment) 619 | expect(changedModel).toBe(comment) 620 | }) 621 | }) 622 | 623 | describe("when clearing", function() { 624 | it("stops listening to collection nested:change events", function() { 625 | model.on('nested:change', function (model) { 626 | changedModel = model 627 | }) 628 | 629 | model.clear() 630 | comment.set({ body: 'some new body' }) 631 | expect(changedModel).toBeUndefined() 632 | }) 633 | 634 | it("stops listening to collection change: events", function() { 635 | model.on('change:comments', function (model) { 636 | changedModel = model 637 | }) 638 | 639 | model.clear() 640 | comment.set({ body: 'some new body' }) 641 | expect(changedModel).toBeUndefined() 642 | }) 643 | }) 644 | }) 645 | 646 | describe("toJSON", function() { 647 | beforeEach(function() { 648 | model = new Post({ title: 'Some Title', comments: [{ body: 'some comment' }] }) 649 | }) 650 | 651 | it("serializes its own attributes, as well as the relation one", function() { 652 | expect(model.toJSON()).toEqual({ title: 'Some Title', comments: [{ body: 'some comment' }] }) 653 | }) 654 | 655 | describe("with nested attributes support", function() { 656 | it("serializes the relation attributes with a _attributes suffix", function() { 657 | expect(model.toJSON({ nested: true })).toEqual({ 658 | title: 'Some Title', 659 | comments_attributes: [{ body: 'some comment' }] 660 | }) 661 | }) 662 | 663 | describe("when a nested model is removed", function() { 664 | describe("and it is a new model", function() { 665 | beforeEach(function() { 666 | comment = model.get('comments').at(0) 667 | }) 668 | 669 | it("is not serialized on the relation", function() { 670 | model.get('comments').remove(comment) 671 | 672 | expect(model.toJSON({ nested: true })).toEqual({ 673 | title: 'Some Title', 674 | comments_attributes: [] 675 | }) 676 | }) 677 | }) 678 | 679 | describe("and it is not a new model", function() { 680 | beforeEach(function() { 681 | comment = model.get('comments').at(0) 682 | comment.set({ id: '123' }) 683 | }) 684 | 685 | it("is serialized on the relation, with { _destroy: true } attribute, besides its own attributes", function() { 686 | model.get('comments').remove(comment) 687 | 688 | expect(model.toJSON({ nested: true })).toEqual({ 689 | title: 'Some Title', 690 | comments_attributes: [{ 691 | id: comment.get('id'), 692 | body: comment.get('body'), 693 | _destroy: true 694 | }] 695 | }) 696 | }) 697 | 698 | describe("after synchronizing the parent model", function() { 699 | beforeEach(function() { 700 | jasmine.Ajax.useMock() 701 | model.url = 'http://someapi.com' 702 | }) 703 | 704 | it("is not serialized on the relation", function() { 705 | model.get('comments').remove(comment) 706 | model.save() 707 | 708 | var request = mostRecentAjaxRequest(); 709 | request.response({status: 200, responseText: { title: 'Some Title', comments: [] }}) 710 | 711 | expect(model.toJSON({ nested: true })).toEqual({ 712 | title: 'Some Title', 713 | comments_attributes: [] 714 | }) 715 | }) 716 | }) 717 | 718 | describe("after clearing the parent model", function() { 719 | it("is not serialized on the relation", function() { 720 | model.get('comments').remove(comment) 721 | model.clear() 722 | 723 | expect(model.toJSON({ nested: true })).toEqual({}) 724 | }) 725 | }) 726 | }) 727 | }) 728 | }) 729 | 730 | describe("with deleted models", function() { 731 | beforeEach(function() { 732 | model = new Post({ id: 321, title: 'Some Title', comments: [{ id: 123, body: 'some comment' }] }) 733 | comment = model.get('comments').at(0) 734 | }) 735 | 736 | it("serializes the deleted models in a relation in a delete_ key", function() { 737 | model.get('comments').remove(comment) 738 | 739 | expect(model.toJSON({ withDeleted: true })).toEqual({ 740 | id: 321, 741 | title: 'Some Title', 742 | comments: [], 743 | deleted_comments: [ {id: 123, body: 'some comment', _destroy: true} ] 744 | }) 745 | }) 746 | 747 | describe("and nested attributes support", function() { 748 | it("serializes the deleted models in a relation in a delete_ key", function() { 749 | model.get('comments').remove(comment) 750 | 751 | expect(model.toJSON({ withDeleted: true, nested: true })).toEqual({ 752 | id: 321, 753 | title: 'Some Title', 754 | comments_attributes: [{ id: 123, body: 'some comment', _destroy: true }], 755 | deleted_comments: [ { id: 123, body: 'some comment', _destroy: true } ] 756 | }) 757 | }) 758 | }) 759 | }) 760 | }) 761 | 762 | describe("with a custom destroy action", function() { 763 | beforeEach(function() { 764 | PostCustomDestroy = Backbone.NestedAttributesModel.extend({ 765 | relations: [ 766 | { 767 | key: 'comments', 768 | destroy_action: '_remove', 769 | relatedModel: function() { return Comment } 770 | } 771 | ] 772 | }) 773 | 774 | model = new PostCustomDestroy({ id: 321, title: 'Some Title', comments: [{ id: 123, body: 'some comment' }] }) 775 | comment = model.get('comments').at(0) 776 | }) 777 | 778 | describe("toJSON with deleted models", function() { 779 | it("serializes the deleted models in a relation with a key", function() { 780 | model.get('comments').remove(comment) 781 | 782 | expect(model.toJSON({ withDeleted: true, nested: true })).toEqual({ 783 | id: 321, 784 | title: 'Some Title', 785 | comments_attributes: [{ id: 123, body: 'some comment', _remove: true }], 786 | deleted_comments: [{ id: 123, body: 'some comment', _remove: true }] 787 | }) 788 | }) 789 | }) 790 | 791 | describe('passing a model in a collection with { : true }', function () { 792 | beforeEach(function() { 793 | model = new PostCustomDestroy({ title: 'Some Title', comments: [{ body: 'some content' }, { id: '123', body: 'other content', _remove: true }] }) 794 | }) 795 | 796 | it('do not add the model with { : true } to the relation collection', function () { 797 | expect(model.get('comments').length).toBe(1) 798 | expect(model.get('comments').at(0).get('body')).toBe('some content') 799 | }) 800 | 801 | it('adds them to the deletedModels collection inside the relation collection', function () { 802 | expect(model.get('comments').deletedModels.length).toBe(1) 803 | expect(model.get('comments').deletedModels.at(0).get('body')).toBe('other content') 804 | }) 805 | }) 806 | }) 807 | 808 | describe("with a whitelist of attributes to serialize", function() { 809 | beforeEach(function() { 810 | PostWhitelisted = Backbone.NestedAttributesModel.extend({ 811 | relations: [ 812 | { 813 | key: 'comments', 814 | serialize_keys: ['id'], 815 | relatedModel: function() { return Comment } 816 | } 817 | ] 818 | }) 819 | 820 | model = new PostWhitelisted({ title: 'Some Title', comments: { id: 123, body: 'some comment' } }) 821 | comment = model.get('comments').at(0) 822 | }) 823 | 824 | it("only serialize whitelisted attributes", function() { 825 | expect(model.toJSON({ nested: true })).toEqual({ 826 | title: 'Some Title', 827 | comments_attributes: [ { id: 123 } ] 828 | }) 829 | }) 830 | 831 | describe("toJSON with deleted models", function() { 832 | it("whitelist the attribute", function() { 833 | model.get('comments').remove(comment) 834 | 835 | expect(model.toJSON({ nested: true })).toEqual({ 836 | title: 'Some Title', 837 | comments_attributes: [{ id: 123, _destroy: true }] 838 | }) 839 | }) 840 | }) 841 | }) 842 | }) 843 | }) 844 | -------------------------------------------------------------------------------- /spec/javascripts/backbone-nested-attributes/UndoableSpec.js: -------------------------------------------------------------------------------- 1 | describe("Backbone.UndoableModel", function() { 2 | var model, 3 | originalAttributes, 4 | afterSyncAttributes, 5 | called, 6 | post, 7 | Post, 8 | comments, 9 | comment, 10 | Comment, 11 | author, 12 | Person 13 | 14 | beforeEach(function() { 15 | jasmine.Ajax.useMock() 16 | called = false 17 | 18 | originalAttributes = { title: 'some title', body: 'some body' } 19 | model = new Backbone.UndoableModel(originalAttributes) 20 | model.url = 'http://someapi.com' 21 | }) 22 | 23 | function performSync(model) { 24 | var request = mostRecentAjaxRequest(); 25 | request.response({status: 200, responseText: model.toJSON()}) 26 | } 27 | 28 | it("should be a Backbone.NestedAttributesModel", function() { 29 | expect(model).toBeAnInstanceOf(Backbone.NestedAttributesModel) 30 | }) 31 | 32 | describe("when the initialize method is overriden", function() { 33 | var Model 34 | 35 | describe("and the undoable method is not called", function() { 36 | beforeEach(function() { 37 | Model = Backbone.UndoableModel.extend({ 38 | initialize: function () {} 39 | }) 40 | 41 | model = new Model(originalAttributes) 42 | }) 43 | 44 | it("does not reverts the model own attributes to its original attributes", function() { 45 | model.set({ title: 'new title', body: 'new body' }) 46 | model.undo() 47 | 48 | expect(model.toJSON()).not.toEqual(originalAttributes) 49 | }) 50 | }) 51 | 52 | describe("and the undoable method is called", function() { 53 | describe("in the initialize method", function() { 54 | beforeEach(function() { 55 | Model = Backbone.UndoableModel.extend({ 56 | initialize: function () { 57 | this.undoable() 58 | } 59 | }) 60 | 61 | model = new Model(originalAttributes) 62 | }) 63 | 64 | it("reverts the model own attributes to its original attributes", function() { 65 | model.set({ title: 'new title', body: 'new body' }) 66 | model.undo() 67 | 68 | expect(model.toJSON()).toEqual(originalAttributes) 69 | }) 70 | }) 71 | 72 | describe("by calling the super method", function() { 73 | beforeEach(function() { 74 | Model = Backbone.UndoableModel.extend({ 75 | initialize: function () { 76 | Backbone.UndoableModel.prototype.initialize.apply(this, arguments) 77 | } 78 | }) 79 | 80 | model = new Model(originalAttributes) 81 | }) 82 | 83 | it("reverts the model own attributes to its original attributes", function() { 84 | model.set({ title: 'new title', body: 'new body' }) 85 | model.undo() 86 | 87 | expect(model.toJSON()).toEqual(originalAttributes) 88 | }) 89 | }) 90 | 91 | describe("after it is initialized", function() { 92 | beforeEach(function() { 93 | Model = Backbone.UndoableModel.extend({}) 94 | 95 | model = new Model(originalAttributes) 96 | }) 97 | 98 | it("reverts the model own attributes to the attributes that the model had before calling undoable", function() { 99 | var beforeUndoableAttributes = { title: 'before undoable title', body: 'before undoable body' } 100 | 101 | model.set(beforeUndoableAttributes) 102 | model.undoable() 103 | model.set({ title: 'new title', body: 'new body' }) 104 | model.undo() 105 | 106 | expect(model.toJSON()).toEqual(beforeUndoableAttributes) 107 | }) 108 | }) 109 | }) 110 | }) 111 | 112 | describe("when it is a new model", function() { 113 | it("should not have changes since sync", function() { 114 | expect(model.hasChangedSinceSync()).toBeFalsy() 115 | }) 116 | 117 | describe("and it is changed", function() { 118 | it("should have changes since sync", function() { 119 | model.set({ title: 'new title', body: 'new body' }) 120 | expect(model.hasChangedSinceSync()).toBeTruthy() 121 | }) 122 | 123 | describe("and then undo", function() { 124 | it("should not have changes since sync", function() { 125 | model.set({ title: 'new title', body: 'new body' }) 126 | model.undo() 127 | expect(model.hasChangedSinceSync()).toBeFalsy() 128 | }) 129 | 130 | it("reverts the model own attributes to its original attributes", function() { 131 | model.set({ title: 'new title', body: 'new body' }) 132 | model.undo() 133 | 134 | expect(model.toJSON()).toEqual(originalAttributes) 135 | }) 136 | }) 137 | 138 | describe("and then sync", function() { 139 | it("should not have changes since sync", function() { 140 | model.set({ title: 'new title', body: 'new body' }) 141 | model.save() 142 | performSync(model) 143 | expect(model.hasChangedSinceSync()).toBeFalsy() 144 | }) 145 | 146 | describe("then undo", function() { 147 | it("reverts the model own attributes to their value right after sync", function() { 148 | model.set({ title: 'sync title', body: 'sync body' }) 149 | model.save() 150 | performSync(model) 151 | 152 | afterSyncAttributes = model.toJSON() 153 | 154 | model.set({ title: 'new title', body: 'new body' }) 155 | model.undo() 156 | 157 | expect(model.toJSON()).toEqual(afterSyncAttributes) 158 | }) 159 | }) 160 | }) 161 | }) 162 | }) 163 | 164 | describe("when it is an existing model", function() { 165 | beforeEach(function() { 166 | 167 | originalAttributes = { id: 123, title: 'some title', body: 'some body' } 168 | model = new Backbone.UndoableModel(originalAttributes) 169 | model.url = 'http://someapi.com' 170 | }) 171 | 172 | describe("and it is changed", function() { 173 | it("should have changes since sync", function() { 174 | model.set({ title: 'new title', body: 'new body' }) 175 | expect(model.hasChangedSinceSync()).toBeTruthy() 176 | }) 177 | 178 | describe("and then undo", function() { 179 | it("should not have changes since sync", function() { 180 | model.set({ title: 'new title', body: 'new body' }) 181 | model.undo() 182 | expect(model.hasChangedSinceSync()).toBeFalsy() 183 | }) 184 | 185 | it("reverts the model own attributes to its original attributes", function() { 186 | model.set({ title: 'new title', body: 'new body' }) 187 | model.undo() 188 | 189 | expect(model.toJSON()).toEqual(originalAttributes) 190 | }) 191 | }) 192 | 193 | describe("and then sync", function() { 194 | it("should not have changes since sync", function() { 195 | model.set({ title: 'new title', body: 'new body' }) 196 | model.save() 197 | performSync(model) 198 | expect(model.hasChangedSinceSync()).toBeFalsy() 199 | }) 200 | 201 | describe("then undo", function() { 202 | it("reverts the model own attributes to their value right after sync", function() { 203 | model.set({ title: 'sync title', body: 'sync body' }) 204 | model.save() 205 | performSync(model) 206 | 207 | afterSyncAttributes = model.toJSON() 208 | 209 | model.set({ title: 'new title', body: 'new body' }) 210 | model.undo() 211 | 212 | expect(model.toJSON()).toEqual(afterSyncAttributes) 213 | }) 214 | }) 215 | }) 216 | }) 217 | }) 218 | 219 | describe("state:restore event", function() { 220 | var called 221 | 222 | beforeEach(function() { 223 | called = false 224 | }) 225 | 226 | it("is triggered when the model is undo", function() { 227 | model.on('state:restore', function () { 228 | called = true 229 | }) 230 | 231 | model.undo() 232 | expect(called).toBeTruthy() 233 | }) 234 | }) 235 | 236 | describe("state:store event", function() { 237 | it("is triggered when the model is saved", function() { 238 | model.on('state:store', function () { 239 | called = true 240 | }) 241 | 242 | model.save() 243 | performSync(model) 244 | expect(called).toBeTruthy() 245 | }) 246 | 247 | describe("when it is a new model", function() { 248 | it("is triggered when the model is saved", function() { 249 | model.on('state:store', function () { 250 | called = true 251 | }) 252 | 253 | model.save() 254 | performSync(model) 255 | expect(called).toBeTruthy() 256 | }) 257 | 258 | it("is not triggered when the model is destroyed", function() { 259 | model.on('state:store', function () { 260 | called = true 261 | }) 262 | 263 | model.destroy() 264 | performSync(model) 265 | expect(called).toBeFalsy() 266 | }) 267 | }) 268 | 269 | describe("when it is not a new model", function() { 270 | beforeEach(function() { 271 | model.set({ id: 123 }) 272 | }); 273 | 274 | it("is triggered when the model is saved", function() { 275 | model.on('state:store', function () { 276 | called = true 277 | }) 278 | 279 | model.save() 280 | performSync(model) 281 | expect(called).toBeTruthy() 282 | }) 283 | 284 | it("is triggered when the model is destroyed", function() { 285 | model.on('state:store', function () { 286 | called = true 287 | }) 288 | 289 | model.destroy() 290 | performSync(model) 291 | expect(called).toBeTruthy() 292 | }) 293 | }) 294 | }) 295 | 296 | describe("when an attribute is added", function() { 297 | it("unsets this attribute on undo", function() { 298 | model.set({ newAttr: 'foo' }) 299 | model.undo() 300 | expect(model.has('newAttr')).toBeFalsy() 301 | }) 302 | }) 303 | 304 | describe("when it has a has one relationship", function() { 305 | beforeEach(function() { 306 | Post = Backbone.UndoableModel.extend({ 307 | relations: [ 308 | { 309 | type: 'one', 310 | key: 'author', 311 | relatedModel: function () { return Person } 312 | } 313 | ] 314 | }) 315 | 316 | Person = Backbone.UndoableModel.extend({}) 317 | 318 | originalAttributes = { title: 'some title', author: { name: 'Jon Snow' } } 319 | post = new Post(_(originalAttributes).clone()) 320 | author = post.get('author') 321 | }) 322 | 323 | describe("and a nested model changes", function() { 324 | it("should have changes since sync", function() { 325 | author.set({ name: 'Robb Stark' }) 326 | expect(author.hasChangedSinceSync()).toBeTruthy() 327 | }) 328 | 329 | it("its parent should have changes since sync", function() { 330 | author.set({ name: 'Robb Stark' }) 331 | expect(post.hasChangedSinceSync()).toBeTruthy() 332 | }) 333 | 334 | describe("and the nested model state is saved", function() { 335 | it("should not have changes since sync", function() { 336 | author.set({ name: 'Robb Stark' }) 337 | author.saveState() 338 | expect(author.hasChangedSinceSync()).toBeFalsy() 339 | }) 340 | 341 | it("its parent should have changes since sync", function() { 342 | author.set({ name: 'Robb Stark' }) 343 | author.saveState() 344 | expect(post.hasChangedSinceSync()).toBeTruthy() 345 | }) 346 | 347 | describe("when undoing changes", function() { 348 | it("revert to the last saved state", function() { 349 | author.set({ name: 'Robb Stark' }) 350 | author.saveState() 351 | author.set({ name: 'Tyrion Lanninster' }) 352 | author.undo() 353 | expect(author.get('name')).toEqual('Robb Stark') 354 | }) 355 | }) 356 | }) 357 | }) 358 | 359 | describe("undoing a change", function() { 360 | describe("in its own attributes", function() { 361 | it("reverts the model own attributes to its original attributes", function() { 362 | post.set({ title: 'new title' }) 363 | post.undo() 364 | 365 | expect(post.toJSON()).toEqual(originalAttributes) 366 | }) 367 | }) 368 | 369 | describe("in a nested model", function() { 370 | it("reverts the model own attributes as well as the nested ones to its original attributes, creating a new model reference", function() { 371 | author.set({ name: 'Robb Stark' }) 372 | post.undo() 373 | 374 | expect(post.toJSON()).toEqual(originalAttributes) 375 | expect(post.get('author')).not.toEqual(author) 376 | }) 377 | }) 378 | }) 379 | 380 | describe("undoing a nested model", function() { 381 | it("does not trigger a state:restore event on the parent model", function() { 382 | post.on('state:restore', function () { 383 | called = true 384 | }) 385 | 386 | author.undo() 387 | expect(called).toBeFalsy() 388 | }) 389 | 390 | it("trigger a state:restore event on the nested model", function() { 391 | author.on('state:restore', function () { 392 | called = true 393 | }) 394 | 395 | author.undo() 396 | expect(called).toBeTruthy() 397 | }) 398 | }) 399 | }) 400 | 401 | describe("when it has a has many relationship", function() { 402 | beforeEach(function() { 403 | Post = Backbone.UndoableModel.extend({ 404 | relations: [ 405 | { 406 | key: 'comments', 407 | relatedModel: function () { return Comment } 408 | } 409 | ] 410 | }) 411 | 412 | Comment = Backbone.UndoableModel.extend({}) 413 | 414 | originalAttributes = { title: 'some title', comments: [ { body: 'some body' } ] } 415 | post = new Post(_(originalAttributes).clone()) 416 | comments = post.get('comments') 417 | comment = comments.at(0) 418 | }) 419 | 420 | describe("and a nested model changes", function() { 421 | it("should have changes since sync", function() { 422 | comment.set({ body: 'new body' }) 423 | expect(comment.hasChangedSinceSync()).toBeTruthy() 424 | }) 425 | 426 | it("its parent should have changes since sync", function() { 427 | comment.set({ body: 'new body' }) 428 | expect(post.hasChangedSinceSync()).toBeTruthy() 429 | }) 430 | 431 | describe("and the nested model state is saved", function() { 432 | it("should not have changes since sync", function() { 433 | comment.set({ body: 'new body' }) 434 | comment.saveState() 435 | expect(comment.hasChangedSinceSync()).toBeFalsy() 436 | }) 437 | 438 | it("its parent should have changes since sync", function() { 439 | comment.set({ body: 'new body' }) 440 | comment.saveState() 441 | expect(post.hasChangedSinceSync()).toBeTruthy() 442 | }) 443 | 444 | describe("when undoing changes", function() { 445 | it("revert to the last saved state", function() { 446 | comment.set({ body: 'new body' }) 447 | comment.saveState() 448 | comment.set({ body: 'other body' }) 449 | comment.undo() 450 | expect(comment.get('body')).toEqual('new body') 451 | }) 452 | }) 453 | }) 454 | }) 455 | 456 | describe("undoing a change", function() { 457 | describe("in its own attributes", function() { 458 | it("reverts the model own attributes to its original attributes", function() { 459 | post.set({ title: 'new title' }) 460 | post.undo() 461 | 462 | expect(post.toJSON()).toEqual(originalAttributes) 463 | }) 464 | }) 465 | 466 | describe("in a nested model", function() { 467 | it("reverts the model own attributes as well as the nested ones to its original attributes, keeping the existing collection untouched", function() { 468 | comment.set({ body: 'new body' }) 469 | post.undo() 470 | 471 | expect(post.toJSON()).toEqual(originalAttributes) 472 | expect(post.get('comments')).toEqual(comments) 473 | }) 474 | }) 475 | }) 476 | 477 | describe("undoing a nested model", function() { 478 | it("does not trigger a state:restore event on the parent model", function() { 479 | post.on('state:restore', function () { 480 | called = true 481 | }) 482 | 483 | comment.undo() 484 | expect(called).toBeFalsy() 485 | }) 486 | 487 | it("trigger a state:restore event on the nested model", function() { 488 | comment.on('state:restore', function () { 489 | called = true 490 | }) 491 | 492 | comment.undo() 493 | expect(called).toBeTruthy() 494 | }) 495 | }) 496 | 497 | 498 | describe("when undoing changes after a nested model had been removed", function() { 499 | beforeEach(function() { 500 | originalAttributes = { id: 321, title: 'some title', comments: [ { id: 123, body: 'some body' } ] } 501 | post = new Post(_(originalAttributes).clone()) 502 | comments = post.get('comments') 503 | comment = comments.at(0) 504 | 505 | comments.remove(comment) 506 | }) 507 | 508 | it("undoes the deletedModels in the relation collection as well", function() { 509 | post.undo() 510 | expect(comments.deletedModels.length).toEqual(0) 511 | }) 512 | 513 | describe("and the state saved", function() { 514 | beforeEach(function() { 515 | post.saveState() 516 | }) 517 | 518 | it("keeps the deletedModels in the relation collection", function() { 519 | post.undo() 520 | expect(comments.deletedModels.at(0)).toBe(comment) 521 | }) 522 | }) 523 | }) 524 | 525 | describe("when undoing changes after a undo", function() { 526 | it("reverts the changes properly", function() { 527 | post.set({ comments: [{ id: 123, body: 'first body' }] }) 528 | post.undo() 529 | post.set({ comments: [{ id: 123, body: 'last body' }] }) 530 | post.undo() 531 | expect(post.get('comments').at(0).get('body')).toEqual('some body') 532 | }) 533 | 534 | describe("on a deep nested model", function() { 535 | var Tag, tag 536 | 537 | beforeEach(function() { 538 | Comment = Backbone.UndoableModel.extend({ 539 | relations: [ 540 | { 541 | key: 'tags', 542 | relatedModel: function () { return Tag } 543 | } 544 | ] 545 | }) 546 | 547 | Tag = Backbone.UndoableModel.extend({}) 548 | 549 | originalAttributes = { id: 321, title: 'some title', comments: [ { id: 123, body: 'some body', tags: [ { id: 456, name: 'javascript' } ] } ] } 550 | post = new Post(originalAttributes) 551 | }) 552 | 553 | it("reverts the changes properly", function() { 554 | post.set({ comments: [ { id: 123, body: 'some body', tags: [ { id: 456, name: 'ruby' } ] } ] }) 555 | post.undo() 556 | post.set({ comments: [ { id: 123, body: 'some body', tags: [ { id: 456, name: 'python' } ] } ] }) 557 | post.undo() 558 | 559 | expect(post.get('comments').at(0).get('tags').at(0).get('name')).toEqual('javascript') 560 | }) 561 | }) 562 | }) 563 | }) 564 | }) -------------------------------------------------------------------------------- /spec/javascripts/helpers/SpecHelper.js: -------------------------------------------------------------------------------- 1 | beforeEach(function() { 2 | this.addMatchers({ 3 | toBeAnInstanceOf: function (expected) { 4 | return this.actual instanceof expected 5 | } 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /spec/javascripts/helpers/mock-ajax.js: -------------------------------------------------------------------------------- 1 | /* 2 | Jasmine-Ajax : a set of helpers for testing AJAX requests under the Jasmine 3 | BDD framework for JavaScript. 4 | 5 | Supports both Prototype.js and jQuery. 6 | 7 | http://github.com/pivotal/jasmine-ajax 8 | 9 | Jasmine Home page: http://pivotal.github.com/jasmine 10 | 11 | Copyright (c) 2008-2010 Pivotal Labs 12 | 13 | Permission is hereby granted, free of charge, to any person obtaining 14 | a copy of this software and associated documentation files (the 15 | "Software"), to deal in the Software without restriction, including 16 | without limitation the rights to use, copy, modify, merge, publish, 17 | distribute, sublicense, and/or sell copies of the Software, and to 18 | permit persons to whom the Software is furnished to do so, subject to 19 | the following conditions: 20 | 21 | The above copyright notice and this permission notice shall be 22 | included in all copies or substantial portions of the Software. 23 | 24 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 25 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 26 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 27 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 28 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 29 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 30 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 31 | 32 | */ 33 | 34 | // Jasmine-Ajax interface 35 | var ajaxRequests = []; 36 | 37 | function mostRecentAjaxRequest() { 38 | if (ajaxRequests.length > 0) { 39 | return ajaxRequests[ajaxRequests.length - 1]; 40 | } else { 41 | return null; 42 | } 43 | } 44 | 45 | function clearAjaxRequests() { 46 | ajaxRequests = []; 47 | } 48 | 49 | // Fake XHR for mocking Ajax Requests & Responses 50 | function FakeXMLHttpRequest() { 51 | var extend = Object.extend || $.extend; 52 | extend(this, { 53 | requestHeaders: {}, 54 | 55 | open: function() { 56 | this.method = arguments[0]; 57 | this.url = arguments[1]; 58 | this.readyState = 1; 59 | }, 60 | 61 | setRequestHeader: function(header, value) { 62 | this.requestHeaders[header] = value; 63 | }, 64 | 65 | abort: function() { 66 | this.readyState = 0; 67 | }, 68 | 69 | readyState: 0, 70 | 71 | onreadystatechange: function(isTimeout) { 72 | }, 73 | 74 | status: null, 75 | 76 | send: function(data) { 77 | this.params = data; 78 | this.readyState = 2; 79 | }, 80 | 81 | getResponseHeader: function(name) { 82 | return this.responseHeaders[name]; 83 | }, 84 | 85 | getAllResponseHeaders: function() { 86 | var responseHeaders = []; 87 | for (var i in this.responseHeaders) { 88 | if (this.responseHeaders.hasOwnProperty(i)) { 89 | responseHeaders.push(i + ': ' + this.responseHeaders[i]); 90 | } 91 | } 92 | return responseHeaders.join('\r\n'); 93 | }, 94 | 95 | responseText: null, 96 | 97 | response: function(response) { 98 | this.status = response.status; 99 | this.responseText = response.responseText || ""; 100 | this.readyState = 4; 101 | this.responseHeaders = response.responseHeaders || 102 | {"Content-type": response.contentType || "application/json" }; 103 | // uncomment for jquery 1.3.x support 104 | // jasmine.Clock.tick(20); 105 | 106 | this.onreadystatechange(); 107 | }, 108 | responseTimeout: function() { 109 | this.readyState = 4; 110 | jasmine.Clock.tick(jQuery.ajaxSettings.timeout || 30000); 111 | this.onreadystatechange('timeout'); 112 | } 113 | }); 114 | 115 | return this; 116 | } 117 | 118 | 119 | jasmine.Ajax = { 120 | 121 | isInstalled: function() { 122 | return jasmine.Ajax.installed == true; 123 | }, 124 | 125 | assertInstalled: function() { 126 | if (!jasmine.Ajax.isInstalled()) { 127 | throw new Error("Mock ajax is not installed, use jasmine.Ajax.useMock()") 128 | } 129 | }, 130 | 131 | useMock: function() { 132 | if (!jasmine.Ajax.isInstalled()) { 133 | var spec = jasmine.getEnv().currentSpec; 134 | spec.after(jasmine.Ajax.uninstallMock); 135 | 136 | jasmine.Ajax.installMock(); 137 | } 138 | }, 139 | 140 | installMock: function() { 141 | if (typeof jQuery != 'undefined') { 142 | jasmine.Ajax.installJquery(); 143 | } else if (typeof Prototype != 'undefined') { 144 | jasmine.Ajax.installPrototype(); 145 | } else { 146 | throw new Error("jasmine.Ajax currently only supports jQuery and Prototype"); 147 | } 148 | jasmine.Ajax.installed = true; 149 | }, 150 | 151 | installJquery: function() { 152 | jasmine.Ajax.mode = 'jQuery'; 153 | jasmine.Ajax.real = jQuery.ajaxSettings.xhr; 154 | jQuery.ajaxSettings.xhr = jasmine.Ajax.jQueryMock; 155 | 156 | }, 157 | 158 | installPrototype: function() { 159 | jasmine.Ajax.mode = 'Prototype'; 160 | jasmine.Ajax.real = Ajax.getTransport; 161 | 162 | Ajax.getTransport = jasmine.Ajax.prototypeMock; 163 | }, 164 | 165 | uninstallMock: function() { 166 | jasmine.Ajax.assertInstalled(); 167 | if (jasmine.Ajax.mode == 'jQuery') { 168 | jQuery.ajaxSettings.xhr = jasmine.Ajax.real; 169 | } else if (jasmine.Ajax.mode == 'Prototype') { 170 | Ajax.getTransport = jasmine.Ajax.real; 171 | } 172 | jasmine.Ajax.reset(); 173 | }, 174 | 175 | reset: function() { 176 | jasmine.Ajax.installed = false; 177 | jasmine.Ajax.mode = null; 178 | jasmine.Ajax.real = null; 179 | }, 180 | 181 | jQueryMock: function() { 182 | var newXhr = new FakeXMLHttpRequest(); 183 | ajaxRequests.push(newXhr); 184 | return newXhr; 185 | }, 186 | 187 | prototypeMock: function() { 188 | return new FakeXMLHttpRequest(); 189 | }, 190 | 191 | installed: false, 192 | mode: null 193 | } 194 | 195 | 196 | // Jasmine-Ajax Glue code for Prototype.js 197 | if (typeof Prototype != 'undefined' && Ajax && Ajax.Request) { 198 | Ajax.Request.prototype.originalRequest = Ajax.Request.prototype.request; 199 | Ajax.Request.prototype.request = function(url) { 200 | this.originalRequest(url); 201 | ajaxRequests.push(this); 202 | }; 203 | 204 | Ajax.Request.prototype.response = function(responseOptions) { 205 | return this.transport.response(responseOptions); 206 | }; 207 | } -------------------------------------------------------------------------------- /spec/javascripts/support/jasmine.yml: -------------------------------------------------------------------------------- 1 | # src_files 2 | # 3 | # Return an array of filepaths relative to src_dir to include before jasmine specs. 4 | # Default: [] 5 | # 6 | # EXAMPLE: 7 | # 8 | # src_files: 9 | # - lib/source1.js 10 | # - lib/source2.js 11 | # - dist/**/*.js 12 | # 13 | src_files: 14 | - assets/application.js 15 | 16 | # stylesheets 17 | # 18 | # Return an array of stylesheet filepaths relative to src_dir to include before jasmine specs. 19 | # Default: [] 20 | # 21 | # EXAMPLE: 22 | # 23 | # stylesheets: 24 | # - css/style.css 25 | # - stylesheets/*.css 26 | # 27 | stylesheets: 28 | - assets/application.css 29 | 30 | # helpers 31 | # 32 | # Return an array of filepaths relative to spec_dir to include before jasmine specs. 33 | # Default: ["helpers/**/*.js"] 34 | # 35 | # EXAMPLE: 36 | # 37 | # helpers: 38 | # - helpers/**/*.js 39 | # 40 | helpers: 41 | - helpers/**/*.js 42 | 43 | # spec_files 44 | # 45 | # Return an array of filepaths relative to spec_dir to include. 46 | # Default: ["**/*[sS]pec.js"] 47 | # 48 | # EXAMPLE: 49 | # 50 | # spec_files: 51 | # - **/*[sS]pec.js 52 | # 53 | spec_files: 54 | - '**/*[sS]pec.js' 55 | 56 | # src_dir 57 | # 58 | # Source directory path. Your src_files must be returned relative to this path. Will use root if left blank. 59 | # Default: project root 60 | # 61 | # EXAMPLE: 62 | # 63 | # src_dir: public 64 | # 65 | src_dir: 66 | 67 | # spec_dir 68 | # 69 | # Spec directory path. Your spec_files must be returned relative to this path. 70 | # Default: spec/javascripts 71 | # 72 | # EXAMPLE: 73 | # 74 | # spec_dir: spec/javascripts 75 | # 76 | spec_dir: spec/javascripts 77 | --------------------------------------------------------------------------------