UNPKG

bb-collection-view

Version:

Easily render backbone.js collections with support for automatic selection of models in response to clicks, reordering models via drag and drop, and more.

1,378 lines (1,120 loc) 49.1 kB
( function( root, factory ) { // UMD wrapper if ( typeof define === 'function' && define.amd ) { // AMD define( [ 'underscore', 'backbone', 'jquery' ], factory ); } else if ( typeof exports !== 'undefined' ) { // Node/CommonJS module.exports = factory( require('underscore' ), require( 'backbone' ), require( 'backbone' ).$ ); } else { // Browser globals factory( root._, root.Backbone, ( root.jQuery || root.Zepto || root.$ ) ); } }( this, function( _, Backbone, $ ) { var mDefaultModelViewConstructor = Backbone.View; var kDefaultReferenceBy = "model"; var kOptionsRequiringRerendering = [ "collection", "modelView", "modelViewOptions", "itemTemplate", "itemTemplateFunction", "detachedRendering" ]; var kStylesForEmptyListCaption = { "background" : "transparent", "border" : "none", "box-shadow" : "none" }; Backbone.CollectionView = Backbone.View.extend( { tagName : "ul", events : { "mousedown > li, tbody > tr > td" : "_listItem_onMousedown", "dblclick > li, tbody > tr > td" : "_listItem_onDoubleClick", "click" : "_listBackground_onClick", "click ul.collection-view, table.collection-view" : "_listBackground_onClick", "keydown" : "_onKeydown" }, // only used if Backbone.Courier is available spawnMessages : { "focus" : "focus" }, //only used if Backbone.Courier is available passMessages : true, // viewOption definitions with default values. initializationOptions : [ { "collection" : null }, { "modelView" : null }, { "modelViewOptions" : {} }, { "itemTemplate" : null }, { "itemTemplateFunction" : null }, { "selectable" : true }, { "clickToSelect" : true }, { "selectableModelsFilter" : null }, { "visibleModelsFilter" : null }, { "sortableModelsFilter" : null }, { "selectMultiple" : false }, { "clickToToggle" : false }, { "processKeyEvents" : true }, { "sortable" : false }, { "sortableOptions" : null }, { "reuseModelViews" : true }, { "detachedRendering" : false }, { "emptyListCaption" : null } ], initialize : function( options ) { Backbone.ViewOptions.add( this, "initializationOptions" ); // setup the ViewOptions functionality. this.setOptions( options ); // and make use of any provided options if( ! this.collection ) this.collection = new Backbone.Collection(); this._hasBeenRendered = false; if( this._isBackboneCourierAvailable() ) { Backbone.Courier.add( this ); } this.$el.data( "view", this ); // needed for connected sortable lists this.$el.addClass( "collection-view collection-list" ); // collection-list is in there for legacy purposes if( this.selectable ) this.$el.addClass( "selectable" ); if( this.selectable && this.processKeyEvents ) this.$el.attr( "tabindex", 0 ); // so we get keyboard events this.selectedItems = []; this._updateItemTemplate(); if( this.collection ) this._registerCollectionEvents(); this.viewManager = new ChildViewContainer(); }, _onOptionsChanged : function( changedOptions, originalOptions ) { var _this = this; var rerender = false; _.each( _.keys( changedOptions ), function( changedOptionKey ) { var newVal = changedOptions[ changedOptionKey ]; var oldVal = originalOptions[ changedOptionKey ]; switch( changedOptionKey ) { case "collection" : if ( newVal !== oldVal ) { _this.stopListening( oldVal ); _this._registerCollectionEvents(); } break; case "selectMultiple" : if( ! newVal && _this.selectedItems.length > 1 ) _this.setSelectedModel( _.first( _this.selectedItems ), { by : "cid" } ); break; case "selectable" : if( ! newVal && _this.selectedItems.length > 0 ) _this.setSelectedModels( [] ); if( newVal && this.processKeyEvents ) _this.$el.attr( "tabindex", 0 ); // so we get keyboard events else _this.$el.removeAttr( "tabindex", 0 ); break; case "sortable" : changedOptions.sortable ? _this._setupSortable() : _this.$el.sortable( "destroy" ); break; case "selectableModelsFilter" : _this.reapplyFilter( 'selectableModels' ); break; case "sortableOptions" : _this.$el.sortable( "destroy" ); _this._setupSortable(); break; case "sortableModelsFilter" : _this.reapplyFilter( 'sortableModels' ); break; case "visibleModelsFilter" : _this.reapplyFilter( 'visibleModels' ); break; case "itemTemplate" : _this._updateItemTemplate(); break; case "processKeyEvents" : if( newVal && this.selectable ) _this.$el.attr( "tabindex", 0 ); // so we get keyboard events else _this.$el.removeAttr( "tabindex", 0 ); break; case "modelView" : //need to remove all old view instances _this.viewManager.each( function( view ) { _this.viewManager.remove( view ); // destroy the View itself view.remove(); } ); break; } if( _.contains( kOptionsRequiringRerendering, changedOptionKey ) ) rerender = true; } ); if( this._hasBeenRendered && rerender ) { this.render(); } }, setOption : function( optionName, optionValue ) { // now is mearly a wrapper around backbone.viewOptions' setOptions() var optionHash = {}; optionHash[ optionName ] = optionValue; this.setOptions( optionHash ); }, getSelectedModel : function( options ) { return this.selectedItems.length ? _.first( this.getSelectedModels( options ) ) : null; }, getSelectedModels : function ( options ) { var _this = this; options = _.extend( {}, { by : kDefaultReferenceBy }, options ); var referenceBy = options.by; var items = []; switch( referenceBy ) { case "id" : _.each( this.selectedItems, function ( item ) { items.push( _this.collection.get( item ).id ); } ); break; case "cid" : items = items.concat( this.selectedItems ); break; case "offset" : var curLineNumber = 0; var itemElements = this._getVisibleItemEls(); itemElements.each( function() { var thisItemEl = $( this ); if( thisItemEl.is( ".selected" ) ) items.push( curLineNumber ); curLineNumber++; } ); break; case "model" : _.each( this.selectedItems, function ( item ) { items.push( _this.collection.get( item ) ); } ); break; case "view" : _.each( this.selectedItems, function ( item ) { items.push( _this.viewManager.findByModel( _this.collection.get( item ) ) ); } ); break; default : throw new Error( "Invalid referenceBy option: " + referenceBy ); break; } return items; }, setSelectedModels : function( newSelectedItems, options ) { if( ! _.isArray( newSelectedItems ) ) throw "Invalid parameter value"; if( ! this.selectable && newSelectedItems.length > 0 ) return; // used to throw error, but there are some circumstances in which a list can be selectable at times and not at others, don't want to have to worry about catching errors options = _.extend( {}, { silent : false, by : kDefaultReferenceBy }, options ); var referenceBy = options.by; var newSelectedCids = []; switch( referenceBy ) { case "cid" : newSelectedCids = newSelectedItems; break; case "id" : this.collection.each( function( thisModel ) { if( _.contains( newSelectedItems, thisModel.id ) ) newSelectedCids.push( thisModel.cid ); } ); break; case "model" : newSelectedCids = _.pluck( newSelectedItems, "cid" ); break; case "view" : _.each( newSelectedItems, function( item ) { newSelectedCids.push( item.model.cid ); } ); break; case "offset" : var curLineNumber = 0; var selectedItems = []; var itemElements = this._getVisibleItemEls(); itemElements.each( function() { var thisItemEl = $( this ); if( _.contains( newSelectedItems, curLineNumber ) ) newSelectedCids.push( thisItemEl.attr( "data-model-cid" ) ); curLineNumber++; } ); break; default : throw new Error( "Invalid referenceBy option: " + referenceBy ); break; } var oldSelectedModels = this.getSelectedModels(); var oldSelectedCids = _.clone( this.selectedItems ); this.selectedItems = this._convertStringsToInts( newSelectedCids ); this._validateSelection(); var newSelectedModels = this.getSelectedModels(); if( ! this._containSameElements( oldSelectedCids, this.selectedItems ) ) { this._addSelectedClassToSelectedItems( oldSelectedCids ); if( ! options.silent ) { if( this._isBackboneCourierAvailable() ) { this.spawn( "selectionChanged", { selectedModels : newSelectedModels, oldSelectedModels : oldSelectedModels } ); } else this.trigger( "selectionChanged", newSelectedModels, oldSelectedModels ); } this.updateDependentControls(); } }, setSelectedModel : function( newSelectedItem, options ) { if( ! newSelectedItem && newSelectedItem !== 0 ) this.setSelectedModels( [], options ); else this.setSelectedModels( [ newSelectedItem ], options ); }, getView : function( reference, options ) { options = _.extend( {}, { by : kDefaultReferenceBy }, options ); switch( options.by ) { case "id" : case "cid" : var model = this.collection.get( reference ) || null; return model && this.viewManager.findByModel( model ); break; case "offset" : var itemElements = this._getVisibleItemEls(); return $( itemElements.get( reference ) ); break; case "model" : return this.viewManager.findByModel( reference ); break; default : throw new Error( "Invalid referenceBy option: " + referenceBy ); break; } }, render : function() { var _this = this; this._hasBeenRendered = true; if( this.selectable ) this._saveSelection(); var modelViewContainerEl; // If collection view element is a table and it has a tbody // within it, render the model views inside of the tbody modelViewContainerEl = this._getContainerEl(); var oldViewManager = this.viewManager; this.viewManager = new ChildViewContainer(); // detach each of our subviews that we have already created to represent models // in the collection. We are going to re-use the ones that represent models that // are still here, instead of creating new ones, so that we don't loose state // information in the views. oldViewManager.each( function( thisModelView ) { // to boost performance, only detach those views that will be sticking around. // we won't need the other ones later, so no need to detach them individually. if( this.reuseModelViews && this.collection.get( thisModelView.model.cid ) ) { thisModelView.$el.detach(); } else thisModelView.remove(); }, this ); modelViewContainerEl.empty(); var fragmentContainer; if( this.detachedRendering ) fragmentContainer = document.createDocumentFragment(); this.collection.each( function( thisModel ) { var thisModelView = oldViewManager.findByModelCid( thisModel.cid ); if( ! this.reuseModelViews || _.isUndefined( thisModelView ) ) { // if the model view has not already been created on a // previous render then create and initialize it now. thisModelView = this._createNewModelView( thisModel, this._getModelViewOptions( thisModel ) ); } this._insertAndRenderModelView( thisModelView, fragmentContainer || modelViewContainerEl ); }, this ); if( this.detachedRendering ) modelViewContainerEl.append( fragmentContainer ); if( this.sortable ) this._setupSortable(); this._showEmptyListCaptionIfAppropriate(); if( this._isBackboneCourierAvailable() ) this.spawn( "render" ); else this.trigger( "render" ); if( this.selectable ) { this._restoreSelection(); this.updateDependentControls(); } this.forceRerenderOnNextSortEvent = false; }, _showEmptyListCaptionIfAppropriate : function ( ) { this._removeEmptyListCaption(); if( this.emptyListCaption ) { var visibleEls = this._getVisibleItemEls(); if( visibleEls.length === 0 ) { var emptyListString; if( _.isFunction( this.emptyListCaption ) ) emptyListString = this.emptyListCaption(); else emptyListString = this.emptyListCaption; var $emptyListCaptionEl; var $varEl = $( "<var class='empty-list-caption'>" + emptyListString + "</var>" ); // need to wrap the empty caption to make it fit the rendered list structure (either with an li or a tr td) if( this._isRenderedAsList() ) $emptyListCaptionEl = $varEl.wrapAll( "<li class='not-sortable'></li>" ).parent().css( kStylesForEmptyListCaption ); else $emptyListCaptionEl = $varEl.wrapAll( "<tr class='not-sortable'><td colspan='1000'></td></tr>" ).parent().parent().css( kStylesForEmptyListCaption ); this._getContainerEl().append( $emptyListCaptionEl ); } } }, _removeEmptyListCaption : function( ) { if( this._isRenderedAsList() ) this._getContainerEl().find( "> li > var.empty-list-caption" ).parent().remove(); else this._getContainerEl().find( "> tr > td > var.empty-list-caption" ).parent().parent().remove(); }, // Render a single model view in container object "parentElOrDocumentFragment", which is either // a documentFragment or a jquery object. optional arg atIndex is not support for document fragments. _insertAndRenderModelView : function( modelView, parentElOrDocumentFragment, atIndex ) { var thisModelViewWrapped = this._wrapModelView( modelView ); if( parentElOrDocumentFragment.nodeType === 11 ) // if we are inserting into a document fragment, we need to use the DOM appendChild method parentElOrDocumentFragment.appendChild( thisModelViewWrapped.get( 0 ) ); else { var numberOfModelViewsCurrentlyInDOM = parentElOrDocumentFragment.children().length; if( ! _.isUndefined( atIndex ) && atIndex >= 0 && atIndex < numberOfModelViewsCurrentlyInDOM ) // note this.collection.length might be greater than parentElOrDocumentFragment.children().length here parentElOrDocumentFragment.children().eq( atIndex ).before( thisModelViewWrapped ); else { // if we are attempting to insert a modelView in an position that is beyond what is currently in the // DOM, then make a note that we need to re-render the collection view on the next sort event. If we dont // force this re-render, we can end up with modelViews in the wrong order when the collection defines // a comparator and multiple models are added at once. See https://github.com/rotundasoftware/backbone.collectionView/issues/69 if( ! _.isUndefined( atIndex ) && atIndex > numberOfModelViewsCurrentlyInDOM ) this.forceRerenderOnNextSortEvent = true; parentElOrDocumentFragment.append( thisModelViewWrapped ); } } this.viewManager.add( modelView ); // we have to render the modelView after it has been put in context, as opposed to in the // initialize function of the modelView, because some rendering might be dependent on // the modelView's context in the DOM tree. For example, if the modelView stretch()'s itself, // it must be in full context in the DOM tree or else the stretch will not behave as intended. var renderResult = modelView.render(); // return false from the view's render function to hide this item if( renderResult === false ) { thisModelViewWrapped.hide(); thisModelViewWrapped.addClass( "not-visible" ); } var hideThisModelView = false; if( _.isFunction( this.visibleModelsFilter ) ) hideThisModelView = ! this.visibleModelsFilter( modelView.model ); if( thisModelViewWrapped.children().length === 1 ) thisModelViewWrapped.toggle( ! hideThisModelView ); else modelView.$el.toggle( ! hideThisModelView ); thisModelViewWrapped.toggleClass( "not-visible", hideThisModelView ); if( ! hideThisModelView && this.emptyListCaption ) this._removeEmptyListCaption(); }, updateDependentControls : function() { if( this._isBackboneCourierAvailable() ) { this.spawn( "updateDependentControls", { selectedModels : this.getSelectedModels() } ); } else this.trigger( "updateDependentControls", this.getSelectedModels() ); }, // Override `Backbone.View.remove` to also destroy all Views in `viewManager` remove : function() { this.viewManager.each( function( view ) { view.remove(); } ); Backbone.View.prototype.remove.apply( this, arguments ); }, reapplyFilter : function( whichFilter ) { var _this = this; if( ! _.contains( [ "selectableModels", "sortableModels", "visibleModels" ], whichFilter ) ) { throw new Error( "Invalid filter identifier supplied to reapplyFilter: " + whichFilter ); } switch( whichFilter ) { case "visibleModels": _this.viewManager.each( function( thisModelView ) { var notVisible = _this.visibleModelsFilter && ! _this.visibleModelsFilter.call( _this, thisModelView.model ); thisModelView.$el.toggleClass( "not-visible", notVisible ); if( _this._modelViewHasWrapperLI( thisModelView ) ) { thisModelView.$el.closest( "li" ).toggleClass( "not-visible", notVisible ).toggle( ! notVisible ); } else thisModelView.$el.toggle( ! notVisible ); } ); this._showEmptyListCaptionIfAppropriate(); break; case "sortableModels": _this.$el.sortable( "destroy" ); _this.viewManager.each( function( thisModelView ) { var notSortable = _this.sortableModelsFilter && ! _this.sortableModelsFilter.call( _this, thisModelView.model ); thisModelView.$el.toggleClass( "not-sortable", notSortable ); if( _this._modelViewHasWrapperLI( thisModelView ) ) { thisModelView.$el.closest( "li" ).toggleClass( "not-sortable", notSortable ); } } ); _this._setupSortable(); break; case "selectableModels": _this.viewManager.each( function( thisModelView ) { var notSelectable = _this.selectableModelsFilter && ! _this.selectableModelsFilter.call( _this, thisModelView.model ); thisModelView.$el.toggleClass( "not-selectable", notSelectable ); if( _this._modelViewHasWrapperLI( thisModelView ) ) { thisModelView.$el.closest( "li" ).toggleClass( "not-selectable", notSelectable ); } } ); _this._validateSelection(); break; } }, // A method to remove the view relating to model. _removeModelView : function( modelView ) { if( this.selectable ) this._saveSelection(); this.viewManager.remove( modelView ); // Remove the view from the viewManager if( this._modelViewHasWrapperLI( modelView ) ) modelView.$el.parent().remove(); // Remove the li wrapper from the DOM modelView.remove(); // Remove the view from the DOM and stop listening to events if( this.selectable ) this._restoreSelection(); this._showEmptyListCaptionIfAppropriate(); }, _validateSelectionAndRender : function() { this._validateSelection(); this.render(); }, _registerCollectionEvents : function() { this.listenTo( this.collection, "add", function( model ) { var modelView; if( this._hasBeenRendered ) { modelView = this._createNewModelView( model, this._getModelViewOptions( model ) ); this._insertAndRenderModelView( modelView, this._getContainerEl(), this.collection.indexOf( model ) ); } if( this._isBackboneCourierAvailable() ) this.spawn( "add", modelView ); else this.trigger( "add", modelView ); } ); this.listenTo( this.collection, "remove", function( model ) { var modelView; if( this._hasBeenRendered ) { modelView = this.viewManager.findByModelCid( model.cid ); this._removeModelView( modelView ); } if( this._isBackboneCourierAvailable() ) this.spawn( "remove" ); else this.trigger( "remove" ); } ); this.listenTo( this.collection, "reset", function() { if( this._hasBeenRendered ) this.render(); if( this._isBackboneCourierAvailable() ) this.spawn( "reset" ); else this.trigger( "reset" ); } ); // we should not be listening to change events on the model as a default behavior. the models // should be responsible for re-rendering themselves if necessary, and if the collection does // also need to re-render as a result of a model change, this should be handled by overriding // this method. by default the collection view should not re-render in response to model changes // this.listenTo( this.collection, "change", function( model ) { // if( this._hasBeenRendered ) this.viewManager.findByModel( model ).render(); // if( this._isBackboneCourierAvailable() ) // this.spawn( "change", { model : model } ); // } ); this.listenTo( this.collection, "sort", function( collection, options ) { if( this._hasBeenRendered && ( options.add !== true || this.forceRerenderOnNextSortEvent ) ) this.render(); if( this._isBackboneCourierAvailable() ) this.spawn( "sort" ); else this.trigger( "sort" ); } ); }, _getContainerEl : function() { if ( this._isRenderedAsTable() ) { // not all tables have a tbody, so we test var tbody = this.$el.find( "> tbody" ); if ( tbody.length > 0 ) return tbody; } return this.$el; }, _getClickedItemId : function( theEvent ) { var clickedItemId = null; // important to use currentTarget as opposed to target, since we could be bubbling // an event that took place within another collectionList var clickedItemEl = $( theEvent.currentTarget ); if( clickedItemEl.closest( ".collection-view" ).get(0) !== this.$el.get(0) ) return; // determine which list item was clicked. If we clicked in the blank area // underneath all the elements, we want to know that too, since in this // case we will want to deselect all elements. so check to see if the clicked // DOM element is the list itself to find that out. var clickedItem = clickedItemEl.closest( "[data-model-cid]" ); if( clickedItem.length > 0 ) { clickedItemId = clickedItem.attr( "data-model-cid" ); if( $.isNumeric( clickedItemId ) ) clickedItemId = parseInt( clickedItemId, 10 ); } return clickedItemId; }, _updateItemTemplate : function() { var itemTemplateHtml; if( this.itemTemplate ) { if( $( this.itemTemplate ).length === 0 ) throw "Could not find item template from selector: " + this.itemTemplate; itemTemplateHtml = $( this.itemTemplate ).html(); } else itemTemplateHtml = this.$( ".item-template" ).html(); if( itemTemplateHtml ) this.itemTemplateFunction = _.template( itemTemplateHtml ); }, _validateSelection : function() { // note can't use the collection's proxy to underscore because "cid" is not an attribute, // but an element of the model object itself. var modelReferenceIds = _.pluck( this.collection.models, "cid" ); this.selectedItems = _.intersection( modelReferenceIds, this.selectedItems ); if( _.isFunction( this.selectableModelsFilter ) ) { this.selectedItems = _.filter( this.selectedItems, function( thisItemId ) { return this.selectableModelsFilter.call( this, this.collection.get( thisItemId ) ); }, this ); } }, _saveSelection : function() { // save the current selection. use restoreSelection() to restore the selection to the state it was in the last time saveSelection() was called. if( ! this.selectable ) throw "Attempt to save selection on non-selectable list"; this.savedSelection = { items : _.clone( this.selectedItems ), offset : this.getSelectedModel( { by : "offset" } ) }; }, _restoreSelection : function() { if( ! this.savedSelection ) throw "Attempt to restore selection but no selection has been saved!"; // reset selectedItems to empty so that we "redraw" all "selected" classes // when we set our new selection. We do this because it is likely that our // contents have been refreshed, and we have thus lost all old "selected" classes. this.setSelectedModels( [], { silent : true } ); if( this.savedSelection.items.length > 0 ) { // first try to restore the old selected items using their reference ids. this.setSelectedModels( this.savedSelection.items, { by : "cid", silent : true } ); // all the items with the saved reference ids have been removed from the list. // ok. try to restore the selection based on the offset that used to be selected. // this is the expected behavior after a item is deleted from a list (i.e. select // the line that immediately follows the deleted line). if( this.selectedItems.length === 0 ) this.setSelectedModel( this.savedSelection.offset, { by : "offset" } ); // Trigger a selection changed if the previously selected items were not all found if (this.selectedItems.length !== this.savedSelection.items.length) { if( this._isBackboneCourierAvailable() ) { this.spawn( "selectionChanged", { selectedModels : this.getSelectedModels(), oldSelectedModels : [] } ); } else this.trigger( "selectionChanged", this.getSelectedModels(), [] ); } } }, _addSelectedClassToSelectedItems : function( oldItemsIdsWithSelectedClass ) { if( _.isUndefined( oldItemsIdsWithSelectedClass ) ) oldItemsIdsWithSelectedClass = []; // oldItemsIdsWithSelectedClass is used for optimization purposes only. If this info is supplied then we // only have to add / remove the "selected" class from those items that "selected" state has changed. var itemsIdsFromWhichSelectedClassNeedsToBeRemoved = oldItemsIdsWithSelectedClass; itemsIdsFromWhichSelectedClassNeedsToBeRemoved = _.without( itemsIdsFromWhichSelectedClassNeedsToBeRemoved, this.selectedItems ); _.each( itemsIdsFromWhichSelectedClassNeedsToBeRemoved, function( thisItemId ) { this._getContainerEl().find( "[data-model-cid=" + thisItemId + "]" ).removeClass( "selected" ); if( this._isRenderedAsList() ) { this._getContainerEl().find( "li[data-model-cid=" + thisItemId + "] > *" ).removeClass( "selected" ); } }, this ); var itemsIdsFromWhichSelectedClassNeedsToBeAdded = this.selectedItems; itemsIdsFromWhichSelectedClassNeedsToBeAdded = _.without( itemsIdsFromWhichSelectedClassNeedsToBeAdded, oldItemsIdsWithSelectedClass ); _.each( itemsIdsFromWhichSelectedClassNeedsToBeAdded, function( thisItemId ) { this._getContainerEl().find( "[data-model-cid=" + thisItemId + "]" ).addClass( "selected" ); if( this._isRenderedAsList() ) { this._getContainerEl().find( "li[data-model-cid=" + thisItemId + "] > *" ).addClass( "selected" ); } }, this ); }, _reorderCollectionBasedOnHTML : function() { var _this = this; this._getContainerEl().children().each( function() { var thisModelCid = $( this ).attr( "data-model-cid" ); if( thisModelCid ) { // remove the current model and then add it back (at the end of the collection). // When we are done looping through all models, they will be in the correct order. var thisModel = _this.collection.get( thisModelCid ); if( thisModel ) { _this.collection.remove( thisModel, { silent : true } ); _this.collection.add( thisModel, { silent : true, sort : ! _this.collection.comparator } ); } } } ); if( this._isBackboneCourierAvailable() ) this.spawn( "reorder" ); else this.collection.trigger( "reorder" ); if( this.collection.comparator ) this.collection.sort(); }, _getModelViewConstructor : function( thisModel ) { return this.modelView || mDefaultModelViewConstructor; }, _getModelViewOptions : function( thisModel ) { var modelViewOptions = this.modelViewOptions; if( _.isFunction( modelViewOptions ) ) modelViewOptions = modelViewOptions( thisModel ); return _.extend( { model : thisModel }, modelViewOptions ); }, _createNewModelView : function( model, modelViewOptions ) { var modelViewConstructor = this._getModelViewConstructor( model ); if( _.isUndefined( modelViewConstructor ) ) throw "Could not find modelView constructor for model"; var newModelView = new( modelViewConstructor )( modelViewOptions ); newModelView.collectionListView = newModelView.collectionView = this; // collectionListView for legacy return newModelView; }, _wrapModelView : function( modelView ) { var _this = this; // we use items client ids as opposed to real ids, since we may not have a representation // of these models on the server var modelViewWrapperEl; if( this._isRenderedAsTable() ) { // if we are rendering the collection in a table, the template $el is a tr so we just need to set the data-model-cid modelViewWrapperEl = modelView.$el; modelView.$el.attr( "data-model-cid", modelView.model.cid ); } else if( this._isRenderedAsList() ) { // if we are rendering the collection in a list, we need wrap each item in an <li></li> (if its not already an <li>) // and set the data-model-cid if( modelView.$el.is( "li" ) ) { modelViewWrapperEl = modelView.$el; modelView.$el.attr( "data-model-cid", modelView.model.cid ); } else { modelViewWrapperEl = modelView.$el.wrapAll( "<li data-model-cid='" + modelView.model.cid + "'></li>" ).parent(); } } if( _.isFunction( this.sortableModelsFilter ) ) if( ! this.sortableModelsFilter.call( _this, modelView.model ) ) { modelViewWrapperEl.addClass( "not-sortable" ); modelView.$el.addClass( "not-selectable" ); } if( _.isFunction( this.selectableModelsFilter ) ) if( ! this.selectableModelsFilter.call( _this, modelView.model ) ) { modelViewWrapperEl.addClass( "not-selectable" ); modelView.$el.addClass( "not-selectable" ); } return modelViewWrapperEl; }, _convertStringsToInts : function( theArray ) { return _.map( theArray, function( thisEl ) { if( ! _.isString( thisEl ) ) return thisEl; var thisElAsNumber = parseInt( thisEl, 10 ); return( thisElAsNumber == thisEl ? thisElAsNumber : thisEl ); } ); }, _containSameElements : function( arrayA, arrayB ) { if( arrayA.length != arrayB.length ) return false; var intersectionSize = _.intersection( arrayA, arrayB ).length; return intersectionSize == arrayA.length; // and must also equal arrayB.length, since arrayA.length == arrayB.length }, _isRenderedAsTable : function() { return this.$el.prop( "tagName" ).toLowerCase() === "table"; }, _isRenderedAsList : function() { return ! this._isRenderedAsTable(); }, _modelViewHasWrapperLI : function( modelView ) { return this._isRenderedAsList() && ! modelView.$el.is( "li" ); }, // Returns the wrapper HTML element for each visible modelView. // When rendering in a table context, the returned elements are the $el of each modelView. // When rendering in a list context, // If the $el of the modelView is an <li>, the returned elements are the $el of each modelView. // Otherwise, the returned elements are the <li>'s the collectionView wrapped around each modelView $el. _getVisibleItemEls : function() { var itemElements = []; itemElements = this._getContainerEl().find( "> [data-model-cid]:not(.not-visible)" ); return itemElements; }, _charCodes : { upArrow : 38, downArrow : 40 }, _isBackboneCourierAvailable : function() { return !_.isUndefined( Backbone.Courier ); }, _setupSortable : function() { var sortableOptions = _.extend( { axis : "y", distance : 10, forcePlaceholderSize : true, items : this._isRenderedAsTable() ? "> tbody > tr:not(.not-sortable)" : "> li:not(.not-sortable)", start : _.bind( this._sortStart, this ), change : _.bind( this._sortChange, this ), stop : _.bind( this._sortStop, this ), receive : _.bind( this._receive, this ), over : _.bind( this._over, this ) }, _.result( this, "sortableOptions" ) ); this.$el = this.$el.sortable( sortableOptions ); //this.$el.sortable( "enable" ); // in case it was disabled previously }, _sortStart : function( event, ui ) { var modelBeingSorted = this.collection.get( ui.item.attr( "data-model-cid" ) ); if( this._isBackboneCourierAvailable() ) this.spawn( "sortStart", { modelBeingSorted : modelBeingSorted } ); else this.trigger( "sortStart", modelBeingSorted ); }, _sortChange : function( event, ui ) { var modelBeingSorted = this.collection.get( ui.item.attr( "data-model-cid" ) ); if( this._isBackboneCourierAvailable() ) this.spawn( "sortChange", { modelBeingSorted : modelBeingSorted } ); else this.trigger( "sortChange", modelBeingSorted ); }, _sortStop : function( event, ui ) { var modelBeingSorted = this.collection.get( ui.item.attr( "data-model-cid" ) ); var modelViewContainerEl = this._getContainerEl(); var newIndex = modelViewContainerEl.children().index( ui.item ); if( newIndex == -1 && modelBeingSorted ) { // the element was removed from this list. can happen if this sortable is connected // to another sortable, and the item was dropped into the other sortable. this.collection.remove( modelBeingSorted ); } if( ! modelBeingSorted ) return; // something is wacky. we don't mess with this case, preferring to guarantee that we can always provide a reference to the model this._reorderCollectionBasedOnHTML(); this.updateDependentControls(); if( this._isBackboneCourierAvailable() ) this.spawn( "sortStop", { modelBeingSorted : modelBeingSorted, newIndex : newIndex } ); else this.trigger( "sortStop", modelBeingSorted, newIndex ); }, _receive : function( event, ui ) { var senderListEl = ui.sender; var senderCollectionListView = senderListEl.data( "view" ); if( ! senderCollectionListView || ! senderCollectionListView.collection ) return; var newIndex = this._getContainerEl().children().index( ui.item ); var modelReceived = senderCollectionListView.collection.get( ui.item.attr( "data-model-cid" ) ); senderCollectionListView.collection.remove( modelReceived ); this.collection.add( modelReceived, { at : newIndex } ); modelReceived.collection = this.collection; // otherwise will not get properly set, since modelReceived.collection might already have a value. this.setSelectedModel( modelReceived ); }, _over : function( event, ui ) { // when an item is being dragged into the sortable, // hide the empty list caption if it exists this._getContainerEl().find( "> var.empty-list-caption" ).hide(); }, _onKeydown : function( event ) { if( ! this.processKeyEvents ) return true; var trap = false; if( this.getSelectedModels( { by : "offset" } ).length == 1 ) { // need to trap down and up arrows or else the browser // will end up scrolling a autoscroll div. var currentOffset = this.getSelectedModel( { by : "offset" } ); if( event.which === this._charCodes.upArrow && currentOffset !== 0 ) { this.setSelectedModel( currentOffset - 1, { by : "offset" } ); trap = true; } else if( event.which === this._charCodes.downArrow && currentOffset !== this.collection.length - 1 ) { this.setSelectedModel( currentOffset + 1, { by : "offset" } ); trap = true; } } return ! trap; }, _listItem_onMousedown : function( theEvent ) { var clickedItemId = this._getClickedItemId( theEvent ); if( clickedItemId ) { var clickedModel = this.collection.get( clickedItemId ); if( this._isBackboneCourierAvailable() ) { var data = { clickedModel : clickedModel, metaKeyPressed : theEvent.ctrlKey || theEvent.metaKey }; _.each( [ 'preventDefault', 'stopPropagation', 'stopImmediatePropagation' ], function( thisMethod ) { data[ thisMethod ] = function() { theEvent[ thisMethod ](); }; } ); this.spawn( "click", data ); } else this.trigger( "click", clickedModel ); } if( ! this.selectable || ! this.clickToSelect ) return; if( clickedItemId ) { // Exit if an unselectable item was clicked if( _.isFunction( this.selectableModelsFilter ) && ! this.selectableModelsFilter.call( this, this.collection.get( clickedItemId ) ) ) { return; } // a selectable list item was clicked if( this.selectMultiple && theEvent.shiftKey ) { var firstSelectedItemIndex = -1; if( this.selectedItems.length > 0 ) { this.collection.find( function( thisItemModel ) { firstSelectedItemIndex++; // exit when we find our first selected element return _.contains( this.selectedItems, thisItemModel.cid ); }, this ); } var clickedItemIndex = -1; this.collection.find( function( thisItemModel ) { clickedItemIndex++; // exit when we find the clicked element return thisItemModel.cid == clickedItemId; }, this ); var shiftKeyRootSelectedItemIndex = firstSelectedItemIndex == -1 ? clickedItemIndex : firstSelectedItemIndex; var minSelectedItemIndex = Math.min( clickedItemIndex, shiftKeyRootSelectedItemIndex ); var maxSelectedItemIndex = Math.max( clickedItemIndex, shiftKeyRootSelectedItemIndex ); var newSelectedItems = []; for( var thisIndex = minSelectedItemIndex; thisIndex <= maxSelectedItemIndex; thisIndex ++ ) newSelectedItems.push( this.collection.at( thisIndex ).cid ); this.setSelectedModels( newSelectedItems, { by : "cid" } ); // shift clicking will usually highlight selectable text, which we do not want. // this is a cross browser (hopefully) snippet that deselects all text selection. if( document.selection && document.selection.empty ) document.selection.empty(); else if(window.getSelection) { var sel = window.getSelection(); if( sel && sel.removeAllRanges ) sel.removeAllRanges(); } } else if( ( this.selectMultiple || _.contains( this.selectedItems, clickedItemId ) ) && ( this.clickToToggle || theEvent.metaKey || theEvent.ctrlKey ) ) { if( _.contains( this.selectedItems, clickedItemId ) ) this.setSelectedModels( _.without( this.selectedItems, clickedItemId ), { by : "cid" } ); else this.setSelectedModels( _.union( this.selectedItems, [clickedItemId] ), { by : "cid" } ); } else this.setSelectedModels( [ clickedItemId ], { by : "cid" } ); } else // the blank area of the list was clicked this.setSelectedModels( [] ); }, _listItem_onDoubleClick : function( theEvent ) { var clickedItemId = this._getClickedItemId( theEvent ); if( clickedItemId ) { var clickedModel = this.collection.get( clickedItemId ); if( this._isBackboneCourierAvailable() ) this.spawn( "doubleClick", { clickedModel : clickedModel, metaKeyPressed : theEvent.ctrlKey || theEvent.metaKey } ); else this.trigger( "doubleClick", clickedModel ); } }, _listBackground_onClick : function( theEvent ) { if( ! this.selectable || ! this.clickToSelect ) return; if( ! $( theEvent.target ).is( ".collection-view" ) ) return; this.setSelectedModels( [] ); } }, { setDefaultModelViewConstructor : function( theConstructor ) { mDefaultModelViewConstructor = theConstructor; } }); /* * Backbone.ViewOptions, v0.2.4 * Copyright (c)2014 Rotunda Software, LLC. * Distributed under MIT license * http://github.com/rotundasoftware/backbone.viewOptions */ Backbone.ViewOptions = {}; Backbone.ViewOptions.add = function( view, optionsDeclarationsProperty ) { if( _.isUndefined( optionsDeclarationsProperty ) ) optionsDeclarationsProperty = "options"; // ****************** Public methods added to view ****************** view.setOptions = function( options ) { var _this = this; var optionsThatWereChanged = {}; var optionsThatWereChangedPreviousValues = {}; var optionDeclarations = _.result( this, optionsDeclarationsProperty ); if( ! _.isUndefined( optionDeclarations ) ) { var normalizedOptionDeclarations = _normalizeOptionDeclarations( optionDeclarations ); _.each( normalizedOptionDeclarations, function( thisOptionProperties, thisOptionName ) { var thisOptionRequired = thisOptionProperties.required; var thisOptionDefaultValue = thisOptionProperties.defaultValue; if( thisOptionRequired ) { // note we do not throw an error if a required option is not supplied, but it is // found on the object itself (due to a prior call of view.setOptions, most likely) if( ( ! options || ! _.contains( _.keys( options ), thisOptionName ) ) && _.isUndefined( _this[ thisOptionName ] ) ) throw new Error( "Required option \"" + thisOptionName + "\" was not supplied." ); if( options && _.contains( _.keys( options ), thisOptionName ) && _.isUndefined( options[ thisOptionName ] ) ) throw new Error( "Required option \"" + thisOptionName + "\" can not be set to undefined." ); } // attach the supplied value of this option, or the appropriate default value, to the view object if( options && thisOptionName in options && ! _.isUndefined( options[ thisOptionName ] ) ) { var oldValue = _this[ thisOptionName ]; var newValue = options[ thisOptionName ]; // if this option already exists on the view, and the new value is different, // make a note that we will be changing it if( ! _.isUndefined( oldValue ) && oldValue !== newValue ) { optionsThatWereChangedPreviousValues[ thisOptionName ] = oldValue; optionsThatWereChanged[ thisOptionName ] = newValue; } _this[ thisOptionName ] = newValue; // note we do NOT delete the option off the options object here so that // multiple views can be passed the same options object without issue. } else if( _.isUndefined( _this[ thisOptionName ] ) ) { // note defaults do not write over any existing properties on the view itself. _this[ thisOptionName ] = thisOptionDefaultValue; } } ); } if( _.keys( optionsThatWereChanged ).length > 0 ) { if( _.isFunction( _this.onOptionsChanged ) ) _this.onOptionsChanged( optionsThatWereChanged, optionsThatWereChangedPreviousValues ); else if( _.isFunction( _this._onOptionsChanged ) ) _this._onOptionsChanged( optionsThatWereChanged, optionsThatWereChangedPreviousValues ); } }; view.getOptions = function() { var optionDeclarations = _.result( this, optionsDeclarationsProperty ); if( _.isUndefined( optionDeclarations ) ) return {}; var normalizedOptionDeclarations = _normalizeOptionDeclarations( optionDeclarations ); var optionsNames = _.keys( normalizedOptionDeclarations ); return _.pick( this, optionsNames ); }; }; // ****************** Private Utility Functions ****************** function _normalizeOptionDeclarations( optionDeclarations ) { // convert our short-hand option syntax (with exclamation marks, etc.) // to a simple array of standard option declaration objects. var normalizedOptionDeclarations = {}; if( ! _.isArray( optionDeclarations ) ) throw new Error( "Option declarations must be an array." ); _.each( optionDeclarations, function( thisOptionDeclaration ) { var thisOptionName, thisOptionRequired, thisOptionDefaultValue; thisOptionRequired = false; thisOptionDefaultValue = undefined; if( _.isString( thisOptionDeclaration ) ) thisOptionName = thisOptionDeclaration; else if( _.isObject( thisOptionDeclaration ) ) { thisOptionName = _.first( _.keys( thisOptionDeclaration ) ); if( _.isFunction( thisOptionDeclaration[ thisOptionName ] ) ) thisOptionDefaultValue = thisOptionDeclaration[ thisOptionName ]; else thisOptionDefaultValue = _.clone( thisOptionDeclaration[ thisOptionName ] ); } else throw new Error( "Each element in the option declarations array must be either a string or an object." ); if( thisOptionName[ thisOptionName.length - 1 ] === "!" ) { thisOptionRequired = true; thisOptionName = thisOptionName.slice( 0, thisOptionName.length - 1 ); } normalizedOptionDeclarations[ thisOptionName ] = normalizedOptionDeclarations[ thisOptionName ] || {}; normalizedOptionDeclarations[ thisOptionName ].required = thisOptionRequired; if( ! _.isUndefined( thisOptionDefaultValue ) ) normalizedOptionDeclarations[ thisOptionName ].defaultValue = thisOptionDefaultValue; } ); return normalizedOptionDeclarations; } // Backbone.BabySitter // ------------------- // v0.0.6 // // Copyright (c)2013 Derick Bailey, Muted Solutions, LLC. // Distributed under MIT license // // http://github.com/babysitterjs/backbone.babysitter // Backbone.ChildViewContainer // --------------------------- // // Provide a container to store, retrieve and // shut down child views. ChildViewContainer = (function(Backbone, _){ // Container Constructor // --------------------- var Container = function(views){ this._views = {}; this._indexByModel = {}; this._indexByCustom = {}; this._updateLength(); _.each(views, this.add, this); }; // Container Methods // ----------------- _.extend(Container.prototype, { // Add a view to this container. Stores the view // by `cid` and makes it searchable by the model // cid (and model itself). Optionally specify // a custom key to store an retrieve the view. add: function(view, customIndex){ var viewCid = view.cid; // store the view this._views[viewCid] = view; // index it by model if (view.model){ this._indexByModel[view.model.cid] = viewCid; } // index by custom if (customIndex){ this._indexByCustom[customIndex] = viewCid; } this._updateLength(); }, // Find a view by the model that was attached to // it. Uses the model's `cid` to find it. findByModel: function(model){ return this.findByModelCid(model.cid); }, // Find a view by the `cid` of the model that was attached to // it. Uses the model's `cid` to find the view `cid` and // retrieve the view using it. findByModelCid: function(modelCid){ var viewCid = this._indexByModel[modelCid]; return this.findByCid(viewCid); }, // Find a view by a custom indexer. findByCustom: function(index){ var viewCid = this._indexByCustom[index]; return this.findByCid(viewCid); }, // Find by index. This is not guaranteed to be a // stable index. findByIndex: function(index){ return _.values(this._views)[index]; }, // retrieve a view by it's `cid` directly findByCid: function(cid){ return this._views[cid]; }, findIndexByCid : function( cid ) { var index = -1; var view = _.find( this._views, function ( view ) { index++; if( view.model.cid == cid ) return view; } ); return ( view ) ? index : -1; }, // Remove a view remove: function(view){ var viewCid = view.cid; // delete model index if (view.model){ delete this._indexByModel[view.model.cid]; } // delete custom index _.any(this._indexByCustom, function(cid, key) { if (cid === viewCid) { delete this._indexByCustom[key]; return true; } }, this); // remove the view from the container delete this._views[viewCid]; // update the length this._updateLength(); }, // Call a method on every view in the container, // passing parameters to the call method one at a // time, like `function.call`. call: function(method){ this.apply(method, _.tail(arguments)); }, // Apply a method on every view in the container, // passing parameters to the call method one at a // time, like `function.apply`. apply: function(method, args){ _.each(this._views, function(view){ if (_.isFunction(view[method])){ view[method].apply(view, args || []); } }); }, // Update the `.length` attribute on this container _updateLength: function(){ this.length = _.size(this._views); } }); // Borrowing this code from Backbone.Collection: // http://backbonejs.org/docs/backbone.html#section-106 // // Mix in methods from Underscore, for iteration, and other // collection related features. var methods = ['forEach', 'each', 'map', 'find', 'detect', 'filter', 'select', 'reject', 'every', 'all', 'some', 'any', 'include', 'contains', 'invoke', 'toArray', 'first', 'initial', 'rest', 'last', 'without', 'isEmpty', 'pluck']; _.each(methods, function(method) { Container.prototype[method] = function() { var views = _.values(this._views); var args = [views].concat(_.toArray(arguments)); return _[method].apply(_, args); }; }); // return the public API return Container; })(Backbone, _); return Backbone.CollectionView; } ) );