Skip to content

Commit

Permalink
Rule-based Collection Builder
Browse files Browse the repository at this point in the history
Vue-based component for defining collections by applying rules to a list of files or more general spreadsheet style information (e.g. sample sheets or tabular data from data sources containing URL or FTP file paths for files along with metadata). The widget is fairly complex but very broadly is broken into two panes - one to preview how rules are applied to build up tabular data defining collections (each row corresponding to a file with columns for metadata and such) and one that displays defined rules and allows for editing of these rules and creation of new ones.

The goal behind defining rules this way instead of allowing the user to interact with the spreadsheet display directly is to enable scaling up collection creation. If a user wishes to upload hundreds of datasets - interacting with a widget directly for each input doesn't scale well and would be error prone. If a user wishes to upload hundreds of thousands of datasets - even loading this information in the GUI may not scale (though I've been impressed with the performance so far of this approach) and so we can potentially just display a preview of some of the rows and process the final set of rules on the backend.

Since we can handle an arbitrary number of columns this way, we can define multiple list identifiers per file and so we can easily construct nested lists. Hence this allows creation of not just potentially larger collections but arbitrarily complex lists as well. Paired identifiers via indicator columns are also implemented.

In order to operate over lists of datasets directly - the multi-select history widget now has a new option "Build Collection from Rules" along side the other collection builders. This mode uses the well established dataset collection API to build collections from HDAs.

In order to operate on lists of FTP files or URLs - the upload widget has a new tab "Rule-based" tab that allows users to paste in tabular data or select a history dataset and then send this tabular data to the new builder widget. This will be extended to include FTP directories for instance over time. This mode uses the new data fetch API to build collections and handle uploads of arbitrary collections of files.

The preview of the tabular data generated via rules is done via [Handsontable](https://handsontable.com/) - a JavaScript spreadsheet widget with a VueJS [wrapper component](https://github.com/handsontable/vue-handsontable-official). This turns out to be a fairly nice application for reactive components - as rules are added or modified the spreadsheet just naturally updates. In my hands the widget scales very nicely - I've uploaded files with tens of thousands of rows and rules modifying the data and changing the spreadsheet do not seem to cause siignificant delays in the web browser.
  • Loading branch information
jmchilton committed Jan 23, 2018
1 parent 9e0813b commit 97436cd
Show file tree
Hide file tree
Showing 14 changed files with 2,333 additions and 96 deletions.
1,859 changes: 1,859 additions & 0 deletions client/galaxy/scripts/components/RuleCollectionBuilder.vue

Large diffs are not rendered by default.

122 changes: 98 additions & 24 deletions client/galaxy/scripts/mvc/collection/list-collection-creator.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import baseCreator from "mvc/collection/base-creator";
import UI_MODAL from "mvc/ui/ui-modal";
import naturalSort from "utils/natural-sort";
import _l from "utils/localization";
import RuleCollectionBuilder from "components/RuleCollectionBuilder.vue";
import Vue from "vue";

import "ui/hoverhighlight";

var logNamespace = "collections";
Expand Down Expand Up @@ -1011,17 +1014,11 @@ var ListCollectionCreator = Backbone.View.extend(BASE_MVC.LoggableMixin)
}
});

