Files
galaxy/static/scripts/mvc/base-mvc.js
T

582 lines
23 KiB
JavaScript

define([
'utils/add-logging',
'utils/localization'
], function( addLogging, _l ){
//ASSUMES: backbone
//==============================================================================
/** @class Mixin to add logging capabilities to an object.
* Designed to allow switching an objects log output off/on at one central
* statement. Can be used with plain browser console (or something more
* complex like an AJAX logger).
* <br />NOTE: currently only uses the console.debug log function
* (as opposed to debug, error, warn, etc.)
* @name LoggableMixin
*
* @example
* // Add to your models/views at the definition using chaining:
* var MyModel = Backbone.Model.extend( LoggableMixin ).extend({ // ... });
*
* // or - more explicitly AFTER the definition:
* var MyModel = Backbone.Model.extend({
* logger : console
* // ...
* this.log( '$#%& it! - broken already...' );
* })
* _.extend( MyModel.prototype, LoggableMixin )
*
*/
var LoggableMixin = /** @lends LoggableMixin# */{
/** The logging object whose log function will be used to output
* messages. Null will supress all logging. Commonly set to console.
*/
// replace null with console (if available) to see all logs
logger : null,
_logNamespace : '?',
/** Output log messages/arguments to logger.
* @param {Arguments} ... (this function is variadic)
* @returns undefined if not this.logger
*/
log : function(){
if( this.logger ){
var log = this.logger.log;
if( typeof this.logger.log === 'object' ){
//TODO:! there has to be a way to get the lineno/file into this
log = Function.prototype.bind.call( this.logger.log, this.logger );
}
return log.apply( this.logger, arguments );
}
return undefined;
}
};
addLogging( LoggableMixin );
//==============================================================================
/** Backbone model that syncs to the browser's sessionStorage API.
* This all largely happens behind the scenes and no special calls are required.
*/
var SessionStorageModel = Backbone.Model.extend({
initialize : function( initialAttrs ){
// check for sessionStorage and error if no id is provided
this._checkEnabledSessionStorage();
if( !initialAttrs.id ){
throw new Error( 'SessionStorageModel requires an id in the initial attributes' );
}
this.id = initialAttrs.id;
// load existing from storage (if any), clear any attrs set by bbone before init is called,
// layer initial over existing and defaults, and save
var existing = ( !this.isNew() )?( this._read( this ) ):( {} );
this.clear({ silent: true });
this.save( _.extend( {}, this.defaults, existing, initialAttrs ), { silent: true });
// save on any change to it immediately
this.on( 'change', function(){
this.save();
});
},
_checkEnabledSessionStorage : function(){
try {
return sessionStorage.length;
} catch( err ){
alert( 'Please enable cookies in your browser for this Galaxy site' );
return false;
}
},
/** override of bbone sync to save to sessionStorage rather than REST
* bbone options (success, errror, etc.) should still apply
*/
sync : function( method, model, options ){
if( !options.silent ){
model.trigger( 'request', model, {}, options );
}
var returned = {};
switch( method ){
case 'create' : returned = this._create( model ); break;
case 'read' : returned = this._read( model ); break;
case 'update' : returned = this._update( model ); break;
case 'delete' : returned = this._delete( model ); break;
}
if( returned !== undefined || returned !== null ){
if( options.success ){ options.success(); }
} else {
if( options.error ){ options.error(); }
}
return returned;
},
/** set storage to the stringified item */
_create : function( model ){
try {
var json = model.toJSON(),
set = sessionStorage.setItem( model.id, JSON.stringify( json ) );
return ( set === null )?( set ):( json );
// DOMException is thrown in Safari if in private browsing mode and sessionStorage is attempted:
// http://stackoverflow.com/questions/14555347
// TODO: this could probably use a more general soln - like detecting priv. mode + safari => non-ajaxing Model
} catch( err ){
if( !( ( err instanceof DOMException ) && ( navigator.userAgent.indexOf("Safari") > -1 ) ) ){
throw err;
}
}
return null;
},
/** read and parse json from storage */
_read : function( model ){
return JSON.parse( sessionStorage.getItem( model.id ) );
},
/** set storage to the item (alias to create) */
_update : function( model ){
return model._create( model );
},
/** remove the item from storage */
_delete : function( model ){
return sessionStorage.removeItem( model.id );
},
/** T/F whether sessionStorage contains the model's id (data is present) */
isNew : function(){
return !sessionStorage.hasOwnProperty( this.id );
},
_log : function(){
return JSON.stringify( this.toJSON(), null, ' ' );
},
toString : function(){
return 'SessionStorageModel(' + this.id + ')';
}
});
(function(){
SessionStorageModel.prototype = _.omit( SessionStorageModel.prototype, 'url', 'urlRoot' );
}());
//==============================================================================
/** Function that allows mixing of hashs into bbone MVC while showing the mixins first
* (before the more local class overrides/hash).
* Basically, a simple reversal of param order on _.defaults() - to show mixins in top of definition.
* @example:
* var NewModel = Something.extend( mixin( MyMixinA, MyMixinB, { ... myVars : ... }) );
*
* NOTE: this does not combine any hashes (like events, etc.) and you're expected to handle that
*/
function mixin( mixinHash1, /* mixinHash2, etc: ... variadic */ propsHash ){
var args = Array.prototype.slice.call( arguments, 0 ),
lastArg = args.pop();
args.unshift( lastArg );
return _.defaults.apply( _, args );
}
//==============================================================================
/** A mixin for models that allow T/F/Matching to their attributes - useful when
* searching or filtering collections of models.
* @example:
* see hda-model for searchAttribute and searchAliases definition examples.
* see history-contents.matches for how collections are filtered
* and see readonly-history-panel.searchHdas for how user input is connected to the filtering
*/
var SearchableModelMixin = {
/** what attributes of an HDA will be used in a text search */
searchAttributes : [
// override
],
/** our attr keys don't often match the labels we display to the user - so, when using
* attribute specifiers ('name="bler"') in a term, allow passing in aliases for the
* following attr keys.
*/
searchAliases : {
// override
},
/** search the attribute with key attrKey for the string searchFor; T/F if found */
searchAttribute : function( attrKey, searchFor ){
var attrVal = this.get( attrKey );
//this.debug( 'searchAttribute', attrKey, attrVal, searchFor );
// bail if empty searchFor or unsearchable values
if( !searchFor
|| ( attrVal === undefined || attrVal === null ) ){
return false;
}
// pass to sep. fn for deep search of array attributes
if( _.isArray( attrVal ) ){ return this._searchArrayAttribute( attrVal, searchFor ); }
return ( attrVal.toString().toLowerCase().indexOf( searchFor.toLowerCase() ) !== -1 );
},
/** deep(er) search for array attributes; T/F if found */
_searchArrayAttribute : function( array, searchFor ){
//this.debug( '_searchArrayAttribute', array, searchFor );
searchFor = searchFor.toLowerCase();
//precondition: searchFor has already been validated as non-empty string
//precondition: assumes only 1 level array
//TODO: could possibly break up searchFor more (CSV...)
return _.any( array, function( elem ){
return ( elem.toString().toLowerCase().indexOf( searchFor.toLowerCase() ) !== -1 );
});
},
/** search all searchAttributes for the string searchFor,
* returning a list of keys of attributes that contain searchFor
*/
search : function( searchFor ){
var model = this;
return _.filter( this.searchAttributes, function( key ){
return model.searchAttribute( key, searchFor );
});
},
/** alias of search, but returns a boolean; accepts attribute specifiers where
* the attributes searched can be narrowed to a single attribute using
* the form: matches( 'genome_build=hg19' )
* (the attribute keys allowed can also be aliases to the true attribute key;
* see searchAliases above)
* @param {String} term plain text or ATTR_SPECIFIER sep. key=val pair
* @returns {Boolean} was term found in (any) attribute(s)
*/
matches : function( term ){
var ATTR_SPECIFIER = '=',
split = term.split( ATTR_SPECIFIER );
// attribute is specified - search only that
if( split.length >= 2 ){
var attrKey = split[0];
attrKey = this.searchAliases[ attrKey ] || attrKey;
return this.searchAttribute( attrKey, split[1] );
}
// no attribute is specified - search all attributes in searchAttributes
return !!this.search( term ).length;
},
/** an implicit AND search for all terms; IOW, a model must match all terms given
* where terms is a whitespace separated value string.
* e.g. given terms of: 'blah bler database=hg19'
* an HDA would have to have attributes containing blah AND bler AND a genome_build == hg19
* To include whitespace in terms: wrap the term in double quotations (name="blah bler").
*/
matchesAll : function( terms ){
var model = this;
// break the terms up by whitespace and filter out the empty strings
terms = terms.match( /(".*"|\w*=".*"|\S*)/g ).filter( function( s ){ return !!s; });
return _.all( terms, function( term ){
term = term.replace( /"/g, '' );
return model.matches( term );
});
}
};
//==============================================================================
/** A view that renders hidden and shows when some activator is clicked.
* options:
* showFn: the effect used to show/hide the View (defaults to jq.toggle)
* $elementShown: some jqObject (defaults to this.$el) to be shown/hidden
* onShowFirstTime: fn called the first time the view is shown
* onshow: fn called every time the view is shown
* onhide: fn called every time the view is hidden
* events:
* hiddenUntilActivated:shown (the view is passed as an arg)
* hiddenUntilActivated:hidden (the view is passed as an arg)
* instance vars:
* view.hidden {boolean} is the view in the hidden state
*/
var HiddenUntilActivatedViewMixin = /** @lends hiddenUntilActivatedMixin# */{
//TODO: since this is a mixin, consider moving toggle, hidden into HUAVOptions
/** call this in your initialize to set up the mixin
* @param {jQuery} $activator the 'button' that's clicked to show/hide the view
* @param {Object} hash with mixin options
*/
hiddenUntilActivated : function( $activator, options ){
// call this in your view's initialize fn
options = options || {};
//TODO: flesh out options - show them all here
this.HUAVOptions = {
$elementShown : this.$el,
showFn : jQuery.prototype.toggle,
showSpeed : 'fast'
};
_.extend( this.HUAVOptions, options || {});
/** has this been shown already (and onshowFirstTime called)? */
this.HUAVOptions.hasBeenShown = this.HUAVOptions.$elementShown.is( ':visible' );
this.hidden = this.isHidden();
if( $activator ){
var mixin = this;
$activator.on( 'click', function( ev ){
mixin.toggle( mixin.HUAVOptions.showSpeed );
});
}
},
//TODO:?? remove? use .hidden?
/** returns T/F if the view is hidden */
isHidden : function(){
return ( this.HUAVOptions.$elementShown.is( ':hidden' ) );
},
/** toggle the hidden state, show/hide $elementShown, call onshow/hide, trigger events */
toggle : function(){
//TODO: more specific name - toggle is too general
// can be called manually as well with normal toggle arguments
//TODO: better as a callback (when the show/hide is actually done)
// show
if( this.hidden ){
// fire the optional fns on the first/each showing - good for render()
if( !this.HUAVOptions.hasBeenShown ){
if( _.isFunction( this.HUAVOptions.onshowFirstTime ) ){
this.HUAVOptions.hasBeenShown = true;
this.HUAVOptions.onshowFirstTime.call( this );
}
}
if( _.isFunction( this.HUAVOptions.onshow ) ){
this.HUAVOptions.onshow.call( this );
this.trigger( 'hiddenUntilActivated:shown', this );
}
this.hidden = false;
// hide
} else {
if( _.isFunction( this.HUAVOptions.onhide ) ){
this.HUAVOptions.onhide.call( this );
this.trigger( 'hiddenUntilActivated:hidden', this );
}
this.hidden = true;
}
return this.HUAVOptions.showFn.apply( this.HUAVOptions.$elementShown, arguments );
}
};
//==============================================================================
/** Mixin for views that can be dragged and dropped
* Allows for the drag behavior to be turned on/off, setting/removing jQuery event
* handlers each time.
* dataTransfer data is set to the JSON string of the view's model.toJSON
* Override '$dragHandle' to define the draggable DOM sub-element.
*/
var DraggableViewMixin = {
/** set up instance vars to track whether this view is currently draggable */
initialize : function( attributes ){
/** is the body of this hda view expanded/not? */
this.draggable = attributes.draggable || false;
},
/** what part of the view's DOM triggers the dragging */
$dragHandle : function(){
//TODO: make abstract/general - move this to listItem
// override to the element you want to be your view's handle
return this.$( '.title-bar' );
},
/** toggle whether this view is draggable */
toggleDraggable : function(){
if( this.draggable ){
this.draggableOff();
} else {
this.draggableOn();
}
},
/** allow the view to be dragged, set up event handlers */
draggableOn : function(){
this.draggable = true;
this.dragStartHandler = _.bind( this._dragStartHandler, this );
this.dragEndHandler = _.bind( this._dragEndHandler, this );
var handle = this.$dragHandle().attr( 'draggable', true ).get(0);
handle.addEventListener( 'dragstart', this.dragStartHandler, false );
handle.addEventListener( 'dragend', this.dragEndHandler, false );
},
/** turn of view dragging and remove event listeners */
draggableOff : function(){
this.draggable = false;
var handle = this.$dragHandle().attr( 'draggable', false ).get(0);
handle.removeEventListener( 'dragstart', this.dragStartHandler, false );
handle.removeEventListener( 'dragend', this.dragEndHandler, false );
},
/** sets the dataTransfer data to the model's toJSON
* @fires draggable:dragstart (bbone event) which is passed the event and this view
*/
_dragStartHandler : function( event ){
event.dataTransfer.effectAllowed = 'move';
//ASSUMES: this.model
//TODO: all except IE: should be 'application/json', IE: must be 'text'
event.dataTransfer.setData( 'text', JSON.stringify( this.model.toJSON() ) );
this.trigger( 'draggable:dragstart', event, this );
return false;
},
/** handle the dragend
* @fires draggable:dragend (bbone event) which is passed the event and this view
*/
_dragEndHandler : function( event ){
this.trigger( 'draggable:dragend', event, this );
return false;
}
};
//==============================================================================
/** Mixin that allows a view to be selected (gen. from a list).
* Selection controls ($selector) may be hidden/shown/toggled.
* The bbone event 'selectable' is fired when the controls are shown/hidden (passed T/F).
* Default rendering is a font-awesome checkbox.
* Default selector is '.selector' within the view's $el.
* The bbone events 'selected' and 'de-selected' are fired when the $selector is clicked.
* Both events are passed the view and the (jQuery) event.
*/
var SelectableViewMixin = {
/** Set up instance state vars for whether the selector is shown and whether the view has been selected */
initialize : function( attributes ){
/** is the view currently in selection mode? */
this.selectable = attributes.selectable || false;
/** is the view currently selected? */
this.selected = attributes.selected || false;
},
/** $el sub-element where the selector is rendered and what can be clicked to select. */
$selector : function(){
return this.$( '.selector' );
},
/** How the selector is rendered - defaults to font-awesome checkbox */
_renderSelected : function(){
// override
this.$selector().find( 'span' )
.toggleClass( 'fa-check-square-o', this.selected ).toggleClass( 'fa-square-o', !this.selected );
},
/** Toggle whether the selector is shown */
toggleSelector : function(){
//TODO: use this.selectable
if( !this.$selector().is( ':visible' ) ){
this.showSelector();
} else {
this.hideSelector();
}
},
/** Display the selector control.
* @param {Number} a jQuery fx speed
* @fires: selectable which is passed true (IOW, the selector is shown) and the view
*/
showSelector : function( speed ){
speed = speed !== undefined? speed : this.fxSpeed;
// make sure selected state is represented properly
this.selectable = true;
this.trigger( 'selectable', true, this );
this._renderSelected();
this.$selector().show( speed );
},
/** remove the selector control
* @param {Number} a jQuery fx speed
* @fires: selectable which is passed false (IOW, the selector is not shown) and the view
*/
hideSelector : function( speed ){
speed = speed !== undefined? speed : this.fxSpeed;
// reverse the process from showSelect
this.selectable = false;
this.trigger( 'selectable', false, this );
this.$selector().hide( speed );
},
/** Toggle whether the view is selected */
toggleSelect : function( event ){
if( this.selected ){
this.deselect( event );
} else {
this.select( event );
}
},
/** Select this view and re-render the selector control to show it
* @param {Event} a jQuery event that caused the selection
* @fires: selected which is passed the view and the DOM event that triggered it (optionally)
*/
select : function( event ){
// switch icon, set selected, and trigger event
if( !this.selected ){
this.trigger( 'selected', this, event );
this.selected = true;
this._renderSelected();
}
return false;
},
/** De-select this view and re-render the selector control to show it
* @param {Event} a jQuery event that caused the selection
* @fires: de-selected which is passed the view and the DOM event that triggered it (optionally)
*/
deselect : function( event ){
// switch icon, set selected, and trigger event
if( this.selected ){
this.trigger( 'de-selected', this, event );
this.selected = false;
this._renderSelected();
}
return false;
}
};
//==============================================================================
/** Return an underscore template fn from an array of strings.
* @param {String[]} template the template strings to compile into the underscore template fn
* @param {String} jsonNamespace an optional namespace for the json data passed in (defaults to 'model')
* @returns {Function} the (wrapped) underscore template fn
* The function accepts:
*
* The template strings can access:
* the json/model hash using model ("<%- model.myAttr %>) using the jsonNamespace above
* _l: the localizer function
* view (if passed): ostensibly, the view using the template (handy for view instance vars)
* Because they're namespaced, undefined attributes will not throw an error.
*
* @example:
* templateBler : BASE_MVC.wrapTemplate([
* '<div class="myclass <%- mynamespace.modelClass %>">',
* '<span><% print( _l( mynamespace.message ) ); %>:<%= view.status %></span>'
* '</div>'
* ], 'mynamespace' )
*
* Meant to be called in a View's definition in order to compile only once.
*
*/
function wrapTemplate( template, jsonNamespace ){
jsonNamespace = jsonNamespace || 'model';
var templateFn = _.template( template.join( '' ) );
return function( json, view ){
var templateVars = { view : view || {}, _l : _l };
templateVars[ jsonNamespace ] = json || {};
return templateFn( templateVars );
};
}
//==============================================================================
return {
LoggableMixin : LoggableMixin,
SessionStorageModel : SessionStorageModel,
mixin : mixin,
SearchableModelMixin : SearchableModelMixin,
HiddenUntilActivatedViewMixin : HiddenUntilActivatedViewMixin,
DraggableViewMixin : DraggableViewMixin,
SelectableViewMixin : SelectableViewMixin,
wrapTemplate : wrapTemplate
};
});