├── .gitignore ├── .project ├── .pydevproject ├── LICENSE ├── README.md ├── app.js ├── log └── PLACEHOLDER ├── models.js ├── package.json ├── static ├── css │ └── all.css ├── img │ ├── green_bkg.png │ ├── shadow_left.png │ └── shadow_right.png ├── index.html └── js │ ├── dist │ ├── backbone-0.9.1.js │ ├── backbone-0.9.1.min.js │ ├── backbone-relational-0.5.0.js │ ├── handlebars-1.0.0.beta.6.js │ ├── jquery-1.7.1.js │ ├── jquery-1.7.1.min.js │ ├── underscore-1.3.1.js │ └── underscore-1.3.1.min.js │ └── forum.js └── tutorial ├── css ├── highlight.css └── markdown.css ├── header.html ├── img ├── screenshot1.png └── screenshot2.png ├── js └── highlight.pack.js ├── publish.sh ├── tutorial.html └── tutorial.txt /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | server.log 3 | tutorial/_tutorial.html 4 | *~ 5 | -------------------------------------------------------------------------------- /.project: -------------------------------------------------------------------------------- 1 | 2 | 3 | Backbone-relational Tutorial 4 | 5 | 6 | 7 | 8 | 9 | org.python.pydev.PyDevBuilder 10 | 11 | 12 | 13 | 14 | 15 | org.python.pydev.pythonNature 16 | 17 | 18 | -------------------------------------------------------------------------------- /.pydevproject: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Default 6 | python 2.7 7 | 8 | /Backbone-relational Tutorial 9 | 10 | 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Backbone-relational Tutorial - Nested Models With Backbone.js 2 | ============================================================= 3 | 4 | Introduction 5 | ------------ 6 | 7 | ### Backbone.js 8 | 9 | Backbone.js allows to implement the whole MVC pattern on the client, leaving the server to do what he knows best: exposing a set of well-defined REST interfaces, which the client queries when he needs to fetch or update some information. No need to split the HTML rendering between the server templates and the client-side javascript. 10 | 11 | It's not only cleaner, it's also an excellent architecture to make responsive applications. Less information needs to be exchanged with the server - the formatting (views and controllers) being on the client, you only need to exchange the data being manipulated on the REST interfaces. 12 | 13 | No full page reload - the server sends a static HTML file upon the first request, then the JS client handles the interaction with the user, only remodeling the portions of the DOM that changes between pages. And, better still, Backbone.js takes care of a large part of the work required to synchronize data with the server. Sweet! 14 | 15 | ### Backbone-relational 16 | 17 | However, when I recently started to learn about Backbone, I realized it doesn't help to handle relationships between models. Most non-trivial applications need this - forum threads each have a series of comments, billing invoices have several items to charge for... 18 | 19 | If you're reading this, you've probably found out about backbone-relational after reading a few threads. But the documentation is sparse, and it's hard to see how to use it practically. How do you structure your views to represent the relations between models? How do you update or push relational models to their corresponding REST APIs? 20 | 21 | [Read the full tutorial - Backbone-relational, Nested Models With Backbone.js][Tutorial] 22 | 23 | [Tutorial]: http://antoviaque.org/docs/tutorials/backbone-relational-tutorial/ 24 | 25 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2012 Xavier Antoviaque 3 | * 4 | * This software's license gives you freedom; you can copy, convey, 5 | * propagate, redistribute and/or modify this program under the terms of 6 | * the GNU Affero Gereral Public License (AGPL) as published by the Free 7 | * Software Foundation (FSF), either version 3 of the License, or (at your 8 | * option) any later version of the AGPL published by the FSF. 9 | * 10 | * This program is distributed in the hope that it will be useful, but 11 | * WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 13 | * General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU Affero General Public License 16 | * along with this program in a file in the toplevel directory called 17 | * "AGPLv3". If not, see . 18 | */ 19 | 20 | // Imports ///////////////////////////////////////////////////// 21 | 22 | var restify = require('restify') 23 | , Logger = require('bunyan') 24 | , mime = require('mime') 25 | , path = require('path') 26 | , filed = require('filed'); 27 | 28 | 29 | // Database //////////////////////////////////////////////////// 30 | 31 | var mongoose = require('mongoose') 32 | , db = mongoose.connect('mongodb://localhost/forum') 33 | , Thread = require('./models.js').Thread(db) 34 | , Message = require('./models.js').Message(db); 35 | 36 | // Views /////////////////////////////////////////////////////// 37 | 38 | function get_thread(req, res, next) { 39 | var send_result = function(err, thread_list) { 40 | if (err) { 41 | return next(err); 42 | } 43 | 44 | if(thread_list) { 45 | if(thread_list.messages) { 46 | thread_list.messages.sort(function(a, b) { 47 | return ((a.date_added < b.date_added) ? -1 : (a.date_added > b.date_added) ? 1 : 0); 48 | }); 49 | } 50 | res.send(thread_list); 51 | return next(); 52 | } else { 53 | return next(new restify.ResourceNotFoundError("Could not find any such thread")); 54 | } 55 | }; 56 | 57 | if('_id' in req.params) { 58 | Thread.findOne({'_id': req.params._id}, send_result); 59 | } else { 60 | Thread.find({}, send_result); 61 | } 62 | } 63 | 64 | function post_thread(req, res, next) { 65 | if(!req.body.title) { 66 | return next(new restify.MissingParameterError("Missing required thread or message attribute in request body")); 67 | } 68 | 69 | new_thread = new Thread({title: req.body.title}); 70 | new_thread.save(); 71 | res.send(new_thread); 72 | 73 | return next(); 74 | } 75 | 76 | function post_message(req, res, next) { 77 | if(!req.body.author || !req.body.text || !req.body.thread) { 78 | return next(new restify.MissingParameterError("Missing required message attribute in request body")); 79 | } 80 | 81 | Thread.findOne({_id: req.body.thread}, function(err, thread) { 82 | if (err) { 83 | return next(err); 84 | } else if(!thread) { 85 | return next(new restify.ResourceNotFoundError("Could not find thread with id="+req.body.thread)); 86 | } 87 | 88 | new_message = new Message({author: req.body.author, 89 | text: req.body.text, 90 | date_added: new Date()}); 91 | new_message.save(); 92 | thread.messages.push(new_message); 93 | thread.save(); 94 | 95 | res.send(new_message); 96 | return next(); 97 | }) 98 | } 99 | 100 | 101 | // Server ///////////////////////////////////////////////////// 102 | 103 | var server = restify.createServer(); 104 | 105 | server.use(restify.acceptParser(server.acceptable)) 106 | .use(restify.authorizationParser()) 107 | .use(restify.dateParser()) 108 | .use(restify.queryParser({ mapParams: false })) 109 | .use(restify.bodyParser({ mapParams: false })) 110 | .use(restify.throttle({ 111 | burst: 10, 112 | rate: 1, 113 | ip: false, 114 | xff: true, 115 | })); 116 | 117 | // Logging 118 | server.on('after', restify.auditLogger({ 119 | log: new Logger({ 120 | name: 'mok', 121 | streams: [{ level: "info", stream: process.stdout }, 122 | { level: "info", path: 'log/server.log' }], 123 | }) 124 | })); 125 | 126 | 127 | // Routes ///////////////////////////////////////////////////// 128 | 129 | // Thread 130 | server.get('/api/thread/', get_thread); 131 | server.get('/api/thread/:_id', get_thread); 132 | server.post('/api/thread/', post_thread); 133 | 134 | // Message 135 | server.post('/api/message/', post_message); 136 | 137 | // Static Content ///////////////////////////////////////////// 138 | 139 | server.get(/\/(css|img|js)?.*/, restify.serveStatic({ 140 | directory: './static', 141 | default: 'index.html' 142 | })); 143 | 144 | server.get('/', restify.serveStatic({ 145 | directory: './static', 146 | default: 'index.html' 147 | })); 148 | 149 | server.listen(3001, function() { 150 | console.log('%s listening at %s', server.name, server.url); 151 | }); 152 | 153 | -------------------------------------------------------------------------------- /log/PLACEHOLDER: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoviaque/backbone-relational-tutorial/f9fe7f4b4d50510fd815ac92d5dd6fc1ae043d4f/log/PLACEHOLDER -------------------------------------------------------------------------------- /models.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var mongoose = require('mongoose') 7 | , Schema = mongoose.Schema; 8 | 9 | /** 10 | * Schema definition 11 | */ 12 | 13 | var Message = new Schema(); 14 | Message.add({ 15 | date_added : Date 16 | , author : String 17 | , text : String 18 | }); 19 | 20 | var Thread = new Schema(); 21 | Thread.add({ 22 | title : String 23 | , messages : [Message] 24 | }); 25 | 26 | 27 | /** 28 | * Models 29 | */ 30 | 31 | mongoose.model('Thread', Thread); 32 | exports.Thread = function(db) { 33 | return db.model('Thread'); 34 | }; 35 | 36 | mongoose.model('Message', Message); 37 | exports.Message = function(db) { 38 | return db.model('Message'); 39 | }; 40 | 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name":"backbone-relational-tutorial", 3 | "version":"0.0.2", 4 | "dependencies":{ 5 | "express":"*", 6 | "mongoose":"*", 7 | "bunyan":"*", 8 | "restify":"*", 9 | "mime":"*", 10 | "path":"*", 11 | "filed":"*", 12 | "markdown":"*" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /static/css/all.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2012 Xavier Antoviaque 3 | * 4 | * This software's license gives you freedom; you can copy, convey, 5 | * propagate, redistribute and/or modify this program under the terms of 6 | * the GNU Affero Gereral Public License (AGPL) as published by the Free 7 | * Software Foundation (FSF), either version 3 of the License, or (at your 8 | * option) any later version of the AGPL published by the FSF. 9 | * 10 | * This program is distributed in the hope that it will be useful, but 11 | * WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 13 | * General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU Affero General Public License 16 | * along with this program in a file in the toplevel directory called 17 | * "AGPLv3". If not, see . 18 | */ 19 | 20 | /* Global **********************************************************/ 21 | 22 | html, body { 23 | font-family: 'Helvetica'; 24 | font-size: 12px; 25 | line-height: 130%; 26 | color: #B9C2A8; 27 | padding: 0; 28 | margin: 0; 29 | background-color: #d2543b; 30 | } 31 | 32 | 33 | a { 34 | color: #B9C2A8; 35 | } 36 | 37 | .container { 38 | width: 800px; 39 | position: relative; 40 | left: 50%; 41 | margin-left: -400px; 42 | } 43 | 44 | .shadow { 45 | position: absolute; 46 | height: 100%; 47 | width: 21px; 48 | } 49 | 50 | .shadow.left { 51 | background: transparent url('../img/shadow_left.png') repeat left top; 52 | top: 0; 53 | left: 0; 54 | margin-left: -21px; 55 | } 56 | 57 | .shadow.right { 58 | background: transparent url('../img/shadow_right.png') repeat right top; 59 | top: 0; 60 | right: 0; 61 | margin-right: -21px; 62 | } 63 | 64 | .main { 65 | padding: 20px; 66 | background-image: url('../img/green_bkg.png'); 67 | } 68 | 69 | .centered { 70 | text-align: center; 71 | } 72 | 73 | ul { 74 | list-style-type: none; 75 | } 76 | 77 | .thread_list .thread_summary_view { 78 | font-size: 15px; 79 | font-weight: bold; 80 | padding: 10px; 81 | border-bottom: 1px solid; 82 | border-color: grey; 83 | cursor: pointer; 84 | margin: 10px; 85 | background-color: #6c8738; 86 | -webkit-border-radius: 10px; 87 | -moz-border-radius: 10px; 88 | -ms-border-radius: 10px; 89 | -o-border-radius: 10px; 90 | border-radius: 10px; 91 | } 92 | 93 | .thread_list .thread_summary_view .thread_title { 94 | font-weight: bold; 95 | } 96 | 97 | .thread_list .thread_summary_view .thread_nb_messages { 98 | float: right; 99 | } 100 | 101 | input[type=text] { 102 | padding: 5px; 103 | width: 400px; 104 | margin: 5px; 105 | } 106 | 107 | textarea { 108 | width: 400px; 109 | height: 200px; 110 | margin: 5px; 111 | } 112 | 113 | .message_author { 114 | font-weight: bold; 115 | padding: 10px 0 0 5px; 116 | } 117 | 118 | .message_text { 119 | font-size: 14px; 120 | padding: 20px; 121 | } 122 | 123 | .message { 124 | margin: 20px; 125 | background-color: #6c8738; 126 | -webkit-border-radius: 10px; 127 | -moz-border-radius: 10px; 128 | -ms-border-radius: 10px; 129 | -o-border-radius: 10px; 130 | border-radius: 10px; 131 | } 132 | -------------------------------------------------------------------------------- /static/img/green_bkg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoviaque/backbone-relational-tutorial/f9fe7f4b4d50510fd815ac92d5dd6fc1ae043d4f/static/img/green_bkg.png -------------------------------------------------------------------------------- /static/img/shadow_left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoviaque/backbone-relational-tutorial/f9fe7f4b4d50510fd815ac92d5dd6fc1ae043d4f/static/img/shadow_left.png -------------------------------------------------------------------------------- /static/img/shadow_right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antoviaque/backbone-relational-tutorial/f9fe7f4b4d50510fd815ac92d5dd6fc1ae043d4f/static/img/shadow_right.png -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sample forum 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |
14 |

