diff --git a/package.json b/package.json index aa143a78..8b22139d 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,9 @@ "gulp-mocha": "^2.1.2", "gulp-uglify": "^1.2.0", "jsdom": "3.x.x", - "run-sequence": "^1.1.1" + "run-sequence": "^1.1.1", + "sinon": "^1.15.4", + "sinon-chai": "^2.8.0" }, "scripts": { "test": "gulp test", diff --git a/src/reactfire.js b/src/reactfire.js index 0f168ebe..84f8a009 100644 --- a/src/reactfire.js +++ b/src/reactfire.js @@ -27,21 +27,272 @@ }(this, function() { 'use strict'; + /*************/ + /* HELPERS */ + /*************/ + /** + * Returns the index of the key in the list. If an item with the key is not in the list, -1 is + * returned. + * + * @param {Array} list A list of items. + * @param {string} key The key for which to search. + * @return {number} The index of the item which has the provided key or -1 if no items have the + * provided key. + */ + function _indexForKey(list, key) { + for (var i = 0, length = list.length; i < length; ++i) { + if (list[i].$key === key) { + return i; + } + } + + /* istanbul ignore next */ + return -1; + } + + /** + * Throws a formatted error message. + * + * @param {string} message The error message to throw. + */ + function _throwError(message) { + throw new Error('ReactFire: ' + message); + } + + /** + * Validates the name of the variable which is being bound. + * + * @param {string} bindVar The variable which is being bound. + */ + function _validateBindVar(bindVar) { + var errorMessage; + + if (typeof bindVar !== 'string') { + errorMessage = 'Bind variable must be a string. Got: ' + bindVar; + } else if (bindVar.length === 0) { + errorMessage = 'Bind variable must be a non-empty string. Got: ""'; + } else if (bindVar.length > 768) { + // Firebase can only stored child paths up to 768 characters + errorMessage = 'Bind variable is too long to be stored in Firebase. Got: ' + bindVar; + } else if (/[\[\].#$\/\u0000-\u001F\u007F]/.test(bindVar)) { + // Firebase does not allow node keys to contain the following characters + errorMessage = 'Bind variable cannot contain any of the following characters: . # $ ] [ /. Got: ' + bindVar; + } + + if (typeof errorMessage !== 'undefined') { + _throwError(errorMessage); + } + } + + + /******************************/ + /* BIND AS OBJECT LISTENERS */ + /******************************/ + /** + * 'value' listener which updates the value of the bound state variable. + * + * @param {string} bindVar The state variable to which the data is being bound. + * @param {Firebase.DataSnapshot} snapshot A snapshot of the data being bound. + */ + function _objectValue(bindVar, snapshot) { + this.data[bindVar] = snapshot.val(); + this.setState(this.data); + } + + + /*****************************/ + /* BIND AS ARRAY LISTENERS */ + /*****************************/ + /** + * Creates a new array record record given a key-value pair. + * + * @param {string} key The new record's key. + * @param {any} value The new record's value. + * @return {Object} The new record. + */ + function _createRecord(key, value) { + var record = {}; + if (typeof value === 'object') { + record = value; + } else { + record.$value = value; + } + record.$key = key; + + return record; + } + + /** + * 'child_added' listener which adds a new record to the bound array. + * + * @param {string} bindVar The state variable to which the data is being bound. + * @param {Firebase.DataSnapshot} snapshot A snapshot of the data being bound. + * @param {string|null} previousChildKey The key of the child after which the provided snapshot + * is positioned; null if the provided snapshot is in the first position. + */ + function _arrayChildAdded(bindVar, snapshot, previousChildKey) { + var key = snapshot.key(); + var value = snapshot.val(); + var array = this.data[bindVar]; + + // Determine where to insert the new record + var insertionIndex; + if (previousChildKey === null) { + insertionIndex = 0; + } else { + var previousChildIndex = _indexForKey(array, previousChildKey); + insertionIndex = previousChildIndex + 1; + } + + // Add the new record to the array + array.splice(insertionIndex, 0, _createRecord(key, value)); + + // Update state + this.setState(this.data); + } + + /** + * 'child_removed' listener which removes a record from the bound array. + * + * @param {string} bindVar The state variable to which the data is bound. + * @param {Firebase.DataSnapshot} snapshot A snapshot of the bound data. + */ + function _arrayChildRemoved(bindVar, snapshot) { + var array = this.data[bindVar]; + + // Look up the record's index in the array + var index = _indexForKey(array, snapshot.key()); + + // Splice out the record from the array + array.splice(index, 1); + + // Update state + this.setState(this.data); + } + + /** + * 'child_changed' listener which updates a record's value in the bound array. + * + * @param {string} bindVar The state variable to which the data is bound. + * @param {Firebase.DataSnapshot} snapshot A snapshot of the data to bind. + */ + function _arrayChildChanged(bindVar, snapshot) { + var key = snapshot.key(); + var value = snapshot.val(); + var array = this.data[bindVar]; + + // Look up the record's index in the array + var index = _indexForKey(array, key); + + // Update the record's value in the array + array[index] = _createRecord(key, value); + + // Update state + this.setState(this.data); + } + + /** + * 'child_moved' listener which updates a record's position in the bound array. + * + * @param {string} bindVar The state variable to which the data is bound. + * @param {Firebase.DataSnapshot} snapshot A snapshot of the bound data. + * @param {string|null} previousChildKey The key of the child after which the provided snapshot + * is positioned; null if the provided snapshot is in the first position. + */ + function _arrayChildMoved(bindVar, snapshot, previousChildKey) { + var key = snapshot.key(); + var array = this.data[bindVar]; + + // Look up the record's index in the array + var currentIndex = _indexForKey(array, key); + + // Splice out the record from the array + var record = array.splice(currentIndex, 1)[0]; + + // Determine where to re-insert the record + var insertionIndex; + if (previousChildKey === null) { + insertionIndex = 0; + } else { + var previousChildIndex = _indexForKey(array, previousChildKey); + insertionIndex = previousChildIndex + 1; + } + + // Re-insert the record into the array + array.splice(insertionIndex, 0, record); + + // Update state + this.setState(this.data); + } + + + /*************/ + /* BINDING */ + /*************/ + /** + * Creates a binding between Firebase and the inputted bind variable as either an array or + * an object. + * + * @param {Firebase} firebaseRef The Firebase ref whose data to bind. + * @param {string} bindVar The state variable to which to bind the data. + * @param {function} cancelCallback The Firebase reference's cancel callback. + * @param {boolean} bindAsArray Whether or not to bind as an array or object. + */ + function _bind(firebaseRef, bindVar, cancelCallback, bindAsArray) { + if (Object.prototype.toString.call(firebaseRef) !== '[object Object]') { + _throwError('Invalid Firebase reference'); + } + + _validateBindVar(bindVar); + + if (typeof this.firebaseRefs[bindVar] !== 'undefined') { + _throwError('this.state.' + bindVar + ' is already bound to a Firebase reference'); + } + + // Keep track of the Firebase reference we are setting up listeners on + this.firebaseRefs[bindVar] = firebaseRef.ref(); + + if (bindAsArray) { + // Set initial state to an empty array + this.data[bindVar] = []; + this.setState(this.data); + + // Add listeners for all 'child_*' events + this.firebaseListeners[bindVar] = { + child_added: firebaseRef.on('child_added', _arrayChildAdded.bind(this, bindVar), cancelCallback), + child_removed: firebaseRef.on('child_removed', _arrayChildRemoved.bind(this, bindVar), cancelCallback), + child_changed: firebaseRef.on('child_changed', _arrayChildChanged.bind(this, bindVar), cancelCallback), + child_moved: firebaseRef.on('child_moved', _arrayChildMoved.bind(this, bindVar), cancelCallback) + }; + } else { + // Add listener for 'value' event + this.firebaseListeners[bindVar] = { + value: firebaseRef.on('value', _objectValue.bind(this, bindVar), cancelCallback) + }; + } + } + + var ReactFireMixin = { /********************/ /* MIXIN LIFETIME */ /********************/ - /* Initializes the Firebase binding refs array */ + /** + * Initializes the Firebase refs and listeners arrays. + **/ componentWillMount: function() { + this.data = {}; this.firebaseRefs = {}; this.firebaseListeners = {}; }, - /* Removes any remaining Firebase bindings */ + /** + * Unbinds any remaining Firebase listeners. + */ componentWillUnmount: function() { - for (var key in this.firebaseRefs) { - if (this.firebaseRefs.hasOwnProperty(key)) { - this.unbind(key); + for (var bindVar in this.firebaseRefs) { + if (this.firebaseRefs.hasOwnProperty(bindVar)) { + this.unbind(bindVar); } } }, @@ -50,114 +301,57 @@ /*************/ /* BINDING */ /*************/ - /* Creates a binding between Firebase and the inputted bind variable as an array */ + /** + * Creates a binding between Firebase and the inputted bind variable as an array. + * + * @param {Firebase} firebaseRef The Firebase ref whose data to bind. + * @param {string} bindVar The state variable to which to bind the data. + * @param {function} cancelCallback The Firebase reference's cancel callback. + */ bindAsArray: function(firebaseRef, bindVar, cancelCallback) { - this._bind(firebaseRef, bindVar, cancelCallback, true); + var bindPartial = _bind.bind(this); + bindPartial(firebaseRef, bindVar, cancelCallback, /* bindAsArray */ true); }, - /* Creates a binding between Firebase and the inputted bind variable as an object */ + /** + * Creates a binding between Firebase and the inputted bind variable as an object. + * + * @param {Firebase} firebaseRef The Firebase ref whose data to bind. + * @param {string} bindVar The state variable to which to bind the data. + * @param {function} cancelCallback The Firebase reference's cancel callback. + */ bindAsObject: function(firebaseRef, bindVar, cancelCallback) { - this._bind(firebaseRef, bindVar, cancelCallback, false); - }, - - /* Throw a formatted error message */ - _throwError: function(message) { - throw new Error('ReactFire: ' + message); - }, - - /* Creates a binding between Firebase and the inputted bind variable as either an array or object */ - _bind: function(firebaseRef, bindVar, cancelCallback, bindAsArray) { - this._validateBindVar(bindVar); - - if (Object.prototype.toString.call(firebaseRef) !== '[object Object]') { - this._throwError('firebaseRef must be an instance of Firebase'); - } - - this.firebaseRefs[bindVar] = firebaseRef.ref(); - this.firebaseListeners[bindVar] = firebaseRef.on('value', function(dataSnapshot) { - var newState = {}; - if (bindAsArray) { - newState[bindVar] = this._toArray(dataSnapshot.val()); - } else { - newState[bindVar] = dataSnapshot.val(); - } - this.setState(newState); - }.bind(this), cancelCallback); + var bindPartial = _bind.bind(this); + bindPartial(firebaseRef, bindVar, cancelCallback, /* bindAsArray */ false); }, - /* Removes the binding between Firebase and the inputted bind variable */ + /** + * Removes the binding between Firebase and the inputted bind variable. + * + * @param {string} bindVar The state variable to which the data is bound. + * @param {function} callback Called when the data is unbound and the state has been updated. + */ unbind: function(bindVar, callback) { - this._validateBindVar(bindVar); + _validateBindVar(bindVar); if (typeof this.firebaseRefs[bindVar] === 'undefined') { - this._throwError('unexpected value for bindVar. "' + bindVar + '" was either never bound or has already been unbound'); + _throwError('this.state.' + bindVar + ' is not bound to a Firebase reference'); } - this.firebaseRefs[bindVar].off('value', this.firebaseListeners[bindVar]); - delete this.firebaseRefs[bindVar]; - delete this.firebaseListeners[bindVar]; + // Turn off all Firebase listeners + for (var event in this.firebaseListeners[bindVar]) { + if (this.firebaseListeners[bindVar].hasOwnProperty(event)) { + var offListener = this.firebaseListeners[bindVar][event]; + this.firebaseRefs[bindVar].off(event, offListener); + } + } + this.firebaseRefs[bindVar] = undefined; + this.firebaseListeners[bindVar] = undefined; + // Update state var newState = {}; newState[bindVar] = undefined; this.setState(newState, callback); - }, - - - /*************/ - /* HELPERS */ - /*************/ - /* Validates the name of the variable which is being bound */ - _validateBindVar: function(bindVar) { - var errorMessage; - - if (typeof bindVar !== 'string') { - errorMessage = 'bindVar must be a string. Got: ' + bindVar; - } else if (bindVar.length === 0) { - errorMessage = 'bindVar must be a non-empty string. Got: ""'; - } else if (bindVar.length > 768) { - // Firebase can only stored child paths up to 768 characters - errorMessage = 'bindVar is too long to be stored in Firebase. Got: ' + bindVar; - } else if (/[\[\].#$\/\u0000-\u001F\u007F]/.test(bindVar)) { - // Firebase does not allow node keys to contain the following characters - errorMessage = 'bindVar cannot contain any of the following characters: . # $ ] [ /. Got: ' + bindVar; - } - - if (typeof errorMessage !== 'undefined') { - this._throwError(errorMessage); - } - }, - - /* Returns true if the inputted object is a JavaScript array */ - _isArray: function(obj) { - return (Object.prototype.toString.call(obj) === '[object Array]'); - }, - - /* Converts a Firebase object to a JavaScript array */ - _toArray: function(obj) { - var item; - var out = []; - if (obj) { - if (this._isArray(obj)) { - for (var i = 0, length = obj.length; i < length; i++) { - item = obj[i]; - if (item !== undefined && item !== null) { - out.push({ $key: i, $value: item }); - } - } - } else if (typeof obj === 'object') { - for (var key in obj) { - if (obj.hasOwnProperty(key)) { - item = obj[key]; - if (typeof item !== 'object') { - item = { $value: item }; - } - item.$key = key; - out.push(item); - } - } - } - } - return out; } }; diff --git a/tests/reactfire.spec.js b/tests/reactfire.spec.js index 3e3accf5..c6bbc95d 100644 --- a/tests/reactfire.spec.js +++ b/tests/reactfire.spec.js @@ -1,8 +1,10 @@ 'use strict'; -// Mocha / Chai +// Mocha / Chai / Sinon var chai = require('chai'); var expect = chai.expect; +var sinon = require('sinon'); +chai.use(require('sinon-chai')); // React var React = require('react/addons'); @@ -54,7 +56,7 @@ describe('ReactFire', function() { TH.invalidFirebaseRefs.forEach(function(invalidFirebaseRef) { expect(function() { _this.bindAsArray(invalidFirebaseRef, 'items'); - }).to.throw('ReactFire: firebaseRef must be an instance of Firebase'); + }).to.throw('ReactFire: Invalid Firebase reference'); }); }, @@ -76,7 +78,7 @@ describe('ReactFire', function() { TH.invalidBindVars.forEach(function(invalidBindVar) { expect(function() { _this.bindAsArray(firebaseRef, invalidBindVar); - }).to.throw(/bindVar/); + }).to.throw(/Bind variable/); }); }, @@ -88,7 +90,28 @@ describe('ReactFire', function() { shallowRenderer.render(React.createElement(TestComponent)); }); - it('binds array items which are objects', function(done) { + it('throws error given an already bound bind variable', function() { + var TestComponent = React.createClass({ + mixins: [ReactFireMixin], + + componentWillMount: function() { + var _this = this; + + expect(function() { + _this.bindAsArray(firebaseRef, 'items'); + _this.bindAsArray(firebaseRef, 'items'); + }).to.throw('this.state.items is already bound to a Firebase reference'); + }, + + render: function() { + return React.DOM.div(null); + } + }); + + shallowRenderer.render(React.createElement(TestComponent)); + }); + + it('binds array records which are objects', function(done) { var TestComponent = React.createClass({ mixins: [ReactFireMixin], @@ -118,7 +141,7 @@ describe('ReactFire', function() { shallowRenderer.render(React.createElement(TestComponent)); }); - it('binds array items which are primitives', function(done) { + it('binds array records which are primitives', function(done) { var TestComponent = React.createClass({ mixins: [ReactFireMixin], @@ -127,9 +150,39 @@ describe('ReactFire', function() { firebaseRef.set(['first', 'second', 'third'], function() { expect(this.state.items).to.deep.equal([ - { '$key': 0, '$value': 'first' }, - { '$key': 1, '$value': 'second' }, - { '$key': 2, '$value': 'third' } + { '$key': '0', '$value': 'first' }, + { '$key': '1', '$value': 'second' }, + { '$key': '2', '$value': 'third' } + ]); + + done(); + }.bind(this)); + }, + + render: function() { + return React.DOM.div(null); + } + }); + + shallowRenderer.render(React.createElement(TestComponent)); + }); + + it('binds array records which are a mix of objects and primitives', function(done) { + var TestComponent = React.createClass({ + mixins: [ReactFireMixin], + + componentWillMount: function() { + this.bindAsArray(firebaseRef, 'items'); + + firebaseRef.set({ + 0: 'first', + 1: 'second', + third: { index: 2 } + }, function() { + expect(this.state.items).to.deep.equal([ + { '$key': '0', '$value': 'first' }, + { '$key': '1', '$value': 'second' }, + { '$key': 'third', index: 2 } ]); done(); @@ -144,6 +197,28 @@ describe('ReactFire', function() { shallowRenderer.render(React.createElement(TestComponent)); }); + it('binds as an empty array for Firebase references with no data', function(done) { + var TestComponent = React.createClass({ + mixins: [ReactFireMixin], + + componentWillMount: function() { + this.bindAsArray(firebaseRef, 'items'); + + firebaseRef.set(null, function() { + expect(this.state.items).to.deep.equal([]); + + done(); + }.bind(this)); + }, + + render: function() { + return React.DOM.div(null); + } + }); + + shallowRenderer.render(React.createElement(TestComponent)); + }); + it('binds sparse arrays', function(done) { var TestComponent = React.createClass({ mixins: [ReactFireMixin], @@ -152,13 +227,11 @@ describe('ReactFire', function() { this.bindAsArray(firebaseRef, 'items'); firebaseRef.set({ 0: 'a', 2: 'b', 5: 'c' }, function() { - expect(this.state).to.deep.equal({ - items: [ - { $key: 0, $value: 'a' }, - { $key: 2, $value: 'b' }, - { $key: 5, $value: 'c' } - ] - }); + expect(this.state.items).to.deep.equal([ + { $key: '0', $value: 'a' }, + { $key: '2', $value: 'b' }, + { $key: '5', $value: 'c' } + ]); done(); }.bind(this)); @@ -172,7 +245,59 @@ describe('ReactFire', function() { shallowRenderer.render(React.createElement(TestComponent)); }); - it('binds with limit queries', function(done) { + it('binds only a subset of records when using limit queries', function(done) { + var TestComponent = React.createClass({ + mixins: [ReactFireMixin], + + componentWillMount: function() { + this.bindAsArray(firebaseRef.limitToLast(2), 'items'); + + firebaseRef.set({ a: 1, b: 2, c: 3 }, function() { + expect(this.state.items).to.deep.equal([ + { $key: 'b', $value: 2 }, + { $key: 'c', $value: 3 } + ]); + + done(); + }.bind(this)); + }, + + render: function() { + return React.DOM.div(null); + } + }); + + shallowRenderer.render(React.createElement(TestComponent)); + }); + + it('removes records when they fall outside of a limit query', function(done) { + var TestComponent = React.createClass({ + mixins: [ReactFireMixin], + + componentWillMount: function() { + this.bindAsArray(firebaseRef.limitToLast(2), 'items'); + + firebaseRef.set({ a: 1, b: 2, c: 3 }, function() { + firebaseRef.child('d').set(4, function() { + expect(this.state.items).to.deep.equal([ + { $key: 'c', $value: 3 }, + { $key: 'd', $value: 4 } + ]); + + done(); + }.bind(this)); + }.bind(this)); + }, + + render: function() { + return React.DOM.div(null); + } + }); + + shallowRenderer.render(React.createElement(TestComponent)); + }); + + it('adds a new record when an existing record in the limit query is removed', function(done) { var TestComponent = React.createClass({ mixins: [ReactFireMixin], @@ -180,12 +305,78 @@ describe('ReactFire', function() { this.bindAsArray(firebaseRef.limitToLast(2), 'items'); firebaseRef.set({ a: 1, b: 2, c: 3 }, function() { - expect(this.state).to.deep.equal({ - items: [ - { $key: 'b', $value: 2 }, + firebaseRef.child('b').remove(function() { + expect(this.state.items).to.deep.equal([ + { $key: 'a', $value: 1 }, { $key: 'c', $value: 3 } - ] - }); + ]); + + done(); + }.bind(this)); + }.bind(this)); + }, + + render: function() { + return React.DOM.div(null); + } + }); + + shallowRenderer.render(React.createElement(TestComponent)); + }); + + it('binds records in the correct order when using ordered queries', function(done) { + var TestComponent = React.createClass({ + mixins: [ReactFireMixin], + + componentWillMount: function() { + this.bindAsArray(firebaseRef.orderByValue(), 'items'); + + firebaseRef.set({ a: 2, b: 1, c: 3 }, function() { + expect(this.state.items).to.deep.equal([ + { $key: 'b', $value: 1 }, + { $key: 'a', $value: 2 }, + { $key: 'c', $value: 3 } + ]); + + done(); + }.bind(this)); + }, + + render: function() { + return React.DOM.div(null); + } + }); + + shallowRenderer.render(React.createElement(TestComponent)); + }); + + it('binds multiple Firebase references to state variables at the same time', function(done) { + var TestComponent = React.createClass({ + mixins: [ReactFireMixin], + + componentWillMount: function() { + this.bindAsArray(firebaseRef.child('items0'), 'bindVar0'); + this.bindAsArray(firebaseRef.child('items1'), 'bindVar1'); + + firebaseRef.set({ + items0: { + first: { index: 0 }, + second: { index: 1 }, + third: { index: 2 } + }, + items1: ['first', 'second', 'third'] + }, function() { + expect(this.state.bindVar0).to.deep.equal([ + { '$key': 'first', index: 0 }, + { '$key': 'second', index: 1 }, + { '$key': 'third', index: 2 } + ]); + + expect(this.state.bindVar1).to.deep.equal([ + { '$key': '0', '$value': 'first' }, + { '$key': '1', '$value': 'second' }, + { '$key': '2', '$value': 'third' } + ]); done(); }.bind(this)); @@ -198,6 +389,150 @@ describe('ReactFire', function() { shallowRenderer.render(React.createElement(TestComponent)); }); + + it('updates an array record when its value changes', function(done) { + var TestComponent = React.createClass({ + mixins: [ReactFireMixin], + + componentWillMount: function() { + this.bindAsArray(firebaseRef, 'items'); + + var _this = this; + firebaseRef.set({ a: 1, b: 2, c: 3 }, function() { + firebaseRef.child('b').set({ foo: 'bar' }, function() { + expect(_this.state.items).to.deep.equal([ + { $key: 'a', $value: 1 }, + { $key: 'b', foo: 'bar' }, + { $key: 'c', $value: 3 } + ]); + + done(); + }); + }); + }, + + render: function() { + return React.DOM.div(null); + } + }); + + shallowRenderer.render(React.createElement(TestComponent)); + }); + + it('removes an array record when it is deleted', function(done) { + var TestComponent = React.createClass({ + mixins: [ReactFireMixin], + + componentWillMount: function() { + this.bindAsArray(firebaseRef, 'items'); + + var _this = this; + firebaseRef.set({ a: 1, b: 2, c: 3 }, function() { + firebaseRef.child('b').remove(function() { + expect(_this.state.items).to.deep.equal([ + { $key: 'a', $value: 1 }, + { $key: 'c', $value: 3 } + ]); + + done(); + }); + }); + }, + + render: function() { + return React.DOM.div(null); + } + }); + + shallowRenderer.render(React.createElement(TestComponent)); + }); + + it('moves an array record when it\'s order changes (moved to start of array)', function(done) { + var TestComponent = React.createClass({ + mixins: [ReactFireMixin], + + componentWillMount: function() { + this.bindAsArray(firebaseRef.orderByValue(), 'items'); + + var _this = this; + firebaseRef.set({ a: 2, b: 3, c: 2 }, function() { + firebaseRef.child('b').set(1, function() { + expect(_this.state.items).to.deep.equal([ + { $key: 'b', $value: 1 }, + { $key: 'a', $value: 2 }, + { $key: 'c', $value: 2 } + ]); + + done(); + }); + }); + }, + + render: function() { + return React.DOM.div(null); + } + }); + + shallowRenderer.render(React.createElement(TestComponent)); + }); + + it('moves an array record when it\'s order changes (moved to middle of array)', function(done) { + var TestComponent = React.createClass({ + mixins: [ReactFireMixin], + + componentWillMount: function() { + this.bindAsArray(firebaseRef.orderByValue(), 'items'); + + var _this = this; + firebaseRef.set({ a: 2, b: 1, c: 4 }, function() { + firebaseRef.child('b').set(3, function() { + expect(_this.state.items).to.deep.equal([ + { $key: 'a', $value: 2 }, + { $key: 'b', $value: 3 }, + { $key: 'c', $value: 4 } + ]); + + done(); + }); + }); + }, + + render: function() { + return React.DOM.div(null); + } + }); + + shallowRenderer.render(React.createElement(TestComponent)); + }); + + it('moves an array record when it\'s order changes (moved to end of array)', function(done) { + var TestComponent = React.createClass({ + mixins: [ReactFireMixin], + + componentWillMount: function() { + this.bindAsArray(firebaseRef.orderByValue(), 'items'); + + var _this = this; + firebaseRef.set({ a: 2, b: 1, c: 3 }, function() { + firebaseRef.child('b').set(4, function() { + expect(_this.state.items).to.deep.equal([ + { $key: 'a', $value: 2 }, + { $key: 'c', $value: 3 }, + { $key: 'b', $value: 4 } + ]); + + done(); + }); + }); + }, + + render: function() { + return React.DOM.div(null); + } + }); + + shallowRenderer.render(React.createElement(TestComponent)); + }); }); @@ -212,7 +547,7 @@ describe('ReactFire', function() { TH.invalidFirebaseRefs.forEach(function(invalidFirebaseRef) { expect(function() { _this.bindAsObject(invalidFirebaseRef, 'items'); - }).to.throw('ReactFire: firebaseRef must be an instance of Firebase'); + }).to.throw('ReactFire: Invalid Firebase reference'); }); }, @@ -234,7 +569,7 @@ describe('ReactFire', function() { TH.invalidBindVars.forEach(function(invalidBindVar) { expect(function() { _this.bindAsObject(firebaseRef, invalidBindVar); - }).to.throw(/bindVar/); + }).to.throw(/Bind variable/); }); }, @@ -246,7 +581,28 @@ describe('ReactFire', function() { shallowRenderer.render(React.createElement(TestComponent)); }); - it('binds objects', function(done) { + it('throws error given an already bound bind variable', function() { + var TestComponent = React.createClass({ + mixins: [ReactFireMixin], + + componentWillMount: function() { + var _this = this; + + expect(function() { + _this.bindAsObject(firebaseRef, 'items'); + _this.bindAsObject(firebaseRef, 'items'); + }).to.throw('this.state.items is already bound to a Firebase reference'); + }, + + render: function() { + return React.DOM.div(null); + } + }); + + shallowRenderer.render(React.createElement(TestComponent)); + }); + + it('binds to an object', function(done) { var TestComponent = React.createClass({ mixins: [ReactFireMixin], @@ -274,6 +630,50 @@ describe('ReactFire', function() { shallowRenderer.render(React.createElement(TestComponent)); }); + it('binds to a primitive', function(done) { + var TestComponent = React.createClass({ + mixins: [ReactFireMixin], + + componentWillMount: function() { + this.bindAsObject(firebaseRef, 'items'); + + firebaseRef.set('foo', function() { + expect(this.state.items).to.deep.equal('foo'); + + done(); + }.bind(this)); + }, + + render: function() { + return React.DOM.div(null); + } + }); + + shallowRenderer.render(React.createElement(TestComponent)); + }); + + it('binds as null for Firebase references with no data', function(done) { + var TestComponent = React.createClass({ + mixins: [ReactFireMixin], + + componentWillMount: function() { + this.bindAsObject(firebaseRef, 'items'); + + firebaseRef.set(null, function() { + expect(this.state.items).to.be.null; + + done(); + }.bind(this)); + }, + + render: function() { + return React.DOM.div(null); + } + }); + + shallowRenderer.render(React.createElement(TestComponent)); + }); + it('binds with limit queries', function(done) { var TestComponent = React.createClass({ mixins: [ReactFireMixin], @@ -302,6 +702,92 @@ describe('ReactFire', function() { shallowRenderer.render(React.createElement(TestComponent)); }); + + it('binds multiple Firebase references to state variables at the same time', function(done) { + var TestComponent = React.createClass({ + mixins: [ReactFireMixin], + + componentWillMount: function() { + this.bindAsObject(firebaseRef.child('items0'), 'bindVar0'); + this.bindAsObject(firebaseRef.child('items1'), 'bindVar1'); + + var items0 = { + first: { index: 0 }, + second: { index: 1 }, + third: { index: 2 } + }; + + var items1 = { + bar: { + foo: 'baz' + }, + baz: true, + foo: 100 + }; + + firebaseRef.set({ + items0: items0, + items1: items1 + }, function() { + expect(this.state.bindVar0).to.deep.equal(items0); + expect(this.state.bindVar1).to.deep.equal(items1); + + done(); + }.bind(this)); + }, + + render: function() { + return React.DOM.div(null); + } + }); + + shallowRenderer.render(React.createElement(TestComponent)); + }); + + it('binds a mixture of arrays and objects to state variables at the same time', function(done) { + var TestComponent = React.createClass({ + mixins: [ReactFireMixin], + + componentWillMount: function() { + this.bindAsObject(firebaseRef.child('items0'), 'bindVar0'); + this.bindAsArray(firebaseRef.child('items1'), 'bindVar1'); + + var items0 = { + first: { index: 0 }, + second: { index: 1 }, + third: { index: 2 } + }; + + var items1 = { + bar: { + foo: 'baz' + }, + baz: true, + foo: 100 + }; + + firebaseRef.set({ + items0: items0, + items1: items1 + }, function() { + expect(this.state.bindVar0).to.deep.equal(items0); + expect(this.state.bindVar1).to.deep.equal([ + { $key: 'bar', foo: 'baz' }, + { $key: 'baz', $value: true }, + { $key: 'foo', $value: 100 } + ]); + + done(); + }.bind(this)); + }, + + render: function() { + return React.DOM.div(null); + } + }); + + shallowRenderer.render(React.createElement(TestComponent)); + }); }); @@ -316,7 +802,7 @@ describe('ReactFire', function() { TH.invalidBindVars.forEach(function(invalidBindVar) { expect(function() { _this.unbind(invalidBindVar); - }).to.throw(/bindVar/); + }).to.throw(/Bind variable/); }); }, @@ -337,7 +823,7 @@ describe('ReactFire', function() { expect(function() { _this.unbind('items'); - }).to.throw(/bindVar/); + }).to.throw('this.state.items is not bound to a Firebase reference'); }, render: function() { @@ -407,7 +893,7 @@ describe('ReactFire', function() { mixins: [ReactFireMixin], componentWillMount: function() { - this.bindAsObject(firebaseRef.limitToLast(2), 'items'); + this.bindAsObject(firebaseRef, 'items'); firebaseRef.set({ first: { index: 0 }, @@ -428,5 +914,38 @@ describe('ReactFire', function() { shallowRenderer.render(React.createElement(TestComponent)); }); + + it('unbinds all bound state when the component unmounts', function(done) { + var TestComponent = React.createClass({ + mixins: [ReactFireMixin], + + componentWillMount: function() { + sinon.spy(this, 'unbind'); + + this.bindAsArray(firebaseRef, 'items0'); + this.bindAsObject(firebaseRef, 'items1'); + + firebaseRef.set({ + first: { index: 0 }, + second: { index: 1 }, + third: { index: 2 } + }, function() { + shallowRenderer.unmount(); + + expect(this.unbind).to.have.been.calledTwice; + expect(this.unbind.args[0][0]).to.equal('items0'); + expect(this.unbind.args[1][0]).to.equal('items1'); + + done(); + }.bind(this)); + }, + + render: function() { + return React.DOM.div(null); + } + }); + + shallowRenderer.render(React.createElement(TestComponent)); + }); }); });