Merge pull request #7553 from guerler/add_data_dialog

Add data dialog option to tool form data selector
This commit is contained in:
Martin Cech
2019-04-15 11:25:53 -04:00
committed by GitHub
13 changed files with 805 additions and 320 deletions
@@ -1,232 +0,0 @@
<template>
<b-modal class="data-dialog-modal" v-model="modalShow" :ok-only="true" ok-title="Close">
<template slot="modal-header">
<b-input-group v-if="optionsShow">
<b-input v-model="filter" placeholder="Type to Search" />
<b-input-group-append>
<b-btn :disabled="!filter" @click="filter = ''">Clear</b-btn>
</b-input-group-append>
</b-input-group>
</template>
<b-alert v-if="errorMessage" variant="danger" :show="errorShow"> {{ errorMessage }} </b-alert>
<div v-else>
<div v-if="optionsShow">
<b-table
small
hover
:items="formatedItems"
:fields="fields"
:filter="filter"
@row-clicked="clicked"
@filtered="filtered"
>
<template slot="name" slot-scope="data">
<i v-if="isDataset(data.item)" class="fa fa-file-o" /> <i v-else class="fa fa-copy" />
{{ data.item.hid }}: {{ data.value }}
</template>
<template slot="extension" slot-scope="data">
{{ data.value ? data.value : "-" }}
</template>
<template slot="update_time" slot-scope="data">
{{ data.value ? data.value.substring(0, 16).replace("T", " ") : "-" }}
</template>
<template slot="arrow" slot-scope="data">
<b-button
variant="link"
size="sm"
class="py-0"
v-if="!isDataset(data.item)"
@click.stop="load(data.item.url)"
>
View
</b-button>
</template>
</b-table>
<div v-if="nItems == 0">
<div v-if="filter">
No search results found for: <b>{{ this.filter }}</b
>.
</div>
<div v-else>No entries.</div>
</div>
</div>
<div v-else><span class="fa fa-spinner fa-spin" /> <span>Please wait...</span></div>
</div>
<div slot="modal-footer" class="w-100">
<b-btn size="sm" class="float-left" v-if="undoShow" @click="load()">
<div class="fa fa-caret-left mr-1" />
Back
</b-btn>
<b-btn size="sm" class="float-right ml-1" variant="primary" @click="done" :disabled="values.length === 0">
Ok
</b-btn>
<b-btn size="sm" class="float-right" @click="modalShow = false"> Cancel </b-btn>
</div>
</b-modal>
</template>
<script>
import axios from "axios";
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import { getGalaxyInstance } from "app";
Vue.use(BootstrapVue);
export default {
props: {
callback: {
type: Function,
required: true
},
multiple: {
type: Boolean,
default: false
},
format: {
type: String,
default: "url"
}
},
data() {
return {
errorMessage: null,
errorShow: false,
fields: {
name: {
sortable: true
},
extension: {
sortable: true
},
update_time: {
sortable: true
},
arrow: {
label: "",
sortable: false,
class: "text-right"
}
},
filter: null,
historyId: null,
items: [],
modalShow: true,
nItems: 0,
optionsShow: false,
undoShow: false,
url: null,
values: {}
};
},
computed: {
formatedItems() {
for (let item of this.items) {
if (this.isDataset(item)) {
let key = item.id;
item._rowVariant = this.values[key] ? "success" : "default";
} else {
item._rowVariant = "active";
}
}
return this.items;
}
},
created: function() {
this.load();
},
methods: {
isDataset: function(item) {
return item.history_content_type == "dataset";
},
filtered: function(items) {
this.nItems = items.length;
},
clicked: function(record) {
if (this.isDataset(record)) {
if (!this.multiple) {
this.values = {};
}
let key = record.id;
if (!this.values[key]) {
this.values[key] = record;
} else {
delete this.values[key];
}
this.values = Object.assign({}, this.values);
if (!this.multiple) {
this.done();
}
}
},
done: function() {
let results = [];
Object.values(this.values).forEach(v => {
let value = v.id;
if (this.format == "url") {
let host = `${window.location.protocol}//${window.location.hostname}:${window.location.port}`;
value = `${host}/api/histories/${v.history_id}/contents/${value}/display`;
}
results.push(value);
});
if (results.length > 0 && !this.multiple) {
results = results[0];
}
this.modalShow = false;
this.callback(results);
},
load: function(url) {
let Galaxy = getGalaxyInstance();
this.optionsShow = false;
this.undoShow = false;
let hasUrl = !!url;
if (!hasUrl) {
let historyId = Galaxy.currHistoryPanel && Galaxy.currHistoryPanel.model.id;
if (historyId) {
url = `${Galaxy.root}api/histories/${historyId}/contents`;
} else {
this.errorMessage = "History not accessible.";
return;
}
}
axios
.get(url)
.then(response => {
this.items = [];
this.stack = [response.data];
while (this.stack.length > 0) {
let root = this.stack.pop();
if (Array.isArray(root)) {
root.forEach(element => {
this.stack.push(element);
});
} else if (root.elements) {
this.stack.push(root.elements);
} else if (root.object) {
this.stack.push(root.object);
} else if (root.hid) {
this.items.push(root);
}
}
this.optionsShow = true;
this.undoShow = hasUrl;
})
.catch(e => {
if (e.response) {
this.errorMessage =
e.response.data.err_msg || `${e.response.statusText} (${e.response.status})`;
} else {
this.errorMessage = "Server unavailable.";
}
});
}
}
};
</script>
<style>
.data-dialog-modal .modal-body {
max-height: 50vh;
height: 50vh;
overflow-y: auto;
}
</style>
@@ -0,0 +1,180 @@
import sinon from "sinon";
import { mount } from "@vue/test-utils";
import DataDialog from "./DataDialog.vue";
import { __RewireAPI__ as rewire } from "./DataDialog";
import { Model } from "./model.js";
import { UrlTracker } from "./utilities.js";
import { Services } from "./services";
import Vue from "vue";
const mockOptions = {
callback: () => {},
host: "host",
root: "root",
history: "history"
};
describe("model.js", () => {
let result = null;
it("Model operations for single, no format", () => {
let model = new Model();
try {
model.add({ idx: 1 });
throw "Accepted invalid record.";
} catch (error) {
expect(error).to.equals("Invalid record with no <id>.");
}
model.add({ id: 1 });
expect(model.count()).to.equals(1);
expect(model.exists(1)).to.equals(true);
model.add({ id: 2, tag: "tag" });
expect(model.count()).to.equals(1);
expect(model.exists(1)).to.equals(false);
expect(model.exists(2)).to.equals(true);
result = model.finalize();
expect(result.id).to.equals(2);
expect(result.tag).to.equals("tag");
});
it("Model operations for multiple, with format", () => {
let model = new Model({ multiple: true, format: "tag" });
model.add({ id: 1, tag: "tag_1" });
expect(model.count()).to.equals(1);
model.add({ id: 2, tag: "tag_2" });
expect(model.count()).to.equals(2);
result = model.finalize();
expect(result.length).to.equals(2);
expect(result[0]).to.equals("tag_1");
expect(result[1]).to.equals("tag_2");
model.add({ id: 1 });
expect(model.count()).to.equals(1);
result = model.finalize();
expect(result[0]).to.equals("tag_2");
});
});
describe("utilities.js/UrlTracker", () => {
it("Test url tracker", () => {
let urlTracker = new UrlTracker("url_initial");
let url = urlTracker.getUrl();
expect(url).to.equals("url_initial");
expect(urlTracker.atRoot()).to.equals(true);
url = urlTracker.getUrl("url_1");
expect(url).to.equals("url_1");
expect(urlTracker.atRoot()).to.equals(false);
url = urlTracker.getUrl("url_2");
expect(url).to.equals("url_2");
expect(urlTracker.atRoot()).to.equals(false);
url = urlTracker.getUrl();
expect(url).to.equals("url_1");
expect(urlTracker.atRoot()).to.equals(false);
url = urlTracker.getUrl();
expect(url).to.equals("url_initial");
expect(urlTracker.atRoot()).to.equals(true);
});
});
describe("services/Services:isDataset", () => {
it("Test dataset identifier", () => {
let services = new Services(mockOptions);
expect(services.isDataset({})).to.equals(false);
expect(services.isDataset({ history_content_type: "dataset" })).to.equals(true);
expect(services.isDataset({ history_content_type: "xyz" })).to.equals(false);
expect(services.isDataset({ type: "file" })).to.equals(true);
expect(services.getRecord({ hid: 1, history_content_type: "dataset" }).isDataset).to.equals(true);
expect(services.getRecord({ hid: 2, history_content_type: "xyz" }).isDataset).to.equals(false);
expect(services.getRecord({ type: "file" }).isDataset).to.equals(true);
});
});
describe("services.js/Services", () => {
it("Test data population from raw data", () => {
let rawData = {
hid: 1,
id: 1,
history_id: 0,
name: "name_1"
};
let services = new Services(mockOptions);
let items = services.getItems(rawData);
expect(items.length).to.equals(1);
let first = items[0];
expect(first.name).to.equals("1: name_1");
expect(first.download).to.equals("host/api/histories/0/contents/1/display");
});
});
describe("DataDialog.vue", () => {
let stub;
let wrapper;
let emitted;
let rawData = [
{
id: 1,
hid: 1,
name: "dataset_1",
history_content_type: "dataset"
},
{
id: 2,
name: "dataset_2",
type: "file"
},
{
id: 3,
hid: 3,
name: "collection_1",
history_content_type: "dataset_collection"
}
];
let mockServices = class {
get(url) {
let services = new Services(mockOptions);
let items = services.getItems(rawData);
return new Promise((resolve, reject) => {
resolve(items);
});
}
};
beforeEach(() => {
rewire.__Rewire__("Services", mockServices);
});
afterEach(() => {
if (stub) stub.restore();
});
it("loads correctly, shows alert", () => {
wrapper = mount(DataDialog, {
propsData: mockOptions
});
emitted = wrapper.emitted();
expect(wrapper.classes()).contain("data-dialog-modal");
expect(wrapper.find(".fa-spinner").text()).to.equals("");
expect(wrapper.find(".btn-secondary").text()).to.equals("Clear");
expect(wrapper.find(".btn-primary").text()).to.equals("Ok");
expect(wrapper.contains(".fa-spinner")).to.equals(true);
return Vue.nextTick().then(() => {
expect(wrapper.findAll(".fa-copy").length).to.equals(2);
expect(wrapper.findAll(".fa-file-o").length).to.equals(2);
});
});
it("loads correctly, shows datasets and folders", () => {
wrapper = mount(DataDialog, {
propsData: mockOptions
});
emitted = wrapper.emitted();
expect(wrapper.classes()).contain("data-dialog-modal");
expect(wrapper.find(".fa-spinner").text()).to.equals("");
expect(wrapper.find(".btn-secondary").text()).to.equals("Clear");
expect(wrapper.find(".btn-primary").text()).to.equals("Ok");
expect(wrapper.contains(".fa-spinner")).to.equals(true);
return Vue.nextTick().then(() => {
expect(wrapper.findAll(".fa-copy").length).to.equals(2);
expect(wrapper.findAll(".fa-file-o").length).to.equals(2);
});
});
});
@@ -0,0 +1,160 @@
<template>
<b-modal class="data-dialog-modal" v-if="modalShow" visible ok-only ok-title="Close">
<template slot="modal-header">
<data-dialog-search v-model="filter" />
</template>
<b-alert v-if="errorMessage" variant="danger" show v-html="errorMessage" />
<div v-else>
<data-dialog-table
v-if="optionsShow"
:items="items"
:multiple="multiple"
:filter="filter"
@clicked="clicked"
@load="load"
/>
<div v-else><span class="fa fa-spinner fa-spin" /> <span>Please wait...</span></div>
</div>
<div v-if="!errorMessage" slot="modal-footer" class="w-100">
<b-btn size="sm" class="float-left" v-if="undoShow" @click="load()">
<div class="fa fa-caret-left mr-1" />
Back
</b-btn>
<b-btn size="sm" class="float-right ml-1" variant="primary" @click="finalize" :disabled="!hasValue">
Ok
</b-btn>
<b-btn size="sm" class="float-right" @click="modalShow = false"> Cancel </b-btn>
</div>
</b-modal>
</template>
<script>
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import DataDialogSearch from "./DataDialogSearch.vue";
import DataDialogTable from "./DataDialogTable.vue";
import { UrlTracker } from "./utilities.js";
import { Model } from "./model.js";
import { Services } from "./services.js";
Vue.use(BootstrapVue);
export default {
components: {
"data-dialog-search": DataDialogSearch,
"data-dialog-table": DataDialogTable
},
props: {
callback: {
type: Function,
required: true
},
multiple: {
type: Boolean,
default: false
},
format: {
type: String,
default: "download"
},
library: {
type: Boolean,
default: true
},
root: {
type: String,
required: true
},
host: {
type: String,
required: true
},
history: {
type: String,
required: true
}
},
data() {
return {
errorMessage: null,
filter: null,
items: [],
modalShow: true,
optionsShow: false,
undoShow: false,
hasValue: false,
oldnewItems: false
};
},
created: function() {
this.services = new Services({ root: this.root, host: this.host });
this.urlTracker = new UrlTracker(this.getHistoryUrl());
this.model = new Model({ multiple: this.multiple, format: this.format });
this.load();
},
methods: {
/** Returns the default url i.e. the url of the current history **/
getHistoryUrl: function() {
return `${this.root}api/histories/${this.history}/contents?deleted=false`;
},
/** Add highlighting for record variations, i.e. datasets vs. libraries/collections **/
formatRows() {
for (let item of this.items) {
let _rowVariant = "active";
if (item.isDataset) {
_rowVariant = this.model.exists(item.id) ? "success" : "default";
}
Vue.set(item, "_rowVariant", _rowVariant);
}
},
/** Collects selected datasets in value array **/
clicked: function(record) {
if (record.isDataset) {
this.model.add(record);
this.hasValue = this.model.count() > 0;
if (this.multiple) {
this.formatRows();
} else {
this.finalize();
}
}
},
/** Called when selection is complete, values are formatted and parsed to external callback **/
finalize: function() {
let results = this.model.finalize();
this.modalShow = false;
this.callback(results);
},
/** Performs server request to retrieve data records **/
load: function(url) {
url = this.urlTracker.getUrl(url);
this.filter = null;
this.optionsShow = false;
this.undoShow = !this.urlTracker.atRoot();
this.services
.get(url)
.then(items => {
if (this.library && this.urlTracker.atRoot()) {
items.unshift({
name: "Data Libraries",
url: `${this.root}api/libraries`
});
}
this.items = items;
this.formatRows();
this.optionsShow = true;
})
.catch(errorMessage => {
this.errorMessage = errorMessage;
});
}
}
};
</script>
<style>
.data-dialog-modal .modal-body {
max-height: 50vh;
height: 50vh;
overflow-y: auto;
}
</style>
@@ -0,0 +1,29 @@
<template>
<b-input-group>
<b-input v-model="filter" placeholder="Type to Search" />
<b-input-group-append>
<b-btn :disabled="!filter" @click="filter = ''">Clear</b-btn>
</b-input-group-append>
</b-input-group>
</template>
<script>
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
Vue.use(BootstrapVue);
export default {
props: ["value"],
computed: {
filter: {
get() {
return this.value;
},
set(val) {
this.$emit('input', val);
}
}
}
};
</script>
@@ -0,0 +1,115 @@
<template>
<div>
<b-table
small
hover
:items="items"
:fields="fields"
:filter="filter"
:per-page="perPage"
:current-page="currentPage"
@row-clicked="clicked"
@filtered="filtered"
>
<template slot="name" slot-scope="data">
<i v-if="data.item.isDataset" class="fa fa-file-o" /> <i v-else class="fa fa-copy" />
{{ data.value ? data.value : "-" }}
</template>
<template slot="details" slot-scope="data">
{{ data.value ? data.value : "-" }}
</template>
<template slot="time" slot-scope="data">
{{ data.value ? data.value : "-" }}
</template>
<template slot="arrow" slot-scope="data">
<b-button
variant="link"
size="sm"
class="py-0"
v-if="!data.item.isDataset"
@click.stop="load(data.item.url)"
>
View
</b-button>
</template>
</b-table>
<div v-if="nItems === 0">
<div v-if="filter">
No search results found for: <b>{{ this.filter }}</b
>.
</div>
<div v-else>No entries.</div>
</div>
<b-pagination v-if="nItems > perPage" v-model="currentPage" :per-page="perPage" :total-rows="nItems" />
</div>
</template>
<script>
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
Vue.use(BootstrapVue);
export default {
props: {
items: {
type: Array,
required: true
},
filter: {
type: String,
default: null
},
multiple: {
type: Boolean,
default: false
}
},
data() {
return {
currentPage: 1,
fields: {
name: {
sortable: true
},
details: {
sortable: true
},
time: {
sortable: true
},
arrow: {
label: "",
sortable: false,
class: "text-right"
}
},
nItems: 0,
perPage: 100
};
},
watch: {
items: {
immediate: true,
handler(items) {
this.filtered(items);
}
}
},
methods: {
/** Resets pagination when a filter/search word is entered **/
filtered: function(items) {
this.nItems = items.length;
this.currentPage = 1;
},
/** Collects selected datasets in value array **/
clicked: function(record) {
this.$emit("clicked", record);
},
/** Performs server request to retrieve data records **/
load: function(url) {
this.$emit("load", url);
}
}
};
</script>
@@ -0,0 +1,52 @@
export class Model {
constructor(options = {}) {
this.values = {};
this.multiple = options.multiple || false;
this.format = options.format || null;
}
/** Adds a new record to the value stack **/
add(record) {
if (!this.multiple) {
this.values = {};
}
let key = record && record.id;
if (key) {
if (!this.values[key]) {
this.values[key] = record;
} else {
delete this.values[key];
}
} else {
throw "Invalid record with no <id>.";
}
}
/** Returns the number of added records **/
count() {
return Object.keys(this.values).length;
}
/** Returns true if a record is available for a given key **/
exists(key) {
return !!this.values[key];
}
/** Finalizes the results from added records **/
finalize() {
let results = [];
Object.values(this.values).forEach(v => {
let value = null;
if (this.format) {
value = v[this.format];
} else {
value = v;
}
results.push(value);
});
if (results.length > 0 && !this.multiple) {
results = results[0];
}
return results;
}
}
@@ -0,0 +1,82 @@
import axios from "axios";
/** Data populator traverses raw server responses **/
export class Services {
constructor(options = {}) {
this.root = options.root;
this.host = options.host;
}
get(url) {
return new Promise((resolve, reject) => {
axios
.get(url)
.then(response => {
let items = this.getItems(response.data);
resolve(items);
})
.catch(e => {
let errorMessage = "Request failed.";
if (e.response) {
errorMessage = e.response.data.err_msg || `${e.response.statusText} (${e.response.status})`;
}
reject(errorMessage);
});
});
}
/** Returns the formatted results **/
getItems(data) {
let items = [];
let stack = [data];
while (stack.length > 0) {
let root = stack.pop();
if (Array.isArray(root)) {
root.forEach(element => {
stack.push(element);
});
} else if (root.elements) {
stack.push(root.elements);
} else if (root.object) {
stack.push(root.object);
} else {
let record = this.getRecord(root);
if (record) {
items.push(record);
}
}
}
return items;
}
/** Populate record data from raw record source **/
getRecord(record) {
record.details = record.extension || record.description;
record.time = record.update_time || record.create_time;
record.isDataset = this.isDataset(record);
if (record.time) {
record.time = record.time.substring(0, 16).replace("T", " ");
}
if (record.model_class == "Library") {
record.url = `${this.root}api/libraries/${record.id}/contents`;
return record;
} else if (record.hid) {
record.name = `${record.hid}: ${record.name}`;
record.download = `${this.host}/api/histories/${record.history_id}/contents/${record.id}/display`;
return record;
} else if (record.type == "file") {
if (record.name && record.name[0] === "/") {
record.name = record.name.substring(1);
}
record.download = `${this.host}${this.root}api/libraries/datasets/download/uncompressed?ld_ids=${
record.id
}`;
return record;
}
}
/** Checks if record is a dataset or drillable **/
isDataset(record) {
return record.history_content_type == "dataset" || record.type == "file";
}
}
@@ -0,0 +1,28 @@
/** This helps track urls for data drilling **/
export class UrlTracker {
constructor(root) {
this.root = root;
this.navigation = [];
}
/** Returns urls for data drilling **/
getUrl(url) {
if (url) {
this.navigation.push(url);
} else {
this.navigation.pop();
let navigationLength = this.navigation.length;
if (navigationLength > 0) {
url = this.navigation[navigationLength - 1];
} else {
url = this.root;
}
}
return url;
}
/** Returns true if the last data is at navigation root **/
atRoot() {
return this.navigation.length == 0;
}
}
+9 -2
View File
@@ -1,5 +1,5 @@
import $ from "jquery";
import DataDialog from "components/DataDialog.vue";
import DataDialog from "components/DataDialog/DataDialog.vue";
import Vue from "vue";
import { getGalaxyInstance } from "app";
import { getAppRoot } from "onload/loadConfig";
@@ -10,7 +10,14 @@ export default class Data {
* @param {function} callback - Result function called with selection
*/
dialog(callback, options = {}) {
options.callback = callback;
let galaxy = getGalaxyInstance();
let host = `${window.location.protocol}//${window.location.hostname}:${window.location.port}`;
Object.assign(options, {
callback: callback,
history: galaxy.currHistoryPanel && galaxy.currHistoryPanel.model.id,
root: galaxy.root,
host: host
});
var instance = Vue.extend(DataDialog);
var vm = document.createElement("div");
$("body").append(vm);
+14 -2
View File
@@ -12,6 +12,7 @@ var Base = Backbone.View.extend({
(options && options.model) ||
new Backbone.Model({
visible: true,
cls: null,
data: [],
id: Utils.uid(),
error_text: "No options available.",
@@ -36,6 +37,7 @@ var Base = Backbone.View.extend({
.empty()
.removeClass()
.addClass("ui-options")
.addClass(this.model.get("cls"))
.append((this.$message = $("<div/>").addClass("mt-2")))
.append((this.$menu = $("<div/>").addClass("ui-options-menu")))
.append((this.$options = $(this._template())));
@@ -154,6 +156,16 @@ var Base = Backbone.View.extend({
return this.$(".ui-option").length;
},
/** Shows the options */
show: function() {
this.model.set("visible", true);
},
/** Hides the options */
hide: function() {
this.model.set("visible", false);
},
/** Set value to dom */
_setValue: function(new_value) {
var self = this;
@@ -260,7 +272,7 @@ RadioButton.View = Base.extend({
/** Template for a single option */
_templateOption: function(pair) {
var $el = $("<label/>").addClass("btn btn-secondary");
var $el = $("<label/>").addClass("btn btn-secondary m-0");
if (pair.icon) {
$el.append(
$("<i/>")
@@ -286,7 +298,7 @@ RadioButton.View = Base.extend({
/** Main template function */
_template: function() {
return $("<div/>").addClass("btn-group ui-radiobutton");
return $("<div/>").addClass("btn-group ui-radiobutton d-flex");
}
});
+117 -70
View File
@@ -149,28 +149,11 @@ var View = Backbone.View.extend({
}
]
});
var $batch_div = $("<div/>")
.addClass("form-text text-muted")
.append($("<i/>").addClass("fa fa-sitemap"))
.append(
$("<span/>").html(
"This is a batch mode input field. Separate jobs will be triggered for each dataset selection."
)
);
this.$batch = {
linked: $batch_div.clone(),
enabled: $batch_div
linked: $(this._templateBatch()).clone(),
enabled: $(this._templateBatch())
.clone()
.append(
$("<div/>")
.append(
$("<div/>")
.addClass("ui-form-title")
.html("Batch options:")
)
.append(this.button_product.$el)
)
.append($("<div/>").css("clear", "both"))
.append(this.button_product.$el)
};
// add drag-drop event handlers
@@ -186,7 +169,13 @@ var View = Backbone.View.extend({
this.lastenter === e.target && self.$el.removeClass("ui-dragover");
});
element.addEventListener("drop", e => {
self._handleDrop(e);
e.preventDefault();
try {
let drop_data = JSON.parse(e.dataTransfer.getData("text"))[0];
this._handleDropValues(drop_data);
} catch (e) {
this._handleDropStatus("danger");
}
});
// track current history elements
@@ -234,7 +223,7 @@ var View = Backbone.View.extend({
/** Return the currently selected dataset values */
value: function(new_value) {
let Galaxy = getGalaxyInstance();
let galaxy = getGalaxyInstance();
new_value !== undefined && this.model.set("value", new_value);
var current = this.model.get("current");
if (this.config[current]) {
@@ -248,7 +237,7 @@ var View = Backbone.View.extend({
if (details) {
result.values.push(details);
} else {
Galaxy.emit.debug(
galaxy.emit.debug(
"ui-select-content::value()",
`Requested details not found for '${id_list[i]}'.`
);
@@ -260,7 +249,7 @@ var View = Backbone.View.extend({
}
}
} else {
Galaxy.emit.debug("ui-select-content::value()", `Invalid value/source '${new_value}'.`);
galaxy.emit.debug("ui-select-content::value()", `Invalid value/source '${new_value}'.`);
}
return null;
},
@@ -269,22 +258,37 @@ var View = Backbone.View.extend({
_changeCurrent: function() {
var self = this;
_.each(this.fields, (field, i) => {
let cnf = self.config[i];
if (self.model.get("current") == i) {
field.$el.show();
_.each(self.$batch, ($batchfield, batchmode) => {
$batchfield[self.config[i].batch == batchmode ? "show" : "hide"]();
if (cnf.batch == batchmode) {
$batchfield.show();
} else {
$batchfield.hide();
}
});
if (cnf.showdialog) {
self.button_dialog.show();
} else {
self.button_dialog.hide();
}
self.button_type.value(i);
} else {
field.$el.hide();
}
});
if (this.fields.length > 1) {
this.button_type.show();
} else {
this.button_type.hide();
}
},
/** Change of type */
_changeType: function() {
let Galaxy = getGalaxyInstance();
var self = this;
let self = this;
let galaxy = getGalaxyInstance();
// identify selector type identifier i.e. [ flavor ]_[ type ]_[ multiple ]
var config_id =
@@ -295,7 +299,7 @@ var View = Backbone.View.extend({
this.config = Configurations[config_id];
} else {
this.config = Configurations["data"];
Galaxy.emit.debug("ui-select-content::_changeType()", `Invalid configuration/type id '${config_id}'.`);
galaxy.emit.debug("ui-select-content::_changeType()", `Invalid configuration/type id '${config_id}'.`);
}
// prepare extension component of error message
@@ -303,7 +307,7 @@ var View = Backbone.View.extend({
var extensions = Utils.textify(this.model.get("extensions"));
var src_labels = this.model.get("src_labels");
// build views
// build radio button for data selectors
this.fields = [];
this.button_data = [];
_.each(this.config, (c, i) => {
@@ -329,24 +333,47 @@ var View = Backbone.View.extend({
this.button_type = new Ui.RadioButton.View({
value: this.model.get("current"),
data: this.button_data,
cls: "mr-2",
onchange: function(value) {
self.model.set("current", value);
self.trigger("change");
}
});
// build data dialog button
this.button_dialog = new Ui.Button({
icon: "fa-folder-open-o",
tooltip: "Browse Datasets",
cls: "ml-2",
onclick: () => {
let current = this.model.get("current");
let cnf = this.config[current];
galaxy.data.dialog(
response => {
this._handleDropValues(response, false);
},
{
multiple: cnf.multiple,
format: null,
library: false
}
);
}
});
// append views
this.$el.empty();
var button_width = 0;
if (this.fields.length > 1) {
this.$el.append(this.button_type.$el);
button_width = `${Math.max(0, this.fields.length * 40)}px`;
}
let $fields = $("<div/>").addClass("w-100");
this.$el
.empty()
.addClass("d-flex flex-row")
.append($("<div/>").append(this.button_type.$el))
.append($fields)
.append($("<div/>").append(this.button_dialog.$el));
_.each(this.fields, field => {
self.$el.append(field.$el.css({ "margin-left": button_width }));
$fields.append(field.$el);
});
_.each(this.$batch, ($batchfield, batchmode) => {
self.$el.append($batchfield.css({ "margin-left": button_width }));
$fields.append($batchfield);
});
this.model.set("current", 0);
this._changeCurrent();
@@ -412,52 +439,62 @@ var View = Backbone.View.extend({
}
},
/** Handles drop events e.g. from history panel */
_handleDrop: function(ev) {
try {
var data = this.model.get("data");
var current = this.model.get("current");
var config = this.config[current];
var field = this.fields[current];
var drop_data = JSON.parse(ev.dataTransfer.getData("text"))[0];
var new_id = drop_data.id;
var new_src = drop_data.history_content_type == "dataset_collection" ? "hdca" : "hda";
var new_value = { id: new_id, src: new_src };
if (data && drop_data.history_id) {
if (!_.findWhere(data[new_src], new_value)) {
data[new_src].push({
id: new_id,
src: new_src,
hid: drop_data.hid || "Dropped",
name: drop_data.hid ? drop_data.name : new_id,
keep: true,
tags: []
});
/** Source helper matches history_content_types to source types */
_getSource: function(v) {
return v.history_content_type == "dataset_collection" ? "hdca" : "hda";
},
/** Add values from drag/drop */
_handleDropValues: function(drop_data, drop_partial = true) {
let data = this.model.get("data");
let current = this.model.get("current");
let config = this.config[current];
let field = this.fields[current];
if (data) {
let values = $.isArray(drop_data) ? drop_data : [drop_data];
if (values.length > 0) {
let data_changed = false;
_.each(values, v => {
let new_id = v.id;
let new_src = (v.src = this._getSource(v));
let new_value = { id: new_id, src: new_src };
if (!_.findWhere(data[new_src], new_value)) {
data_changed = true;
data[new_src].push({
id: new_id,
src: new_src,
hid: v.hid || "Selected",
name: v.hid ? v.name : new_id,
keep: true,
tags: []
});
}
});
if (data_changed) {
this._changeData();
}
if (config.src == new_src) {
let first_id = values[0].id;
let first_src = values[0].src;
if (config.src == first_src && drop_partial) {
var current_value = field.value();
if (current_value && config.multiple) {
if (current_value.indexOf(new_id) == -1) {
current_value.push(new_id);
}
_.each(values, v => {
if (current_value.indexOf(v.id) == -1) {
current_value.push(v.id);
}
});
} else {
current_value = new_id;
current_value = first_id;
}
field.value(current_value);
} else {
this.model.set("value", { values: [new_value] });
this.model.set("value", { values: values });
this.model.trigger("change:value");
}
this.trigger("change");
this._handleDropStatus("success");
} else {
this._handleDropStatus("danger");
}
} catch (e) {
this._handleDropStatus("danger");
}
ev.preventDefault();
this._handleDropStatus("success");
},
/** Highlight drag result */
@@ -487,6 +524,16 @@ var View = Backbone.View.extend({
}
}
return result;
},
/** Template for batch mode execution options */
_templateBatch: function() {
return `<div class="form-text text-muted" style="clear: both;">
<i class="fa fa-sitemap"/>
<span>
This is a batch mode input field. Separate jobs will be triggered for each dataset selection.
</span>
</div>`;
}
});
+16 -8
View File
@@ -726,15 +726,18 @@ QUnit.test("select-content", function(assert) {
"Contains " + options.totalmultiple + " multiselect fields"
);
assert.ok(
select.$el.children(".ui-options").find(".ui-option").length ===
(options.selectfields > 1 ? options.selectfields : 0),
"Radio button count"
select.$el.find(".ui-options:first .ui-option").length === options.selectfields,
"Radio button count, expected " + options.selectfields
);
assert.ok(select.$(".ui-select:first").css("display") == "block", "Check select visibility");
assert.ok(
select.$(".ui-select:last").css("display") == (options.selectfields == 1 ? "block" : "none"),
"Last select visibility"
);
/*assert.ok(
(select.button_dialog.$el.css("display") != "none") === options.showdialog,
"Data dialog button visible"
);*/
_testSelect("first", options);
_testSelect("last", options);
};
@@ -759,7 +762,8 @@ QUnit.test("select-content", function(assert) {
lastvalue: "id2",
lastlabel: "hid2: name2",
lastlength: 3,
lastmultiple: false
lastmultiple: false,
showdialog: true
};
_test(initial);
@@ -775,7 +779,8 @@ QUnit.test("select-content", function(assert) {
lastvalue: "id2",
lastlabel: "hid2: name2",
lastlength: 3,
lastmultiple: true
lastmultiple: true,
showdialog: true
});
select.model.set("multiple", false);
@@ -790,7 +795,8 @@ QUnit.test("select-content", function(assert) {
lastvalue: "id2",
lastlabel: "hid2: name2",
lastlength: 3,
lastmultiple: false
lastmultiple: false,
showdialog: false
});
select.model.set("type", "module_data_collection");
@@ -804,7 +810,8 @@ QUnit.test("select-content", function(assert) {
lastvalue: "id2",
lastlabel: "hid2: name2",
lastlength: 3,
lastmultiple: true
lastmultiple: true,
showdialog: false
});
select.model.set("type", "module_data");
@@ -818,7 +825,8 @@ QUnit.test("select-content", function(assert) {
lastvalue: "id0",
lastlabel: "hid0: name0",
lastlength: 2,
lastmultiple: true
lastmultiple: true,
showdialog: true
});
select.model.set("type", "data");
+3 -6
View File
@@ -421,6 +421,9 @@ $ui-margin-horizontal-large: $margin-v * 2;
-webkit-appearance: none;
-moz-border-radius: $border-radius-base;
line-height: 1.5rem;
.select2-chosen {
white-space: normal;
}
.select2-arrow {
display: none;
}
@@ -449,12 +452,6 @@ $ui-margin-horizontal-large: $margin-v * 2;
}
}
.ui-select-content {
.ui-options {
@extend .float-left;
}
}
.ui-dragover {
border-radius: 3px;
border: 2px solid $table-border-color;