diff --git a/backbone-polymer-mixin.js b/backbone-polymer-mixin.js index 33e5997..87f6f0c 100644 --- a/backbone-polymer-mixin.js +++ b/backbone-polymer-mixin.js @@ -1,6 +1,5 @@ var BackbonePolymerAttach = function(element, pathPrefix) { - console.log('BackbonePolymerAttach', this, element, pathPrefix); var indexOf = this.indexOf.bind(this); @@ -14,44 +13,123 @@ var BackbonePolymerAttach = function(element, pathPrefix) { }); }; - var splicesObject = this.models; + this.each(modelSetup.bind(this)); - var addNotify = function(model) { - var ix = indexOf(model); + // Default options for `Collection#set`. + var setOptions = {add: true, remove: true, merge: true}; + // Splices `insert` into `array` at index `at`. + var splice = function(array, insert, at) { + at = Math.min(Math.max(at, 0), array.length); + var args = [pathPrefix + '.models', at, 0].concat(insert); + element.splice.apply(this, args); + }; + // Update a collection by `set`-ing a new list of models, adding new ones, + // removing models that are no longer present, and merging models that + // already exist in the collection, as necessary. Similar to **Model#set**, + // the core operation for updating the data contained by the collection. + this.set = function(models, options) { + if (models == null) return; - // https://www.polymer-project.org/1.0/docs/devguide/properties.html#array-observation - var change = {keySplices:[], indexSplices:[]}; - change.keySplices.push({ - index: ix, - removed: [], - removedItems: [], - added: [ix] - }); - change.indexSplices.push({ - index: ix, - addedCount: 1, - removed: [], - object: splicesObject, - type: 'splice', - addedKeys: [ix] - }); + options = _.defaults({}, options, setOptions); + if (options.parse && !this._isModel(models)) models = this.parse(models, options); - element.notifyPath(pathPrefix + '.models.splices', change); + var singular = !_.isArray(models); + models = singular ? [models] : models.slice(); - // remove this timeout and the rendered element goes blank - window.setTimeout(function() { - // TODO would it be possible to notify .* here? - for (var attribute in model.attributes) { - // we could reuse code with modelSetup here - var value = model.get(attribute); - element.notifyPath(pathPrefix + '.models.' + ix + '.attributes.' + attribute, value); + var at = options.at; + if (at != null) at = +at; + if (at < 0) at += this.length + 1; + + var set = []; + var toAdd = []; + var toRemove = []; + var modelMap = {}; + + var add = options.add; + var merge = options.merge; + var remove = options.remove; + + var sort = false; + var sortable = this.comparator && (at == null) && options.sort !== false; + var sortAttr = _.isString(this.comparator) ? this.comparator : null; + + // Turn bare objects into model references, and prevent invalid models + // from being added. + var model; + for (var i = 0; i < models.length; i++) { + model = models[i]; + + // If a duplicate is found, prevent it from being added and + // optionally merge it into the existing model. + var existing = this.get(model); + if (existing) { + if (merge && model !== existing) { + var attrs = this._isModel(model) ? model.attributes : model; + if (options.parse) attrs = existing.parse(attrs, options); + existing.set(attrs, options); + if (sortable && !sort) sort = existing.hasChanged(sortAttr); + } + if (!modelMap[existing.cid]) { + modelMap[existing.cid] = true; + set.push(existing); + } + models[i] = existing; + + // If this is a new, valid model, push it to the `toAdd` list. + } else if (add) { + model = models[i] = this._prepareModel(model, options); + if (model) { + toAdd.push(model); + this._addReference(model, options); + modelMap[model.cid] = true; + set.push(model); + } + } + } + + // Remove stale models. + if (remove) { + for (i = 0; i < this.length; i++) { + model = this.models[i]; + if (!modelMap[model.cid]) toRemove.push(model); + } + if (toRemove.length) this._removeModels(toRemove, options); + } + + // See if sorting is needed, update `length` and splice in new models. + var orderChanged = false; + var replace = !sortable && add && remove; + if (set.length && replace) { + orderChanged = this.length != set.length || _.some(this.models, function(model, index) { + return model !== set[index]; + }); + this.models.length = 0; + splice(this.models, set, 0); + this.length = this.models.length; + } else if (toAdd.length) { + if (sortable) sort = true; + splice(this.models, toAdd, at == null ? this.length : at); + this.length = this.models.length; + } + + // Silently sort the collection if appropriate. + if (sort) this.sort({silent: true}); + + // Unless silenced, it's time to fire all appropriate add/sort events. + if (!options.silent) { + for (i = 0; i < toAdd.length; i++) { + if (at != null) options.index = at + i; + model = toAdd[i]; + model.trigger('add', model, this, options); } - }, 1); + if (sort || orderChanged) this.trigger('sort', this, options); + if (toAdd.length || toRemove.length) this.trigger('update', this, options); + } + + // Return the added (or merged) model (or models). + return singular ? models[0] : models; }; - this.each(modelSetup.bind(this)); - this.on('add', addNotify.bind(this)); - this.on('add', modelSetup.bind(this)); }; if (typeof module !== 'undefined') { diff --git a/test/backbone-emulated-events-spec.js b/test/backbone-emulated-events-spec.js new file mode 100644 index 0000000..9d964fe --- /dev/null +++ b/test/backbone-emulated-events-spec.js @@ -0,0 +1,141 @@ + +var expect = require('chai').expect; + +// still undecided on how the mixin gets dependencies +_ = require('yobo')._; + +var Backbone = require('yobo').Backbone; + +var BackbonePolymerAttach = require('../backbone-polymer-mixin'); + +var PolymerElementMock = function(testArray) { + + var spliced = this.spliced = []; + + this.splice = function(path, index, removeCount, items) { + spliced.push({path: path, index: index, removeCount: removeCount, items: items}); + if (typeof testArray !== 'undefined') { + testArray.splice.apply(testArray, [index, removeCount].concat(items)); + } + }; + + this.notifyPath = function() { + // our code still does this for attribute changes + }; + +}; + +describe("Array modification through Polymer splices, emulate backbone events", function() { + + describe("Backbone events before backbone-polymer", function() { + + it("add model", function() { + var c = new Backbone.Collection(); + var m = new Backbone.Model({id: 'a1', type: 'testmodel'}) + c.on('add', function(model, collection, options) { + console.log('add', m === model ? '(model)' : model, c === collection ? '(collection)' : collection, JSON.stringify(options)); + }); + c.add(m); + }); + + it("add model at index", function() { + var c = new Backbone.Collection(); + c.add({id: 'a2', type: 'testmodel2'}); + c.add({id: 'a3', type: 'testmodel3'}); + var m = new Backbone.Model({id: 'a1', type: 'testmodel'}); + c.on('add', function(model, collection, options) { + console.log('add', m === model ? '(model)' : model, c === collection ? '(collection)' : collection, JSON.stringify(options)); + }); + m.on('add', function(model, collection, options) { + console.log('model add', m === model ? '(model)' : model, c === collection ? '(collection)' : collection, JSON.stringify(options)); + }); + var added = c.add(m, {at: 1}); + console.log('add returned', m === added ? '(model)' : model); + // common Backbone operations + expect(c.get('a1')).to.exist.and.have.property('cid'); + expect(c.at(0)).to.exist.and.have.property('cid'); + }); + + }); + + describe("#add, reduced Backbone functionality", function() { + + xit("Accepts only a single item", function() { + expect(function() { + var c = new Backbone.Collection(); + BackbonePolymerAttach.call(c, new PolymerElementMock(), 'edit.units'); + c.add([]); + }).to.throw(/single/); + }); + + xit("Requires item to be a real model", function() { + expect(function() { + var c = new Backbone.Collection(); + BackbonePolymerAttach.call(c, new PolymerElementMock(), 'edit.units'); + c.add({id: 'add1', type: 'test'}); + }).to.throw(/requires model instance/); + }); + + xit("Bails out if the model already exists", function() { + expect(function() { + var c = new Backbone.Collection(); + BackbonePolymerAttach.call(c, new PolymerElementMock(), 'edit.units'); + var m = new Backbone.Model({id: 'add1', type: 'test'}); + c.add(m); + c.add(m); + }).to.throw(/backbone-polymer model already exists as cid \w+/); + }); + + it("Is transparent to backbone add event listener", function() { + var c = new Backbone.Collection(); + var e = new PolymerElementMock(c.models); + + var adds = []; + c.on('add', function(m, c, o) { + adds.push({model:m, collection:c, options:o}); + }); + + BackbonePolymerAttach.call(c, e, 'edit.units'); + var m = c.add(new Backbone.Model({id: 'add1', type: 'test'})); + + expect(m.get('type')).to.equal('test'); + expect(adds).to.have.length(1); + expect(e.spliced).to.have.length(1); + + //Expects on the options obj. + expect(adds[0].options).to.be.an('object'); + expect(adds[0].options).to.have.property('add').that.equals(true); + expect(adds[0].options).to.have.property('merge').that.equals(false); + expect(adds[0].options).to.have.property('remove').that.equals(false); + expect(adds[0].options).to.not.have.property('at'); + expect(adds[0].collection).to.have.property('length').and.equal(1); + + var m1 = new Backbone.Model({id: 'add2', type: 'test'}); + modeladd = []; + m1.on('add', function(m, c, o) { + modeladd.push({model:m, collection:c, options:o}); + }); + c.add(m1, {at: 1}); + + //Adding at an index, expects Options to have 'at' + expect(adds[1].options).to.have.property('add').that.equals(true); + expect(adds[1].options).to.have.property('at').that.equals(1); + expect(adds[1].collection).to.have.length(2); + expect(e.spliced).to.have.length(2); + expect(modeladd).to.have.length(1); + expect(modeladd[0]).to.deep.equal(adds[1]); + + //Adding again at index 1, i.e. insert + var m2 = new Backbone.Model({id: 'add3', type: 'test'}); + c.add(m2, {at: 1}); + + expect(e.spliced[2]).to.have.property('index').and.equal(1); + expect(adds[0].collection).to.have.property('length').and.equal(3); + + // common Backbone operations + expect(c.get('add1')).to.exist.and.have.property('cid'); + expect(c.models).to.have.length(3); + expect(c.at(1)).to.exist.and.have.property('cid'); + }); + }); +}); diff --git a/test/test.html b/test/test.html index 5aab32a..6f8d11e 100644 --- a/test/test.html +++ b/test/test.html @@ -85,7 +85,7 @@ }); - it("Uses 'this' for the collection, like a regular Backbone member function", function(done) { + it("Can be used with .call instead of as mixin", function(done) { var element1 = document.querySelector('#collection1');