commit/galaxy-central: carlfeberhard: HDA/Collections client-side refactoring for drill down view
1 new commit in galaxy-central: https://bitbucket.org/galaxy/galaxy-central/commits/700560884da7/ Changeset: 700560884da7 User: carlfeberhard Date: 2014-08-11 17:07:34 Summary: HDA/Collections client-side refactoring for drill down view Affected #: 48 files diff -r bdc4017c2e7e9ecb5dfa3d36798f535402ec80aa -r 700560884da71e8bd3bdad6ddbe4edfde9566f7f static/scripts/mvc/base-mvc.js --- a/static/scripts/mvc/base-mvc.js +++ b/static/scripts/mvc/base-mvc.js @@ -53,6 +53,7 @@ //============================================================================== /** 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 ){ @@ -147,13 +148,128 @@ //============================================================================== +/** 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, @@ -172,12 +288,15 @@ } }, +//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 @@ -207,60 +326,101 @@ } }; + //============================================================================== +/** 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 ){ - // usage: var NewModel = Something.extend( mixin( MyMixinA, MyMixinB, { ... }) ); - //NOTE: this does not combine any hashes (like events, etc.) and you're expected to handle that - - // simple reversal of param order on _.defaults() - to show mixins in top of definition var args = Array.prototype.slice.call( arguments, 0 ), lastArg = args.pop(); args.unshift( lastArg ); return _.defaults.apply( _, args ); } +//============================================================================== +/** 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 ); + }; +} //============================================================================== +/** A view which, when first rendered, shows only summary data/attributes, but + * can be expanded to show further details (and optionally fetch those + * details from the server). + */ var ExpandableView = Backbone.View.extend( LoggableMixin ).extend({ //TODO: Although the reasoning behind them is different, this shares a lot with HiddenUntilActivated above: combine them //PRECONDITION: model must have method hasDetails + //PRECONDITION: subclasses must have templates.el and templates.details initialize : function( attributes ){ /** are the details of this view expanded/shown or not? */ this.expanded = attributes.expanded || false; - this.log( '\t expanded:', this.expanded ); + //this.log( '\t expanded:', this.expanded ); }, // ........................................................................ render main -//TODO: for lack of a better place, add rendering logic here + /** jq fx speed */ fxSpeed : 'fast', /** Render this content, set up ui. - * @param {Integer} speed the speed of the render - * @fires rendered when rendered - * @fires rendered:ready when first rendered and NO running HDAs - * @returns {Object} this HDABaseView + * @param {Number or String} speed the speed of the render */ render : function( speed ){ var $newRender = this._buildNewRender(); + this._setUpBehaviors( $newRender ); this._queueNewRender( $newRender, speed ); return this; }, + /** Build a temp div containing the new children for the view's $el. + * If the view is already expanded, build the details as well. + */ _buildNewRender : function(){ // create a new render using a skeleton template, render title buttons, render body, and set up events, etc. - var $newRender = $( this.templates.skeleton( this.model.toJSON() ) ); + var $newRender = $( this.templates.el( this.model.toJSON(), this ) ); if( this.expanded ){ - $newRender.children( '.details' ).replaceWith( this._renderDetails() ); + this.$details( $newRender ).replaceWith( this._renderDetails().show() ); } - this._setUpBehaviors( $newRender ); return $newRender; }, - /** Fade out the old el, replace with new dom, then fade in. - * @param {Boolean} fade whether or not to fade out/in when re-rendering + /** Fade out the old el, swap in the new contents, then fade in. + * @param {Number or String} speed jq speed to use for rendering effects * @fires rendered when rendered - * @fires rendered:ready when first rendered and NO running HDAs */ _queueNewRender : function( $newRender, speed ) { speed = ( speed === undefined )?( this.fxSpeed ):( speed ); @@ -280,6 +440,7 @@ ]); }, + /** empty out the current el, move the $newRender's children in */ _swapNewRender : function( $newRender ){ return this.$el.empty().attr( 'class', this.className ).append( $newRender.children() ); }, @@ -287,21 +448,29 @@ /** set up js behaviors, event handlers for elements within the given container * @param {jQuery} $container jq object that contains the elements to process (defaults to this.$el) */ - _setUpBehaviors : function( $container ){ - $container = $container || this.$el; + _setUpBehaviors : function( $where ){ + $where = $where || this.$el; // set up canned behavior on children (bootstrap, popupmenus, editable_text, etc.) - make_popup_menus( $container ); - $container.find( '[title]' ).tooltip({ placement : 'bottom' }); + make_popup_menus( $where ); + $where.find( '[title]' ).tooltip({ placement : 'bottom' }); }, // ......................................................................... details + /** shortcut to details DOM (as jQ) */ + $details : function( $where ){ + $where = $where || this.$el; + return $where.find( '.details' ); + }, + + /** build the DOM for the details and set up behaviors on it */ _renderDetails : function(){ - // override this - return null; + var $newDetails = $( this.templates.details( this.model.toJSON(), this ) ); + this._setUpBehaviors( $newDetails ); + return $newDetails; }, // ......................................................................... expansion/details - /** Show or hide the body/details of history content. + /** Show or hide the details * @param {Boolean} expand if true, expand; if false, collapse */ toggleExpanded : function( expand ){ @@ -320,26 +489,26 @@ */ expand : function(){ var view = this; - - function _renderDetailsAndExpand(){ - view.$( '.details' ).replaceWith( view._renderDetails() ); - // needs to be set after the above or the slide will not show - view.expanded = true; - view.$( '.details' ).slideDown( view.fxSpeed, function(){ + return view._fetchModelDetails() + .always(function(){ + var $newDetails = view._renderDetails(); + view.$details().replaceWith( $newDetails ); + // needs to be set after the above or the slide will not show + view.expanded = true; + $newDetails.slideDown( view.fxSpeed, function(){ view.trigger( 'expanded', view ); }); + }); + }, + + /** Check for model details and, if none, fetch them. + * @returns {jQuery.promise} the model.fetch.xhr if details are being fetched, an empty promise if not + */ + _fetchModelDetails : function(){ + if( !this.model.hasDetails() ){ + return this.model.fetch(); } -//TODO:?? remove - // fetch first if no details in the model - if( !view.model.hasDetails() ){ - // we need the change event on HDCA's for the elements to be processed - so silent == false - view.model.fetch().always( function( model ){ - _renderDetailsAndExpand(); - }); -//TODO: no error handling - } else { - _renderDetailsAndExpand(); - } + return jQuery.when(); }, /** Hide the body/details of an HDA. @@ -348,19 +517,378 @@ collapse : function(){ var view = this; view.expanded = false; - this.$( '.details' ).slideUp( view.fxSpeed, function(){ + this.$details().slideUp( view.fxSpeed, function(){ view.trigger( 'collapsed', view ); }); } }); + +//============================================================================== +/** 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; + //TODO: I have no idea why this doesn't work with the events hash or jq.on()... + //this.$el.find( '.title-bar' ) + // .attr( 'draggable', true ) + // .bind( 'dragstart', this.dragStartHandler, false ) + // .bind( 'dragend', this.dragEndHandler, false ); + 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 dragstart (bbone event) which is passed this view + */ + _dragStartHandler : function( event ){ + //this.debug( 'dragStartHandler:', this, event, arguments ) + this.trigger( 'dragstart', this ); + event.dataTransfer.effectAllowed = 'move'; + //TODO: all except IE: should be 'application/json', IE: must be 'text' + event.dataTransfer.setData( 'text', JSON.stringify( this.model.toJSON() ) ); + return false; + }, + + /** handle the dragend + * @fires dragend (bbone event) which is passed this view + */ + _dragEndHandler : function( event ){ + this.trigger( 'dragend', this ); + //this.debug( 'dragEndHandler:', event ) + 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; + } +}; + + +//============================================================================== +/** A view that is displayed in some larger list/grid/collection. + * Inherits from Expandable, Selectable, Draggable. + * The DOM contains warnings, a title bar, and a series of primary action controls. + * Primary actions are meant to be easily accessible item functions (such as delete) + * that are rendered in the title bar. + * + * Details are rendered when the user clicks the title bar or presses enter/space when + * the title bar is in focus. + * + * Designed as a base class for history panel contents - but usable elsewhere (I hope). + */ +var ListItemView = ExpandableView.extend( mixin( SelectableViewMixin, DraggableViewMixin, { + +//TODO: that's a little contradictory + tagName : 'div', + className : 'list-item', + + /** Set up the base class and all mixins */ + initialize : function( attributes ){ + ExpandableView.prototype.initialize.call( this, attributes ); + SelectableViewMixin.initialize.call( this, attributes ); + DraggableViewMixin.initialize.call( this, attributes ); + }, + + // ........................................................................ rendering + /** In this override, call methods to build warnings, titlebar and primary actions */ + _buildNewRender : function(){ + var $newRender = ExpandableView.prototype._buildNewRender.call( this ); + $newRender.find( '.warnings' ).replaceWith( this._renderWarnings() ); + $newRender.find( '.title-bar' ).replaceWith( this._renderTitleBar() ); + $newRender.find( '.primary-actions' ).append( this._renderPrimaryActions() ); + $newRender.find( '.subtitle' ).replaceWith( this._renderSubtitle() ); + return $newRender; + }, + + /** In this override, render the selector controls and set up dragging before the swap */ + _swapNewRender : function( $newRender ){ + ExpandableView.prototype._swapNewRender.call( this, $newRender ); + if( this.selectable ){ this.showSelector( 0 ); } + if( this.draggable ){ this.draggableOn(); } + return this.$el; + }, + + /** Render any warnings the item may need to show (e.g. "I'm deleted") */ + _renderWarnings : function(){ + var view = this, + $warnings = $( '<div class="warnings"></div>' ), + json = view.model.toJSON(); +//TODO:! unordered (map) + _.each( view.templates.warnings, function( templateFn ){ + $warnings.append( $( templateFn( json, view ) ) ); + }); + return $warnings; + }, + + /** Render the title bar (the main/exposed SUMMARY dom element) */ + _renderTitleBar : function(){ + return $( this.templates.titleBar( this.model.toJSON(), this ) ); + }, + + /** Return an array of jQ objects containing common/easily-accessible item controls */ + _renderPrimaryActions : function(){ + // override this + return []; + }, + + /** Render the title bar (the main/exposed SUMMARY dom element) */ + _renderSubtitle : function(){ + return $( this.templates.subtitle( this.model.toJSON(), this ) ); + }, + + // ......................................................................... events + /** event map */ + events : { + // expand the body when the title is clicked or when in focus and space or enter is pressed + 'click .title-bar' : '_clickTitleBar', + 'keydown .title-bar' : '_keyDownTitleBar', + + // dragging - don't work, originalEvent === null + //'dragstart .dataset-title-bar' : 'dragStartHandler', + //'dragend .dataset-title-bar' : 'dragEndHandler' + + 'click .selector' : 'toggleSelect' + }, + + /** expand when the title bar is clicked */ + _clickTitleBar : function( event ){ + event.stopPropagation(); + this.toggleExpanded(); + }, + + /** expand when the title bar is in focus and enter or space is pressed */ + _keyDownTitleBar : function( event ){ + // bail (with propagation) if keydown and not space or enter + var KEYCODE_SPACE = 32, KEYCODE_RETURN = 13; + if( event && ( event.type === 'keydown' ) + &&( event.keyCode === KEYCODE_SPACE || event.keyCode === KEYCODE_RETURN ) ){ + this.toggleExpanded(); + event.stopPropagation(); + return false; + } + return true; + }, + + // ......................................................................... misc + /** String representation */ + toString : function(){ + var modelString = ( this.model )?( this.model + '' ):( '(no model)' ); + return 'ListItemView(' + modelString + ')'; + } +})); + +// ............................................................................ TEMPLATES +/** underscore templates */ +ListItemView.prototype.templates = (function(){ +//TODO: move to require text! plugin + + var elTemplato = wrapTemplate([ + '<div class="list-element">', + // errors, messages, etc. + '<div class="warnings"></div>', + + // multi-select checkbox + '<div class="selector">', + '<span class="fa fa-2x fa-square-o"></span>', + '</div>', + // space for title bar buttons - gen. floated to the right + '<div class="primary-actions"></div>', + '<div class="title-bar"></div>', + + // expandable area for more details + '<div class="details"></div>', + '</div>' + ]); + + var warnings = {}; + + var titleBarTemplate = wrapTemplate([ + // adding a tabindex here allows focusing the title bar and the use of keydown to expand the dataset display + '<div class="title-bar clear" tabindex="0">', +//TODO: prob. belongs in dataset-list-item + '<span class="state-icon"></span>', + '<div class="title">', + '<span class="name"><%- element.name %></span>', + '</div>', + '<div class="subtitle"></div>', + '</div>' + ], 'element' ); + + var subtitleTemplate = wrapTemplate([ + // override this + '<div class="subtitle"></div>' + ]); + + var detailsTemplate = wrapTemplate([ + // override this + '<div class="details"></div>' + ]); + + return { + el : elTemplato, + warnings : warnings, + titleBar : titleBarTemplate, + subtitle : subtitleTemplate, + details : detailsTemplate + }; +}()); + + //============================================================================== return { LoggableMixin : LoggableMixin, SessionStorageModel : SessionStorageModel, + SearchableModelMixin : SearchableModelMixin, HiddenUntilActivatedViewMixin : HiddenUntilActivatedViewMixin, mixin : mixin, - ExpandableView : ExpandableView + wrapTemplate : wrapTemplate, + ExpandableView : ExpandableView, + DraggableViewMixin : DraggableViewMixin, + SelectableViewMixin : SelectableViewMixin, + ListItemView : ListItemView }; }); diff -r bdc4017c2e7e9ecb5dfa3d36798f535402ec80aa -r 700560884da71e8bd3bdad6ddbe4edfde9566f7f static/scripts/mvc/collection/collection-model.js --- a/static/scripts/mvc/collection/collection-model.js +++ b/static/scripts/mvc/collection/collection-model.js @@ -1,29 +1,57 @@ define([ - "mvc/dataset/hda-model", + "mvc/dataset/dataset-model", "mvc/base-mvc", "utils/localization" -], function( HDA_MODEL, BASE_MVC, _l ){ +], function( DATASET, BASE_MVC, _l ){ //============================================================================== -/** @class Backbone model for Dataset collection elements. - * DC Elements contain a sub-model named 'object'. This class moves that - * 'object' from the JSON in the attributes list to a full, instantiated - * sub-model found in this.object. This is done on intialization and - * everytime the 'change:object' event is fired. - * - * @borrows LoggableMixin#logger as #logger - * @borrows LoggableMixin#log as #log - * @constructs +/* +Notes: + +Terminology: + DatasetCollection/DC : a container of datasets or nested DatasetCollections + Element/DatasetCollectionElement/DCE : an item contained in a DatasetCollection + HistoryDatasetCollectionAssociation/HDCA: a DatasetCollection contained in a history + + +This all seems too complex unfortunately: + +- Terminology collision between DatasetCollections (DCs) and Backbone Collections. +- In the DatasetCollections API JSON, DC Elements use a 'Has A' stucture to *contain* + either a dataset or a nested DC. This would make the hierarchy much taller. I've + decided to merge the contained JSON with the DC element json - making the 'has a' + relation into an 'is a' relation. This seems simpler to me and allowed a lot of + DRY in both models and views, but may make tracking or tracing within these models + more difficult (since DatasetCollectionElements are now *also* DatasetAssociations + or DatasetCollections (nested)). This also violates the rule of thumb about + favoring aggregation over inheritance. +- Currently, there are three DatasetCollection subclasses: List, Pair, and ListPaired. + These each should a) be usable on their own, b) be usable in the context of + nesting within a collection model (at least in the case of ListPaired), and + c) be usable within the context of other container models (like History or + LibraryFolder, etc.). I've tried to separate/extract classes in order to + handle those three situations, but it's proven difficult to do in a simple, + readable manner. +- Ideally, histories and libraries would inherit from the same server models as + dataset collections do since they are (in essence) dataset collections themselves - + making the whole nested structure simpler. This would be a large, error-prone + refactoring and migration. + +Many of the classes and heirarchy are meant as extension points so, while the +relations and flow may be difficult to understand initially, they'll allow us to +handle the growth or flux dataset collection in the future (w/o actually implementing +any YAGNI). + +*/ +//_________________________________________________________________________________________________ ELEMENTS +/** @class mixin for Dataset collection elements. + * When collection elements are passed from the API, the underlying element is + * in a sub-object 'object' (IOW, a DCE representing an HDA will have HDA json in element.object). + * This mixin uses the constructor and parse methods to merge that JSON with the DCE attribtues + * effectively changing a DCE from a container to a subclass. */ -var DatasetCollectionElement = Backbone.Model.extend( BASE_MVC.LoggableMixin ).extend( -/** @lends DatasetCollectionElement.prototype */{ +var DatasetCollectionElementMixin = { - //TODO:?? this model may be unneccessary - it reflects the api structure, but... - // if we munge the element with the element.object at parse, we can flatten the entire hierarchy - - /** logger used to record this.log messages, commonly set to console */ - // comment this out to suppress log output - //logger : console, - + /** default attributes used by elements in a dataset collection */ defaults : { model_class : 'DatasetCollectionElement', element_identifier : null, @@ -31,137 +59,52 @@ element_type : null }, - /** Set up. - * @see Backbone.Collection#initialize - */ - initialize : function( model, options ){ - this.info( this + '.initialize:', model, options ); - options = options || {}; - //this._setUpListeners(); - - this.object = this._createObjectModel(); - this.on( 'change:object', function(){ - //this.log( 'change:object' ); -//TODO: prob. better to update the sub-model instead of re-creating it - this.object = this._createObjectModel(); - }); + /** merge the attributes of the sub-object 'object' into this model */ + _mergeObject : function( attributes ){ + _.extend( attributes, attributes.object ); + delete attributes.object; + return attributes; }, - _createObjectModel : function(){ - //this.log( '_createObjectModel', this.get( 'object' ), this.object ); - //TODO: same patterns as HDCA _createElementsModel - refactor to BASE_MVC.hasSubModel? - if( _.isUndefined( this.object ) ){ this.object = null; } - if( !this.get( 'object' ) ){ return this.object; } - - var object = this.get( 'object' ), - ObjectClass = this._getObjectClass(); - this.unset( 'object', { silent: true }); - this.object = new ObjectClass( object ); - - return this.object; + /** override to merge this.object into this */ + constructor : function( attributes, options ){ + this.debug( '\t DatasetCollectionElement.constructor:', attributes, options ); + attributes = this._mergeObject( attributes ); + Backbone.Model.apply( this, arguments ); }, - _getObjectClass : function(){ - this.debug( 'DCE, element_type:', this.get( 'element_type' ) ); - switch( this.get( 'element_type' ) ){ - case 'dataset_collection': - return DatasetCollection; - case 'hda': - return HDA_MODEL.HistoryDatasetAssociation; - } - throw new TypeError( 'Unknown element_type: ' + this.get( 'element_type' ) ); - }, + /** when the model is fetched, merge this.object into this */ + parse : function( response, options ){ + var attributes = response; + attributes = this._mergeObject( attributes ); + return attributes; + } +}; - toJSON : function(){ - var json = Backbone.Model.prototype.toJSON.call( this ); - if( this.object ){ - json.object = this.object.toJSON(); - } - return json; - }, +//TODO: unused? +/** Concrete class of Generic DatasetCollectionElement */ +var DatasetCollectionElement = Backbone.Model + .extend( BASE_MVC.LoggableMixin ) + .extend( DatasetCollectionElementMixin ); - hasDetails : function(){ - return ( this.object !== null - && this.object.hasDetails() ); - }, - - /** String representation. */ - toString : function(){ - var objStr = ( this.object )?( '' + this.object ):( this.get( 'element_identifier' ) ); - return ([ 'DatasetCollectionElement(', objStr, ')' ].join( '' )); - } -}); - - + //============================================================================== -/** @class Backbone model for - * @borrows LoggableMixin#logger as #logger - * @borrows LoggableMixin#log as #log - * @constructs - */ -var HDADCE = DatasetCollectionElement.extend( -/** @lends DatasetCollectionElement.prototype */{ - - _getObjectClass : function(){ - return HDA_MODEL.HistoryDatasetAssociation; - }, - - /** String representation. */ - toString : function(){ - var objStr = ( this.object )?( '' + this.object ):( this.get( 'element_identifier' ) ); - return ([ 'HDADCE(', objStr, ')' ].join( '' )); - } -}); - - -//============================================================================== -/** @class Backbone model for - * @borrows LoggableMixin#logger as #logger - * @borrows LoggableMixin#log as #log - * @constructs - */ -var DCDCE = DatasetCollectionElement.extend( -/** @lends DatasetCollectionElement.prototype */{ - - _getObjectClass : function(){ - return DatasetCollection; - }, - - getVisibleContents : function(){ - return this.object? this.object.getVisibleContents(): []; - }, - - /** String representation. */ - toString : function(){ - var objStr = ( this.object )?( '' + this.object ):( this.get( 'element_identifier' ) ); - return ([ 'DCDCE(', objStr, ')' ].join( '' )); - } -}); - - -//============================================================================== -/** @class Backbone collection for DCEs. - * NOTE: used *only* in second level of list:paired collections (a - * collection that contains collections) - * - * @borrows LoggableMixin#logger as #logger - * @borrows LoggableMixin#log as #log - * @constructs - */ +/** @class Base/Abstract Backbone collection for Generic DCEs. */ var DCECollection = Backbone.Collection.extend( BASE_MVC.LoggableMixin ).extend( -/** @lends DatasetCollectionElementCollection.prototype */{ +/** @lends DCECollection.prototype */{ model: DatasetCollectionElement, // comment this out to suppress log output /** logger used to record this.log messages, commonly set to console */ //logger : console, +//TODO: unused? /** Set up. * @see Backbone.Collection#initialize */ - initialize : function( models, options ){ + initialize : function( attributes, options ){ + this.debug( this + '(DCECollection).initialize:', attributes, options ); options = options || {}; - this.info( this + '.initialize:', models, options ); //this._setUpListeners(); }, @@ -173,69 +116,98 @@ //============================================================================== -/** @class Backbone collection for - * @borrows LoggableMixin#logger as #logger - * @borrows LoggableMixin#log as #log - * @constructs +/** @class Backbone model for a dataset collection element that is a dataset (HDA). */ -var HDADCECollection = DCECollection.extend( -/** @lends DatasetCollectionElementCollection.prototype */{ - model: HDADCE, +var DatasetDCE = DATASET.DatasetAssociation.extend( BASE_MVC.mixin( DatasetCollectionElementMixin, +/** @lends DatasetDCE.prototype */{ + + /** logger used to record this.log messages, commonly set to console */ + //logger : console, + + defaults : _.extend( {}, DATASET.DatasetAssociation.prototype.defaults, DatasetCollectionElementMixin.defaults ), + + // because all objects have constructors (as this hashmap would even if this next line wasn't present) + // the constructor in hcontentMixin won't be attached by BASE_MVC.mixin to this model + // - re-apply manually it now + /** call the mixin constructor */ + constructor : function( attributes, options ){ + this.debug( '\t DatasetDCE.constructor:', attributes, options ); + //DATASET.DatasetAssociation.prototype.constructor.call( this, attributes, options ); + DatasetCollectionElementMixin.constructor.call( this, attributes, options ); + }, + +//TODO: unused? + /** set up */ + initialize : function( attributes, options ){ + this.debug( this + '(DatasetDCE).initialize:', attributes, options ); + DATASET.DatasetAssociation.prototype.initialize.call( this, attributes, options ); + }, /** String representation. */ toString : function(){ - return ([ 'HDADCECollection(', this.length, ')' ].join( '' )); + var objStr = this.get( 'element_identifier' ); + return ([ 'DatasetDCE(', objStr, ')' ].join( '' )); + } +})); + + +//============================================================================== +/** @class DCECollection of DatasetDCE's (a list of datasets, a pair of datasets). + */ +var DatasetDCECollection = DCECollection.extend( +/** @lends DatasetDCECollection.prototype */{ + model: DatasetDCE, + + /** logger used to record this.log messages, commonly set to console */ + //logger : console, + +//TODO: unused? + /** */ + initialize : function( attributes, options ){ + this.debug( this + '(DatasetDCECollection).initialize:', attributes, options ); + DCECollection.prototype.initialize.call( this, attributes, options ); + }, + + /** String representation. */ + toString : function(){ + return ([ 'DatasetDCECollection(', this.length, ')' ].join( '' )); } }); -//============================================================================== -/** @class Backbone collection for - * @borrows LoggableMixin#logger as #logger - * @borrows LoggableMixin#log as #log - * @constructs +//_________________________________________________________________________________________________ COLLECTIONS +/** @class Backbone model for Dataset Collections. + * The DC API returns an array of JSON objects under the attribute elements. + * This model: + * - removes that array/attribute ('elements') from the model, + * - creates a bbone collection (of the class defined in the 'collectionClass' attribute), + * - passes that json onto the bbone collection + * - caches the bbone collection in this.elements */ -var DCDCECollection = DCECollection.extend( -/** @lends DatasetCollectionElementCollection.prototype */{ - model: DCDCE, +var DatasetCollection = Backbone.Model + .extend( BASE_MVC.LoggableMixin ) + .extend( BASE_MVC.SearchableModelMixin ) +.extend(/** @lends DatasetCollection.prototype */{ - /** String representation. */ - toString : function(){ - return ([ 'DCDCECollection(', this.length, ')' ].join( '' )); - } -}); - - -//============================================================================== -/** @class Backbone model for Dataset Collections. - * DCs contain a bbone collection named 'elements' using the class found in - * this.collectionClass (gen. DatasetCollectionElementCollection). DCs move - * that 'object' from the JSON in the attributes list to a full, instantiated - * collection found in this.elements. This is done on intialization and - * everytime the 'change:elements' event is fired. - * - * @borrows LoggableMixin#logger as #logger - * @borrows LoggableMixin#log as #log - * @constructs - */ -var DatasetCollection = Backbone.Model.extend( BASE_MVC.LoggableMixin ).extend( -/** @lends ListDatasetCollection.prototype */{ - - //logger : console, + /** logger used to record this.log messages, commonly set to console */ + //logger : console, /** default attributes for a model */ defaults : { - collection_type : 'list' + /* 'list', 'paired', or 'list:paired' */ + collection_type : null, + //?? + deleted : false }, + /** Which class to use for elements */ collectionClass : DCECollection, /** */ initialize : function( model, options ){ - this.info( 'DatasetCollection.initialize:', model, options ); + this.debug( this + '(DatasetCollection).initialize:', model, options, this ); //historyContent.HistoryContent.prototype.initialize.call( this, attrs, options ); this.elements = this._createElementsModel(); -//TODO:?? no way to use parse here? this.on( 'change:elements', function(){ this.log( 'change:elements' ); //TODO: prob. better to update the collection instead of re-creating it @@ -245,15 +217,17 @@ /** move elements model attribute to full collection */ _createElementsModel : function(){ - this.log( '_createElementsModel', this.get( 'elements' ), this.elements ); + this.debug( this + '._createElementsModel', this.collectionClass, this.get( 'elements' ), this.elements ); //TODO: same patterns as DatasetCollectionElement _createObjectModel - refactor to BASE_MVC.hasSubModel? var elements = this.get( 'elements' ) || []; - this.info( 'elements:', elements ); this.unset( 'elements', { silent: true }); this.elements = new this.collectionClass( elements ); + //this.debug( 'collectionClass:', this.collectionClass + '', this.elements ); return this.elements; }, + // ........................................................................ common queries + /** pass the elements back within the model json when this is serialized */ toJSON : function(){ var json = Backbone.Model.prototype.toJSON.call( this ); if( this.elements ){ @@ -262,17 +236,43 @@ return json; }, + /** is the collection done with updates and ready to be used? (finished running, etc.) */ + inReadyState : function(){ +//TODO: state currenly unimplemented for collections + return true; + }, + + //TODO:?? the following are the same interface as DatasetAssociation - can we combine? + /** Does the DC contain any elements yet? Is a fetch() required? */ hasDetails : function(){ //TODO: this is incorrect for (accidentally) empty collections this.debug( 'hasDetails:', this.elements.length ); return this.elements.length !== 0; }, + /** Given the filters, what models in this.elements would be returned? */ getVisibleContents : function( filters ){ - //TODO: filters unused for now + // filters unused for now return this.elements; }, + // ........................................................................ ajax + /** save this dataset, _Mark_ing it as deleted (just a flag) */ + 'delete' : function( options ){ + if( this.get( 'deleted' ) ){ return jQuery.when(); } + return this.save( { deleted: true }, options ); + }, + /** save this dataset, _Mark_ing it as undeleted */ + undelete : function( options ){ + if( !this.get( 'deleted' ) || this.get( 'purged' ) ){ return jQuery.when(); } + return this.save( { deleted: false }, options ); + }, + + // ........................................................................ searchable + searchAttributes : [ + 'name' + ], + // ........................................................................ misc /** String representation */ toString : function(){ @@ -283,10 +283,21 @@ //============================================================================== +/** Model for a DatasetCollection containing datasets (non-nested). + */ var ListDatasetCollection = DatasetCollection.extend( /** @lends ListDatasetCollection.prototype */{ - collectionClass : HDADCECollection, + /** logger used to record this.log messages, commonly set to console */ + //logger : console, + + collectionClass : DatasetDCECollection, + +//TODO: unused? + initialize : function( attrs, options ){ + this.debug( this + '(ListDatasetCollection).initialize:', attrs, options ); + DatasetCollection.prototype.initialize.call( this, attrs, options ); + }, /** String representation. */ toString : function(){ @@ -296,8 +307,20 @@ //============================================================================== +/** Model for a DatasetCollection containing fwd/rev datasets (a list of 2). + */ var PairDatasetCollection = ListDatasetCollection.extend( -/** @lends ListDatasetCollection.prototype */{ +/** @lends PairDatasetCollection.prototype */{ + + /** logger used to record this.log messages, commonly set to console */ + //logger : console, + +//TODO: unused? + /** */ + initialize : function( attrs, options ){ + this.debug( this + '(PairDatasetCollection).initialize:', attrs, options ); + ListDatasetCollection.prototype.initialize.call( this, attrs, options ); + }, /** String representation. */ toString : function(){ @@ -306,14 +329,129 @@ }); +//_________________________________________________________________________________________________ NESTED COLLECTIONS +// this is where things get weird, man. Weird. +//TODO: it might be possible to compact all the following...I think. //============================================================================== +/** @class Backbone model for a Generic DatasetCollectionElement that is also a DatasetCollection + * (a nested collection). Currently only list:paired. + */ +var NestedDCDCE = DatasetCollection.extend( BASE_MVC.mixin( DatasetCollectionElementMixin, +/** @lends NestedDCDCE.prototype */{ + + /** logger used to record this.log messages, commonly set to console */ + //logger : console, + + // because all objects have constructors (as this hashmap would even if this next line wasn't present) + // the constructor in hcontentMixin won't be attached by BASE_MVC.mixin to this model + // - re-apply manually it now + /** call the mixin constructor */ + constructor : function( attributes, options ){ + this.debug( '\t NestedDCDCE.constructor:', attributes, options ); + DatasetCollectionElementMixin.constructor.call( this, attributes, options ); + }, + + /** String representation. */ + toString : function(){ + var objStr = ( this.object )?( '' + this.object ):( this.get( 'element_identifier' ) ); + return ([ 'NestedDCDCE(', objStr, ')' ].join( '' )); + } +})); + + +//============================================================================== +/** @class Backbone collection containing Generic NestedDCDCE's (nested dataset collections). + */ +var NestedDCDCECollection = DCECollection.extend( +/** @lends NestedDCDCECollection.prototype */{ + + /** logger used to record this.log messages, commonly set to console */ + //logger : console, + + model: NestedDCDCE, + +//TODO: unused? + /** */ + initialize : function( attrs, options ){ + this.debug( this + '(NestedDCDCECollection).initialize:', attrs, options ); + DCECollection.prototype.initialize.call( this, attrs, options ); + }, + + /** String representation. */ + toString : function(){ + return ([ 'NestedDCDCECollection(', this.length, ')' ].join( '' )); + } +}); + + +//============================================================================== +/** @class Backbone model for a paired dataset collection within a list:paired dataset collection. + */ +var NestedPairDCDCE = PairDatasetCollection.extend( BASE_MVC.mixin( DatasetCollectionElementMixin, +/** @lends NestedPairDCDCE.prototype */{ +//TODO:?? possibly rename to NestedDatasetCollection? + + /** logger used to record this.log messages, commonly set to console */ + //logger : console, + + /** */ + constructor : function( attributes, options ){ + this.debug( '\t NestedPairDCDCE.constructor:', attributes, options ); + //DatasetCollection.constructor.call( this, attributes, options ); + DatasetCollectionElementMixin.constructor.call( this, attributes, options ); + }, + + /** String representation. */ + toString : function(){ + var objStr = ( this.object )?( '' + this.object ):( this.get( 'element_identifier' ) ); + return ([ 'NestedPairDCDCE(', objStr, ')' ].join( '' )); + } +})); + + +//============================================================================== +/** @class Backbone collection for a backbone collection containing paired dataset collections. + */ +var NestedPairDCDCECollection = NestedDCDCECollection.extend( +/** @lends PairDCDCECollection.prototype */{ + + /** logger used to record this.log messages, commonly set to console */ + //logger : console, + + model: NestedPairDCDCE, + +//TODO: unused? + /** */ + initialize : function( attrs, options ){ + this.debug( this + '(NestedPairDCDCECollection).initialize:', attrs, options ); + NestedDCDCECollection.prototype.initialize.call( this, attrs, options ); + }, + + /** String representation. */ + toString : function(){ + return ([ 'NestedPairDCDCECollection(', this.length, ')' ].join( '' )); + } +}); + + +//============================================================================== +/** @class Backbone Model for a DatasetCollection (list) that contains DatasetCollections (pairs). + */ var ListPairedDatasetCollection = DatasetCollection.extend( -/** @lends ListDatasetCollection.prototype */{ +/** @lends ListPairedDatasetCollection.prototype */{ - collectionClass : DCDCECollection, + /** logger used to record this.log messages, commonly set to console */ + //logger : console, // list:paired is the only collection that itself contains collections - //collectionClass : DatasetCollectionCollection, + collectionClass : NestedPairDCDCECollection, + +//TODO: unused? + /** */ + initialize : function( attributes, options ){ + this.debug( this + '(ListPairedDatasetCollection).initialize:', attributes, options ); + DatasetCollection.prototype.initialize.call( this, attributes, options ); + }, /** String representation. */ toString : function(){ @@ -324,7 +462,6 @@ //============================================================================== return { - //DatasetCollection : DatasetCollection, ListDatasetCollection : ListDatasetCollection, PairDatasetCollection : PairDatasetCollection, ListPairedDatasetCollection : ListPairedDatasetCollection diff -r bdc4017c2e7e9ecb5dfa3d36798f535402ec80aa -r 700560884da71e8bd3bdad6ddbe4edfde9566f7f static/scripts/mvc/collection/collection-panel.js --- a/static/scripts/mvc/collection/collection-panel.js +++ b/static/scripts/mvc/collection/collection-panel.js @@ -21,7 +21,6 @@ //MODEL is either a DatasetCollection (or subclass) or a DatasetCollectionElement (list of pairs) /** logger used to record this.log messages, commonly set to console */ - // comment this out to suppress log output //logger : console, tagName : 'div', @@ -30,7 +29,8 @@ /** (in ms) that jquery effects will use */ fxSpeed : 'fast', - DCEViewClass : DC_BASE.DCEBaseView, + DatasetDCEViewClass : DC_BASE.DatasetDCEBaseView, + NestedDCEViewClass : DC_BASE.NestedDCEBaseView, // ......................................................................... SET UP /** Set up the view, set up storage, bind listeners to HistoryContents events @@ -47,8 +47,6 @@ this.hasUser = attributes.hasUser; this.panelStack = []; this.parentName = attributes.parentName; - - window.collectionPanel = this; }, /** create any event listeners for the panel @@ -194,10 +192,8 @@ //this.debug( 'content json:', JSON.stringify( content, null, ' ' ) ); var contentView = null, ContentClass = this._getContentClass( content ); - //this.debug( 'content.object json:', JSON.stringify( content.object, null, ' ' ) ); this.debug( 'ContentClass:', ContentClass ); - //this.debug( 'content:', content ); - this.debug( 'content.object:', content.object ); + this.debug( 'content:', content ); contentView = new ContentClass({ model : content, linkTarget : this.linkTarget, @@ -213,6 +209,7 @@ /** */ _getContentClass : function( content ){ this.debug( this + '._getContentClass:', content ); + this.debug( 'DCEViewClass:', this.DCEViewClass ); switch( content.get( 'element_type' ) ){ case 'hda': return this.DCEViewClass; @@ -336,7 +333,9 @@ // ============================================================================= /** @class non-editable, read-only View/Controller for a dataset collection. */ var ListCollectionPanel = CollectionPanel.extend({ - DCEViewClass : DC_BASE.HDADCEBaseView, + + DCEViewClass : DC_BASE.DatasetDCEBaseView, + // ........................................................................ misc /** string rep */ toString : function(){ @@ -348,6 +347,7 @@ // ============================================================================= /** @class non-editable, read-only View/Controller for a dataset collection. */ var PairCollectionPanel = ListCollectionPanel.extend({ + // ........................................................................ misc /** string rep */ toString : function(){ @@ -359,7 +359,9 @@ // ============================================================================= /** @class non-editable, read-only View/Controller for a dataset collection. */ var ListOfPairsCollectionPanel = CollectionPanel.extend({ - DCEViewClass : DC_BASE.DCDCEBaseView, + + DCEViewClass : DC_BASE.NestedDCDCEBaseView, + // ........................................................................ misc /** string rep */ toString : function(){ This diff is so big that we needed to truncate the remainder. Repository URL: https://bitbucket.org/galaxy/galaxy-central/ -- This is a commit notification from bitbucket.org. You are receiving this because you have the service enabled, addressing the recipient of this email.
participants (1)
-
commits-noreply@bitbucket.org