diff --git a/client/galaxy/scripts/mvc/collection/list-collection-creator.js b/client/galaxy/scripts/mvc/collection/list-collection-creator.js index af2b795223c..46a9e0fe66f 100644 --- a/client/galaxy/scripts/mvc/collection/list-collection-creator.js +++ b/client/galaxy/scripts/mvc/collection/list-collection-creator.js @@ -1,52 +1,938 @@ + define([ + "mvc/history/hdca-model", "mvc/dataset/states", - "mvc/ui/error-modal", + "mvc/base-mvc", + "mvc/ui/ui-modal", + "utils/natural-sort", "utils/localization" -], function( STATES, errorModal, _l ){ +], function( HDCA, STATES, BASE_MVC, UI_MODAL, naturalSort, _l ){ /*============================================================================== ==============================================================================*/ -function validElements( collection ){ - var elements = collection.toJSON().filter( function( content ){ - return content.history_content_type === 'dataset' - && content.state === STATES.OK; - }); - return elements; -} +/** A view for both DatasetDCEs and NestedDCDCEs + * (things that implement collection-model:DatasetCollectionElementMixin) + */ +var DatasetCollectionElementView = Backbone.View.extend( BASE_MVC.LoggableMixin ).extend({ -var noValidElementsMessage = 'No valid datasets were selected'; +//TODO: use proper class (DatasetDCE or NestedDCDCE (or the union of both)) + tagName : 'li', + className : 'collection-element', -function noValidElementsErrorModal(){ - errorModal( - 'Use the checkboxes at the left of the dataset names to select them. ' + - 'Selected datasets should be error-free and should have completed running.', - noValidElementsMessage - ); -} + initialize : function( attributes ){ + this.element = attributes.element || {}; + this.selected = attributes.selected || false; + }, -function createListHDCA( collection ){ - var name = 'New Dataset List', - elementIdentifiers = validElements( collection ).map( function( element ){ - // TODO: Handle duplicate names. - return { - id : element.id, - name : element.name, - //TODO: this allows for list:list even if the filter above does not - reconcile - src : ( element.history_content_type === 'dataset'? 'hda' : 'hdca' ) + render : function(){ + this.$el + .attr( 'data-element-id', this.element.id ) + .attr( 'draggable', true ) + .html( this.template({ element: this.element }) ); + if( this.selected ){ + this.$el.addClass( 'selected' ); + } + return this; + }, + + //TODO: lots of unused space in the element - possibly load details and display them horiz. + template : _.template([ + '<%= element.name %>', + '', + _l( 'Discard' ), + '', + ].join('')), + + select : function( toggle ){ + this.$el.toggleClass( 'selected', toggle ); + this.trigger( 'select', { + source : this, + selected : this.$el.hasClass( 'selected' ) + }); + }, + + discard : function(){ + var element = this, + parentWidth = this.$el.parent().width(); + this.$el.animate({ 'margin-right' : parentWidth }, 'fast', function(){ + element.trigger( 'discard', { + source : element + }); + }); + }, + + events : { + 'click' : '_click', + 'click .name' : '_clickName', + 'click .discard': '_clickDiscard', + + 'dragstart' : '_dragstart', + 'dragend' : '_dragend', + 'dragover' : '_sendToParent', + 'drop' : '_sendToParent' + }, + + _click : function( ev ){ + ev.stopPropagation(); + this.select( ev ); + }, + + /** rename a pair when the pair name is clicked */ + _clickName : function( ev ){ + ev.stopPropagation(); + ev.preventDefault(); + var response = prompt( _l( 'Enter a new name for the element' ) + ':', this.element.name ); + if( response ){ + this.element.name = response; + this.render(); + } + //TODO: cancelling with ESC leads to closure of the creator... + }, + + _clickDiscard : function( ev ){ + ev.stopPropagation(); + this.discard(); + }, + + /** dragging pairs for re-ordering */ + _dragstart : function( ev ){ + if( ev.originalEvent ){ ev = ev.originalEvent; } + ev.dataTransfer.effectAllowed = 'move'; + ev.dataTransfer.setData( 'text/plain', JSON.stringify( this.element ) ); + + this.$el.addClass( 'dragging' ); + this.$el.parent().trigger( 'collection-element.dragstart', [ this ] ); + }, + + /** dragging pairs for re-ordering */ + _dragend : function( ev ){ + this.$el.removeClass( 'dragging' ); + this.$el.parent().trigger( 'collection-element.dragend', [ this ] ); + }, + + /** manually bubble up an event to the parent/container */ + _sendToParent : function( ev ){ + this.$el.parent().trigger( ev ); + }, + + /** string rep */ + toString : function(){ + return 'DatasetCollectionElementView()'; + } +}); + + +// ============================================================================ +/** An interface for building collections. + */ +var ListCollectionCreator = Backbone.View.extend( BASE_MVC.LoggableMixin ).extend({ + + /** the class this creator will create and save */ + collectionClass : HDCA.HistoryListDatasetCollection, + className : 'list-collection-creator collection-creator flex-row-container', + + /** set up initial options, instance vars, behaviors */ + initialize : function( attributes ){ + this.metric( 'ListCollectionCreator.initialize', attributes ); + + attributes = _.defaults( attributes, { + elements : [], + creationFn : function(){ throw new TypeError( 'no creation fn for creator' ); } + }); + + /** unordered, original list - cache to allow reversal */ + this.initialElements = attributes.elements; + /** a list of invalid elements and the reasons they aren't valid */ +//TODO: display invalid and allow the user to move forward + this.invalidElements = []; + /** data for list in progress */ + this.workingElements = []; + /** views for workingElements */ + this.elementViews = []; + +//TODO: remove - use new collectionClass().save() + /** takes elements and creates the proper collection - returns a promise */ + this.creationFn = attributes.creationFn; + /** fn to call when the collection is created (scoped to this) */ + this.oncreate = attributes.oncreate; + /** fn to call when the cancel button is clicked (scoped to this) - if falsy, no btn is displayed */ + this.oncancel = attributes.oncancel; + + /** fn to call when the cancel button is clicked (scoped to this) - if falsy, no btn is displayed */ + this.autoscrollDist = attributes.autoscrollDist || 24; + /** Ids of elements that have been selected by the user - to preserve over renders */ + this.selectedIds = {}; + /** DOM elements currently being dragged */ + this.$dragging = null; + /** Used for blocking UI events during ajax/operations (don't post twice) */ + this.blocking = false; + + this._setUpBehaviors(); + this._elementsSetUp(); + }, + + // ------------------------------------------------------------------------ process raw list + /** set up main data */ + _elementsSetUp : function(){ + //this.debug( '-- _dataSetUp' ); + + // copy initial list, sort, add ids if needed + this.workingElements = this.initialElements.slice( 0 ); + this._ensureElementIds(); + this._validateElements(); + this._sortElements(); + }, + + /** add ids to dataset objs in initial list if none */ + _ensureElementIds : function(){ + this.workingElements.forEach( function( element ){ + if( !element.hasOwnProperty( 'id' ) ){ + element.id = _.uniqueId(); } }); - if( !elementIdentifiers.length ){ - noValidElementsErrorModal(); - return jQuery.Deferred().reject( noValidElementsMessage ); - } - return collection.createHDCA( elementIdentifiers, 'list', name ); + return this.workingElements; + }, + + /** separate working list into valid and invalid elements for this collection */ + _validateElements : function(){ + var creator = this; + creator.invalidElements = []; + + this.workingElements = this.workingElements.filter( function( element ){ + var problem = creator._isElementInvalid( element ); + if( problem ){ + creator.invalidElements.push({ + element : element, + text : problem + }); + } + return !problem; + }); + return this.workingElements; + }, + + /** separate working list into valid and invalid elements for this collection */ + _isElementInvalid : function( element ){ + if( element.state !== STATES.OK ){ + if( _.contains( STATES.NOT_READY_STATES, element.state ) ){ + return _l( "hasn't finished running yet" ); + } + return _l( "has errored, is paused, or is not accessible" ); + } + if( element.history_content_type !== 'dataset' ){ + return _l( "is not a dataset" ); + } + if( element.deleted || element.purged ){ + return _l( "has been deleted or purged" ); + } + return null; + }, + + /** sort a list of datasets */ + _sortElements : function( list ){ + // currently only natural sort by name + this.workingElements.sort( function( a, b ){ return naturalSort( a.name, b.name ); }); + return this.workingElements; + }, + + // ------------------------------------------------------------------------ rendering + /** render the entire interface */ + render : function( speed, callback ){ + //this.debug( '-- _render' ); + this.$el.empty().html( ListCollectionCreator.templates.main() ); + this._renderHeader( speed ); + this._renderMiddle( speed ); + this._renderFooter( speed ); + this._addPluginComponents(); + this.trigger( 'rendered', this ); + return this; + }, + + /** render the header section */ + _renderHeader : function( speed, callback ){ + var $header = this.$( '.header' ).empty().html( ListCollectionCreator.templates.header() ) + .find( '.help-content' ).prepend( $( ListCollectionCreator.templates.helpContent() ) ); + //TODO: should only show once despite calling _renderHeader again + if( this.invalidElements.length ){ + this._invalidElementsAlert(); + } + return $header; + }, + + /** render the middle including the elements */ + _renderMiddle : function( speed, callback ){ + var $middle = this.$( '.middle' ).empty().html( ListCollectionCreator.templates.middle() ); + this._renderList( speed ); + return $middle; + }, + + /** render the footer, completion controls, and cancel controls */ + _renderFooter : function( speed, callback ){ + var $footer = this.$( '.footer' ).empty().html( ListCollectionCreator.templates.footer() ); + if( typeof this.oncancel === 'function' ){ + this.$( '.cancel-create.btn' ).show(); + } + return $footer; + }, + + /** add any jQuery/bootstrap/custom plugins to elements rendered */ + _addPluginComponents : function(){ + this.$( '.help-content i' ).hoverhighlight( '.collection-creator', 'rgba( 64, 255, 255, 1.0 )' ); + }, + + /** add (or clear if clear is truthy) a validation warning to what */ + _validationWarning : function( what, clear ){ + var VALIDATION_CLASS = 'validation-warning'; + if( what === 'name' ){ + what = this.$( '.collection-name' ).add( this.$( '.collection-name-prompt' ) ); + this.$( '.collection-name' ).focus().select(); + } + if( clear ){ + what = what || this.$( '.' + VALIDATION_CLASS ); + what.removeClass( VALIDATION_CLASS ); + } else { + what.addClass( VALIDATION_CLASS ); + } + }, + + // ------------------------------------------------------------------------ rendering elements + /** */ + $list : function(){ + return this.$( '.collection-elements' ); + }, + + /** */ + _invalidElementsAlert : function(){ + var message = _l( 'The following selections could not be included due to problems' ) + ':'; + message += ''; + message += this.invalidElements.map( function( problem ){ + return [ + '', problem.element.name, ': ', problem.text, '' + ].join( '' ); + }).join( '' ); + message += ''; + this._showAlert( message, 'alert-warning' ); + }, + + /** */ + _renderClearSelected : function(){ + if( _.size( this.selectedIds ) ){ + this.$( '.collection-elements-controls > .clear-selected' ).show(); + } else { + this.$( '.collection-elements-controls > .clear-selected' ).hide(); + } + }, + + /** */ + _renderList : function( speed, callback ){ + //this.debug( '-- _renderList' ); + var creator = this, + $tmp = jQuery( '' ); + creator.workingElements.forEach( function( element ){ + var elementView = creator._createElementView( element ); + $tmp.append( elementView.$el ); + }); + + creator._renderClearSelected(); + creator.$list().empty().append( $tmp.children() ); + _.invoke( creator.elementViews, 'render' ); + }, + + /** */ + _createElementView : function( element ){ + var elementView = new DatasetCollectionElementView({ +//TODO: use non-generic class or not all + // model : COLLECTION.DatasetDCE( element ) + element : element, + selected: _.has( this.selectedIds, element.id ) + }); + this.elementViews.push( elementView ); + this._listenToElementView( elementView ); + return elementView; + }, + + _listenToElementView : function( view ){ + var creator = this; + creator.listenTo( view, { + select : function( data ){ + var element = data.source.element; + if( data.selected ){ + creator.selectedIds[ element.id ] = true; + } else { + delete creator.selectedIds[ element.id ]; + } + creator.trigger( 'elements:select', data ); + }, + discard : function( data ){ + creator.trigger( 'elements:discard', data ); + } + }); + }, + + /** */ + addElementView : function( element ){ + // workingElements is sorted, add element in appropo index + // add element, sort elements, find element index + var view = this._createElementView( element ); + return view; + }, + + /** */ + removeElementView : function( view ){ + view.$el.remove(); + + delete this.selectedIds[ view.element.id ]; + this._renderClearSelected(); + + this.elementViews = _.without( this.elementViews, view ); + this.stopListening( view ); + view.off(); + }, + + _renderNoElementsLeft : function(){ + var $message = $( '' ), + $startOverLink = $( '' ); + $message.html([ + _l( 'No elements left' ), '', _l( 'Would you like to ' ), + '', _l( 'start over' ), '?' + ].join( '' )); + this.$( '.collection-elements' ).append( $message ); + }, + + // ------------------------------------------------------------------------ API + /** convert element into JSON compatible with the collections API */ + _elementToJSON : function( element ){ + // return element.toJSON(); + return element; + }, + + /** create the collection via the API + * @returns {jQuery.xhr Object} the jquery ajax request + */ + createList : function( name ){ + var creator = this, + elements = this.workingElements.map( function( element ){ + return creator._elementToJSON( element ); + }); + + creator.blocking = true; + return creator.creationFn( elements, name ) + .always( function(){ + creator.blocking = false; + }) + .fail( function( xhr, status, message ){ + creator.trigger( 'error', { + xhr : xhr, + status : status, + message : _l( 'An error occurred while creating this collection' ) + }); + }) + .done( function( response, message, xhr ){ + creator.trigger( 'collection:created', response, message, xhr ); + creator.metric( 'collection:created', response ); + if( typeof creator.oncreate === 'function' ){ + creator.oncreate.call( this, response, message, xhr ); + } + }); + }, + + // ------------------------------------------------------------------------ events + /** set up event handlers on self */ + _setUpBehaviors : function(){ + this.on( 'error', this._errorHandler ); + + this.once( 'rendered', function(){ + this.trigger( 'rendered:initial', this ); + }); + + this.on( 'elements:select', function( data ){ + this._renderClearSelected(); + }); + + this.on( 'elements:discard', function( data ){ + var element = data.source.element; + this.removeElementView( data.source ); + + this.workingElements = _.without( this.workingElements, element ); + if( !this.workingElements.length ){ + this._renderNoElementsLeft(); + } + }); + + //this.on( 'all', function(){ + // this.info( arguments ); + //}); + return this; + }, + + /** handle errors with feedback and details to the user (if available) */ + _errorHandler : function( data ){ + this.error( data ); + + var creator = this; + content = data.message || _l( 'An error occurred' ); + if( data.xhr ){ + var xhr = data.xhr, + message = data.message; + if( xhr.readyState === 0 && xhr.status === 0 ){ + content += ': ' + _l( 'Galaxy could not be reached and may be updating.' ) + + _l( ' Try again in a few minutes.' ); + } else if( xhr.responseJSON ){ + content += ':' + JSON.stringify( xhr.responseJSON ) + ''; + } else { + content += ': ' + message; + } + } + creator._showAlert( content, 'alert-danger' ); + }, + + events : { + // header + 'click .more-help' : '_clickMoreHelp', + 'click .less-help' : '_clickLessHelp', + 'click .main-help' : '_toggleHelp', + 'click .header .alert button' : '_hideAlert', + + 'click .reset' : 'reset', + 'click .clear-selected' : 'clearSelectedElements', + + // elements - selection +//TODO: this is not enough area - no affordance + 'click .collection-elements' : 'clearSelectedElements', + + // elements - drop target + // 'dragenter .collection-elements': '_dragenterElements', + // 'dragleave .collection-elements': '_dragleaveElements', + 'dragover .collection-elements' : '_dragoverElements', + 'drop .collection-elements' : '_dropElements', + + // these bubble up from the elements as custom events + 'collection-element.dragstart .collection-elements' : '_elementDragstart', + 'collection-element.dragend .collection-elements' : '_elementDragend', + + // footer + 'change .collection-name' : '_changeName', + 'keydown .collection-name' : '_nameCheckForEnter', + 'click .cancel-create' : function( ev ){ + if( typeof this.oncancel === 'function' ){ + this.oncancel.call( this ); + } + }, + 'click .create-collection' : '_clickCreate'//, + }, + + // ........................................................................ header + /** expand help */ + _clickMoreHelp : function( ev ){ + ev.stopPropagation(); + this.$( '.main-help' ).addClass( 'expanded' ); + this.$( '.more-help' ).hide(); + }, + /** collapse help */ + _clickLessHelp : function( ev ){ + ev.stopPropagation(); + this.$( '.main-help' ).removeClass( 'expanded' ); + this.$( '.more-help' ).show(); + }, + /** toggle help */ + _toggleHelp : function( ev ){ + ev.stopPropagation(); + this.$( '.main-help' ).toggleClass( 'expanded' ); + this.$( '.more-help' ).toggle(); + }, + + /** show an alert on the top of the interface containing message (alertClass is bootstrap's alert-*)*/ + _showAlert : function( message, alertClass ){ + alertClass = alertClass || 'alert-danger'; + this.$( '.main-help' ).hide(); + this.$( '.header .alert' ).attr( 'class', 'alert alert-dismissable' ).addClass( alertClass ).show() + .find( '.alert-message' ).html( message ); + }, + /** hide the alerts at the top */ + _hideAlert : function( message ){ + this.$( '.main-help' ).show(); + this.$( '.header .alert' ).hide(); + }, + + // ........................................................................ elements + reset : function(){ + this.selectedIds = {}; + this.$dragging = null; + this.blocking = false; + this._elementsSetUp(); + this.render(); + }, + + /** deselect all pairs */ + clearSelectedElements : function( ev ){ + this.$( '.collection-elements .collection-element' ).removeClass( 'selected' ); + this.$( '.collection-elements-controls > .clear-selected' ).hide(); + }, + + //_dragenterElements : function( ev ){ + // this.debug( '_dragenterElements:', ev ); + //}, + _dragleaveElements : function( ev ){ + //this.debug( '_dragleaveElements:', ev ); +//TODO: if selected are dragged out of the list area - remove the placeholder - cuz it won't work anyway + }, + + /** track the mouse drag over the paired list adding a placeholder to show where the drop would occur */ + _dragoverElements : function( ev ){ + //this.debug( '_dragoverElements:', ev ); + ev.preventDefault(); + + var $list = this.$list(); + this._checkForAutoscroll( $list, ev.originalEvent.clientY ); + var $nearest = this._getNearestElement( ev.originalEvent.clientY ); + +//TODO: no need to re-create - move instead + this.$( '.element-drop-placeholder' ).remove(); + var $placeholder = $( '' ); + if( !$nearest.size() ){ + $list.append( $placeholder ); + } else { + $nearest.before( $placeholder ); + } + }, + + /** If the mouse is near enough to the list's top or bottom, scroll the list */ + _checkForAutoscroll : function( $element, y ){ + var AUTOSCROLL_SPEED = 2, + offset = $element.offset(), + scrollTop = $element.scrollTop(), + upperDist = y - offset.top, + lowerDist = ( offset.top + $element.outerHeight() ) - y; + if( upperDist >= 0 && upperDist < this.autoscrollDist ){ + $element.scrollTop( scrollTop - AUTOSCROLL_SPEED ); + } else if( lowerDist >= 0 && lowerDist < this.autoscrollDist ){ + $element.scrollTop( scrollTop + AUTOSCROLL_SPEED ); + } + }, + + /** get the nearest element based on the mouse's Y coordinate. + * If the y is at the end of the list, return an empty jQuery object. + */ + _getNearestElement : function( y ){ + var WIGGLE = 4, + lis = this.$( '.collection-elements li.collection-element' ).toArray(); + for( var i=0; i y && top - halfHeight < y ){ + //this.debug( y, top + halfHeight, top - halfHeight ) + return $li; + } + } + return $(); + }, + + /** drop (dragged/selected elements) onto the list, re-ordering the internal list */ + _dropElements : function( ev ){ + if( ev.originalEvent ){ ev = ev.originalEvent; } + // both required for firefox + ev.preventDefault(); + ev.dataTransfer.dropEffect = 'move'; + + // remove the placeholder if any and insert before the nearest element or after the last. + var $nearest = this._getNearestElement( ev.clientY ); + if( $nearest.size() ){ + this.$dragging.insertBefore( $nearest ); + } else { + // no nearest before - insert after last element (unpair button) + this.$dragging.insertAfter( this.$( '.collection-elements .collection-element' ).last() ); + } + // resync the creator's list of paired based on the new DOM order + this._syncOrderToDom(); + return false; + }, + + /** resync the creator's list of elements based on the DOM order */ + _syncOrderToDom : function(){ + var creator = this, + newElements = []; + //TODO: doesn't seem wise to use the dom to store these - can't we sync another way? + this.$( '.collection-elements .collection-element' ).each( function(){ + var id = $( this ).attr( 'data-element-id' ), + element = _.findWhere( creator.workingElements, { id: id }); + if( element ){ + newElements.push( element ); + } else { + console.error( 'missing element: ', id ); + } + }); + this.workingElements = newElements; + this._renderList(); + }, + + /** drag communication with element sub-views: dragstart */ + _elementDragstart : function( ev, element ){ + // auto select the element causing the event and move all selected + element.select( true ); + this.$dragging = this.$( '.collection-elements .collection-element.selected' ); + }, + + /** drag communication with element sub-views: dragend - remove the placeholder */ + _elementDragend : function( ev, element ){ + $( '.element-drop-placeholder' ).remove(); + this.$dragging = null; + }, + + // ........................................................................ footer + /** handle a collection name change */ + _changeName : function( ev ){ + this._validationWarning( 'name', !!this._getName() ); + }, + + /** check for enter key press when in the collection name and submit */ + _nameCheckForEnter : function( ev ){ + if( ev.keyCode === 13 && !this.blocking ){ + this._clickCreate(); + } + }, + + /** get the current collection name */ + _getName : function(){ + return _.escape( this.$( '.collection-name' ).val() ); + }, + + /** attempt to create the current collection */ + _clickCreate : function( ev ){ + var name = this._getName(); + if( !name ){ + this._validationWarning( 'name' ); + } else if( !this.blocking ){ + this.createList( name ); + } + }, + + // ------------------------------------------------------------------------ misc + /** string rep */ + toString : function(){ return 'ListCollectionCreator'; } +}); + + +//TODO: move to require text plugin and load these as text +//TODO: underscore currently unnecc. bc no vars are used +//TODO: better way of localizing text-nodes in long strings +/** underscore template fns attached to class */ +ListCollectionCreator.templates = ListCollectionCreator.templates || { + + /** the skeleton */ + main : _.template([ + '', + '', + '
' + JSON.stringify( xhr.responseJSON ) + '