Forum

15 |
16 |
17 |
18 |
19 | 20 | 21 | 22 | 36 | 37 | 41 | 42 | 56 | 57 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /static/js/dist/backbone-0.9.1.min.js: -------------------------------------------------------------------------------- 1 | // Backbone.js 0.9.1 2 | 3 | // (c) 2010-2012 Jeremy Ashkenas, DocumentCloud Inc. 4 | // Backbone may be freely distributed under the MIT license. 5 | // For all details and documentation: 6 | // http://backbonejs.org 7 | (function(){var i=this,r=i.Backbone,s=Array.prototype.slice,t=Array.prototype.splice,g;g="undefined"!==typeof exports?exports:i.Backbone={};g.VERSION="0.9.1";var f=i._;!f&&"undefined"!==typeof require&&(f=require("underscore"));var h=i.jQuery||i.Zepto||i.ender;g.setDomLibrary=function(a){h=a};g.noConflict=function(){i.Backbone=r;return this};g.emulateHTTP=!1;g.emulateJSON=!1;g.Events={on:function(a,b,c){for(var d,a=a.split(/\s+/),e=this._callbacks||(this._callbacks={});d=a.shift();){d=e[d]||(e[d]= 8 | {});var f=d.tail||(d.tail=d.next={});f.callback=b;f.context=c;d.tail=f.next={}}return this},off:function(a,b,c){var d,e,f;if(a){if(e=this._callbacks)for(a=a.split(/\s+/);d=a.shift();)if(f=e[d],delete e[d],b&&f)for(;(f=f.next)&&f.next;)if(!(f.callback===b&&(!c||f.context===c)))this.on(d,f.callback,f.context)}else delete this._callbacks;return this},trigger:function(a){var b,c,d,e;if(!(d=this._callbacks))return this;e=d.all;for((a=a.split(/\s+/)).push(null);b=a.shift();)e&&a.push({next:e.next,tail:e.tail, 9 | event:b}),(c=d[b])&&a.push({next:c.next,tail:c.tail});for(e=s.call(arguments,1);c=a.pop();){b=c.tail;for(d=c.event?[c.event].concat(e):e;(c=c.next)!==b;)c.callback.apply(c.context||this,d)}return this}};g.Events.bind=g.Events.on;g.Events.unbind=g.Events.off;g.Model=function(a,b){var c;a||(a={});b&&b.parse&&(a=this.parse(a));if(c=j(this,"defaults"))a=f.extend({},c,a);b&&b.collection&&(this.collection=b.collection);this.attributes={};this._escapedAttributes={};this.cid=f.uniqueId("c");if(!this.set(a, 10 | {silent:!0}))throw Error("Can't create an invalid model");delete this._changed;this._previousAttributes=f.clone(this.attributes);this.initialize.apply(this,arguments)};f.extend(g.Model.prototype,g.Events,{idAttribute:"id",initialize:function(){},toJSON:function(){return f.clone(this.attributes)},get:function(a){return this.attributes[a]},escape:function(a){var b;if(b=this._escapedAttributes[a])return b;b=this.attributes[a];return this._escapedAttributes[a]=f.escape(null==b?"":""+b)},has:function(a){return null!= 11 | this.attributes[a]},set:function(a,b,c){var d,e;f.isObject(a)||null==a?(d=a,c=b):(d={},d[a]=b);c||(c={});if(!d)return this;d instanceof g.Model&&(d=d.attributes);if(c.unset)for(e in d)d[e]=void 0;if(!this._validate(d,c))return!1;this.idAttribute in d&&(this.id=d[this.idAttribute]);var b=this.attributes,k=this._escapedAttributes,n=this._previousAttributes||{},h=this._setting;this._changed||(this._changed={});this._setting=!0;for(e in d)if(a=d[e],f.isEqual(b[e],a)||delete k[e],c.unset?delete b[e]:b[e]= 12 | a,this._changing&&!f.isEqual(this._changed[e],a)&&(this.trigger("change:"+e,this,a,c),this._moreChanges=!0),delete this._changed[e],!f.isEqual(n[e],a)||f.has(b,e)!=f.has(n,e))this._changed[e]=a;h||(!c.silent&&this.hasChanged()&&this.change(c),this._setting=!1);return this},unset:function(a,b){(b||(b={})).unset=!0;return this.set(a,null,b)},clear:function(a){(a||(a={})).unset=!0;return this.set(f.clone(this.attributes),a)},fetch:function(a){var a=a?f.clone(a):{},b=this,c=a.success;a.success=function(d, 13 | e,f){if(!b.set(b.parse(d,f),a))return!1;c&&c(b,d)};a.error=g.wrapError(a.error,b,a);return(this.sync||g.sync).call(this,"read",this,a)},save:function(a,b,c){var d,e;f.isObject(a)||null==a?(d=a,c=b):(d={},d[a]=b);c=c?f.clone(c):{};c.wait&&(e=f.clone(this.attributes));a=f.extend({},c,{silent:!0});if(d&&!this.set(d,c.wait?a:c))return!1;var k=this,h=c.success;c.success=function(a,b,e){b=k.parse(a,e);c.wait&&(b=f.extend(d||{},b));if(!k.set(b,c))return!1;h?h(k,a):k.trigger("sync",k,a,c)};c.error=g.wrapError(c.error, 14 | k,c);b=this.isNew()?"create":"update";b=(this.sync||g.sync).call(this,b,this,c);c.wait&&this.set(e,a);return b},destroy:function(a){var a=a?f.clone(a):{},b=this,c=a.success,d=function(){b.trigger("destroy",b,b.collection,a)};if(this.isNew())return d();a.success=function(e){a.wait&&d();c?c(b,e):b.trigger("sync",b,e,a)};a.error=g.wrapError(a.error,b,a);var e=(this.sync||g.sync).call(this,"delete",this,a);a.wait||d();return e},url:function(){var a=j(this.collection,"url")||j(this,"urlRoot")||o();return this.isNew()? 15 | a:a+("/"==a.charAt(a.length-1)?"":"/")+encodeURIComponent(this.id)},parse:function(a){return a},clone:function(){return new this.constructor(this.attributes)},isNew:function(){return null==this.id},change:function(a){if(this._changing||!this.hasChanged())return this;this._moreChanges=this._changing=!0;for(var b in this._changed)this.trigger("change:"+b,this,this._changed[b],a);for(;this._moreChanges;)this._moreChanges=!1,this.trigger("change",this,a);this._previousAttributes=f.clone(this.attributes); 16 | delete this._changed;this._changing=!1;return this},hasChanged:function(a){return!arguments.length?!f.isEmpty(this._changed):this._changed&&f.has(this._changed,a)},changedAttributes:function(a){if(!a)return this.hasChanged()?f.clone(this._changed):!1;var b,c=!1,d=this._previousAttributes,e;for(e in a)if(!f.isEqual(d[e],b=a[e]))(c||(c={}))[e]=b;return c},previous:function(a){return!arguments.length||!this._previousAttributes?null:this._previousAttributes[a]},previousAttributes:function(){return f.clone(this._previousAttributes)}, 17 | isValid:function(){return!this.validate(this.attributes)},_validate:function(a,b){if(b.silent||!this.validate)return!0;var a=f.extend({},this.attributes,a),c=this.validate(a,b);if(!c)return!0;b&&b.error?b.error(this,c,b):this.trigger("error",this,c,b);return!1}});g.Collection=function(a,b){b||(b={});b.comparator&&(this.comparator=b.comparator);this._reset();this.initialize.apply(this,arguments);a&&this.reset(a,{silent:!0,parse:b.parse})};f.extend(g.Collection.prototype,g.Events,{model:g.Model,initialize:function(){}, 18 | toJSON:function(){return this.map(function(a){return a.toJSON()})},add:function(a,b){var c,d,e,g,h,i={},j={};b||(b={});a=f.isArray(a)?a.slice():[a];for(c=0,d=a.length;c=b))this.iframe=h('