UNPKG

backbone.select

Version:

Selecting Backbone models; handling selections in Backbone collections.

1,088 lines (817 loc) 67.2 kB
// Backbone.Select, v2.1.0 // Copyright (c) 2014-2016 Michael Heim, Zeilenwechsel.de // (c) 2013 Derick Bailey, Muted Solutions, LLC. // Distributed under MIT license // http://github.com/hashchange/backbone.select ;( function ( root, factory ) { "use strict"; // UMD for a Backbone plugin. Supports AMD, Node.js, CommonJS and globals. // // - Code lives in the Backbone namespace. // - The module does not export a meaningful value. // - The module does not create a global. var supportsExports = typeof exports === "object" && exports && !exports.nodeType && typeof module === "object" && module && !module.nodeType; // AMD: // - Some AMD build optimizers like r.js check for condition patterns like the AMD check below, so keep it as is. // - Check for `exports` after `define` in case a build optimizer adds an `exports` object. // - The AMD spec requires the dependencies to be an array **literal** of module IDs. Don't use a variable there, // or optimizers may fail. if ( typeof define === "function" && typeof define.amd === "object" && define.amd ) { // AMD module define( [ "exports", "underscore", "backbone" ], factory ); } else if ( supportsExports ) { // Node module, CommonJS module factory( exports, require( "underscore" ), require( "backbone" ) ); } else { // Global (browser or Rhino) factory( {}, _, Backbone ); } }( this, function ( exports, _, Backbone ) { "use strict"; var illegalLabelNames = [], Mixins = { SelectOne: { // Type indicator, part of the API (monitored by tests). Can be queried safely by other components. Use // it read-only. _pickyType: "Backbone.Select.One", select: function ( model, options ) { var label, reselected, eventOptions, forwardedOptions; options = initOptions( options ); if ( options["@bbs:processedBy"][this._pickyCid] ) return this; label = getLabel( options, this ); if ( isIgnoredLabel( label, this ) ) return this; reselected = model && this[label] === model ? model : undefined; if ( !reselected ) { // Using eventQueueAppendOnly instead of eventQueue for the forwarded options: // // When a deselect sub action is initiated from a select action, the deselection events are // added to the common event queue. But the event queue must not be resolved prematurely during // the deselection phase. Resolution is prevented by naming the queue differently. // // See getActiveQueue() for a detailed description of the process. (Also explains why // processedBy is omitted in the call.) forwardedOptions = _.extend( _.omit( options, "@bbs:silentLocally", "@bbs:processedBy", "@bbs:eventQueue" ), { "@bbs:eventQueueAppendOnly": getActiveQueue( options ) } ); this.deselect( undefined, forwardedOptions ); this[label] = model; } options["@bbs:processedBy"][this._pickyCid] = { done: false }; if ( !options["@bbs:processedBy"][this[label].cid] ) this[label].select( stripLocalOptions( options ) ); if ( !( options.silent || options["@bbs:silentLocally"] ) ) { eventOptions = toEventOptions( options, label, this ); if ( reselected ) { if ( !options["@bbs:silentReselect"] ) queueEventSet( "reselect:one", label, [ model, this, eventOptions ], this, options ); } else { queueEventSet( "select:one", label, [ model, this, eventOptions ], this, options ); } } options["@bbs:processedBy"][this._pickyCid].done = true; processEventQueue( options ); return this; }, deselect: function ( model, options ) { var label; options = initOptions( options ); if ( options["@bbs:processedBy"][this._pickyCid] ) return this; label = getLabel( options, this ); if ( isIgnoredLabel( label, this ) || !this[label] ) return this; // The messageOnly flag is used for a noop which is supposed to convey the label name only, and // make sure it is registered. That's done, so we can bail out now. if ( options["@bbs:messageOnly"] ) return this; model = model || this[label]; if ( this[label] !== model ) return this; options["@bbs:processedBy"][this._pickyCid] = { done: false }; delete this[label]; if ( !options["@bbs:skipModelCall"] ) model.deselect( stripLocalOptions( options ) ); if ( !( options.silent || options["@bbs:silentLocally"] ) ) queueEventSet( "deselect:one", label, [ model, this, toEventOptions( options, label, this ) ], this, options ); options["@bbs:processedBy"][this._pickyCid].done = true; processEventQueue( options ); return this; }, close: function () { unregisterCollectionWithModels( this ); this.stopListening(); return this; } }, SelectMany: { // Type indicator, part of the API (monitored by tests). Can be queried safely by other components. Use // it read-only. _pickyType: "Backbone.Select.Many", select: function ( model, options ) { var label, prevSelected, reselected, forwardedOptions; options = initOptions( options ); label = getLabel( options, this ); if ( isIgnoredLabel( label, this ) ) return this; prevSelected = _.clone( this[label] ); reselected = this[label][model.cid] ? [model] : []; if ( reselected.length && options["@bbs:processedBy"][this._pickyCid] ) return this; if ( options.exclusive ) { // Using eventQueueAppendOnly instead of eventQueue for the forwarded options: See .select() in // Select.One or, in more detail, getActiveQueue() (also explains why processedBy is omitted in // the call). forwardedOptions = _.extend( _.omit( options, "@bbs:eventQueue", "exclusive" ), { "@bbs:eventQueueAppendOnly": getActiveQueue( options ), "@bbs:silentLocally": true } ); this.each( function ( iteratedModel ) { if ( iteratedModel !== model ) this.deselect( iteratedModel, _.omit( forwardedOptions, "@bbs:processedBy" ) ); }, this ); } if ( !reselected.length ) { this[label][model.cid] = model; setSelectionSize( _.size( this[label] ), this, label ); } options["@bbs:processedBy"][this._pickyCid] = { done: false }; if ( !options["@bbs:processedBy"][model.cid] ) model.select( stripLocalOptions( options ) ); triggerMultiSelectEvents( this, prevSelected, options, reselected ); options["@bbs:processedBy"][this._pickyCid].done = true; processEventQueue( options ); return this; }, deselect: function ( model, options ) { var label, prevSelected; if ( !model ) return this.deselectAll( options ); options = initOptions( options ); if ( options["@bbs:processedBy"][this._pickyCid] ) return this; label = getLabel( options, this ); if ( isIgnoredLabel( label, this ) ) return this; // The messageOnly flag is used for a noop which is supposed to convey the label name only, and // make sure it is registered. That's done, so we can bail out now. if ( options["@bbs:messageOnly"] ) return this; prevSelected = _.clone( this[label] ); if ( !this[label][model.cid] ) return this; options["@bbs:processedBy"][this._pickyCid] = { done: false }; delete this[label][model.cid]; setSelectionSize( _.size( this[label] ), this, label ); if ( !options["@bbs:skipModelCall"] ) model.deselect( stripLocalOptions( options ) ); triggerMultiSelectEvents( this, prevSelected, options ); options["@bbs:processedBy"][this._pickyCid].done = true; processEventQueue( options ); return this; }, selectAll: function ( options ) { var label, prevSelected, forwardedOptions, reselected = []; options = initOptions( options ); label = getLabel( options, this ); if ( isIgnoredLabel( label, this ) ) return this; prevSelected = _.clone( this[label] ); // Using eventQueueAppendOnly instead of eventQueue for the forwarded options: See .select() in // Select.One or, in more detail, getActiveQueue() (also explains why processedBy is omitted in the // call). forwardedOptions = _.extend( _.omit( options, "@bbs:eventQueue", "exclusive" ), { "@bbs:eventQueueAppendOnly": getActiveQueue( options ), "@bbs:silentLocally": true } ); this.each( function ( model ) { if ( this[label][model.cid] ) reselected.push( model ); this.select( model, _.omit( forwardedOptions, "@bbs:processedBy" ) ); }, this ); setSelectionSize( _.size( this[label] ), this, label ); triggerMultiSelectEvents( this, prevSelected, options, reselected ); if ( options["@bbs:processedBy"][this._pickyCid] ) { options["@bbs:processedBy"][this._pickyCid].done = true; } else { options["@bbs:processedBy"][this._pickyCid] = { done: true }; } processEventQueue( options ); return this; }, invertSelection: function ( options ) { var label, prevSelected, forwardedOptions; options = initOptions( options ); label = getLabel( options, this ); if ( isIgnoredLabel( label, this ) ) return this; prevSelected = _.clone( this[label] ); // Using eventQueueAppendOnly instead of eventQueue for the forwarded options: See .select() in // Select.One or, in more detail, getActiveQueue() (also explains why processedBy is omitted in the // call). forwardedOptions = _.extend( _.omit( options, "@bbs:eventQueue", "exclusive" ), { "@bbs:eventQueueAppendOnly": getActiveQueue( options ), "@bbs:silentLocally": true } ); this.each( function ( model ) { if ( this[label][model.cid] ) { this.deselect( model, _.omit( forwardedOptions, "@bbs:processedBy" ) ); } else { this.select( model, _.omit( forwardedOptions, "@bbs:processedBy" ) ); } }, this ); setSelectionSize( _.size( this[label] ), this, label ); triggerMultiSelectEvents( this, prevSelected, options ); if ( options["@bbs:processedBy"][this._pickyCid] ) { options["@bbs:processedBy"][this._pickyCid].done = true; } else { options["@bbs:processedBy"][this._pickyCid] = { done: true }; } processEventQueue( options ); return this; }, deselectAll: function ( options ) { var prevSelected, label, forwardedOptions; options = initOptions( options ); label = getLabel( options, this ); if ( isIgnoredLabel( label, this ) ) return this; if ( getSelectionSize( this, label ) === 0 ) return this; prevSelected = _.clone( this[label] ); // Using eventQueueAppendOnly instead of eventQueue for the forwarded options: See .select() in // Select.One or, in more detail, getActiveQueue() (also explains why processedBy is omitted in the // call). forwardedOptions = _.extend( _.omit( options, "@bbs:eventQueue" ), { "@bbs:eventQueueAppendOnly": getActiveQueue( options ), "@bbs:silentLocally": true } ); this.each( function ( model ) { this.deselect( model, _.omit( forwardedOptions, "@bbs:processedBy" ) ); }, this ); setSelectionSize( 0, this, label ); triggerMultiSelectEvents( this, prevSelected, options ); if ( options["@bbs:processedBy"][this._pickyCid] ) { options["@bbs:processedBy"][this._pickyCid].done = true; } else { options["@bbs:processedBy"][this._pickyCid] = { done: true }; } processEventQueue( options ); return this; }, selectNone: function ( options ) { return this.deselectAll( options ); }, // Toggle select all / none. If some are selected, it will select all. If all are selected, it will // select none. If none are selected, it will select all. toggleSelectAll: function ( options ) { var label; options || ( options = {} ); label = getLabel( options, this ); if ( isIgnoredLabel( label, this ) ) return this; if ( getSelectionSize( this, label ) === this.length ) { this.deselectAll( options ); } else { this.selectAll( options ); } return this; }, close: function () { unregisterCollectionWithModels( this ); this.stopListening(); return this; } }, SelectMe: { // Type indicator, part of the API (monitored by tests). Can be queried safely by other components. Use // it read-only. _pickyType: "Backbone.Select.Me", select: function ( options ) { var label, reselected, eventOptions; options = initOptions( options ); if ( options["@bbs:processedBy"][this.cid] ) return this; options["@bbs:processedBy"][this.cid] = { done: false }; label = getLabel( options, this ); reselected = this[label]; this[label] = true; // Notify collections with an event if ( this._pickyCollections ) this.trigger( "@bbs:_selected", this, stripLocalOptionsExcept( options, "exclusive" ) ); if ( !( options.silent || options["@bbs:silentLocally"] ) ) { eventOptions = toEventOptions( options, label, this ); if ( reselected ) { if ( !options["@bbs:silentReselect"] ) queueEventSet( "reselected", label, [ this, eventOptions ], this, options ); } else { queueEventSet( "selected", label, [ this, eventOptions ], this, options ); } } options["@bbs:processedBy"][this.cid].done = true; processEventQueue( options ); return this; }, deselect: function ( options ) { var label, isNoop; options = initOptions( options ); if ( options["@bbs:processedBy"][this.cid] ) return this; label = getLabel( options, this ); isNoop = !this[label]; options["@bbs:processedBy"][this.cid] = { done: isNoop }; this[label] = false; // Notify collections with an event if ( this._pickyCollections ) { if ( isNoop ) options = _.extend( options, { "@bbs:messageOnly": true } ); this.trigger( "@bbs:_deselected", this, stripLocalOptions( options ) ); } if ( isNoop ) return this; if ( !( options.silent || options["@bbs:silentLocally"] ) ) queueEventSet( "deselected", label, [ this, toEventOptions( options, label, this ) ], this, options ); options["@bbs:processedBy"][this.cid].done = true; processEventQueue( options ); return this; }, toggleSelected: function ( options ) { var label; options || ( options = {} ); label = getLabel( options, this ); if ( this[label] ) { this.deselect( options ); } else { this.select( options ); } return this; } } }, // Applying the mixins: setup methods Select = { Me: { applyTo: function ( hostObject, options ) { if ( !_.isObject( hostObject ) ) throw new Error( "The host object is undefined or not an object." ); _.extend( hostObject, Mixins.SelectMe ); hostObject._pickyLabels = {}; hostObject._pickyDefaultLabel = options && options.defaultLabel || "selected"; ensureLabelIsRegistered( hostObject._pickyDefaultLabel, hostObject ); augmentTrigger( hostObject ); }, custom: { /** * @type {Function|undefined} * @param {Backbone.Model} model a plain Backbone model, or a subtype, without the Select.Me mixin applied * @param {Backbone.Collection} collection a Select.One or Select.Many collection * @param {Object} [options] the options which will later be provided to the `applyTo()` method of the mixin */ applyModelMixin: undefined } }, One: { applyTo: function ( hostObject, models, options ) { var oldSelect; if ( !_.isObject( hostObject ) ) throw new Error( "The host object is undefined or not an object." ); if ( arguments.length < 2 ) throw new Error( "The `models` parameter has not been passed to Select.One.applyTo. Its value can be undefined, or null, if no models are passed in during instantiation, but even so, it must be provided." ); // Store a reference to the existing select method (most likely the default // Backbone.Collection.select method). Used to overload the new select method. oldSelect = hostObject.select; _.extend( hostObject, Mixins.SelectOne ); hostObject._pickyCid = _.uniqueId( 'singleSelect' ); hostObject._pickyLabels = {}; hostObject._pickyDefaultLabel = options && options.defaultLabel || "selected"; registerIgnoredLabels( options && options.ignoreLabel, hostObject ); ensureLabelIsRegistered( hostObject._pickyDefaultLabel, hostObject ); augmentTrigger( hostObject ); overloadSelect( oldSelect, hostObject ); patchSilentAdd( hostObject ); patchSilentSet( hostObject ); patchSilentRemove( hostObject ); patchSilentReset( hostObject ); if ( _.isArray( models ) ) { // Setting up the models. _.each( models, function ( model ) { // Bail out if the item is not a model. // // In that case, Backbone creates the model(s) when the collection is populated, with a // silent reset(). That happens just before the constructor exits, after initialize() has // run. The model setup is deferred until after the reset. (It is handled by // onResetSingleSelect(), which is invoked at the end of the reset.) // // NB When Backbone models are passed in, the setup is done here. Then, selections can be // made immediately after the mixin is applied - ie, in initialize(). If raw model data is // passed in, selections can't be made in initialize(). if ( !( model && model instanceof Backbone.Model ) ) return; // Auto-apply the Backbone.Select.Me mixin if not yet done for the model. // // Options are passed on to the mixin. Ie, if `defaultLabel` has been defined for the // collection, the model will share it. If models need a different setting, do not rely on // an auto-applied mixin. ensureModelMixin( model, hostObject, options ); registerCollectionWithModel( model, hostObject ); forEachLabelInModel( model, function ( label ) { ensureLabelIsRegistered( label, hostObject ); if ( model[label] && !isIgnoredLabel( label, hostObject ) ) { if ( hostObject[label] ) hostObject[label].deselect( { label: label } ); hostObject[label] = model; } } ); } ); } hostObject.listenTo( hostObject, '@bbs:_selected', hostObject.select ); hostObject.listenTo( hostObject, '@bbs:_deselected', hostObject.deselect ); // NB Calls to reset, add, remove are handled by listening to the corresponding event. That doesn't // work when the call is silenced (silent option). Then, the handler is called directly (sans event) // by the patched version of each method. hostObject.listenTo( hostObject, 'reset', onResetSingleSelect ); hostObject.listenTo( hostObject, 'add', onAdd ); hostObject.listenTo( hostObject, 'remove', onRemove ); } }, Many: { applyTo: function ( hostObject, models, options ) { var oldSelect; if ( !_.isObject( hostObject ) ) throw new Error( "The host object is undefined or not an object." ); if ( arguments.length < 2 ) throw new Error( "The `models` parameter has not been passed to Select.One.applyTo. Its value can be undefined, or null, if no models are passed in during instantiation, but even so, it must be provided." ); // Store a reference to the existing select method (most likely the default // Backbone.Collection.select method). Used to overload the new select method. oldSelect = hostObject.select; _.extend( hostObject, Mixins.SelectMany ); hostObject._pickyCid = _.uniqueId( 'multiSelect' ); hostObject._pickyLabels = {}; hostObject._pickyDefaultLabel = options && options.defaultLabel || "selected"; registerIgnoredLabels( options && options.ignoreLabel, hostObject ); ensureLabelIsRegistered( hostObject._pickyDefaultLabel, hostObject ); augmentTrigger( hostObject ); overloadSelect( oldSelect, hostObject ); patchSilentAdd( hostObject ); patchSilentSet( hostObject ); patchSilentRemove( hostObject ); patchSilentReset( hostObject ); if ( _.isArray( models ) ) { // Setting up the models. _.each( models, function ( model ) { // Bail out if the item is not a model. // // In that case, Backbone creates the model(s) when the collection is populated, with a // silent reset(). That happens just before the constructor exits, after initialize() has // run. The model setup is deferred until after the reset. (It is handled by // onResetMultiSelect(), which is invoked at the end of the reset.) // // NB When Backbone models are passed in, the setup is done here. Then, selections can be // made immediately after the mixin is applied - ie, in initialize(). If raw model data is // passed in, selections can't be made in initialize(). if ( !( model && model instanceof Backbone.Model ) ) return; // Auto-apply the Backbone.Select.Me mixin if not yet done for the model. // // Options are passed on to the mixin. Ie, if `defaultLabel` has been defined for the // collection, the model will share it. If models need a different setting, do not rely on // an auto-applied mixin. ensureModelMixin( model, hostObject, options ); registerCollectionWithModel( model, hostObject ); forEachLabelInModel( model, function ( label ) { ensureLabelIsRegistered( label, hostObject ); if ( model[label] && !isIgnoredLabel( label, hostObject ) ) { hostObject[label][model.cid] = model; } } ); } ); } hostObject.listenTo( hostObject, '@bbs:_selected', hostObject.select ); hostObject.listenTo( hostObject, '@bbs:_deselected', hostObject.deselect ); // NB Calls to reset, add, remove are handled by listening to the corresponding event. That doesn't // work when the call is silenced (silent option). Then, the handler is called directly (sans event) // by the patched version of each method. hostObject.listenTo( hostObject, 'reset', onResetMultiSelect ); hostObject.listenTo( hostObject, 'add', onAdd ); hostObject.listenTo( hostObject, 'remove', onRemove ); } } }; // Helper Methods // -------------- /** @type {string[]} options which are local to a method call, and not inherited by other method calls */ var localOptions = ["@bbs:silentLocally", "_externalEvent", "exclusive"], /** @type {string[]} options which are used internally for communicating across method calls, should not appear in public events */ internalOptions = ["@bbs:messageOnly", "@bbs:silentLocally", "@bbs:silentReselect", "@bbs:skipModelCall", "@bbs:processedBy", "@bbs:eventQueue", "@bbs:eventQueueAppendOnly", "@bbs:backboneSubcall"]; // Trigger events from a multi-select collection, based on the number of selected items. function triggerMultiSelectEvents ( collection, prevSelected, options, reselected ) { function mapCidsToModels ( cids, collection, previousSelection ) { function mapper ( cid ) { // Find the model in the collection. If not found, it has been removed, so get it from the array of // previously selected models. return collection.get( cid ) || previousSelection[cid]; } return _.map( cids, mapper ); } if ( options.silent || options["@bbs:silentLocally"] ) return; var diff, label = getLabel( options, collection ), selectionSize = getSelectionSize( collection, label ), length = collection.length, prevSelectedCids = _.keys( prevSelected ), selectedCids = _.keys( collection[label] ), addedCids = _.difference( selectedCids, prevSelectedCids ), removedCids = _.difference( prevSelectedCids, selectedCids ), unchanged = (selectionSize === prevSelectedCids.length && addedCids.length === 0 && removedCids.length === 0); if ( reselected && reselected.length && !options["@bbs:silentReselect"] ) { queueEventSet( "reselect:any", label, [ reselected, collection, toEventOptions( options, label, collection ) ], collection, options ); } if ( unchanged ) return; diff = { selected: mapCidsToModels( addedCids, collection, prevSelected ), deselected: mapCidsToModels( removedCids, collection, prevSelected ) }; if ( selectionSize === 0 ) { queueEventSet( "select:none", label, [ diff, collection, toEventOptions( options, label, collection ) ], collection, options ); return; } if ( selectionSize === length ) { queueEventSet( "select:all", label, [ diff, collection, toEventOptions( options, label, collection ) ], collection, options ); return; } if ( selectionSize > 0 && selectionSize < length ) { queueEventSet( "select:some", label, [ diff, collection, toEventOptions( options, label, collection ) ], collection, options ); return; } } function onAdd ( model, collection, options ) { ensureModelMixin( model, collection, options ); registerCollectionWithModel( model, collection ); forEachLabelInModel( model, function ( label ) { var selectOptions; // We want to keep the list of registered labels as small as possible in the collection, in order to keep // the processing overhead low. // // In Select.One collections, there is no need to register a label before we encounter a model which is // selected with it. The collection property for the label does not have to be created before - it would be // left undefined anyway. (Below, a collection.select() call handles label creation when it is required.) // // In Select.Many collections, however, existing labels should be registered even if no model is selected // with that label at the moment. We want the collection property for the label to exist, even if it is just // an empty hash. That way, we don't have to guard against the property being undefined when it is queried // in client code. if ( collection._pickyType === "Backbone.Select.Many" ) ensureLabelIsRegistered( label, collection ); selectOptions = { "@bbs:silentReselect": true, _externalEvent: "add", label: label }; if ( options.silent ) selectOptions.silent = options.silent; if ( model[label] ) collection.select( model, selectOptions ); } ); } function onRemove ( model, collection, options ) { releaseModel( model, collection, _.extend( {}, options, { _externalEvent: "remove" } ) ); } function releaseModel ( model, collection, options ) { if ( model._pickyCollections ) model._pickyCollections = _.without( model._pickyCollections, collection._pickyCid ); forEachLabelInCollection( collection, function ( label ) { var deselectOptions; if ( model[label] ) { if ( model._pickyCollections && model._pickyCollections.length === 0 ) { deselectOptions = _.extend( {}, options, { label: label } ); } else { deselectOptions = _.extend( {}, options, { label: label, "@bbs:skipModelCall": true } ); } collection.deselect( model, deselectOptions ); } } ); } function onResetSingleSelect ( collection, options ) { var selected, releaseOptions, deselectOptions, excessiveSelections, deselectOnRemove = {}; forEachLabelInCollection( collection, function ( label ) { var removeThis = _.find( options.previousModels, function ( model ) { return model[label]; } ); if ( removeThis ) deselectOnRemove[removeThis.cid] = removeThis; } ); releaseOptions = { "@bbs:silentLocally": true }; if ( options.silent ) releaseOptions.silent = options.silent; _.each( deselectOnRemove, function ( model ) { releaseModel( model, collection, releaseOptions ); } ); _.each( options.previousModels, function ( model ) { if ( model._pickyCollections ) model._pickyCollections = _.without( model._pickyCollections, collection._pickyCid ); } ); collection.each( function ( model ) { ensureModelMixin( model, collection, options ); registerCollectionWithModel( model, collection ); ensureModelLabelsInCollection( model, collection ); } ); forEachLabelInCollection( collection, function ( label ) { selected = collection.filter( function ( model ) { return model[label]; } ); excessiveSelections = _.initial( selected ); deselectOptions = { label: label }; if ( options.silent ) deselectOptions.silent = options.silent; if ( excessiveSelections.length ) _.each( excessiveSelections, function ( model ) { model.deselect( deselectOptions ); } ); if ( selected.length ) collection.select( _.last( selected ), { silent: true, label: label } ); } ); } function onResetMultiSelect ( collection, options ) { var select, deselectOptions, deselect = _.filter( options.previousModels, function ( model ) { return isModelSelectedWithAnyCollectionLabel( model, collection ); } ); deselectOptions = { "@bbs:silentLocally": true }; if ( options.silent ) deselectOptions.silent = options.silent; if ( deselect ) _.each( deselect, function ( model ) { releaseModel( model, collection, deselectOptions ); } ); _.each( options.previousModels, function ( model ) { if ( model._pickyCollections ) model._pickyCollections = _.without( model._pickyCollections, collection._pickyCid ); } ); collection.each( function ( model ) { ensureModelMixin( model, collection, options ); registerCollectionWithModel( model, collection ); ensureModelLabelsInCollection( model, collection ); } ); forEachLabelInCollection( collection, function ( label ) { select = collection.filter( function ( model ) { return model[label]; } ); if ( select.length ) _.each( select, function ( model ) { collection.select( model, { silent: true, label: label } ); } ); } ); } function registerCollectionWithModel ( model, collection ) { model._pickyCollections || (model._pickyCollections = []); model._pickyCollections.push( collection._pickyCid ); } function unregisterCollectionWithModels ( collection ) { collection.each( function ( model ) { releaseModel( model, collection, { "@bbs:silentLocally": true } ); } ); } function stripLocalOptions ( options ) { return _.omit( options, localOptions ); } function stripLocalOptionsExcept ( options, exceptions ) { var omit = localOptions; if ( exceptions ) { if ( _.isString( exceptions ) ) exceptions = [exceptions]; omit = _.without.apply( undefined, [ localOptions ].concat( exceptions ) ); } return _.omit( options, omit ); } function stripInternalOptions ( options ) { return _.omit( options, internalOptions ); } function toEventOptions ( options, label ) { var eventOptions = stripInternalOptions( options ); // The default label is used for a select/deselect action unless the label has been passed in explicitly. More // precisely, what gets used is the default label of the object on which select/deselect has initially been // called. // // But other objects may have been created with another default label. So it can happen that the default label // of the ongoing operation, initiated elsewhere, is different from the default label of the object which is // processed right now. // // Therefore, in order to avoid ambiguity, the label is always exposed in the event options. _.extend( eventOptions, { label: label } ); return eventOptions; } function getLabel ( options, obj ) { // getLabel must be called before any work gets done in a select/deselect method. Therefore, it is also tasked // with a few side jobs regarding proper initialization: // // - It ensures that the label is explicit in the options object, from here on out // - It ensures that the label is registered options.label || ( options.label = obj._pickyDefaultLabel ); ensureLabelIsRegistered( options.label, obj ); return options.label; } // Auto-apply the Backbone.Select.Me mixin if not yet done for the model. // // Options are passed on to the mixin. Ie, if `defaultLabel` has been defined in the options, the model will be set // up accordingly. function ensureModelMixin( model, collection, options ) { var applyModelMixin; if ( !model._pickyType ) { applyModelMixin = Backbone.Select.Me.custom.applyModelMixin; if ( applyModelMixin && _.isFunction( applyModelMixin ) ) { applyModelMixin( model, collection, options ); } else { Backbone.Select.Me.applyTo( model, options ); } } } function ensureLabelIsRegistered ( name, obj ) { if ( name && !obj._pickyLabels[name] && !isIgnoredLabel( name, obj ) ) { // Check if the name is safe if ( _.indexOf( illegalLabelNames, name ) !== -1 ) throw new Error( 'Illegal label name "' + name + '", is in conflict with an existing Backbone or Backbone.Select property or method' ); obj._pickyLabels[name] = true; if ( obj._pickyType === "Backbone.Select.Many" ) { obj[name] = {}; setSelectionSize( 0, obj, name ); } } } function ensureModelLabelsInCollection ( model, collection ) { forEachLabelInModel( model, function ( label ) { ensureLabelIsRegistered( label, collection ); } ); } function isModelSelectedWithAnyCollectionLabel ( model, collection ) { var hasCollectionLabel = false; forEachLabelInCollection( collection, function ( label ) { hasCollectionLabel || ( hasCollectionLabel = model[label] ); } ); return hasCollectionLabel; } function registerIgnoredLabels ( labels, collection ) { labels || ( labels = [] ); if ( _.isString( labels ) ) labels = [ labels ]; if ( !_.isArray( labels ) ) throw new Error( "ignoreLabel option: illegal value. Expected a string or an array of strings but got the value " + labels ); if ( _.contains( labels, collection._pickyDefaultLabel ) ) throw new Error( "ignoreLabel option: illegal value. Can't ignore the default label, \"" + collection._pickyDefaultLabel + "\", of a collection (_pickyCid: " + collection._pickyCid + ")" ); collection._pickyIgnoredLabels = labels; } function isIgnoredLabel ( label, collection ) { // - The query only really makes sense for collections. Labels can be ignored in collections. // - A model doesn't ignore labels and doesn't have a _pickyIgnoredLabels property, so return false for a model. // - If the label is undefined, return false, too. return ( label && collection._pickyIgnoredLabels ) ? _.contains( collection._pickyIgnoredLabels, label ) : false; } function getSelectionSizeProp ( label ) { // For Select.Many collections only return label + "Length"; } function getSelectionSize( collection, label ) { // For Select.Many collections only return collection[getSelectionSizeProp( label )]; } function setSelectionSize( size, collection, label ) { // For Select.Many collections only collection[getSelectionSizeProp( label )] = size; } function forEachLabelInCollection ( collection, callback ) { _forEachEntityLabel( collection, callback ); } function forEachLabelInModel ( model, callback ) { _forEachEntityLabel( model, callback ); } function _forEachEntityLabel ( collectionOrModel, callback ) { var labels = _.keys( collectionOrModel._pickyLabels ); _.each( labels, function ( label, index ) { callback( label, collectionOrModel, index, labels ); } ); } function initOptions ( options ) { options || (options = {}); options["@bbs:processedBy"] || (options["@bbs:processedBy"] = {}); options["@bbs:eventQueue"] || (options["@bbs:eventQueue"] = []); return options; } function queueEventSet ( eventName, label, eventArgs, context, storage ) { // Queue two events which are identical, except that one is namespaced to the label. queueEvent( storage, context, [ eventName ].concat( eventArgs ) ); queueEvent( storage, context, [ eventName + ":" + label ].concat( eventArgs ) ); } function getActiveQueue ( storage ) { // There are two properties which could store the queue: // // - Usually, the queue is stored in the eventQueue property. // // The queue will eventually be processed by the object which created it. The eventQueue is created in the // initial select/deselect method call which started the whole thing. When all secondary calls are done and // the end of that method is reached, the eventQueue is processed. // // Secondary calls on other objects just add to the queue. They don't resolve it when they reach their own // processEventQueue() because its resolution is blocked by the original method. That method has created a // processedBy entry for the calling object which is not yet marked as done. (All processedBy entries must be // marked as done when the queue is processed.) // // In the course of secondary calls, the original object is called back sometimes. These recursive, tertiary // calls also don't resolve the queue (which would be premature). They also don't have to do any real work, // except for some minor tasks. Recursive, tertiary calls return early when a processedBy entry for the object // exists, whether it is marked done or not. Hence, they don't reach processEventQueue(). // // - Sometimes, though, recursive calls to methods on the original object _have_ to do real work and must be // followed through. For those calls, the processedBy entry is not passed on. They don't return early // (allowing them to do their work), add events to the queue etc, but when their end is reached, // processEventQueue() must not process the queue. // // That's why they don't receive the queue in eventQueue. Instead, the queue object is referenced in // eventQueueAppendOnly during these calls. The eventQueueAppendOnly property is left alone by // processEventQueue(), protecting the queue from premature resolution. // // New events in these recursive calls must be added to eventQueueAppendOnly, not eventQueue, which just // contains an unused, empty hash. The original calling method shares the reference, and will process the // queue in the end, including the events added by the recursive call. // // eventQueueAppendOnly exists only when needed, and thus takes precedence. If it exists, it is the active // queue, whereas eventQueue just contains an unused, empty hash. If not, eventQueue is the real thing. return storage["@bbs:eventQueueAppendOnly"] || storage["@bbs:eventQueue"]; } function queueEvent ( storage, context, triggerArgs ) { var queue = getActiveQueue( storage ); queue.push( { context: context, triggerArgs: triggerArgs } ); } function processEventQueue ( storage ) { var resolved, eventData; if ( storage["@bbs:eventQueue"].length ) { resolved = _.every( storage["@bbs:processedBy"], function ( entry ) { return entry.done; } ); if ( resolved ) { mergeMultiSelectEvents( storage["@bbs:eventQueue"] ); while ( storage["@bbs:eventQueue"].length ) { eventData = storage["@bbs:eventQueue"].pop(); eventData.context.trigger.apply( eventData.context, eventData.triggerArgs ); } } } } // Merges separate (sub-)events of a Select.Many collection into a single, summary event, and cleans up the event // queue. // // NB "reselect:any" events stand on their own and are not merged into a joint select:some event. They only occur // once per collection in the event queue. function mergeMultiSelectEvents ( queue ) { var multiSelectCollections = {}; // Create merged data for each multi-select collection _.each( queue, function ( event, index ) { var label, datasetId, extractedData, diff, opts, context = event.context, eventName = event.triggerArgs[0]; // Only act on queue entries for Backbone.Select.Many, and ignore their "reselect:any" events. if ( context._pickyType === "Backbone.Select.Many" && eventName.indexOf( "reselect:any" ) === -1 ) { // NB Label (= event namespace) is an empty string for the non-namespaced event label = eventName.replace( /^select:(all|some|none):?/, "" ); datasetId = context._pickyCid + "-ns-" + label; extractedData = multiSelectCollections[datasetId]; if ( !extractedData ) extractedData = multiSelectCollections[datasetId] = { context: context, label: label, indexes: [], merged: { selected: [], deselected: [], options: {} } }; extractedData.indexes.push( index ); diff = event.triggerArgs[1]; opts = event.triggerArgs[3];