2 new commits in galaxy-central: https://bitbucket.org/galaxy/galaxy-central/changeset/0a957e499a3c/ changeset: 0a957e499a3c user: jgoecks date: 2012-04-11 16:31:18 summary: Add Backbone-relational library; this is used for managing complex object/collection relationships. affected #: 2 files diff -r bf88ce609370a7829337dad758fafe388b3fd117 -r 0a957e499a3c9b7ba11f88d337e4878260d73d6d static/scripts/libs/backbone-relational.js --- /dev/null +++ b/static/scripts/libs/backbone-relational.js @@ -0,0 +1,1406 @@ +/** + * Backbone-relational.js 0.5.0 + * (c) 2011 Paul Uithol + * + * Backbone-relational may be freely distributed under the MIT license. + * For details and documentation: https://github.com/PaulUithol/Backbone-relational. + * Depends on Backbone: https://github.com/documentcloud/backbone. + */ +( function( undefined ) { + "use strict"; + + /** + * CommonJS shim + **/ + var _, Backbone, exports; + if ( typeof window === 'undefined' ) { + _ = require( 'underscore' ); + Backbone = require( 'backbone' ); + exports = module.exports = Backbone; + } + else { + var _ = window._; + Backbone = window.Backbone; + exports = window; + } + + Backbone.Relational = { + showWarnings: true + }; + + /** + * Semaphore mixin; can be used as both binary and counting. + **/ + Backbone.Semaphore = { + _permitsAvailable: null, + _permitsUsed: 0, + + acquire: function() { + if ( this._permitsAvailable && this._permitsUsed >= this._permitsAvailable ) { + throw new Error( 'Max permits acquired' ); + } + else { + this._permitsUsed++; + } + }, + + release: function() { + if ( this._permitsUsed === 0 ) { + throw new Error( 'All permits released' ); + } + else { + this._permitsUsed--; + } + }, + + isLocked: function() { + return this._permitsUsed > 0; + }, + + setAvailablePermits: function( amount ) { + if ( this._permitsUsed > amount ) { + throw new Error( 'Available permits cannot be less than used permits' ); + } + this._permitsAvailable = amount; + } + }; + + /** + * A BlockingQueue that accumulates items while blocked (via 'block'), + * and processes them when unblocked (via 'unblock'). + * Process can also be called manually (via 'process'). + */ + Backbone.BlockingQueue = function() { + this._queue = []; + }; + _.extend( Backbone.BlockingQueue.prototype, Backbone.Semaphore, { + _queue: null, + + add: function( func ) { + if ( this.isBlocked() ) { + this._queue.push( func ); + } + else { + func(); + } + }, + + process: function() { + while ( this._queue && this._queue.length ) { + this._queue.shift()(); + } + }, + + block: function() { + this.acquire(); + }, + + unblock: function() { + this.release(); + if ( !this.isBlocked() ) { + this.process(); + } + }, + + isBlocked: function() { + return this.isLocked(); + } + }); + /** + * Global event queue. Accumulates external events ('add:<key>', 'remove:<key>' and 'update:<key>') + * until the top-level object is fully initialized (see 'Backbone.RelationalModel'). + */ + Backbone.Relational.eventQueue = new Backbone.BlockingQueue(); + + /** + * Backbone.Store keeps track of all created (and destruction of) Backbone.RelationalModel. + * Handles lookup for relations. + */ + Backbone.Store = function() { + this._collections = []; + this._reverseRelations = []; + }; + _.extend( Backbone.Store.prototype, Backbone.Events, { + _collections: null, + _reverseRelations: null, + + /** + * Add a reverse relation. Is added to the 'relations' property on model's prototype, and to + * existing instances of 'model' in the store as well. + * @param {object} relation + * @param {Backbone.RelationalModel} relation.model + * @param {String} relation.type + * @param {String} relation.key + * @param {String|object} relation.relatedModel + */ + addReverseRelation: function( relation ) { + var exists = _.any( this._reverseRelations, function( rel ) { + return _.all( relation, function( val, key ) { + return val === rel[ key ]; + }); + }); + + if ( !exists && relation.model && relation.type ) { + this._reverseRelations.push( relation ); + + if ( !relation.model.prototype.relations ) { + relation.model.prototype.relations = []; + } + relation.model.prototype.relations.push( relation ); + + this.retroFitRelation( relation ); + } + }, + + /** + * Add a 'relation' to all existing instances of 'relation.model' in the store + * @param {object} relation + */ + retroFitRelation: function( relation ) { + var coll = this.getCollection( relation.model ); + coll.each( function( model ) { + new relation.type( model, relation ); + }, this); + }, + + /** + * Find the Store's collection for a certain type of model. + * @param {Backbone.RelationalModel} model + * @return {Backbone.Collection} A collection if found (or applicable for 'model'), or null + */ + getCollection: function( model ) { + var coll = _.detect( this._collections, function( c ) { + // Check if model is the type itself (a ref to the constructor), or is of type c.model + return model === c.model || model.constructor === c.model; + }); + + if ( !coll ) { + coll = this._createCollection( model ); + } + + return coll; + }, + + /** + * Find a type on the global object by name. Splits name on dots. + * @param {String} name + * @return {Object} + */ + getObjectByName: function( name ) { + var type = _.reduce( name.split( '.' ), function( memo, val ) { + return memo[ val ]; + }, exports); + return type !== exports ? type: null; + }, + + _createCollection: function( type ) { + var coll; + + // If 'type' is an instance, take it's constructor + if ( type instanceof Backbone.RelationalModel ) { + type = type.constructor; + } + + // Type should inherit from Backbone.RelationalModel. + if ( type.prototype instanceof Backbone.RelationalModel.prototype.constructor ) { + coll = new Backbone.Collection(); + coll.model = type; + + this._collections.push( coll ); + } + + return coll; + }, + + /** + * Find an id + * @param type + * @param {String|Number|Object|Backbone.RelationalModel} item + */ + resolveIdForItem: function( type, item ) { + var id = _.isString( item ) || _.isNumber( item ) ? item : null; + + if ( id == null ) { + if ( item instanceof Backbone.RelationalModel ) { + id = item.id; + } + else if ( _.isObject( item ) ) { + id = item[ type.prototype.idAttribute ]; + } + } + + return id; + }, + + /** + * + * @param type + * @param {String|Number|Object|Backbone.RelationalModel} item + */ + find: function( type, item ) { + var id = this.resolveIdForItem( type, item ); + var coll = this.getCollection( type ); + return coll && coll.get( id ); + }, + + /** + * Add a 'model' to it's appropriate collection. Retain the original contents of 'model.collection'. + * @param {Backbone.RelationalModel} model + */ + register: function( model ) { + var modelColl = model.collection; + var coll = this.getCollection( model ); + coll && coll.add( model ); + model.bind( 'destroy', this.unregister, this ); + model.collection = modelColl; + }, + + /** + * Explicitly update a model's id in it's store collection + * @param {Backbone.RelationalModel} model + */ + update: function( model ) { + var coll = this.getCollection( model ); + coll._onModelEvent( 'change:' + model.idAttribute, model, coll ); + }, + + /** + * Remove a 'model' from the store. + * @param {Backbone.RelationalModel} model + */ + unregister: function( model ) { + model.unbind( 'destroy', this.unregister ); + var coll = this.getCollection( model ); + coll && coll.remove( model ); + } + }); + Backbone.Relational.store = new Backbone.Store(); + + /** + * The main Relation class, from which 'HasOne' and 'HasMany' inherit. Internally, 'relational:<key>' events + * are used to regulate addition and removal of models from relations. + * + * @param {Backbone.RelationalModel} instance + * @param {object} options + * @param {string} options.key + * @param {Backbone.RelationalModel.constructor} options.relatedModel + * @param {Boolean|String} [options.includeInJSON=true] Serialize the given attribute for related model(s)' in toJSON, or just their ids. + * @param {Boolean} [options.createModels=true] Create objects from the contents of keys if the object is not found in Backbone.store. + * @param {object} [options.reverseRelation] Specify a bi-directional relation. If provided, Relation will reciprocate + * the relation to the 'relatedModel'. Required and optional properties match 'options', except that it also needs + * {Backbone.Relation|String} type ('HasOne' or 'HasMany'). + */ + Backbone.Relation = function( instance, options ) { + this.instance = instance; + // Make sure 'options' is sane, and fill with defaults from subclasses and this object's prototype + options = ( typeof options === 'object' && options ) || {}; + this.reverseRelation = _.defaults( options.reverseRelation || {}, this.options.reverseRelation ); + this.reverseRelation.type = !_.isString( this.reverseRelation.type ) ? this.reverseRelation.type : + Backbone[ this.reverseRelation.type ] || Backbone.Relational.store.getObjectByName( this.reverseRelation.type ); + this.model = options.model || this.instance.constructor; + this.options = _.defaults( options, this.options, Backbone.Relation.prototype.options ); + + this.key = this.options.key; + this.keySource = this.options.keySource || this.key; + this.keyDestination = this.options.keyDestination || this.options.keySource || this.key; + + // 'exports' should be the global object where 'relatedModel' can be found on if given as a string. + this.relatedModel = this.options.relatedModel; + if ( _.isString( this.relatedModel ) ) { + this.relatedModel = Backbone.Relational.store.getObjectByName( this.relatedModel ); + } + + if ( !this.checkPreconditions() ) { + return false; + } + + if ( instance ) { + this.keyContents = this.instance.get( this.keySource ); + + // Explicitly clear 'keySource', to prevent a leaky abstraction if 'keySource' differs from 'key'. + if ( this.key !== this.keySource ) { + this.instance.unset( this.keySource, { silent: true } ); + } + + // Add this Relation to instance._relations + this.instance._relations.push( this ); + } + + // Add the reverse relation on 'relatedModel' to the store's reverseRelations + if ( !this.options.isAutoRelation && this.reverseRelation.type && this.reverseRelation.key ) { + Backbone.Relational.store.addReverseRelation( _.defaults( { + isAutoRelation: true, + model: this.relatedModel, + relatedModel: this.model, + reverseRelation: this.options // current relation is the 'reverseRelation' for it's own reverseRelation + }, + this.reverseRelation // Take further properties from this.reverseRelation (type, key, etc.) + ) ); + } + + _.bindAll( this, '_modelRemovedFromCollection', '_relatedModelAdded', '_relatedModelRemoved' ); + + if( instance ) { + this.initialize(); + + // When a model in the store is destroyed, check if it is 'this.instance'. + Backbone.Relational.store.getCollection( this.instance ) + .bind( 'relational:remove', this._modelRemovedFromCollection ); + + // When 'relatedModel' are created or destroyed, check if it affects this relation. + Backbone.Relational.store.getCollection( this.relatedModel ) + .bind( 'relational:add', this._relatedModelAdded ) + .bind( 'relational:remove', this._relatedModelRemoved ); + } + }; + // Fix inheritance :\ + Backbone.Relation.extend = Backbone.Model.extend; + // Set up all inheritable **Backbone.Relation** properties and methods. + _.extend( Backbone.Relation.prototype, Backbone.Events, Backbone.Semaphore, { + options: { + createModels: true, + includeInJSON: true, + isAutoRelation: false + }, + + instance: null, + key: null, + keyContents: null, + relatedModel: null, + reverseRelation: null, + related: null, + + _relatedModelAdded: function( model, coll, options ) { + // Allow 'model' to set up it's relations, before calling 'tryAddRelated' + // (which can result in a call to 'addRelated' on a relation of 'model') + var dit = this; + model.queue( function() { + dit.tryAddRelated( model, options ); + }); + }, + + _relatedModelRemoved: function( model, coll, options ) { + this.removeRelated( model, options ); + }, + + _modelRemovedFromCollection: function( model ) { + if ( model === this.instance ) { + this.destroy(); + } + }, + + /** + * Check several pre-conditions. + * @return {Boolean} True if pre-conditions are satisfied, false if they're not. + */ + checkPreconditions: function() { + var i = this.instance, + k = this.key, + m = this.model, + rm = this.relatedModel, + warn = Backbone.Relational.showWarnings && typeof console !== 'undefined'; + + if ( !m || !k || !rm ) { + warn && console.warn( 'Relation=%o; no model, key or relatedModel (%o, %o, %o)', this, m, k, rm ); + return false; + } + // Check if the type in 'relatedModel' inherits from Backbone.RelationalModel + if ( !( m.prototype instanceof Backbone.RelationalModel.prototype.constructor ) ) { + warn && console.warn( 'Relation=%o; model does not inherit from Backbone.RelationalModel (%o)', this, i ); + return false; + } + // Check if the type in 'relatedModel' inherits from Backbone.RelationalModel + if ( !( rm.prototype instanceof Backbone.RelationalModel.prototype.constructor ) ) { + warn && console.warn( 'Relation=%o; relatedModel does not inherit from Backbone.RelationalModel (%o)', this, rm ); + return false; + } + // Check if this is not a HasMany, and the reverse relation is HasMany as well + if ( this instanceof Backbone.HasMany && this.reverseRelation.type === Backbone.HasMany.prototype.constructor ) { + warn && console.warn( 'Relation=%o; relation is a HasMany, and the reverseRelation is HasMany as well.', this ); + return false; + } + + // Check if we're not attempting to create a duplicate relationship + if( i && i._relations.length ) { + var exists = _.any( i._relations, function( rel ) { + var hasReverseRelation = this.reverseRelation.key && rel.reverseRelation.key; + return rel.relatedModel === rm && rel.key === k && + ( !hasReverseRelation || this.reverseRelation.key === rel.reverseRelation.key ); + }, this ); + + if ( exists ) { + warn && console.warn( 'Relation=%o between instance=%o.%s and relatedModel=%o.%s already exists', + this, i, k, rm, this.reverseRelation.key ); + return false; + } + } + + return true; + }, + + /** + * Set the related model(s) for this relation + * @param {Backbone.Mode|Backbone.Collection} related + * @param {Object} [options] + */ + setRelated: function( related, options ) { + this.related = related; + + this.instance.acquire(); + this.instance.set( this.key, related, _.defaults( options || {}, { silent: true } ) ); + this.instance.release(); + }, + + createModel: function( item ) { + if ( this.options.createModels && typeof( item ) === 'object' ) { + return new this.relatedModel( item ); + } + }, + + /** + * Determine if a relation (on a different RelationalModel) is the reverse + * relation of the current one. + * @param {Backbone.Relation} relation + * @return {Boolean} + */ + _isReverseRelation: function( relation ) { + if ( relation.instance instanceof this.relatedModel && this.reverseRelation.key === relation.key && + this.key === relation.reverseRelation.key ) { + return true; + } + return false; + }, + + /** + * Get the reverse relations (pointing back to 'this.key' on 'this.instance') for the currently related model(s). + * @param {Backbone.RelationalModel} [model] Get the reverse relations for a specific model. + * If not specified, 'this.related' is used. + * @return {Backbone.Relation[]} + */ + getReverseRelations: function( model ) { + var reverseRelations = []; + // Iterate over 'model', 'this.related.models' (if this.related is a Backbone.Collection), or wrap 'this.related' in an array. + var models = !_.isUndefined( model ) ? [ model ] : this.related && ( this.related.models || [ this.related ] ); + _.each( models , function( related ) { + _.each( related.getRelations(), function( relation ) { + if ( this._isReverseRelation( relation ) ) { + reverseRelations.push( relation ); + } + }, this ); + }, this ); + + return reverseRelations; + }, + + /** + * Rename options.silent to options.silentChange, so events propagate properly. + * (for example in HasMany, from 'addRelated'->'handleAddition') + * @param {Object} [options] + * @return {Object} + */ + sanitizeOptions: function( options ) { + options = options ? _.clone( options ) : {}; + if ( options.silent ) { + options = _.extend( {}, options, { silentChange: true } ); + delete options.silent; + } + return options; + }, + + /** + * Rename options.silentChange to options.silent, so events are silenced as intended in Backbone's + * original functions. + * @param {Object} [options] + * @return {Object} + */ + unsanitizeOptions: function( options ) { + options = options ? _.clone( options ) : {}; + if ( options.silentChange ) { + options = _.extend( {}, options, { silent: true } ); + delete options.silentChange; + } + return options; + }, + + // Cleanup. Get reverse relation, call removeRelated on each. + destroy: function() { + Backbone.Relational.store.getCollection( this.instance ) + .unbind( 'relational:remove', this._modelRemovedFromCollection ); + + Backbone.Relational.store.getCollection( this.relatedModel ) + .unbind( 'relational:add', this._relatedModelAdded ) + .unbind( 'relational:remove', this._relatedModelRemoved ); + + _.each( this.getReverseRelations(), function( relation ) { + relation.removeRelated( this.instance ); + }, this ); + } + }); + + Backbone.HasOne = Backbone.Relation.extend({ + options: { + reverseRelation: { type: 'HasMany' } + }, + + initialize: function() { + _.bindAll( this, 'onChange' ); + + this.instance.bind( 'relational:change:' + this.key, this.onChange ); + + var model = this.findRelated( { silent: true } ); + this.setRelated( model ); + + // Notify new 'related' object of the new relation. + var dit = this; + _.each( dit.getReverseRelations(), function( relation ) { + relation.addRelated( dit.instance ); + } ); + }, + + findRelated: function( options ) { + var item = this.keyContents; + var model = null; + + if ( item instanceof this.relatedModel ) { + model = item; + } + else if ( item ) { + // Try to find an instance of the appropriate 'relatedModel' in the store, or create it + model = Backbone.Relational.store.find( this.relatedModel, item ); + + if ( model && _.isObject( item ) ) { + model.set( item, options ); + } + else if ( !model ) { + model = this.createModel( item ); + } + } + + return model; + }, + + /** + * If the key is changed, notify old & new reverse relations and initialize the new relation + */ + onChange: function( model, attr, options ) { + // Don't accept recursive calls to onChange (like onChange->findRelated->createModel->initializeRelations->addRelated->onChange) + if ( this.isLocked() ) { + return; + } + this.acquire(); + options = this.sanitizeOptions( options ); + + // 'options._related' is set by 'addRelated'/'removeRelated'. If it is set, the change + // is the result of a call from a relation. If it's not, the change is the result of + // a 'set' call on this.instance. + var changed = _.isUndefined( options._related ); + var oldRelated = changed ? this.related : options._related; + + if ( changed ) { + this.keyContents = attr; + + // Set new 'related' + if ( attr instanceof this.relatedModel ) { + this.related = attr; + } + else if ( attr ) { + var related = this.findRelated( options ); + this.setRelated( related ); + } + else { + this.setRelated( null ); + } + } + + // Notify old 'related' object of the terminated relation + if ( oldRelated && this.related !== oldRelated ) { + _.each( this.getReverseRelations( oldRelated ), function( relation ) { + relation.removeRelated( this.instance, options ); + }, this ); + } + + // Notify new 'related' object of the new relation. Note we do re-apply even if this.related is oldRelated; + // that can be necessary for bi-directional relations if 'this.instance' was created after 'this.related'. + // In that case, 'this.instance' will already know 'this.related', but the reverse might not exist yet. + _.each( this.getReverseRelations(), function( relation ) { + relation.addRelated( this.instance, options ); + }, this); + + // Fire the 'update:<key>' event if 'related' was updated + if ( !options.silentChange && this.related !== oldRelated ) { + var dit = this; + Backbone.Relational.eventQueue.add( function() { + dit.instance.trigger( 'update:' + dit.key, dit.instance, dit.related, options ); + }); + } + this.release(); + }, + + /** + * If a new 'this.relatedModel' appears in the 'store', try to match it to the last set 'keyContents' + */ + tryAddRelated: function( model, options ) { + if ( this.related ) { + return; + } + options = this.sanitizeOptions( options ); + + var item = this.keyContents; + if ( item ) { + var id = Backbone.Relational.store.resolveIdForItem( this.relatedModel, item ); + if ( model.id === id ) { + this.addRelated( model, options ); + } + } + }, + + addRelated: function( model, options ) { + if ( model !== this.related ) { + var oldRelated = this.related || null; + this.setRelated( model ); + this.onChange( this.instance, model, { _related: oldRelated } ); + } + }, + + removeRelated: function( model, options ) { + if ( !this.related ) { + return; + } + + if ( model === this.related ) { + var oldRelated = this.related || null; + this.setRelated( null ); + this.onChange( this.instance, model, { _related: oldRelated } ); + } + } + }); + + Backbone.HasMany = Backbone.Relation.extend({ + collectionType: null, + + options: { + reverseRelation: { type: 'HasOne' }, + collectionType: Backbone.Collection, + collectionKey: true, + collectionOptions: {} + }, + + initialize: function() { + _.bindAll( this, 'onChange', 'handleAddition', 'handleRemoval', 'handleReset' ); + this.instance.bind( 'relational:change:' + this.key, this.onChange ); + + // Handle a custom 'collectionType' + this.collectionType = this.options.collectionType; + if ( _( this.collectionType ).isString() ) { + this.collectionType = Backbone.Relational.store.getObjectByName( this.collectionType ); + } + if ( !this.collectionType.prototype instanceof Backbone.Collection.prototype.constructor ){ + throw new Error( 'collectionType must inherit from Backbone.Collection' ); + } + + // Handle cases where a model/relation is created with a collection passed straight into 'attributes' + if ( this.keyContents instanceof Backbone.Collection ) { + this.setRelated( this._prepareCollection( this.keyContents ) ); + } + else { + this.setRelated( this._prepareCollection() ); + } + + this.findRelated( { silent: true } ); + }, + + _getCollectionOptions: function() { + return _.isFunction( this.options.collectionOptions ) ? + this.options.collectionOptions( this.instance ) : + this.options.collectionOptions; + }, + + /** + * Bind events and setup collectionKeys for a collection that is to be used as the backing store for a HasMany. + * If no 'collection' is supplied, a new collection will be created of the specified 'collectionType' option. + * @param {Backbone.Collection} [collection] + */ + _prepareCollection: function( collection ) { + if ( this.related ) { + this.related + .unbind( 'relational:add', this.handleAddition ) + .unbind( 'relational:remove', this.handleRemoval ) + .unbind( 'relational:reset', this.handleReset ) + } + + if ( !collection || !( collection instanceof Backbone.Collection ) ) { + collection = new this.collectionType( [], this._getCollectionOptions() ); + } + + collection.model = this.relatedModel; + + if ( this.options.collectionKey ) { + var key = this.options.collectionKey === true ? this.options.reverseRelation.key : this.options.collectionKey; + + if (collection[ key ] && collection[ key ] !== this.instance ) { + if ( Backbone.Relational.showWarnings && typeof console !== 'undefined' ) { + console.warn( 'Relation=%o; collectionKey=%s already exists on collection=%o', this, key, this.options.collectionKey ); + } + } + else if (key) { + collection[ key ] = this.instance; + } + } + + collection + .bind( 'relational:add', this.handleAddition ) + .bind( 'relational:remove', this.handleRemoval ) + .bind( 'relational:reset', this.handleReset ); + + return collection; + }, + + findRelated: function( options ) { + if ( this.keyContents ) { + var models = []; + + if ( this.keyContents instanceof Backbone.Collection ) { + models = this.keyContents.models; + } + else { + // Handle cases the an API/user supplies just an Object/id instead of an Array + this.keyContents = _.isArray( this.keyContents ) ? this.keyContents : [ this.keyContents ]; + + // Try to find instances of the appropriate 'relatedModel' in the store + _.each( this.keyContents, function( item ) { + var model = Backbone.Relational.store.find( this.relatedModel, item ); + + if ( model && _.isObject( item ) ) { + model.set( item, options ); + } + else if ( !model ) { + model = this.createModel( item ); + } + + if ( model && !this.related.getByCid( model ) && !this.related.get( model ) ) { + models.push( model ); + } + }, this ); + } + + // Add all found 'models' in on go, so 'add' will only be called once (and thus 'sort', etc.) + if ( models.length ) { + options = this.unsanitizeOptions( options ); + this.related.add( models, options ); + } + } + }, + + /** + * If the key is changed, notify old & new reverse relations and initialize the new relation + */ + onChange: function( model, attr, options ) { + options = this.sanitizeOptions( options ); + this.keyContents = attr; + + // Notify old 'related' object of the terminated relation + _.each( this.getReverseRelations(), function( relation ) { + relation.removeRelated( this.instance, options ); + }, this ); + + // Replace 'this.related' by 'attr' if it is a Backbone.Collection + if ( attr instanceof Backbone.Collection ) { + this._prepareCollection( attr ); + this.related = attr; + } + // Otherwise, 'attr' should be an array of related object ids. + // Re-use the current 'this.related' if it is a Backbone.Collection, and remove any current entries. + // Otherwise, create a new collection. + else { + var coll; + + if ( this.related instanceof Backbone.Collection ) { + coll = this.related; + coll.reset( [], { silent: true } ); + } + else { + coll = this._prepareCollection(); + } + + this.setRelated( coll ); + this.findRelated( options ); + } + + // Notify new 'related' object of the new relation + _.each( this.getReverseRelations(), function( relation ) { + relation.addRelated( this.instance, options ); + }, this ); + + var dit = this; + Backbone.Relational.eventQueue.add( function() { + !options.silentChange && dit.instance.trigger( 'update:' + dit.key, dit.instance, dit.related, options ); + }); + }, + + tryAddRelated: function( model, options ) { + options = this.sanitizeOptions( options ); + if ( !this.related.getByCid( model ) && !this.related.get( model ) ) { + // Check if this new model was specified in 'this.keyContents' + var item = _.any( this.keyContents, function( item ) { + var id = Backbone.Relational.store.resolveIdForItem( this.relatedModel, item ); + return id && id === model.id; + }, this ); + + if ( item ) { + this.related.add( model, options ); + } + } + }, + + /** + * When a model is added to a 'HasMany', trigger 'add' on 'this.instance' and notify reverse relations. + * (should be 'HasOne', must set 'this.instance' as their related). + */ + handleAddition: function( model, coll, options ) { + //console.debug('handleAddition called; args=%o', arguments); + // Make sure the model is in fact a valid model before continuing. + // (it can be invalid as a result of failing validation in Backbone.Collection._prepareModel) + if( !( model instanceof Backbone.Model ) ) { + return; + } + + options = this.sanitizeOptions( options ); + + _.each( this.getReverseRelations( model ), function( relation ) { + relation.addRelated( this.instance, options ); + }, this ); + + // Only trigger 'add' once the newly added model is initialized (so, has it's relations set up) + var dit = this; + Backbone.Relational.eventQueue.add( function() { + !options.silentChange && dit.instance.trigger( 'add:' + dit.key, model, dit.related, options ); + }); + }, + + /** + * When a model is removed from a 'HasMany', trigger 'remove' on 'this.instance' and notify reverse relations. + * (should be 'HasOne', which should be nullified) + */ + handleRemoval: function( model, coll, options ) { + //console.debug('handleRemoval called; args=%o', arguments); + if( !( model instanceof Backbone.Model ) ) { + return; + } + + options = this.sanitizeOptions( options ); + + _.each( this.getReverseRelations( model ), function( relation ) { + relation.removeRelated( this.instance, options ); + }, this ); + + var dit = this; + Backbone.Relational.eventQueue.add( function() { + !options.silentChange && dit.instance.trigger( 'remove:' + dit.key, model, dit.related, options ); + }); + }, + + handleReset: function( coll, options ) { + options = this.sanitizeOptions( options ); + + var dit = this; + Backbone.Relational.eventQueue.add( function() { + !options.silentChange && dit.instance.trigger( 'reset:' + dit.key, dit.related, options ); + }); + }, + + addRelated: function( model, options ) { + var dit = this; + options = this.unsanitizeOptions( options ); + model.queue( function() { // Queued to avoid errors for adding 'model' to the 'this.related' set twice + if ( dit.related && !dit.related.getByCid( model ) && !dit.related.get( model ) ) { + dit.related.add( model, options ); + } + }); + }, + + removeRelated: function( model, options ) { + options = this.unsanitizeOptions( options ); + if ( this.related.getByCid( model ) || this.related.get( model ) ) { + this.related.remove( model, options ); + } + } + }); + + /** + * A type of Backbone.Model that also maintains relations to other models and collections. + * New events when compared to the original: + * - 'add:<key>' (model, related collection, options) + * - 'remove:<key>' (model, related collection, options) + * - 'update:<key>' (model, related model or collection, options) + */ + Backbone.RelationalModel = Backbone.Model.extend({ + relations: null, // Relation descriptions on the prototype + _relations: null, // Relation instances + _isInitialized: false, + _deferProcessing: false, + _queue: null, + + constructor: function( attributes, options ) { + // Nasty hack, for cases like 'model.get( <HasMany key> ).add( item )'. + // Defer 'processQueue', so that when 'Relation.createModels' is used we: + // a) Survive 'Backbone.Collection.add'; this takes care we won't error on "can't add model to a set twice" + // (by creating a model from properties, having the model add itself to the collection via one of + // it's relations, then trying to add it to the collection). + // b) Trigger 'HasMany' collection events only after the model is really fully set up. + // Example that triggers both a and b: "p.get('jobs').add( { company: c, person: p } )". + var dit = this; + if ( options && options.collection ) { + this._deferProcessing = true; + + var processQueue = function( model ) { + if ( model === dit ) { + dit._deferProcessing = false; + dit.processQueue(); + options.collection.unbind( 'relational:add', processQueue ); + } + }; + options.collection.bind( 'relational:add', processQueue ); + + // So we do process the queue eventually, regardless of whether this model really gets added to 'options.collection'. + _.defer( function() { + processQueue( dit ); + }); + } + + this._queue = new Backbone.BlockingQueue(); + this._queue.block(); + Backbone.Relational.eventQueue.block(); + + Backbone.Model.prototype.constructor.apply( this, arguments ); + + // Try to run the global queue holding external events + Backbone.Relational.eventQueue.unblock(); + }, + + /** + * Override 'trigger' to queue 'change' and 'change:*' events + */ + trigger: function( eventName ) { + if ( eventName.length > 5 && 'change' === eventName.substr( 0, 6 ) ) { + var dit = this, args = arguments; + Backbone.Relational.eventQueue.add( function() { + Backbone.Model.prototype.trigger.apply( dit, args ); + }); + } + else { + Backbone.Model.prototype.trigger.apply( this, arguments ); + } + + return this; + }, + + /** + * Initialize Relations present in this.relations; determine the type (HasOne/HasMany), then creates a new instance. + * Invoked in the first call so 'set' (which is made from the Backbone.Model constructor). + */ + initializeRelations: function() { + this.acquire(); // Setting up relations often also involve calls to 'set', and we only want to enter this function once + this._relations = []; + + _.each( this.relations, function( rel ) { + var type = !_.isString( rel.type ) ? rel.type : Backbone[ rel.type ] || Backbone.Relational.store.getObjectByName( rel.type ); + if ( type && type.prototype instanceof Backbone.Relation.prototype.constructor ) { + new type( this, rel ); // Also pushes the new Relation into _relations + } + else { + Backbone.Relational.showWarnings && typeof console !== 'undefined' && console.warn( 'Relation=%o; missing or invalid type!', rel ); + } + }, this ); + + this._isInitialized = true; + this.release(); + this.processQueue(); + }, + + /** + * When new values are set, notify this model's relations (also if options.silent is set). + * (Relation.setRelated locks this model before calling 'set' on it to prevent loops) + */ + updateRelations: function( options ) { + if( this._isInitialized && !this.isLocked() ) { + _.each( this._relations, function( rel ) { + var val = this.attributes[ rel.key ]; + if ( rel.related !== val ) { + this.trigger('relational:change:' + rel.key, this, val, options || {} ); + } + }, this ); + } + }, + + /** + * Either add to the queue (if we're not initialized yet), or execute right away. + */ + queue: function( func ) { + this._queue.add( func ); + }, + + /** + * Process _queue + */ + processQueue: function() { + if ( this._isInitialized && !this._deferProcessing && this._queue.isBlocked() ) { + this._queue.unblock(); + } + }, + + /** + * Get a specific relation. + * @param key {string} The relation key to look for. + * @return {Backbone.Relation} An instance of 'Backbone.Relation', if a relation was found for 'key', or null. + */ + getRelation: function( key ) { + return _.detect( this._relations, function( rel ) { + if ( rel.key === key ) { + return true; + } + }, this ); + }, + + /** + * Get all of the created relations. + * @return {Backbone.Relation[]} + */ + getRelations: function() { + return this._relations; + }, + + /** + * Retrieve related objects. + * @param key {string} The relation key to fetch models for. + * @param options {object} Options for 'Backbone.Model.fetch' and 'Backbone.sync'. + * @return {jQuery.when[]} An array of request objects + */ + fetchRelated: function( key, options ) { + options || ( options = {} ); + var setUrl, + requests = [], + rel = this.getRelation( key ), + keyContents = rel && rel.keyContents, + toFetch = keyContents && _.select( _.isArray( keyContents ) ? keyContents : [ keyContents ], function( item ) { + var id = Backbone.Relational.store.resolveIdForItem( rel.relatedModel, item ); + return id && !Backbone.Relational.store.find( rel.relatedModel, id ); + }, this ); + + if ( toFetch && toFetch.length ) { + // Create a model for each entry in 'keyContents' that is to be fetched + var models = _.map( toFetch, function( item ) { + var model; + + if ( typeof( item ) === 'object' ) { + model = new rel.relatedModel( item ); + } + else { + var attrs = {}; + attrs[ rel.relatedModel.prototype.idAttribute ] = item; + model = new rel.relatedModel( attrs ); + } + + return model; + }, this ); + + // Try if the 'collection' can provide a url to fetch a set of models in one request. + if ( rel.related instanceof Backbone.Collection && _.isFunction( rel.related.url ) ) { + setUrl = rel.related.url( models ); + } + + // An assumption is that when 'Backbone.Collection.url' is a function, it can handle building of set urls. + // To make sure it can, test if the url we got by supplying a list of models to fetch is different from + // the one supplied for the default fetch action (without args to 'url'). + if ( setUrl && setUrl !== rel.related.url() ) { + var opts = _.defaults( + { + error: function() { + var args = arguments; + _.each( models, function( model ) { + model.trigger( 'destroy', model, model.collection, options ); + options.error && options.error.apply( model, args ); + }); + }, + url: setUrl + }, + options, + { add: true } + ); + + requests = [ rel.related.fetch( opts ) ]; + } + else { + requests = _.map( models, function( model ) { + var opts = _.defaults( + { + error: function() { + model.trigger( 'destroy', model, model.collection, options ); + options.error && options.error.apply( model, arguments ); + } + }, + options + ); + return model.fetch( opts ); + }, this ); + } + } + + return requests; + }, + + set: function( key, value, options ) { + Backbone.Relational.eventQueue.block(); + + // Duplicate backbone's behavior to allow separate key/value parameters, instead of a single 'attributes' object + var attributes; + if (_.isObject( key ) || key == null) { + attributes = key; + options = value; + } + else { + attributes = {}; + attributes[ key ] = value; + } + + var result = Backbone.Model.prototype.set.apply( this, arguments ); + + // 'set' is called quite late in 'Backbone.Model.prototype.constructor', but before 'initialize'. + // Ideal place to set up relations :) + if ( !this._isInitialized && !this.isLocked() ) { + Backbone.Relational.store.register( this ); + this.initializeRelations(); + } + // Update the 'idAttribute' in Backbone.store if; we don't want it to miss an 'id' update due to {silent:true} + else if ( attributes && this.idAttribute in attributes ) { + Backbone.Relational.store.update( this ); + } + + if ( attributes ) { + this.updateRelations( options ); + } + + // Try to run the global queue holding external events + Backbone.Relational.eventQueue.unblock(); + + return result; + }, + + unset: function( attribute, options ) { + Backbone.Relational.eventQueue.block(); + + var result = Backbone.Model.prototype.unset.apply( this, arguments ); + this.updateRelations( options ); + + // Try to run the global queue holding external events + Backbone.Relational.eventQueue.unblock(); + + return result; + }, + + clear: function( options ) { + Backbone.Relational.eventQueue.block(); + + var result = Backbone.Model.prototype.clear.apply( this, arguments ); + this.updateRelations( options ); + + // Try to run the global queue holding external events + Backbone.Relational.eventQueue.unblock(); + + return result; + }, + + /** + * Override 'change', so the change will only execute after 'set' has finised (relations are updated), + * and 'previousAttributes' will be available when the event is fired. + */ + change: function( options ) { + var dit = this, args = arguments; + Backbone.Relational.eventQueue.add( function() { + Backbone.Model.prototype.change.apply( dit, args ); + }); + }, + + clone: function() { + var attributes = _.clone( this.attributes ); + if ( !_.isUndefined( attributes[ this.idAttribute ] ) ) { + attributes[ this.idAttribute ] = null; + } + + _.each( this.getRelations(), function( rel ) { + delete attributes[ rel.key ]; + }); + + return new this.constructor( attributes ); + }, + + /** + * Convert relations to JSON, omits them when required + */ + toJSON: function() { + // If this Model has already been fully serialized in this branch once, return to avoid loops + if ( this.isLocked() ) { + return this.id; + } + + this.acquire(); + var json = Backbone.Model.prototype.toJSON.call( this ); + + _.each( this._relations, function( rel ) { + var value = json[ rel.key ]; + + if ( rel.options.includeInJSON === true && value && _.isFunction( value.toJSON ) ) { + json[ rel.keyDestination ] = value.toJSON(); + } + else if ( _.isString( rel.options.includeInJSON ) ) { + if ( value instanceof Backbone.Collection ) { + json[ rel.keyDestination ] = value.pluck( rel.options.includeInJSON ); + } + else if ( value instanceof Backbone.Model ) { + json[ rel.keyDestination ] = value.get( rel.options.includeInJSON ); + } + } + else { + delete json[ rel.key ]; + } + + if ( rel.keyDestination !== rel.key ) { + delete json[ rel.key ]; + } + }, this ); + + this.release(); + return json; + } + }); + _.extend( Backbone.RelationalModel.prototype, Backbone.Semaphore ); + + /** + * Override Backbone.Collection.add, so objects fetched from the server multiple times will + * update the existing Model. Also, trigger 'relational:add'. + */ + var add = Backbone.Collection.prototype.__add = Backbone.Collection.prototype.add; + Backbone.Collection.prototype.add = function( models, options ) { + options || (options = {}); + if ( !_.isArray( models ) ) { + models = [ models ]; + } + + var modelsToAdd = []; + + //console.debug( 'calling add on coll=%o; model=%o, options=%o', this, models, options ); + _.each( models, function( model ) { + if ( !( model instanceof Backbone.Model ) ) { + // Try to find 'model' in Backbone.store. If it already exists, set the new properties on it. + var existingModel = Backbone.Relational.store.find( this.model, model[ this.model.prototype.idAttribute ] ); + if ( existingModel ) { + existingModel.set( existingModel.parse ? existingModel.parse( model ) : model, options ); + model = existingModel; + } + else { + model = Backbone.Collection.prototype._prepareModel.call( this, model, options ); + } + } + + if ( model instanceof Backbone.Model && !this.get( model ) && !this.getByCid( model ) ) { + modelsToAdd.push( model ); + } + }, this ); + + + // Add 'models' in a single batch, so the original add will only be called once (and thus 'sort', etc). + if ( modelsToAdd.length ) { + add.call( this, modelsToAdd, options ); + + _.each( modelsToAdd, function( model ) { + this.trigger('relational:add', model, this, options); + }, this ); + } + + return this; + }; + + /** + * Override 'Backbone.Collection.remove' to trigger 'relational:remove'. + */ + var remove = Backbone.Collection.prototype.__remove = Backbone.Collection.prototype.remove; + Backbone.Collection.prototype.remove = function( models, options ) { + options || (options = {}); + if (!_.isArray( models ) ) { + models = [ models ]; + } + + //console.debug('calling remove on coll=%o; models=%o, options=%o', this, models, options ); + _.each( models, function( model ) { + model = this.getByCid( model ) || this.get( model ); + + if ( model instanceof Backbone.Model ) { + remove.call( this, model, options ); + this.trigger('relational:remove', model, this, options); + } + }, this ); + + return this; + }; + + /** + * Override 'Backbone.Collection.reset' to trigger 'relational:reset'. + */ + var reset = Backbone.Collection.prototype.__reset = Backbone.Collection.prototype.reset; + Backbone.Collection.prototype.reset = function( models, options ) { + reset.call( this, models, options ); + this.trigger( 'relational:reset', models, options ); + + return this; + }; + + /** + * Override 'Backbone.Collection.trigger' so 'add', 'remove' and 'reset' events are queued until relations + * are ready. + */ + var trigger = Backbone.Collection.prototype.__trigger = Backbone.Collection.prototype.trigger; + Backbone.Collection.prototype.trigger = function( eventName ) { + if ( eventName === 'add' || eventName === 'remove' || eventName === 'reset' ) { + var dit = this, args = arguments; + Backbone.Relational.eventQueue.add( function() { + trigger.apply( dit, args ); + }); + } + else { + trigger.apply( this, arguments ); + } + + return this; + }; + + // Override .extend() to check for reverseRelations to initialize. + Backbone.RelationalModel.extend = function( protoProps, classProps ) { + var child = Backbone.Model.extend.apply( this, arguments ); + + var relations = ( protoProps && protoProps.relations ) || []; + _.each( relations, function( rel ) { + if( rel.reverseRelation ) { + rel.model = child; + + var preInitialize = true; + if ( _.isString( rel.relatedModel ) ) { + /** + * The related model might not be defined for two reasons + * 1. it never gets defined, e.g. a typo + * 2. it is related to itself + * In neither of these cases do we need to pre-initialize reverse relations. + */ + var relatedModel = Backbone.Relational.store.getObjectByName( rel.relatedModel ); + preInitialize = relatedModel && ( relatedModel.prototype instanceof Backbone.RelationalModel.prototype.constructor ); + } + + var type = !_.isString( rel.type ) ? rel.type : Backbone[ rel.type ] || Backbone.Relational.store.getObjectByName( rel.type ); + if ( preInitialize && type && type.prototype instanceof Backbone.Relation.prototype.constructor ) { + new type( null, rel ); + } + } + }); + + return child; + }; + +})(); diff -r bf88ce609370a7829337dad758fafe388b3fd117 -r 0a957e499a3c9b7ba11f88d337e4878260d73d6d static/scripts/packed/libs/backbone-relational.js --- /dev/null +++ b/static/scripts/packed/libs/backbone-relational.js @@ -0,0 +1,1 @@ +(function(g){var d,h,b;if(typeof window==="undefined"){d=require("underscore");h=require("backbone");b=module.exports=h}else{var d=window._;h=window.Backbone;b=window}h.Relational={showWarnings:true};h.Semaphore={_permitsAvailable:null,_permitsUsed:0,acquire:function(){if(this._permitsAvailable&&this._permitsUsed>=this._permitsAvailable){throw new Error("Max permits acquired")}else{this._permitsUsed++}},release:function(){if(this._permitsUsed===0){throw new Error("All permits released")}else{this._permitsUsed--}},isLocked:function(){return this._permitsUsed>0},setAvailablePermits:function(i){if(this._permitsUsed>i){throw new Error("Available permits cannot be less than used permits")}this._permitsAvailable=i}};h.BlockingQueue=function(){this._queue=[]};d.extend(h.BlockingQueue.prototype,h.Semaphore,{_queue:null,add:function(i){if(this.isBlocked()){this._queue.push(i)}else{i()}},process:function(){while(this._queue&&this._queue.length){this._queue.shift()()}},block:function(){this.acquire()},unblock:function(){this.release();if(!this.isBlocked()){this.process()}},isBlocked:function(){return this.isLocked()}});h.Relational.eventQueue=new h.BlockingQueue();h.Store=function(){this._collections=[];this._reverseRelations=[]};d.extend(h.Store.prototype,h.Events,{_collections:null,_reverseRelations:null,addReverseRelation:function(j){var i=d.any(this._reverseRelations,function(k){return d.all(j,function(m,l){return m===k[l]})});if(!i&&j.model&&j.type){this._reverseRelations.push(j);if(!j.model.prototype.relations){j.model.prototype.relations=[]}j.model.prototype.relations.push(j);this.retroFitRelation(j)}},retroFitRelation:function(j){var i=this.getCollection(j.model);i.each(function(k){new j.type(k,j)},this)},getCollection:function(i){var j=d.detect(this._collections,function(k){return i===k.model||i.constructor===k.model});if(!j){j=this._createCollection(i)}return j},getObjectByName:function(i){var j=d.reduce(i.split("."),function(k,l){return k[l]},b);return j!==b?j:null},_createCollection:function(j){var i;if(j instanceof h.RelationalModel){j=j.constructor}if(j.prototype instanceof h.RelationalModel.prototype.constructor){i=new h.Collection();i.model=j;this._collections.push(i)}return i},resolveIdForItem:function(i,j){var k=d.isString(j)||d.isNumber(j)?j:null;if(k==null){if(j instanceof h.RelationalModel){k=j.id}else{if(d.isObject(j)){k=j[i.prototype.idAttribute]}}}return k},find:function(j,k){var l=this.resolveIdForItem(j,k);var i=this.getCollection(j);return i&&i.get(l)},register:function(j){var i=j.collection;var k=this.getCollection(j);k&&k.add(j);j.bind("destroy",this.unregister,this);j.collection=i},update:function(i){var j=this.getCollection(i);j._onModelEvent("change:"+i.idAttribute,i,j)},unregister:function(i){i.unbind("destroy",this.unregister);var j=this.getCollection(i);j&&j.remove(i)}});h.Relational.store=new h.Store();h.Relation=function(i,j){this.instance=i;j=(typeof j==="object"&&j)||{};this.reverseRelation=d.defaults(j.reverseRelation||{},this.options.reverseRelation);this.reverseRelation.type=!d.isString(this.reverseRelation.type)?this.reverseRelation.type:h[this.reverseRelation.type]||h.Relational.store.getObjectByName(this.reverseRelation.type);this.model=j.model||this.instance.constructor;this.options=d.defaults(j,this.options,h.Relation.prototype.options);this.key=this.options.key;this.keySource=this.options.keySource||this.key;this.keyDestination=this.options.keyDestination||this.options.keySource||this.key;this.relatedModel=this.options.relatedModel;if(d.isString(this.relatedModel)){this.relatedModel=h.Relational.store.getObjectByName(this.relatedModel)}if(!this.checkPreconditions()){return false}if(i){this.keyContents=this.instance.get(this.keySource);if(this.key!==this.keySource){this.instance.unset(this.keySource,{silent:true})}this.instance._relations.push(this)}if(!this.options.isAutoRelation&&this.reverseRelation.type&&this.reverseRelation.key){h.Relational.store.addReverseRelation(d.defaults({isAutoRelation:true,model:this.relatedModel,relatedModel:this.model,reverseRelation:this.options},this.reverseRelation))}d.bindAll(this,"_modelRemovedFromCollection","_relatedModelAdded","_relatedModelRemoved");if(i){this.initialize();h.Relational.store.getCollection(this.instance).bind("relational:remove",this._modelRemovedFromCollection);h.Relational.store.getCollection(this.relatedModel).bind("relational:add",this._relatedModelAdded).bind("relational:remove",this._relatedModelRemoved)}};h.Relation.extend=h.Model.extend;d.extend(h.Relation.prototype,h.Events,h.Semaphore,{options:{createModels:true,includeInJSON:true,isAutoRelation:false},instance:null,key:null,keyContents:null,relatedModel:null,reverseRelation:null,related:null,_relatedModelAdded:function(k,l,j){var i=this;k.queue(function(){i.tryAddRelated(k,j)})},_relatedModelRemoved:function(j,k,i){this.removeRelated(j,i)},_modelRemovedFromCollection:function(i){if(i===this.instance){this.destroy()}},checkPreconditions:function(){var n=this.instance,l=this.key,j=this.model,p=this.relatedModel,q=h.Relational.showWarnings&&typeof console!=="undefined";if(!j||!l||!p){q&&console.warn("Relation=%o; no model, key or relatedModel (%o, %o, %o)",this,j,l,p);return false}if(!(j.prototype instanceof h.RelationalModel.prototype.constructor)){q&&console.warn("Relation=%o; model does not inherit from Backbone.RelationalModel (%o)",this,n);return false}if(!(p.prototype instanceof h.RelationalModel.prototype.constructor)){q&&console.warn("Relation=%o; relatedModel does not inherit from Backbone.RelationalModel (%o)",this,p);return false}if(this instanceof h.HasMany&&this.reverseRelation.type===h.HasMany.prototype.constructor){q&&console.warn("Relation=%o; relation is a HasMany, and the reverseRelation is HasMany as well.",this);return false}if(n&&n._relations.length){var o=d.any(n._relations,function(i){var k=this.reverseRelation.key&&i.reverseRelation.key;return i.relatedModel===p&&i.key===l&&(!k||this.reverseRelation.key===i.reverseRelation.key)},this);if(o){q&&console.warn("Relation=%o between instance=%o.%s and relatedModel=%o.%s already exists",this,n,l,p,this.reverseRelation.key);return false}}return true},setRelated:function(j,i){this.related=j;this.instance.acquire();this.instance.set(this.key,j,d.defaults(i||{},{silent:true}));this.instance.release()},createModel:function(i){if(this.options.createModels&&typeof(i)==="object"){return new this.relatedModel(i)}},_isReverseRelation:function(i){if(i.instance instanceof this.relatedModel&&this.reverseRelation.key===i.key&&this.key===i.reverseRelation.key){return true}return false},getReverseRelations:function(i){var j=[];var k=!d.isUndefined(i)?[i]:this.related&&(this.related.models||[this.related]);d.each(k,function(l){d.each(l.getRelations(),function(m){if(this._isReverseRelation(m)){j.push(m)}},this)},this);return j},sanitizeOptions:function(i){i=i?d.clone(i):{};if(i.silent){i=d.extend({},i,{silentChange:true});delete i.silent}return i},unsanitizeOptions:function(i){i=i?d.clone(i):{};if(i.silentChange){i=d.extend({},i,{silent:true});delete i.silentChange}return i},destroy:function(){h.Relational.store.getCollection(this.instance).unbind("relational:remove",this._modelRemovedFromCollection);h.Relational.store.getCollection(this.relatedModel).unbind("relational:add",this._relatedModelAdded).unbind("relational:remove",this._relatedModelRemoved);d.each(this.getReverseRelations(),function(i){i.removeRelated(this.instance)},this)}});h.HasOne=h.Relation.extend({options:{reverseRelation:{type:"HasMany"}},initialize:function(){d.bindAll(this,"onChange");this.instance.bind("relational:change:"+this.key,this.onChange);var j=this.findRelated({silent:true});this.setRelated(j);var i=this;d.each(i.getReverseRelations(),function(k){k.addRelated(i.instance)})},findRelated:function(j){var k=this.keyContents;var i=null;if(k instanceof this.relatedModel){i=k}else{if(k){i=h.Relational.store.find(this.relatedModel,k);if(i&&d.isObject(k)){i.set(k,j)}else{if(!i){i=this.createModel(k)}}}}return i},onChange:function(l,i,k){if(this.isLocked()){return}this.acquire();k=this.sanitizeOptions(k);var o=d.isUndefined(k._related);var m=o?this.related:k._related;if(o){this.keyContents=i;if(i instanceof this.relatedModel){this.related=i}else{if(i){var n=this.findRelated(k);this.setRelated(n)}else{this.setRelated(null)}}}if(m&&this.related!==m){d.each(this.getReverseRelations(m),function(p){p.removeRelated(this.instance,k)},this)}d.each(this.getReverseRelations(),function(p){p.addRelated(this.instance,k)},this);if(!k.silentChange&&this.related!==m){var j=this;h.Relational.eventQueue.add(function(){j.instance.trigger("update:"+j.key,j.instance,j.related,k)})}this.release()},tryAddRelated:function(j,i){if(this.related){return}i=this.sanitizeOptions(i);var k=this.keyContents;if(k){var l=h.Relational.store.resolveIdForItem(this.relatedModel,k);if(j.id===l){this.addRelated(j,i)}}},addRelated:function(j,i){if(j!==this.related){var k=this.related||null;this.setRelated(j);this.onChange(this.instance,j,{_related:k})}},removeRelated:function(j,i){if(!this.related){return}if(j===this.related){var k=this.related||null;this.setRelated(null);this.onChange(this.instance,j,{_related:k})}}});h.HasMany=h.Relation.extend({collectionType:null,options:{reverseRelation:{type:"HasOne"},collectionType:h.Collection,collectionKey:true,collectionOptions:{}},initialize:function(){d.bindAll(this,"onChange","handleAddition","handleRemoval","handleReset");this.instance.bind("relational:change:"+this.key,this.onChange);this.collectionType=this.options.collectionType;if(d(this.collectionType).isString()){this.collectionType=h.Relational.store.getObjectByName(this.collectionType)}if(!this.collectionType.prototype instanceof h.Collection.prototype.constructor){throw new Error("collectionType must inherit from Backbone.Collection")}if(this.keyContents instanceof h.Collection){this.setRelated(this._prepareCollection(this.keyContents))}else{this.setRelated(this._prepareCollection())}this.findRelated({silent:true})},_getCollectionOptions:function(){return d.isFunction(this.options.collectionOptions)?this.options.collectionOptions(this.instance):this.options.collectionOptions},_prepareCollection:function(j){if(this.related){this.related.unbind("relational:add",this.handleAddition).unbind("relational:remove",this.handleRemoval).unbind("relational:reset",this.handleReset)}if(!j||!(j instanceof h.Collection)){j=new this.collectionType([],this._getCollectionOptions())}j.model=this.relatedModel;if(this.options.collectionKey){var i=this.options.collectionKey===true?this.options.reverseRelation.key:this.options.collectionKey;if(j[i]&&j[i]!==this.instance){if(h.Relational.showWarnings&&typeof console!=="undefined"){console.warn("Relation=%o; collectionKey=%s already exists on collection=%o",this,i,this.options.collectionKey)}}else{if(i){j[i]=this.instance}}}j.bind("relational:add",this.handleAddition).bind("relational:remove",this.handleRemoval).bind("relational:reset",this.handleReset);return j},findRelated:function(i){if(this.keyContents){var j=[];if(this.keyContents instanceof h.Collection){j=this.keyContents.models}else{this.keyContents=d.isArray(this.keyContents)?this.keyContents:[this.keyContents];d.each(this.keyContents,function(l){var k=h.Relational.store.find(this.relatedModel,l);if(k&&d.isObject(l)){k.set(l,i)}else{if(!k){k=this.createModel(l)}}if(k&&!this.related.getByCid(k)&&!this.related.get(k)){j.push(k)}},this)}if(j.length){i=this.unsanitizeOptions(i);this.related.add(j,i)}}},onChange:function(l,i,k){k=this.sanitizeOptions(k);this.keyContents=i;d.each(this.getReverseRelations(),function(n){n.removeRelated(this.instance,k)},this);if(i instanceof h.Collection){this._prepareCollection(i);this.related=i}else{var m;if(this.related instanceof h.Collection){m=this.related;m.reset([],{silent:true})}else{m=this._prepareCollection()}this.setRelated(m);this.findRelated(k)}d.each(this.getReverseRelations(),function(n){n.addRelated(this.instance,k)},this);var j=this;h.Relational.eventQueue.add(function(){!k.silentChange&&j.instance.trigger("update:"+j.key,j.instance,j.related,k)})},tryAddRelated:function(j,i){i=this.sanitizeOptions(i);if(!this.related.getByCid(j)&&!this.related.get(j)){var k=d.any(this.keyContents,function(l){var m=h.Relational.store.resolveIdForItem(this.relatedModel,l);return m&&m===j.id},this);if(k){this.related.add(j,i)}}},handleAddition:function(k,l,j){if(!(k instanceof h.Model)){return}j=this.sanitizeOptions(j);d.each(this.getReverseRelations(k),function(m){m.addRelated(this.instance,j)},this);var i=this;h.Relational.eventQueue.add(function(){!j.silentChange&&i.instance.trigger("add:"+i.key,k,i.related,j)})},handleRemoval:function(k,l,j){if(!(k instanceof h.Model)){return}j=this.sanitizeOptions(j);d.each(this.getReverseRelations(k),function(m){m.removeRelated(this.instance,j)},this);var i=this;h.Relational.eventQueue.add(function(){!j.silentChange&&i.instance.trigger("remove:"+i.key,k,i.related,j)})},handleReset:function(k,j){j=this.sanitizeOptions(j);var i=this;h.Relational.eventQueue.add(function(){!j.silentChange&&i.instance.trigger("reset:"+i.key,i.related,j)})},addRelated:function(k,j){var i=this;j=this.unsanitizeOptions(j);k.queue(function(){if(i.related&&!i.related.getByCid(k)&&!i.related.get(k)){i.related.add(k,j)}})},removeRelated:function(j,i){i=this.unsanitizeOptions(i);if(this.related.getByCid(j)||this.related.get(j)){this.related.remove(j,i)}}});h.RelationalModel=h.Model.extend({relations:null,_relations:null,_isInitialized:false,_deferProcessing:false,_queue:null,constructor:function(j,k){var i=this;if(k&&k.collection){this._deferProcessing=true;var l=function(m){if(m===i){i._deferProcessing=false;i.processQueue();k.collection.unbind("relational:add",l)}};k.collection.bind("relational:add",l);d.defer(function(){l(i)})}this._queue=new h.BlockingQueue();this._queue.block();h.Relational.eventQueue.block();h.Model.prototype.constructor.apply(this,arguments);h.Relational.eventQueue.unblock()},trigger:function(j){if(j.length>5&&"change"===j.substr(0,6)){var i=this,k=arguments;h.Relational.eventQueue.add(function(){h.Model.prototype.trigger.apply(i,k)})}else{h.Model.prototype.trigger.apply(this,arguments)}return this},initializeRelations:function(){this.acquire();this._relations=[];d.each(this.relations,function(i){var j=!d.isString(i.type)?i.type:h[i.type]||h.Relational.store.getObjectByName(i.type);if(j&&j.prototype instanceof h.Relation.prototype.constructor){new j(this,i)}else{h.Relational.showWarnings&&typeof console!=="undefined"&&console.warn("Relation=%o; missing or invalid type!",i)}},this);this._isInitialized=true;this.release();this.processQueue()},updateRelations:function(i){if(this._isInitialized&&!this.isLocked()){d.each(this._relations,function(j){var k=this.attributes[j.key];if(j.related!==k){this.trigger("relational:change:"+j.key,this,k,i||{})}},this)}},queue:function(i){this._queue.add(i)},processQueue:function(){if(this._isInitialized&&!this._deferProcessing&&this._queue.isBlocked()){this._queue.unblock()}},getRelation:function(i){return d.detect(this._relations,function(j){if(j.key===i){return true}},this)},getRelations:function(){return this._relations},fetchRelated:function(n,p){p||(p={});var l,j=[],o=this.getRelation(n),q=o&&o.keyContents,m=q&&d.select(d.isArray(q)?q:[q],function(r){var s=h.Relational.store.resolveIdForItem(o.relatedModel,r);return s&&!h.Relational.store.find(o.relatedModel,s)},this);if(m&&m.length){var k=d.map(m,function(t){var s;if(typeof(t)==="object"){s=new o.relatedModel(t)}else{var r={};r[o.relatedModel.prototype.idAttribute]=t;s=new o.relatedModel(r)}return s},this);if(o.related instanceof h.Collection&&d.isFunction(o.related.url)){l=o.related.url(k)}if(l&&l!==o.related.url()){var i=d.defaults({error:function(){var r=arguments;d.each(k,function(s){s.trigger("destroy",s,s.collection,p);p.error&&p.error.apply(s,r)})},url:l},p,{add:true});j=[o.related.fetch(i)]}else{j=d.map(k,function(r){var s=d.defaults({error:function(){r.trigger("destroy",r,r.collection,p);p.error&&p.error.apply(r,arguments)}},p);return r.fetch(s)},this)}}return j},set:function(l,m,k){h.Relational.eventQueue.block();var j;if(d.isObject(l)||l==null){j=l;k=m}else{j={};j[l]=m}var i=h.Model.prototype.set.apply(this,arguments);if(!this._isInitialized&&!this.isLocked()){h.Relational.store.register(this);this.initializeRelations()}else{if(j&&this.idAttribute in j){h.Relational.store.update(this)}}if(j){this.updateRelations(k)}h.Relational.eventQueue.unblock();return i},unset:function(k,j){h.Relational.eventQueue.block();var i=h.Model.prototype.unset.apply(this,arguments);this.updateRelations(j);h.Relational.eventQueue.unblock();return i},clear:function(j){h.Relational.eventQueue.block();var i=h.Model.prototype.clear.apply(this,arguments);this.updateRelations(j);h.Relational.eventQueue.unblock();return i},change:function(k){var i=this,j=arguments;h.Relational.eventQueue.add(function(){h.Model.prototype.change.apply(i,j)})},clone:function(){var i=d.clone(this.attributes);if(!d.isUndefined(i[this.idAttribute])){i[this.idAttribute]=null}d.each(this.getRelations(),function(j){delete i[j.key]});return new this.constructor(i)},toJSON:function(){if(this.isLocked()){return this.id}this.acquire();var i=h.Model.prototype.toJSON.call(this);d.each(this._relations,function(j){var k=i[j.key];if(j.options.includeInJSON===true&&k&&d.isFunction(k.toJSON)){i[j.keyDestination]=k.toJSON()}else{if(d.isString(j.options.includeInJSON)){if(k instanceof h.Collection){i[j.keyDestination]=k.pluck(j.options.includeInJSON)}else{if(k instanceof h.Model){i[j.keyDestination]=k.get(j.options.includeInJSON)}}}else{delete i[j.key]}}if(j.keyDestination!==j.key){delete i[j.key]}},this);this.release();return i}});d.extend(h.RelationalModel.prototype,h.Semaphore);var f=h.Collection.prototype.__add=h.Collection.prototype.add;h.Collection.prototype.add=function(k,i){i||(i={});if(!d.isArray(k)){k=[k]}var j=[];d.each(k,function(m){if(!(m instanceof h.Model)){var l=h.Relational.store.find(this.model,m[this.model.prototype.idAttribute]);if(l){l.set(l.parse?l.parse(m):m,i);m=l}else{m=h.Collection.prototype._prepareModel.call(this,m,i)}}if(m instanceof h.Model&&!this.get(m)&&!this.getByCid(m)){j.push(m)}},this);if(j.length){f.call(this,j,i);d.each(j,function(l){this.trigger("relational:add",l,this,i)},this)}return this};var a=h.Collection.prototype.__remove=h.Collection.prototype.remove;h.Collection.prototype.remove=function(j,i){i||(i={});if(!d.isArray(j)){j=[j]}d.each(j,function(k){k=this.getByCid(k)||this.get(k);if(k instanceof h.Model){a.call(this,k,i);this.trigger("relational:remove",k,this,i)}},this);return this};var e=h.Collection.prototype.__reset=h.Collection.prototype.reset;h.Collection.prototype.reset=function(j,i){e.call(this,j,i);this.trigger("relational:reset",j,i);return this};var c=h.Collection.prototype.__trigger=h.Collection.prototype.trigger;h.Collection.prototype.trigger=function(j){if(j==="add"||j==="remove"||j==="reset"){var i=this,k=arguments;h.Relational.eventQueue.add(function(){c.apply(i,k)})}else{c.apply(this,arguments)}return this};h.RelationalModel.extend=function(j,k){var l=h.Model.extend.apply(this,arguments);var i=(j&&j.relations)||[];d.each(i,function(m){if(m.reverseRelation){m.model=l;var o=true;if(d.isString(m.relatedModel)){var n=h.Relational.store.getObjectByName(m.relatedModel);o=n&&(n.prototype instanceof h.RelationalModel.prototype.constructor)}var p=!d.isString(m.type)?m.type:h[m.type]||h.Relational.store.getObjectByName(m.type);if(o&&p&&p.prototype instanceof h.Relation.prototype.constructor){new p(null,m)}}});return l}})(); \ No newline at end of file https://bitbucket.org/galaxy/galaxy-central/changeset/423f4f2910ac/ changeset: 423f4f2910ac user: jgoecks date: 2012-04-11 16:32:02 summary: Add Backbone-relational to base templates affected #: 2 files diff -r 0a957e499a3c9b7ba11f88d337e4878260d73d6d -r 423f4f2910ac90098e799d354a2936513242a21e templates/base.mako --- a/templates/base.mako +++ b/templates/base.mako @@ -26,7 +26,7 @@ ## <!--[if lt IE 7]> ## <script type='text/javascript' src="/static/scripts/IE7.js"></script> ## <![endif]--> - ${h.js( "jquery", "galaxy.base", "libs/underscore", "libs/backbone", "libs/handlebars.runtime", "backbone/ui" )} + ${h.js( "jquery", "galaxy.base", "libs/underscore", "libs/backbone", "libs/backbone-relational", "libs/handlebars.runtime", "backbone/ui" )} <script type="text/javascript"> // Set up needed paths. var galaxy_paths = new GalaxyPaths({ diff -r 0a957e499a3c9b7ba11f88d337e4878260d73d6d -r 423f4f2910ac90098e799d354a2936513242a21e templates/base_panels.mako --- a/templates/base_panels.mako +++ b/templates/base_panels.mako @@ -47,7 +47,7 @@ <!--[if lt IE 7]> ${h.js( 'IE7', 'ie7-recalc' )} <![endif]--> - ${h.js( 'jquery', 'libs/underscore', 'libs/backbone', 'libs/handlebars.runtime', 'backbone/ui' )} + ${h.js( 'jquery', 'libs/underscore', 'libs/backbone', 'libs/backbone-relational', 'libs/handlebars.runtime', 'backbone/ui' )} <script type="text/javascript"> // Set up needed paths. var galaxy_paths = new GalaxyPaths({ Repository URL: https://bitbucket.org/galaxy/galaxy-central/ -- This is a commit notification from bitbucket.org. You are receiving this because you have the service enabled, addressing the recipient of this email.