Files
galaxy/static/scripts/mvc/history/history-panel.js
T

676 lines
27 KiB
JavaScript

define([
"mvc/list/list-panel",
"mvc/history/history-model",
"mvc/history/history-contents",
"mvc/history/hda-li",
"mvc/history/hdca-li",
"mvc/collection/collection-panel",
"mvc/user/user-model",
"ui/fa-icon-button",
"mvc/ui/popup-menu",
"mvc/base-mvc",
"utils/localization",
"ui/search-input"
], function(
LIST_PANEL,
HISTORY_MODEL,
HISTORY_CONTENTS,
HDA_LI,
HDCA_LI,
COLLECTION_PANEL,
USER,
faIconButton,
PopupMenu,
BASE_MVC,
_l
){
// ============================================================================
/** session storage for individual history preferences */
var HistoryPrefs = BASE_MVC.SessionStorageModel.extend(
/** @lends HistoryPrefs.prototype */{
//TODO:?? possibly mark as current T/F - have History.currId() (a class method) return that value
defaults : {
//TODO:?? expandedIds to array?
expandedIds : {},
//TODO:?? move to user?
show_deleted : false,
show_hidden : false
//TODO: add scroll position?
},
/** add an hda id to the hash of expanded hdas */
addExpanded : function( model ){
var key = 'expandedIds';
//TODO:?? is this right anymore?
this.save( key, _.extend( this.get( key ), _.object([ model.id ], [ model.get( 'id' ) ]) ) );
},
/** remove an hda id from the hash of expanded hdas */
removeExpanded : function( model ){
var key = 'expandedIds';
this.save( key, _.omit( this.get( key ), model.id ) );
},
toString : function(){
return 'HistoryPrefs(' + this.id + ')';
}
});
// class lvl for access w/o instantiation
HistoryPrefs.storageKeyPrefix = 'history:';
/** key string to store each histories settings under */
HistoryPrefs.historyStorageKey = function historyStorageKey( historyId ){
if( !historyId ){
throw new Error( 'HistoryPrefs.historyStorageKey needs valid id: ' + historyId );
}
// single point of change
return ( HistoryPrefs.storageKeyPrefix + historyId );
};
/** return the existing storage for the history with the given id (or create one if it doesn't exist) */
HistoryPrefs.get = function get( historyId ){
return new HistoryPrefs({ id: HistoryPrefs.historyStorageKey( historyId ) });
};
/** clear all history related items in sessionStorage */
HistoryPrefs.clearAll = function clearAll( historyId ){
for( var key in sessionStorage ){
if( key.indexOf( HistoryPrefs.storageKeyPrefix ) === 0 ){
sessionStorage.removeItem( key );
}
}
};
/* =============================================================================
TODO:
============================================================================= */
/** @class non-editable, read-only View/Controller for a history model.
* Allows:
* changing the loaded history
* displaying data, info, and download
* tracking history attrs: size, tags, annotations, name, etc.
* Does not allow:
* changing the name
*/
var _super = LIST_PANEL.ModelListPanel;
var HistoryPanel = _super.extend(
/** @lends HistoryPanel.prototype */{
/** logger used to record this.log messages, commonly set to console */
//logger : console,
/** class to use for constructing the HDA views */
HDAViewClass : HDA_LI.HDAListItemView,
/** class to use for constructing the HDCA views */
HDCAViewClass : HDCA_LI.HDCAListItemView,
/** class to used for constructing collection of sub-view models */
collectionClass : HISTORY_CONTENTS.HistoryContents,
/** key of attribute in model to assign to this.collection */
modelCollectionKey : 'contents',
tagName : 'div',
className : _super.prototype.className + ' history-panel',
/** string to display when the collection is empty */
emptyMsg : _l( 'This history is empty' ),
/** displayed when no items match the search terms */
noneFoundMsg : _l( 'No matching datasets found' ),
/** string used for search placeholder */
searchPlaceholder : _l( 'search datasets' ),
// ......................................................................... SET UP
/** Set up the view, bind listeners.
* @param {Object} attributes optional settings for the panel
*/
initialize : function( attributes ){
_super.prototype.initialize.call( this, attributes );
// ---- instance vars
// control contents/behavior based on where (and in what context) the panel is being used
/** where should pages from links be displayed? (default to new tab/window) */
this.linkTarget = attributes.linkTarget || '_blank';
},
/** In this override, clear the update timer on the model */
freeModel : function(){
_super.prototype.freeModel.call( this );
//TODO: move to History.free()
if( this.model ){
this.model.clearUpdateTimeout();
}
return this;
},
/** create any event listeners for the panel
* @fires: rendered:initial on the first render
* @fires: empty-history when switching to a history with no contents or creating a new history
*/
_setUpListeners : function(){
_super.prototype._setUpListeners.call( this );
this.on( 'error', function( model, xhr, options, msg, details ){
this.errorHandler( model, xhr, options, msg, details );
});
this.on( 'loading-done', function(){
//TODO:?? if( this.collection.length ){
if( !this.views.length ){
this.trigger( 'empty-history', this );
}
});
},
// ------------------------------------------------------------------------ loading history/hda models
//NOTE: all the following fns replace the existing history model with a new model
// (in the following 'details' refers to the full set of contents api data (urls, display_apps, misc_info, etc.)
// - contents w/o details will have summary data only (name, hid, deleted, visible, state, etc.))
//TODO: too tangled...
/** loads a history & contents, getting details of any contents whose ids are stored in sessionStorage
* (but does not make them the current history)
*/
loadHistoryWithDetails : function( historyId, attributes, historyFn, contentsFn ){
this.info( 'loadHistoryWithDetails:', historyId, attributes, historyFn, contentsFn );
var detailIdsFn = function( historyData ){
// will be called to get content ids that need details from the api
//TODO:! non-visible contents are getting details loaded... either stop loading them at all or filter ids thru isVisible
return _.values( HistoryPrefs.get( historyData.id ).get( 'expandedIds' ) );
};
return this.loadHistory( historyId, attributes, historyFn, contentsFn, detailIdsFn );
},
/** loads a history & contents (but does not make them the current history) */
loadHistory : function( historyId, attributes, historyFn, contentsFn, detailIdsFn ){
this.info( 'loadHistory:', historyId, attributes, historyFn, contentsFn, detailIdsFn );
var panel = this;
attributes = attributes || {};
panel.trigger( 'loading', panel );
//this.info( 'loadHistory:', historyId, attributes, historyFn, contentsFn, detailIdsFn );
var xhr = HISTORY_MODEL.History.getHistoryData( historyId, {
historyFn : historyFn,
contentsFn : contentsFn,
detailIdsFn : attributes.initiallyExpanded || detailIdsFn
});
return panel._loadHistoryFromXHR( xhr, attributes )
.fail( function( xhr, where, history ){
// throw an error up for the error handler
panel.trigger( 'error', panel, xhr, attributes, _l( 'An error was encountered while ' + where ),
{ historyId: historyId, history: history || {} });
})
.always( function(){
// bc _hideLoadingIndicator relies on this firing
panel.trigger( 'loading-done', panel );
});
},
/** given an xhr that will provide both history and contents data, pass data to set model or handle xhr errors */
_loadHistoryFromXHR : function( xhr, attributes ){
var panel = this;
xhr.then( function( historyJSON, contentsJSON ){
panel.JSONToModel( historyJSON, contentsJSON, attributes );
panel.render();
});
xhr.fail( function( xhr, where ){
// render anyways - whether we get a model or not
panel.render();
});
return xhr;
},
/** convenience alias to the model. Updates the item list only (not the history) */
refreshContents : function( detailIds, options ){
if( this.model ){
return this.model.refresh( detailIds, options );
}
// may have callbacks - so return an empty promise
return $.when();
},
//TODO:?? seems unneccesary
//TODO: Maybe better in History?
/** create a new history model from JSON and call setModel on it */
JSONToModel : function( newHistoryJSON, newHdaJSON, attributes ){
this.log( 'JSONToModel:', newHistoryJSON, newHdaJSON, attributes );
attributes = attributes || {};
//this.log( 'JSONToModel:', newHistoryJSON, newHdaJSON.length, attributes );
var model = new HISTORY_MODEL.History( newHistoryJSON, newHdaJSON, attributes );
//TODO:?? here?
this.setModel( model );
return model;
},
/** release/free/shutdown old models and set up panel for new models
* @fires new-model with the panel as parameter
*/
setModel : function( model, attributes ){
attributes = attributes || {};
_super.prototype.setModel.call( this, model, attributes );
if( this.model ){
this._setUpWebStorage( attributes.initiallyExpanded, attributes.show_deleted, attributes.show_hidden );
}
},
// ------------------------------------------------------------------------ browser stored prefs
/** Set up client side storage. Currently PersistanStorage keyed under 'HistoryPanel.<id>'
* @param {Object} initiallyExpanded
* @param {Boolean} show_deleted whether to show deleted contents (overrides stored)
* @param {Boolean} show_hidden
* @see PersistentStorage
*/
_setUpWebStorage : function( initiallyExpanded, show_deleted, show_hidden ){
//if( !this.model ){ return this; }
//this.log( '_setUpWebStorage', initiallyExpanded, show_deleted, show_hidden );
if( this.storage ){
this.stopListening( this.storage );
}
this.storage = new HistoryPrefs({
id: HistoryPrefs.historyStorageKey( this.model.get( 'id' ) )
});
// expandedIds is a map of content.ids -> a boolean repr'ing whether that item's body is already expanded
// store any pre-expanded ids passed in
if( _.isObject( initiallyExpanded ) ){
this.storage.set( 'expandedIds', initiallyExpanded );
}
// get the show_deleted/hidden settings giving priority to values passed in, using web storage otherwise
// if the page has specifically requested show_deleted/hidden, these will be either true or false
// (as opposed to undefined, null) - and we give priority to that setting
if( _.isBoolean( show_deleted ) ){
this.storage.set( 'show_deleted', show_deleted );
}
if( _.isBoolean( show_hidden ) ){
this.storage.set( 'show_hidden', show_hidden );
}
this.trigger( 'new-storage', this.storage, this );
this.log( this + ' (init\'d) storage:', this.storage.get() );
this.listenTo( this.storage, {
'change:show_deleted' : function( view, newVal ){
this.showDeleted = newVal;
},
'change:show_hidden' : function( view, newVal ){
this.showHidden = newVal;
}
}, this );
this.showDeleted = ( show_deleted !== undefined )? show_deleted : this.storage.get( 'show_deleted' );
this.showHidden = ( show_hidden !== undefined )? show_hidden : this.storage.get( 'show_hidden' );
return this;
},
// ------------------------------------------------------------------------ panel rendering
/** In this override, add a btn to toggle the selectors */
_buildNewRender : function(){
var $newRender = _super.prototype._buildNewRender.call( this );
//if( this.views.length && this.multiselectActions().length ){
if( this.multiselectActions().length ){
$newRender.find( '.controls .actions' ).prepend( this._renderSelectButton() );
}
return $newRender;
},
/** button for starting select mode */
_renderSelectButton : function( $where ){
return faIconButton({
title : _l( 'Operations on multiple datasets' ),
classes : 'show-selectors-btn',
faIcon : 'fa-check-square-o'
});
},
// ------------------------------------------------------------------------ sub-views
/** In this override, since history contents are mixed,
* get the appropo view class based on history_content_type
*/
_getItemViewClass : function( model ){
var contentType = model.get( "history_content_type" );
switch( contentType ){
case 'dataset':
return this.HDAViewClass;
case 'dataset_collection':
return this.HDCAViewClass;
}
throw new TypeError( 'Unknown history_content_type: ' + contentType );
},
/** in this override, check if the contents would also display based on show_deleted/hidden */
_filterItem : function( model ){
var panel = this;
return ( _super.prototype._filterItem.call( panel, model )
&& ( !model.hidden() || panel.showHidden )
&& ( !model.isDeletedOrPurged() || panel.showDeleted ) );
},
/** in this override, add a linktarget, and expand if id is in web storage */
_getItemViewOptions : function( model ){
var options = _super.prototype._getItemViewOptions.call( this, model );
return _.extend( options, {
linkTarget : this.linkTarget,
expanded : !!this.storage.get( 'expandedIds' )[ model.id ],
hasUser : this.model.ownedByCurrUser()
});
},
/** In this override, add/remove expanded/collapsed model ids to/from web storage */
_setUpItemViewListeners : function( view ){
var panel = this;
_super.prototype._setUpItemViewListeners.call( panel, view );
//TODO:?? could use 'view:expanded' here?
// maintain a list of items whose bodies are expanded
view.on( 'expanded', function( v ){
panel.storage.addExpanded( v.model );
});
view.on( 'collapsed', function( v ){
panel.storage.removeExpanded( v.model );
});
return this;
},
// ------------------------------------------------------------------------ selection
/** Override to correctly set the historyId of the new collection */
getSelectedModels : function(){
var collection = _super.prototype.getSelectedModels.call( this );
collection.historyId = this.collection.historyId;
return collection;
},
// ------------------------------------------------------------------------ panel events
/** event map */
events : _.extend( _.clone( _super.prototype.events ), {
// toggle list item selectors
'click .show-selectors-btn' : 'toggleSelectors',
// allow (error) messages to be clicked away
'click .messages [class$=message]' : 'clearMessages'
}),
/** Handle the user toggling the deleted visibility by:
* (1) storing the new value in the persistent storage
* (2) re-rendering the history
* @returns {Boolean} new show_deleted setting
*/
toggleShowDeleted : function( show, store ){
show = ( show !== undefined )?( show ):( !this.showDeleted );
store = ( store !== undefined )?( store ):( true );
this.showDeleted = show;
if( store ){
this.storage.set( 'show_deleted', show );
}
this.trigger( 'show-hidden', show );
//TODO:?? to events on storage('change:show_deleted')
this.renderItems();
return this.showDeleted;
},
/** Handle the user toggling the deleted visibility by:
* (1) storing the new value in the persistent storage
* (2) re-rendering the history
* @returns {Boolean} new show_hidden setting
*/
toggleShowHidden : function( show, store ){
show = ( show !== undefined )?( show ):( !this.showHidden );
store = ( store !== undefined )?( store ):( true );
this.showHidden = show;
if( store ){
this.storage.set( 'show_hidden', show );
}
this.trigger( 'show-hidden', show );
//TODO:?? to events on storage('change:show_deleted')
this.renderItems();
return this.showHidden;
},
/** On the first search, if there are no details - load them, then search */
_firstSearch : function( searchFor ){
var panel = this,
inputSelector = '.history-search-input';
this.log( 'onFirstSearch', searchFor );
if( panel.model.contents.haveDetails() ){
panel.searchItems( searchFor );
return;
}
panel.$el.find( inputSelector ).searchInput( 'toggle-loading' );
panel.model.contents.fetchAllDetails({ silent: true })
.always( function(){
panel.$el.find( inputSelector ).searchInput( 'toggle-loading' );
})
.done( function(){
panel.searchItems( searchFor );
});
},
//TODO: break this out
// ........................................................................ error handling
/** Event handler for errors (from the panel, the history, or the history's contents)
* @param {Model or View} model the (Backbone) source of the error
* @param {XMLHTTPRequest} xhr any ajax obj. assoc. with the error
* @param {Object} options the options map commonly used with bbone ajax
* @param {String} msg optional message passed to ease error location
* @param {Object} msg optional object containing error details
*/
errorHandler : function( model, xhr, options, msg, details ){
this.error( model, xhr, options, msg, details );
// interrupted ajax
if( xhr && xhr.status === 0 && xhr.readyState === 0 ){
//TODO: gmail style 'retrying in Ns'
// bad gateway
} else if( xhr && xhr.status === 502 ){
//TODO: gmail style 'retrying in Ns'
// otherwise, show an error message inside the panel
} else {
// if sentry is available, attempt to get the event id
var parsed = this._parseErrorMessage( model, xhr, options, msg, details );
// it's possible to have a triggered error before the message container is rendered - wait for it to show
if( !this.$messages().is( ':visible' ) ){
this.once( 'rendered', function(){
this.displayMessage( 'error', parsed.message, parsed.details );
});
} else {
this.displayMessage( 'error', parsed.message, parsed.details );
}
}
},
/** Parse an error event into an Object usable by displayMessage based on the parameters
* note: see errorHandler for more info on params
*/
_parseErrorMessage : function( model, xhr, options, msg, details, sentryId ){
//if( xhr.responseText ){
// xhr.responseText = _.escape( xhr.responseText );
//}
var user = Galaxy.currUser,
// add the args (w/ some extra info) into an obj
parsed = {
message : this._bePolite( msg ),
details : {
message : msg,
raven : ( window.Raven && _.isFunction( Raven.lastEventId) )?
( Raven.lastEventId() ):( undefined ),
agent : navigator.userAgent,
// add ajax data from Galaxy object cache
url : ( window.Galaxy )?( Galaxy.lastAjax.url ):( undefined ),
data : ( window.Galaxy )?( Galaxy.lastAjax.data ):( undefined ),
options : ( xhr )?( _.omit( options, 'xhr' ) ):( options ),
xhr : xhr,
source : ( _.isFunction( model.toJSON ) )?( model.toJSON() ):( model + '' ),
user : ( user instanceof USER.User )?( user.toJSON() ):( user + '' )
}
};
// add any extra details passed in
_.extend( parsed.details, details || {} );
// fancy xhr.header parsing (--> obj)
if( xhr && _.isFunction( xhr.getAllResponseHeaders ) ){
var responseHeaders = xhr.getAllResponseHeaders();
responseHeaders = _.compact( responseHeaders.split( '\n' ) );
responseHeaders = _.map( responseHeaders, function( header ){
return header.split( ': ' );
});
parsed.details.xhr.responseHeaders = _.object( responseHeaders );
}
return parsed;
},
/** Modify an error message to be fancy and wear a monocle. */
_bePolite : function( msg ){
msg = msg || _l( 'An error occurred while getting updates from the server' );
return msg + '. ' + _l( 'Please contact a Galaxy administrator if the problem persists' ) + '.';
},
// ........................................................................ (error) messages
/** Display a message in the top of the panel.
* @param {String} type type of message ('done', 'error', 'warning')
* @param {String} msg the message to display
* @param {Object or HTML} modal contents displayed when the user clicks 'details' in the message
*/
displayMessage : function( type, msg, details ){
//precondition: msgContainer must have been rendered even if there's no model
var panel = this;
//this.log( 'displayMessage', type, msg, details );
this.scrollToTop();
var $msgContainer = this.$messages(),
$msg = $( '<div/>' ).addClass( type + 'message' ).html( msg );
//this.log( ' ', $msgContainer );
if( !_.isEmpty( details ) ){
var $detailsLink = $( '<a href="javascript:void(0)">Details</a>' )
.click( function(){
Galaxy.modal.show( panel._messageToModalOptions( type, msg, details ) );
return false;
});
$msg.append( ' ', $detailsLink );
}
return $msgContainer.append( $msg );
},
/** convert msg and details into modal options usable by Galaxy.modal */
_messageToModalOptions : function( type, msg, details ){
// only error is fleshed out here
var panel = this,
options = { title: 'Details' };
if( _.isObject( details ) ){
details = _.omit( details, _.functions( details ) );
var text = JSON.stringify( details, null, ' ' ),
pre = $( '<pre/>' ).text( text );
options.body = $( '<div/>' ).append( pre );
} else {
options.body = $( '<div/>' ).html( details );
}
options.buttons = {
'Ok': function(){
Galaxy.modal.hide();
panel.clearMessages();
}
//TODO: if( type === 'error' ){ options.buttons[ 'Report this error' ] = function(){} }
};
return options;
},
/** Remove all messages from the panel. */
clearMessages : function( ev ){
var $target = !_.isUndefined( ev )?
$( ev.currentTarget )
:this.$messages().children( '[class$="message"]' );
$target.fadeOut( this.fxSpeed, function(){
$( this ).remove();
});
return this;
},
// ........................................................................ scrolling
/** Scrolls the panel to show the content sub-view with the given hid.
* @param {Integer} hid the hid of item to scroll into view
* @returns {HistoryPanel} the panel
*/
scrollToHid : function( hid ){
return this.scrollToItem( _.first( this.viewsWhereModel({ hid: hid }) ) );
},
// ........................................................................ misc
/** Return a string rep of the history */
toString : function(){
return 'HistoryPanel(' + (( this.model )?( this.model.get( 'name' )):( '' )) + ')';
}
});
//------------------------------------------------------------------------------ TEMPLATES
HistoryPanel.prototype.templates = (function(){
var controlsTemplate = BASE_MVC.wrapTemplate([
'<div class="controls">',
'<div class="title">',
'<div class="name"><%= history.name %></div>',
'</div>',
'<div class="subtitle"></div>',
'<div class="history-size"><%= history.nice_size %></div>',
'<div class="actions"></div>',
'<div class="messages">',
'<% if( history.deleted && history.purged ){ %>',
'<div class="deleted-msg warningmessagesmall">',
_l( 'This history has been purged and deleted' ),
'</div>',
'<% } else if( history.deleted ){ %>',
'<div class="deleted-msg warningmessagesmall">',
_l( 'This history has been deleted' ),
'</div>',
'<% } else if( history.purged ){ %>',
'<div class="deleted-msg warningmessagesmall">',
_l( 'This history has been purged' ),
'</div>',
'<% } %>',
'<% if( history.message ){ %>',
// should already be localized
'<div class="<%= history.message.level || "info" %>messagesmall">',
'<%= history.message.text %>',
'</div>',
'<% } %>',
'</div>',
// add tags and annotations
'<div class="tags-display"></div>',
'<div class="annotation-display"></div>',
'<div class="search">',
'<div class="search-input"></div>',
'</div>',
'<div class="list-actions">',
'<div class="btn-group">',
'<button class="select-all btn btn-default"',
'data-mode="select">', _l( 'All' ), '</button>',
'<button class="deselect-all btn btn-default"',
'data-mode="select">', _l( 'None' ), '</button>',
'</div>',
'<div class="list-action-menu btn-group">',
'</div>',
'</div>',
'</div>'
], 'history' );
return _.extend( _.clone( _super.prototype.templates ), {
controls : controlsTemplate
});
}());
//==============================================================================
return {
HistoryPanel: HistoryPanel
};
});