diff --git a/.gitignore b/.gitignore index e7512ed..d1ac9ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ dist/ test/coverage/ node_modules/ -bower_components/ \ No newline at end of file +bower_components/ +test/coverage diff --git a/Gruntfile.js b/Gruntfile.js index 0f95017..a23b837 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -6,8 +6,8 @@ module.exports = function(grunt) { grunt.initConfig({ concat: { dist: { - src: ['src/backfire.js'], - dest: 'dist/backfire.js' + src: ['src/backbonefire.js'], + dest: 'dist/backbonefire.js' } }, @@ -17,7 +17,7 @@ module.exports = function(grunt) { }, app : { files : { - 'dist/backfire.min.js' : ['src/backfire.js'] + 'dist/backbonefire.min.js' : ['src/backbonefire.js'] } } }, @@ -44,12 +44,12 @@ module.exports = function(grunt) { 'unused' : true, 'trailing' : true }, - all : ['src/backfire.js'] + all : ['src/backbonefire.js'] }, watch : { scripts : { - files : 'src/backfire.js', + files : 'src/backbonefire.js', tasks : ['default', 'notify:watch'], options : { interrupt : true @@ -75,7 +75,24 @@ module.exports = function(grunt) { autowatch: false, singleRun: true } + }, + + copy: { + main: { + src: 'src/backbonefire.js', + dest: 'examples/todos/js/backbonefire.js', + }, + }, + + serve: { + options: { + port: 9000, + 'serve': { + 'path': 'examples/todos' + } + } } + }); grunt.loadNpmTasks('grunt-contrib-concat'); @@ -84,10 +101,13 @@ module.exports = function(grunt) { grunt.loadNpmTasks('grunt-contrib-watch'); grunt.loadNpmTasks('grunt-notify'); grunt.loadNpmTasks('grunt-karma'); + grunt.loadNpmTasks('grunt-copy'); + grunt.loadNpmTasks('grunt-serve'); // Unit tests grunt.registerTask('test', ['karma:unit']); grunt.registerTask('build', ['jshint', 'concat', 'uglify']); grunt.registerTask('default', ['build', 'test']); + grunt.registerTask('todo', ['build', 'serve']); }; diff --git a/README.md b/README.md index 12a3fb0..191d93b 100644 --- a/README.md +++ b/README.md @@ -1,165 +1,144 @@ -# BackFire +# BackboneFire -[![Build Status](https://travis-ci.org/firebase/backfire.svg?branch=master)](https://travis-ci.org/firebase/backfire) -[![Version](https://badge.fury.io/gh/firebase%2Fbackfire.svg?branch=master)](http://badge.fury.io/gh/firebase%2Fbackfire) - -BackFire is the officially supported [Backbone](http://backbonejs.org) binding for -[Firebase](http://www.firebase.com/?utm_medium=web&utm_source=backfire). The bindings let you use -special model and collection types that will automatically synchronize with Firebase, and also -allow you to use regular `Backbone.Sync` based synchronization methods. +[![Build Status](https://travis-ci.org/firebase/backbonefire.svg?branch=master)](https://travis-ci.org/firebase/backbonefire) +[![Version](https://badge.fury.io/gh/firebase%2Fbackbonefire.svg?branch=master)](http://badge.fury.io/gh/firebase%2Fbackbonefire) +BackboneFire is the officially supported [Backbone](http://backbonejs.org) binding for Firebase. The bindings let you use special model and collection types that allow for synchronizing data with [Firebase](http://www.firebase.com/?utm_medium=web&utm_source=backbonefire). ## Live Demo -Play around with our [realtime Todo App demo](https://backbonefire.firebaseapp.com) -which was created using BackFire. +Play around with our [realtime Todo App demo](https://backbonefire.firebaseapp.com/). This Todo App is a simple port of the TodoMVC app using BackboneFire. + +## Basic Usage +Using BackboneFire collections and models is very similar to the regular ones in Backbone. To setup with BackboneFire use `Backbone.Firebase` rather than just `Backbone`. + +```javascript +// This is a plain old Backbone Model +var Todo = Backbone.Model.extend({ + defaults: { + completed: false, + title: 'New todo' + } +}); +// This is a Firebase Collection that syncs data from this url +var Todos = Backbone.Firebase.Collection.extend({ + url: 'https://.firebaseio.com/todos', + model: Todo +}); +``` -## Downloading BackFire +## Downloading BackboneFire -In order to use BackFire in your project, you need to include the following files in your HTML: +To get started include Firebase and BackboneFire after the usual Backbone dependencies (jQuery, Underscore, and Backbone). ```html + + + + + + - + - + - - + + ``` -Use the URL above to download both the minified and non-minified versions of BackFire from the +Use the URL above to download both the minified and non-minified versions of BackboneFire from the Firebase CDN. You can also download them from the -[releases page of this GitHub repository](https://github.com/firebase/backfire/releases). -[Firebase](https://www.firebase.com/docs/web/quickstart.html?utm_medium=web&utm_source=backfire) and +[releases page of this GitHub repository](https://github.com/firebase/backbonefire/releases). +[Firebase](https://www.firebase.com/docs/web/quickstart.html?utm_medium=web&utm_source=backbonefire) and [Backbone](http://backbonejs.org/) can be downloaded directly from their respective websites. -You can also install BackFire via Bower and its dependencies will be downloaded automatically: +You can also install BackboneFire via Bower and its dependencies will be downloaded automatically: ```bash -$ bower install backfire --save +$ bower install backbonefire --save ``` -Once you've included BackFire and its dependencies into your project, you will have access to the -`Backbone.Firebase`, `Backbone.Firebase.Collection`, and `Backbone.Firebase.Model` objects. +Once you've included BackboneFire and its dependencies into your project, you will have access to the `Backbone.Firebase.Collection`, and `Backbone.Firebase.Model` objects. ## Getting Started with Firebase -BackFire requires Firebase in order to sync data. You can -[sign up here](https://www.firebase.com/signup/?utm_medium=web&utm_source=backfire) for a free +BackboneFire requires Firebase in order to sync data. You can +[sign up here](https://www.firebase.com/signup/?utm_medium=web&utm_source=backbonefire) for a free account. +## autoSync -## Backbone.Firebase - -The bindings also override `Backbone.sync` to use Firebase. You may consider this option if you -want to maintain an explicit seperation between _local_ and _remote_ data, and want to use regular -Backbone models and collections. - -This adapter works very similarly to the -[localStorage adapter](http://documentcloud.github.com/backbone/docs/backbone-localstorage.html) -used in the canonical Todos example. - -Please see [todos-sync.js](https://github.com/firebase/backfire/blob/gh-pages/examples/todos/todos-sync.js) -for an example of how to use this feature. +As of the 0.5 release there are two ways to sync `Models` and `Collections`. By specifying the property `autoSync` to either true of false, you can control whether the component is synced in realtime. The `autoSync` property is true by default. -### firebase - -You simply provide a `firebase` property in your collection, and that set of objects will be -persisted at that location. +#### autoSync: true ```javascript -var TodoList = Backbone.Collection.extend({ - model: Todo, - firebase: new Backbone.Firebase("https://.firebaseio.com") +var RealtimeList = Backbone.Firebase.Collection.extend({ + url: 'https://.firebaseio.com/todos', + autoSync: true // this is true by default +}) +// this collection will immediately begin syncing data +// no call to fetch is required, and any calls to fetch will be ignored +var realtimeList = new RealtimeList(); + +realtimeList.on('sync', function(collection) { + console.log('collection is loaded', collection); }); ``` -You can also do this with a model: +#### autoSync: false ```javascript -var MyTodo = Backbone.Model.extend({ - firebase: new Backbone.Firebase("https://.firebaseio.com/myTodo") +// This collection will remain empty until fetch is called +var OnetimeList = Backbone.Firebase.Collection.extend({ + url: 'https://.firebaseio.com/todos', + autoSync: false +}) +var onetimeList = new OnetimeList(); + +onetimeList.on('sync', function(collection) { + console.log('collection is loaded', collection); }); -``` - -### fetch() -In a collection with the `firebase` property defined, calling `fetch()` will retrieve data from -Firebase and update the collection with its contents. - -```javascript -TodoList.fetch(); -``` - -### sync() - -In a collection with the `firebase` property defined, calling `sync()` will set the contents of the -local collection to the specified Firebase location. - -```javascript -TodoList.sync(); -``` - -### save() - -In a model with the `firebase` property defined, calling `save()` will set the contents of the -model to the specified Firebase location. - -```javascript -MyTodo.save(); -``` - -### destroy() - -In a model with the `firebase` property defined, calling `destroy()` will remove the contents at -the specified Firebase location. - -```javascript -MyTodo.destroy(); +onetimeList.fetch(); ``` ## Backbone.Firebase.Collection This is a special collection object that will automatically synchronize its contents with Firebase. You may extend this object, and must provide a Firebase URL or a Firebase reference as the -`firebase` property. +`url` property. -Each model in the collection will be treated as a `Backbone.Firebase.Model` (see below). +Each model in the collection will have its own `firebase` property that is its reference in Firebase. -Please see [todos.js](https://github.com/firebase/backfire/blob/gh-pages/examples/todos/todos.js) -for an example of how to use this special collection object. +For a simple example of using `Backbone.Firebase.Collection` see [todos.js](). ```javascript var TodoList = Backbone.Firebase.Collection.extend({ model: Todo, - firebase: "https://.firebaseio.com" + url: 'https://.firebaseio.com/todos' }); ``` -You may also apply a `limit` or some other +You may also apply an `orderByChild` or some other [query](https://www.firebase.com/docs/web/guide/retrieving-data.html#section-queries) on a reference and pass it in: ```javascript -var Messages = Backbone.Firebase.Collection.extend({ - firebase: new Firebase("https://.firebaseio.com").limit(10) +var TodoList = Backbone.Firebase.Collection.extend({ + url: new Firebase('https://.firebaseio.com/todos').orderByChild('importance') }); ``` Any models added to the collection will be synchronized to the provided Firebase. Any other clients using the Backbone binding will also receive `add`, `remove` and `changed` events on the collection as appropriate. -**BE AWARE!** You do not need to call any functions that will affect _remote_ data. If you call -`fetch()` or `sync()` on the collection, **the library will ignore it silently**. - -```javascript -Messages.fetch(); // DOES NOTHING -Messages.sync(); // DOES NOTHING -``` +**BE AWARE!** If autoSync is set to true, you do not need to call any functions that will affect _remote_ data. If you call +`fetch()` on the collection, **the library will ignore it silently**. However, if autoSync is set to false, you can use `fetch()`. This is explained above in the autoSync section. You should add and remove your models to the collection as you normally would, (via `add()` and `remove()`) and _remote_ data will be instantly updated. Subsequently, the same events will fire on @@ -167,23 +146,34 @@ all your other clients immediately. ### add(model) -Adds a new model to the collection. This model will be synchronized to Firebase, triggering an -`add` event both locally and on all other clients. +Adds a new model to the collection. If autoSync set to true, the newly added model will be synchronized to Firebase, triggering an +`add` and `sync` event both locally and on all other clients. If autoSync is set to false, the `add` event will only be raised locally. ```javascript -Messages.add({ - subject: "Hello", - time: new Date().getTime() +todoList.add({ + subject: 'Make more coffee', + importance: 1 +}); + +todoList.on('all', function(event) { + // if autoSync is true this will log add and sync + // if autoSync is false this will only log add + console.log(event); }); ``` ### remove(model) -Removes a model from the collection. This model will also be removed from Firebase, triggering a -`remove` event both locally and on all other clients. +Removes a model from the collection. If autoSync is set to true this model will also be removed from Firebase, triggering a `remove` event both locally and on all other clients. If autoSync is set to false, this model will only trigger a local `remove` event. ```javascript -Messages.remove(someModel); +todoList.remove(someModel); + +todoList.on('all', function(event) { + // if autoSync is true this will log remove and sync + // if autoSync is false this will only log remove + console.log(event); +}); ``` ### create(value) @@ -192,34 +182,75 @@ Creates and adds a new model to the collection. The newly created model is retur `id` property (uniquely generated by Firebase). ```javascript -var model = Messages.create({bar: "foo"}); -Messages.get(model.id); +var model = todoList.create({bar: "foo"}); +todoList.get(model.id); + +todoList.on('all', function(event) { + // will log add and sync + console.log(event); +}); ``` ## Backbone.Firebase.Model This is a special model object that will automatically synchronize its contents with Firebase. You -may extend this object, and must provide a Firebase URL or a Firebase reference as the `firebase` +may extend this object, and must provide a Firebase URL or a Firebase reference as the `url` property. ```javascript var MyTodo = Backbone.Firebase.Model.extend({ - firebase: "https://.firebaseio.com/mytodo" + url: "https://.firebaseio.com/mytodo" }); ``` -You may apply limits as with `Backbone.Firebase.Collection`. +You may apply query methods as with `Backbone.Firebase.Collection`. -**BE AWARE!** You do not need to call any functions that will affect _remote_ data. If you call -`save()`, `sync()` or `fetch()` on the model, **the library will ignore it silently**. +**BE AWARE!** You do not need to call any functions that will affect _remote_ data. If autoSync is enabled and you call +`save()` or `fetch()` on the model, **the library will ignore it silently**. + +If autoSync is enabled, you should modify your model as you normally would, (via `set()` and `destroy()`) and _remote_ data +will be instantly updated. + +#### autoSync: true ```javascript -MyTodo.save(); // DOES NOTHING -MyTodo.sync(); // DOES NOTHING -MyTodo.fetch(); // DOES NOTHING +var RealtimeModel = Backbone.Firebase.Model({ + url: 'https://.firebaseio.com/mytodo', + autoSync: true // true by default +}); + +var realtimeModel = new RealtimeModel(); + +realtimeModel.on('sync', function(model) { + console.log('model loaded', model); +}); + +// calling .set() will sync the changes to firebase +// this will fire the sync, change, and change:name events +realtimeModel.set('name', 'Bob'); ``` -You should modify your model as you normally would, (via `set()` and `destroy()`) and _remote_ data -will be instantly updated. +#### autoSync: false + +```javascript +var RealtimeModel = Backbone.Firebase.Model({ + url: 'https://.firebaseio.com/mytodo', + autoSync: false +}); + +var realtimeModel = new RealtimeModel(); + +realtimeModel.on('sync', function(model) { + console.log('model loaded', model); +}); + +// this will fire off the sync event +realtimeModel.fetch(); + +// calling .save() will sync the changes to firebase +// this will fire the sync, change, and change:name events +realtimeModel.save('name', 'Bob'); + +``` ### set(value) @@ -239,12 +270,12 @@ MyTodo.destroy(); // Model is instantly removed from Firebase (and other clients ## Contributing -If you'd like to contribute to BackFire, you'll need to run the following commands to get your +If you'd like to contribute to BackboneFire, you'll need to run the following commands to get your environment set up: ```bash -$ git clone https://github.com/firebase/backfire.git -$ cd backfire # go to the backfire directory +$ git clone https://github.com/firebase/backbonefire.git +$ cd backbonefire # go to the backbonefire directory $ npm install -g grunt-cli # globally install grunt task runner $ npm install -g bower # globally install Bower package manager $ npm install # install local npm build / test dependencies @@ -252,8 +283,8 @@ $ bower install # install local JavaScript dependencies $ grunt watch # watch for source file changes ``` -`grunt watch` will watch for changes to `src/backfire.js` and lint and minify the source file when a -change occurs. The output files - `backfire.js` and `backfire.min.js` - are written to the `/dist/` +`grunt watch` will watch for changes to `src/backbonefire.js` and lint and minify the source file when a +change occurs. The output files - `backbonefire.js` and `backbonefire.min.js` - are written to the `/dist/` directory. You can run the test suite via the command line using `grunt test`. diff --git a/bower.json b/bower.json index c2aca43..275ef98 100644 --- a/bower.json +++ b/bower.json @@ -1,14 +1,14 @@ { - "name": "backfire", + "name": "backbonefire", "description": "The officially supported Backbone binding for Firebase", "version": "0.0.0", "authors": [ "Firebase (https://www.firebase.com/)" ], - "homepage": "https://github.com/firebase/backfire/", + "homepage": "https://github.com/firebase/backbonefire/", "repository": { "type": "git", - "url": "https://github.com/firebase/backfire.git" + "url": "https://github.com/firebase/backbonefire.git" }, "license": "MIT", "keywords": [ @@ -17,7 +17,7 @@ "realtime" ], "main": [ - "dist/backfire.js" + "dist/backbonefire.js" ], "ignore": [ "**/.*", @@ -37,6 +37,6 @@ "underscore": "~1.5.2" }, "devDependencies": { - "jasmine": "~2.0.0" + "mockfirebase": "~0.3.0" } } diff --git a/examples/todos/.bowerrc b/examples/todos/.bowerrc new file mode 100644 index 0000000..baa91a3 --- /dev/null +++ b/examples/todos/.bowerrc @@ -0,0 +1,3 @@ +{ + "directory": "bower_components" +} \ No newline at end of file diff --git a/examples/todos/bower.json b/examples/todos/bower.json new file mode 100644 index 0000000..3398958 --- /dev/null +++ b/examples/todos/bower.json @@ -0,0 +1,15 @@ +{ + "name": "todomvc-backbonefire", + "version": "0.0.0", + "private": true, + "dependencies": { + "backbone": "~1.1.0", + "underscore": "~1.5.0", + "jquery": "~2.0.0", + "todomvc-common": "~0.3.0", + "requirejs": "~2.1.5", + "requirejs-text": "~2.0.5", + "backbone.localStorage": "~1.1.0", + "firebase": "~2.0.3" + } +} diff --git a/examples/todos/destroy.png b/examples/todos/destroy.png deleted file mode 100644 index 56d7637..0000000 Binary files a/examples/todos/destroy.png and /dev/null differ diff --git a/examples/todos/index.html b/examples/todos/index.html index 46f7ca7..8052866 100644 --- a/examples/todos/index.html +++ b/examples/todos/index.html @@ -1,75 +1,28 @@ - - - - BackFire Todos - - - - -
-
-

Todos

- -
- -
- - -
    -
    - - -
    - -
    - Double-click to edit a todo. -
    - -
    - Adapted by Firebase -
    - Based on work by - Jérôme Gravel-Niquet - and - TodoMVC -
    - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + Backbone.js + Firebase • TodoMVC + + + +
    + +
    + + +
      +
      +
      +
      + + + + \ No newline at end of file diff --git a/examples/todos/js/backfire.js b/examples/todos/js/backfire.js new file mode 100644 index 0000000..d910be0 --- /dev/null +++ b/examples/todos/js/backfire.js @@ -0,0 +1,771 @@ +/*! + * BackFire is the officially supported Backbone binding for Firebase. The + * bindings let you use special model and collection types that allow for + * synchronizing data with Firebase. + * + * BackFire 0.0.0 + * https://github.com/firebase/backfire/ + * License: MIT + */ + +define([ + 'underscore', + 'backbone', +], function (_, Backbone) { + "use strict"; + + Backbone.Firebase = {}; + + /** + * A utility for retrieving the key name of a Firebase ref or + * DataSnapshot. This is backwards-compatible with `name()` + * from Firebase 1.x.x and `key()` from Firebase 2.0.0+. Once + * support for Firebase 1.x.x is dropped in BackFire, this + * helper can be removed. + */ + Backbone.Firebase._getKey = function(refOrSnapshot) { + return (typeof refOrSnapshot.key === 'function') ? refOrSnapshot.key() : refOrSnapshot.name(); + }; + + /** + * A utility for resolving whether an item will have the autoSync + * property. Models can have this property on the prototype. + */ + Backbone.Firebase._determineAutoSync = function(self, options) { + var proto = Object.getPrototypeOf(self); + return _.extend( + { + autoSync: proto.hasOwnProperty('autoSync') ? proto.autoSync : true + }, + this, + options + ).autoSync; + }; + + /** + * Overriding of Backbone.sync. + * All Backbone crud calls (destroy, add, create, save...) will pipe into + * this method. This way Backbone can handle the prepping of the models + * and the trigering of the appropiate methods. While sync can be overwritten + * to handle updates to Firebase. + */ + Backbone.Firebase.sync = function(method, model, options) { + var modelJSON = model.toJSON(); + + if (method === 'read') { + + Backbone.Firebase._readOnce(model.firebase, function onComplete(snap) { + var resp = snap.val(); + options.success(resp); + }); + + } else if (method === 'create') { + + Backbone.Firebase._setWithCheck(model.firebase, modelJSON, options); + + } else if (method === 'update') { + + Backbone.Firebase._updateWithCheck(model.firebase, modelJSON, options); + + } else if(method === 'delete') { + + Backbone.Firebase._setWithCheck(model.firebase, null, options); + + } + + }; + + /** + * A utility for a one-time read from Firebase. + */ + Backbone.Firebase._readOnce = function(ref, cb) { + ref.once('value', cb); + }; + + /** + * A utility for a destructive save to Firebase. + */ + Backbone.Firebase._setToFirebase = function(ref, item, onComplete) { + ref.set(item, onComplete); + }; + + + /** + * A utility for a non-destructive save to Firebase. + */ + Backbone.Firebase._updateToFirebase = function(ref, item, onComplete) { + ref.update(item, onComplete); + }; + + /** + * A utility for success and error events that are called after updates + * from Firebase. + */ + Backbone.Firebase._onCompleteCheck = function(err, item, options) { + if(!options) { return; } + + if(err && options.error) { + options.error(item, err, options); + } else if(options.success) { + options.success(item, null, options); + } + }; + + /** + * A utility for a destructive save to Firebase that handles success and + * error events from the server. + */ + Backbone.Firebase._setWithCheck = function(ref, item, options) { + Backbone.Firebase._setToFirebase(ref, item, function(err) { + Backbone.Firebase._onCompleteCheck(err, item, options); + }); + }; + + /** + * A utility for a non-destructive save to Firebase that handles success and + * error events from the server. + */ + Backbone.Firebase._updateWithCheck = function(ref, item, options) { + Backbone.Firebase._updateToFirebase(ref, item, function(err) { + Backbone.Firebase._onCompleteCheck(err, item, options); + }); + }; + + /** + * A utility for throwing errors. + */ + Backbone.Firebase._throwError = function(message) { + throw new Error(message); + }; + + + /** + * A utility for a determining whether a string or a Firebase + * reference should be returned. + * string - return new Firebase('') + * object - assume object is ref and return + */ + Backbone.Firebase._determineRef = function(objOrString) { + switch (typeof(objOrString)) { + case 'string': + return new Firebase(objOrString); + case 'object': + return objOrString; + default: + Backbone.Firebase._throwError('Invalid type passed to url property'); + } + }; + + /** + * A utility for assigning an id from a snapshot. + * object - Assign id from snapshot key + * primitive - Throw error, primitives cannot be synced + * null - Create blank object and assign id + */ + Backbone.Firebase._checkId = function(snap) { + var model = snap.val(); + + // if the model is a primitive throw an error + if (Backbone.Firebase._isPrimitive(model)) { + Backbone.Firebase._throwError('InvalidIdException: Models must have an Id. Note: You may ' + + 'be trying to sync a primitive value (int, string, bool).'); + } + + // if the model is null set it to an empty object and assign its id + // this way listeners can still be attached to populate the object in the future + if(model === null) { + model = {}; + } + + // set the id to the snapshot's key + model.id = Backbone.Firebase._getKey(snap); + + return model; + }; + + /** + * A utility for checking if a value is a primitive + */ + Backbone.Firebase._isPrimitive = function(value) { + // is the value not an object and not null (basically, is it a primitive?) + return !_.isObject(value) && value !== null; + }; + + /** + * Model responsible for autoSynced objects + * This model is never directly used. The Backbone.Firebase.Model will + * inherit from this if it is an autoSynced model + */ + var SyncModel = (function() { + + function SyncModel() { + // Set up sync events + + // apply remote changes locally + this.firebase.on('value', function(snap) { + + this._setLocal(snap); + this.trigger('sync', this, null, null); + }, this); + + // apply local changes remotely + this._listenLocalChange(function(model) { + this.firebase.update(model); + }); + + } + + SyncModel.protoype = { + save: function() { + console.warn('Save called on a Firebase model with autoSync enabled, ignoring.'); + }, + fetch: function() { + console.warn('Save called on a Firebase model with autoSync enabled, ignoring.'); + }, + sync: function(method, model, options) { + if(method === 'delete') { + Backbone.Firebase.sync(method, model, options); + } else { + console.warn('Sync called on a Fireabse model with autoSync enabled, ignoring.'); + } + } + }; + + return SyncModel; + }()); + + /** + * Model responsible for one-time requests + * This model is never directly used. The Backbone.Firebase.Model will + * inherit from this if it is an autoSynced model + */ + var OnceModel = (function() { + + function OnceModel() { + + // when an unset occurs set the key to null + // so Firebase knows to delete it on the server + this._listenLocalChange(function(model) { + this.set(model, { silent: true }); + }); + + } + + OnceModel.protoype = { + + sync: function(method, model, options) { + Backbone.Firebase.sync(method, model, options); + } + + }; + + return OnceModel; + }()); + + Backbone.Firebase.Model = Backbone.Model.extend({ + + // Determine whether the realtime or once methods apply + constructor: function(model, options) { + Backbone.Model.apply(this, arguments); + var defaults = _.result(this, 'defaults'); + + this.backboneDestroy = this.destroy; + + // Apply defaults only after first sync. + this.once('sync', function() { + this.set(_.defaults(this.toJSON(), defaults)); + }); + + this.autoSync = Backbone.Firebase._determineAutoSync(this, options); + + switch (typeof this.url) { + case 'string': + this.firebase = Backbone.Firebase._determineRef(this.url); + break; + case 'function': + this.firebase = Backbone.Firebase._determineRef(this.url()); + break; + case 'object': + this.firebase = Backbone.Firebase._determineRef(this.url); + break; + default: + Backbone.Firebase._throwError('url parameter required'); + } + + if(!this.autoSync) { + OnceModel.apply(this, arguments); + _.extend(this, OnceModel.protoype); + } else { + _.extend(this, SyncModel.protoype); + SyncModel.apply(this, arguments); + } + + }, + + + /** + * Siliently set the id of the model to the snapshot key + */ + _setId: function(snap) { + // if the item new set the name to the id + if(this.isNew()) { + this.set('id', Backbone.Firebase._getKey(snap), { silent: true }); + } + }, + + /** + * Proccess changes from a snapshot and apply locally + */ + _setLocal: function(snap) { + var newModel = this._unsetAttributes(snap); + this.set(newModel); + }, + + /** + * Unset attributes that have been deleted from the server + * by comparing the keys that have been removed. + */ + _unsetAttributes: function(snap) { + var newModel = Backbone.Firebase._checkId(snap); + + if (typeof newModel === 'object' && newModel !== null) { + var diff = _.difference(_.keys(this.attributes), _.keys(newModel)); + _.each(diff, _.bind(function(key) { + this.unset(key); + }, this)); + } + + // check to see if it needs an id + this._setId(snap); + + return newModel; + }, + + /** + * Find the deleted keys and set their values to null + * so Firebase properly deletes them. + */ + _updateModel: function(model) { + var modelObj = model.changedAttributes(); + _.each(model.changed, function(value, key) { + if (typeof value === "undefined" || value === null) { + if (key == "id") { + delete modelObj[key]; + } else { + modelObj[key] = null; + } + } + }); + + return modelObj; + }, + + + /** + * Determine if the model will update for every local change. + * Provide a callback function to call events after the update. + */ + _listenLocalChange: function(cb) { + var method = cb ? 'on' : 'off'; + this[method]('change', function(model) { + var newModel = this._updateModel(model); + if(_.isFunction(cb)){ + cb.call(this, newModel); + } + }, this); + } + + }); + + var OnceCollection = (function() { + function OnceCollection() { + + } + OnceCollection.protoype = { + /** + * Create an id from a Firebase push-id and call Backbone.create, which + * will do prepare the models and trigger the proper events and then call + * Backbone.Firebase.sync with the correct method. + */ + create: function(model, options) { + model.id = Backbone.Firebase._getKey(this.firebase.push()); + options = _.extend({ autoSync: false }, options); + return Backbone.Collection.prototype.create.apply(this, [model, options]); + }, + /** + * Create an id from a Firebase push-id and call Backbone.add, which + * will do prepare the models and trigger the proper events and then call + * Backbone.Firebase.sync with the correct method. + */ + add: function(model, options) { + model.id = Backbone.Firebase._getKey(this.firebase.push()); + options = _.extend({ autoSync: false }, options); + return Backbone.Collection.prototype.add.apply(this, [model, options]); + }, + /** + * Proxy to Backbone.Firebase.sync + */ + sync: function(method, model, options) { + Backbone.Firebase.sync(method, model, options); + }, + /** + * Firebase returns lists as an object with keys, where Backbone + * collections require an array. This function modifies the existing + * Backbone.Collection.fetch method by mapping the returned object from + * Firebase to an array that Backbone can use. + */ + fetch: function(options) { + options = options ? _.clone(options) : {}; + if (options.parse === void 0) { options.parse = true; } + var success = options.success; + var collection = this; + options.success = function(resp) { + var arr = []; + var keys = _.keys(resp); + _.each(keys, function(key) { + arr.push(resp[key]); + }); + var method = options.reset ? 'reset' : 'set'; + collection[method](arr, options); + if (success) { success(collection, arr, options); } + options.autoSync = false; + options.url = this.url; + collection.trigger('sync', collection, arr, options); + }; + return this.sync('read', this, options); + } + }; + + return OnceCollection; + }()); + + var SyncCollection = (function() { + + function SyncCollection() { + // Add handlers for remote events + this.firebase.on("child_added", _.bind(this._childAdded, this)); + this.firebase.on("child_moved", _.bind(this._childMoved, this)); + this.firebase.on("child_changed", _.bind(this._childChanged, this)); + this.firebase.on("child_removed", _.bind(this._childRemoved, this)); + + // Once handler to emit "sync" event. + this.firebase.once("value", _.bind(function() { + this.trigger("sync", this, null, null); + }, this)); + + // Handle changes in any local models. + this.listenTo(this, "change", this._updateModel, this); + // Listen for destroy event to remove models. + this.listenTo(this, "destroy", this._removeModel, this); + } + + SyncCollection.protoype = { + comparator: function(model) { + return model.id; + }, + + add: function(models, options) { + var parsed = this._parseModels(models); + options = options ? _.clone(options) : {}; + options.success = + _.isFunction(options.success) ? options.success : function() {}; + + var success = options.success; + options.success = _.bind(function(model, resp) { + if (success) { + success(model, resp, options); + } + this.trigger('sync', this, null, null); + }, this); + + for (var i = 0; i < parsed.length; i++) { + var model = parsed[i]; + + if (options.silent === true) { + this._suppressEvent = true; + } + + var childRef = this.firebase.ref().child(model.id); + childRef.set(model, _.bind(options.success, model)); + } + + return parsed; + }, + + create: function(model, options) { + options = options ? _.clone(options) : {}; + if (options.wait) { + this._log("Wait option provided to create, ignoring."); + } + if (!model) { + return false; + } + var set = this.add([model], options); + return set[0]; + }, + + remove: function(models, options) { + var parsed = this._parseModels(models); + options = options ? _.clone(options) : {}; + options.success = + _.isFunction(options.success) ? options.success : function() {}; + + for (var i = 0; i < parsed.length; i++) { + var model = parsed[i]; + var childRef = this.firebase.child(model.id); + if (options.silent === true) { + this._suppressEvent = true; + } + Backbone.Firebase._setWithCheck(childRef, null, options); + } + + return parsed; + }, + + reset: function(models, options) { + options = options ? _.clone(options) : {}; + // Remove all models remotely. + this.remove(this.models, {silent: true}); + // Add new models. + var ret = this.add(models, {silent: true}); + // Trigger "reset" event. + if (!options.silent) { + this.trigger('reset', this, options); + } + return ret; + }, + + _log: function(msg) { + if (console && console.log) { + console.log(msg); + } + }, + + _parseModels: function(models, options) { + var pushArray = []; + // check if the models paramter is an array or a single object + var singular = !_.isArray(models); + // if the models parameter is a single object then wrap it into an array + models = singular ? (models ? [models] : []) : models.slice(); + + for (var i = 0; i < models.length; i++) { + var model = models[i]; + + if (!model.id) { + model.id = Backbone.Firebase._getKey(this.firebase.push()); + } + + // call Backbone's prepareModel to apply options + model = Backbone.Collection.prototype._prepareModel.apply( + this, [model, options || {}] + ); + + if (model.toJSON && typeof model.toJSON == "function") { + model = model.toJSON(); + } + + pushArray.push(model); + + } + + return pushArray; + }, + + _childAdded: function(snap) { + var model = Backbone.Firebase._checkId(snap); + + if (this._suppressEvent === true) { + this._suppressEvent = false; + Backbone.Collection.prototype.add.apply(this, [model], {silent: true}); + } else { + Backbone.Collection.prototype.add.apply(this, [model]); + } + this.get(model.id)._remoteAttributes = model; + }, + + _childMoved: function(snap) { + // TODO: Investigate: can this occur without the ID changing? + this._log("_childMoved called with " + snap.val()); + }, + + // when a model has changed remotely find differences between the + // local and remote data and apply them to the local model + _childChanged: function(snap) { + var model = Backbone.Firebase._checkId(snap); + + var item = _.find(this.models, function(child) { + return child.id == model.id; + }); + + if (!item) { + // TODO: Investigate: what is the right way to handle this case? + //throw new Error("Could not find model with ID " + model.id); + this._childAdded(snap); + return; + } + + this._preventSync(item, true); + item._remoteAttributes = model; + + // find the attributes that have been deleted remotely and + // unset them locally + var diff = _.difference(_.keys(item.attributes), _.keys(model)); + _.each(diff, function(key) { + item.unset(key); + }); + + item.set(model); + this._preventSync(item, false); + }, + + // remove an item from the collection when removed remotely + // provides the ability to remove siliently + _childRemoved: function(snap) { + var model = Backbone.Firebase._checkId(snap); + + if (this._suppressEvent === true) { + this._suppressEvent = false; + Backbone.Collection.prototype.remove.apply( + this, [model], {silent: true} + ); + } else { + Backbone.Collection.prototype.remove.apply(this, [model]); + } + }, + + // Add handlers for all models in this collection, and any future ones + // that may be added. + _updateModel: function(model) { + var remoteAttributes; + var localAttributes; + var updateAttributes; + var ref; + + // if the model is already being handled by listeners then return + if (model._remoteChanging) { + return; + } + + remoteAttributes = model._remoteAttributes || {}; + localAttributes = model.toJSON(); + + // consolidate the updates to Firebase + updateAttributes = this._compareAttributes(remoteAttributes, localAttributes); + + ref = this.firebase.child(model.id); + + // if ".priority" is present setWithPriority + // else do a regular update + if (_.has(updateAttributes, ".priority")) { + this._setWithPriority(ref, localAttributes); + } else { + this._updateToFirebase(ref, localAttributes); + } + + }, + + // set the attributes to be updated to Firebase + // set any removed attributes to null so that Firebase removes them + _compareAttributes: function(remoteAttributes, localAttributes) { + var updateAttributes = {}; + + var union = _.union(_.keys(remoteAttributes), _.keys(localAttributes)); + + _.each(union, function(key) { + if (!_.has(localAttributes, key)) { + updateAttributes[key] = null; + } else if (localAttributes[key] != remoteAttributes[key]) { + updateAttributes[key] = localAttributes[key]; + } + }); + + return updateAttributes; + }, + + // Special case if ".priority" was updated - a merge is not + // allowed so we'll have to do a full setWithPriority. + _setWithPriority: function(ref, item) { + var priority = item[".priority"]; + delete item[".priority"]; + ref.setWithPriority(item, priority); + return item; + }, + + // TODO: possibly pass in options for onComplete callback + _updateToFirebase: function(ref, item) { + ref.update(item); + }, + + // Triggered when model.destroy() is called on one of the children. + _removeModel: function(model, collection, options) { + options = options ? _.clone(options) : {}; + options.success = + _.isFunction(options.success) ? options.success : function() {}; + var childRef = this.firebase.child(model.id); + Backbone.Firebase._setWithCheck(childRef, null, _.bind(options.success, model)); + }, + + _preventSync: function(model, state) { + model._remoteChanging = state; + } + }; + + return SyncCollection; + }()); + + Backbone.Firebase.Collection = Backbone.Collection.extend({ + + constructor: function (model, options) { + Backbone.Collection.apply(this, arguments); + var self = this; + var BaseModel = self.model; + this.autoSync = Backbone.Firebase._determineAutoSync(this, options); + + switch (typeof this.url) { + case 'string': + this.firebase = Backbone.Firebase._determineRef(this.url); + break; + case 'function': + this.firebase = Backbone.Firebase._determineRef(this.url()); + break; + case 'object': + this.firebase = Backbone.Firebase._determineRef(this.url); + break; + default: + throw new Error('url parameter required'); + } + + // if we are not autoSyncing, the model needs + // to be a non-autoSynced model + if(!this.autoSync) { + _.extend(this, OnceCollection.protoype); + OnceCollection.apply(this, arguments); + } else { + _.extend(this, SyncCollection.protoype); + SyncCollection.apply(this, arguments); + } + + // Intercept the given model and give it a firebase ref. + // Have it listen to local changes silently. When attributes + // are unset, the callback will set them to null so that they + // are removed on the Firebase server. + this.model = function(attrs, opts) { + + var newItem = new BaseModel(attrs, opts); + newItem.autoSync = false; + newItem.firebase = self.firebase.child(newItem.id); + newItem.sync = Backbone.Firebase.sync; + newItem.on('change', function(model) { + var updated = Backbone.Firebase.Model.prototype._updateModel(model); + model.set(updated, { silent: true }); + }); + + return newItem; + + }; + + } + + }); + +}); \ No newline at end of file diff --git a/examples/todos/js/collections/todos.js b/examples/todos/js/collections/todos.js new file mode 100644 index 0000000..d5e18f3 --- /dev/null +++ b/examples/todos/js/collections/todos.js @@ -0,0 +1,39 @@ +/*global define */ +define([ + 'underscore', + 'backbone', + 'models/todo', + 'firebase', + 'backbonefire' +], function (_, Backbone, Todo) { + 'use strict'; + + var TodosCollection = Backbone.Firebase.Collection.extend({ + // Reference to this collection's model. + model: Todo, + + // Save all of the todo items under the `"todos"` namespace. + url: 'https://backbonefire.firebaseio.com/todos', + + // Filter down the list of all todo items that are finished. + completed: function () { + return this.where({completed: true}); + }, + + // Filter down the list to only todo items that are still not finished. + remaining: function () { + return this.where({completed: false}); + }, + + // We keep the Todos in sequential order, despite being saved by unordered + // GUID in the database. This generates the next order number for new items. + nextOrder: function () { + return this.length ? this.last().get('order') + 1 : 1; + }, + + // Todos are sorted by their original insertion order. + comparator: 'order' + }); + + return new TodosCollection(); +}); \ No newline at end of file diff --git a/examples/todos/js/common.js b/examples/todos/js/common.js new file mode 100644 index 0000000..8022178 --- /dev/null +++ b/examples/todos/js/common.js @@ -0,0 +1,13 @@ +/*global define*/ +'use strict'; + +define([], function () { + return { + // Which filter are we using? + TodoFilter: '', // empty, active, completed + + // What is the enter key constant? + ENTER_KEY: 13, + ESCAPE_KEY: 27 + }; +}); \ No newline at end of file diff --git a/examples/todos/js/firebase.js b/examples/todos/js/firebase.js new file mode 100644 index 0000000..751805a --- /dev/null +++ b/examples/todos/js/firebase.js @@ -0,0 +1,195 @@ +/*! @license Firebase v1.1.3 - License: https://www.firebase.com/terms/terms-of-service.html */ (function() {var k,ba=this;function l(a){return void 0!==a}function ca(){}function da(a){a.ib=function(){return a.Ld?a.Ld:a.Ld=new a}} +function ea(a){var b=typeof a;if("object"==b)if(a){if(a instanceof Array)return"array";if(a instanceof Object)return b;var c=Object.prototype.toString.call(a);if("[object Window]"==c)return"object";if("[object Array]"==c||"number"==typeof a.length&&"undefined"!=typeof a.splice&&"undefined"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable("splice"))return"array";if("[object Function]"==c||"undefined"!=typeof a.call&&"undefined"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable("call"))return"function"}else return"null"; +else if("function"==b&&"undefined"==typeof a.call)return"object";return b}function fa(a){return"array"==ea(a)}function ga(a){var b=ea(a);return"array"==b||"object"==b&&"number"==typeof a.length}function p(a){return"string"==typeof a}function ha(a){return"number"==typeof a}function ia(a){return"function"==ea(a)}function ja(a){var b=typeof a;return"object"==b&&null!=a||"function"==b}function ka(a,b,c){return a.call.apply(a.bind,arguments)} +function la(a,b,c){if(!a)throw Error();if(2b?e+="000":256>b?e+="00":4096>b&&(e+="0");return sa[a]=e+b.toString(16)}),'"')};function ua(a){return"undefined"!==typeof JSON&&l(JSON.parse)?JSON.parse(a):oa(a)}function u(a){if("undefined"!==typeof JSON&&l(JSON.stringify))a=JSON.stringify(a);else{var b=[];qa(new pa,a,b);a=b.join("")}return a};function va(a){for(var b=[],c=0,d=0;d=e&&(e-=55296,d++,v(de?b[c++]=e:(2048>e?b[c++]=e>>6|192:(65536>e?b[c++]=e>>12|224:(b[c++]=e>>18|240,b[c++]=e>>12&63|128),b[c++]=e>>6&63|128),b[c++]=e&63|128)}return b};var wa={};function x(a,b,c,d){var e;dc&&(e=0===c?"none":"no more than "+c);if(e)throw Error(a+" failed: Was called with "+d+(1===d?" argument.":" arguments.")+" Expects "+e+".");} +function y(a,b,c){var d="";switch(b){case 1:d=c?"first":"First";break;case 2:d=c?"second":"Second";break;case 3:d=c?"third":"Third";break;case 4:d=c?"fourth":"Fourth";break;default:xa.assert(!1,"errorPrefix_ called with argumentNumber > 4. Need to update it?")}return a=a+" failed: "+(d+" argument ")}function z(a,b,c,d){if((!d||l(c))&&!ia(c))throw Error(y(a,b,d)+"must be a valid function.");}function ya(a,b,c){if(l(c)&&(!ja(c)||null===c))throw Error(y(a,b,!0)+"must be a valid context object.");};function A(a,b){return Object.prototype.hasOwnProperty.call(a,b)}function B(a,b){if(Object.prototype.hasOwnProperty.call(a,b))return a[b]}function za(a,b){for(var c in a)Object.prototype.hasOwnProperty.call(a,c)&&b(c,a[c])}function Aa(a){var b={};za(a,function(a,d){b[a]=d});return b};var xa={},Ba=/[\[\].#$\/\u0000-\u001F\u007F]/,Ca=/[\[\].#$\u0000-\u001F\u007F]/;function Da(a){return p(a)&&0!==a.length&&!Ba.test(a)}function Ea(a,b,c){c&&!l(b)||Fa(y(a,1,c),b)} +function Fa(a,b,c,d){c||(c=0);d=d||[];if(!l(b))throw Error(a+"contains undefined"+Ga(d));if(ia(b))throw Error(a+"contains a function"+Ga(d)+" with contents: "+b.toString());if(Ha(b))throw Error(a+"contains "+b.toString()+Ga(d));if(1E310485760/3&&10485760=a)throw"Query.limit: First argument must be a positive integer.";return new F(this.i,this.path,a,this.fa,this.za,this.Ea,this.fb)};F.prototype.limit=F.prototype.ze;F.prototype.ae=function(a,b){x("Query.startAt",0,2,arguments.length);Ja("Query.startAt",1,a,!0);La("Query.startAt",b);l(a)||(b=a=null);return new F(this.i,this.path,this.Ga,a,b,this.Ea,this.fb)};F.prototype.startAt=F.prototype.ae; +F.prototype.Hd=function(a,b){x("Query.endAt",0,2,arguments.length);Ja("Query.endAt",1,a,!0);La("Query.endAt",b);return new F(this.i,this.path,this.Ga,this.fa,this.za,a,b)};F.prototype.endAt=F.prototype.Hd;F.prototype.se=function(a,b){x("Query.equalTo",1,2,arguments.length);Ja("Query.equalTo",1,a,!1);La("Query.equalTo",b);return this.ae(a,b).Hd(a,b)};F.prototype.equalTo=F.prototype.se; +function Ra(a){var b={};l(a.fa)&&(b.sp=a.fa);l(a.za)&&(b.sn=a.za);l(a.Ea)&&(b.ep=a.Ea);l(a.fb)&&(b.en=a.fb);l(a.Ga)&&(b.l=a.Ga);l(a.fa)&&l(a.za)&&null===a.fa&&null===a.za&&(b.vf="l");return b}F.prototype.Wa=function(){var a=Sa(Ra(this));return"{}"===a?"default":a}; +function Qa(a,b,c){var d={};if(b&&c)d.cancel=b,z(a,3,d.cancel,!0),d.$=c,ya(a,4,d.$);else if(b)if("object"===typeof b&&null!==b)d.$=b;else if("function"===typeof b)d.cancel=b;else throw Error(wa.af(a,3,!0)+"must either be a cancel callback or a context object.");return d};function H(a,b){if(1==arguments.length){this.u=a.split("/");for(var c=0,d=0;d=a.u.length?null:a.u[a.W]}function Ta(a){var b=a.W;b=this.u.length)return null;for(var a=[],b=this.W;b=this.u.length};k.length=function(){return this.u.length-this.W}; +function Va(a,b){var c=D(a);if(null===c)return b;if(c===D(b))return Va(Ta(a),Ta(b));throw"INTERNAL ERROR: innerPath ("+b+") is not within outerPath ("+a+")";}k.contains=function(a){var b=this.W,c=a.W;if(this.length()>a.length())return!1;for(;bb?1:0}k=bb.prototype;k.ta=function(a,b){return new bb(this.ab,this.ea.ta(a,b,this.ab).M(null,null,!1,null,null))};k.remove=function(a){return new bb(this.ab,this.ea.remove(a,this.ab).M(null,null,!1,null,null))};k.get=function(a){for(var b,c=this.ea;!c.f();){b=this.ab(a,c.key);if(0===b)return c.value;0>b?c=c.left:0c?d=d.left:0d?e.M(null,null,null,e.left.ta(a,b,c),null):0===d?e.M(null,b,null,null,null):e.M(null,null,null,null,e.right.ta(a,b,c));return jb(e)};function kb(a){if(a.left.f())return db;a.left.R()||a.left.left.R()||(a=lb(a));a=a.M(null,null,null,kb(a.left),null);return jb(a)} +k.remove=function(a,b){var c,d;c=this;if(0>b(a,c.key))c.left.f()||c.left.R()||c.left.left.R()||(c=lb(c)),c=c.M(null,null,null,c.left.remove(a,b),null);else{c.left.R()&&(c=mb(c));c.right.f()||c.right.R()||c.right.left.R()||(c=nb(c),c.left.left.R()&&(c=mb(c),c=nb(c)));if(0===b(a,c.key)){if(c.right.f())return db;d=ib(c.right);c=c.M(d.key,d.value,null,null,kb(c.right))}c=c.M(null,null,null,null,c.right.remove(a,b))}return jb(c)};k.R=function(){return this.color}; +function jb(a){a.right.R()&&!a.left.R()&&(a=ob(a));a.left.R()&&a.left.left.R()&&(a=mb(a));a.left.R()&&a.right.R()&&(a=nb(a));return a}function lb(a){a=nb(a);a.right.left.R()&&(a=a.M(null,null,null,null,mb(a.right)),a=ob(a),a=nb(a));return a}function ob(a){return a.right.M(null,null,a.color,a.M(null,null,!0,null,a.right.left),null)}function mb(a){return a.left.M(null,null,a.color,null,a.M(null,null,!0,a.left.right,null))} +function nb(a){return a.M(null,null,!a.color,a.left.M(null,null,!a.left.color,null,null),a.right.M(null,null,!a.right.color,null,null))}function pb(){}k=pb.prototype;k.M=function(){return this};k.ta=function(a,b){return new hb(a,b,null)};k.remove=function(){return this};k.count=function(){return 0};k.f=function(){return!0};k.Fa=function(){return!1};k.Xa=function(){return!1};k.Lb=function(){return null};k.lb=function(){return null};k.R=function(){return!1};var db=new pb;function qb(a){this.Cb=a;this.zc="firebase:"}k=qb.prototype;k.set=function(a,b){null==b?this.Cb.removeItem(this.zc+a):this.Cb.setItem(this.zc+a,u(b))};k.get=function(a){a=this.Cb.getItem(this.zc+a);return null==a?null:ua(a)};k.remove=function(a){this.Cb.removeItem(this.zc+a)};k.Nd=!1;k.toString=function(){return this.Cb.toString()};function tb(){this.yb={}}tb.prototype.set=function(a,b){null==b?delete this.yb[a]:this.yb[a]=b};tb.prototype.get=function(a){return A(this.yb,a)?this.yb[a]:null};tb.prototype.remove=function(a){delete this.yb[a]};tb.prototype.Nd=!0;function ub(a){try{if("undefined"!==typeof window&&"undefined"!==typeof window[a]){var b=window[a];b.setItem("firebase:sentinel","cache");b.removeItem("firebase:sentinel");return new qb(b)}}catch(c){}return new tb}var vb=ub("localStorage"),J=ub("sessionStorage");function wb(a,b,c,d,e){this.host=a.toLowerCase();this.domain=this.host.substr(this.host.indexOf(".")+1);this.Ya=b;this.Ta=c;this.Ye=d;this.yc=e||"";this.ia=vb.get("host:"+a)||this.host}function xb(a,b){b!==a.ia&&(a.ia=b,"s-"===a.ia.substr(0,2)&&vb.set("host:"+a.host,a.ia))}wb.prototype.toString=function(){var a=(this.Ya?"https://":"http://")+this.host;this.yc&&(a+="<"+this.yc+">");return a};function yb(){this.ra=-1};function zb(){this.ra=-1;this.ra=64;this.F=[];this.Sc=[];this.ge=[];this.vc=[];this.vc[0]=128;for(var a=1;ae;e++)d[e]=b.charCodeAt(c)<<24|b.charCodeAt(c+1)<<16|b.charCodeAt(c+2)<<8|b.charCodeAt(c+3),c+=4;else for(e=0;16>e;e++)d[e]=b[c]<<24|b[c+1]<<16|b[c+2]<<8|b[c+3],c+=4;for(e=16;80>e;e++){var f=d[e-3]^d[e-8]^d[e-14]^d[e-16];d[e]=(f<<1|f>>>31)&4294967295}b=a.F[0];c=a.F[1];for(var g=a.F[2],h=a.F[3],m=a.F[4],n,e=0;80>e;e++)40>e?20>e?(f=h^c&(g^h),n=1518500249):(f=c^g^h,n=1859775393):60>e?(f=c&g|h&(c|g),n=2400959708):(f=c^g^h,n=3395469782),f=(b<< +5|b>>>27)+f+m+n+d[e]&4294967295,m=h,h=g,g=(c<<30|c>>>2)&4294967295,c=b,b=f;a.F[0]=a.F[0]+b&4294967295;a.F[1]=a.F[1]+c&4294967295;a.F[2]=a.F[2]+g&4294967295;a.F[3]=a.F[3]+h&4294967295;a.F[4]=a.F[4]+m&4294967295} +zb.prototype.update=function(a,b){l(b)||(b=a.length);for(var c=b-this.ra,d=0,e=this.Sc,f=this.kb;dc?Math.max(0,a.length+c):c;if(p(a))return p(b)&&1==b.length?a.indexOf(b,c):-1;for(;cc?null:p(a)?a.charAt(c):a[c]}function Jb(a,b){a.sort(b||Kb)}function Kb(a,b){return a>b?1:aparseFloat(a))?String(b):a})();var Tb=null,Ub=null; +function Vb(a,b){if(!ga(a))throw Error("encodeByteArray takes an array as a parameter");if(!Tb){Tb={};Ub={};for(var c=0;65>c;c++)Tb[c]="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=".charAt(c),Ub[c]="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.".charAt(c)}for(var c=b?Ub:Tb,d=[],e=0;e>2,f=(f&3)<<4|h>>4,h=(h&15)<<2|n>>6,n=n&63;m||(n=64,g||(h=64));d.push(c[q],c[f],c[h],c[n])}return d.join("")} +;var Wb=function(){var a=1;return function(){return a++}}();function v(a,b){if(!a)throw Error("Firebase INTERNAL ASSERT FAILED:"+b);}function Xb(a){try{if("undefined"!==typeof atob)return atob(a)}catch(b){M("base64DecodeIfNativeSupport failed: ",b)}return null} +function Yb(a){var b=va(a);a=new zb;a.update(b);var b=[],c=8*a.Kc;56>a.kb?a.update(a.vc,56-a.kb):a.update(a.vc,a.ra-(a.kb-56));for(var d=a.ra-1;56<=d;d--)a.Sc[d]=c&255,c/=256;Ab(a,a.Sc);for(d=c=0;5>d;d++)for(var e=24;0<=e;e-=8)b[c]=a.F[d]>>e&255,++c;return Vb(b)}function Zb(a){for(var b="",c=0;cb?1:-1:0}function ic(a,b){if(a===b)return 0;var c=jc(a),d=jc(b);return null!==c?null!==d?0==c-d?a.length-b.length:c-d:-1:null!==d?1:aa?c.push(a.substring(d,a.length)):c.push(a.substring(d,d+b));return c}function mc(a,b){if(fa(a))for(var c=0;ca,a=Math.abs(a),a>=Math.pow(2,-1022)?(d=Math.min(Math.floor(Math.log(a)/Math.LN2),1023),c=d+1023,d=Math.round(a*Math.pow(2,52-d)-Math.pow(2,52))):(c=0,d=Math.round(a/Math.pow(2,-1074))));e=[];for(a=52;a;a-=1)e.push(d%2?1:0),d=Math.floor(d/2);for(a=11;a;a-=1)e.push(c%2?1:0),c=Math.floor(c/2);e.push(b?1:0);e.reverse();b=e.join("");c="";for(a=0;64>a;a+=8)d=parseInt(b.substr(a,8),2).toString(16),1===d.length&& +(d="0"+d),c+=d;return c.toLowerCase()}function qc(a){var b="Unknown Error";"too_big"===a?b="The data requested exceeds the maximum size that can be accessed with a single request.":"permission_denied"==a?b="Client doesn't have permission to access the desired data.":"unavailable"==a&&(b="The service is unavailable");b=Error(a+": "+b);b.code=a.toUpperCase();return b}var rc=/^-?\d{1,10}$/;function jc(a){return rc.test(a)&&(a=Number(a),-2147483648<=a&&2147483647>=a)?a:null} +function sc(a){try{a()}catch(b){setTimeout(function(){throw b;},Math.floor(0))}}function P(a,b){if(ia(a)){var c=Array.prototype.slice.call(arguments,1).slice();sc(function(){a.apply(null,c)})}};function tc(a,b){this.H=a;v(null!==this.H,"LeafNode shouldn't be created with null value.");this.pb="undefined"!==typeof b?b:null}k=tc.prototype;k.Q=function(){return!0};k.m=function(){return this.pb};k.La=function(a){return new tc(this.H,a)};k.P=function(){return Q};k.N=function(a){return null===D(a)?this:Q};k.ha=function(){return null};k.K=function(a,b){return(new R).K(a,b).La(this.pb)};k.Ba=function(a,b){var c=D(a);return null===c?b:this.K(c,Q.Ba(Ta(a),b))};k.f=function(){return!1};k.qc=function(){return 0}; +k.X=function(a){return a&&null!==this.m()?{".value":this.k(),".priority":this.m()}:this.k()};k.hash=function(){var a="";null!==this.m()&&(a+="priority:"+uc(this.m())+":");var b=typeof this.H,a=a+(b+":"),a="number"===b?a+pc(this.H):a+this.H;return Yb(a)};k.k=function(){return this.H};k.toString=function(){return"string"===typeof this.H?this.H:'"'+this.H+'"'};function vc(a,b){return hc(a.la,b.la)||ic(a.name,b.name)}function wc(a,b){return ic(a.name,b.name)}function xc(a,b){return ic(a,b)};function R(a,b){this.o=a||new bb(xc);this.pb="undefined"!==typeof b?b:null}k=R.prototype;k.Q=function(){return!1};k.m=function(){return this.pb};k.La=function(a){return new R(this.o,a)};k.K=function(a,b){var c=this.o.remove(a);b&&b.f()&&(b=null);null!==b&&(c=c.ta(a,b));return b&&null!==b.m()?new yc(c,null,this.pb):new R(c,this.pb)};k.Ba=function(a,b){var c=D(a);if(null===c)return b;var d=this.P(c).Ba(Ta(a),b);return this.K(c,d)};k.f=function(){return this.o.f()};k.qc=function(){return this.o.count()}; +var zc=/^(0|[1-9]\d*)$/;k=R.prototype;k.X=function(a){if(this.f())return null;var b={},c=0,d=0,e=!0;this.B(function(f,g){b[f]=g.X(a);c++;e&&zc.test(f)?d=Math.max(d,Number(f)):e=!1});if(!a&&e&&d<2*c){var f=[],g;for(g in b)f[g]=b[g];return f}a&&null!==this.m()&&(b[".priority"]=this.m());return b};k.hash=function(){var a="";null!==this.m()&&(a+="priority:"+uc(this.m())+":");this.B(function(b,c){var d=c.hash();""!==d&&(a+=":"+b+":"+d)});return""===a?"":Yb(a)}; +k.P=function(a){a=this.o.get(a);return null===a?Q:a};k.N=function(a){var b=D(a);return null===b?this:this.P(b).N(Ta(a))};k.ha=function(a){return eb(this.o,a)};k.Jd=function(){return this.o.Lb()};k.Kd=function(){return this.o.lb()};k.B=function(a){return this.o.Fa(a)};k.$c=function(a){return this.o.Xa(a)};k.jb=function(){return this.o.jb()};k.toString=function(){var a="{",b=!0;this.B(function(c,d){b?b=!1:a+=", ";a+='"'+c+'" : '+d.toString()});return a+="}"};var Q=new R;function yc(a,b,c){R.call(this,a,c);null===b&&(b=new bb(vc),a.Fa(function(a,c){b=b.ta({name:a,la:c.m()},c)}));this.ya=b}na(yc,R);k=yc.prototype;k.K=function(a,b){var c=this.P(a),d=this.o,e=this.ya;null!==c&&(d=d.remove(a),e=e.remove({name:a,la:c.m()}));b&&b.f()&&(b=null);null!==b&&(d=d.ta(a,b),e=e.ta({name:a,la:b.m()},b));return new yc(d,e,this.m())};k.ha=function(a,b){var c=eb(this.ya,{name:a,la:b.m()});return c?c.name:null};k.B=function(a){return this.ya.Fa(function(b,c){return a(b.name,c)})}; +k.$c=function(a){return this.ya.Xa(function(b,c){return a(b.name,c)})};k.jb=function(){return this.ya.jb(function(a,b){return{key:a.name,value:b}})};k.Jd=function(){return this.ya.f()?null:this.ya.Lb().name};k.Kd=function(){return this.ya.f()?null:this.ya.lb().name};function S(a,b){if(null===a)return Q;var c=null;"object"===typeof a&&".priority"in a?c=a[".priority"]:"undefined"!==typeof b&&(c=b);v(null===c||"string"===typeof c||"number"===typeof c||"object"===typeof c&&".sv"in c,"Invalid priority type found: "+typeof c);"object"===typeof a&&".value"in a&&null!==a[".value"]&&(a=a[".value"]);if("object"!==typeof a||".sv"in a)return new tc(a,c);if(a instanceof Array){var d=Q,e=a;nc(e,function(a,b){if(A(e,b)&&"."!==b.substring(0,1)){var c=S(a);if(c.Q()||!c.f())d= +d.K(b,c)}});return d.La(c)}var f=[],g={},h=!1,m=a;mc(m,function(a,b){if("string"!==typeof b||"."!==b.substring(0,1)){var c=S(m[b]);c.f()||(h=h||null!==c.m(),f.push({name:b,la:c.m()}),g[b]=c)}});var n=Ac(f,g,!1);if(h){var q=Ac(f,g,!0);return new yc(n,q,c)}return new R(n,c)}var Bc=Math.log(2);function Cc(a){this.count=parseInt(Math.log(a+1)/Bc,10);this.Fd=this.count-1;this.pe=a+1&parseInt(Array(this.count+1).join("1"),2)}function Dc(a){var b=!(a.pe&1<=a.length){var b=Number(a);if(!isNaN(b)){e.Ad=b;e.frames=[];a=null;break a}}e.Ad=1;e.frames=[]}null!==a&&$c(e,a)}};this.Y.onerror=function(a){e.e("WebSocket error. Closing connection.");(a=a.message||a.data)&&e.e(a);e.Ia()}};Xc.prototype.start=function(){}; +Xc.isAvailable=function(){var a=!1;if("undefined"!==typeof navigator&&navigator.userAgent){var b=navigator.userAgent.match(/Android ([0-9]{0,}\.[0-9]{0,})/);b&&1parseFloat(b[1])&&(a=!0)}return!a&&null!==Wc&&!Yc};Xc.responsesRequiredToBeHealthy=2;Xc.healthyTimeout=3E4;k=Xc.prototype;k.nc=function(){vb.remove("previous_websocket_failure")};function $c(a,b){a.frames.push(b);if(a.frames.length==a.Ad){var c=a.frames.join("");a.frames=null;c=ua(c);a.Ge(c)}} +k.send=function(a){Zc(this);a=u(a);this.Oa+=a.length;Oc(this.ga,"bytes_sent",a.length);a=lc(a,16384);1document.domain="'+document.domain+'";\x3c/script>');a=""+a+"";try{this.aa.Da.open(),this.aa.Da.write(a),this.aa.Da.close()}catch(f){M("frame writing exception"),f.stack&&M(f.stack),M(f)}} +id.prototype.close=function(){this.Qc=!1;if(this.aa){this.aa.Da.body.innerHTML="";var a=this;setTimeout(function(){null!==a.aa&&(document.body.removeChild(a.aa),a.aa=null)},Math.floor(0))}var b=this.ka;b&&(this.ka=null,b())}; +function ld(a){if(a.Qc&&a.Fc&&a.od.count()<(0=a.Tb[0].Gd.length+30+c.length){var e=a.Tb.shift(),c=c+"&seg"+d+"="+e.Pe+"&ts"+d+"="+e.We+"&d"+d+"="+e.Gd;d++}else break;md(a,b+c,a.Yc);return!0}return!1}function md(a,b,c){function d(){a.od.remove(c);ld(a)}a.od.add(c);var e=setTimeout(d,Math.floor(25E3));kd(a,b,function(){clearTimeout(e);d()})} +function kd(a,b,c){setTimeout(function(){try{if(a.Fc){var d=a.aa.Da.createElement("script");d.type="text/javascript";d.async=!0;d.src=b;d.onload=d.onreadystatechange=function(){var a=d.readyState;a&&"loaded"!==a&&"complete"!==a||(d.onload=d.onreadystatechange=null,d.parentNode&&d.parentNode.removeChild(d),c())};d.onerror=function(){M("Long-poll script failed to load: "+b);a.Fc=!1;a.close()};a.aa.Da.body.appendChild(d)}}catch(e){}},Math.floor(1))};function nd(a){od(this,a)}var pd=[fd,Xc];function od(a,b){var c=Xc&&Xc.isAvailable(),d=c&&!(vb.Nd||!0===vb.get("previous_websocket_failure"));b.Ye&&(c||O("wss:// URL used, but browser isn't known to support websockets. Trying anyway."),d=!0);if(d)a.bc=[Xc];else{var e=a.bc=[];mc(pd,function(a,b){b&&b.isAvailable()&&e.push(b)})}}function qd(a){if(0=a.Yd?(a.e("Secondary connection is healthy."),a.Sa=!0,a.w.nc(),a.w.start(),a.e("sending client ack on secondary"),a.w.send({t:"c",d:{t:"a",d:{}}}),a.e("Ending transmission on primary"),a.C.send({t:"c",d:{t:"n",d:{}}}),a.cc=a.w,xd(a)):(a.e("sending ping on secondary."),a.w.send({t:"c",d:{t:"p",d:{}}}))}rd.prototype.tc=function(a){zd(this);this.ob(a)};function zd(a){a.Sa||(a.qd--,0>=a.qd&&(a.e("Primary connection is healthy."),a.Sa=!0,a.C.nc()))} +function wd(a,b){a.w=new b("c:"+a.id+":"+a.Dd++,a.D,a.Gc);a.Yd=b.responsesRequiredToBeHealthy||0;a.w.open(td(a,a.w),ud(a,a.w));setTimeout(function(){a.w&&(a.e("Timed out trying to upgrade."),a.w.close())},Math.floor(6E4))}function vd(a,b,c){a.e("Realtime connection established.");a.C=b;a.oa=1;a.Nb&&(a.Nb(c),a.Nb=null);0===a.qd?(a.e("Primary connection is healthy."),a.Sa=!0):setTimeout(function(){Ad(a)},Math.floor(5E3))} +function Ad(a){a.Sa||1!==a.oa||(a.e("sending ping on primary."),Ed(a,{t:"c",d:{t:"p",d:{}}}))}function Ed(a,b){if(1!==a.oa)throw"Connection is not connected";a.cc.send(b)}rd.prototype.close=function(){2!==this.oa&&(this.e("Closing realtime connection."),this.oa=2,yd(this),this.U&&(this.U(),this.U=null))};function yd(a){a.e("Shutting down all connections");a.C&&(a.C.close(),a.C=null);a.w&&(a.w.close(),a.w=null);a.kc&&(clearTimeout(a.kc),a.kc=null)};function Fd(a){var b={},c={},d={},e="";try{var f=a.split("."),b=ua(Xb(f[0])||""),c=ua(Xb(f[1])||""),e=f[2],d=c.d||{};delete c.d}catch(g){}return{bf:b,Uc:c,data:d,Ue:e}}function Gd(a){a=Fd(a).Uc;return"object"===typeof a&&a.hasOwnProperty("iat")?B(a,"iat"):null}function Hd(a){a=Fd(a);var b=a.Uc;return!!a.Ue&&!!b&&"object"===typeof b&&b.hasOwnProperty("iat")};function Id(a,b,c,d,e){this.id=Jd++;this.e=cc("p:"+this.id+":");this.Za=!0;this.ja={};this.V=[];this.Pb=0;this.Mb=[];this.T=!1;this.va=1E3;this.oc=3E5;this.uc=b||ca;this.sc=c||ca;this.nd=d||ca;this.dd=e||ca;this.D=a;this.ud=null;this.Xb={};this.Oe=0;this.Ib=this.hd=null;Kd(this,0);Gc.ib().Ua("visible",this.Je,this);-1===a.host.indexOf("fblocal")&&Hc.ib().Ua("online",this.He,this)}var Jd=0,Ld=0;k=Id.prototype; +k.Ja=function(a,b,c){var d=++this.Oe;a={r:d,a:a,b:b};this.e(u(a));v(this.T,"sendRequest_ call when we're not connected not allowed.");this.ma.Zd(a);c&&(this.Xb[d]=c)};function Md(a,b,c){var d=b.toString(),e=b.path().toString();a.ja[e]=a.ja[e]||{};v(!a.ja[e][d],"listen() called twice for same path/queryId.");a.ja[e][d]={qb:b.qb(),G:c};a.T&&Nd(a,e,d,b.qb(),c)} +function Nd(a,b,c,d,e){a.e("Listen on "+b+" for "+c);var f={p:b};d=Fb(d,function(a){return Ra(a)});"{}"!==c&&(f.q=d);f.h=a.dd(b);a.Ja("l",f,function(d){a.e("listen response",d);d=d.s;"ok"!==d&&Od(a,b,c);e&&e(d)})}k.I=function(a,b,c){this.bb={re:a,Id:!1,ca:b,fc:c};this.e("Authenticating using credential: "+a);Pd(this);(b=40==a.length)||(a=Fd(a).Uc,b="object"===typeof a&&!0===B(a,"admin"));b&&(this.e("Admin auth credential detected. Reducing max reconnect time."),this.oc=3E4)}; +k.Bd=function(a){delete this.bb;this.T&&this.Ja("unauth",{},function(b){a(b.s,b.d)})};function Pd(a){var b=a.bb;a.T&&b&&a.Ja("auth",{cred:b.re},function(c){var d=c.s;c=c.d||"error";"ok"!==d&&a.bb===b&&delete a.bb;b.Id?"ok"!==d&&b.fc&&b.fc(d,c):(b.Id=!0,b.ca&&b.ca(d,c))})}function Qd(a,b,c,d){b=b.toString();Od(a,b,c)&&a.T&&Rd(a,b,c,d)}function Rd(a,b,c,d){a.e("Unlisten on "+b+" for "+c);b={p:b};d=Fb(d,function(a){return Ra(a)});"{}"!==c&&(b.q=d);a.Ja("u",b)} +function Sd(a,b,c,d){a.T?Td(a,"o",b,c,d):a.Mb.push({Rb:b,action:"o",data:c,G:d})}function Ud(a,b,c,d){a.T?Td(a,"om",b,c,d):a.Mb.push({Rb:b,action:"om",data:c,G:d})}k.ld=function(a,b){this.T?Td(this,"oc",a,null,b):this.Mb.push({Rb:a,action:"oc",data:null,G:b})};function Td(a,b,c,d,e){c={p:c,d:d};a.e("onDisconnect "+b,c);a.Ja(b,c,function(a){e&&setTimeout(function(){e(a.s,a.d)},Math.floor(0))})}k.put=function(a,b,c,d){Vd(this,"p",a,b,c,d)};function Wd(a,b,c,d){Vd(a,"m",b,c,d,void 0)} +function Vd(a,b,c,d,e,f){c={p:c,d:d};l(f)&&(c.h=f);a.V.push({action:b,Vd:c,G:e});a.Pb++;b=a.V.length-1;a.T&&Xd(a,b)}function Xd(a,b){var c=a.V[b].action,d=a.V[b].Vd,e=a.V[b].G;a.V[b].Le=a.T;a.Ja(c,d,function(d){a.e(c+" response",d);delete a.V[b];a.Pb--;0===a.Pb&&(a.V=[]);e&&e(d.s,d.d)})} +k.tc=function(a){if("r"in a){this.e("from server: "+u(a));var b=a.r,c=this.Xb[b];c&&(delete this.Xb[b],c(a.b))}else{if("error"in a)throw"A server-side error has occurred: "+a.error;"a"in a&&(b=a.a,c=a.b,this.e("handleServerMessage",b,c),"d"===b?this.uc(c.p,c.d,!1):"m"===b?this.uc(c.p,c.d,!0):"c"===b?Yd(this,c.p,c.q):"ac"===b?(a=c.s,b=c.d,c=this.bb,delete this.bb,c&&c.fc&&c.fc(a,b)):"sd"===b?this.ud?this.ud(c):"msg"in c&&"undefined"!==typeof console&&console.log("FIREBASE: "+c.msg.replace("\n","\nFIREBASE: ")): +dc("Unrecognized action received from server: "+u(b)+"\nAre you using the latest client?"))}};k.Nb=function(a){this.e("connection ready");this.T=!0;this.Ib=(new Date).getTime();this.nd({serverTimeOffset:a-(new Date).getTime()});Pd(this);for(var b in this.ja)for(var c in this.ja[b])a=this.ja[b][c],Nd(this,b,c,a.qb,a.G);for(b=0;be.status){try{a=ua(e.responseText)}catch(b){}c(null,a)}else 500<=e.status&&600>e.status?c(W("SERVER_ERROR")):c(W("NETWORK_ERROR"));c=null;se(window,"beforeunload",d)}};if("GET"===f)a+=(/\?/.test(a)?"":"?")+ve(b),g=null;else{var h=this.options.headers.content_type; +"application/json"===h&&(g=u(b));"application/x-www-form-urlencoded"===h&&(g=ve(b))}e.open(f,a,!0);a={"X-Requested-With":"XMLHttpRequest",Accept:"application/json;text/plain"};Mc(a,this.options.headers);for(var m in a)e.setRequestHeader(m,a[m]);e.send(g)};ze.isAvailable=function(){return!!window.XMLHttpRequest&&"string"===typeof(new XMLHttpRequest).responseType&&(!(navigator.userAgent.match(/MSIE/)||navigator.userAgent.match(/Trident/))||ye())};ze.prototype.Ab=function(){return"json"};function Ae(a){a=a||{};this.Yb=Bb()+Bb()+Bb();this.Rd=a||{}} +Ae.prototype.open=function(a,b,c){function d(){c&&(c(W("USER_CANCELLED")),c=null)}var e=this,f=fc(le),g;b.requestId=this.Yb;b.redirectTo=f.scheme+"://"+f.host+"/blank/page.html";a+=/\?/.test(a)?"":"?";a+=ve(b);(g=window.open(a,"_blank","location=no"))&&ia(g.addEventListener)?(g.addEventListener("loadstart",function(a){var b;if(b=a&&a.url)a:{var f=a.url;try{var q=document.createElement("a");q.href=f;b=q.host===fc(le).host&&"/blank/page.html"===q.pathname;break a}catch(s){}b=!1}b&&(a=ue(a.url),g.removeEventListener("exit", +d),g.close(),a=new me(null,null,{requestId:e.Yb,requestKey:a}),e.Rd.requestWithCredential("/auth/session",a,c),c=null)}),g.addEventListener("exit",d)):c(W("TRANSPORT_UNAVAILABLE"))};Ae.isAvailable=function(){return xe()};Ae.prototype.Ab=function(){return"redirect"};function Be(a){a=a||{};if(!a.window_features||-1!==navigator.userAgent.indexOf("Fennec/")||-1!==navigator.userAgent.indexOf("Firefox/")&&-1!==navigator.userAgent.indexOf("Android"))a.window_features=void 0;a.window_name||(a.window_name="_blank");a.relay_url||(a.relay_url=we()+"/auth/channel");this.options=a} +Be.prototype.open=function(a,b,c){function d(a){g&&(document.body.removeChild(g),g=void 0);q&&(q=clearInterval(q));se(window,"message",e);se(window,"unload",d);if(n&&!a)try{n.close()}catch(b){h.postMessage("die",m)}n=h=void 0}function e(a){if(a.origin===m)try{var b=ua(a.data);"ready"===b.a?h.postMessage(s,m):"error"===b.a?(d(!1),c&&(c(b.d),c=null)):"response"===b.a&&(d(b.forceKeepWindowOpen),c&&(c(null,b.d),c=null))}catch(e){}}var f=ye(),g,h,m=te(a);if(m!==te(this.options.relay_url))c&&setTimeout(function(){c(Error("invalid arguments: origin of url and relay_url must match"))}, +0);else{f&&(g=document.createElement("iframe"),g.setAttribute("src",this.options.relay_url),g.style.display="none",g.setAttribute("name","__winchan_relay_frame"),document.body.appendChild(g),h=g.contentWindow);a+=(/\?/.test(a)?"":"?")+ve(b);var n=window.open(a,this.options.window_name,this.options.window_features);h||(h=n);var q=setInterval(function(){n&&n.closed&&(d(!1),c&&(c(W("USER_CANCELLED")),c=null))},500),s=u({a:"request",d:b});re(window,"unload",d);re(window,"message",e)}}; +Be.isAvailable=function(){return"postMessage"in window&&!/^file:\//.test(location.href)&&!(xe()||navigator.userAgent.match(/Windows Phone/)||window.Windows&&/^ms-appx:/.test(location.href)||navigator.userAgent.match(/(iPhone|iPod|iPad).*AppleWebKit(?!.*Safari)/i)||navigator.userAgent.match(/CriOS/)||navigator.userAgent.match(/Twitter for iPhone/)||navigator.userAgent.match(/FBAN\/FBIOS/)||window.navigator.standalone)&&!navigator.userAgent.match(/PhantomJS/)};Be.prototype.Ab=function(){return"popup"};function Ce(a){a=a||{};a.callback_parameter||(a.callback_parameter="callback");this.options=a;window.__firebase_auth_jsonp=window.__firebase_auth_jsonp||{}} +Ce.prototype.open=function(a,b,c){function d(){c&&(c(W("REQUEST_INTERRUPTED")),c=null)}function e(){setTimeout(function(){delete window.__firebase_auth_jsonp[f];Jc(window.__firebase_auth_jsonp)&&delete window.__firebase_auth_jsonp;try{var a=document.getElementById(f);a&&a.parentNode.removeChild(a)}catch(b){}},1);se(window,"beforeunload",d)}var f="fn"+(new Date).getTime()+Math.floor(99999*Math.random());b[this.options.callback_parameter]="__firebase_auth_jsonp."+f;a+=(/\?/.test(a)?"":"?")+ve(b);re(window, +"beforeunload",d);window.__firebase_auth_jsonp[f]=function(a){c&&(c(null,a),c=null);e()};De(f,a,c)};function De(a,b,c){setTimeout(function(){try{var d=document.createElement("script");d.type="text/javascript";d.id=a;d.async=!0;d.src=b;d.onerror=function(){var b=document.getElementById(a);null!==b&&b.parentNode.removeChild(b);c&&c(W("NETWORK_ERROR"))};var e=document.getElementsByTagName("head");(e&&0!=e.length?e[0]:document.documentElement).appendChild(d)}catch(f){c&&c(W("NETWORK_ERROR"))}},0)} +Ce.isAvailable=function(){return!xe()};Ce.prototype.Ab=function(){return"json"};function Ee(a,b){this.pd=["session",a.yc,a.Ta].join(":");this.Ic=b}Ee.prototype.set=function(a,b){if(!b)if(this.Ic.length)b=this.Ic[0];else throw Error("fb.login.SessionManager : No storage options available!");b.set(this.pd,a)};Ee.prototype.get=function(){var a=Fb(this.Ic,r(this.we,this)),a=Eb(a,function(a){return null!==a});Jb(a,function(a,c){return Gd(c.token)-Gd(a.token)});return 0c)f=B(s,K.key),l(f)?(n.push({ad:K,zd:h[f]}),h[f]=null):(t[K.key]=m.length,m.push(K)),f=!0,K=gb(w);else{if(0c||0===c&&0>=ic(a,d.fb)}):c.push(function(a,b){return 0>=hc(b,d.Ea)}));var e=null,f=null;if(l(this.S.Ga))if(l(this.S.fa)){if(e=mf(a,c,this.S.Ga,!1)){var g=a.P(e).m();c.push(function(a,b){var c=hc(b,g);return 0>c||0===c&& +0>=ic(a,e)})}}else if(f=mf(a,c,this.S.Ga,!0)){var h=a.P(f).m();c.push(function(a,b){var c=hc(b,h);return 0f;f++)b[f]=Math.floor(64*Math.random());for(f=0;12>f;f++)c+="-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".charAt(b[f]);v(20===c.length,"NextPushId: Length should be 20."); +return c}}();function G(a,b){var c,d,e;if(a instanceof Ef)c=a,d=b;else{x("new Firebase",1,2,arguments.length);d=fc(arguments[0]);c=d.Ve;"firebase"===d.domain&&ec(d.host+" is no longer supported. Please use .firebaseio.com instead");c||ec("Cannot parse Firebase url. Please use https://.firebaseio.com");d.Ya||"undefined"!==typeof window&&window.location&&window.location.protocol&&-1!==window.location.protocol.indexOf("https:")&&O("Insecure Firebase access from a secure page. Please use https in calls to new Firebase()."); +c=new wb(d.host,d.Ya,c,"ws"===d.scheme||"wss"===d.scheme);d=new H(d.Rb);e=d.toString();var f;!(f=!p(c.host)||0===c.host.length||!Da(c.Ta))&&(f=0!==e.length)&&(e&&(e=e.replace(/^\/*\.info(\/|$)/,"/")),f=!(p(e)&&0!==e.length&&!Ca.test(e)));if(f)throw Error(y("new Firebase",1,!1)+'must be a valid firebase URL and the path can\'t contain ".", "#", "$", "[", or "]".');if(b)if(b instanceof Y)e=b;else if(p(b))e=Y.ib(),c.yc=b;else throw Error("Expected a valid Firebase.Context for second argument to new Firebase()"); +else e=Y.ib();f=c.toString();var g=B(e.sb,f);g||(g=new Ef(c),e.sb[f]=g);c=g}F.call(this,c,d)}na(G,F);var dg=G,eg=["Firebase"],fg=ba;eg[0]in fg||!fg.execScript||fg.execScript("var "+eg[0]);for(var gg;eg.length&&(gg=eg.shift());)!eg.length&&l(dg)?fg[gg]=dg:fg=fg[gg]?fg[gg]:fg[gg]={};G.prototype.name=function(){x("Firebase.name",0,0,arguments.length);return this.path.f()?null:Ua(this.path)};G.prototype.name=G.prototype.name; +G.prototype.J=function(a){x("Firebase.child",1,1,arguments.length);if(ha(a))a=String(a);else if(!(a instanceof H))if(null===D(this.path)){var b=a;b&&(b=b.replace(/^\/*\.info(\/|$)/,"/"));Ma("Firebase.child",b)}else Ma("Firebase.child",a);return new G(this.i,this.path.J(a))};G.prototype.child=G.prototype.J;G.prototype.parent=function(){x("Firebase.parent",0,0,arguments.length);var a=this.path.parent();return null===a?null:new G(this.i,a)};G.prototype.parent=G.prototype.parent; +G.prototype.root=function(){x("Firebase.ref",0,0,arguments.length);for(var a=this;null!==a.parent();)a=a.parent();return a};G.prototype.root=G.prototype.root;G.prototype.toString=function(){x("Firebase.toString",0,0,arguments.length);var a;if(null===this.parent())a=this.i.toString();else{a=this.parent().toString()+"/";var b=this.name();a+=encodeURIComponent(String(b))}return a};G.prototype.toString=G.prototype.toString; +G.prototype.set=function(a,b){x("Firebase.set",1,2,arguments.length);C("Firebase.set",this.path);Ea("Firebase.set",a,!1);z("Firebase.set",2,b,!0);this.i.wb(this.path,a,null,b)};G.prototype.set=G.prototype.set; +G.prototype.update=function(a,b){x("Firebase.update",1,2,arguments.length);C("Firebase.update",this.path);if(fa(a)){for(var c={},d=0;d<%= remaining %> <%= remaining == 1 ? 'item' : 'items' %> left + +<% if ( completed ) { %> + +<% } %> \ No newline at end of file diff --git a/examples/todos/js/templates/todos.html b/examples/todos/js/templates/todos.html new file mode 100644 index 0000000..b5a62e7 --- /dev/null +++ b/examples/todos/js/templates/todos.html @@ -0,0 +1,6 @@ +
      + > + + +
      + \ No newline at end of file diff --git a/examples/todos/js/views/app.js b/examples/todos/js/views/app.js new file mode 100644 index 0000000..ea6af97 --- /dev/null +++ b/examples/todos/js/views/app.js @@ -0,0 +1,134 @@ +/*global define*/ +define([ + 'jquery', + 'underscore', + 'backbone', + 'views/todos', + 'text!templates/stats.html', + 'common' +], function ($, _, Backbone, TodoView, statsTemplate, Common) { + 'use strict'; + + // Our overall **AppView** is the top-level piece of UI. + var AppView = Backbone.View.extend({ + + // Instead of generating a new element, bind to the existing skeleton of + // the App already present in the HTML. + el: '#todoapp', + + // Compile our stats template + template: _.template(statsTemplate), + + // Delegated events for creating new items, and clearing completed ones. + events: { + 'keypress #new-todo': 'createOnEnter', + 'click #clear-completed': 'clearCompleted', + 'click #toggle-all': 'toggleAllComplete' + }, + + // At initialization we bind to the relevant events on the `this.collection` + // collection, when items are added or changed. Kick things off by + // loading any preexisting todos that might be saved in *localStorage*. + initialize: function () { + this.allCheckbox = this.$('#toggle-all')[0]; + this.$input = this.$('#new-todo'); + this.$footer = this.$('#footer'); + this.$main = this.$('#main'); + this.$todoList = this.$('#todo-list'); + + this.listenTo(this.collection, 'add', this.addOne); + this.listenTo(this.collection, 'reset', this.addAll); + this.listenTo(this.collection, 'change:completed', this.filterOne); + this.listenTo(this.collection, 'filter', this.filterAll); + this.listenTo(this.collection, 'all', this.render); + + this.collection.fetch(); + }, + + // Re-rendering the App just means refreshing the statistics -- the rest + // of the app doesn't change. + render: function () { + var completed = this.collection.completed().length; + var remaining = this.collection.remaining().length; + + if (this.collection.length) { + this.$main.show(); + this.$footer.show(); + + this.$footer.html(this.template({ + completed: completed, + remaining: remaining + })); + + this.$('#filters li a') + .removeClass('selected') + .filter('[href="#/' + (Common.TodoFilter || '') + '"]') + .addClass('selected'); + } else { + this.$main.hide(); + this.$footer.hide(); + } + + this.allCheckbox.checked = !remaining; + }, + + // Add a single todo item to the list by creating a view for it, and + // appending its element to the `
        `. + addOne: function (todo) { + var view = new TodoView({ model: todo }); + this.$todoList.append(view.render().el); + }, + + // Add all items in the **this.collection** collection at once. + addAll: function () { + this.$todoList.empty(); + this.collection.each(this.addOne, this); + }, + + filterOne: function (todo) { + todo.trigger('visible'); + }, + + filterAll: function () { + this.collection.each(this.filterOne, this); + }, + + // Generate the attributes for a new Todo item. + newAttributes: function () { + return { + title: this.$input.val().trim(), + order: 0, + completed: false + }; + }, + + // If you hit return in the main input field, create new **Todo** model, + // persisting it to *localStorage*. + createOnEnter: function (e) { + if (e.which !== Common.ENTER_KEY || !this.$input.val().trim()) { + return; + } + + this.collection.create(this.newAttributes()); + this.$input.val(''); + }, + + // Clear all completed todo items, destroying their models. + clearCompleted: function () { + _.invoke(this.collection.completed(), 'destroy'); + return false; + }, + + toggleAllComplete: function () { + var completed = this.allCheckbox.checked; + + this.collection.each(function (todo) { + todo.save({ + completed: completed + }); + }); + } + }); + + return AppView; +}); \ No newline at end of file diff --git a/examples/todos/js/views/todos.js b/examples/todos/js/views/todos.js new file mode 100644 index 0000000..d04f812 --- /dev/null +++ b/examples/todos/js/views/todos.js @@ -0,0 +1,114 @@ +/*global define*/ +define([ + 'jquery', + 'underscore', + 'backbone', + 'text!templates/todos.html', + 'common' +], function ($, _, Backbone, todosTemplate, Common) { + 'use strict'; + + var TodoView = Backbone.View.extend({ + + tagName: 'li', + + template: _.template(todosTemplate), + + // The DOM events specific to an item. + events: { + 'click .toggle': 'toggleCompleted', + 'dblclick label': 'edit', + 'click .destroy': 'clear', + 'keypress .edit': 'updateOnEnter', + 'keydown .edit': 'revertOnEscape', + 'blur .edit': 'close' + }, + + // The TodoView listens for changes to its model, re-rendering. Since there's + // a one-to-one correspondence between a **Todo** and a **TodoView** in this + // app, we set a direct reference on the model for convenience. + initialize: function () { + this.listenTo(this.model, 'change', this.render); + this.listenTo(this.model, 'destroy', this.remove); + this.listenTo(this.model, 'visible', this.toggleVisible); + }, + + // Re-render the titles of the todo item. + render: function () { + this.$el.html(this.template(this.model.toJSON())); + this.$el.toggleClass('completed', this.model.get('completed')); + + this.toggleVisible(); + this.$input = this.$('.edit'); + return this;{} + }, + + toggleVisible: function () { + this.$el.toggleClass('hidden', this.isHidden()); + }, + + isHidden: function () { + var isCompleted = this.model.get('completed'); + return (// hidden cases only + (!isCompleted && Common.TodoFilter === 'completed') || + (isCompleted && Common.TodoFilter === 'active') + ); + }, + + // Toggle the `"completed"` state of the model. + toggleCompleted: function () { + this.model.toggle(); + }, + + // Switch this view into `"editing"` mode, displaying the input field. + edit: function () { + this.$el.addClass('editing'); + this.$input.focus(); + }, + + // Close the `"editing"` mode, saving changes to the todo. + close: function () { + var value = this.$input.val(); + var trimmedValue = value.trim(); + + if (trimmedValue) { + this.model.set({ title: trimmedValue }); + + if (value !== trimmedValue) { + // Model values changes consisting of whitespaces only are not causing change to be triggered + // Therefore we've to compare untrimmed version with a trimmed one to chech whether anything changed + // And if yes, we've to trigger change event ourselves + this.model.trigger('change'); + } + } else { + this.clear(); + } + + this.$el.removeClass('editing'); + }, + + // If you hit `enter`, we're through editing the item. + updateOnEnter: function (e) { + if (e.keyCode === Common.ENTER_KEY) { + this.close(); + } + }, + + // If you're pressing `escape` we revert your change by simply leaving + // the `editing` state. + revertOnEscape: function (e) { + if (e.which === Common.ESCAPE_KEY) { + this.$el.removeClass('editing'); + // Also reset the hidden input back to the original value. + this.$input.val(this.model.get('title')); + } + }, + + // Remove the item, destroy the model from *localStorage* and delete its view. + clear: function () { + this.model.destroy(); + } + }); + + return TodoView; +}); \ No newline at end of file diff --git a/examples/todos/package.json b/examples/todos/package.json new file mode 100644 index 0000000..eb53247 --- /dev/null +++ b/examples/todos/package.json @@ -0,0 +1,15 @@ +{ + "name": "backbone-fire-todo", + "version": "0.0.0", + "description": "Todo example using Backbone Fire.", + "main": "index.html", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "Backbone", + "Firebase" + ], + "author": "", + "license": "MIT" +} diff --git a/examples/todos/todos.css b/examples/todos/todos.css deleted file mode 100644 index 35bdb05..0000000 --- a/examples/todos/todos.css +++ /dev/null @@ -1,211 +0,0 @@ -html, -body { - margin: 0; - padding: 0; -} - -body { - font: 14px "Helvetica Neue", Helvetica, Arial, sans-serif; - line-height: 1.4em; - background: #eeeeee; - color: #333333; - width: 520px; - margin: 0 auto; - -webkit-font-smoothing: antialiased; -} - -#todoapp { - background: #fff; - padding: 20px; - margin-bottom: 40px; - -webkit-box-shadow: rgba(0, 0, 0, 0.2) 0 2px 6px 0; - -moz-box-shadow: rgba(0, 0, 0, 0.2) 0 2px 6px 0; - -ms-box-shadow: rgba(0, 0, 0, 0.2) 0 2px 6px 0; - -o-box-shadow: rgba(0, 0, 0, 0.2) 0 2px 6px 0; - box-shadow: rgba(0, 0, 0, 0.2) 0 2px 6px 0; - -webkit-border-radius: 0 0 5px 5px; - -moz-border-radius: 0 0 5px 5px; - -ms-border-radius: 0 0 5px 5px; - -o-border-radius: 0 0 5px 5px; - border-radius: 0 0 5px 5px; -} - -#todoapp h1 { - font-size: 36px; - font-weight: bold; - text-align: center; - padding: 0 0 10px 0; -} - -#todoapp input[type="text"] { - width: 466px; - font-size: 24px; - font-family: inherit; - line-height: 1.4em; - border: 0; - outline: none; - padding: 6px; - border: 1px solid #999999; - -webkit-box-shadow: rgba(0, 0, 0, 0.2) 0 1px 2px 0 inset; - -moz-box-shadow: rgba(0, 0, 0, 0.2) 0 1px 2px 0 inset; - -ms-box-shadow: rgba(0, 0, 0, 0.2) 0 1px 2px 0 inset; - -o-box-shadow: rgba(0, 0, 0, 0.2) 0 1px 2px 0 inset; - box-shadow: rgba(0, 0, 0, 0.2) 0 1px 2px 0 inset; -} - -#todoapp input::-webkit-input-placeholder { - font-style: italic; -} - -#main { - display: none; -} - -#todo-list { - margin: 10px 0; - padding: 0; - list-style: none; -} - -#todo-list li { - padding: 18px 20px 18px 0; - position: relative; - font-size: 24px; - border-bottom: 1px solid #cccccc; -} - -#todo-list li:last-child { - border-bottom: none; -} - -#todo-list li.done label { - color: #777777; - text-decoration: line-through; -} - -#todo-list .destroy { - position: absolute; - right: 5px; - top: 20px; - display: none; - cursor: pointer; - width: 20px; - height: 20px; - background: url(destroy.png) no-repeat; -} - -#todo-list li:hover .destroy { - display: block; -} - -#todo-list .destroy:hover { - background-position: 0 -20px; -} - -#todo-list li.editing { - border-bottom: none; - margin-top: -1px; - padding: 0; -} - -#todo-list li.editing:last-child { - margin-bottom: -1px; -} - -#todo-list li.editing .edit { - display: block; - width: 444px; - padding: 13px 15px 14px 20px; - margin: 0; -} - -#todo-list li.editing .view { - display: none; -} - -#todo-list li .view label { - word-break: break-word; -} - -#todo-list li .edit { - display: none; -} - -#todoapp footer { - display: none; - margin: 0 -20px -20px -20px; - overflow: hidden; - color: #555555; - background: #f4fce8; - border-top: 1px solid #ededed; - padding: 0 20px; - line-height: 37px; - -webkit-border-radius: 0 0 5px 5px; - -moz-border-radius: 0 0 5px 5px; - -ms-border-radius: 0 0 5px 5px; - -o-border-radius: 0 0 5px 5px; - border-radius: 0 0 5px 5px; -} - -#clear-completed { - float: right; - line-height: 20px; - text-decoration: none; - background: rgba(0, 0, 0, 0.1); - color: #555555; - font-size: 11px; - margin-top: 8px; - margin-bottom: 8px; - padding: 0 10px 1px; - cursor: pointer; - -webkit-border-radius: 12px; - -moz-border-radius: 12px; - -ms-border-radius: 12px; - -o-border-radius: 12px; - border-radius: 12px; - -webkit-box-shadow: rgba(0, 0, 0, 0.2) 0 -1px 0 0; - -moz-box-shadow: rgba(0, 0, 0, 0.2) 0 -1px 0 0; - -ms-box-shadow: rgba(0, 0, 0, 0.2) 0 -1px 0 0; - -o-box-shadow: rgba(0, 0, 0, 0.2) 0 -1px 0 0; - box-shadow: rgba(0, 0, 0, 0.2) 0 -1px 0 0; -} - -#clear-completed:hover { - background: rgba(0, 0, 0, 0.15); - -webkit-box-shadow: rgba(0, 0, 0, 0.3) 0 -1px 0 0; - -moz-box-shadow: rgba(0, 0, 0, 0.3) 0 -1px 0 0; - -ms-box-shadow: rgba(0, 0, 0, 0.3) 0 -1px 0 0; - -o-box-shadow: rgba(0, 0, 0, 0.3) 0 -1px 0 0; - box-shadow: rgba(0, 0, 0, 0.3) 0 -1px 0 0; -} - -#clear-completed:active { - position: relative; - top: 1px; -} - -#todo-count span { - font-weight: bold; -} - -#instructions { - margin: 10px auto; - color: #777777; - text-shadow: rgba(255, 255, 255, 0.8) 0 1px 0; - text-align: center; -} - -#instructions a { - color: #336699; -} - -#credits { - margin: 30px auto; - color: #999; - text-shadow: rgba(255, 255, 255, 0.8) 0 1px 0; - text-align: center; -} - -#credits a { - color: #888; -} diff --git a/index.html b/index.html deleted file mode 100644 index 6f2ceba..0000000 --- a/index.html +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - Backfire - A real-time backend for Backbone from Firebase. - - - - - - - - - - - - - - - - - - - -
        -
        -

        Backfire

        - -
        -
        - - -
        -
        - -
        - -
        -
        - - - diff --git a/package.json b/package.json index 085e140..2da174d 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,15 @@ { - "name": "backfire", + "name": "backbonefire", "description": "The officially supported Backbone binding for Firebase", "version": "0.0.0", "author": "Firebase (https://www.firebase.com/)", - "homepage": "https://github.com/firebase/backfire/", + "homepage": "https://github.com/firebase/backbonefire/", "repository": { "type": "git", - "url": "https://github.com/firebase/backfire.git" + "url": "https://github.com/firebase/backbonefire.git" }, "bugs": { - "url": "https://github.com/firebase/backfire/issues" + "url": "https://github.com/firebase/backbonefire/issues" }, "licenses": [ { @@ -22,7 +22,7 @@ "firebase", "realtime" ], - "main": "dist/backfire.js", + "main": "dist/backbonefire.js", "files": [ "dist/**", "LICENSE", @@ -46,7 +46,6 @@ "istanbul": "^0.3.2", "karma": "~0.12.8", "karma-chai": "0.0.2", - "karma-coffee-preprocessor": "~0.1.0", "karma-coverage": "^0.2.6", "karma-failed-reporter": "0.0.2", "karma-mocha": "~0.1.0", @@ -55,7 +54,11 @@ "karma-sinon": "~1.0.0", "karma-spec-reporter": "0.0.13", "mocha": "~1.14.0", - "requirejs": "~2.1.9" + "requirejs": "~2.1.9", + "karma-chrome-launcher": "^0.1.4", + "grunt-copy": "^0.1.0", + "grunt-contrib-connect": "^0.9.0", + "grunt-serve": "^0.1.6" }, "scripts": { "test": "grunt test", diff --git a/src/backbonefire.js b/src/backbonefire.js new file mode 100644 index 0000000..c26def7 --- /dev/null +++ b/src/backbonefire.js @@ -0,0 +1,768 @@ +/*! + * BackboneFire is the officially supported Backbone binding for Firebase. The + * bindings let you use special model and collection types that allow for + * synchronizing data with Firebase. + * + * BackboneFire 0.0.0 + * https://github.com/firebase/backbonefire/ + * License: MIT + */ + +(function(_, Backbone) { + 'use strict'; + + Backbone.Firebase = {}; + + /** + * A utility for retrieving the key name of a Firebase ref or + * DataSnapshot. This is backwards-compatible with `name()` + * from Firebase 1.x.x and `key()` from Firebase 2.0.0+. Once + * support for Firebase 1.x.x is dropped in BackboneFire, this + * helper can be removed. + */ + Backbone.Firebase._getKey = function(refOrSnapshot) { + return (typeof refOrSnapshot.key === 'function') ? refOrSnapshot.key() : refOrSnapshot.name(); + }; + + /** + * A utility for resolving whether an item will have the autoSync + * property. Models can have this property on the prototype. + */ + Backbone.Firebase._determineAutoSync = function(model, options) { + var proto = Object.getPrototypeOf(model); + return _.extend( + { + autoSync: proto.hasOwnProperty('autoSync') ? proto.autoSync : true + }, + this, + options + ).autoSync; + }; + + /** + * Overriding of Backbone.sync. + * All Backbone crud calls (destroy, add, create, save...) will pipe into + * this method. This way Backbone can handle the prepping of the models + * and the trigering of the appropiate methods. While sync can be overwritten + * to handle updates to Firebase. + */ + Backbone.Firebase.sync = function(method, model, options) { + var modelJSON = model.toJSON(); + + if (method === 'read') { + + Backbone.Firebase._readOnce(model.firebase, function onComplete(snap) { + var resp = snap.val(); + if(options.success) { + options.success(resp); + } + }, function _readOnceError(err) { + if(options.error) { + options.error(err); + } + }); + + } else if (method === 'create') { + + Backbone.Firebase._setWithCheck(model.firebase, modelJSON, options); + + } else if (method === 'update') { + + Backbone.Firebase._updateWithCheck(model.firebase, modelJSON, options); + + } else if(method === 'delete') { + + Backbone.Firebase._setWithCheck(model.firebase, null, options); + + } + + }; + + /** + * A utility for a one-time read from Firebase. + */ + Backbone.Firebase._readOnce = function(ref, onComplete) { + ref.once('value', onComplete); + }; + + /** + * A utility for a destructive save to Firebase. + */ + Backbone.Firebase._setToFirebase = function(ref, item, onComplete) { + ref.set(item, onComplete); + }; + + + /** + * A utility for a non-destructive save to Firebase. + */ + Backbone.Firebase._updateToFirebase = function(ref, item, onComplete) { + ref.update(item, onComplete); + }; + + /** + * A utility for success and error events that are called after updates + * from Firebase. + */ + Backbone.Firebase._onCompleteCheck = function(err, item, options) { + if(!options) { return; } + + if(err && options.error) { + options.error(item, err, options); + } else if(options.success) { + options.success(item, null, options); + } + }; + + /** + * A utility for a destructive save to Firebase that handles success and + * error events from the server. + */ + Backbone.Firebase._setWithCheck = function(ref, item, options) { + Backbone.Firebase._setToFirebase(ref, item, function(err) { + Backbone.Firebase._onCompleteCheck(err, item, options); + }); + }; + + /** + * A utility for a non-destructive save to Firebase that handles success and + * error events from the server. + */ + Backbone.Firebase._updateWithCheck = function(ref, item, options) { + Backbone.Firebase._updateToFirebase(ref, item, function(err) { + Backbone.Firebase._onCompleteCheck(err, item, options); + }); + }; + + /** + * A utility for throwing errors. + */ + Backbone.Firebase._throwError = function(message) { + throw new Error(message); + }; + + + /** + * A utility for a determining whether a string or a Firebase + * reference should be returned. + * string - return new Firebase('') + * object - assume object is ref and return + */ + Backbone.Firebase._determineRef = function(objOrString) { + switch (typeof(objOrString)) { + case 'string': + return new Firebase(objOrString); + case 'object': + return objOrString; + default: + Backbone.Firebase._throwError('Invalid type passed to url property'); + } + }; + + /** + * A utility for assigning an id from a snapshot. + * object - Assign id from snapshot key + * primitive - Throw error, primitives cannot be synced + * null - Create blank object and assign id + */ + Backbone.Firebase._checkId = function(snap) { + var model = snap.val(); + + // if the model is a primitive throw an error + if (Backbone.Firebase._isPrimitive(model)) { + Backbone.Firebase._throwError('InvalidIdException: Models must have an Id. Note: You may ' + + 'be trying to sync a primitive value (int, string, bool).'); + } + + // if the model is null set it to an empty object and assign its id + // this way listeners can still be attached to populate the object in the future + if(model === null) { + model = {}; + } + + // set the id to the snapshot's key + model.id = Backbone.Firebase._getKey(snap); + + return model; + }; + + /** + * A utility for checking if a value is a primitive + */ + Backbone.Firebase._isPrimitive = function(value) { + // is the value not an object and not null (basically, is it a primitive?) + return !_.isObject(value) && value !== null; + }; + + /** + * Model responsible for autoSynced objects + * This model is never directly used. The Backbone.Firebase.Model will + * inherit from this if it is an autoSynced model + */ + var SyncModel = (function() { + + function SyncModel() { + // Set up sync events + + // apply remote changes locally + this.firebase.on('value', function(snap) { + this._setLocal(snap); + this.trigger('sync', this, null, null); + }, this); + + // apply local changes remotely + this._listenLocalChange(function(model) { + this.firebase.update(model); + }); + + } + + SyncModel.protoype = { + save: function() { + console.warn('Save called on a Firebase model with autoSync enabled, ignoring.'); + }, + fetch: function() { + console.warn('Save called on a Firebase model with autoSync enabled, ignoring.'); + }, + sync: function(method, model, options) { + if(method === 'delete') { + Backbone.Firebase.sync(method, model, options); + } else { + console.warn('Sync called on a Fireabse model with autoSync enabled, ignoring.'); + } + } + }; + + return SyncModel; + }()); + + /** + * Model responsible for one-time requests + * This model is never directly used. The Backbone.Firebase.Model will + * inherit from this if it is an autoSynced model + */ + var OnceModel = (function() { + + function OnceModel() { + + // when an unset occurs set the key to null + // so Firebase knows to delete it on the server + this._listenLocalChange(function(model) { + this.set(model, { silent: true }); + }); + + } + + OnceModel.protoype = { + + sync: function(method, model, options) { + Backbone.Firebase.sync(method, model, options); + } + + }; + + return OnceModel; + }()); + + Backbone.Firebase.Model = Backbone.Model.extend({ + + // Determine whether the realtime or once methods apply + constructor: function(model, options) { + Backbone.Model.apply(this, arguments); + var defaults = _.result(this, 'defaults'); + + // Apply defaults only after first sync. + this.once('sync', function() { + this.set(_.defaults(this.toJSON(), defaults)); + }); + + this.autoSync = Backbone.Firebase._determineAutoSync(this, options); + + switch (typeof this.url) { + case 'string': + this.firebase = Backbone.Firebase._determineRef(this.url); + break; + case 'function': + this.firebase = Backbone.Firebase._determineRef(this.url()); + break; + case 'object': + this.firebase = Backbone.Firebase._determineRef(this.url); + break; + default: + Backbone.Firebase._throwError('url parameter required'); + } + + if(!this.autoSync) { + OnceModel.apply(this, arguments); + _.extend(this, OnceModel.protoype); + } else { + _.extend(this, SyncModel.protoype); + SyncModel.apply(this, arguments); + } + + }, + + + /** + * Siliently set the id of the model to the snapshot key + */ + _setId: function(snap) { + // if the item new set the name to the id + if(this.isNew()) { + this.set('id', Backbone.Firebase._getKey(snap), { silent: true }); + } + }, + + /** + * Proccess changes from a snapshot and apply locally + */ + _setLocal: function(snap) { + var newModel = this._unsetAttributes(snap); + this.set(newModel); + }, + + /** + * Unset attributes that have been deleted from the server + * by comparing the keys that have been removed. + */ + _unsetAttributes: function(snap) { + var newModel = Backbone.Firebase._checkId(snap); + + if (typeof newModel === 'object' && newModel !== null) { + var diff = _.difference(_.keys(this.attributes), _.keys(newModel)); + _.each(diff, _.bind(function(key) { + this.unset(key); + }, this)); + } + + // check to see if it needs an id + this._setId(snap); + + return newModel; + }, + + /** + * Find the deleted keys and set their values to null + * so Firebase properly deletes them. + */ + _updateModel: function(model) { + var modelObj = model.changedAttributes(); + _.each(model.changed, function(value, key) { + if (typeof value === 'undefined' || value === null) { + if (key == 'id') { + delete modelObj[key]; + } else { + modelObj[key] = null; + } + } + }); + + return modelObj; + }, + + + /** + * Determine if the model will update for every local change. + * Provide a callback function to call events after the update. + */ + _listenLocalChange: function(cb) { + var method = cb ? 'on' : 'off'; + this[method]('change', function(model) { + var newModel = this._updateModel(model); + if(_.isFunction(cb)){ + cb.call(this, newModel); + } + }, this); + } + + }); + + var OnceCollection = (function() { + function OnceCollection() { + + } + OnceCollection.protoype = { + /** + * Create an id from a Firebase push-id and call Backbone.create, which + * will do prepare the models and trigger the proper events and then call + * Backbone.Firebase.sync with the correct method. + */ + create: function(model, options) { + model.id = Backbone.Firebase._getKey(this.firebase.push()); + options = _.extend({ autoSync: false }, options); + return Backbone.Collection.prototype.create.apply(this, [model, options]); + }, + /** + * Create an id from a Firebase push-id and call Backbone.add, which + * will do prepare the models and trigger the proper events and then call + * Backbone.Firebase.sync with the correct method. + */ + add: function(model, options) { + model.id = Backbone.Firebase._getKey(this.firebase.push()); + options = _.extend({ autoSync: false }, options); + return Backbone.Collection.prototype.add.apply(this, [model, options]); + }, + /** + * Proxy to Backbone.Firebase.sync + */ + sync: function(method, model, options) { + Backbone.Firebase.sync(method, model, options); + }, + /** + * Firebase returns lists as an object with keys, where Backbone + * collections require an array. This function modifies the existing + * Backbone.Collection.fetch method by mapping the returned object from + * Firebase to an array that Backbone can use. + */ + fetch: function(options) { + options = options ? _.clone(options) : {}; + if (options.parse === void 0) { options.parse = true; } + var success = options.success; + var collection = this; + options.success = function(resp) { + var arr = []; + var keys = _.keys(resp); + _.each(keys, function(key) { + arr.push(resp[key]); + }); + var method = options.reset ? 'reset' : 'set'; + collection[method](arr, options); + if (success) { success(collection, arr, options); } + options.autoSync = false; + options.url = this.url; + collection.trigger('sync', collection, arr, options); + }; + return this.sync('read', this, options); + } + }; + + return OnceCollection; + }()); + + var SyncCollection = (function() { + + function SyncCollection() { + + // Add handlers for remote events + this.firebase.on('child_added', _.bind(this._childAdded, this)); + this.firebase.on('child_moved', _.bind(this._childMoved, this)); + this.firebase.on('child_changed', _.bind(this._childChanged, this)); + this.firebase.on('child_removed', _.bind(this._childRemoved, this)); + + // Once handler to emit 'sync' event whenever data changes + this.firebase.on('value', _.bind(function() { + this.trigger('sync', this, null, null); + }, this)); + + // Handle changes in any local models. + this.listenTo(this, 'change', this._updateModel, this); + // Listen for destroy event to remove models. + this.listenTo(this, 'destroy', this._removeModel, this); + } + + SyncCollection.protoype = { + comparator: function(model) { + return model.id; + }, + + add: function(models, options) { + var parsed = this._parseModels(models); + options = options ? _.clone(options) : {}; + options.success = + _.isFunction(options.success) ? options.success : function() {}; + + for (var i = 0; i < parsed.length; i++) { + var model = parsed[i]; + + if (options.silent === true) { + this._suppressEvent = true; + } + + var childRef = this.firebase.ref().child(model.id); + childRef.set(model, _.bind(options.success, model)); + } + + return parsed; + }, + + create: function(model, options) { + options = options ? _.clone(options) : {}; + if (options.wait) { + this._log('Wait option provided to create, ignoring.'); + } + if (!model) { + return false; + } + var set = this.add([model], options); + return set[0]; + }, + + remove: function(models, options) { + var parsed = this._parseModels(models); + options = options ? _.clone(options) : {}; + options.success = + _.isFunction(options.success) ? options.success : function() {}; + + for (var i = 0; i < parsed.length; i++) { + var model = parsed[i]; + var childRef = this.firebase.child(model.id); + if (options.silent === true) { + this._suppressEvent = true; + } + Backbone.Firebase._setWithCheck(childRef, null, options); + } + + return parsed; + }, + + reset: function(models, options) { + options = options ? _.clone(options) : {}; + // Remove all models remotely. + this.remove(this.models, {silent: true}); + // Add new models. + var ret = this.add(models, {silent: true}); + // Trigger 'reset' event. + if (!options.silent) { + this.trigger('reset', this, options); + } + return ret; + }, + + _log: function(msg) { + if (console && console.log) { + console.log(msg); + } + }, + + _parseModels: function(models, options) { + var pushArray = []; + // check if the models paramter is an array or a single object + var singular = !_.isArray(models); + // if the models parameter is a single object then wrap it into an array + models = singular ? (models ? [models] : []) : models.slice(); + + for (var i = 0; i < models.length; i++) { + var model = models[i]; + + if (!model.id) { + model.id = Backbone.Firebase._getKey(this.firebase.push()); + } + + // call Backbone's prepareModel to apply options + model = Backbone.Collection.prototype._prepareModel.apply( + this, [model, options || {}] + ); + + if (model.toJSON && typeof model.toJSON == 'function') { + model = model.toJSON(); + } + + pushArray.push(model); + + } + + return pushArray; + }, + + _childAdded: function(snap) { + var model = Backbone.Firebase._checkId(snap); + + if (this._suppressEvent === true) { + this._suppressEvent = false; + Backbone.Collection.prototype.add.apply(this, [model], {silent: true}); + } else { + Backbone.Collection.prototype.add.apply(this, [model]); + } + this.get(model.id)._remoteAttributes = model; + }, + + _childMoved: function(snap) { + // TODO: Investigate: can this occur without the ID changing? + this._log('_childMoved called with ' + snap.val()); + }, + + // when a model has changed remotely find differences between the + // local and remote data and apply them to the local model + _childChanged: function(snap) { + var model = Backbone.Firebase._checkId(snap); + + var item = _.find(this.models, function(child) { + return child.id == model.id; + }); + + if (!item) { + // TODO: Investigate: what is the right way to handle this case? + //throw new Error('Could not find model with ID ' + model.id); + this._childAdded(snap); + return; + } + + this._preventSync(item, true); + item._remoteAttributes = model; + + // find the attributes that have been deleted remotely and + // unset them locally + var diff = _.difference(_.keys(item.attributes), _.keys(model)); + _.each(diff, function(key) { + item.unset(key); + }); + + item.set(model); + // fire sync since this is a response from the server + this.trigger('sync', this); + this._preventSync(item, false); + }, + + // remove an item from the collection when removed remotely + // provides the ability to remove siliently + _childRemoved: function(snap) { + var model = Backbone.Firebase._checkId(snap); + + if (this._suppressEvent === true) { + this._suppressEvent = false; + Backbone.Collection.prototype.remove.apply( + this, [model], {silent: true} + ); + } else { + // trigger sync because data has been received from the server + this.trigger('sync', this); + Backbone.Collection.prototype.remove.apply(this, [model]); + } + }, + + // Add handlers for all models in this collection, and any future ones + // that may be added. + _updateModel: function(model) { + var remoteAttributes; + var localAttributes; + var updateAttributes; + var ref; + + // if the model is already being handled by listeners then return + if (model._remoteChanging) { + return; + } + + remoteAttributes = model._remoteAttributes || {}; + localAttributes = model.toJSON(); + + // consolidate the updates to Firebase + updateAttributes = this._compareAttributes(remoteAttributes, localAttributes); + + ref = this.firebase.ref().child(model.id); + + // if '.priority' is present setWithPriority + // else do a regular update + if (_.has(updateAttributes, '.priority')) { + this._setWithPriority(ref, localAttributes); + } else { + this._updateToFirebase(ref, localAttributes); + } + + }, + + // set the attributes to be updated to Firebase + // set any removed attributes to null so that Firebase removes them + _compareAttributes: function(remoteAttributes, localAttributes) { + var updateAttributes = {}; + + var union = _.union(_.keys(remoteAttributes), _.keys(localAttributes)); + + _.each(union, function(key) { + if (!_.has(localAttributes, key)) { + updateAttributes[key] = null; + } else if (localAttributes[key] != remoteAttributes[key]) { + updateAttributes[key] = localAttributes[key]; + } + }); + + return updateAttributes; + }, + + // Special case if '.priority' was updated - a merge is not + // allowed so we'll have to do a full setWithPriority. + _setWithPriority: function(ref, item) { + var priority = item['.priority']; + delete item['.priority']; + ref.setWithPriority(item, priority); + return item; + }, + + // TODO: possibly pass in options for onComplete callback + _updateToFirebase: function(ref, item) { + ref.update(item); + }, + + // Triggered when model.destroy() is called on one of the children. + _removeModel: function(model, collection, options) { + options = options ? _.clone(options) : {}; + options.success = + _.isFunction(options.success) ? options.success : function() {}; + var childRef = this.firebase.child(model.id); + Backbone.Firebase._setWithCheck(childRef, null, _.bind(options.success, model)); + }, + + _preventSync: function(model, state) { + model._remoteChanging = state; + } + }; + + return SyncCollection; + }()); + + Backbone.Firebase.Collection = Backbone.Collection.extend({ + + constructor: function (model, options) { + Backbone.Collection.apply(this, arguments); + var self = this; + var BaseModel = self.model; + this.autoSync = Backbone.Firebase._determineAutoSync(this, options); + + switch (typeof this.url) { + case 'string': + this.firebase = Backbone.Firebase._determineRef(this.url); + break; + case 'function': + this.firebase = Backbone.Firebase._determineRef(this.url()); + break; + case 'object': + this.firebase = Backbone.Firebase._determineRef(this.url); + break; + default: + throw new Error('url parameter required'); + } + + // if we are not autoSyncing, the model needs + // to be a non-autoSynced model + if(!this.autoSync) { + _.extend(this, OnceCollection.protoype); + OnceCollection.apply(this, arguments); + } else { + _.extend(this, SyncCollection.protoype); + SyncCollection.apply(this, arguments); + } + + // Intercept the given model and give it a firebase ref. + // Have it listen to local changes silently. When attributes + // are unset, the callback will set them to null so that they + // are removed on the Firebase server. + this.model = function(attrs, opts) { + + var newItem = new BaseModel(attrs, opts); + newItem.autoSync = false; + newItem.firebase = self.firebase.child(newItem.id); + newItem.sync = Backbone.Firebase.sync; + newItem.on('change', function(model) { + var updated = Backbone.Firebase.Model.prototype._updateModel(model); + model.set(updated, { silent: true }); + }); + + return newItem; + + }; + + } + + }); + +})(window._, window.Backbone); diff --git a/src/backfire.js b/src/backfire.js deleted file mode 100644 index ad78c4f..0000000 --- a/src/backfire.js +++ /dev/null @@ -1,543 +0,0 @@ -/*! - * BackFire is the officially supported Backbone binding for Firebase. The - * bindings let you use special model and collection types that will - * automatically synchronize with Firebase, and also allow you to use regular - * Backbone.Sync based synchronization methods. - * - * BackFire 0.0.0 - * https://github.com/firebase/backfire/ - * License: MIT - */ - -"use strict"; - -(function() { - - var _ = window._; - var Backbone = window.Backbone; - - Backbone.Firebase = function(ref) { - this._fbref = ref; - this._children = []; - if (typeof ref == "string") { - this._fbref = new Firebase(ref); - } - - this._fbref.on("child_added", this._childAdded, this); - this._fbref.on("child_moved", this._childMoved, this); - this._fbref.on("child_changed", this._childChanged, this); - this._fbref.on("child_removed", this._childRemoved, this); - }; - - _.extend(Backbone.Firebase.prototype, { - - /** - * A utility for retrieving the key name of a Firebase ref or - * DataSnapshot. This is backwards-compatible with `name()` - * from Firebase 1.x.x and `key()` from Firebase 2.0.0+. Once - * support for Firebase 1.x.x is dropped in BackFire, this - * helper can be removed. - */ - _getKey: function(refOrSnapshot) { - return (typeof refOrSnapshot.key === 'function') ? refOrSnapshot.key() : refOrSnapshot.name(); - }, - - _childAdded: function(childSnap, prevChild) { - var model = childSnap.val(); - model.id = this._getKey(childSnap); - if (prevChild) { - var item = _.find(this._children, function(child) { - return child.id == prevChild; - }); - this._children.splice(this._children.indexOf(item) + 1, 0, model); - } else { - this._children.unshift(model); - } - }, - - _childMoved: function(childSnap, prevChild) { - var model = childSnap.val(); - this._children = _.reject(this._children, function(child) { - return child.id == model.id; - }); - this._childAdded(childSnap, prevChild); - }, - - _childChanged: function(childSnap) { - var model = childSnap.val(); - model.id = this._getKey(childSnap); - var item = _.find(this._children, function(child) { - return child.id == model.id; - }); - this._children[this._children.indexOf(item)] = model; - }, - - _childRemoved: function(oldChildSnap) { - var model = oldChildSnap.val(); - this._children = _.reject(this._children, function(child) { - return child.id == model.id; - }); - }, - - create: function(model, cb) { - if (!model.id) { - model.id = this._getKey(this._fbref.ref().push()); - } - - var val = model.toJSON(); - this._fbref.ref().child(model.id).set(val, _.bind(function(err) { - if (!err) { - cb(null, val); - } else { - cb("Could not create model " + model.id); - } - }, this)); - }, - - read: function(model, cb) { - if (!model.id) { - _.defer(cb, "Invalid model ID provided to read"); - return; - } - - var index = _.find(this._children, function(child) { - return child.id == model.id; - }); - - _.defer(cb, null, this._children[index]); - }, - - readAll: function(model, cb) { - _.defer(cb, null, this._children); - }, - - update: function(model, cb) { - var val = model.toJSON(); - this._fbref.ref().child(model.id).update(val, function(err) { - if (!err) { - cb(null, val); - } else { - cb("Could not update model " + model.id, null); - } - }); - }, - - "delete": function(model, cb) { - this._fbref.ref().child(model.id).remove(function(err) { - if (!err) { - cb(null, model); - } else { - cb("Could not delete model " + model.id); - } - }); - }, - - ref: function() { - return this._fbref; - } - }); - - - Backbone.Firebase.sync = function(method, model, options, error) { - var store = model.firebase || model.collection.firebase; - - // Backwards compatibility with Backbone <= 0.3.3 - if (typeof options == "function") { - options = { - success: options, - error: error - }; - } - - if (method == "read" && model.id === undefined) { - method = "readAll"; - } - - store[method].apply(store, [model, function(err, val) { - if (err) { - model.trigger("error", model, err, options); - if (Backbone.VERSION === "0.9.10") { - options.error(model, err, options); - } else { - options.error(err); - } - } else { - model.trigger("sync", model, val, options); - if (Backbone.VERSION === "0.9.10") { - options.success(model, val, options); - } else { - options.success(val); - } - } - }]); - }; - - Backbone.oldSync = Backbone.sync; - - // Override "Backbone.sync" to default to Firebase sync. - // the original "Backbone.sync" is still available in "Backbone.oldSync" - Backbone.sync = function(method, model, options, error) { - var syncMethod = Backbone.oldSync; - if (model.firebase || (model.collection && model.collection.firebase)) { - syncMethod = Backbone.Firebase.sync; - } - return syncMethod.apply(this, [method, model, options, error]); - }; - - // Custom Firebase Collection. - Backbone.Firebase.Collection = Backbone.Collection.extend({ - sync: function() { - this._log("Sync called on a Firebase collection, ignoring."); - }, - - fetch: function() { - this._log("Fetch called on a Firebase collection, ignoring."); - }, - - constructor: function(models, options) { - // Apply parent constructor (this will also call initialize). - Backbone.Collection.apply(this, arguments); - - if (options && options.firebase) { - this.firebase = options.firebase; - } - switch (typeof this.firebase) { - case "object": - break; - case "string": - this.firebase = new Firebase(this.firebase); - break; - case "function": - this.firebase = this.firebase(); - break; - default: - throw new Error("Invalid firebase reference created"); - } - - // Add handlers for remote events. - this.firebase.on("child_added", _.bind(this._childAdded, this)); - this.firebase.on("child_moved", _.bind(this._childMoved, this)); - this.firebase.on("child_changed", _.bind(this._childChanged, this)); - this.firebase.on("child_removed", _.bind(this._childRemoved, this)); - - // Once handler to emit "sync" event. - this.firebase.once("value", _.bind(function() { - this.trigger("sync", this, null, null); - }, this)); - - // Handle changes in any local models. - this.listenTo(this, "change", this._updateModel, this); - // Listen for destroy event to remove models. - this.listenTo(this, "destroy", this._removeModel, this); - - // Don't suppress local events by default. - this._suppressEvent = false; - }, - - comparator: function(model) { - return model.id; - }, - - add: function(models, options) { - var parsed = this._parseModels(models); - options = options ? _.clone(options) : {}; - options.success = - _.isFunction(options.success) ? options.success : function() {}; - - for (var i = 0; i < parsed.length; i++) { - var model = parsed[i]; - var childRef = this.firebase.ref().child(model.id); - if (options.silent === true) { - this._suppressEvent = true; - } - childRef.set(model, _.bind(options.success, model)); - } - - return parsed; - }, - - remove: function(models, options) { - var parsed = this._parseModels(models); - options = options ? _.clone(options) : {}; - options.success = - _.isFunction(options.success) ? options.success : function() {}; - - for (var i = 0; i < parsed.length; i++) { - var model = parsed[i]; - var childRef = this.firebase.ref().child(model.id); - if (options.silent === true) { - this._suppressEvent = true; - } - childRef.set(null, _.bind(options.success, model)); - } - - return parsed; - }, - - create: function(model, options) { - options = options ? _.clone(options) : {}; - if (options.wait) { - this._log("Wait option provided to create, ignoring."); - } - model = Backbone.Collection.prototype._prepareModel.apply( - this, [model, options] - ); - if (!model) { - return false; - } - var set = this.add([model], options); - return set[0]; - }, - - reset: function(models, options) { - options = options ? _.clone(options) : {}; - // Remove all models remotely. - this.remove(this.models, {silent: true}); - // Add new models. - var ret = this.add(models, {silent: true}); - // Trigger "reset" event. - if (!options.silent) { - this.trigger("reset", this, options); - } - return ret; - }, - - _log: function(msg) { - if (console && console.log) { - console.log(msg); - } - }, - - // TODO: Options will be ignored for add & remove, document this! - _parseModels: function(models) { - var ret = []; - models = _.isArray(models) ? models.slice() : [models]; - for (var i = 0; i < models.length; i++) { - var model = models[i]; - if (model.toJSON && typeof model.toJSON == "function") { - model = model.toJSON(); - } - if (!model.id) { - model.id = Backbone.Firebase.prototype._getKey(this.firebase.ref().push()); - } - ret.push(model); - } - return ret; - }, - - _childAdded: function(snap) { - var model = snap.val(); - if (!model.id) { - if (!_.isObject(model)) { - model = {}; - } - model.id = Backbone.Firebase.prototype._getKey(snap); - } - if (this._suppressEvent === true) { - this._suppressEvent = false; - Backbone.Collection.prototype.add.apply(this, [model], {silent: true}); - } else { - Backbone.Collection.prototype.add.apply(this, [model]); - } - this.get(model.id)._remoteAttributes = model; - }, - - _childMoved: function(snap) { - // TODO: Investigate: can this occur without the ID changing? - this._log("_childMoved called with " + snap.val()); - }, - - _childChanged: function(snap) { - var model = snap.val(); - if (!model.id) { - model.id = Backbone.Firebase.prototype._getKey(snap); - } - - var item = _.find(this.models, function(child) { - return child.id == model.id; - }); - - if (!item) { - // TODO: Investigate: what is the right way to handle this case? - throw new Error("Could not find model with ID " + model.id); - } - - this._preventSync(item, true); - item._remoteAttributes = model; - - var diff = _.difference(_.keys(item.attributes), _.keys(model)); - _.each(diff, function(key) { - item.unset(key); - }); - - item.set(model); - this._preventSync(item, false); - }, - - _childRemoved: function(snap) { - var model = snap.val(); - if (!model.id) { - model.id = Backbone.Firebase.prototype._getKey(snap); - } - if (this._suppressEvent === true) { - this._suppressEvent = false; - Backbone.Collection.prototype.remove.apply( - this, [model], {silent: true} - ); - } else { - Backbone.Collection.prototype.remove.apply(this, [model]); - } - }, - - // Add handlers for all models in this collection, and any future ones - // that may be added. - _updateModel: function(model) { - if (model._remoteChanging) { - return; - } - - var remoteAttributes = model._remoteAttributes || {}; - var localAttributes = model.toJSON(); - var updateAttributes = {}; - - var union = _.union(_.keys(remoteAttributes), _.keys(localAttributes)); - _.each(union, function(key) { - if (!_.has(localAttributes, key)) { - updateAttributes[key] = null; - } else if (localAttributes[key] != remoteAttributes[key]) { - updateAttributes[key] = localAttributes[key]; - } - }); - - if (_.size(updateAttributes)) { - // Special case if ".priority" was updated - a merge is not - // allowed so we'll have to do a full setWithPriority. - if (_.has(updateAttributes, ".priority")) { - var ref = this.firebase.ref().child(model.id); - var priority = localAttributes[".priority"]; - delete localAttributes[".priority"]; - ref.setWithPriority(localAttributes, priority); - } else { - this.firebase.ref().child(model.id).update(updateAttributes); - } - } - }, - - // Triggered when model.destroy() is called on one of the children. - _removeModel: function(model, collection, options) { - options = options ? _.clone(options) : {}; - options.success = - _.isFunction(options.success) ? options.success : function() {}; - var childRef = this.firebase.ref().child(model.id); - childRef.set(null, _.bind(options.success, model)); - }, - - _preventSync: function(model, state) { - model._remoteChanging = state; - } - }); - - // Custom Firebase Model. - Backbone.Firebase.Model = Backbone.Model.extend({ - save: function() { - this._log("Save called on a Firebase model, ignoring."); - }, - - destroy: function(options) { - // TODO: Fix naive success callback. Add error callback. - this.firebase.ref().set(null, this._log); - this.trigger("destroy", this, this.collection, options); - if (options.success) { - options.success(this, null, options); - } - }, - - constructor: function(model, options) { - // Store defaults so they don't get applied immediately. - var defaults = _.result(this, "defaults"); - - // Apply defaults only after first sync. - this.once("sync", function() { - this.set(_.defaults(this.toJSON(), defaults)); - }); - - // Apply parent constructor (this will also call initialize). - Backbone.Model.apply(this, arguments); - - if (options && options.firebase) { - this.firebase = options.firebase; - } - switch (typeof this.firebase) { - case "object": - break; - case "string": - this.firebase = new Firebase(this.firebase); - break; - case "function": - this.firebase = this.firebase(); - break; - default: - throw new Error("Invalid firebase reference created"); - } - - // Add handlers for remote events. - this.firebase.on("value", _.bind(this._modelChanged, this)); - - this._listenLocalChange(true); - }, - - _listenLocalChange: function(state) { - if (state) { - this.on("change", this._updateModel, this); - } else { - this.off("change", this._updateModel, this); - } - }, - - _updateModel: function(model) { - // Find the deleted keys and set their values to null - // so Firebase properly deletes them. - var modelObj = model.changedAttributes(); - _.each(model.changed, function(value, key) { - if (typeof value === "undefined" || value === null) { - if (key == "id") { - delete modelObj[key]; - } else { - modelObj[key] = null; - } - } - }); - if (_.size(modelObj)) { - this.firebase.ref().update(modelObj, this._log); - } - }, - - _modelChanged: function(snap) { - // Unset attributes that have been deleted from the server - // by comparing the keys that have been removed. - var newModel = snap.val(); - if (typeof newModel === "object" && newModel !== null) { - var diff = _.difference(_.keys(this.attributes), _.keys(newModel)); - var self = this; - _.each(diff, function(key) { - self.unset(key); - }); - } - this._listenLocalChange(false); - this.set(newModel); - this._listenLocalChange(true); - this.trigger("sync", this, null, null); - }, - - _log: function(msg) { - if (typeof msg === "undefined" || msg === null) { - return; - } - if (console && console.log) { - console.log(msg); - } - } - - }); - -})(); diff --git a/test/fixtures.coffee b/test/fixtures.coffee deleted file mode 100644 index 8223e3c..0000000 --- a/test/fixtures.coffee +++ /dev/null @@ -1,12 +0,0 @@ -class window.Firebase - constructor: -> - @name = sinon.stub().returns(@) - @set = sinon.stub().returns(@) - @child = sinon.stub().returns(@) - @ref = sinon.stub().returns(@) - @push = sinon.stub().returns(@) - @update = sinon.stub().returns(@) - @auth = sinon.stub().returns(@) - @on = sinon.stub().returns(@) - @off = sinon.stub().returns(@) - @once = sinon.stub().returns(@) diff --git a/test/fixtures.js b/test/fixtures.js new file mode 100644 index 0000000..903c034 --- /dev/null +++ b/test/fixtures.js @@ -0,0 +1,50 @@ +MockFirebase.override(); + +function MockSnap(params) { + params = params || {}; + + this._key = params.key; + this._key = params.name; + this._val = params.val; + + this.name = function() { + return this._key; + }; + this.key = function() { + return this._key; + }; + this.val = function() { + return this._val; + }; + this.setKey = function(key) { + this._key = key; + }; + this.setVal = function(val) { + this._val = val; + }; +} + +if (!Function.prototype.bind) { + Function.prototype.bind = function(oThis) { + if (typeof this !== 'function') { + // closest thing possible to the ECMAScript 5 + // internal IsCallable function + throw new TypeError('Function.prototype.bind - what is trying to be bound is not callable'); + } + + var aArgs = Array.prototype.slice.call(arguments, 1), + fToBind = this, + fNOP = function() {}, + fBound = function() { + return fToBind.apply(this instanceof fNOP && oThis + ? this + : oThis, + aArgs.concat(Array.prototype.slice.call(arguments))); + }; + + fNOP.prototype = this.prototype; + fBound.prototype = new fNOP(); + + return fBound; + }; +} \ No newline at end of file diff --git a/test/karma.conf.js b/test/karma.conf.js index 0dd76a0..9beb095 100644 --- a/test/karma.conf.js +++ b/test/karma.conf.js @@ -3,16 +3,16 @@ module.exports = function(config) { frameworks: ['mocha', 'chai', 'sinon'], preprocessors: { - '**/*.coffee': ['coffee'], '../src/*.js': 'coverage' }, files: [ '../bower_components/underscore/underscore.js', '../bower_components/backbone/backbone.js', - '../test/fixtures.coffee', - '../src/backfire.js', - 'specs/*_test.coffee' + '../bower_components/mockfirebase/browser/mockfirebase.js', + 'fixtures.js', + '../src/backbonefire.js', + './specs/*_test.js' ], reporters: ['spec', 'failed', 'coverage'], diff --git a/test/specs/collection_test.js b/test/specs/collection_test.js new file mode 100644 index 0000000..ec780d0 --- /dev/null +++ b/test/specs/collection_test.js @@ -0,0 +1,806 @@ +describe('Backbone.Firebase.Collection', function() { + + it('should exist', function() { + return expect(Backbone.Firebase.Collection).to.be.ok; + }); + + it('should extend', function() { + var Collection = Backbone.Firebase.Collection.extend({ + url: 'Mock://' + }); + return expect(Collection).to.be.ok; + }); + + it('should extend construct', function() { + var Collection = Backbone.Firebase.Collection.extend({ + url: 'Mock://' + }); + return expect(new Collection()).to.be.ok; + }); + + // throw err + it('should throw an error if an invalid url is provided', function() { + var Collection = Backbone.Firebase.Collection.extend({ + url: true + }); + try { + var model = new Collection(); + } catch (err) { + assert(err.message === 'url parameter required'); + } + }); + + it('should call Backbone.Firebase._determineRef with url as a function', function() { + sinon.spy(Backbone.Firebase, '_determineRef'); + var Collection = Backbone.Firebase.Collection.extend({ + url: function() { + return ''; + } + }); + var collection = new Collection(); + expect(Backbone.Firebase._determineRef.calledOnce).to.be.ok; + Backbone.Firebase._determineRef.restore(); + }); + + it('should call Backbone.Firebase._determineRef with url as a string', function() { + sinon.spy(Backbone.Firebase, '_determineRef'); + var Collection = Backbone.Firebase.Collection.extend({ + url: 'Mock://' + }); + var collection = new Collection(); + expect(Backbone.Firebase._determineRef.calledOnce).to.be.ok; + Backbone.Firebase._determineRef.restore(); + }); + + it('should call Backbone.Firebase._determineRef with url as a Firebase reference', function() { + sinon.spy(Backbone.Firebase, '_determineRef'); + var ref = new Firebase('Mock://'); + var Collection = Backbone.Firebase.Collection.extend({ + url: ref + }); + var collection = new Collection(); + expect(Backbone.Firebase._determineRef.calledOnce).to.be.ok; + Backbone.Firebase._determineRef.restore(); + }); + + it('should call Backbone.Firebase._determineRef with url as a fn returning a Firebase reference', function() { + sinon.spy(Backbone.Firebase, '_determineRef'); + var Collection = Backbone.Firebase.Collection.extend({ + url: function() { + return new Firebase('Mock://'); + } + }); + var collection = new Collection(); + expect(Backbone.Firebase._determineRef.calledOnce).to.be.ok; + Backbone.Firebase._determineRef.restore(); + }); + + describe('#_compareAttributes', function() { + // should null remotely out deleted values + var collection; + beforeEach(function() { + var Collection = Backbone.Firebase.Collection.extend({ + url: 'Mock://', + autoSync: true + }); + + collection = new Collection(); + }); + + it('should should set deleted values to null', function() { + + var remoteAttributes = { + id: '1', + name: 'David' + }; + + var localAttributes = { + id: '1' + }; + + var updatedAttributes = collection._compareAttributes(remoteAttributes, localAttributes); + + // name should be set to null since the local property for name was removed + assert(updatedAttributes.name === null); + }); + + it('should return updated attributes', function() { + + var remoteAttributes = { + id: '1', + name: 'Kato' + }; + + var localAttributes = { + id: '1', + name: 'David', + age: 26 + }; + + // name and age should be populated in an object because they were updated in the local attributes + var updatedAttributes = collection._compareAttributes(remoteAttributes, localAttributes); + expect(updatedAttributes).to.be.defined; + expect(updatedAttributes.id).to.be.undefined; + assert(updatedAttributes.name === 'David'); + assert(updatedAttributes.age === 26); + }); + + }); + + describe('#_setWithPriority', function() { + var collection; + var ref; + var item; + beforeEach(function() { + var Collection = Backbone.Firebase.Collection.extend({ + url: 'Mock://', + autoSync: true + }); + + collection = new Collection(); + item = { + id: '1', + '.priority': 1, + name: 'David' + } + }); + + it('should call setWithPriority on a Firebase reference', function() { + collection._setWithPriority(collection.firebase, item); + //collection.firebase.flush(); + expect(collection.firebase.setWithPriority.calledOnce).to.be.ok; + }); + + it('should delete local priority', function() { + var setItem = collection._setWithPriority(collection.firebase, item); + expect(setItem['.priority']).to.be.undefined; + }); + + }); + + describe('#_updateToFirebase', function() { + + it('should call update on a Firebase reference', function() { + var Collection = Backbone.Firebase.Collection.extend({ + url: 'Mock://' + }); + collection = new Collection(); + collection._updateToFirebase(collection.firebase, { id: '1' }); + expect(collection.firebase.update.calledOnce).to.be.ok; + }); + + }); + + describe('#_parseModels()', function() { + var Collection = Backbone.Firebase.Collection.extend({ + url: 'Mock://' + }); + + var collection = new Collection(); + + it('should be a method', function() { + return expect(collection).to.have.property('_parseModels').that.is.a('function'); + }); + + it('should return an empty array when called without parameters', function() { + var result = collection._parseModels(); + return expect(result).to.eql([]); + }); + + describe('calling Backbone.Collection.prototype._prepareModel', function() { + var Users, Users, collection; + + beforeEach(function() { + User = Backbone.Model.extend({}), + Users = Backbone.Firebase.Collection.extend({ + url: 'Mock://', + initialize: function(models, options) { + this.model = function(attrs, opts) { + return new User(_.extend(attrs, { addedFromCollection: true}), opts); + }; + } + }); + collection = new Users(); + }); + + it('should call Backbone.Collection.prototype._prepareModel', function() { + sinon.spy(Backbone.Collection.prototype, '_prepareModel'); + collection.add({ firstname: 'Dave' }); + expect(Backbone.Collection.prototype._prepareModel.calledOnce).to.be.ok; + Backbone.Collection.prototype._prepareModel.restore(); + }); + + it('should prepare models', function() { + var addedArray = collection.add({ firstname: 'Dave' }); + var addedObject = addedArray[0]; + expect(addedObject.addedFromCollection).to.be.ok; + }); + + }); + + }); + + describe('SyncCollection', function() { + + var collection; + beforeEach(function() { + User = Backbone.Model.extend({}), + Users = Backbone.Firebase.Collection.extend({ + url: 'Mock://', + initialize: function(models, options) { + this.model = function(attrs, opts) { + return new User(_.extend(attrs, { addedFromCollection: true}), opts); + }; + } + }); + collection = new Users(); + }); + + it('should enable autoSync by default', function() { + var Model = Backbone.Firebase.Collection.extend({ + url: 'Mock://' + }); + + var model = new Model(); + + return expect(model.autoSync).to.be.ok; + }); + + it('should call sync when added', function() { + var spy = sinon.spy(); + var Models = Backbone.Firebase.Collection.extend({ + url: 'Mock://', + autoSync: true + }); + + var models = new Models(); + + models.on('sync', spy); + + models.add({ title: 'blah' }); + models.firebase.flush(); + return expect(spy.called).to.be.ok; + }); + + describe('#create', function() { + + // ignore wait + it('should ignore options.wait', function() { + sinon.spy(collection, '_log'); + collection.create({ firstname: 'David'}, { wait: function() { }}); + collection.firebase.flush(); + + expect(collection._log.calledOnce).to.be.ok; + + collection._log.restore(); + }); + + // call SyncCollection.add + it('should call SyncCollection.add', function() { + sinon.spy(collection, 'add'); + + collection.create({ firstname: 'David'}); + collection.firebase.flush(); + + expect(collection.add.calledOnce).to.be.true; + + collection.add.restore(); + }); + + // return false for no model + it('should return false when no model is provided', function() { + var expectFalse = collection.create(); + collection.firebase.flush(); + expect(expectFalse).to.be.false; + }); + + }); + + describe('#remove', function() { + + // call _setWithCheck + it('should call Backbone.Firebase._setWithCheck', function() { + sinon.spy(Backbone.Firebase, '_setWithCheck') + + collection.remove({ id: '1'}); + collection.firebase.flush(); + + expect(Backbone.Firebase._setWithCheck.calledOnce).to.be.ok; + + Backbone.Firebase._setWithCheck.restore(); + }); + + // call silently + it('should set _suppressEvent to true when set silently', function() { + collection.remove({ id: '1'}, { silent: true }); + // TODO: investigate + //collection.firebase.flush(); + expect(collection._suppressEvent).to.be.ok; + }); + + }); + + describe('#_childMoved', function() { + + it('shoud call _log', function() { + sinon.spy(collection, '_log'); + var mockSnap = new MockSnap({ + name: '1', + val: { + name: 'David' + } + }); + collection._childMoved(mockSnap); + + expect(collection._log.calledOnce).to.be.ok; + + collection._log.restore(); + }); + + }); + + describe('#reset', function() { + + // call remove + it('should call SyncCollection.remove', function() { + sinon.spy(collection, 'remove'); + + collection.reset({ id: '1'}); + collection.firebase.flush(); + + expect(collection.remove.calledOnce).to.be.ok; + + collection.remove.restore(); + }); + + // call add + it('should call SyncCollection.add', function() { + sinon.spy(collection, 'add'); + + collection.reset({ id: '1'}); + collection.firebase.flush(); + + expect(collection.add.calledOnce).to.be.ok; + + collection.add.restore(); + }); + + // don't trigger reset when silent + it('should not trigger the resete event when silent is passed', function() { + var spy = sinon.spy(); + + collection.on('reset', spy); + + collection.reset({ id: '1'}, { silent: true }); + collection.firebase.flush(); + + expect(spy.calledOnce).to.be.false; + }); + + it('should trigger the resete event when silent is passed', function() { + var spy = sinon.spy(); + + collection.on('reset', spy); + + collection.reset({ id: '1'}); + collection.firebase.flush(); + + expect(spy.calledOnce).to.be.true; + }); + + }); + + describe('#_log', function() { + + beforeEach(function() { + sinon.spy(console, 'log'); + }); + + afterEach(function() { + console.log.restore(); + }); + + it('should call console.log', function() { + collection._log('logging'); + expect(console.log.calledOnce).to.be.true; + }); + + }); + + describe('#_preventSync', function() { + var collection; + var model = {}; + beforeEach(function() { + var Collection = Backbone.Firebase.Collection.extend({ + url: 'Mock://', + autoSync: true + }); + + collection = new Collection(); + }) + + it('should change from false to true', function() { + + collection._preventSync(model, true); + expect(model._remoteChanging).to.be.ok; + + }); + + it('should change from true to false', function() { + + collection._preventSync(model, false); + expect(model._remoteChanging).to.be.false; + + }); + + }); + + describe('#_childChanged', function() { + + var collection; + beforeEach(function() { + var Collection = Backbone.Firebase.Collection.extend({ + url: 'Mock://', + autoSync: true + }); + + collection = new Collection(); + + collection.models = [ + new Backbone.Model({ + id: '1', + name: 'David', + age: 26 + }) + ]; + + }); + + it('should unset local property from remote deletion', function() { + + var mockSnap = new MockSnap({ + name: '1', + val: { + id: '1', + name: 'David' + // age has been removed + } + }); + + collection._childChanged(mockSnap); + + var changedModel = collection.models[0]; + + expect(changedModel.age).to.be.undefined; + + }); + + it('should update local model from remote update', function () { + + var mockSnap = new MockSnap({ + name: '1', + val: { + id: '1', + name: 'David', + age: 26, + favDino: 'trex' + // trex has been added + } + }); + + collection._childChanged(mockSnap); + + var changedModel = collection.models[0]; + + expect(changedModel.get('favDino')).to.be.ok; + + }); + + it('should add when item cannot be found', function() { + sinon.spy(collection, '_childAdded'); + + var mockSnap = new MockSnap({ + name: '4', + val: { + id: '4', + name: 'Cash', + age: 2 + } + }); + + collection._childChanged(mockSnap); + expect(collection._childAdded.calledOnce).to.be.true; + + collection._childAdded.restore(); + }); + }); + + describe('#_childRemoved', function() { + + var collection; + beforeEach(function() { + var Collection = Backbone.Firebase.Collection.extend({ + url: 'Mock://', + autoSync: true + }); + + collection = new Collection(); + + collection.models = [ + new Backbone.Model({ + id: '1', + name: 'David', + age: 26 + }) + ]; + + }); + + it('should call Backbone.Collection.remove', function() { + sinon.spy(Backbone.Collection.prototype, 'remove'); + + var mockSnap = new MockSnap({ + name: '1', + val: { + id: '1', + name: 'David', + age: 26 + } + }); + + collection._childRemoved(mockSnap); + + expect(Backbone.Collection.prototype.remove.calledOnce).to.be.ok; + Backbone.Collection.prototype.remove.restore(); + }); + + // silent remove + it('should call Backbone.Collection.remove silently', function() { + sinon.spy(Backbone.Collection.prototype, 'remove'); + + var mockSnap = new MockSnap({ + name: '1', + val: { + id: '1', + name: 'David', + age: 26 + } + }); + + collection._suppressEvent = true; + collection._childRemoved(mockSnap); + + expect(Backbone.Collection.prototype.remove.calledWith({silent: true})); + Backbone.Collection.prototype.remove.restore(); + }); + + }); + + describe('#_childAdded', function() { + + var collection; + beforeEach(function() { + var Collection = Backbone.Firebase.Collection.extend({ + url: 'Mock://', + autoSync: true + }); + + collection = new Collection(); + + collection.models = [ + new Backbone.Model({ + id: '1', + name: 'David', + age: 26 + }) + ]; + + }); + + it('should call Backbone.Collection.add', function() { + sinon.spy(Backbone.Collection.prototype, 'add'); + + var mockSnap = new MockSnap({ + name: '1', + val: { + id: '1', + name: 'David', + age: 26 + } + }); + + collection._childAdded(mockSnap); + + expect(Backbone.Collection.prototype.add.calledOnce).to.be.ok; + Backbone.Collection.prototype.add.restore(); + }); + + // silent add + it('should call Backbone.Collection.add silently', function() { + sinon.spy(Backbone.Collection.prototype, 'add'); + var mockSnap = new MockSnap({ + name: '1', + val: { + id: '1', + name: 'David', + age: 26 + } + }); + + collection._suppressEvent = true; + collection._childAdded(mockSnap); + + expect(Backbone.Collection.prototype.add.calledWith({silent: true})); + Backbone.Collection.prototype.add.restore(); + }); + + }); + + describe('#_updateModel', function() { + + var collection; + var model; + beforeEach(function() { + + var Collection = Backbone.Firebase.Collection.extend({ + url: 'Mock://' + }); + + collection = new Collection(); + + collection.models = [ + new Backbone.Model({ + id: '1', + name: 'David', + age: 26 + }) + ]; + + model = new Backbone.Model({ + id: "1", + name: 'Kato', + age: 26 + }); + + }); + + it('should not update if the model\'s _remoteChanging property is true', function() { + + model._remoteChanging = true; + + collection._updateModel(model); + + var collectionModel = collection.models[0]; + + // The name property should still be equal to 'David' + // because 'model' object had _remoteChanging set to true + // which cancels the update. This is because _remoteChanging + // indicates that the item is being updated through the + // Firebase sync listeners + collectionModel.get('name').should.equal('David'); + + }); + + it('should call _setWithPriority if the .priority property is present', function() { + sinon.spy(collection, '_setWithPriority'); + model.attributes['.priority'] = 14; + collection._updateModel(model); + expect(collection._setWithPriority.calledOnce).to.be.ok; + collection._setWithPriority.restore(); + }); + + it('should call _updateToFirebase if no .priority property is present', function() { + sinon.spy(collection, '_updateToFirebase'); + collection._updateModel(model); + expect(collection._updateToFirebase.calledOnce).to.be.ok; + collection._updateToFirebase.restore(); + }); + + }); + + describe('#_removeModel', function() { + + var collection; + var model; + beforeEach(function() { + var Collection = Backbone.Firebase.Collection.extend({ + url: 'Mock://', + autoSync: true + }); + + collection = new Collection(); + + collection.models = [ + new Backbone.Model({ + id: '1', + name: 'David', + age: 26 + }) + ]; + + }); + + it('should call _setWithCheck', function() { + var model = new Backbone.Model({ + id: '1' + }); + sinon.spy(Backbone.Firebase, '_setWithCheck'); + collection._removeModel(model, collection, null); + collection.firebase.flush(); + expect(Backbone.Firebase._setWithCheck.calledOnce).to.be.ok; + Backbone.Firebase._setWithCheck.restore(); + }); + + }); + + }); + + describe('OnceCollection', function() { + + var collection; + beforeEach(function() { + var Collection = Backbone.Firebase.Collection.extend({ + url: 'Mock://', + autoSync: false + }); + + collection = new Collection(); + }); + + it('should not call sync when added', function() { + var spy = sinon.spy(); + + collection.on('sync', spy); + + collection.add({ title: 'blah' }); + + collection.firebase.flush(); + + return expect(spy.called).to.be.false; + }); + + describe('#create', function() { + + it('should call Backbone.Collection.prototype.create', function() { + sinon.spy(Backbone.Collection.prototype, 'create'); + + collection.create({}); + collection.firebase.flush(); + + expect(Backbone.Collection.prototype.create.calledOnce).to.be.ok; + + Backbone.Collection.prototype.create.restore(); + }); + + }); + + describe('#add', function() { + it('should call Backbone.Collection.prototype.add', function() { + sinon.spy(Backbone.Collection.prototype, 'add'); + + collection.add({}); + collection.firebase.flush(); + + expect(Backbone.Collection.prototype.add.calledOnce).to.be.ok; + + Backbone.Collection.prototype.add.restore(); + }); + }); + + describe('#fetch', function() { + + it('should call Backbone.Firebase.sync', function() { + sinon.spy(Backbone.Firebase, 'sync'); + + collection.fetch(); + + expect(Backbone.Firebase.sync.calledOnce).to.be.ok; + + Backbone.Firebase.sync.restore(); + }); + + }); + + }); + +}); \ No newline at end of file diff --git a/test/specs/model_test.js b/test/specs/model_test.js new file mode 100644 index 0000000..a93ffc6 --- /dev/null +++ b/test/specs/model_test.js @@ -0,0 +1,389 @@ +describe('Backbone.Firebase.Model', function() { + + it('should exist', function() { + return expect(Backbone.Firebase.Model).to.be.ok; + }); + + it('should extend', function() { + var Model = Backbone.Firebase.Model.extend({ + url: 'Mock://' + }); + return expect(Model).to.be.ok; + }); + + it('should contstruct', function() { + var Model = Backbone.Firebase.Model.extend({ + url: 'Mock://' + }); + return expect(new Model()).to.be.ok; + }); + + describe('#constructor', function() { + + it('should throw an error if an invalid url is provided', function() { + var Model = Backbone.Firebase.Model.extend({ + url: true + }); + try { + var model = new Model(); + } catch (err) { + assert(err.message === 'url parameter required'); + } + }); + + it('should call Backbone.Firebase._determineRef with url as a Firebase reference', function() { + sinon.spy(Backbone.Firebase, '_determineRef'); + var ref = new Firebase('Mock://'); + var Model = Backbone.Firebase.Model.extend({ + url: ref + }); + var model = new Model(); + expect(Backbone.Firebase._determineRef.calledOnce).to.be.ok; + Backbone.Firebase._determineRef.restore(); + }); + + }); + + describe('#destroy', function() { + + var model; + beforeEach(function() { + var Model = Backbone.Firebase.Model.extend({ + urlRoot: 'Mock://' + }); + + model = new Model(); + }); + + it('should trigger the destroy event', function() { + var spy = sinon.spy(); + + model.on('destroy', spy); + + model.destroy(); + model.firebase.flush(); + + expect(spy.calledOnce).to.be.ok; + + }); + + }); + + it('should update model', function() { + // TODO: Test _updateModel + }); + + it('should set changed attributes to null', function() { + // TODO: Test _updateModel + + }); + + describe('#_unsetAttributes', function() { + + it('should unset attributes that have been deleted on the server', function() { + var Model = Backbone.Firebase.Model.extend({ + url: 'Mock://' + }); + var model = new Model(); + + // set the initial attributes silently + model.set({ + firstName: 'David', + lastName: 'East' + }, { silent: true }); + + // create a mock snap that removes the 'lastName' property + var mockSnap = new MockSnap({ + name: '1', + val: { + firstName: 'David' + } + }); + + model._unsetAttributes(mockSnap); + + expect(model.get('firstName')).to.be.ok; + expect(model.get('lastName')).to.be.undefined; + + }); + + it('should call _unsetAttributes', function() { + var Model = Backbone.Firebase.Model.extend({ + url: 'Mock://' + }); + + var model = new Model(); + + var mockSnap = new MockSnap({ + name: '1', + val: { + firstname: 'David' + } + }); + + sinon.spy(model, '_unsetAttributes'); + + model._setLocal(mockSnap); + + expect(model._unsetAttributes.calledOnce).to.be.ok; + model._unsetAttributes.restore(); + }); + + + }); + + describe('#_setId', function() { + it('should set id to its value', function() { + var Model = Backbone.Firebase.Model.extend({ + url: 'Mock://' + }); + var model = new Model(); + var mockSnap = new MockSnap({ + name: '1' + }); + model._setId(mockSnap); + + model.get('id').should.equal('1'); + }); + }); + + describe('SyncModel', function() { + + var Model = null; + + beforeEach(function() { + Model = Backbone.Firebase.Model.extend({ + url: 'Mock://' + }); + }); + + describe('ignored methods', function() { + + beforeEach(function() { + sinon.spy(console, 'warn'); + }); + + afterEach(function() { + console.warn.restore(); + }); + + it('should do nothing when save is called', function() { + var model = new Model(); + model.save(); + return expect(console.warn.calledOnce).to.be.ok; + }); + + it('should do nothing when fetch is called', function() { + var model = new Model(); + model.fetch(); + return expect(console.warn.calledOnce).to.be.ok; + }); + + it('should do nothing when sync is called', function() { + var model = new Model(); + model.sync(); + return expect(console.warn.calledOnce).to.be.ok; + }); + + }); + + describe('#constructor', function() { + + it('should call sync when model is set', function() { + var spy = sinon.spy(); + + var model = new Model(); + + model.on('sync', spy); + + model.set('ok', 'ok'); + model.firebase.flush(); + + return expect(spy.called).to.be.ok; + }); + + it('should set up a Firebase value listener', function() { + var spy = sinon.spy(); + + var model = new Model(); + model.firebase.on('value', spy); + model.firebase.flush(); + + return expect(spy.called).to.be.ok; + }); + + it('should listen for local changes', function() { + var model = new Model(); + var spy = sinon.spy(); + + model._listenLocalChange(spy); + + model.set('ok', 'ok'); + model.firebase.flush(); + + return expect(spy.called).to.be.ok; + }); + + }); + + }); + + describe('OnceModel', function() { + + describe('#constructor', function(){ + + it('should call _listenLocalChange', function() { + sinon.spy(Backbone.Firebase.Model.prototype, '_listenLocalChange'); + + var Model = Backbone.Firebase.Model.extend({ + url: 'Mock://', + autoSync: false + }); + model = new Model(); + + expect(Backbone.Firebase.Model.prototype._listenLocalChange.calledOnce).to.be.ok; + Backbone.Firebase.Model.prototype._listenLocalChange.restore(); + }); + + it('should listen for local changes', function() { + var Model = Backbone.Firebase.Model.extend({ + url: 'Mock://', + autoSync: false + }); + + var model = new Model(); + var spy = sinon.spy(); + + model._listenLocalChange(spy); + + model.set('ok', 'ok'); + model.firebase.flush(); + + return expect(spy.called).to.be.ok; + }); + + }); + + describe('#sync', function() { + + // Backbone.Firebase.Model.sync should proxy to Backbone.Firebase.sync + // if it comes from a OnceModel + it('should call Backbone.Firebase.sync', function() { + sinon.spy(Backbone.Firebase, 'sync'); + + var Model = Backbone.Firebase.Model.extend({ + url: 'Mock://', + autoSync: false + }); + model = new Model(); + + model.sync('read', model, null); + + expect(Backbone.Firebase.sync.calledOnce).to.be.ok; + Backbone.Firebase.sync.restore(); + }); + + }); + + }); + + describe('autoSync options', function() { + + /* + + Model null -> Instance null = true + Model null -> Instance autosync:true = true + Model null -> Instance autosync:false = false + + + Model autosync:true -> Instance null = true + Model autosync:true -> Instance autosync:false = false + Model autosync:true -> Instance autosync:true = true + + Model autosync:false -> Instance null = false + Model autosync:false -> Instance autosync:true + Model autosync:false -> Instance autosync:true = true + + */ + + it('Constructor null -> Instance null', function() { + var Model = Backbone.Firebase.Model.extend({ + url: 'Mock://' + }); + var model = new Model(); + return expect(model.autoSync).to.be.ok; + }); + + it('Constructor null -> Instance true', function() { + var Model = Backbone.Firebase.Model.extend({ + url: 'Mock://' + }); + var model = new Model({}, { autoSync: true }); + return expect(model.autoSync).to.be.ok; + }); + + it('Constructor null -> Instance false', function() { + var Model = Backbone.Firebase.Model.extend({ + url: 'Mock://' + }); + var model = new Model({}, { autoSync: false }); + return expect(model.autoSync).to.be.false; + }); + + it('Constructor true -> Instance null', function() { + var Model = Backbone.Firebase.Model.extend({ + url: 'Mock://', + autoSync: true + }); + var model = new Model(); + return expect(model.autoSync).to.be.ok; + }); + + it('Constructor true -> Instance true', function() { + var Model = Backbone.Firebase.Model.extend({ + url: 'Mock://', + autoSync: true + }); + var model = new Model({}, { autoSync: true }); + return expect(model.autoSync).to.be.ok; + }); + + it('Constructor true -> Instance false', function() { + var Model = Backbone.Firebase.Model.extend({ + url: 'Mock://', + autoSync: true + }); + var model = new Model({}, { autoSync: false }); + return expect(model.autoSync).to.be.false; + }); + + it('Constructor false -> Instance null', function() { + var Model = Backbone.Firebase.Model.extend({ + url: 'Mock://', + autoSync: false + }); + var model = new Model(); + return expect(model.autoSync).to.be.false; + }); + + it('Constructor false -> Instance true', function() { + var Model = Backbone.Firebase.Model.extend({ + url: 'Mock://', + autoSync: false + }); + var model = new Model({}, { autoSync: true }); + return expect(model.autoSync).to.be.ok; + }); + + it('Constructor false -> Instance false', function() { + var Model = Backbone.Firebase.Model.extend({ + url: 'Mock://', + autoSync: false + }); + var model = new Model({}, { autoSync: false }); + return expect(model.autoSync).to.be.false; + }); + + }); + +}); \ No newline at end of file diff --git a/test/specs/prototype_test.coffee b/test/specs/prototype_test.coffee deleted file mode 100644 index d554c8a..0000000 --- a/test/specs/prototype_test.coffee +++ /dev/null @@ -1,19 +0,0 @@ -describe 'Backbone.Firebase', -> - - beforeEach -> - @Firebase = new Backbone.Firebase(new Firebase) - - it 'should exist', -> - expect(@Firebase) - .to.be.ok - - it 'should create a Firebase reference', -> - expect(@Firebase._fbref) - .to.be.an.instanceOf Firebase - - describe '#_childAdded()', -> - - it 'should be a method', -> - expect(@Firebase) - .to.have.property('_childAdded') - .that.is.a 'function' diff --git a/test/specs/prototype_test.js b/test/specs/prototype_test.js new file mode 100644 index 0000000..4354a1b --- /dev/null +++ b/test/specs/prototype_test.js @@ -0,0 +1,336 @@ +describe('Backbone.Firebase', function() { + + it('should exist', function() { + return expect(Backbone.Firebase).to.be.ok; + }); + + describe("#_isPrimitive", function() { + + it('should return false for null', function() { + var value = Backbone.Firebase._isPrimitive(null); + expect(value).to.be.false; + }); + + it('should return false for object', function() { + var value = Backbone.Firebase._isPrimitive({}); + expect(value).to.be.false; + }); + + it('should return true for string', function() { + var value = Backbone.Firebase._isPrimitive('hello'); + expect(value).to.be.true; + }); + + it('should return true for int', function() { + var value = Backbone.Firebase._isPrimitive(1); + expect(value).to.be.true; + }); + + it('should return true for bool', function() { + var value = Backbone.Firebase._isPrimitive(true); + expect(value).to.be.true; + }); + + }); + + describe('#_checkId', function() { + + it('should add an id to a new model', function() { + + var mockSnap = new MockSnap({ + name: '1', + val: { + firstname: 'David' + } + }); + + var model = Backbone.Firebase._checkId(mockSnap); + + expect(model.id).to.be.ok; + model.id.should.equal(mockSnap.name()); + + }); + + it('should throw an error if the model is not an object', function() { + var mockSnap = new MockSnap({ + name: '1', + val: 'hello' + }); + try { + var model = Backbone.Firebase._checkId(1); + } catch (err) { + expect(err).to.be.ok + } + + }); + + it('should create an object with an id for null values', function() { + var mockSnap = new MockSnap({ + name: '1', + val: null + }); + var model = Backbone.Firebase._checkId(mockSnap); + expect(model.id).to.be.ok; + }); + + }); + + describe('#_readOnce', function() { + + var ref; + beforeEach(function() { + ref = new Firebase('Mock://'); + }); + + // To read a value one-time, once() will be called on a Firebase reference. + it('should call Firebase.once', function() { + Backbone.Firebase._readOnce(ref, function() {}); + ref.flush(); + expect(ref.once.calledOnce).to.be.ok; + }); + + // _readOnce calls once() which will return a snapshot from the + // callback function. We need to make sure we're properly returning + // that from the callback function parameter. + it('should return a snapshot from a callback function', function() { + var snapExpected; + Backbone.Firebase._readOnce(ref, function(snap) { + snapExpected = snap; + }); + ref.flush(); + expect(snapExpected).to.be.defined; + expect(snapExpected.val).to.be.defined; + }); + + }); + + describe('#_setToFirebase', function() { + + var ref; + beforeEach(function() { + ref = new Firebase('Mock://'); + }); + + it('should call Firebase.set', function() { + Backbone.Firebase._setToFirebase(ref, {}, function() {}); + ref.flush(); + expect(ref.set.calledOnce).to.be.ok; + }); + + it('should return a response from a callback function', function() { + var responseExpected; + Backbone.Firebase._setToFirebase(ref, { id: '1'}, function(err) { + responseExpected = err; + }); + ref.flush(); + expect(responseExpected).to.be.defined; + }); + + }); + + describe('#_updateToFirebase', function() { + + var ref; + beforeEach(function() { + ref = new Firebase('Mock://'); + }); + + it('should call Firebase.update', function() { + Backbone.Firebase._updateToFirebase(ref, {}, function() {}); + ref.flush(); + expect(ref.update.calledOnce).to.be.ok; + }); + + it('should return a response from a callback function', function() { + var responseExpected; + Backbone.Firebase._updateToFirebase(ref, { id: '1'}, function(err) { + responseExpected = err; + }); + ref.flush(); + expect(responseExpected).to.be.defined; + }); + + }); + + describe('#_onCompleteCheck', function() { + + var item; + beforeEach(function() { + item = { id: '1' }; + }); + + //_onCompleteCheck = function(err, item, options) + + it('should call options.error if an error exists', function() { + var spy = sinon.spy(); + options = { + error: spy + }; + Backbone.Firebase._onCompleteCheck(new Error(), item, options); + expect(spy.calledOnce).to.be.ok; + }); + + it('should call options.success if no error exists', function() { + var spy = sinon.spy(); + options = { + success: spy + }; + Backbone.Firebase._onCompleteCheck(null, item, options); + expect(spy.calledOnce).to.be.ok; + }); + + it('should return if no options are present', function() { + Backbone.Firebase._onCompleteCheck(null, item, null); + }); + + }); + + describe('#sync', function() { + + // Backbone.Firebase.sync = function(method, model, options) + + var model; + beforeEach(function() { + var Model = Backbone.Firebase.Model.extend({ + url: 'Mock://', + autoSync: false + }); + model = new Model(); + }); + + describe('#sync("read", ...)', function() { + + // sync('read', model, null) + // This should call _readOnce with proxies to Firebase.once() + it('should call Backbone.Firebase._readOnce', function() { + sinon.spy(Backbone.Firebase, '_readOnce'); + Backbone.Firebase.sync('read', model, null); + expect(Backbone.Firebase._readOnce.calledOnce).to.be.ok; + Backbone.Firebase._readOnce.restore(); + }); + + // sync('read', model, { success: Function }) + // This should call _readOnce and test for a success callback + it('should call Backbone.Firebase._readOnce with a success option', function() { + var responseExpected; + sinon.spy(Backbone.Firebase, '_readOnce'); + Backbone.Firebase.sync('read', model, { + success: function(resp) { + responseExpected = resp; + } + }); + model.firebase.flush(); + expect(responseExpected).to.be.defined; + Backbone.Firebase._readOnce.restore(); + }); + + // - one time read with error? + + }); + + describe('#_setWithCheck', function() { + + it('should call Backbone.Firebase._setToFirebase', function() { + sinon.spy(Backbone.Firebase, '_setToFirebase'); + Backbone.Firebase._setWithCheck(model.firebase, null, null); + expect(Backbone.Firebase._setToFirebase.calledOnce).to.be.ok; + Backbone.Firebase._setToFirebase.restore(); + }); + + // test that _onCompleteCheck is called + it('should call Backbone.Firebase._onCompleteCheck', function() { + sinon.spy(Backbone.Firebase, '_onCompleteCheck'); + Backbone.Firebase._setWithCheck(model.firebase, null, null); + model.firebase.flush(); + expect(Backbone.Firebase._onCompleteCheck.calledOnce).to.be.ok; + Backbone.Firebase._onCompleteCheck.restore(); + }); + + }); + + + describe('#sync("create", ...)', function() { + + it('should call Backbone.Firebase._onCompleteCheck', function() { + sinon.spy(Backbone.Firebase, '_onCompleteCheck'); + Backbone.Firebase.sync('create', model, null); + model.firebase.flush(); + expect(Backbone.Firebase._onCompleteCheck.calledOnce).to.be.ok; + Backbone.Firebase._onCompleteCheck.restore(); + }); + + it('should call Backbone.Firebase._setWithCheck', function() { + sinon.spy(Backbone.Firebase, '_setWithCheck'); + Backbone.Firebase.sync('create', model, null); + model.firebase.flush(); + expect(Backbone.Firebase._setWithCheck.calledOnce).to.be.ok; + Backbone.Firebase._setWithCheck.restore(); + }); + + }); + + describe('#sync("update", ...)', function() { + // update + + // test that _onCompleteCheck is called + it('should call Backbone.Firebase._onCompleteCheck', function() { + sinon.spy(Backbone.Firebase, '_onCompleteCheck'); + Backbone.Firebase.sync('update', model, null); + model.firebase.flush(); + expect(Backbone.Firebase._onCompleteCheck.calledOnce).to.be.ok; + Backbone.Firebase._onCompleteCheck.restore(); + }); + + it('should call Backbone.Firebase._updateWithCheck', function() { + sinon.spy(Backbone.Firebase, '_updateWithCheck'); + Backbone.Firebase.sync('update', model, null); + model.firebase.flush(); + expect(Backbone.Firebase._updateWithCheck.calledOnce).to.be.ok; + Backbone.Firebase._updateWithCheck.restore(); + }); + + }); + + }); + + describe('#_throwError', function() { + + it('should throw and catch an error', function() { + try { + Backbone.Firebase._throwError('Error'); + } catch (err) { + expect(err).to.be.defined; + } + }); + + }); + + describe('#_determineRef', function() { + + // return new Firebase if string + it('should create a Firebase ref if a string is provided', function() { + sinon.spy(window, 'Firebase'); + Backbone.Firebase._determineRef('Mock://'); + expect(Firebase.calledOnce).to.be.ok; + window.Firebase.restore(); + }); + + // return object if a ref + it('should return a Firebase ref if a ref is provided', function() { + var paramRef = new Firebase('Mock://'); + var returnedRef = Backbone.Firebase._determineRef(paramRef); + assert(typeof(returnedRef) === 'object'); + }); + + // throw error if not object or string + it('should throw an error if neither an object or string is provided', function() { + try { + Backbone.Firebase._determineRef(false); + } catch (error) { + assert(error.message === 'Invalid type passed to url property'); + } + }); + + }); + +}); \ No newline at end of file