//=============================================================================
/** Create a modal and load its body with the given CreatorClass creator type
* @returns {Deferred} resolved when creator has built a collection.
*/
var collectionCreatorModal = function _collectionCreatorModal(elements, options, CreatorClass) {
var deferred = jQuery.Deferred();
var modal = Galaxy.modal || new UI_MODAL.View();
var creator;
const collectionCreatorModalSetup = function _collectionCreatorModalSetup(options) {
const deferred = jQuery.Deferred();
const modal = Galaxy.modal || new UI_MODAL.View();

options = _.defaults(options || {}, {
elements: elements,
const creatorOptions = _.defaults(options || {}, {
oncancel: function() {
modal.hide();
deferred.reject("cancelled");
Expand All @@ -1032,18 +1029,61 @@ var collectionCreatorModal = function _collectionCreatorModal(elements, options,
}
});

creator = new CreatorClass(options);
modal.show({
title: options.title || _l("Create a collection"),
body: creator.$el,
width: "80%",
height: "100%",
closing_events: true
const showEl = function(el) {
modal.show({
title: options.title || _l("Create a collection"),
body: el,
width: "80%",
height: "100%",
closing_events: true
});
};

return { deferred, creatorOptions, showEl };
};

//=============================================================================
/** Create a modal and load its body with the given CreatorClass creator type
* @returns {Deferred} resolved when creator has built a collection.
*/
var collectionCreatorModal = function _collectionCreatorModal(elements, options, CreatorClass) {
options = _.defaults(options || {}, {
elements: elements
});
const { deferred, creatorOptions, showEl } = collectionCreatorModalSetup(options);
var creator = new CreatorClass(creatorOptions);
showEl(creator.$el);
creator.render();
window._collectionCreator = creator;
return deferred;
};

//TODO: remove modal header
var ruleBasedCollectionCreatorModal = function _ruleBasedCollectionCreatorModal(elements, elementsType, importType, options) {
let title;
if(importType == "datasets") {
title = _l("Build Rules for Uploading Datasets");
} else if(elementsType == "datasets") {
title = _l("Build Rules for Creating Collection")
} else {
title = _l("Build Rules for Uploading Collections");
}
options = _.defaults(options || {}, {
title: title
});
const { deferred, creatorOptions, showEl } = collectionCreatorModalSetup(options);
var ruleCollectionBuilderInstance = Vue.extend(RuleCollectionBuilder);
var vm = document.createElement("div");
showEl(vm);
new ruleCollectionBuilderInstance({
propsData: {
initialElements: elements,
elementsType: elementsType,
importType: importType,
creationFn: options.creationFn,
oncancel: options.oncancel,
oncreate: options.oncreate,
defaultHideSourceItems: options.defaultHideSourceItems
}
}).$mount(vm);
return deferred;
};

Expand All @@ -1059,15 +1099,14 @@ var listCollectionCreatorModal = function _listCollectionCreatorModal(elements,
* @returns {Deferred} resolved when the collection is added to the history.
*/
function createListCollection(contents, defaultHideSourceItems) {
var elements = contents.toJSON();
const elements = contents.toJSON();

var promise = listCollectionCreatorModal(elements, {
const promise = listCollectionCreatorModal(elements, {
defaultHideSourceItems: defaultHideSourceItems,
creationFn: function(elements, name, hideSourceItems) {
elements = elements.map(element => ({
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"
}));
Expand All @@ -1078,12 +1117,47 @@ function createListCollection(contents, defaultHideSourceItems) {
return promise;
}

function createCollectionViaRules(selection, defaultHideSourceItems) {
let elements, elementsType, importType;
if (!selection.selectionType) {
// Have HDAs from the history panel.
elements = selection.toJSON();
elementsType = "datasets";
importType = "collections";
} else {
const hasNonWhitespaceChars = RegExp(/[^\s]/);
// Have pasted data, data from a history dataset, or FTP list.
const lines = selection.content.split(/[\n\r]/).filter(line => line.length > 0 && hasNonWhitespaceChars.exec(line));

// Really poor tabular parser - we should get a library for this or expose options? I'm not
// sure.
let hasTabs = false;
if(lines.length > 0) {
const firstLine = lines[0];
if(firstLine.indexOf("\t") >= 0) {
hasTabs = true;
}
}
const regex = hasTabs ? /\t/ : /\s+/;
elements = lines.map(line => line.split(regex));
elementsType = selection.selectionType;
importType = selection.dataType || "collections";
}
const promise = ruleBasedCollectionCreatorModal(elements, elementsType, importType, {
defaultHideSourceItems: defaultHideSourceItems,
creationFn: function(elements, collectionType, name, hideSourceItems) {
return selection.createHDCA(elements, collectionType, name, hideSourceItems);
}
});
return promise;
}

//==============================================================================
export default {
DatasetCollectionElementView: DatasetCollectionElementView,
ListCollectionCreator: ListCollectionCreator,

collectionCreatorModal: collectionCreatorModal,
listCollectionCreatorModal: listCollectionCreatorModal,
createListCollection: createListCollection
createListCollection: createListCollection,
createCollectionViaRules: createCollectionViaRules
};
18 changes: 9 additions & 9 deletions client/galaxy/scripts/mvc/history/history-view-edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -290,22 +290,20 @@ var HistoryViewEdit = _super.extend(
return [
{
html: _l("Build Dataset List"),
func: function() {
panel.buildCollection("list");
}
func: () => panel.buildCollection("list")
},
// TODO: Only show quick pair if two things selected.
{
html: _l("Build Dataset Pair"),
func: function() {
panel.buildCollection("paired");
}
func: () => panel.buildCollection("paired")
},
{
html: _l("Build List of Dataset Pairs"),
func: function() {
panel.buildCollection("list:paired");
}
func: () => panel.buildCollection("list:paired")
},
{
html: _l("Build Collection from Rules"),
func: () => panel.buildCollection("rules")
}
];
},
Expand All @@ -321,6 +319,8 @@ var HistoryViewEdit = _super.extend(
createFunc = PAIR_COLLECTION_CREATOR.createPairCollection;
} else if (collectionType == "list:paired") {
createFunc = LIST_OF_PAIRS_COLLECTION_CREATOR.createListOfPairsCollection;
} else if (collectionType.startsWith("rules")) {
createFunc = LIST_COLLECTION_CREATOR.createCollectionViaRules;
} else {
console.warn(`Unknown collectionType encountered ${collectionType}`);
}
Expand Down
176 changes: 176 additions & 0 deletions client/galaxy/scripts/mvc/upload/collection/rules-input-view.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import _l from "utils/localization";
import Ui from "mvc/ui/ui-misc";
import Select from "mvc/ui/ui-select";
import UploadUtils from "mvc/upload/upload-utils";
import axios from "axios";

export default Backbone.View.extend({
initialize: function(app) {
this.app = app;
this.options = app.options;
this.setElement(this._template());
this.btnBuild = new Ui.Button({
id: "btn-build",
title: _l("Build"),
onclick: () => {
this._eventBuild();
}
});
_.each([this.btnBuild], button => {
this.$(".upload-buttons").prepend(button.$el);
});
const dataTypeOptions = [
{ id: "datasets", text: "Datasets" },
{ id: "collections", text: "Collection(s)" },
];
this.dataType = "datasets";
this.dataTypeView = new Select.View({
css: "upload-footer-selection",
container: this.$(".rule-data-type"),
data: dataTypeOptions,
value: this.dataType,
onchange: value => {
this.dataType = value;
// this._renderSelectedType();
}
});

const selectionTypeOptions = [
{ id: "paste", text: "Pasted Table" },
{ id: "dataset", text: "History Dataset" },
{ id: "ftp", text: "FTP Directory" }
];
this.selectionType = "paste";
this.selectionTypeView = new Select.View({
css: "upload-footer-selection",
container: this.$(".rule-select-type"),
data: selectionTypeOptions,
value: this.selectionType,
onchange: value => {
this.selectionType = value;
this._renderSelectedType();
}
});
this.selectedDatasetId = null;

this._renderSelectedType();
},

_renderSelectedType: function() {
const selectionType = this.selectionType;
if (selectionType == "dataset") {
if (!this.datasetSelectorView) {
this.selectedDatasetId = null;
const history = parent.Galaxy && parent.Galaxy.currHistoryPanel && parent.Galaxy.currHistoryPanel.model;
const historyContentModels = history.contents.models;
const options = [];
for (let historyContentModel of historyContentModels) {
const attr = historyContentModel.attributes;
if (attr.history_content_type !== "dataset") {
continue;
}
options.push({ id: attr.id, text: `${attr.hid}: ${_.escape(attr.name)}` });
}
this.datasetSelectorView = new Select.View({
container: this.$(".dataset-selector"),
data: options,
placeholder: _l("Select a dataset"),
onchange: val => {
this._onDataset(val);
}
});
} else {
this.datasetSelectorView.value(null);
}
} else if (selectionType == "ftp") {
UploadUtils.getRemoteFiles(ftp_files => {
this._setPreview(ftp_files.map(file => file["path"]).join("\n"));
});
}
this._updateScreen();
},

_onDataset: function(selectedDatasetId) {
this.selectedDatasetId = selectedDatasetId;
if (!selectedDatasetId) {
this._setPreview("");
return;
}
axios
.get(
`${Galaxy.root}api/histories/${Galaxy.currHistoryPanel.model.id}/contents/${selectedDatasetId}/display`
)
.then(response => {
this._setPreview(response.data);
})
.catch(error => console.log(error));
},

_eventBuild: function() {
const selection = this.$(".upload-rule-source-content").val();
this._buildSelection(selection);
},

_buildSelection: function(content) {
const selectionType = this.selectionType;
const selection = { content: content };
if (selectionType == "dataset" || selectionType == "paste") {
selection.selectionType = "raw";
} else if (selectionType == "ftp") {
selection.selectionType = "ftp";
}
selection.dataType = this.dataType;
Galaxy.currHistoryPanel.buildCollection("rules", selection, true);
this.app.modal.hide();
},

_setPreview: function(content) {
$(".upload-rule-source-content").val(content);
this._updateScreen();
},

_updateScreen: function() {
const selectionType = this.selectionType;
const selection = this.$(".upload-rule-source-content").val();
this.btnBuild[selection || selectionType == "paste" ? "enable" : "disable"]();
this.$("#upload-rule-dataset-option")[selectionType == "dataset" ? "show" : "hide"]();
this.$(".upload-rule-source-content").attr("disabled", selectionType !== "paste");
},

_template: function() {
return `
<div class="upload-view-default">
<div class="upload-top">
<h6 class="upload-top-info">
Tabular source data to extract collection files and metadata from
</h6>
</div>
<div class="upload-box" style="height: 335px;">
<span style="width: 25%; display: inline; height: 100%" class="pull-left">
<div class="upload-rule-option">
<div class="upload-rule-option-title">${_l("Upload data as")}:</div>
<div class="rule-data-type" />
</div>
<div class="upload-rule-option">
<div class="upload-rule-option-title">${_l("Load tabular data from")}:</div>
<div class="rule-select-type" />
</div>
<div id="upload-rule-dataset-option" class="upload-rule-option">
<div class="upload-rule-option-title">${_l("Select dataset to load")}:</div>
<div class="dataset-selector" />
</div>
</span>
<span style="display: inline; float: right; width: 75%; height: 300px">
<textarea class="upload-rule-source-content form-control" style="height: 100%"></textarea>
</span>
</div>
<div class="clear" />
<!--
<div class="upload-footer">
</div>
-->
<div class="upload-buttons"/>
</div>
`;
}
});
Loading

0 comments on commit 97436cd

Please sign in to comment.