Merge pull request #14660 from guerler/strip_history_from_makos

Remove legacy history from code base
This commit is contained in:
Aysam Guerler
2022-09-22 19:51:20 -04:00
committed by GitHub
110 changed files with 206 additions and 11261 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
import $ from "jquery";
import _ from "underscore";
import Backbone from "backbone";
import BASE_MVC from "mvc/base-mvc";
import userModel from "mvc/user/user-model";
import BASE_MVC from "./base-mvc";
import userModel from "./user-model";
import metricsLogger from "utils/metrics-logger";
import addLogging from "utils/add-logging";
import localize from "utils/localization";
@@ -1,6 +1,6 @@
import Backbone from "backbone";
import { getAppRoot } from "onload/loadConfig";
import baseMVC from "mvc/base-mvc";
import baseMVC from "./base-mvc";
import _l from "utils/localization";
var logNamespace = "user";
+1 -10
View File
@@ -18,18 +18,8 @@ import Circster from "viz/circster";
export { PhylovizView as phyloviz } from "viz/phyloviz";
export { SweepsterVisualization, SweepsterVisualizationView } from "viz/sweepster";
export { createTabularDatasetChunkedView } from "mvc/dataset/data";
export { History } from "mvc/history/history-model";
export { HistoryContents } from "mvc/history/history-contents";
// TODO: there are side effects in history-view-edit somewhere; follow up as legacy history panel bits are removed.
import { HistoryViewEdit } from "mvc/history/history-view-edit"; // eslint-disable-line no-unused-vars
export { historyEntry as history } from "mvc/history/history-view";
export { default as HistoryViewAnnotated } from "mvc/history/history-view-annotated";
export { default as HistoryCopyDialog } from "mvc/history/copy-dialog";
export { default as HDAListItemEdit } from "mvc/history/hda-li-edit";
export { default as HDAModel } from "mvc/history/hda-model";
export { default as LegacyGridView } from "legacy/grid/grid-view";
export { create_chart, create_histogram } from "reports/run_stats";
export { default as ToolshedGroups } from "toolshed/toolshed.groups";
export { openGlobalUploadModal } from "components/Upload";
export { runTour } from "components/Tour/runTour";
export { Toast } from "ui/toast"; // TODO: remove when external consumers are updated/gone (IES right now)
@@ -55,6 +45,7 @@ export function chart(options) {
}
export { initMasthead } from "components/Masthead/initMasthead";
export { mountHistory } from "components/History/mount";
export { mountMakoTags } from "components/Tags";
export { mountWorkflowEditor } from "components/Workflow/Editor/mount";
export { mountPageEditor } from "components/PageEditor/mount";
+1 -1
View File
@@ -2,7 +2,7 @@
<div id="webhook-view" ref="webhook" />
</template>
<script>
import Webhooks from "mvc/webhooks";
import Webhooks from "utils/webhooks";
export default {
props: {
+1 -1
View File
@@ -3,7 +3,7 @@
</template>
<script>
import HistoryList from "mvc/history/history-list";
import HistoryList from "./history-list";
export default {
props: {
actionId: {
@@ -52,6 +52,7 @@
</span>
<ContentOptions
v-else
:writable="writable"
:is-dataset="isDataset"
:is-deleted="item.deleted"
:is-history-item="isHistoryItem"
@@ -85,6 +86,7 @@
<DatasetDetails
v-if="expandDataset"
:dataset="item"
:writable="writable"
:show-highlight="isHistoryItem"
:item-urls="itemUrls"
@edit="onEdit"
@@ -94,7 +96,6 @@
</template>
<script>
import { iframeAdd } from "components/plugins/legacyNavigation";
import { StatelessTags } from "components/Tags";
import { STATES, HIERARCHICAL_COLLECTION_JOB_STATES } from "./model/states";
import CollectionDescription from "./Collection/CollectionDescription";
@@ -117,6 +118,7 @@ export default {
FontAwesomeIcon,
},
props: {
writable: { type: Boolean, default: true },
expandDataset: { type: Boolean, required: true },
highlight: { type: String, default: null },
id: { type: Number, required: true },
@@ -169,7 +171,7 @@ export default {
return this.item.tags;
},
tagsDisabled() {
return !this.expandDataset || !this.isHistoryItem;
return !this.writable || !this.expandDataset || !this.isHistoryItem;
},
isCollection() {
return "collection_type" in this.item;
@@ -179,12 +181,12 @@ export default {
const id = this.item.id;
if (this.isCollection) {
return {
edit: `/collection/edit/${id}`,
edit: `/collection/${id}/edit`,
};
}
return {
display: `/datasets/${id}/display/?preview=True`,
edit: `/datasets/edit/${id}`,
display: `/datasets/${id}/preview`,
edit: `/datasets/${id}/edit`,
showDetails: `/datasets/${id}/details`,
reportError: `/datasets/${id}/error`,
rerun: `/tool_runner/rerun?id=${id}`,
@@ -201,7 +203,7 @@ export default {
}
},
onDisplay() {
iframeAdd({ path: this.itemUrls.display, title: this.name });
this.$router.push(this.itemUrls.display, this.name);
},
onDragStart(evt) {
evt.dataTransfer.dropEffect = "move";
@@ -12,7 +12,7 @@
<icon icon="eye" />
</b-button>
<b-button
v-if="isHistoryItem"
v-if="writable && isHistoryItem"
:disabled="editDisabled"
:title="editButtonTitle"
class="edit-btn px-1"
@@ -23,7 +23,7 @@
<icon icon="pen" />
</b-button>
<b-button
v-if="isHistoryItem && !isDeleted"
v-if="writable && isHistoryItem && !isDeleted"
class="delete-btn px-1"
title="Delete"
size="sm"
@@ -32,7 +32,7 @@
<icon icon="trash" />
</b-button>
<b-button
v-if="isHistoryItem && isDeleted"
v-if="writable && isHistoryItem && isDeleted"
class="undelete-btn px-1"
title="Undelete"
size="sm"
@@ -41,7 +41,7 @@
<icon icon="trash-restore" />
</b-button>
<b-button
v-if="isHistoryItem && !isVisible"
v-if="writable && isHistoryItem && !isVisible"
class="unhide-btn px-1"
title="Unhide"
size="sm"
@@ -56,6 +56,7 @@
import { prependPath } from "utils/redirect.js";
export default {
props: {
writable: { type: Boolean, default: true },
isDataset: { type: Boolean, required: true },
isDeleted: { type: Boolean, default: false },
isHistoryItem: { type: Boolean, required: true },
@@ -33,7 +33,7 @@
<span class="fa fa-info-circle" />
</b-button>
<b-button
v-if="showRerun"
v-if="writable && showRerun"
class="rerun-btn px-1"
title="Run Job Again"
size="sm"
@@ -74,7 +74,6 @@ import { copy as sendToClipboard } from "utils/clipboard";
import { absPath, prependPath } from "utils/redirect.js";
import { downloadUrlMixin } from "./mixins.js";
import DatasetDownload from "./DatasetDownload";
import { iframeAdd } from "components/plugins/legacyNavigation";
export default {
components: {
@@ -83,6 +82,7 @@ export default {
mixins: [downloadUrlMixin],
props: {
item: { type: Object, required: true },
writable: { type: Boolean, default: true },
showHighlight: { type: Boolean, default: false },
itemUrls: { type: Object, required: true },
},
@@ -139,11 +139,8 @@ export default {
this.$router.push(`/root?job_id=${this.item.creating_job}`);
},
onVisualize() {
iframeAdd({
path: this.itemUrls.visualize,
title: `Visualization of ${this.item.name || ""}`,
$router: this.$router,
});
const title = `Visualization of ${this.item.name || ""}`;
this.$router.push(this.itemUrls.visualize, title);
},
onHighlight() {
this.$emit("toggleHighlights");
@@ -23,6 +23,7 @@
</div>
<DatasetActions
:item="result"
:writable="writable"
:show-highlight="showHighlight"
:item-urls="itemUrls"
@toggleHighlights="toggleHighlights" />
@@ -97,6 +98,7 @@ export default {
},
props: {
dataset: { type: Object, required: true },
writable: { type: Boolean, default: true },
showHighlight: { type: Boolean, default: false },
itemUrls: { type: Object, required: true },
},
@@ -35,7 +35,10 @@
:filter-text.sync="filterText"
:show-advanced.sync="showAdvanced" />
<section v-if="!showAdvanced">
<HistoryDetails :history="history" @update:history="$emit('updateHistory', $event)" />
<HistoryDetails
:history="history"
:writeable="writable"
@update:history="$emit('updateHistory', $event)" />
<HistoryMessages :history="history" />
<HistoryCounter
v-if="showControls"
@@ -108,6 +111,7 @@
is-history-item
:item="item"
:name="item.name"
:writable="writable"
:expand-dataset="isExpanded(item)"
:is-dataset="isDataset(item)"
:highlight="getHighlight(item)"
@@ -177,6 +181,7 @@ export default {
listOffset: { type: Number, default: 0 },
history: { type: Object, required: true },
filter: { type: String, default: "" },
writable: { type: Boolean, default: true },
showControls: { type: Boolean, default: true },
},
data() {
@@ -31,13 +31,11 @@
v-else
v-on="handlers"
:history="history"
:writable="user.id == history.user_id"
:show-controls="false"
@view-collection="onViewCollection" />
<CopyModal id="copy-history-modal" :history="history" />
</div>
<b-alert v-else class="m-2" variant="info" show>
<LoadingSpan message="Loading History" />
</b-alert>
</UserHistories>
</CurrentUser>
</template>
@@ -46,14 +44,12 @@
import { mapGetters } from "vuex";
import CurrentUser from "components/providers/CurrentUser";
import UserHistories from "components/providers/UserHistories";
import LoadingSpan from "components/LoadingSpan";
import CollectionPanel from "./CurrentCollection/CollectionPanel";
import HistoryPanel from "./CurrentHistory/HistoryPanel";
import CopyModal from "./Modals/CopyModal";
export default {
components: {
LoadingSpan,
HistoryPanel,
CollectionPanel,
CurrentUser,
@@ -1,7 +1,7 @@
<template>
<section class="m-3 details" data-description="edit details">
<b-button
:disabled="currentUser.isAnonymous || !writeable"
v-if="writeable && !currentUser.isAnonymous"
class="edit-button ml-1 float-right"
data-description="editor toggle"
size="sm"
+24
View File
@@ -0,0 +1,24 @@
/**
* Endpoint for mounting History in non-Vue environment.
*/
import Vue from "vue";
import VueRouter from "vue-router";
import HistoryView from "./HistoryView";
import store from "store";
import { safePath } from "utils/redirect";
Vue.use(VueRouter);
export const mountHistory = (el, propsData) => {
const component = Vue.extend(HistoryView);
const router = new VueRouter();
router.push = (url) => {
window.location.href = safePath(url);
};
return new component({
el: el,
propsData: propsData,
router: router,
store: store,
});
};
@@ -79,7 +79,7 @@ import CodeRow from "./CodeRow.vue";
import { JobDetailsProvider } from "components/providers/JobProvider";
import UtcDate from "components/UtcDate";
import CopyToClipboard from "components/CopyToClipboard";
import JOB_STATES_MODEL from "mvc/history/job-states-model";
import JOB_STATES_MODEL from "utils/job-states-model";
import { formatDuration, intervalToDuration } from "date-fns";
export default {
+1 -1
View File
@@ -1,5 +1,5 @@
import { getAppRoot } from "onload/loadConfig";
import JOB_STATES_MODEL from "mvc/history/job-states-model";
import JOB_STATES_MODEL from "utils/job-states-model";
import axios from "axios";
export function waitOnJob(jobId, onStateUpdate = null, interval = 1000) {
@@ -1,7 +1,7 @@
import { getGalaxyInstance } from "app";
import { Toast } from "ui/toast";
import _l from "utils/localization";
import mod_library_model from "mvc/library/library-model";
import mod_library_model from "./library-model";
import _ from "underscore";
import Backbone from "backbone";
import $ from "jquery";
@@ -1,7 +1,7 @@
import { getGalaxyInstance } from "app";
import { Toast } from "ui/toast";
import _l from "utils/localization";
import mod_library_model from "mvc/library/library-model";
import mod_library_model from "./library-model";
import _ from "underscore";
import $ from "jquery";
@@ -1,7 +1,7 @@
import { getGalaxyInstance } from "app";
import { getAppRoot } from "onload/loadConfig";
import { Toast } from "ui/toast";
import mod_library_model from "mvc/library/library-model";
import mod_library_model from "../library-model";
import _ from "underscore";
import Backbone from "backbone";
import axios from "axios";
@@ -1,7 +1,7 @@
import { getGalaxyInstance } from "app";
import { Toast } from "ui/toast";
import _l from "utils/localization";
import mod_library_model from "mvc/library/library-model";
import mod_library_model from "../library-model";
import _ from "underscore";
import Backbone from "backbone";
import $ from "jquery";
+1 -1
View File
@@ -1,4 +1,4 @@
import Webhooks from "mvc/webhooks";
import Webhooks from "utils/webhooks";
import Utils from "utils/utils";
export function loadWebhookMenuItems(items) {
@@ -8,7 +8,7 @@
<script>
import _l from "utils/localization";
import RuleDefs from "mvc/rules/rule-definitions";
import RuleDefs from "./rule-definitions";
const MAPPING_TARGETS = RuleDefs.MAPPING_TARGETS;
export default {
@@ -16,7 +16,7 @@
<script>
import _l from "utils/localization";
import RuleDefs from "mvc/rules/rule-definitions";
import RuleDefs from "./rule-definitions";
const RULES = RuleDefs.RULES;
export default {
@@ -9,7 +9,7 @@
</template>
<script>
import RuleDefs from "mvc/rules/rule-definitions";
import RuleDefs from "./rule-definitions";
const RULES = RuleDefs.RULES;
export default {
@@ -26,7 +26,7 @@
import Vue from "vue";
import _l from "utils/localization";
import BootstrapVue from "bootstrap-vue";
import { RULES, MAPPING_TARGETS } from "mvc/rules/rule-definitions";
import { RULES, MAPPING_TARGETS } from "./rule-definitions";
import UtcDate from "components/UtcDate";
Vue.use(BootstrapVue);
@@ -574,11 +574,11 @@ import _l from "utils/localization";
import { refreshContentsWrapper } from "utils/data";
import HotTable from "@handsontable/vue";
import UploadUtils from "mvc/upload/upload-utils";
import JobStatesModel from "mvc/history/job-states-model";
import RuleDefs from "mvc/rules/rule-definitions";
import JobStatesModel from "utils/job-states-model";
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import Select2 from "components/Select2";
import RuleDefs from "components/RuleBuilder/rule-definitions";
import ColumnSelector from "components/RuleBuilder/ColumnSelector";
import RegularExpressionInput from "components/RuleBuilder/RegularExpressionInput";
import RuleDisplay from "components/RuleBuilder/RuleDisplay";
@@ -3,7 +3,7 @@
</template>
<script>
import RuleDefs from "mvc/rules/rule-definitions";
import RuleDefs from "components/RuleBuilder/rule-definitions";
export default {
props: {
@@ -11,7 +11,7 @@
</template>
<script>
import RuleDefs from "mvc/rules/rule-definitions";
import RuleDefs from "components/RuleBuilder/rule-definitions";
export default {
props: {
rule: {
@@ -17,7 +17,7 @@
</div>
</template>
<script>
import RuleDefs from "mvc/rules/rule-definitions";
import RuleDefs from "components/RuleBuilder/rule-definitions";
import RuleDisplayPreview from "./RuleDisplayPreview";
import IdentifierDisplayPreview from "./IdentifierDisplayPreview";
+1 -1
View File
@@ -112,7 +112,7 @@ import FormMessage from "components/Form/FormMessage";
import ToolFooter from "components/Tool/ToolFooter";
import ToolHelp from "components/Tool/ToolHelp";
import ToolSourceMenuItem from "components/Tool/ToolSourceMenuItem";
import Webhooks from "mvc/webhooks";
import Webhooks from "utils/webhooks";
import { addFavorite, removeFavorite } from "components/Tool/services";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
@@ -27,7 +27,7 @@
</template>
<script>
import { clearPolling, pollUntilActive } from "mvc/entrypoints/poll";
import { clearPolling, pollUntilActive } from "./poll";
import { getAppRoot } from "onload/loadConfig";
export default {
@@ -7,7 +7,7 @@ import UploadModel from "mvc/upload/upload-model";
import UploadWrapper from "./UploadWrapper";
import { defaultNewFileName, uploadModelsToPayload } from "./helpers";
import UploadFtp from "mvc/upload/upload-ftp";
import LazyLimited from "mvc/lazy/lazy-limited";
import LazyLimited from "./lazy-limited";
import { findExtension } from "./utils";
import { filesDialog, refreshContentsWrapper } from "utils/data";
import { getAppRoot } from "onload";
@@ -27,7 +27,7 @@
<script>
import { mapGetters } from "vuex";
import { WorkflowInvocationState } from "components/WorkflowInvocationState";
import Webhooks from "mvc/webhooks";
import Webhooks from "utils/webhooks";
import { getAppRoot } from "onload/loadConfig";
import { refreshContentsWrapper } from "utils/data";
@@ -36,7 +36,7 @@ import WorkflowInvocationSummary from "./WorkflowInvocationSummary.vue";
import WorkflowInvocationDetails from "./WorkflowInvocationDetails.vue";
import WorkflowInvocationExportOptions from "./WorkflowInvocationExportOptions.vue";
import JOB_STATES_MODEL from "mvc/history/job-states-model";
import JOB_STATES_MODEL from "utils/job-states-model";
import mixin from "components/JobStates/mixin";
import { mapGetters, mapActions } from "vuex";
+1 -1
View File
@@ -106,7 +106,7 @@ import { getAppRoot } from "onload/loadConfig";
import axios from "axios";
import JobsTable from "components/admin/JobsTable";
import JobLock from "./JobLock";
import JOB_STATES_MODEL from "mvc/history/job-states-model";
import JOB_STATES_MODEL from "utils/job-states-model";
import { commonJobFields } from "./JobFields";
import { errorMessageAsString } from "utils/simple-error";
import { jobsProvider } from "components/providers/JobProvider";
@@ -2,27 +2,7 @@
* Navigation mixin for legacy code. Supplies functions to components
* to navigation to locations not controlled by Vue.
*/
import { getGalaxyInstance } from "app";
import { redirectToUrl, prependPath } from "utils/redirect";
import _l from "utils/localization";
// wrapper for window manager
export function iframeAdd({ path, title = "Galaxy", $router = null }) {
const Galaxy = getGalaxyInstance();
if (Galaxy.frame && Galaxy.frame.active) {
Galaxy.frame.add({
title: _l(title),
url: path,
});
return true;
} else if ($router) {
$router.push(path);
return true;
} else {
return iframeRedirect(path);
}
}
// straight ifrme redirect
export function iframeRedirect(path, target = "galaxy_main") {
@@ -49,7 +29,6 @@ export function redirect(path) {
export const legacyNavigationMixin = {
methods: {
redirect,
iframeAdd,
iframeRedirect,
prependPath,
},
+1 -1
View File
@@ -1,4 +1,4 @@
import JOB_STATES_MODEL from "mvc/history/job-states-model";
import JOB_STATES_MODEL from "utils/job-states-model";
import { snakeCase } from "snake-case";
export function stateIsTerminal(result) {
+7 -1
View File
@@ -45,6 +45,7 @@ import { getAppRoot } from "onload";
import { HistoryPanelProxy } from "components/History/adapters/HistoryPanelProxy";
import { fetchMenu } from "entry/analysis/menu";
import { WindowManager } from "layout/window-manager";
import { safePath } from "utils/redirect";
export default {
components: {
@@ -97,7 +98,12 @@ export default {
if (!urlObj.target) {
this.$router.push(urlObj.url);
} else {
this.windowManager.add(urlObj);
const url = safePath(urlObj.url);
if (urlObj.target == "_blank") {
window.open(url);
} else {
window.location = url;
}
}
},
},
+17 -3
View File
@@ -6,6 +6,7 @@ import { getGalaxyInstance } from "app";
// these modules are mounted below the masthead.
import Analysis from "entry/analysis/modules/Analysis";
import Home from "entry/analysis/modules/Home";
import CenterFrame from "entry/analysis/modules/CenterFrame";
import Login from "entry/analysis/modules/Login";
import WorkflowEditorModule from "entry/analysis/modules/WorkflowEditor";
@@ -59,7 +60,7 @@ Vue.use(VueRouter);
// patches $router.push() to trigger an event and hide duplication warnings
const originalPush = VueRouter.prototype.push;
VueRouter.prototype.push = function push(location) {
VueRouter.prototype.push = function push(location, windowManagerTitle = null) {
// verify if confirmation is required
console.debug("VueRouter - push: ", location);
if (this.confirmation) {
@@ -69,6 +70,12 @@ VueRouter.prototype.push = function push(location) {
return;
}
}
// show location in window manager
const Galaxy = getGalaxyInstance();
if (windowManagerTitle && Galaxy.frame && Galaxy.frame.active) {
Galaxy.frame.add({ title: windowManagerTitle, url: location });
return;
}
// always emit event when a route is pushed
this.app.$emit("router-push");
// avoid console warning when user clicks to revisit same route
@@ -113,12 +120,12 @@ export function getRouter(Galaxy) {
redirect: redirectAnon(),
},
{
path: "collection/edit/:collection_id",
path: "collection/:collection_id/edit",
component: CollectionEditView,
props: true,
},
{
path: "datasets/edit/:datasetId",
path: "datasets/:datasetId/edit",
component: DatasetAttributes,
props: true,
},
@@ -131,6 +138,13 @@ export function getRouter(Galaxy) {
component: DatasetDetails,
props: true,
},
{
path: "datasets/:datasetId/preview",
component: CenterFrame,
props: (route) => ({
src: `datasets/${route.params.datasetId}/display/?preview=True`,
}),
},
{
// legacy route, potentially used by 3rd parties
path: "datasets/:datasetId/show_params",
+9 -26
View File
@@ -27,32 +27,15 @@ export class WindowManager {
/** Add and display a new window based on options. */
add(options) {
const url = safePath(options.url);
if (options.target == "_blank") {
window.open(url);
} else if (options.target == "_top" || options.target == "_parent" || options.target == "_self") {
window.location = url;
} else if (!this.active) {
const $galaxy_main = window.parent.document.getElementById("galaxy_main");
if (options.target == "galaxy_main" || options.target == "center") {
if ($galaxy_main.length === 0) {
window.location = this._build_url(url, { use_panels: true });
} else {
$galaxy_main.attr("src", url);
}
} else {
window.location = url;
}
} else {
this.counter++;
const boxUrl = this._build_url(url, { hide_panels: true, hide_masthead: true });
WinBox.new({
title: options.title || "Window",
url: boxUrl,
onclose: () => {
this.counter--;
},
});
}
this.counter++;
const boxUrl = this._build_url(url, { hide_panels: true, hide_masthead: true });
WinBox.new({
title: options.title || "Window",
url: boxUrl,
onclose: () => {
this.counter--;
},
});
}
/** Called before closing all windows. */
-83
View File
@@ -1,83 +0,0 @@
import _ from "underscore";
import Backbone from "backbone";
import baseMVC from "mvc/base-mvc";
import _l from "utils/localization";
import "ui/editable-text";
// =============================================================================
/** A view on any model that has a 'annotation' attribute
*/
var AnnotationEditor = Backbone.View.extend(baseMVC.LoggableMixin)
.extend(baseMVC.HiddenUntilActivatedViewMixin)
.extend({
tagName: "div",
className: "annotation-display",
/** Set up listeners, parse options */
initialize: function (options) {
options = options || {};
this.tooltipConfig = options.tooltipConfig || {
placement: "bottom",
};
//console.debug( this, options );
// only listen to the model only for changes to annotations
this.listenTo(this.model, "change:annotation", function () {
this.render();
});
this.hiddenUntilActivated(options.$activator, options);
},
/** Build the DOM elements, call select to on the created input, and set up behaviors */
render: function () {
var view = this;
this.$el.html(this._template());
//TODO: handle empties better
this.$annotation().make_text_editable({
use_textarea: true,
data_description: "annotation input",
on_finish: function (newAnnotation) {
view.$annotation().text(newAnnotation);
view.model.save({ annotation: newAnnotation }, { silent: true }).fail(() => {
view.$annotation().text(view.model.previous("annotation"));
});
},
});
return this;
},
/** @returns {String} the html text used to build the view's DOM */
_template: function () {
var annotation = this.model.get("annotation");
return [
//TODO: make prompt optional
'<label class="prompt">',
_l("Annotation"),
"</label>",
// set up initial tags by adding as CSV to input vals (necc. to init select2)
'<div class="annotation" data-description="annotation value">',
_.escape(annotation),
"</div>",
].join("");
},
/** @returns {jQuery} the main element for this view */
$annotation: function () {
return this.$el.find(".annotation");
},
/** shut down event listeners and remove this view's DOM */
remove: function () {
this.$annotation.off();
this.stopListening(this.model);
Backbone.View.prototype.remove.call(this);
},
/** string rep */
toString: function () {
return ["AnnotationEditor(", `${this.model}`, ")"].join("");
},
});
// =============================================================================
export default {
AnnotationEditor: AnnotationEditor,
};
@@ -1,336 +0,0 @@
import jQuery from "jquery";
import _ from "underscore";
import Backbone from "backbone";
import BASE_MVC from "mvc/base-mvc";
//=============================================================================
/**
* A Collection that can be limited/offset/re-ordered/filtered.
* @type {Backbone.Collection}
*/
var ControlledFetchCollection = Backbone.Collection.extend({
/** call setOrder on initialization to build the comparator based on options */
initialize: function (models, options) {
Backbone.Collection.prototype.initialize.call(this, models, options);
this.setOrder(options.order || this.order, { silent: true });
},
/** set up to track order changes and re-sort when changed */
_setUpListeners: function () {
return this.on({
"changed-order": this.sort,
});
},
/** override to provide order and offsets based on instance vars, set limit if passed,
* and set allFetched/fire 'all-fetched' when xhr returns
*/
fetch: function (options) {
options = this._buildFetchOptions(options);
this.trigger("fetch:started");
return Backbone.Collection.prototype.fetch.call(this, options);
},
/** build ajax data/parameters from options */
_buildFetchOptions: function (options) {
// note: we normally want options passed in to override the defaults built here
// so most of these fns will generate defaults
options = _.clone(options) || {};
// jquery ajax option; allows multiple q/qv for filters (instead of 'q[]')
options.traditional = true;
// options.data
// we keep limit, offset, etc. in options *as well as move it into data* because:
// - it makes fetch calling convenient to add it to a single options map (instead of as mult. args)
// - it allows the std. event handlers (for fetch, etc.) to have access
// to the pagination options too
// (i.e. this.on( 'sync', function( options ){ if( options.limit ){ ... } }))
// however, when we send to xhr/jquery we copy them to data also so that they become API query params
options.data = options.data || this._buildFetchData(options);
// options.data.filters --> options.data.q, options.data.qv
var filters = this._buildFetchFilters(options);
if (!_.isEmpty(filters)) {
_.extend(options.data, this._fetchFiltersToAjaxData(filters));
}
return options;
},
/** Build the dictionary to send to fetch's XHR as data */
_buildFetchData: function (options) {
var defaults = {};
if (this.order) {
defaults.order = this.order;
}
return _.defaults(_.pick(options, this._fetchParams), defaults);
},
/** These attribute keys are valid params to fetch/API-index */
_fetchParams: [
/** model dependent string to control the order of models returned */
"order",
/** limit the number of models returned from a fetch */
"limit",
/** skip this number of models when fetching */
"offset",
/** what series of attributes to return (model dependent) */
"view",
/** individual keys to return for the models (see api/histories.index) */
"keys",
],
/** add any needed filters here based on collection state */
_buildFetchFilters: function (options) {
// override
return _.clone(options.filters || {});
},
/** Convert dictionary filters to qqv style arrays */
_fetchFiltersToAjaxData: function (filters) {
// return as a map so ajax.data can extend from it
var filterMap = {
q: [],
qv: [],
};
_.each(filters, (v, k) => {
// don't send if filter value is empty
if (v === undefined || v === "") {
return;
}
// json to python
if (v === true) {
v = "True";
}
if (v === false) {
v = "False";
}
if (v === null) {
v = "None";
}
// map to k/v arrays (q/qv)
filterMap.q.push(k);
filterMap.qv.push(v);
});
return filterMap;
},
/** override to reset allFetched flag to false */
reset: function (models, options) {
this.allFetched = false;
return Backbone.Collection.prototype.reset.call(this, models, options);
},
// ........................................................................ order
order: null,
/** @type {Object} map of collection available sorting orders containing comparator fns */
comparators: {
update_time: BASE_MVC.buildComparator("update_time", {
ascending: false,
}),
"update_time-asc": BASE_MVC.buildComparator("update_time", {
ascending: true,
}),
create_time: BASE_MVC.buildComparator("create_time", {
ascending: false,
}),
"create_time-asc": BASE_MVC.buildComparator("create_time", {
ascending: true,
}),
},
/** set the order and comparator for this collection then sort with the new order
* @event 'changed-order' passed the new order and the collection
*/
setOrder: function (order, options) {
options = options || {};
var collection = this;
var comparator = collection.comparators[order];
if (_.isUndefined(comparator)) {
throw new Error(`unknown order: ${order}`);
}
// if( _.isUndefined( comparator ) ){ return; }
if (comparator === collection.comparator) {
return;
}
collection.order = order;
collection.comparator = comparator;
if (!options.silent) {
collection.trigger("changed-order", options);
}
return collection;
},
});
//=============================================================================
/**
*
*/
var PaginatedCollection = ControlledFetchCollection.extend({
/** @type {Number} limit used for each page's fetch */
limitPerPage: 500,
initialize: function (models, options) {
ControlledFetchCollection.prototype.initialize.call(this, models, options);
this.currentPage = options.currentPage || 0;
},
getTotalItemCount: function () {
return this.length;
},
shouldPaginate: function () {
return this.getTotalItemCount() >= this.limitPerPage;
},
getLastPage: function () {
return Math.floor(this.getTotalItemCount() / this.limitPerPage);
},
getPageCount: function () {
return this.getLastPage() + 1;
},
getPageLimitOffset: function (pageNum) {
pageNum = this.constrainPageNum(pageNum);
return {
limit: this.limitPerPage,
offset: pageNum * this.limitPerPage,
};
},
constrainPageNum: function (pageNum) {
return Math.max(0, Math.min(pageNum, this.getLastPage()));
},
/** fetch the next page of data */
fetchPage: function (pageNum, options) {
var self = this;
pageNum = self.constrainPageNum(pageNum);
self.currentPage = pageNum;
options = _.defaults(options || {}, self.getPageLimitOffset(pageNum));
self.trigger("fetching-more");
return self.fetch(options).always(() => {
self.trigger("fetching-more-done");
});
},
fetchCurrentPage: function (options) {
return this.fetchPage(this.currentPage, options);
},
fetchPrevPage: function (options) {
return this.fetchPage(this.currentPage - 1, options);
},
fetchNextPage: function (options) {
return this.fetchPage(this.currentPage + 1, options);
},
});
//=============================================================================
/**
* A Collection that will load more elements without reseting.
*/
var InfinitelyScrollingCollection = ControlledFetchCollection.extend({
/** @type {Number} limit used for the first fetch (or a reset) */
limitOnFirstFetch: null,
/** @type {Number} limit used for each subsequent fetch */
limitPerFetch: 100,
initialize: function (models, options) {
ControlledFetchCollection.prototype.initialize.call(this, models, options);
/** @type {Integer} number of contents to return from the first fetch */
this.limitOnFirstFetch = options.limitOnFirstFetch || this.limitOnFirstFetch;
/** @type {Integer} limit for every fetch after the first */
this.limitPerFetch = options.limitPerFetch || this.limitPerFetch;
/** @type {Boolean} are all contents fetched? */
this.allFetched = false;
/** @type {Integer} what was the offset of the last content returned */
this.lastFetched = options.lastFetched || 0;
},
/** build ajax data/parameters from options */
_buildFetchOptions: function (options) {
// options (options for backbone.fetch and jquery.ajax generally)
// backbone option; false here to make fetching an addititive process
options.remove = options.remove || false;
return ControlledFetchCollection.prototype._buildFetchOptions.call(this, options);
},
/** fetch the first 'page' of data */
fetchFirst: function (options) {
options = options ? _.clone(options) : {};
this.allFetched = false;
this.lastFetched = 0;
return this.fetchMore(
_.defaults(options, {
reset: true,
limit: this.limitOnFirstFetch,
})
);
},
/** fetch the next page of data */
fetchMore: function (options) {
options = _.clone(options || {});
var collection = this;
if (!options.reset && collection.allFetched) {
return jQuery.when();
}
// TODO: this fails in the edge case where
// the first fetch offset === limit (limit 4, offset 4, collection.length 4)
if (options.reset) {
options.offset = 0;
} else if (options.offset === undefined) {
options.offset = collection.lastFetched;
}
var limit = (options.limit = options.limit || collection.limitPerFetch || null);
collection.trigger("fetching-more");
return (
collection
.fetch(options)
.always(() => {
collection.trigger("fetching-more-done");
})
// maintain allFetched flag and trigger if all were fetched this time
.done(function _postFetchMore(fetchedData) {
var numFetched = _.isArray(fetchedData) ? fetchedData.length : 0;
collection.lastFetched += numFetched;
// anything less than a full page means we got all there is to get
if (!limit || numFetched < limit) {
collection.allFetched = true;
collection.trigger("all-fetched", this);
}
})
);
},
/** fetch all the collection */
fetchAll: function (options) {
// whitelist options to prevent allowing limit/offset/filters
// (use vanilla fetch instead)
options = options || {};
var self = this;
options = _.pick(options, "silent");
options.filters = {};
return self.fetch(options).done(() => {
self.allFetched = true;
self.trigger("all-fetched", self);
});
},
});
//==============================================================================
export default {
ControlledFetchCollection: ControlledFetchCollection,
PaginatedCollection: PaginatedCollection,
InfinitelyScrollingCollection: InfinitelyScrollingCollection,
};
@@ -1,115 +0,0 @@
import $ from "jquery";
import _ from "underscore";
import DC_LI from "mvc/collection/collection-li";
import DATASET_LI_EDIT from "mvc/dataset/dataset-li-edit";
//==============================================================================
var DCListItemView = DC_LI.DCListItemView;
/** @class Edit view for DatasetCollection.
*/
var DCListItemEdit = DCListItemView.extend(
/** @lends DCListItemEdit.prototype */ {
/** override to add linkTarget */
initialize: function (attributes) {
DCListItemView.prototype.initialize.call(this, attributes);
},
// ......................................................................... misc
/** String representation */
toString: function () {
var modelString = this.model ? `${this.model}` : "(no model)";
return `DCListItemEdit(${modelString})`;
},
}
);
//==============================================================================
var DCEListItemView = DC_LI.DCEListItemView;
/** @class Read only view for DatasetCollectionElement.
*/
var DCEListItemEdit = DCEListItemView.extend(
/** @lends DCEListItemEdit.prototype */ {
//TODO: this might be expendable - compacted with HDAListItemView
/** set up */
initialize: function (attributes) {
DCEListItemView.prototype.initialize.call(this, attributes);
},
// ......................................................................... misc
/** String representation */
toString: function () {
var modelString = this.model ? `${this.model}` : "(no model)";
return `DCEListItemEdit(${modelString})`;
},
}
);
//==============================================================================
// NOTE: this does not inherit from DatasetDCEListItemView as you would expect
//TODO: but should - if we can find something simpler than using diamond
/** @class Editable view for a DatasetCollectionElement that is also an DatasetAssociation
* (a dataset contained in a dataset collection).
*/
var DatasetDCEListItemEdit = DATASET_LI_EDIT.DatasetListItemEdit.extend(
/** @lends DatasetDCEListItemEdit.prototype */ {
/** set up */
initialize: function (attributes) {
DATASET_LI_EDIT.DatasetListItemEdit.prototype.initialize.call(this, attributes);
},
// NOTE: this does not inherit from DatasetDCEListItemView - so we duplicate this here
//TODO: fix
/** In this override, only get details if in the ready state.
* Note: fetch with no 'change' event triggering to prevent automatic rendering.
*/
_fetchModelDetails: function () {
var view = this;
if (view.model.inReadyState() && !view.model.hasDetails()) {
return view.model.fetch({ silent: true });
}
return $.when();
},
/** Override to remove delete button */
_renderDeleteButton: function () {
return null;
},
// ......................................................................... misc
/** String representation */
toString: function () {
var modelString = this.model ? `${this.model}` : "(no model)";
return `DatasetDCEListItemEdit(${modelString})`;
},
}
);
// ............................................................................ TEMPLATES
/** underscore templates */
DatasetDCEListItemEdit.prototype.templates = (() =>
_.extend({}, DATASET_LI_EDIT.DatasetListItemEdit.prototype.templates, {
titleBar: DC_LI.DatasetDCEListItemView.prototype.templates.titleBar,
}))();
//==============================================================================
/** @class Read only view for a DatasetCollectionElement that is also a DatasetCollection
* (a nested DC).
*/
var NestedDCDCEListItemEdit = DC_LI.NestedDCDCEListItemView.extend(
/** @lends NestedDCDCEListItemEdit.prototype */ {
/** String representation */
toString: function () {
var modelString = this.model ? `${this.model}` : "(no model)";
return `NestedDCDCEListItemEdit(${modelString})`;
},
}
);
//==============================================================================
export default {
DCListItemEdit: DCListItemEdit,
DCEListItemEdit: DCEListItemEdit,
DatasetDCEListItemEdit: DatasetDCEListItemEdit,
NestedDCDCEListItemEdit: NestedDCDCEListItemEdit,
};
-277
View File
@@ -1,277 +0,0 @@
import _ from "underscore";
import $ from "jquery";
import { ListItemView, FoldoutListItemView } from "mvc/list/list-item";
import { DatasetListItemView } from "mvc/dataset/dataset-li";
import BASE_MVC from "mvc/base-mvc";
import _l from "utils/localization";
/** @class Read only view for DatasetCollection.
*/
var DCListItemView = FoldoutListItemView.extend(
/** @lends DCListItemView.prototype */ {
className: `${FoldoutListItemView.prototype.className} dataset-collection`,
id: function () {
return ["dataset_collection", this.model.get("id")].join("-");
},
/** override to add linkTarget */
initialize: function (attributes) {
this.linkTarget = attributes.linkTarget || "_blank";
this.hasUser = attributes.hasUser;
FoldoutListItemView.prototype.initialize.call(this, attributes);
},
/** event listeners */
_setUpListeners: function () {
FoldoutListItemView.prototype._setUpListeners.call(this);
this.listenTo(this.model, "change", function (model, options) {
// if the model has changed deletion status render it entirely
if (_.has(model.changed, "deleted")) {
this.render();
// if the model has been decorated after the fact with the element count,
// render the subtitle where the count is displayed
} else if (_.has(model.changed, "element_count")) {
this.$("> .title-bar .subtitle").replaceWith(this._renderSubtitle());
}
});
},
// ......................................................................... rendering
/** render a subtitle to show the user what sort of collection this is */
_renderSubtitle: function () {
return $(this.templates.subtitle(this.model.toJSON(), this));
},
// ......................................................................... foldout
/** override to add linktarget to sub-panel */
_getFoldoutPanelOptions: function () {
var options = FoldoutListItemView.prototype._getFoldoutPanelOptions.call(this);
return _.extend(options, {
linkTarget: this.linkTarget,
hasUser: this.hasUser,
});
},
/** override to not catch sub-panel selectors */
$selector: function () {
return this.$("> .selector");
},
// ......................................................................... misc
/** String representation */
toString: function () {
var modelString = this.model ? `${this.model}` : "(no model)";
return `DCListItemView(${modelString})`;
},
}
);
// ............................................................................ TEMPLATES
/** underscore templates */
DCListItemView.prototype.templates = (() => {
var warnings = _.extend({}, FoldoutListItemView.prototype.templates.warnings, {
error: BASE_MVC.wrapTemplate([
// error during index fetch - show error on dataset
"<% if( model.error ){ %>",
'<div class="errormessagesmall">',
_l("There was an error getting the data for this collection"),
": <%- model.error %>",
"</div>",
"<% } %>",
]),
purged: BASE_MVC.wrapTemplate([
"<% if( model.purged ){ %>",
'<div class="purged-msg warningmessagesmall">',
_l("This collection has been deleted and removed from disk"),
"</div>",
"<% } %>",
]),
deleted: BASE_MVC.wrapTemplate([
// deleted not purged
"<% if( model.deleted && !model.purged ){ %>",
'<div class="deleted-msg warningmessagesmall">',
_l("This collection has been deleted"),
"</div>",
"<% } %>",
]),
});
// use element identifier
var titleBarTemplate = BASE_MVC.wrapTemplate(
[
'<div class="title-bar clear" tabindex="0">',
'<div class="title">',
'<span class="name"><%- collection.element_identifier || collection.name %></span>',
"</div>",
'<div class="subtitle"></div>',
"</div>",
],
"collection"
);
// use element identifier
var subtitleTemplate = BASE_MVC.wrapTemplate(
[
'<div class="subtitle">',
'<% var countText = collection.element_count? ( collection.element_count + " " ) : ""; %>',
'<% if( collection.collection_type === "list" ){ %>',
_l("a list of <%- countText %>datasets"),
'<% } else if( collection.collection_type === "paired" ){ %>',
_l("a pair of datasets"),
'<% } else if( collection.collection_type === "list:paired" ){ %>',
_l("a list of <%- countText %>dataset pairs"),
'<% } else if( collection.collection_type === "list:list" ){ %>',
_l("a list of <%- countText %>dataset lists"),
"<% } %>",
"</div>",
],
"collection"
);
return _.extend({}, FoldoutListItemView.prototype.templates, {
warnings: warnings,
titleBar: titleBarTemplate,
subtitle: subtitleTemplate,
});
})();
//==============================================================================
/** @class Read only view for DatasetCollectionElement.
*/
var DCEListItemView = ListItemView.extend(
/** @lends DCEListItemView.prototype */ {
/** add the DCE class to the list item */
className: `${ListItemView.prototype.className} dataset-collection-element`,
/** set up */
initialize: function (attributes) {
if (attributes.logger) {
this.logger = this.model.logger = attributes.logger;
}
this.log("DCEListItemView.initialize:", attributes);
ListItemView.prototype.initialize.call(this, attributes);
},
// ......................................................................... misc
/** String representation */
toString: function () {
var modelString = this.model ? `${this.model}` : "(no model)";
return `DCEListItemView(${modelString})`;
},
}
);
// ............................................................................ TEMPLATES
/** underscore templates */
DCEListItemView.prototype.templates = (() => {
// use the element identifier here - since that will persist and the user will need it
var titleBarTemplate = BASE_MVC.wrapTemplate(
[
'<div class="title-bar clear" tabindex="0">',
'<div class="title">',
'<span class="name"><%- element.element_identifier %></span>',
"</div>",
'<div class="subtitle"></div>',
"</div>",
],
"element"
);
return _.extend({}, ListItemView.prototype.templates, {
titleBar: titleBarTemplate,
});
})();
//==============================================================================
/** @class Read only view for a DatasetCollectionElement that is also an DatasetAssociation
* (a dataset contained in a dataset collection).
*/
var DatasetDCEListItemView = DatasetListItemView.extend(
/** @lends DatasetDCEListItemView.prototype */ {
className: `${DatasetListItemView.prototype.className} dataset-collection-element`,
/** set up */
initialize: function (attributes) {
if (attributes.logger) {
this.logger = this.model.logger = attributes.logger;
}
this.log("DatasetDCEListItemView.initialize:", attributes);
DatasetListItemView.prototype.initialize.call(this, attributes);
},
/** In this override, only get details if in the ready state.
* Note: fetch with no 'change' event triggering to prevent automatic rendering.
*/
_fetchModelDetails: function () {
var view = this;
if (view.model.inReadyState() && !view.model.hasDetails()) {
return view.model.fetch({ silent: true });
}
return $.when();
},
// ......................................................................... misc
/** String representation */
toString: function () {
var modelString = this.model ? `${this.model}` : "(no model)";
return `DatasetDCEListItemView(${modelString})`;
},
}
);
// ............................................................................ TEMPLATES
/** underscore templates */
DatasetDCEListItemView.prototype.templates = (() => {
// use the element identifier here and not the dataset name
//TODO:?? can we steal the DCE titlebar?
var titleBarTemplate = BASE_MVC.wrapTemplate(
[
'<div class="title-bar clear" tabindex="0">',
'<span class="state-icon"></span>',
'<div class="title">',
'<span class="name"><%- element.element_identifier %></span>',
"</div>",
"</div>",
],
"element"
);
return _.extend({}, DatasetListItemView.prototype.templates, {
titleBar: titleBarTemplate,
});
})();
//==============================================================================
/** @class Read only view for a DatasetCollectionElement that is also a DatasetCollection
* (a nested DC).
*/
var NestedDCDCEListItemView = DCListItemView.extend(
/** @lends NestedDCDCEListItemView.prototype */ {
className: `${DCListItemView.prototype.className} dataset-collection-element`,
/** In this override, add the state as a class for use with state-based CSS */
_swapNewRender: function ($newRender) {
DCListItemView.prototype._swapNewRender.call(this, $newRender);
var state = this.model.get("state") || "ok";
this.$el.addClass(`state-${state}`);
this.$el.attr("data-state", state);
return this.$el;
},
// ......................................................................... misc
/** String representation */
toString: function () {
var modelString = this.model ? `${this.model}` : "(no model)";
return `NestedDCDCEListItemView(${modelString})`;
},
}
);
//==============================================================================
export default {
DCListItemView: DCListItemView,
DCEListItemView: DCEListItemView,
DatasetDCEListItemView: DatasetDCEListItemView,
NestedDCDCEListItemView: NestedDCDCEListItemView,
};
@@ -1,396 +0,0 @@
import $ from "jquery";
import _ from "underscore";
import Backbone from "backbone";
import { getAppRoot } from "onload/loadConfig";
import DATASET_MODEL from "mvc/dataset/dataset-model";
import BASE_MVC from "mvc/base-mvc";
import Utils from "utils/utils";
//==============================================================================
/*
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 (has a --> is a).
*/
var DatasetCollectionElementMixin = {
/** default attributes used by elements in a dataset collection */
defaults: {
model_class: "DatasetCollectionElement",
element_identifier: null,
element_index: null,
element_type: null,
},
/** merge the attributes of the sub-object 'object' into this model */
_mergeObject: function (attributes) {
// Don't let the dataset ID replace the DCE's ID so record it as the
// element_id and when fetching dataset details below use the element_id
// instead of this.id.
const object = attributes.object;
let elementId = this.elementId;
if (object) {
elementId = attributes.object.id;
delete attributes.object.id;
}
_.extend(attributes, attributes.object, {
element_id: elementId,
});
delete attributes.object;
return attributes;
},
/** override to merge this.object into this */
constructor: function (attributes, options) {
// console.debug( '\t DatasetCollectionElement.constructor:', attributes, options );
attributes = this._mergeObject(attributes);
this.idAttribute = "element_id";
Backbone.Model.apply(this, arguments);
},
/** when the model is fetched, merge this.object into this */
parse: function (response, options) {
var attributes = response;
attributes = this._mergeObject(attributes);
return attributes;
},
};
/** @class Concrete class of Generic DatasetCollectionElement */
var DatasetCollectionElement = Backbone.Model.extend(BASE_MVC.LoggableMixin)
.extend(DatasetCollectionElementMixin)
.extend({ _logNamespace: "collections" });
//==============================================================================
/** @class Base/Abstract Backbone collection for Generic DCEs -
current may be associated with a dataset (DatasetDCECollection)
or another collection (NestedDCDCECollection).
*/
var DCECollection = Backbone.Collection.extend(BASE_MVC.LoggableMixin).extend(
/** @lends DCECollection.prototype */ {
_logNamespace: "collections",
model: DatasetCollectionElement,
/** String representation. */
toString: function () {
return ["DatasetCollectionElementCollection(", this.length, ")"].join("");
},
}
);
//==============================================================================
/** @class Backbone model for a dataset collection element that is a dataset (HDA).
*/
var DatasetDCE = DATASET_MODEL.DatasetAssociation.extend(
BASE_MVC.mixin(
DatasetCollectionElementMixin,
/** @lends DatasetDCE.prototype */ {
/** url fn */
url: function () {
// won't always be an hda
if (!this.has("history_id")) {
console.warn("no endpoint for non-hdas within a collection yet");
// (a little silly since this api endpoint *also* points at hdas)
return `${getAppRoot()}api/datasets`;
}
const datasetId = this._getDatasetId();
return `${getAppRoot()}api/histories/${this.get("history_id")}/contents/${datasetId}`;
},
_getDatasetId: function () {
// I'm a DCE acting as dataset, this URL needs to be the dataset URL so
// use element_id instead of id. See note above in _mergeObject and
// discussion on #3782 for more context.
return this.get("element_id");
},
defaults: _.extend(
{},
DATASET_MODEL.DatasetAssociation.prototype.defaults,
DatasetCollectionElementMixin.defaults
),
_downloadQueryParameters: function () {
// Setting the file extension to just 'data' defers that
// decision to the serverside, setting based on the datatype.
var fileExt = this.get("file_ext") || "data";
var elementIdentifier = this.get("element_identifier");
var parentHdcaId = this.get("parent_hdca_id");
return `?to_ext=${fileExt}&hdca_id=${parentHdcaId}&element_identifier=${elementIdentifier}`;
},
// 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 for now
/** call the mixin constructor */
constructor: function (attributes, options) {
this.debug("\t DatasetDCE.constructor:", attributes, options);
//DATASET_MODEL.DatasetAssociation.prototype.constructor.call( this, attributes, options );
DatasetCollectionElementMixin.constructor.call(this, attributes, options);
},
/** Does this model already contain detailed data (as opposed to just summary level data)? */
hasDetails: function () {
return this.elements && this.elements.length;
},
/** String representation. */
toString: function () {
var objStr = this.get("element_identifier");
return `DatasetDCE(${objStr})`;
},
}
)
);
//==============================================================================
/** @class DCECollection of DatasetDCE's (a list of datasets, a pair of datasets).
*/
var DatasetDCECollection = DCECollection.extend(
/** @lends DatasetDCECollection.prototype */ {
model: DatasetDCE,
/** String representation. */
toString: function () {
return ["DatasetDCECollection(", this.length, ")"].join("");
},
}
);
//_________________________________________________________________________________________________ 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 DatasetCollection = Backbone.Model.extend(BASE_MVC.LoggableMixin)
.extend(BASE_MVC.SearchableModelMixin)
.extend(
/** @lends DatasetCollection.prototype */ {
_logNamespace: "collections",
/** default attributes for a model */
defaults: {
/* 'list', 'paired', or 'list:paired' */
collection_type: null,
//??
deleted: false,
},
/** Which class to use for elements */
collectionClass: function () {
if (this.attributes.collection_type.indexOf(":") > 0) {
return NestedDCDCECollection;
} else {
return DatasetDCECollection;
}
},
/** set up: create elements instance var and (on changes to elements) update them */
initialize: function (model, options) {
this.debug(`${this}(DatasetCollection).initialize:`, model, options, this);
this.elements = this._createElementsModel();
this.on("change:elements", function () {
this.log("change:elements");
//TODO: prob. better to update the collection instead of re-creating it
this.elements = this._createElementsModel();
});
},
/** move elements model attribute to full collection */
_createElementsModel: function () {
var collectionClass = this.collectionClass();
this.debug(`${this}._createElementsModel`, collectionClass, this.get("elements"), this.elements);
//TODO: same patterns as DatasetCollectionElement _createObjectModel - refactor to BASE_MVC.hasSubModel?
var elements = this.get("elements") || [];
this.unset("elements", { silent: true });
var parentHdcaId = this.get("parent_hdca_id") || this.get("id");
_.each(elements, (element, index) => {
_.extend(element, {
parent_hdca_id: parentHdcaId,
});
// Warning: MEGA-hack ahead...
if (!element.element_type && !element.object) {
// The DCE has to be in error state... so we simulate it
_.extend(element, {
element_type: "hda",
object: { state: "error" },
});
}
});
this.elements = new 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) {
json.elements = this.elements.toJSON();
}
return json;
},
/** Is this collection in a 'ready' state no processing (for the collection) is left
* to do on the server.
*/
inReadyState: function () {
var populated = this.get("populated");
return this.isDeletedOrPurged() || populated;
},
//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 () {
return this.elements.length !== 0;
},
/** Given the filters, what models in this.elements would be returned? */
getVisibleContents: function (filters) {
// filters unused for now
return this.elements;
},
// ........................................................................ ajax
/** override to use actual Dates objects for create/update times */
parse: function (response, options) {
var parsed = Backbone.Model.prototype.parse.call(this, response, options);
if (parsed.create_time) {
parsed.create_time = new Date(parsed.create_time);
}
if (parsed.update_time) {
parsed.update_time = new Date(parsed.update_time);
}
return parsed;
},
/** save this collection, _Mark_ing it as deleted (just a flag) */
delete: function (recursive, purge, options) {
recursive = recursive || false;
purge = purge || false;
if (this.get("deleted")) {
return $.when();
}
options = Utils.merge(options, { method: "delete" });
return this.save({ deleted: true, recursive: recursive, purge: purge }, options);
},
/** save this collection, _Mark_ing it as undeleted */
undelete: function (options) {
if (!this.get("deleted")) {
return $.when();
}
return this.save({ deleted: false }, options);
},
/** Is this collection deleted or purged? */
isDeletedOrPurged: function () {
return this.get("deleted") || this.get("purged");
},
// ........................................................................ searchable
/** searchable attributes for collections */
searchAttributes: ["name", "tags"],
// ........................................................................ misc
/** String representation */
toString: function () {
var idAndName = [this.get("id"), this.get("name") || this.get("element_identifier")];
return `DatasetCollection(${idAndName.join(",")})`;
},
}
);
//_________________________________________________________________________________________________ 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).
*/
var NestedDCDCE = DatasetCollection.extend(
BASE_MVC.mixin(
DatasetCollectionElementMixin,
/** @lends NestedDCDCE.prototype */ {
// 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 */ {
/** This is a collection of nested collections */
model: NestedDCDCE,
/** String representation. */
toString: function () {
return ["NestedDCDCECollection(", this.length, ")"].join("");
},
}
);
//==============================================================================
export default {
DatasetCollection: DatasetCollection,
};
@@ -1,114 +0,0 @@
import $ from "jquery";
import { getGalaxyInstance } from "app";
import DC_VIEW from "mvc/collection/collection-view";
import DC_EDIT from "mvc/collection/collection-li-edit";
import { mountModelTags } from "components/Tags";
import _l from "utils/localization";
import "ui/editable-text";
/* =============================================================================
TODO:
============================================================================= */
/** @class editable View/Controller for a dataset collection.
*/
var _super = DC_VIEW.CollectionView;
var CollectionViewEdit = _super.extend(
/** @lends CollectionView.prototype */ {
//MODEL is either a DatasetCollection (or subclass) or a DatasetCollectionElement (list of pairs)
/** logger used to record this.log messages, commonly set to console */
//logger : console,
/** sub view class used for datasets */
DatasetDCEViewClass: DC_EDIT.DatasetDCEListItemEdit,
/** sub view class used for nested collections */
NestedDCDCEViewClass: DC_EDIT.NestedDCDCEListItemEdit,
getNestedDCDCEViewClass: function () {
return DC_EDIT.NestedDCDCEListItemEdit.extend({
foldoutPanelClass: CollectionViewEdit,
});
},
// ......................................................................... SET UP
/** Set up the view, set up storage, bind listeners to HistoryContents events
* @param {Object} attributes optional settings for the panel
*/
initialize: function (attributes) {
_super.prototype.initialize.call(this, attributes);
},
/** In this override, make the collection name editable
*/
_setUpBehaviors: function ($where) {
$where = $where || this.$el;
_super.prototype._setUpBehaviors.call(this, $where);
if (!this.model) {
return;
}
// anon users shouldn't have access to any of the following
const Galaxy = getGalaxyInstance();
if (!Galaxy.user || Galaxy.user.isAnonymous()) {
return;
}
this.tagsEditorShown = true;
//TODO: extract
var panel = this;
var nameSelector = "> .controls .name";
$where
.find(nameSelector)
.attr("title", _l("Click to rename collection"))
.tooltip({ placement: "bottom" })
.make_text_editable({
on_finish: function (newName) {
var previousName = panel.model.get("name");
if (newName && newName !== previousName) {
panel.$el.find(nameSelector).text(newName);
panel.model.save({ name: newName }).fail(() => {
panel.$el.find(nameSelector).text(panel.model.previous("name"));
});
} else {
panel.$el.find(nameSelector).text(previousName);
}
},
});
const el = $where.find(".tags-display")[0];
const propsData = {
model: this.model,
disabled: false,
context: "collection-view-edit",
};
const vm = mountModelTags(propsData, el);
const toggleEditor = () => {
$(vm.$el).toggleClass("active");
this.tagsEditorShown = $(vm.$el).hasClass("active");
};
if (this.tagsEditorShown) {
const editorIsOpen = $(vm.$el).hasClass("active");
if (!editorIsOpen) {
toggleEditor();
}
}
},
// ........................................................................ misc
/** string rep */
toString: function () {
return `CollectionViewEdit(${this.model ? this.model.get("name") : ""})`;
},
}
);
//==============================================================================
export default {
CollectionViewEdit: CollectionViewEdit,
};
@@ -1,235 +0,0 @@
import _ from "underscore";
import { getAppRoot } from "onload/loadConfig";
import LIST_VIEW from "mvc/list/list-view";
import DC_LI from "mvc/collection/collection-li";
import _l from "utils/localization";
var logNamespace = "collections";
/* =============================================================================
TODO:
============================================================================= */
/** @class non-editable, read-only View/Controller for a dataset collection.
*/
var _super = LIST_VIEW.ModelListPanel;
var CollectionView = _super.extend(
/** @lends CollectionView.prototype */ {
//MODEL is either a DatasetCollection (or subclass) or a DatasetCollectionElement (list of pairs)
_logNamespace: logNamespace,
className: `${_super.prototype.className} dataset-collection-panel`,
/** sub view class used for datasets */
DatasetDCEViewClass: DC_LI.DatasetDCEListItemView,
/** key of attribute in model to assign to this.collection */
modelCollectionKey: "elements",
// ......................................................................... SET UP
/** Set up the view, set up storage, bind listeners to HistoryContents events
* @param {Object} attributes optional settings for the panel
*/
initialize: function (attributes) {
_super.prototype.initialize.call(this, attributes);
this.linkTarget = attributes.linkTarget || "_blank";
this.dragItems = true;
this.hasUser = attributes.hasUser;
/** A stack of panels that currently cover or hide this panel */
this.panelStack = [];
/** The text of the link to go back to the panel containing this one */
this.parentName = attributes.parentName;
/** foldout or drilldown */
this.foldoutStyle = attributes.foldoutStyle || "foldout";
this.downloadUrl = `${getAppRoot()}api/dataset_collections/${this.model.attributes.id}/download`;
},
getNestedDCDCEViewClass: function () {
return DC_LI.NestedDCDCEListItemView.extend({
foldoutPanelClass: CollectionView,
});
},
_queueNewRender: function ($newRender, speed) {
speed = speed === undefined ? this.fxSpeed : speed;
var panel = this;
this.handleWarning($newRender);
panel.log("_queueNewRender:", $newRender, speed);
// TODO: jquery@1.12 doesn't change display when the elem has display: flex
// this causes display: block for those elems after the use of show/hide animations
// animations are removed from this view for now until fixed
panel._swapNewRender($newRender);
panel.trigger("rendered", panel);
},
handleWarning: function ($newRender) {
var $warns = $newRender.find(".elements-warning");
if (this.model.get("populated_state") === "failed") {
var error = _l(`${this.model.get("populated_state_message")}`);
$warns.html(`<div class="errormessagesmall">${error}</div>`);
return;
}
var viewLength = this.views.length;
var elementCount = this.model.get("element_count");
if (elementCount && elementCount !== viewLength) {
var warning = _l(`displaying only ${viewLength} of ${elementCount} items`);
$warns.html(`<div class="warningmessagesmall">${warning}</div>`);
}
},
// ------------------------------------------------------------------------ sub-views
/** In this override, use model.getVisibleContents */
_filterCollection: function () {
//TODO: should *not* be model.getVisibleContents - visibility is not model related
return this.model.getVisibleContents();
},
/** override to return proper view class based on element_type */
_getItemViewClass: function (model) {
//this.debug( this + '._getItemViewClass:', model );
//TODO: subclasses use DCEViewClass - but are currently unused - decide
switch (model.get("element_type")) {
case "hda":
return this.DatasetDCEViewClass;
case "dataset_collection":
return this.getNestedDCDCEViewClass();
}
throw new TypeError("Unknown element type:", model.get("element_type"));
},
/** override to add link target and anon */
_getItemViewOptions: function (model) {
var options = _super.prototype._getItemViewOptions.call(this, model);
return _.extend(options, {
linkTarget: this.linkTarget,
hasUser: this.hasUser,
//TODO: could move to only nested: list:paired
foldoutStyle: this.foldoutStyle,
});
},
// ------------------------------------------------------------------------ collection sub-views
/** In this override, add/remove expanded/collapsed model ids to/from web storage */
_setUpItemViewListeners: function (view) {
var panel = this;
_super.prototype._setUpItemViewListeners.call(panel, view);
// use pub-sub to: handle drilldown expansion and collapse
panel.listenTo(view, {
"expanded:drilldown": function (v, drilldown) {
this._expandDrilldownPanel(drilldown);
},
"collapsed:drilldown": function (v, drilldown) {
this._collapseDrilldownPanel(drilldown);
},
});
return this;
},
/** Handle drill down by hiding this panels list and controls and showing the sub-panel */
_expandDrilldownPanel: function (drilldown) {
this.panelStack.push(drilldown);
// hide this panel's controls and list, set the name for back navigation, and attach to the $el
this.$("> .controls").add(this.$list()).hide();
drilldown.parentName = this.model.get("name");
this.$el.append(drilldown.render().$el);
},
/** Handle drilldown close by freeing the panel and re-rendering this panel */
_collapseDrilldownPanel: function (drilldown) {
this.panelStack.pop();
this.render();
},
// ------------------------------------------------------------------------ panel events
/** event map */
events: {
"click .navigation .back": "close",
},
/** close/remove this collection panel */
close: function (event) {
this.remove();
this.trigger("close");
},
// ........................................................................ misc
/** string rep */
toString: function () {
return `CollectionView(${this.model ? this.model.get("name") : ""})`;
},
}
);
//------------------------------------------------------------------------------ TEMPLATES
CollectionView.prototype.templates = (() => {
var controlsTemplate = (collection, view) => {
var subtitle = collectionDescription(view.model);
return `
<div class="controls">
<div class="navigation">
<a class="back" href="javascript:void(0)">
<span class="fa fa-icon fa-angle-left"></span>
${_l("Back to ")}
${_.escape(view.parentName)}
</a>
</div>
<div class="title">
<div class="name">${_.escape(collection.name) || _.escape(collection.element_identifier)}</div>
<div class="subtitle">
${subtitle}
</div>
</div>
<div class="elements-warning">
</div>
<div class="tags-display"></div>
<div class="actions">
<a class="download-btn icon-btn" href="${view.downloadUrl}"
title="" download="" data-original-title="Download Collection">
<span class="fa fa-floppy-o"></span>
</a>
</div>
</div>`;
};
return _.extend(_.clone(_super.prototype.templates), {
controls: controlsTemplate,
});
})();
function collectionTypeDescription(collection) {
var collectionType = collection.get("collection_type");
var collectionTypeDescription;
if (collectionType == "list") {
collectionTypeDescription = _l("list");
} else if (collectionType == "paired") {
collectionTypeDescription = _l("dataset pair");
} else if (collectionType == "list:paired") {
collectionTypeDescription = _l("list of pairs");
} else {
collectionTypeDescription = _l("nested list");
}
return collectionTypeDescription;
}
function collectionDescription(collection) {
var elementCount = collection.get("element_count");
var itemsDescription = `a ${collectionTypeDescription(collection)}`;
if (elementCount) {
var countDescription;
if (elementCount == 1) {
countDescription = "with 1 item";
} else if (elementCount) {
countDescription = `with ${elementCount} items`;
}
itemsDescription = `${itemsDescription} ${_l(countDescription)}`;
}
return itemsDescription;
}
//==============================================================================
export default {
collectionTypeDescription: collectionTypeDescription,
collectionDescription: collectionDescription,
CollectionView: CollectionView,
};
-434
View File
@@ -1,434 +0,0 @@
import $ from "jquery";
import _ from "underscore";
import Backbone from "backbone";
import DATASET from "mvc/dataset/dataset-model";
import DATASET_LIST from "mvc/dataset/dataset-list";
import MODAL from "mvc/ui/ui-modal";
import BASE_MVC from "mvc/base-mvc";
import _l from "utils/localization";
var logNamespace = "dataset";
/* ============================================================================
TODO:
does this really work with mixed contents?
Single dataset choice: allow none?
tooltips rendered *behind* modal
collection selector
better handling when no results returned from filterDatasetJSON
pass optional subtitle from choice display to modal
onfirstclick
drop target
return modal with promise?
auto showing the modal may not be best
============================================================================ */
/** Filters an array of dataset plain JSON objs.
*/
function _filterDatasetJSON(datasetJSON, where, datasetsOnly) {
//TODO: replace with _.matches (underscore 1.6.0)
function matches(obj, toMatch) {
for (var key in toMatch) {
if (Object.prototype.hasOwnProperty.call(toMatch, key)) {
if (obj[key] !== toMatch[key]) {
return false;
}
}
}
return true;
}
return datasetJSON.filter((json) => {
console.debug(json);
return (
!json.deleted &&
json.visible &&
(!datasetsOnly || json.collection_type === undefined) &&
matches(json, where)
);
});
}
// ============================================================================
/** Given an array of plain JSON objs rep. datasets, show a modal allowing a choice
* of one or more of those datasets.
*
* Pass:
* an array of plain JSON objects representing allowed dataset choices
* a map of options (see below)
*
* Options:
* datasetsOnly: T: display only datasets, F: datasets + dataset collections
* where: a map of attributes available choices *must have* (defaults to { state: 'ok' })
* multiselect: T: user can select more than one, F: only one
* selected: array of dataset ids to make them selected by default
*
* @example:
* var datasetJSON = // from ajax or bootstrap
* // returns a jQuery promise (that 'fail's only if no datasets are found matching 'where' below)
* var choice = new DatasetChoiceModal( datasetJSON, {
* datasetsOnly : false,
* where : { state: 'ok', file_ext: 'bed', ... },
* multiselect : true,
* selected : [ 'df7a1f0c02a5b08e', 'abcdef0123456789' ]
*
* }).done( function( json ){
* if( json ){
* console.debug( json );
* // returned choice will always be an array (single or multi)
* // [{ <selected dataset JSON 1>, <selected dataset JSON 2>, ... }]
* // ... do stuff
* } else {
* // json will === null if the user cancelled selection
* console.debug( 'cancelled' );
* }
* });
*/
var DatasetChoiceModal = (datasetJSON, options) => {
// option defaults
options = _.defaults(options || {}, {
// show datasets or datasets and collections
datasetsOnly: true,
// map of attributes to filter datasetJSON by
where: { state: "ok" },
// select more than one dataset?
multiselect: false,
// any dataset ids that will display as already selected
selected: [],
});
// default title should depend on multiselect
options.title = options.title || (options.multiselect ? _l("Choose datasets:") : _l("Choose a dataset:"));
var modal;
var list;
var buttons;
var promise = $.Deferred();
var filterFn = options.filter || _filterDatasetJSON;
// filter the given datasets and if none left return a rejected promise for use with fail()
datasetJSON = filterFn(datasetJSON, options.where, options.datasetsOnly);
if (!datasetJSON.length) {
return promise.reject("No matches found");
}
// resolve the returned promise with the json of the selected datasets
function resolveWithSelected() {
promise.resolve(list.getSelectedModels().map((model) => model.toJSON()));
}
// if multiselect - add a button for the user to complete the changes
if (options.multiselect) {
buttons = {};
buttons[_l("Ok")] = resolveWithSelected;
}
// create a full-height modal that's cancellable, remove unneeded elements and styles
modal = new MODAL.View({
height: "auto",
buttons: buttons,
closing_events: true,
closing_callback: function () {
promise.resolve(null);
},
body: ['<div class="list-panel"></div>'].join(""),
});
modal.$(".modal-header").remove();
modal.$(".modal-footer").css("margin-top", "0px");
// attach a dataset list (of the filtered datasets) to that modal that's selectable
list = new DATASET_LIST.DatasetList({
title: options.title,
subtitle:
options.subtitle ||
_l(
[
//TODO: as option
"Click the checkboxes on the right to select datasets. ",
"Click the datasets names to see their details. ",
].join("")
),
el: modal.$body.find(".list-panel"),
selecting: true,
selected: options.selected,
collection: new DATASET.DatasetAssociationCollection(datasetJSON),
});
// when the list is rendered, show the modal (also add a specifying class for css)
list.once("rendered:initial", () => {
modal.show();
modal.$el.addClass("dataset-choice-modal");
});
if (!options.multiselect) {
// if single select, remove the all/none list actions from the panel
list.on("rendered", () => {
list.$(".list-actions").hide();
});
// if single select, immediately resolve on a single selection
list.on("view:selected", (view) => {
promise.resolve([view.model.toJSON()]);
});
}
list.render(0);
// return the promise, and on any resolution close the modal
return promise.always(() => {
modal.hide();
});
};
// ============================================================================
/** Activator for single dataset selection modal and display of the selected dataset.
* The activator/display will show as a single div and, when a dataset is selected,
* show the name and details of the selected dataset.
*
* When clicked the div will generate a DatasetChoiceModal of the available choices.
*
* Options:
* datasetJSON: array of plain json objects representing allowed choices
* datasetsOnly: T: only show datasets in the allowed choices, F: datasets + collections
* where: map of attributes to filter datasetJSON by (e.g. { file_ext: 'bed' })
* label: the label/prompt displayed
* selected: array of dataset ids that will show as already selected in the control
*
* @example:
* var choice1 = new DATASET_CHOICE.DatasetChoice({
* datasetJSON : datasetJSON,
* label : 'Input dataset',
* selected : [ 'df7a1f0c02a5b08e' ]
* });
* $( 'body' ).append( choice1.render().$el )
*
* Listen to the DatasetChoice to react to changes in the user's choice/selection:
* @example:
* choice1.on( 'selected', function( chooser, selectionJSONArray ){
* // ... do stuff with new selections
* });
*/
var DatasetChoice = Backbone.View.extend(BASE_MVC.LoggableMixin).extend({
_logNamespace: logNamespace,
className: "dataset-choice",
/** set up defaults, options, and listeners */
initialize: function (attributes) {
this.debug(`${this}(DatasetChoice).initialize:`, attributes);
this.label = attributes.label !== undefined ? _l(attributes.label) : "";
this.where = attributes.where;
this.datasetsOnly = attributes.datasetsOnly !== undefined ? attributes.datasetsOnly : true;
this.datasetJSON = attributes.datasetJSON || [];
this.selected = attributes.selected || [];
this._setUpListeners();
},
/** add any (bbone) listeners */
_setUpListeners: function () {
//this.on( 'all', function(){
// this.log( this + '', arguments );
//});
},
/** render the view */
render: function () {
var json = this.toJSON();
this.$el.html(this._template(json));
this.$(".selected").replaceWith(this._renderSelected(json));
return this;
},
/** return plain html for the overall control */
_template: function (json) {
return _.template(
["<label>", '<span class="prompt"><%- label %></span>', '<div class="selected"></div>', "</label>"].join("")
)(json);
},
/** return jQ DOM for the selected dataset (only one) */
_renderSelected: function (json) {
if (json.selected.length) {
//TODO: break out?
return $(
_.template(
[
'<div class="selected">',
'<span class="title"><%- selected.hid %>: <%- selected.name %></span>',
'<span class="subtitle">',
"<i><%- selected.misc_blurb %></i>",
"<i>",
`${_l("format")}: `,
"<%- selected.file_ext %></i>",
"<i><%- selected.misc_info %></i>",
"</span>",
"</div>",
].join(""),
{ variable: "selected" }
)(json.selected[0])
);
}
return $(['<span class="none-selected-msg">(', _l("click to select a dataset"), ")</span>"].join(""));
},
//TODO:?? why not just pass in view?
/** return a plain JSON object with both the view and dataset attributes */
toJSON: function () {
var chooser = this;
return {
label: chooser.label,
datasets: chooser.datasetJSON,
selected: _.compact(
_.map(chooser.selected, (id) =>
_.findWhere(chooser.datasetJSON, {
id: id,
})
)
),
};
},
/** event map: when to open the modal */
events: {
// the whole thing functions as a button
click: "chooseWithModal",
},
//TODO:?? modal to prop of this?
//TODO:?? should be able to handle 'none selectable' on initialize
/** open the modal and handle the promise representing the user's choice
* @fires 'selected' when the user selects dataset(s) - passed full json of the selected datasets
* @fires 'cancelled' when the user clicks away/closes the modal (no selection made) - passed this
* @fires 'error' if the modal has no selectable datasets based on this.where - passed this and other args
*/
chooseWithModal: function () {
var chooser = this;
return this._createModal()
.done((json) => {
if (json) {
chooser.selected = _.pluck(json, "id");
chooser.trigger("selected", chooser, json);
chooser.render();
} else {
chooser.trigger("cancelled", chooser);
}
})
.fail(function () {
chooser.trigger("error", chooser, arguments);
});
},
/** create and return the modal to use for choosing */
_createModal: function () {
return new DatasetChoiceModal(this.datasetJSON, this._getModalOptions());
},
/** return a plain JSON containing the options to pass to the modal */
_getModalOptions: function () {
return {
title: this.label,
multiselect: false,
selected: this.selected,
where: this.where,
datasetsOnly: this.datasetsOnly,
};
},
// ------------------------------------------------------------------------ misc
/** string rep */
toString: function () {
return `DatasetChoice(${this.selected})`;
},
});
// ============================================================================
/** Activator for multiple dataset selection modal and display of the selected datasets.
* The activator/display will show as a table of all choices.
*
* See DatasetChoice (above) for example usage.
*
* Additional options:
* showHeaders: T: show headers for selected dataset attributes in the display table
* cells: map of attribute keys -> Human readable/localized column headers
* (e.g. { file_ext: _l( 'Format' ) }) - defaults are listed below
*/
var MultiDatasetChoice = DatasetChoice.extend({
className: `${DatasetChoice.prototype.className} multi`,
/** default (dataset attribute key -> table header text) map of what cells to display in the table */
cells: {
hid: _l("History #"),
name: _l("Name"),
misc_blurb: _l("Summary"),
file_ext: _l("Format"),
genome_build: _l("Genome"),
tags: _l("Tags"),
annotation: _l("Annotation"),
},
/** in this override, add the showHeaders and cells options */
initialize: function (attributes) {
this.showHeaders = attributes.showHeaders !== undefined ? attributes.showHeaders : true;
this.cells = attributes.cells || this.cells;
DatasetChoice.prototype.initialize.call(this, attributes);
},
/** in this override, display the selected datasets as a table with optional headers */
_renderSelected: function (json) {
if (json.selected.length) {
return $(
_.template(
[
'<table class="selected">',
"<% if( json.showHeaders ){ %>",
"<thead><tr>",
"<% _.map( json.cells, function( val, key ){ %>",
"<th><%- val %></th>",
"<% }); %>",
"</tr></thead>",
"<% } %>",
"<tbody>",
"<% _.map( json.selected, function( selected ){ %>",
"<tr>",
"<% _.map( json.cells, function( val, key ){ %>",
'<td class="cell-<%- key %>"><%- selected[ key ] %></td>',
"<% }) %>",
"</tr>",
"<% }); %>",
"</tbody>",
"</table>",
].join(""),
{ variable: "json" }
)(json)
);
}
return $(['<span class="none-selected-msg">(', _l("click to select a dataset"), ")</span>"].join(""));
},
/** in this override, send the showHeaders and cells options as well */
toJSON: function () {
return _.extend(DatasetChoice.prototype.toJSON.call(this), {
showHeaders: this.showHeaders,
cells: this.cells,
});
},
/** in this override, set multiselect to true */
_getModalOptions: function () {
return _.extend(DatasetChoice.prototype._getModalOptions.call(this), {
multiselect: true,
});
},
// ------------------------------------------------------------------------ misc
/** string rep */
toString: function () {
return `DatasetChoice(${this.selected})`;
},
});
// ============================================================================
export default {
DatasetChoiceModal: DatasetChoiceModal,
DatasetChoice: DatasetChoice,
MultiDatasetChoice: MultiDatasetChoice,
};
-472
View File
@@ -1,472 +0,0 @@
import _ from "underscore";
import $ from "jquery";
import { getAppRoot } from "onload/loadConfig";
import { getGalaxyInstance } from "app";
import STATES from "mvc/dataset/states";
import DATASET_LI from "mvc/dataset/dataset-li";
import { mountModelTags } from "components/Tags";
import ANNOTATIONS from "mvc/annotation";
import faIconButton from "ui/fa-icon-button";
import BASE_MVC from "mvc/base-mvc";
import _l from "utils/localization";
//==============================================================================
var _super = DATASET_LI.DatasetListItemView;
/** @class Editing view for DatasetAssociation.
*/
var DatasetListItemEdit = _super.extend(
/** @lends DatasetListItemEdit.prototype */ {
/** set up: options */
initialize: function (attributes) {
_super.prototype.initialize.call(this, attributes);
this.hasUser = attributes.hasUser;
/** allow user purge of dataset files? */
this.purgeAllowed = attributes.purgeAllowed || false;
//TODO: move to HiddenUntilActivatedViewMixin
/** should the tags editor be shown or hidden initially? */
this.tagsEditorShown = attributes.tagsEditorShown || false;
/** should the tags editor be shown or hidden initially? */
this.annotationEditorShown = attributes.annotationEditorShown || false;
},
// ......................................................................... titlebar actions
/** In this override, add the other two primary actions: edit and delete */
_renderPrimaryActions: function () {
var actions = _super.prototype._renderPrimaryActions.call(this);
if (this.model.get("state") === STATES.NOT_VIEWABLE) {
return actions;
}
// render the display, edit attr and delete icon-buttons
return _super.prototype._renderPrimaryActions
.call(this)
.concat([this._renderEditButton(), this._renderDeleteButton()]);
},
//TODO: move titleButtons into state renderers, remove state checks in the buttons
/** Render icon-button to edit the attributes (format, permissions, etc.) this dataset. */
_renderEditButton: function () {
var self = this;
// don't show edit while uploading, in-accessible
// DO show if in error (ala previous history panel)
if (this.model.get("state") === STATES.DISCARDED || !this.model.get("accessible")) {
return null;
}
var purged = this.model.get("purged");
var deleted = this.model.get("deleted");
var editBtnData = {
title: _l("Edit attributes"),
href: `${getAppRoot()}datasets/edit?dataset_id=${this.model.get("element_id") || this.model.get("id")}`,
faIcon: "fa-pencil",
classes: "edit-btn",
onclick: function (ev) {
if (!deleted && !purged) {
const Galaxy = getGalaxyInstance();
if (Galaxy.router) {
ev.preventDefault();
const identifier = self.model.get("element_id") || self.model.get("id");
Galaxy.router.push("datasets/edit", {
dataset_id: identifier,
});
}
}
},
};
// disable if purged or deleted and explain why in the tooltip
if (deleted || purged) {
editBtnData.disabled = true;
if (purged) {
editBtnData.title = _l("Cannot edit attributes of datasets removed from disk");
} else if (deleted) {
editBtnData.title = _l("Undelete dataset to edit attributes");
}
// disable if still uploading or new
} else if ([STATES.UPLOAD, STATES.NEW].includes(this.model.get("state"))) {
editBtnData.disabled = true;
editBtnData.title = _l("This dataset is not yet editable");
}
return faIconButton(editBtnData);
},
/** Render icon-button to delete this hda. */
_renderDeleteButton: function () {
// don't show delete if...
if (!this.model.get("accessible")) {
return null;
}
var self = this;
var deletedAlready = this.model.isDeletedOrPurged();
return faIconButton({
title: !deletedAlready ? _l("Delete") : _l("Dataset is already deleted"),
disabled: deletedAlready,
faIcon: "fa-times",
classes: "delete-btn",
onclick: function () {
self.$el.find(".icon-btn.delete-btn").tooltip("dispose");
self.model["delete"]();
},
});
},
// ......................................................................... details
/** In this override, add tags and annotations controls, make the ? dbkey a link to editing page */
_renderDetails: function () {
//TODO: generalize to be allow different details for each state
var $details = _super.prototype._renderDetails.call(this);
var state = this.model.get("state");
if (!this.model.isDeletedOrPurged()) {
//Enable tagging+annotation regardless of job state (see issue #6330)
this._renderTags($details);
this._renderAnnotation($details);
if ([STATES.OK, STATES.FAILED_METADATA].includes(state)) {
this._makeDbkeyEditLink($details);
}
}
this._setUpBehaviors($details);
return $details;
},
/**************************************************************************
* Render help button to show tool help text without rerunning the tool.
* Issue #2100
*/
_renderToolHelpButton: function () {
var datasetID = this.model.attributes.dataset_id;
var jobID = this.model.attributes.creating_job;
var self = this;
var parseToolBuild = (data) => {
var helpString = `<div id="thdiv-${datasetID}" class="toolhelp">`;
if (data.name && data.help) {
helpString += `<strong>Tool help for ${data.name}</strong><hr/>`;
helpString += data.help;
} else {
helpString += "<strong>Tool help is unavailable for this dataset.</strong><hr/>";
}
helpString += "</div>";
self.$el.find(".details").append($.parseHTML(helpString));
};
var parseToolID = (data) => {
$.ajax({
url: `${getAppRoot()}api/tools/${data.tool_id}/build`,
})
.done((data) => {
parseToolBuild(data);
})
.fail(() => {
parseToolBuild({});
});
};
const Galaxy = getGalaxyInstance();
if (Galaxy.user.id === null) {
return null;
}
return faIconButton({
title: _l("Tool Help"),
classes: "icon-btn",
href: "#",
faIcon: "fa-question",
onclick: function (ev) {
ev.preventDefault();
if (self.$el.find(".toolhelp").length > 0) {
self.$el.find(".toolhelp").toggle();
} else {
$.ajax({
url: `${getAppRoot()}api/jobs/${jobID}`,
})
.done((data) => {
parseToolID(data);
})
.fail(() => {
console.log(
`Failed at recovering job information from the Galaxy API for job id "${jobID}".`
);
});
}
},
});
},
//*************************************************************************
/** Add less commonly used actions in the details section based on state */
_renderSecondaryActions: function () {
var actions = _super.prototype._renderSecondaryActions.call(this);
switch (this.model.get("state")) {
case STATES.UPLOAD:
case STATES.NOT_VIEWABLE:
return actions;
case STATES.ERROR:
// error button comes first
actions.unshift(this._renderErrButton());
return actions.concat([this._renderRerunButton(), this._renderToolHelpButton()]);
case STATES.OK:
case STATES.FAILED_METADATA:
return actions.concat([
this._renderRerunButton(),
this._renderVisualizationsButton(),
this._renderToolHelpButton(),
]);
}
return actions.concat([this._renderRerunButton(), this._renderToolHelpButton()]);
},
/** Render icon-button to report an error on this dataset to the galaxy admin. */
_renderErrButton: function () {
var self = this;
return faIconButton({
title: _l("View or report this error"),
href: `${getAppRoot()}datasets/error?dataset_id=${
self.model.get("element_id") || self.model.get("id")
}`,
classes: "report-error-btn",
faIcon: "fa-bug",
onclick: function (ev) {
const Galaxy = getGalaxyInstance();
if (Galaxy.router) {
ev.preventDefault();
Galaxy.router.push("datasets/error", {
dataset_id: self.model.get("element_id") || self.model.get("id"),
});
}
},
});
},
/** Render icon-button to re-run the job that created this dataset. */
_renderRerunButton: function () {
var creating_job = this.model.get("creating_job");
if (this.model.get("rerunnable")) {
return faIconButton({
title: _l("Run this job again"),
href: this.model.urls.rerun,
classes: "rerun-btn",
target: this.linkTarget,
faIcon: "fa-redo",
onclick: function (ev) {
const Galaxy = getGalaxyInstance();
if (Galaxy.router) {
ev.preventDefault();
Galaxy.router.push("/", {
job_id: creating_job,
});
}
},
});
}
},
/** Render an icon-button or popupmenu of links based on the applicable visualizations */
_renderVisualizationsButton: function () {
const Galaxy = getGalaxyInstance();
const dsid = this.model.get("element_id") || this.model.get("id");
const url = getAppRoot() + "visualizations?dataset_id=" + dsid;
return faIconButton({
title: _l("Visualize this data"),
href: url,
classes: "visualization-link",
faIcon: "fa-bar-chart-o",
onclick: (ev) => {
if (Galaxy.frame && Galaxy.frame.active) {
ev.preventDefault();
Galaxy.frame.add({ url: url, title: "Visualization" });
} else if (Galaxy.router) {
ev.preventDefault();
Galaxy.router.push("visualizations", {
dataset_id: dsid,
});
Galaxy.trigger("activate-hda", dsid);
}
},
});
},
/** Render the tags list/control */
_renderTags: function ($where) {
if (!this.hasUser) {
return;
}
const el = $where.find(".tags-display")[0];
const propsData = {
model: this.model,
disabled: false,
context: "dataset-li-edit",
};
const vm = mountModelTags(propsData, el);
// tag icon button open/closes
const activator = faIconButton({
title: _l("Edit dataset tags"),
classes: "tag-btn",
faIcon: "fa-tags",
}).appendTo($where.find(".actions .right"));
const toggleEditor = () => {
$(vm.$el).toggleClass("active");
this.tagsEditorShown = $(vm.$el).hasClass("active");
};
activator.on("click", toggleEditor);
if (this.tagsEditorShown) {
const editorIsOpen = $(vm.$el).hasClass("active");
if (!editorIsOpen) {
toggleEditor();
}
}
return vm;
},
/** Render the annotation display/control */
_renderAnnotation: function ($where) {
if (!this.hasUser) {
return;
}
var view = this;
this.annotationEditor = new ANNOTATIONS.AnnotationEditor({
model: this.model,
el: $where.find(".annotation-display"),
onshowFirstTime: function () {
this.render();
},
// persist state on the hda view (and not the editor) since these are currently re-created each time
onshow: function () {
view.annotationEditorShown = true;
},
onhide: function () {
view.annotationEditorShown = false;
},
$activator: faIconButton({
title: _l("Edit dataset annotation"),
classes: "annotate-btn",
faIcon: "fa-comment",
}).appendTo($where.find(".actions .right")),
});
if (this.annotationEditorShown) {
this.annotationEditor.toggle(true);
}
},
/** If the format/dbkey/genome_build isn't set, make the display a link to the edit page */
_makeDbkeyEditLink: function ($details) {
// make the dbkey a link to editing
if (this.model.get("metadata_dbkey") === "?" && !this.model.isDeletedOrPurged()) {
var editableDbkey = $('<a class="value">?</a>')
.attr("href", this.model.urls.edit)
.attr("target", "_top");
$details.find(".dbkey .value").replaceWith(editableDbkey);
}
},
// ......................................................................... events
/** event map */
events: _.extend(_.clone(_super.prototype.events), {
"click .undelete-link": "_clickUndeleteLink",
"click .purge-link": "_clickPurgeLink",
"click .edit-btn": function (ev) {
this.trigger("edit", this, ev);
},
"click .delete-btn": function (ev) {
this.trigger("delete", this, ev);
},
"click .rerun-btn": function (ev) {
this.trigger("rerun", this, ev);
},
"click .report-err-btn": function (ev) {
this.trigger("report-err", this, ev);
},
"click .visualization-btn": function (ev) {
this.trigger("visualize", this, ev);
},
"click .dbkey a": function (ev) {
this.trigger("edit", this, ev);
},
}),
/** listener for item undelete (in the messages section) */
_clickUndeleteLink: function (ev) {
this.model.undelete();
return false;
},
/** listener for item purge (in the messages section) */
_clickPurgeLink: function (ev) {
if (window.confirm(_l("This will permanently remove the data in your dataset. Are you sure?"))) {
this.model.purge();
}
return false;
},
// ......................................................................... misc
/** string rep */
toString: function () {
var modelString = this.model ? `${this.model}` : "(no model)";
return `HDAEditView(${modelString})`;
},
}
);
// ............................................................................ TEMPLATES
/** underscore templates */
DatasetListItemEdit.prototype.templates = (() => {
var warnings = _.extend({}, _super.prototype.templates.warnings, {
failed_metadata: BASE_MVC.wrapTemplate(
[
// in this override, provide a link to the edit page
'<% if( dataset.state === "failed_metadata" ){ %>',
'<div class="failed_metadata-warning warningmessagesmall">',
_l("An error occurred setting the metadata for this dataset"),
'<br /><a href="<%- dataset.urls.edit %>" target="_top">',
_l("Set it manually or retry auto-detection"),
"</a>",
"</div>",
"<% } %>",
],
"dataset"
),
deleted: BASE_MVC.wrapTemplate(
[
// in this override, provide links to undelete or purge the dataset
"<% if( dataset.deleted && !dataset.purged ){ %>",
// deleted not purged
'<div class="deleted-msg warningmessagesmall">',
_l("This dataset has been deleted"),
'<br /><a class="undelete-link" href="javascript:void(0);">',
_l("Undelete it"),
"</a>",
"<% if( view.purgeAllowed ){ %>",
'<br /><a class="purge-link" href="javascript:void(0);">',
_l("Permanently remove it from disk"),
"</a>",
"<% } %>",
"</div>",
"<% } %>",
],
"dataset"
),
});
return _.extend({}, _super.prototype.templates, {
warnings: warnings,
});
})();
//==============================================================================
export default {
DatasetListItemEdit: DatasetListItemEdit,
};
-639
View File
@@ -1,639 +0,0 @@
import _ from "underscore";
import $ from "jquery";
import Backbone from "backbone";
import { getGalaxyInstance } from "app";
import LIST_ITEM from "mvc/list/list-item";
import STATES from "mvc/dataset/states";
import faIconButton from "ui/fa-icon-button";
import BASE_MVC from "mvc/base-mvc";
import _l from "utils/localization";
import { mountNametags } from "components/Nametags";
import { Toast } from "ui/toast";
import { getAppRoot } from "onload/loadConfig";
var logNamespace = "dataset";
/*==============================================================================
TODO:
straighten out state rendering and templates used
inaccessible/STATES.NOT_VIEWABLE is a special case
simplify button rendering
==============================================================================*/
var _super = LIST_ITEM.ListItemView;
/** @class Read only list view for either LDDAs, HDAs, or HDADCEs.
* Roughly, any DatasetInstance (and not a raw Dataset).
*/
export var DatasetListItemView = _super.extend(
/** @lends DatasetListItemView.prototype */ {
_logNamespace: logNamespace,
className: `${_super.prototype.className} dataset`,
//TODO:?? doesn't exactly match an hda's type_id
id: function () {
return ["dataset", this.model.get("id")].join("-");
},
/** Set up: instance vars, options, and event handlers */
initialize: function (attributes) {
if (attributes.logger) {
this.logger = this.model.logger = attributes.logger;
}
this.log(`${this}.initialize:`, attributes);
_super.prototype.initialize.call(this, attributes);
/** where should pages from links be displayed? (default to new tab/window) */
this.linkTarget = attributes.linkTarget || "_blank";
},
// mount new vue component for tags
render: function () {
const result = _super.prototype.render.apply(this, arguments);
this._mountVueNametags();
return result;
},
_mountVueNametags: function () {
const container = this.$(".nametags")[0];
const { id, model_class, tags } = this.model.attributes;
const storeKey = `${model_class}-${id}`;
mountNametags({ storeKey, tags }, container);
},
/** event listeners */
_setUpListeners: function () {
_super.prototype._setUpListeners.call(this);
var self = this;
// re-rendering on any model changes
return self.listenTo(self.model, {
change: function (model) {
// if the model moved into the ready state and is expanded without details, fetch those details now
if (
self.model.changedAttributes().state &&
self.model.inReadyState() &&
self.expanded &&
!self.model.hasDetails()
) {
// normally, will render automatically (due to fetch -> change),
// but! setting_metadata sometimes doesn't cause any other changes besides state
// so, not rendering causes it to seem frozen in setting_metadata state
self.model.fetch({ silent: true }).done(() => {
self.render();
});
} else {
if (_.has(self.model.changed, "tags") && _.keys(self.model.changed).length === 2) {
// If only the tags and update time have changed,
// rerender specifically the titlebar region.
// Otherwise default to the full render.
self._mountVueNametags();
} else {
self.render();
}
}
},
});
},
// ......................................................................... expandable
/** In this override, only get details if in the ready state, get rerunnable if in other states.
* Note: fetch with no 'change' event triggering to prevent automatic rendering.
*/
_fetchModelDetails: function () {
var view = this;
if (view.model.inReadyState() && !view.model.hasDetails()) {
return view.model.fetch({ silent: true });
}
return $.when();
},
// ......................................................................... removal
/** Remove this view's html from the DOM and remove all event listeners.
* @param {Number or String} speed jq effect speed
* @param {Function} callback an optional function called when removal is done (scoped to this view)
*/
remove: function (speed, callback) {
var view = this;
speed = speed || this.fxSpeed;
this.$el.fadeOut(speed, () => {
Backbone.View.prototype.remove.call(view);
if (callback) {
callback.call(view);
}
});
},
// ......................................................................... rendering
/* TODO:
dataset states are the issue primarily making dataset rendering complex
each state should have it's own way of displaying/set of details
often with different actions that can be applied
throw in deleted/purged/visible and things get complicated easily
I've considered (a couple of times) - creating a view for each state
- but recreating the view during an update...seems wrong
*/
/** In this override, add the dataset state as a class for use with state-based CSS */
_swapNewRender: function ($newRender) {
_super.prototype._swapNewRender.call(this, $newRender);
if (this.model.has("state")) {
this.$el.addClass(`state-${this.model.get("state")}`);
this.$el.attr("data-state", this.model.get("state"));
}
return this.$el;
},
// ................................................................................ titlebar
/** In this override, add the dataset display button. */
_renderPrimaryActions: function () {
// render just the display for read-only
return [this._renderDisplayButton()];
},
/** Render icon-button to display dataset data */
_renderDisplayButton: function () {
// don't show display if not viewable or not accessible
var state = this.model.get("state");
if (state === STATES.NOT_VIEWABLE || state === STATES.DISCARDED || !this.model.get("accessible")) {
return null;
}
var displayBtnData = {
target: this.linkTarget,
classes: "display-btn",
};
// show a disabled display if the data's been purged
if (this.model.get("purged")) {
displayBtnData.disabled = true;
displayBtnData.title = _l("Cannot display datasets removed from disk");
// disable if still uploading
} else if (state === STATES.UPLOAD) {
displayBtnData.disabled = true;
displayBtnData.title = _l("This dataset must finish uploading before it can be viewed");
// disable if still new
} else if (state === STATES.NEW) {
displayBtnData.disabled = true;
displayBtnData.title = _l("This dataset is not yet viewable");
} else {
displayBtnData.title = _l("View data");
// default link for dataset
displayBtnData.href = this.model.urls.display;
// add frame manager option onclick event
var self = this;
displayBtnData.onclick = (ev) => {
const Galaxy = getGalaxyInstance();
if (Galaxy.frame && Galaxy.frame.active) {
// Add dataset to frames.
const identifier = self.model.get("element_id") || self.model.get("id");
const url = `${getAppRoot()}datasets/${identifier}/display/?preview=True`;
Galaxy.frame.add({ url });
ev.preventDefault();
}
};
}
displayBtnData.faIcon = "fa-eye";
return faIconButton(displayBtnData);
},
// ......................................................................... rendering details
/** Render the enclosing div of the hda body and, if expanded, the html in the body
* @returns {jQuery} rendered DOM
*/
_renderDetails: function () {
//TODO: generalize to be allow different details for each state
// no access - render nothing but a message
if (this.model.get("state") === STATES.NOT_VIEWABLE) {
return $(this.templates.noAccess(this.model.toJSON(), this));
}
var $details = _super.prototype._renderDetails.call(this);
$details.find(".actions .left").empty().append(this._renderSecondaryActions());
$details.find(".summary").html(this._renderSummary()).prepend(this._renderDetailMessages());
$details.find(".display-applications").html(this._renderDisplayApplications());
this._setUpBehaviors($details);
return $details;
},
/** Defer to the appropo summary rendering fn based on state */
_renderSummary: function () {
var json = this.model.toJSON();
var summaryRenderFn = this.templates.summaries[json.state];
summaryRenderFn = summaryRenderFn || this.templates.summaries.unknown;
return summaryRenderFn(json, this);
},
/** Render messages to be displayed only when the details are shown */
_renderDetailMessages: function () {
var view = this;
var $warnings = $('<div class="detail-messages"></div>');
var json = view.model.toJSON();
//TODO:! unordered (map)
_.each(view.templates.detailMessages, (templateFn) => {
$warnings.append($(templateFn(json, view)));
});
return $warnings;
},
/** Render the external display application links */
_renderDisplayApplications: function () {
if (this.model.isDeletedOrPurged()) {
return "";
}
// render both old and new display apps using the same template
return [
this.templates.displayApplications(this.model.get("display_apps"), this),
this.templates.displayApplications(this.model.get("display_types"), this),
].join("");
},
// ......................................................................... secondary/details actions
/** A series of links/buttons for less commonly used actions: re-run, info, etc. */
_renderSecondaryActions: function () {
this.debug("_renderSecondaryActions");
switch (this.model.get("state")) {
case STATES.NOT_VIEWABLE:
return [];
case STATES.OK:
case STATES.FAILED_METADATA:
case STATES.ERROR:
return [
this._renderDownloadButton(),
this._renderClipboardButton(),
this._renderShowParamsButton(),
];
}
return [this._renderShowParamsButton()];
},
/** Render icon-button to show the input and output (stdout/err) for the job that created this.
* @returns {jQuery} rendered DOM
*/
_renderShowParamsButton: function () {
return faIconButton({
title: _l("View details"),
classes: "params-btn",
href: this.model.urls.show_params,
target: this.linkTarget,
faIcon: "fa-info-circle",
onclick: (ev) => {
const Galaxy = getGalaxyInstance();
const showDetailsUrl = `/datasets/${this.model.get("element_id") || this.model.get("id")}/details`;
if (Galaxy.frame && Galaxy.frame.active) {
ev.preventDefault();
Galaxy.frame.add({
url: showDetailsUrl,
title: `Dataset Details of ${this.model.get("name")}`,
});
} else if (Galaxy.router) {
ev.preventDefault();
Galaxy.router.push(showDetailsUrl);
Galaxy.trigger("activate-hda", this.model.get("element_id") || this.model.get("id"));
}
},
});
},
isPurged: function () {
// don't show anything if the data's been purged
return !!(this.model.get("purged") || !this.model.hasData());
},
/** Render icon-button/popupmenu to download the data (and/or the associated meta files (bai, etc.)) for this.
* @returns {jQuery} rendered DOM
*/
_renderClipboardButton: function () {
const isUnsharable =
this.model.attributes.permissions &&
this.model.attributes.permissions.access.length === 1 &&
this.model.attributes.permissions.access.includes(getGalaxyInstance().user.id);
var urls = this.model.urls;
if (!this.isPurged() && urls.download) {
let title = _l("Copy link");
let style = "";
if (isUnsharable) {
title = _l("Preferences restrict sharing");
style = "opacity: 0.3";
}
return faIconButton({
faIcon: "fa-chain",
style: style,
title: title,
onclick: function () {
if (!isUnsharable) {
navigator.clipboard.writeText(`${window.location.origin}${urls.download}`).then(() => {
Toast.info("Link is copied to your clipboard");
});
} else {
Toast.warning("Dataset is not sharable");
}
},
});
}
},
/** Render icon-button/popupmenu to download the data (and/or the associated meta files (bai, etc.)) for this.
* @returns {jQuery} rendered DOM
*/
_renderDownloadButton: function () {
if (this.isPurged()) {
return null;
}
// return either: a popupmenu with links to download assoc. meta files (if there are meta files)
// or a single download icon-button (if there are no meta files)
if (!_.isEmpty(this.model.get("meta_files"))) {
return this._renderMetaFileDownloadButton();
}
return $(`
<a class="download-btn icon-btn" href="${this.model.urls.download}" title="${_l("Download")}">
<span class="fa fa-floppy-o"></span>
</a>`);
},
/** Render the download button which opens a dropdown with links to download assoc. meta files (indices, etc.) */
_renderMetaFileDownloadButton: function () {
var urls = this.model.urls;
return $(`
<div class="metafile-dropdown dropdown">
<a class="download-btn icon-btn" href="${urls.download}" data-toggle="dropdown" title="${_l(
"Download"
)}">
<span class="fa fa-floppy-o"></span>
</a>
<div class="dropdown-menu" role="menu">
<a class="dropdown-item" href="${urls.download}">
${_l("Download dataset")}
</a>
${_.map(
this.model.get("meta_files"),
(meta_file) =>
`<a class="dropdown-item" href="${
urls.meta_download + meta_file.file_type
}" data-description="download ${meta_file.file_type}">
${_l("Download")} ${meta_file.file_type}
</a>`
)}
</div>
</div>`);
},
// ......................................................................... misc
events: _.extend(_.clone(_super.prototype.events), {
"click .display-btn": function (ev) {
this.trigger("display", this, ev);
},
"click .params-btn": function (ev) {
this.trigger("params", this, ev);
},
"click .download-btn": function (ev) {
this.trigger("download", this, ev);
},
}),
// ......................................................................... misc
/** String representation */
toString: function () {
var modelString = this.model ? `${this.model}` : "(no model)";
return `DatasetListItemView(${modelString})`;
},
}
);
// ............................................................................ TEMPLATES
/** underscore templates */
DatasetListItemView.prototype.templates = (() => {
//TODO: move to require text! plugin
var warnings = _.extend({}, _super.prototype.templates.warnings, {
failed_metadata: BASE_MVC.wrapTemplate([
// failed metadata is rendered as a warning on an otherwise ok dataset view
'<% if( model.state === "failed_metadata" ){ %>',
'<div class="warningmessagesmall">',
_l("An error occurred setting the metadata for this dataset"),
"</div>",
"<% } %>",
]),
error: BASE_MVC.wrapTemplate([
// error during index fetch - show error on dataset
"<% if( model.error ){ %>",
'<div class="errormessagesmall">',
_l("There was an error getting the data for this dataset"),
": <%- model.error %>",
"</div>",
"<% } %>",
]),
purged: BASE_MVC.wrapTemplate([
"<% if( model.purged ){ %>",
'<div class="purged-msg warningmessagesmall">',
_l("This dataset has been deleted and removed from disk"),
"</div>",
"<% } %>",
]),
deleted: BASE_MVC.wrapTemplate([
// deleted not purged
"<% if( model.deleted && !model.purged ){ %>",
'<div class="deleted-msg warningmessagesmall">',
_l("This dataset has been deleted"),
"</div>",
"<% } %>",
]),
//NOTE: hidden warning is only needed for HDAs
});
var detailsTemplate = BASE_MVC.wrapTemplate(
[
'<div class="details">',
'<div class="summary"></div>',
'<div class="actions clear">',
'<div class="left"></div>',
'<div class="right"></div>',
"</div>",
// do not display tags, annotation, display apps, or peek when deleted
"<% if( !dataset.deleted && !dataset.purged ){ %>",
'<div class="tags-display"></div>',
'<div class="annotation-display"></div>',
'<div class="display-applications"></div>',
"<% if( dataset.peek ){ %>",
'<pre class="dataset-peek"><%= dataset.peek %></pre>',
"<% } %>",
"<% } %>",
"</div>",
],
"dataset"
);
var noAccessTemplate = BASE_MVC.wrapTemplate(
[
'<div class="details">',
'<div class="summary">',
_l("You do not have permission to view this dataset"),
"</div>",
"</div>",
],
"dataset"
);
//TODO: still toooooooooooooo complex - rework
var summaryTemplates = {};
summaryTemplates[STATES.OK] = summaryTemplates[STATES.FAILED_METADATA] = BASE_MVC.wrapTemplate(
[
"<% if( dataset.misc_blurb ){ %>",
'<div class="blurb">',
'<span class="value"><%- dataset.misc_blurb %></span>',
"</div>",
"<% } %>",
"<% if( dataset.file_ext ){ %>",
'<div class="datatype">',
'<label class="prompt">',
_l("format"),
"</label>",
'<span class="value"><%- dataset.file_ext %></span>',
"</div>",
"<% } %>",
"<% if( dataset.metadata_dbkey ){ %>",
'<div class="dbkey">',
'<label class="prompt">',
_l("database"),
"</label>",
'<span class="value">',
"<%- dataset.metadata_dbkey %>",
"</span>",
"</div>",
"<% } %>",
"<% if( dataset.misc_info ){ %>",
'<div class="info">',
'<span class="value"><%- dataset.misc_info %></span>',
"</div>",
"<% } %>",
],
"dataset"
);
summaryTemplates[STATES.NEW] = BASE_MVC.wrapTemplate(
["<div>", _l("This is a new dataset and not all of its data are available yet"), "</div>"],
"dataset"
);
summaryTemplates[STATES.NOT_VIEWABLE] = BASE_MVC.wrapTemplate(
["<div>", _l("You do not have permission to view this dataset"), "</div>"],
"dataset"
);
summaryTemplates[STATES.DISCARDED] = BASE_MVC.wrapTemplate(
[
"<div>",
_l(
"This dataset is discarded - the job creating it may have been cancelled or it may have been imported with file data"
),
"</div>",
],
"dataset"
);
summaryTemplates[STATES.DEFERRED] = BASE_MVC.wrapTemplate(
[
"<div>",
_l("This dataset is remote, has not be ingested by Galaxy, and full metadata may not be available"),
"</div>",
],
"dataset"
);
summaryTemplates[STATES.QUEUED] = BASE_MVC.wrapTemplate(
["<div>", _l("This job is waiting to run"), "</div>"],
"dataset"
);
summaryTemplates[STATES.RUNNING] = BASE_MVC.wrapTemplate(
["<div>", _l("This job is currently running"), "</div>"],
"dataset"
);
summaryTemplates[STATES.UPLOAD] = BASE_MVC.wrapTemplate(
["<div>", _l("This dataset is currently uploading"), "</div>"],
"dataset"
);
summaryTemplates[STATES.SETTING_METADATA] = BASE_MVC.wrapTemplate(
["<div>", _l("Metadata is being auto-detected"), "</div>"],
"dataset"
);
summaryTemplates[STATES.PAUSED] = BASE_MVC.wrapTemplate(
[
"<div>",
_l('This job is paused. Use the "Resume Paused Jobs" in the history menu to resume'),
"</div>",
'<div class="info"><%- dataset.misc_info %></div>',
],
"dataset"
);
summaryTemplates[STATES.ERROR] = BASE_MVC.wrapTemplate(
[
"<% if( !dataset.purged ){ %>",
"<div><%- dataset.misc_blurb %></div>",
"<% } %>",
'<span class="help-text">',
_l("An error occurred with this dataset"),
":</span>",
'<div class="job-error-text"><%- dataset.misc_info %></div>',
],
"dataset"
);
summaryTemplates[STATES.EMPTY] = BASE_MVC.wrapTemplate(
["<div>", _l("No data"), ": <i><%- dataset.misc_blurb %></i></div>"],
"dataset"
);
summaryTemplates.unknown = BASE_MVC.wrapTemplate(
['<div>Error: unknown dataset state: "<%- dataset.state %>"</div>'],
"dataset"
);
// messages to be displayed only within the details section ('below the fold')
var detailMessageTemplates = {
resubmitted: BASE_MVC.wrapTemplate([
// deleted not purged
"<% if( model.resubmitted ){ %>",
'<div class="resubmitted-msg alert alert-info">',
_l("The job creating this dataset has been resubmitted"),
"</div>",
"<% } %>",
]),
};
// this is applied to both old and new style display apps
var displayApplicationsTemplate = BASE_MVC.wrapTemplate(
[
"<% _.each( apps, function( app ){ %>",
'<div class="display-application">',
'<span class="display-application-location"><%- app.label %></span> ',
'<span class="display-application-links">',
"<% _.each( app.links, function( link ){ %>",
'<a target="<%- link.target %>" href="<%- link.href %>">',
"<% print( _l( link.text ) ); %>",
"</a> ",
"<% }); %>",
"</span>",
"</div>",
"<% }); %>",
],
"apps"
);
return _.extend({}, _super.prototype.templates, {
warnings: warnings,
details: detailsTemplate,
noAccess: noAccessTemplate,
summaries: summaryTemplates,
detailMessages: detailMessageTemplates,
displayApplications: displayApplicationsTemplate,
});
})();
// ============================================================================
export default {
DatasetListItemView: DatasetListItemView,
};
-42
View File
@@ -1,42 +0,0 @@
import LIST_VIEW from "mvc/list/list-view";
import DATASET_LI from "mvc/dataset/dataset-li";
import _l from "utils/localization";
var logNamespace = "dataset";
/* =============================================================================
TODO:
============================================================================= */
var _super = LIST_VIEW.ListPanel;
/** @class non-editable, read-only View/Controller for a list of datasets.
*/
var DatasetList = _super.extend(
/** @lends DatasetList.prototype */ {
_logNamespace: logNamespace,
/** class to use for constructing the sub-views */
viewClass: DATASET_LI.DatasetListItemView,
className: `${_super.prototype.className} dataset-list`,
/** string to no hdas match the search terms */
noneFoundMsg: _l("No matching datasets found"),
// ......................................................................... SET UP
/** Set up the view, set up storage, bind listeners to HistoryContents events
* @param {Object} attributes optional settings for the panel
*/
initialize: function (attributes) {
_super.prototype.initialize.call(this, attributes);
},
/** Return a string rep of the history */
toString: function () {
return `DatasetList(${this.collection})`;
},
}
);
//==============================================================================
export default {
DatasetList: DatasetList,
};
-390
View File
@@ -1,390 +0,0 @@
import _ from "underscore";
import jQuery from "jquery";
import Backbone from "backbone";
import { getAppRoot } from "onload/loadConfig";
import STATES from "mvc/dataset/states";
import BASE_MVC from "mvc/base-mvc";
import _l from "utils/localization";
import TAB_UPDATES from "mvc/user/tab-updates";
var logNamespace = "dataset";
var hiddenupdates = 1;
//==============================================================================
var searchableMixin = BASE_MVC.SearchableModelMixin;
/** @class base model for any DatasetAssociation (HDAs, LDDAs, DatasetCollectionDAs).
* No knowledge of what type (HDA/LDDA/DCDA) should be needed here.
* The DA's are made searchable (by attribute) by mixing in SearchableModelMixin.
*/
var DatasetAssociation = Backbone.Model.extend(BASE_MVC.LoggableMixin).extend(
BASE_MVC.mixin(
searchableMixin,
/** @lends DatasetAssociation.prototype */ {
_logNamespace: logNamespace,
/** default attributes for a model */
defaults: {
state: STATES.NEW,
deleted: false,
purged: false,
name: "(unnamed dataset)",
accessible: true,
// sniffed datatype (sam, tabular, bed, etc.)
data_type: "",
file_ext: "",
file_size: 0,
// array of associated file types (eg. [ 'bam_index', ... ])
meta_files: [],
misc_blurb: "",
misc_info: "",
tags: [],
// do NOT default on annotation, as this default is valid and will be passed on 'save'
// which is incorrect behavior when the model is only partially fetched (annos are not passed in summary data)
//annotation : ''
},
/** instance vars and listeners */
initialize: function (attributes, options) {
this.debug(`${this}(Dataset).initialize`, attributes, options);
//!! this state is not in trans.app.model.Dataset.states - set it here -
if (!this.get("accessible")) {
this.set("state", STATES.NOT_VIEWABLE);
}
/** Datasets rely/use some web controllers - have the model generate those URLs on startup */
this.urls = this._generateUrls();
this._setUpListeners();
},
_getDatasetId: function () {
return this.get("id");
},
/** returns misc. web urls for rendering things like re-run, display, etc. */
_generateUrls: function () {
const id = this._getDatasetId();
if (!id) {
return {};
}
var urls = {
purge: `datasets/${id}/purge_async`,
display: `datasets/${id}/display/?preview=True`,
edit: `datasets/edit?dataset_id=${id}`,
download: `api/datasets/${id}/display${this._downloadQueryParameters()}`,
report_error: `datasets/error?dataset_id=${id}`,
rerun: `tool_runner/rerun?id=${id}`,
show_params: `datasets/${id}/details`,
visualization: "visualization",
meta_download: `dataset/get_metadata_file?hda_id=${id}&metadata_name=`,
};
_.each(urls, (value, key) => {
urls[key] = getAppRoot() + value;
});
this.urls = urls;
return urls;
},
_downloadQueryParameters: function () {
return `?to_ext=${this.get("file_ext")}`;
},
/** set up any event listeners
* event: state:ready fired when this DA moves into/is already in a ready state
*/
_setUpListeners: function () {
// if the state has changed and the new state is a ready state, fire an event
this.on("change:state", function (currModel, newState) {
this.log(`${this} has changed state:`, currModel, newState);
if (this.inReadyState()) {
this.trigger("state:ready", currModel, newState, this.previous("state"));
if (newState != "discarded") {
if (newState === "ok") {
// If Notifications are supported, send one.
if (window.Notification) {
new Notification(`Job complete: ${this.get("name")}`, {
icon: "static/favicon.ico",
});
}
if (TAB_UPDATES.is_hidden()) {
TAB_UPDATES.hidden_count(hiddenupdates);
hiddenupdates++;
}
} else if (newState == "error") {
// If Notifications are supported, send one.
if (window.Notification) {
new Notification(`Job failure: ${this.get("name")}`, {
icon: "static/erricon.ico",
});
if (TAB_UPDATES.is_hidden() && Notification.permission == "granted") {
TAB_UPDATES.change_favicon("static/erricon.ico");
}
}
}
}
}
});
// the download url (currently) relies on having a correct file extension
this.on("change:id change:file_ext", function (currModel) {
this._generateUrls();
});
},
// ........................................................................ common queries
/** override to add urls */
toJSON: function () {
var json = Backbone.Model.prototype.toJSON.call(this);
//console.warn( 'returning json?' );
//return json;
return _.extend(json, {
urls: this.urls,
});
},
/** Is this dataset deleted or purged? */
isDeletedOrPurged: function () {
return this.get("deleted") || this.get("purged");
},
/** Is this dataset in a 'ready' state; where 'Ready' states are states where no
* processing (for the ds) is left to do on the server.
*/
inReadyState: function () {
var ready = _.contains(STATES.READY_STATES, this.get("state"));
return this.isDeletedOrPurged() || ready;
},
/** Does this model already contain detailed data (as opposed to just summary level data)? */
hasDetails: function () {
// if it's inaccessible assume it has everything it needs
if (!this.get("accessible")) {
return true;
}
return this.has("annotation");
},
/** Convenience function to match dataset.has_data. */
hasData: function () {
return this.get("file_size") > 0;
},
// ........................................................................ ajax
fetch: function (options) {
var dataset = this;
return Backbone.Model.prototype.fetch.call(this, options).always(() => {
dataset._generateUrls();
});
},
/** override to use actual Dates objects for create/update times */
parse: function (response, options) {
var parsed = Backbone.Model.prototype.parse.call(this, response, options);
if (parsed.create_time) {
parsed.create_time = new Date(parsed.create_time);
}
if (parsed.update_time) {
parsed.update_time = new Date(parsed.update_time);
}
return parsed;
},
/** override to wait by default */
save: function (attrs, options) {
options = options || {};
options.wait = _.isUndefined(options.wait) ? true : options.wait;
return Backbone.Model.prototype.save.call(this, attrs, options);
},
//NOTE: subclasses of DA's will need to implement url and urlRoot in order to have these work properly
/** 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);
},
/** remove the file behind this dataset from the filesystem (if permitted) */
purge: function _purge(options) {
//TODO: use, override model.destroy, HDA.delete({ purge: true })
if (this.get("purged")) {
return jQuery.when();
}
options = options || {};
options.url = this.urls.purge;
//TODO: ideally this would be a DELETE call to the api
// using purge async for now
var hda = this;
var xhr = jQuery.ajax(options);
xhr.done((message, status, responseObj) => {
hda.set({ deleted: true, purged: true });
});
xhr.fail((xhr, status, message) => {
// Exception messages are hidden within error page including: '...not allowed in this Galaxy instance.'
// unbury and re-add to xhr
var error = _l("Unable to purge dataset");
var messageBuriedInUnfortunatelyFormattedError =
"Removal of datasets by users " + "is not allowed in this Galaxy instance";
if (xhr.responseJSON && xhr.responseJSON.error) {
error = xhr.responseJSON.error;
} else if (xhr.responseText.indexOf(messageBuriedInUnfortunatelyFormattedError) !== -1) {
error = messageBuriedInUnfortunatelyFormattedError;
}
xhr.responseText = error;
hda.trigger("error", hda, xhr, options, _l(error), {
error: error,
});
});
return xhr;
},
// ........................................................................ searching
/** what attributes of an HDA will be used in a text search */
searchAttributes: ["name", "file_ext", "genome_build", "misc_blurb", "misc_info", "annotation", "tags"],
/** 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: {
title: "name",
format: "file_ext",
database: "genome_build",
blurb: "misc_blurb",
description: "misc_blurb",
info: "misc_info",
tag: "tags",
},
// ........................................................................ misc
/** String representation */
toString: function () {
var nameAndId = this.get("id") || "";
if (this.get("name")) {
nameAndId = `"${this.get("name")}",${nameAndId}`;
}
return `Dataset(${nameAndId})`;
},
}
)
);
//==============================================================================
/** @class Backbone collection for dataset associations.
*/
var DatasetAssociationCollection = Backbone.Collection.extend(BASE_MVC.LoggableMixin).extend(
/** @lends HistoryContents.prototype */ {
_logNamespace: logNamespace,
model: DatasetAssociation,
/** root api url */
urlRoot: `${getAppRoot()}api/datasets`,
/** url fn */
url: function () {
return this.urlRoot;
},
// ........................................................................ common queries
/** Get the ids of every item in this collection
* @returns array of encoded ids
*/
ids: function () {
return this.map((item) => item.get("id"));
},
/** Get contents that are not ready
* @returns array of content models
*/
notReady: function () {
return this.filter((content) => !content.inReadyState());
},
/** return true if any datasets don't have details */
haveDetails: function () {
return this.all((dataset) => dataset.hasDetails());
},
// ........................................................................ ajax
/** using a queue, perform ajaxFn on each of the models in this collection */
ajaxQueue: function (ajaxFn, options) {
var deferred = jQuery.Deferred();
var startingLength = this.length;
var responses = [];
if (!startingLength) {
deferred.resolve([]);
return deferred;
}
// use reverse order (stylistic choice)
var ajaxFns = this.chain()
.reverse()
.map((dataset, i) => () => {
var xhr = ajaxFn.call(dataset, options);
// if successful, notify using the deferred to allow tracking progress
xhr.done((response) => {
deferred.notify({
curr: i,
total: startingLength,
response: response,
model: dataset,
});
});
// (regardless of previous error or success) if not last ajax call, shift and call the next
// if last fn, resolve deferred
xhr.always((response) => {
responses.push(response);
if (ajaxFns.length) {
ajaxFns.shift()();
} else {
deferred.resolve(responses);
}
});
})
.value();
// start the queue
ajaxFns.shift()();
return deferred;
},
// ........................................................................ sorting/filtering
/** return a new collection of datasets whose attributes contain the substring matchesWhat */
matches: function (matchesWhat) {
return this.filter((dataset) => dataset.matches(matchesWhat));
},
/** String representation. */
toString: function () {
return ["DatasetAssociationCollection(", this.length, ")"].join("");
},
}
);
window.addEventListener("focus", function () {
const testing = document.getElementById("tabicon");
if (testing && testing.href.search("erricon") != -1) {
TAB_UPDATES.change_favicon("static/favicon.ico");
}
document.title = "Galaxy";
hiddenupdates = 1;
});
//==============================================================================
export default {
DatasetAssociation: DatasetAssociation,
DatasetAssociationCollection: DatasetAssociationCollection,
};
-33
View File
@@ -1,33 +0,0 @@
import $ from "jquery";
import GridView from "mvc/grid/grid-view";
import { clearPolling, pollUntilActive } from "mvc/entrypoints/poll";
export default GridView.extend({
init_grid_elements: function () {
GridView.prototype.init_grid_elements.call(this);
const activated = {};
const onUpdate = (entryPoints) => {
entryPoints.forEach((entryPoint) => {
const entryPointId = entryPoint.id;
if (entryPoint.active && !activated[entryPointId]) {
const $link = $(`.entry-point-link[entry_point_id='${entryPointId}']`);
if ($link.length > 0) {
$link.attr("href", entryPoint["target"]);
activated[entryPointId] = true;
}
}
});
};
const onError = (e) => {
console.error(e);
};
pollUntilActive(onUpdate, onError, { running: true });
},
remove: function () {
// Your processing code here
clearPolling();
GridView.prototype.remove.apply(this, arguments);
},
});
-237
View File
@@ -1,237 +0,0 @@
import MODAL from "mvc/ui/ui-modal";
import ERROR_MODAL from "mvc/ui/error-modal";
import _l from "utils/localization";
import _ from "underscore";
import jQuery from "jquery";
import { getGalaxyInstance } from "app";
import { getAppRoot } from "onload/loadConfig";
var $ = jQuery;
//==============================================================================
/**
* A dialog/modal that allows copying a user history or 'importing' from user
* another. Generally called via historyCopyDialog below.
* @type {Object}
*/
var CopyDialog = {
// language related strings/fns
defaultName: _.template("Copy of '<%- name %>'"),
title: _.template(`${_l("Copying history")} "<%- name %>"`),
submitLabel: _l("Copy"),
errorMessage: _l("History could not be copied."),
progressive: _l("Copying history"),
activeLabel: _l("Copy only the active, non-deleted datasets"),
allLabel: _l("Copy all datasets including deleted ones"),
anonWarning:
_l("As an anonymous user, unless you login or register, you will lose your current history ") +
_l("after copying this history. "),
// template for modal body
_template: _.template(
[
//TODO: remove inline styles
// show a warning message for losing current to anon users
"<% if( isAnon ){ %>",
'<div class="warningmessage">',
"<%- anonWarning %>",
_l("You can"),
' <a href="<%= galaxyRoot %>user/login">',
_l("login here"),
"</a> ",
_l("or"),
" ",
' <a href="<%= galaxyRoot %>user/create">',
_l("register here"),
"</a>.",
"</div>",
"<% } %>",
"<form>",
'<label for="copy-modal-title">',
_l("Enter a title for the new history"),
":",
"</label><br />",
// TODO: could use required here and the form validators
// NOTE: use unescaped here if escaped in the modal function below
'<input id="copy-modal-title" class="form-control" style="width: 100%" value="<%= name %>" />',
'<p class="invalid-title bg-danger" style="color: red; margin: 8px 0px 8px 0px; display: none">',
_l("Please enter a valid history title"),
"</p>",
// if allowAll, add the option to copy deleted datasets, too
"<% if( allowAll ){ %>",
"<br />",
"<p>",
_l("Choose which datasets from the original history to include:"),
"</p>",
// copy non-deleted is the default
'<input name="copy-what" type="radio" id="copy-non-deleted" value="copy-non-deleted" ',
'<% if( copyWhat === "copy-non-deleted" ){ print( "checked" ); } %>/>',
'<label for="copy-non-deleted"> <%- activeLabel %></label>',
"<br />",
'<input name="copy-what" type="radio" id="copy-all" value="copy-all" ',
'<% if( copyWhat === "copy-all" ){ print( "checked" ); } %>/>',
'<label for="copy-all"> <%- allLabel %></label>',
"<% } %>",
"</form>",
].join("")
),
// empty modal body and let the user know the copy is happening
_showAjaxIndicator: function _showAjaxIndicator() {
var indicator = `<p><span class="fa fa-spinner fa-spin"></span> ${this.progressive}...</p>`;
this.modal.$(".modal-body").empty().append(indicator).css({ "margin-top": "8px" });
},
// (sorta) public interface - display the modal, render the form, and potentially copy the history
// returns a jQuery.Deferred done->history copied, fail->user cancelled
dialog: function _dialog(modal, history, options) {
options = options || {};
var dialog = this;
var deferred = jQuery.Deferred();
var // TODO: getting a little byzantine here
defaultCopyNameFn = options.nameFn || this.defaultName;
var defaultCopyName = defaultCopyNameFn({
name: history.get("name"),
});
var // TODO: these two might be simpler as one 3 state option (all,active,no-choice)
defaultCopyWhat = options.allDatasets ? "copy-all" : "copy-non-deleted";
var allowAll = !_.isUndefined(options.allowAll) ? options.allowAll : true;
var autoClose = !_.isUndefined(options.autoClose) ? options.autoClose : true;
this.modal = modal;
// validate the name and copy if good
function checkNameAndCopy() {
var name = modal.$("#copy-modal-title").val();
if (!name) {
modal.$(".invalid-title").show();
return;
}
// get further settings, shut down and indicate the ajax call, then hide and resolve/reject
var copyAllDatasets = modal.$('input[name="copy-what"]:checked').val() === "copy-all";
modal.$("button").prop("disabled", true);
dialog._showAjaxIndicator();
history
.copy(true, name, copyAllDatasets)
.done((response) => {
deferred.resolve(response);
})
.fail(function (xhr, status, message) {
var options = {
name: name,
copyAllDatasets: copyAllDatasets,
};
ERROR_MODAL.ajaxErrorModal(history, xhr, options, dialog.errorMessage);
deferred.rejectWith(deferred, arguments);
})
.done(() => {
if (autoClose) {
modal.hide();
}
});
}
var originalClosingCallback = options.closing_callback;
const Galaxy = getGalaxyInstance();
modal.show(
_.extend(options, {
title: this.title({ name: history.get("name") }),
body: $(
dialog._template({
galaxyRoot: getAppRoot(),
name: defaultCopyName,
isAnon: Galaxy.user.isAnonymous(),
allowAll: allowAll,
copyWhat: defaultCopyWhat,
activeLabel: this.activeLabel,
allLabel: this.allLabel,
anonWarning: this.anonWarning,
})
),
buttons: _.object([
[
_l("Cancel"),
() => {
modal.hide();
},
],
[this.submitLabel, checkNameAndCopy],
]),
height: "auto",
closing_events: true,
closing_callback: function _historyCopyClose(cancelled) {
if (cancelled) {
deferred.reject({ cancelled: true });
}
if (originalClosingCallback) {
originalClosingCallback(cancelled);
}
},
})
);
// set the default dataset copy, autofocus the title, and set up for a simple return
modal.$("#copy-modal-title").focus().select();
modal.$("#copy-modal-title").on("keydown", (ev) => {
if (ev.keyCode === 13) {
ev.preventDefault();
checkNameAndCopy();
}
});
return deferred;
},
};
//==============================================================================
// maintain the (slight) distinction between copy and import
/**
* Subclass CopyDialog to use the import language.
*/
var ImportDialog = _.extend({}, CopyDialog, {
defaultName: _.template("imported: <%- name %>"),
title: _.template(`${_l("Importing history")} "<%- name %>"`),
submitLabel: _l("Import"),
errorMessage: _l("History could not be imported."),
progressive: _l("Importing history"),
activeLabel: _l("Import only the active, non-deleted datasets"),
allLabel: _l("Import all datasets including deleted ones"),
anonWarning:
_l("As an anonymous user, unless you login or register, you will lose your current history ") +
_l("after importing this history. "),
});
//==============================================================================
/**
* Main interface for both history import and history copy dialogs.
* @param {Backbone.Model} history the history to copy
* @param {Object} options a hash
* @return {jQuery.Deferred} promise that fails on close and succeeds on copy
*
* options:
* (this object is also passed to the modal used to display the dialog and accepts modal options)
* {Function} nameFn if defined, use this to build the default name shown to the user
* (the fn is passed: {name: <original history's name>})
* {bool} useImport if true, use the 'import' language (instead of Copy)
* {bool} allowAll if true, allow the user to choose between copying all datasets and
* only non-deleted datasets
* {String} allDatasets default initial checked radio button: 'copy-all' or 'copy-non-deleted',
*/
var historyCopyDialog = (history, options) => {
const Galaxy = getGalaxyInstance();
options = options || {};
// create our own modal if Galaxy doesn't have one (mako tab without use_panels)
var modal = Galaxy.modal || new MODAL.View({});
return options.useImport
? ImportDialog.dialog(modal, history, options)
: CopyDialog.dialog(modal, history, options);
};
//==============================================================================
export default historyCopyDialog;
-84
View File
@@ -1,84 +0,0 @@
import $ from "jquery";
import _ from "underscore";
import DATASET_LI_EDIT from "mvc/dataset/dataset-li-edit";
import HDA_LI from "mvc/history/hda-li";
import BASE_MVC from "mvc/base-mvc";
import _l from "utils/localization";
//==============================================================================
var _super = DATASET_LI_EDIT.DatasetListItemEdit;
/** @class Editing view for HistoryDatasetAssociation.
*/
var HDAListItemEdit = _super.extend(
/** @lends HDAListItemEdit.prototype */ {
className: `${_super.prototype.className} history-content`,
/** In this override, only get details if in the ready state, get rerunnable if in other states.
* Note: fetch with no 'change' event triggering to prevent automatic rendering.
*/
_fetchModelDetails: function () {
var view = this;
if (view.model.inReadyState() && !view.model.hasDetails()) {
return view.model.fetch({ silent: true });
// special case the need for the rerunnable and creating_job attributes
// needed for rendering re-run button on queued, running datasets
} else if (!view.model.has("rerunnable")) {
return view.model.fetch({
silent: true,
data: {
// only fetch rerunnable and creating_job to keep overhead down
keys: ["rerunnable", "creating_job"].join(","),
},
});
}
return $.when();
},
/** event map */
events: _.extend(_.clone(_super.prototype.events), {
"click .unhide-link": function (ev) {
this.model.unhide();
return false;
},
}),
/** string rep */
toString: function () {
var modelString = this.model ? `${this.model}` : "(no model)";
return `HDAListItemEdit(${modelString})`;
},
}
);
// ............................................................................ TEMPLATES
/** underscore templates */
HDAListItemEdit.prototype.templates = (() => {
var warnings = _.extend({}, _super.prototype.templates.warnings, {
hidden: BASE_MVC.wrapTemplate(
[
"<% if( !dataset.visible ){ %>",
// add a link to unhide a dataset
'<div class="hidden-msg warningmessagesmall">',
_l("This dataset has been hidden"),
'<br /><a class="unhide-link" a href="javascript:void(0);">',
_l("Unhide it"),
"</a>",
"</div>",
"<% } %>",
],
"dataset"
),
});
return _.extend({}, _super.prototype.templates, {
//NOTE: *steal* the HDAListItemView titleBar
titleBar: HDA_LI.HDAListItemView.prototype.templates.titleBar,
warnings: warnings,
});
})();
//==============================================================================
export default {
HDAListItemEdit: HDAListItemEdit,
};
-68
View File
@@ -1,68 +0,0 @@
import _ from "underscore";
import DATASET_LI from "mvc/dataset/dataset-li";
import BASE_MVC from "mvc/base-mvc";
import _l from "utils/localization";
//==============================================================================
var _super = DATASET_LI.DatasetListItemView;
/** @class Read only view for HistoryDatasetAssociation.
* Since there are no controls on the HDAView to hide the dataset,
* the primary thing this class does (currently) is override templates
* to render the HID.
*/
var HDAListItemView = _super.extend(
/** @lends HDAListItemView.prototype */ {
className: `${_super.prototype.className} history-content`,
initialize: function (attributes, options) {
_super.prototype.initialize.call(this, attributes, options);
},
// ......................................................................... misc
/** String representation */
toString: function () {
var modelString = this.model ? `${this.model}` : "(no model)";
return `HDAListItemView(${modelString})`;
},
}
);
// ............................................................................ TEMPLATES
/** underscore templates */
HDAListItemView.prototype.templates = (() => {
var titleBarTemplate = (dataset) => `
<div class="title-bar clear" tabindex="0">
<span class="state-icon"></span>
<div class="title content-title">
<span class="hid">${dataset.hid}</span>
<span class="name">${_.escape(dataset.name)}</span>
</div>
</br>
<div class="nametags"></div>
</div>
`;
var warnings = _.extend({}, _super.prototype.templates.warnings, {
hidden: BASE_MVC.wrapTemplate(
[
// add a warning when hidden
"<% if( !dataset.visible ){ %>",
'<div class="hidden-msg warningmessagesmall">',
_l("This dataset has been hidden"),
"</div>",
"<% } %>",
],
"dataset"
),
});
return _.extend({}, _super.prototype.templates, {
titleBar: titleBarTemplate,
warnings: warnings,
});
})();
//==============================================================================
export default {
HDAListItemView: HDAListItemView,
};
-28
View File
@@ -1,28 +0,0 @@
import _ from "underscore";
import DATASET from "mvc/dataset/dataset-model";
import HISTORY_CONTENT from "mvc/history/history-content-model";
import BASE_MVC from "mvc/base-mvc";
//==============================================================================
var _super = DATASET.DatasetAssociation;
var hcontentMixin = HISTORY_CONTENT.HistoryContentMixin;
/** @class (HDA) model for a Galaxy dataset contained in and related to a history.
*/
var HistoryDatasetAssociation = _super.extend(
BASE_MVC.mixin(
hcontentMixin,
/** @lends HistoryDatasetAssociation.prototype */ {
/** default attributes for a model */
defaults: _.extend({}, _super.prototype.defaults, hcontentMixin.defaults, {
history_content_type: "dataset",
model_class: "HistoryDatasetAssociation",
}),
}
)
);
//==============================================================================
export default {
HistoryDatasetAssociation: HistoryDatasetAssociation,
};
-83
View File
@@ -1,83 +0,0 @@
import $ from "jquery";
import _ from "underscore";
import HDCA_LI from "mvc/history/hdca-li";
import DC_VIEW_EDIT from "mvc/collection/collection-view-edit";
import _l from "utils/localization";
//==============================================================================
var _super = HDCA_LI.HDCAListItemView;
/** @class Editing view for HistoryDatasetCollectionAssociation.
*/
var HDCAListItemEdit = _super.extend(
/** @lends HDCAListItemEdit.prototype */ {
/** logger used to record this.log messages, commonly set to console */
//logger : console,
/** set up: options */
initialize: function (attributes) {
_super.prototype.initialize.call(this, attributes);
/** allow user purge of dataset files? */
this.purgeAllowed = attributes.purgeAllowed || false;
},
/** Override to return editable versions of the collection panels */
_getFoldoutPanelClass: function () {
return DC_VIEW_EDIT.CollectionViewEdit;
},
// ......................................................................... delete
/** In this override, add the delete button. */
_renderPrimaryActions: function () {
this.log(`${this}._renderPrimaryActions`);
// render the display, edit attr and delete icon-buttons
return _super.prototype._renderPrimaryActions.call(this).concat([this._renderDeleteButton()]);
},
_renderDeleteButton: function () {
return $(`
<div class="dropdown">
<a class="delete-btn icon-btn" title="${_l("Delete")}" data-toggle="dropdown">
<span class="fa fa-times"></span>
</a>
<div class="dropdown-menu" role="menu">
<a href="javascript:void(0)" role="button" class="dropdown-item delete-collection">
${_l("Collection Only")}
</a>
<a href="javascript:void(0)" role="button" class="dropdown-item delete-collection-and-datasets">
${_l("Delete Datasets")}
</a>
<a href="javascript:void(0)" role="button" style="display: ${
this.purgeAllowed ? "inherit" : "none"
}" class="dropdown-item delete-collection-and-purge-datasets">
${_l("Permanently Delete Datasets")}
</a>
</div>
</div>`);
},
// ......................................................................... misc
events: _.extend(_.clone(_super.prototype.events), {
"click .delete-collection": function (ev) {
this.model["delete"]();
},
"click .delete-collection-and-datasets": function (ev) {
this.model["delete"](true);
},
"click .delete-collection-and-purge-datasets": function (ev) {
this.model["delete"](true, true);
},
}),
// ......................................................................... misc
/** string rep */
toString: function () {
var modelString = this.model ? `${this.model}` : "(no model)";
return `HDCAListItemEdit(${modelString})`;
},
}
);
//==============================================================================
export default {
HDCAListItemEdit: HDCAListItemEdit,
};
-126
View File
@@ -1,126 +0,0 @@
import _ from "underscore";
import STATES from "mvc/dataset/states";
import DC_LI from "mvc/collection/collection-li";
import DC_VIEW from "mvc/collection/collection-view";
import _l from "utils/localization";
import { mountNametags } from "components/Nametags";
import { mountCollectionJobStates } from "components/JobStates";
//==============================================================================
var _super = DC_LI.DCListItemView;
/** @class Read only view for HistoryDatasetCollectionAssociation (a dataset collection inside a history).
*/
var HDCAListItemView = _super.extend(
/** @lends HDCAListItemView.prototype */ {
className: `${_super.prototype.className} history-content`,
render: function () {
const result = _super.prototype.render.apply(this, arguments);
this._mountNametags("initialize");
return result;
},
/** event listeners */
_setUpListeners: function () {
_super.prototype._setUpListeners.call(this);
var renderListen = (model, options) => {
// We want this to swap immediately without extra animations.
this.render(0);
this._mountNametags("listener");
};
if (this.model.jobStatesSummary) {
this.listenTo(this.model.jobStatesSummary, "change", renderListen);
}
this.listenTo(this.model, {
"change:tags change:visible change:state": renderListen,
});
},
_mountNametags(context) {
const container = this.$el.find(".nametags")[0];
if (container) {
const { id, model_class, tags } = this.model.attributes;
const storeKey = `${model_class}-${id}`;
mountNametags({ storeKey, tags }, container);
}
},
/** Override to provide the proper collections panels as the foldout */
_getFoldoutPanelClass: function () {
return DC_VIEW.CollectionView;
},
/** In this override, add the state as a class for use with state-based CSS */
_swapNewRender: function ($newRender) {
_super.prototype._swapNewRender.call(this, $newRender);
//TODO: model currently has no state
var state;
var jobStatesSummary = this.model.jobStatesSummary;
if (jobStatesSummary) {
if (jobStatesSummary.new()) {
state = "loading";
} else if (jobStatesSummary.errored()) {
state = "error";
} else if (jobStatesSummary.terminal()) {
state = "ok";
} else if (jobStatesSummary.running()) {
state = "running";
} else {
state = "queued";
}
} else if (this.model.get("job_source_id")) {
// Initial rendering - polling will fill in more details in a bit.
state = "loading";
} else {
state = this.model.get("populated_state") ? STATES.OK : STATES.RUNNING;
}
this.$el.addClass(`state-${state}`);
this.$el.attr("data-state", state);
const collection = this.model;
const stateContainer = this.$el.find(".state-description")[0];
mountCollectionJobStates({ jobStatesSummary, collection }, stateContainer);
return this.$el;
},
// ......................................................................... misc
/** String representation */
toString: function () {
var modelString = this.model ? `${this.model}` : "(no model)";
return `HDCAListItemView(${modelString})`;
},
}
);
/** underscore templates */
HDCAListItemView.prototype.templates = (() => {
var warnings = _.extend({}, _super.prototype.templates.warnings, {
hidden: (collection) => {
collection.visible
? ""
: `<div class="hidden-msg warningmessagesmall">${_l("This collection has been hidden")}</div>`;
},
});
var titleBarTemplate = (collection) => `
<div class="title-bar clear" tabindex="0">
<span class="state-icon"></span>
<div class="title content-title">
<span class="hid">${collection.hid}</span>
<span class="name">${_.escape(collection.name)}</span>
</div>
<div class="state-description">
</div>
<div class="nametags"><!-- Nametags mount here (hdca-li) --></div>
</div>
`;
return _.extend({}, _super.prototype.templates, {
warnings: warnings,
titleBar: titleBarTemplate,
});
})();
//==============================================================================
export default {
HDCAListItemView: HDCAListItemView,
};
-46
View File
@@ -1,46 +0,0 @@
import _ from "underscore";
import DC_MODEL from "mvc/collection/collection-model";
import HISTORY_CONTENT from "mvc/history/history-content-model";
/*==============================================================================
Models for DatasetCollections contained within a history.
==============================================================================*/
var hcontentMixin = HISTORY_CONTENT.HistoryContentMixin;
var DC = DC_MODEL.DatasetCollection;
//==============================================================================
/** @class Backbone model for List Dataset Collection within a History.
*/
var HistoryDatasetCollection = DC.extend(hcontentMixin).extend(
/** @lends HistoryDatasetCollection.prototype */ {
defaults: _.extend(_.clone(DC.prototype.defaults), {
history_content_type: "dataset_collection",
model_class: "HistoryDatasetCollectionAssociation",
}),
//==============================================================================
/** Override to post to contents route w/o id. */
save: function (attributes, options) {
if (this.isNew()) {
options = options || {};
options.url = `${this.urlRoot + this.get("history_id")}/contents`;
attributes = attributes || {};
attributes.type = "dataset_collection";
}
return DC.prototype.save.call(this, attributes, options);
},
/** String representation. */
toString: function () {
return `History${DC.prototype.toString.call(this)}`;
},
}
);
//==============================================================================
export default {
HistoryDatasetCollection: HistoryDatasetCollection,
};
@@ -1,104 +0,0 @@
// import _ from "underscore";
import jQuery from "jquery";
// import Backbone from "backbone";
import { getAppRoot } from "onload/loadConfig";
// import STATES from "mvc/dataset/states";
// import BASE_MVC from "mvc/base-mvc";
// import _l from "utils/localization";
var collectionFuzzyCountDefault = 1000;
try {
collectionFuzzyCountDefault = localStorage.getItem("collectionFuzzyCountDefault") || collectionFuzzyCountDefault;
} catch (err) {
console.debug(err);
}
//==============================================================================
/** @class Mixin for HistoryContents content (HDAs, HDCAs).
*/
var HistoryContentMixin = {
/** default attributes for a model */
defaults: {
/** parent (containing) history */
history_id: null,
/** some content_type (HistoryContents can contain mixed model classes) */
history_content_type: null,
/** indicating when/what order the content was generated in the context of the history */
hid: null,
/** whether the user wants the content shown (visible) */
visible: true,
},
// ........................................................................ mixed content element
// In order to be part of a MIXED bbone collection, we can't rely on the id
// (which may collide btwn models of different classes)
// Instead, use type_id which prefixes the history_content_type so the bbone collection can differentiate
idAttribute: "type_id",
// ........................................................................ common queries
/** the more common alias of visible */
hidden: function () {
return !this.get("visible");
},
//TODO: remove
/** based on includeDeleted, includeHidden (gen. from the container control),
* would this ds show in the list of ds's?
* @param {Boolean} includeDeleted are we showing deleted hdas?
* @param {Boolean} includeHidden are we showing hidden hdas?
*/
isVisible: function (includeDeleted, includeHidden) {
var isVisible = true;
if (!includeDeleted && (this.get("deleted") || this.get("purged"))) {
isVisible = false;
}
if (!includeHidden && !this.get("visible")) {
isVisible = false;
}
return isVisible;
},
// ........................................................................ ajax
//TODO?: these are probably better done on the leaf classes
/** history content goes through the 'api/histories' API */
urlRoot: `${getAppRoot()}api/histories/`,
/** full url spec. for this content */
url: function () {
var historyContentType = this.get("history_content_type");
var historyId = this.get("history_id");
var historyContentId = this.get("id");
var url = `${this.urlRoot}${historyId}/contents/${historyContentType}s/${historyContentId}`;
if (historyContentType == "dataset_collection") {
// Don't fetch whole collection - just enought to render outline. Backbone will
// make a detailed request if any datasets are expanded beyond that point.
url = `${url}?view=element-reference&fuzzy_count=${collectionFuzzyCountDefault}`;
}
return url;
},
/** save this content as not visible */
hide: function (options) {
if (!this.get("visible")) {
return jQuery.when();
}
return this.save({ visible: false }, options);
},
/** save this content as visible */
unhide: function (options) {
if (this.get("visible")) {
return jQuery.when();
}
return this.save({ visible: true }, options);
},
// ........................................................................ misc
toString: function () {
return [this.get("type_id"), this.get("hid"), this.get("name")].join(":");
},
};
//==============================================================================
export default {
HistoryContentMixin: HistoryContentMixin,
};
-505
View File
@@ -1,505 +0,0 @@
import _ from "underscore";
import jQuery from "jquery";
import Backbone from "backbone";
import { getAppRoot } from "onload/loadConfig";
import CONTROLLED_FETCH_COLLECTION from "mvc/base/controlled-fetch-collection";
import HDA_MODEL from "mvc/history/hda-model";
import HDCA_MODEL from "mvc/history/hdca-model";
import HISTORY_PREFS from "mvc/history/history-preferences";
import JOB_STATES_MODEL from "mvc/history/job-states-model";
import BASE_MVC from "mvc/base-mvc";
import AJAX_QUEUE from "utils/ajax-queue";
const limitPerPageDefault = window.localStorage.getItem("historyContentsLimitPerPageDefault") || 500;
//==============================================================================
var _super = CONTROLLED_FETCH_COLLECTION.PaginatedCollection;
/** @class Backbone collection for history content.
* NOTE: history content seems like a dataset collection, but differs in that it is mixed:
* each element can be either an HDA (dataset) or a DatasetCollection and co-exist on
* the same level.
* Dataset collections on the other hand are not mixed and (so far) can only contain either
* HDAs or child dataset collections on one level.
* This is why this does not inherit from any of the DatasetCollections (currently).
*/
export var HistoryContents = _super.extend(BASE_MVC.LoggableMixin).extend({
_logNamespace: "history",
// ........................................................................ set up
limitPerPage: limitPerPageDefault,
/** @type {Integer} how many contents per call to fetch when using progressivelyFetchDetails */
limitPerProgressiveFetch: limitPerPageDefault,
/** @type {String} order used here and when fetching from server */
order: "hid",
/** complete api url */
url: function () {
return `${this.urlRoot}/${this.historyId}/contents`;
},
/** Set up */
initialize: function (models, options) {
this.on({
"sync add": this.trackJobStates,
});
options = options || {};
this.urlRoot = `${getAppRoot()}api/histories`;
_super.prototype.initialize.call(this, models, options);
this.history = options.history || null;
this.setHistoryId(options.historyId || null);
/** @type {Boolean} does this collection contain and fetch deleted elements */
this.includeDeleted = options.includeDeleted || this.includeDeleted;
/** @type {Boolean} does this collection contain and fetch non-visible elements */
this.includeHidden = options.includeHidden || this.includeHidden;
// backbonejs uses collection.model.prototype.idAttribute to determine if a model is *already* in a collection
// and either merged or replaced. In this case, our 'model' is a function so we need to add idAttribute
// manually here - if we don't, contents will not merge but be replaced/swapped.
this.model.prototype.idAttribute = "type_id";
},
trackJobStates: function () {
this.each((historyContent) => {
if (this.jobStateSummariesCollection) {
this.jobStateSummariesCollection.trackModel(historyContent);
}
});
},
// ........................................................................ composite collection
/** since history content is a mix, override model fn into a factory, creating based on history_content_type */
model: function (attrs, options) {
if (attrs.history_content_type === "dataset") {
return new HDA_MODEL.HistoryDatasetAssociation(attrs, options);
} else if (attrs.history_content_type === "dataset_collection") {
return new HDCA_MODEL.HistoryDatasetCollection(attrs, options);
} else {
return {
validationError: `Unknown history_content_type: ${attrs.history_content_type}`,
};
}
},
stopPolling: function () {
if (this.jobStateSummariesCollection) {
this.jobStateSummariesCollection.active = false;
this.jobStateSummariesCollection.clearUpdateTimeout();
}
},
setHistoryId: function (newId) {
this.stopPolling();
this.historyId = newId;
if (newId) {
// If actually reflecting a history - setup storage and monitor jobs.
this._setUpWebStorage();
this.jobStateSummariesCollection = new JOB_STATES_MODEL.JobStatesSummaryCollection();
this.jobStateSummariesCollection.historyId = newId;
this.jobStateSummariesCollection.monitor();
}
},
/** Set up client side storage. Currently PersistanStorage keyed under 'history:<id>' */
_setUpWebStorage: function (initialSettings) {
// TODO: use initialSettings
this.storage = new HISTORY_PREFS.HistoryPrefs({
id: HISTORY_PREFS.HistoryPrefs.historyStorageKey(this.historyId),
});
this.trigger("new-storage", this.storage, this);
this.on({
"include-deleted": function (newVal) {
this.storage.includeDeleted(newVal);
},
"include-hidden": function (newVal) {
this.storage.includeHidden(newVal);
},
});
this.includeDeleted = this.storage.includeDeleted() || false;
this.includeHidden = this.storage.includeHidden() || false;
return this;
},
// ........................................................................ common queries
/** @type {Object} map of collection available sorting orders containing comparator fns */
comparators: _.extend(_.clone(_super.prototype.comparators), {
name: BASE_MVC.buildComparator("name", { ascending: true }),
"name-dsc": BASE_MVC.buildComparator("name", {
ascending: false,
}),
hid: BASE_MVC.buildComparator("hid", { ascending: false }),
"hid-asc": BASE_MVC.buildComparator("hid", { ascending: true }),
}),
/** Get every model in this collection not in a 'ready' state (running). */
running: function () {
return this.filter((c) => !c.inReadyState());
},
/** return contents that are not ready and not deleted/hidden */
runningAndActive: function () {
return this.filter(
(c) =>
!c.inReadyState() &&
c.get("visible") &&
// TODO: deletedOrPurged?
!c.get("deleted")
);
},
/** Get the model with the given hid
* @param {Int} hid the hid to search for
* @returns {HistoryDatasetAssociation} the model with the given hid or undefined if not found
*/
getByHid: function (hid) {
// note: there *can* be more than one content with a given hid, this finds the first based on order
return this.findWhere({ hid: hid });
},
/** return true if all contents have details */
haveDetails: function () {
return this.all((c) => c.hasDetails());
},
// ........................................................................ hidden / deleted
/** return a new contents collection of only hidden items */
hidden: function () {
return this.filter((c) => c.hidden());
},
/** return a new contents collection of only hidden items */
deleted: function () {
return this.filter((c) => c.get("deleted"));
},
/** return a new contents collection of only hidden items */
visibleAndUndeleted: function () {
return this.filter(
(c) =>
c.get("visible") &&
// TODO: deletedOrPurged?
!c.get("deleted")
);
},
/** create a setter in order to publish the change */
setIncludeDeleted: function (setting, options) {
if (_.isBoolean(setting) && setting !== this.includeDeleted) {
this.includeDeleted = setting;
if (_.result(options, "silent")) {
return;
}
this.trigger("include-deleted", setting, this);
}
},
/** create a setter in order to publish the change */
setIncludeHidden: function (setting, options) {
if (_.isBoolean(setting) && setting !== this.includeHidden) {
this.includeHidden = setting;
options = options || {};
if (_.result(options, "silent")) {
return;
}
this.trigger("include-hidden", setting, this);
}
},
// ........................................................................ ajax
// ............ controlled fetch collection
/** override to get expanded ids from sessionStorage and pass to API as details */
fetch: function (options) {
options = options || {};
if (this.historyId && !options.details) {
var prefs = HISTORY_PREFS.HistoryPrefs.get(this.historyId).toJSON();
if (!_.isEmpty(prefs.expandedIds)) {
options.details = _.values(prefs.expandedIds).join(",");
}
}
return _super.prototype.fetch.call(this, options);
},
// ............. ControlledFetch stuff
/** override to include the API versioning flag */
_buildFetchData: function (options) {
return _.extend(_super.prototype._buildFetchData.call(this, options), {
v: "dev",
});
},
/** Extend to include details and version */
_fetchParams: _super.prototype._fetchParams.concat([
// TODO: remove (the need for) both
/** version */
"v",
/** dataset ids to get full details of */
"details",
]),
/** override to add deleted/hidden filters */
_buildFetchFilters: function (options) {
var superFilters = _super.prototype._buildFetchFilters.call(this, options) || {};
var filters = {};
if (!this.includeDeleted) {
filters.deleted = false;
filters.purged = false;
}
if (!this.includeHidden) {
filters.visible = true;
}
return _.defaults(superFilters, filters);
},
// ............ paginated collection
getTotalItemCount: function () {
return this.history.contentsShown();
},
// ............ history contents specific ajax
/** override to filter requested contents to those updated after the Date 'since' */
fetchUpdated: function (since, options) {
if (since) {
options = options || { filters: {} };
options.remove = false;
options.filters = {
"update_time-ge": since.toISOString(),
// workflows will produce hidden datasets (non-output datasets) that still
// need to be updated in the collection or they'll update forever
// we can remove the default visible filter by using an 'empty' value
visible: "",
};
}
return this.fetch(options);
},
/** fetch all the deleted==true contents of this collection */
fetchDeleted: function (options) {
options = options || {};
options.filters = _.extend(options.filters, {
// all deleted, purged or not
deleted: true,
purged: undefined,
});
options.remove = false;
this.trigger("fetching-deleted", this);
return this.fetch(options).always(() => {
this.trigger("fetching-deleted-done", this);
});
},
/** fetch all the visible==false contents of this collection */
fetchHidden: function (options) {
options = options || {};
options.filters = _.extend(options.filters, {
visible: false,
});
options.remove = false;
this.trigger("fetching-hidden", this);
return this.fetch(options).always(() => {
this.trigger("fetching-hidden-done", this);
});
},
/** fetch detailed model data for all contents in this collection */
fetchAllDetails: function (options) {
options = options || {};
var detailsFlag = { details: "all" };
options.data = _.extend(options.data || {}, detailsFlag);
return this.fetch(options);
},
// ............. quasi-batch ops
// TODO: to batch
/** helper that fetches using filterParams then calls save on each fetched using updateWhat as the save params */
_filterAndUpdate: function (filterParams, updateWhat) {
var idAttribute = this.model.prototype.idAttribute;
var updateArgs = [updateWhat];
return this.fetch({ filters: filterParams, remove: false }).then((fetched) => {
// convert filtered json array to model array
fetched = fetched.reduce((modelArray, currJson, i) => {
var model = this.get(currJson[idAttribute]);
return model ? modelArray.concat(model) : modelArray;
}, []);
return this.ajaxQueue("save", updateArgs, fetched);
});
},
/** using a queue, perform ajaxFn on each of the models in this collection */
ajaxQueue: function (ajaxFn, args, collection) {
collection = collection || this.models;
return new AJAX_QUEUE.AjaxQueue(
collection
.slice()
.reverse()
.map((content, i) => {
var fn = _.isString(ajaxFn) ? content[ajaxFn] : ajaxFn;
return () => fn.apply(content, args);
})
).deferred;
},
_recursivelyFetch: function (options, detailKeys, deferred, limit, offset) {
offset = offset || 0;
var _options = _.extend(_.clone(options), {
view: "summary",
keys: detailKeys,
limit: limit,
offset: offset,
reset: offset === 0,
remove: false,
});
_.defer(() => {
this.fetch
.call(this, _options)
.fail(deferred.reject)
.done((response) => {
deferred.notify(response, limit, offset);
if (response.length !== limit) {
this.allFetched = true;
deferred.resolve(response, limit, offset);
} else {
this._recursivelyFetch(options, detailKeys, deferred, limit, offset + limit);
}
});
});
},
/** fetch contents' details in batches of limitPerCall - note: only get searchable details here */
progressivelyFetchDetails: function (options) {
// TODO: only fetch tags and annotations if specifically requested
options = options || {};
const deferred = jQuery.Deferred();
this._recursivelyFetch(
options,
HDA_MODEL.HistoryDatasetAssociation.prototype.searchAttributes.join(","),
deferred,
options.limitPerCall || this.limitPerProgressiveFetch
);
return deferred;
},
/** does some bit of JSON represent something that can be copied into this contents collection */
isCopyable: function (contentsJSON) {
var copyableModelClasses = ["HistoryDatasetAssociation", "HistoryDatasetCollectionAssociation"];
return (
_.isObject(contentsJSON) && contentsJSON.id && _.contains(copyableModelClasses, contentsJSON.model_class)
);
},
/** copy an existing, accessible hda into this collection */
copy: function (json) {
// TODO: somehow showhorn all this into 'save'
var id;
var type;
var contentType;
if (_.isString(json)) {
id = json;
contentType = "hda";
type = "dataset";
} else {
id = json.id;
contentType =
{
HistoryDatasetAssociation: "hda",
LibraryDatasetDatasetAssociation: "ldda",
HistoryDatasetCollectionAssociation: "hdca",
}[json.model_class] || "hda";
type = contentType === "hdca" ? "dataset_collection" : "dataset";
}
var collection = this;
var xhr = jQuery
.ajax(this.url(), {
method: "POST",
contentType: "application/json",
data: JSON.stringify({
content: id,
source: contentType,
type: type,
copy_elements: true,
}),
})
.done((response) => {
collection.add([response], { parse: true });
})
.fail((error, status, message) => {
collection.trigger("error", collection, xhr, {}, "Error copying contents", {
type: type,
id: id,
source: contentType,
});
});
return xhr;
},
/** create a new HDCA in this collection */
createHDCA: function (elementIdentifiers, collectionType, name, hideSourceItems, copyElements, options) {
// normally collection.create returns the new model, but we need the promise from the ajax, so we fake create
//precondition: elementIdentifiers is an array of plain js objects
// in the proper form to create the collectionType
if (copyElements === undefined) {
copyElements = true;
}
var hdca = this.model({
history_content_type: "dataset_collection",
collection_type: collectionType,
history_id: this.historyId,
name: name,
hide_source_items: hideSourceItems || false,
copy_elements: copyElements,
// should probably be able to just send in a bunch of json here and restruct per class
// note: element_identifiers is now (incorrectly) an attribute
element_identifiers: elementIdentifiers,
// do not create the model on the client until the ajax returns
});
return hdca.save(options);
},
// ........................................................................ searching
/** return true if all contents have the searchable attributes */
haveSearchDetails: function () {
return (
this.allFetched &&
this.all(
(
content // null (which is a valid returned annotation value)
) =>
// will return false when using content.has( 'annotation' )
//TODO: a bit hacky - formalize
_.has(content.attributes, "annotation")
)
);
},
/** return a new collection of contents whose attributes contain the substring matchesWhat */
matches: function (matchesWhat) {
return this.filter((content) => content.matches(matchesWhat));
},
// ........................................................................ misc
/** In this override, copy the historyId to the clone */
clone: function () {
var clone = Backbone.Collection.prototype.clone.call(this);
clone.historyId = this.historyId;
return clone;
},
/** String representation. */
toString: function () {
return ["HistoryContents(", [this.historyId, this.length].join(), ")"].join("");
},
});
-538
View File
@@ -1,538 +0,0 @@
import _ from "underscore";
import jQuery from "jquery";
import Backbone from "backbone";
import { getGalaxyInstance } from "app";
import { getAppRoot } from "onload/loadConfig";
import { HistoryContents } from "mvc/history/history-contents";
import CONTROLLED_FETCH_COLLECTION from "mvc/base/controlled-fetch-collection";
import UTILS from "utils/utils";
import BASE_MVC from "mvc/base-mvc";
import _l from "utils/localization";
//==============================================================================
/** @class Model for a Galaxy history resource - both a record of user
* tool use and a collection of the datasets those tools produced.
* @name History
* @augments Backbone.Model
*/
export var History = Backbone.Model.extend(BASE_MVC.LoggableMixin).extend(
BASE_MVC.mixin(
BASE_MVC.SearchableModelMixin,
/** @lends History.prototype */ {
_logNamespace: "history",
/** ms between fetches when checking running jobs/datasets for updates */
UPDATE_DELAY: 4000,
// values from api (may need more)
defaults: {
model_class: "History",
id: null,
name: "Unnamed History",
state: "new",
deleted: false,
contents_active: {},
contents_states: {},
},
contentsClass: HistoryContents,
/** What model fields to search with */
searchAttributes: ["name", "annotation", "tags"],
/** Adding title and singular tag */
searchAliases: {
title: "name",
tag: "tags",
},
// ........................................................................ set up/tear down
/** Set up the model
* @param {Object} historyJSON model data for this History
* @param {Object} options any extra settings including logger
*/
initialize: function (historyJSON, options) {
options = options || {};
this.logger = options.logger || null;
this.log(`${this}.initialize:`, historyJSON, options);
this.urlRoot = `${getAppRoot()}api/histories`;
/** HistoryContents collection of the HDAs contained in this history. */
this.contents = new this.contentsClass([], {
history: this,
historyId: this.get("id"),
order: options.order,
});
this._setUpListeners();
this._setUpCollectionListeners();
/** cached timeout id for the dataset updater */
this.updateTimeoutId = null;
},
/** set up any event listeners for this history including those to the contained HDAs
* events: error:contents if an error occurred with the contents collection
*/
_setUpListeners: function () {
// if the model's id changes ('current' or null -> an actual id), update the contents history_id
return this.on({
error: function (model, xhr, options, msg, details) {
this.clearUpdateTimeout();
},
"change:id": function (model, newId) {
if (this.contents) {
this.contents.historyId = newId;
}
},
});
},
/** event handlers for the contents submodels */
_setUpCollectionListeners: function () {
if (!this.contents) {
return this;
}
// bubble up errors
return this.listenTo(this.contents, {
error: function () {
this.trigger.apply(this, jQuery.makeArray(arguments));
},
});
},
// ........................................................................ derived attributes
/** */
contentsShown: function () {
var contentsActive = this.get("contents_active");
var shown = contentsActive.active || 0;
shown += this.contents.includeDeleted ? contentsActive.deleted : 0;
shown += this.contents.includeHidden ? contentsActive.hidden : 0;
return shown;
},
/** convert size in bytes to a more human readable version */
nice_size: function () {
var size = this.get("size");
return size ? UTILS.bytesToString(size, true, 2) : _l("(empty)");
},
/** override to add nice_size */
toJSON: function () {
return _.extend(Backbone.Model.prototype.toJSON.call(this), {
nice_size: this.nice_size(),
});
},
/** override to allow getting nice_size */
get: function (key) {
if (key === "nice_size") {
return this.nice_size();
}
return Backbone.Model.prototype.get.apply(this, arguments);
},
// ........................................................................ common queries
/** T/F is this history owned by the current user (Galaxy.user)
* Note: that this will return false for an anon user even if the history is theirs.
*/
ownedByCurrUser: function () {
const Galaxy = getGalaxyInstance();
// no currUser
if (!Galaxy || !Galaxy.user) {
return false;
}
// user is anon or history isn't owned
if (Galaxy.user.isAnonymous() || Galaxy.user.id !== this.get("user_id")) {
return false;
}
return true;
},
/** Return the number of running jobs assoc with this history (note: unknown === 0) */
numOfUnfinishedJobs: function () {
var unfinishedJobIds = this.get("non_ready_jobs");
return unfinishedJobIds ? unfinishedJobIds.length : 0;
},
/** Return the number of running hda/hdcas in this history (note: unknown === 0) */
numOfUnfinishedShownContents: function () {
return this.contents.runningAndActive().length || 0;
},
// ........................................................................ updates
_fetchContentRelatedAttributes: function () {
var contentRelatedAttrs = ["size", "non_ready_jobs", "contents_active", "hid_counter"];
return this.fetch({
data: jQuery.param({
keys: contentRelatedAttrs.join(","),
}),
});
},
/** check for any changes since the last time we updated (or fetch all if ) */
refresh: function (options) {
// console.log( this + '.refresh' );
options = options || {};
// note if there was no previous update time, all summary contents will be fetched
var lastUpdateTime = this.lastUpdateTime;
// if we don't flip this, then a fully-fetched list will not be re-checked via fetch
this.contents.allFetched = false;
var fetchFn =
this.contents.currentPage !== 0
? () => this.contents.fetchPage(this.contents.currentPage)
: () => this.contents.fetchUpdated(lastUpdateTime);
// note: if there was no previous update time, all summary contents will be fetched
return fetchFn().done((response, status, xhr) => {
var serverResponseDatetime;
try {
serverResponseDatetime = new Date(xhr.getResponseHeader("Date"));
} catch (err) {
console.error(err);
}
this.lastUpdateTime = serverResponseDatetime || new Date();
this.checkForUpdates(options);
});
},
/** continuously fetch updated contents every UPDATE_DELAY ms if this history's datasets or jobs are unfinished */
checkForUpdates: function (options) {
// console.log( this + '.checkForUpdates' );
options = options || {};
var delay = this.UPDATE_DELAY;
if (!this.id) {
return;
}
var _delayThenUpdate = () => {
// prevent buildup of updater timeouts by clearing previous if any, then set new and cache id
this.clearUpdateTimeout();
this.updateTimeoutId = setTimeout(() => {
this.refresh(options);
}, delay);
};
// if there are still datasets in the non-ready state, recurse into this function with the new time
var nonReadyContentCount = this.numOfUnfinishedShownContents();
// console.log( 'nonReadyContentCount:', nonReadyContentCount );
if (nonReadyContentCount > 0) {
_delayThenUpdate();
} else {
// no datasets are running, but currently runnning jobs may still produce new datasets
// see if the history has any running jobs and continue to update if so
// (also update the size for the user in either case)
this._fetchContentRelatedAttributes().done((historyData) => {
// console.log( 'non_ready_jobs:', historyData.non_ready_jobs );
if (this.numOfUnfinishedJobs() > 0) {
_delayThenUpdate();
} else {
// otherwise, let listeners know that all updates have stopped
this.trigger("ready");
}
});
}
},
/** clear the timeout and the cached timeout id */
clearUpdateTimeout: function () {
if (this.updateTimeoutId) {
clearTimeout(this.updateTimeoutId);
this.updateTimeoutId = null;
}
},
stopPolling: function () {
this.clearUpdateTimeout();
if (this.contents) {
this.contents.stopPolling();
}
},
// ........................................................................ ajax
/** override to use actual Dates objects for create/update times */
parse: function (response, options) {
var parsed = Backbone.Model.prototype.parse.call(this, response, options);
if (parsed.create_time) {
parsed.create_time = new Date(parsed.create_time);
}
if (parsed.update_time) {
parsed.update_time = new Date(parsed.update_time);
}
return parsed;
},
/** fetch this histories data (using options) then it's contents (using contentsOptions) */
fetchWithContents: function (options, contentsOptions) {
options = options || {};
var self = this;
// console.log( this + '.fetchWithContents' );
// TODO: push down to a base class
options.view = "dev-detailed";
// fetch history then use history data to fetch (paginated) contents
return this.fetch(options).then(function getContents(history) {
self.contents.history = self;
self.contents.setHistoryId(history.id);
return self.fetchContents(contentsOptions);
});
},
/** fetch this histories contents, adjusting options based on the stored history preferences */
fetchContents: function (options) {
options = options || {};
// we're updating, reset the update time
this.lastUpdateTime = new Date();
return this.contents.fetchCurrentPage(options);
},
/** save this history, _Mark_ing it as deleted (just a flag) */
_delete: function (options) {
if (this.get("deleted")) {
return jQuery.when();
}
return this.save({ deleted: true }, options);
},
/** purge this history, _Mark_ing it as purged and removing all dataset data from the server */
purge: function (options) {
if (this.get("purged")) {
return jQuery.when();
}
return this.save({ deleted: true, purged: true }, options);
},
/** save this history, _Mark_ing it as undeleted */
undelete: function (options) {
if (!this.get("deleted")) {
return jQuery.when();
}
return this.save({ deleted: false }, options);
},
/** Make a copy of this history on the server
* @param {Boolean} current if true, set the copy as the new current history (default: true)
* @param {String} name name of new history (default: none - server sets to: Copy of <current name>)
* @fires copied passed this history and the response JSON from the copy
* @returns {xhr}
*/
copy: function (current, name, allDatasets) {
current = current !== undefined ? current : true;
if (!this.id) {
throw new Error("You must set the history ID before copying it.");
}
var postData = { history_id: this.id };
if (current) {
postData.current = true;
}
if (name) {
postData.name = name;
}
if (!allDatasets) {
postData.all_datasets = false;
}
postData.view = "dev-detailed";
var history = this;
var copy = jQuery.post(this.urlRoot, postData);
// if current - queue to setAsCurrent before firing 'copied'
if (current) {
return copy.then((response) => {
var newHistory = new History(response);
return newHistory.setAsCurrent().done(() => {
history.trigger("copied", history, response);
});
});
}
return copy.done((response) => {
history.trigger("copied", history, response);
});
},
setAsCurrent: function () {
var history = this;
var xhr = jQuery.getJSON(`${getAppRoot()}history/set_as_current?id=${this.id}`);
xhr.done(() => {
history.trigger("set-as-current", history);
});
return xhr;
},
// ........................................................................ misc
toString: function () {
return `History(${this.get("id")},${this.get("name")})`;
},
}
)
);
//==============================================================================
var _collectionSuper = CONTROLLED_FETCH_COLLECTION.InfinitelyScrollingCollection;
/** @class A collection of histories (per user)
* that maintains the current history as the first in the collection.
* New or copied histories become the current history.
*/
export var HistoryCollection = _collectionSuper.extend(BASE_MVC.LoggableMixin).extend({
_logNamespace: "history",
model: History,
/** @type {String} initial order used by collection */
order: "update_time",
/** @type {Number} limit used for the first fetch (or a reset) */
limitOnFirstFetch: 10,
/** @type {Number} limit used for each subsequent fetch */
limitPerFetch: 10,
initialize: function (models, options) {
options = options || {};
this.log("HistoryCollection.initialize", models, options);
this.urlRoot = `${getAppRoot()}api/histories`;
_collectionSuper.prototype.initialize.call(this, models, options);
/** @type {boolean} should deleted histories be included */
this.includeDeleted = options.includeDeleted || false;
/** @type {String} encoded id of the history that's current */
this.currentHistoryId = options.currentHistoryId;
this.setUpListeners();
// note: models are sent to reset *after* this fn ends; up to this point
// the collection *is empty*
},
url: function () {
return this.urlRoot;
},
/** set up reflexive event handlers */
setUpListeners: function setUpListeners() {
return this.on({
// when a history is deleted, remove it from the collection (if optionally set to do so)
"change:deleted": function (history) {
// TODO: this becomes complicated when more filters are used
this.debug("change:deleted", this.includeDeleted, history.get("deleted"));
if (!this.includeDeleted && history.get("deleted")) {
this.remove(history);
}
},
// listen for a history copy, setting it to current
copied: function (original, newData) {
this.setCurrent(new History(newData, []));
},
// when a history is made current, track the id in the collection
"set-as-current": function (history) {
var oldCurrentId = this.currentHistoryId;
this.trigger("no-longer-current", oldCurrentId);
this.currentHistoryId = history.id;
},
});
},
/** override to change view */
_buildFetchData: function (options) {
return _.extend(_collectionSuper.prototype._buildFetchData.call(this, options), {
view: "dev-detailed",
});
},
/** override to filter out deleted and purged */
_buildFetchFilters: function (options) {
var superFilters = _collectionSuper.prototype._buildFetchFilters.call(this, options) || {};
var filters = {};
if (this.includeDeleted !== true) {
filters.deleted = false;
filters.purged = false;
} else {
// force API to return both deleted and non
//TODO: when the API is updated, remove this
filters.deleted = null;
}
return _.defaults(superFilters, filters);
},
/** override to fetch current as well (as it may be outside the first 10, etc.) */
fetchFirst: function (options) {
// TODO: batch?
var xhr = jQuery.when();
if (this.currentHistoryId) {
xhr = _collectionSuper.prototype.fetchFirst.call(this, {
silent: true,
limit: 1,
filters: {
"encoded_id-in": this.currentHistoryId,
// without these a deleted current history will return [] here and block the other xhr
deleted: null,
purged: "",
},
});
}
return xhr.then(() => {
options = options || {};
options.offset = 0;
return this.fetchMore(options);
});
},
/** @type {Object} map of collection available sorting orders containing comparator fns */
comparators: _.extend(_.clone(_collectionSuper.prototype.comparators), {
name: BASE_MVC.buildComparator("name", {
ascending: true,
}),
"name-dsc": BASE_MVC.buildComparator("name", {
ascending: false,
}),
size: BASE_MVC.buildComparator("size", {
ascending: false,
}),
"size-asc": BASE_MVC.buildComparator("size", {
ascending: true,
}),
}),
/** override to always have the current history first */
sort: function (options) {
options = options || {};
var silent = options.silent;
var currentHistory = this.remove(this.get(this.currentHistoryId));
_collectionSuper.prototype.sort.call(this, _.defaults({ silent: true }, options));
this.unshift(currentHistory, { silent: true });
if (!silent) {
this.trigger("sort", this, options);
}
return this;
},
/** create a new history and by default set it to be the current history */
create: function create(data, hdas, historyOptions, xhrOptions) {
//TODO: .create is actually a collection function that's overridden here
var collection = this;
var xhr = jQuery.getJSON(`${getAppRoot()}history/create_new_current`);
return xhr.done((newData) => {
collection.setCurrent(new History(newData, [], historyOptions || {}));
});
},
/** set the current history to the given history, placing it first in the collection.
* Pass standard bbone options for use in unshift.
* @triggers new-current passed history and this collection
*/
setCurrent: function (history, options) {
options = options || {};
// new histories go in the front
this.unshift(history, options);
this.currentHistoryId = history.get("id");
if (!options.silent) {
this.trigger("new-current", history, this);
}
return this;
},
toString: function toString() {
return `HistoryCollection(${this.length},current:${this.currentHistoryId})`;
},
});
@@ -1,99 +0,0 @@
import _ from "underscore";
import BASE_MVC from "mvc/base-mvc";
// ============================================================================
/** session storage for individual history preferences */
var HistoryPrefs = BASE_MVC.SessionStorageModel.extend(
/** @lends HistoryPrefs.prototype */ {
//TODO:?? move to user prefs?
defaults: {
//TODO:?? expandedIds to array?
expandedIds: {},
show_deleted: false,
show_hidden: false,
},
/** add an hda id to the hash of expanded hdas */
addExpanded: function (model) {
//TODO: use type_id and not model
var current = this.get("expandedIds");
current[model.id] = model.get("id");
this.save("expandedIds", current);
},
/** remove an hda id from the hash of expanded hdas */
removeExpanded: function (model) {
var current = this.get("expandedIds");
delete current[model.id];
this.save("expandedIds", current);
},
isExpanded: function (contentId) {
return _.result(this.get("expandedIds"), contentId, false);
},
allExpanded: function () {
return _.values(this.get("expandedIds"));
},
clearExpanded: function () {
this.set("expandedIds", {});
},
includeDeleted: function (val) {
// moving the invocation here so other components don't need to know the key
// TODO: change this key later
if (!_.isUndefined(val)) {
this.set("show_deleted", val);
}
return this.get("show_deleted");
},
includeHidden: function (val) {
// TODO: change this key later
if (!_.isUndefined(val)) {
this.set("show_hidden", val);
}
return this.get("show_hidden");
},
toString: function () {
return `HistoryPrefs(${this.id})`;
},
},
{
// ........................................................................ class vars
// class lvl for access w/o instantiation
storageKeyPrefix: "history:",
/** key string to store each histories settings under */
historyStorageKey: function historyStorageKey(historyId) {
if (!historyId) {
throw new Error(`HistoryPrefs.historyStorageKey needs valid id: ${historyId}`);
}
// single point of change
return HistoryPrefs.storageKeyPrefix + historyId;
},
/** return the existing storage for the history with the given id (or create one if it doesn't exist) */
get: function get(historyId) {
return new HistoryPrefs({
id: HistoryPrefs.historyStorageKey(historyId),
});
},
/** clear all history related items in sessionStorage */
clearAll: function clearAll(historyId) {
for (var key in sessionStorage) {
if (key.indexOf(HistoryPrefs.storageKeyPrefix) === 0) {
sessionStorage.removeItem(key);
}
}
},
}
);
//==============================================================================
export default {
HistoryPrefs: HistoryPrefs,
};
@@ -1,103 +0,0 @@
import $ from "jquery";
import _ from "underscore";
import HISTORY_VIEW from "mvc/history/history-view";
import _l from "utils/localization";
/* =============================================================================
TODO:
============================================================================= */
var _super = HISTORY_VIEW.HistoryView;
// used in history/display.mako and history/embed.mako
/** @class View/Controller for a tabular view of the history model.
*
* As ReadOnlyHistoryView, but with:
* history annotation always shown
* datasets displayed in a table:
* datasets in left cells, dataset annotations in the right
*/
var AnnotatedHistoryView = _super.extend(
/** @lends AnnotatedHistoryView.prototype */ {
className: `${_super.prototype.className} annotated-history-panel`,
// ------------------------------------------------------------------------ panel rendering
/** In this override, add the history annotation */
_buildNewRender: function () {
//TODO: shouldn't this display regardless (on all non-current panels)?
var $newRender = _super.prototype._buildNewRender.call(this);
this.renderHistoryAnnotation($newRender);
return $newRender;
},
/** render the history's annotation as its own field */
renderHistoryAnnotation: function ($newRender) {
var annotation = this.model.get("annotation");
if (!annotation) {
return;
}
$newRender.find("> .controls .subtitle").text(annotation);
},
/** override to add headers to indicate the dataset, annotation columns */
renderItems: function ($whereTo) {
$whereTo = $whereTo || this.$el;
_super.prototype.renderItems.call(this, $whereTo);
var $controls = $whereTo.find("> .controls");
$controls.find(".contents-container.headers").remove();
$('<div class="contents-container headers"/>')
.append([
$('<div class="history-content header"/>').text(_l("Dataset")),
$('<div class="additional-info header"/>').text(_l("Annotation")),
])
.appendTo($controls);
return self.views;
},
// ------------------------------------------------------------------------ sub-views
/** override to wrap each subview */
_renderItemView$el: function (view) {
return $('<div class="contents-container"/>').append([
view.render(0).$el,
$('<div class="additional-info"/>').text(view.model.get("annotation") || ""),
]);
},
// ------------------------------------------------------------------------ panel events
events: _.extend(_.clone(_super.prototype.events), {
// clicking on any part of the row will expand the items
"click .contents-container": function (ev) {
ev.stopPropagation();
$(ev.currentTarget).find(".list-item .title-bar").click();
},
// prevent propagation on icon btns so they won't bubble up to tr and toggleBodyVisibility
"click .icon-btn": function (ev) {
ev.stopPropagation();
// stopProp will prevent bootstrap from getting the click needed to open a dropdown
// in the case of metafile download buttons - workaround here
var $currTarget = $(ev.currentTarget);
if ($currTarget.length && $currTarget.attr("data-toggle") === "dropdown") {
$currTarget.dropdown("toggle");
}
},
}),
_clickSectionLink: function (ev) {
var sectionNumber = $(ev.currentTarget).parent().parent().data("section");
this.openSection(sectionNumber);
},
// ........................................................................ misc
/** Return a string rep of the history */
toString: function () {
return `AnnotatedHistoryView(${this.model ? this.model.get("name") : ""})`;
},
}
);
//==============================================================================
export default {
AnnotatedHistoryView: AnnotatedHistoryView,
};
@@ -1,503 +0,0 @@
import _ from "underscore";
import $ from "jquery";
import { getAppRoot } from "onload/loadConfig";
import { getGalaxyInstance } from "app";
import HISTORY_VIEW_EDIT from "mvc/history/history-view-edit";
import BASE_MVC from "mvc/base-mvc";
import _l from "utils/localization";
import { openGlobalUploadModal } from "components/Upload";
// ============================================================================
/** session storage for history panel preferences (and to maintain state)
*/
var HistoryViewPrefs = BASE_MVC.SessionStorageModel.extend(
/** @lends HistoryViewPrefs.prototype */ {
defaults: {
/** should the tags editor be shown or hidden initially? */
tagsEditorShown: false,
/** should the annotation editor be shown or hidden initially? */
annotationEditorShown: false,
///** what is the currently focused content (dataset or collection) in the current history?
// * (the history panel will highlight and scroll to the focused content view)
// */
//focusedContentId : null
/** Current scroll position */
scrollPosition: 0,
},
toString: function () {
return `HistoryViewPrefs(${JSON.stringify(this.toJSON())})`;
},
}
);
/** key string to store panel prefs (made accessible on class so you can access sessionStorage directly) */
HistoryViewPrefs.storageKey = function storageKey() {
return "history-panel";
};
/* =============================================================================
TODO:
============================================================================= */
var _super = HISTORY_VIEW_EDIT.HistoryViewEdit;
// used in root/index.mako
/** @class View/Controller for the user's current history model as used in the history
* panel (current right hand panel) of the analysis page.
*
* The only history panel that:
* will poll for updates.
* displays datasets in reverse hid order.
*/
var CurrentHistoryView = _super.extend(
/** @lends CurrentHistoryView.prototype */ {
className: `${_super.prototype.className} current-history-panel`,
/** override to use drilldown (and not foldout) for how collections are displayed */
HDCAViewClass: _super.prototype.HDCAViewClass.extend({
foldoutStyle: "drilldown",
}),
emptyMsg: [
_l("This history is empty"),
". ",
_l("You can "),
'<a class="uploader-link" href="javascript:void(0)">',
_l("load your own data"),
"</a>",
_l(" or "),
'<a class="get-data-link" href="javascript:void(0)">',
_l("get data from an external source"),
"</a>",
].join(""),
// ......................................................................... SET UP
/** Set up the view, set up storage, bind listeners to HistoryContents events */
initialize: function (attributes) {
attributes = attributes || {};
// ---- persistent preferences
/** maintain state / preferences over page loads */
this.preferences = new HistoryViewPrefs(
_.extend(
{
id: HistoryViewPrefs.storageKey(),
},
_.pick(attributes, _.keys(HistoryViewPrefs.prototype.defaults))
)
);
_super.prototype.initialize.call(this, attributes);
/** sub-views that will overlay this panel (collections) */
this.panelStack = [];
/** id of currently focused content */
this.currentContentId = attributes.currentContentId || null;
//NOTE: purposely not sent to localstorage since panel recreation roughly lines up with a reset of this value
},
/** Override to cache the current scroll position with a listener */
_setUpListeners: function () {
_super.prototype._setUpListeners.call(this);
var panel = this;
// reset scroll position when there's a new history
this.on("new-model", () => {
panel.preferences.set("scrollPosition", 0);
});
},
// ------------------------------------------------------------------------ loading history/item models
// TODO: next three more appropriate moved to the app level
/** (re-)loads the user's current history & contents w/ details */
loadCurrentHistory: function () {
return this.loadHistory(null, {
url: `${getAppRoot()}history/current_history_json`,
});
},
/** loads a history & contents w/ details and makes them the current history */
switchToHistory: function (historyId, attributes) {
const Galaxy = getGalaxyInstance();
if (Galaxy.user.isAnonymous()) {
this.trigger("error", _l("You must be logged in to switch histories"), _l("Anonymous user"));
return $.when();
}
return this.loadHistory(historyId, {
url: `${getAppRoot()}history/set_as_current?id=${historyId}`,
});
},
/** creates a new history on the server and sets it as the user's current history */
createNewHistory: function (attributes) {
const Galaxy = getGalaxyInstance();
if (Galaxy.user.isAnonymous()) {
this.trigger("error", _l("You must be logged in to create histories"), _l("Anonymous user"));
return $.when();
}
return this.loadHistory(null, {
url: `${getAppRoot()}history/create_new_current`,
});
},
/** release/free/shutdown old models and set up panel for new models */
setModel: function (model, attributes, render) {
_super.prototype.setModel.call(this, model, attributes, render);
if (this.model && this.model.id) {
this.log("checking for updates");
this.model.checkForUpdates();
}
return this;
},
// ------------------------------------------------------------------------ history/content event listening
/** listening for history events */
_setUpModelListeners: function () {
_super.prototype._setUpModelListeners.call(this);
// re-broadcast any model change events so that listeners don't have to re-bind to each history
return this.listenTo(this.model, {
"change:nice_size change:size": function () {
this.trigger("history-size-change", this, this.model, arguments);
},
"change:id": function () {
this.once("loading-done", function () {
this.model.checkForUpdates();
});
},
});
},
/** listening for collection events */
_setUpCollectionListeners: function () {
_super.prototype._setUpCollectionListeners.call(this);
// if a hidden item is created (gen. by a workflow), moves thru the updater to the ready state,
// then: remove it from the collection if the panel is set to NOT show hidden datasets
this.listenTo(this.collection, "state:ready", function (model, newState, oldState) {
if (!model.get("visible") && !this.collection.storage.includeHidden()) {
this.removeItemView(model);
}
});
},
// ------------------------------------------------------------------------ panel rendering
/** override to add a handler to capture the scroll position when the parent scrolls */
_setUpBehaviors: function ($where) {
$where = $where || this.$el;
// console.log( '_setUpBehaviors', this.$scrollContainer( $where ).get(0), this.$list( $where ) );
// we need to call this in _setUpBehaviors which is called after render since the $el
// may not be attached to $el.parent and $scrollContainer() may not work
var panel = this;
_super.prototype._setUpBehaviors.call(panel, $where);
// cache the handler to remove and re-add so we don't pile up the handlers
if (!this._debouncedScrollCaptureHandler) {
this._debouncedScrollCaptureHandler = _.debounce(function scrollCapture() {
// cache the scroll position (only if visible)
if (panel.$el.is(":visible")) {
panel.preferences.set("scrollPosition", $(this).scrollTop());
}
}, 40);
}
panel
.$scrollContainer($where)
.off("scroll", this._debouncedScrollCaptureHandler)
.on("scroll", this._debouncedScrollCaptureHandler);
return panel;
},
/** In this override, handle null models and move the search input to the top */
_buildNewRender: function () {
if (!this.model) {
return $();
}
var $newRender = _super.prototype._buildNewRender.call(this);
$newRender.find(".search").prependTo($newRender.find("> .controls"));
this._renderQuotaMessage($newRender);
return $newRender;
},
/** render the message displayed when a user is over quota and can't run jobs */
_renderQuotaMessage: function ($whereTo) {
$whereTo = $whereTo || this.$el;
return $(this.templates.quotaMsg({}, this)).prependTo($whereTo.find(".messages"));
},
/** In this override, get and set current panel preferences when editor is used */
_renderTags: function ($where) {
return _super.prototype._renderTags.call(this, $where);
},
/** In this override, get and set current panel preferences when editor is used */
_renderAnnotation: function ($where) {
var panel = this;
// render annotation and show/hide based on preferences
_super.prototype._renderAnnotation.call(panel, $where);
if (panel.preferences.get("annotationEditorShown")) {
panel.annotationEditor.toggle(true);
}
// store preference when shown or hidden
panel.listenTo(
panel.annotationEditor,
"hiddenUntilActivated:shown hiddenUntilActivated:hidden",
(annotationEditor) => {
panel.preferences.set("annotationEditorShown", annotationEditor.hidden);
}
);
},
/** Override to scroll to cached position (in prefs) after swapping */
_swapNewRender: function ($newRender) {
_super.prototype._swapNewRender.call(this, $newRender);
var panel = this;
_.delay(() => {
var pos = panel.preferences.get("scrollPosition");
if (pos) {
panel.scrollTo(pos, 0);
}
}, 10);
//TODO: is this enough of a delay on larger histories?
return this;
},
// ------------------------------------------------------------------------ sub-views
/** Override to add the current-content highlight class to currentContentId's view */
_attachItems: function ($whereTo) {
_super.prototype._attachItems.call(this, $whereTo);
var panel = this;
if (panel.currentContentId) {
panel._setCurrentContentById(panel.currentContentId);
}
return this;
},
/** Override to remove any drill down panels */
addItemView: function (model, collection, options) {
var view = _super.prototype.addItemView.call(this, model, collection, options);
if (!view) {
return view;
}
if (this.panelStack.length) {
return this._collapseDrilldownPanel();
}
return view;
},
// ------------------------------------------------------------------------ collection sub-views
/** In this override, add/remove expanded/collapsed model ids to/from web storage */
_setUpItemViewListeners: function (view) {
var panel = this;
_super.prototype._setUpItemViewListeners.call(panel, view);
// use pub-sub to: handle drilldown expansion and collapse
return panel.listenTo(view, {
"expanded:drilldown": function (v, drilldown) {
this._expandDrilldownPanel(drilldown);
},
"collapsed:drilldown": function (v, drilldown) {
this._collapseDrilldownPanel(drilldown);
},
});
},
/** display 'current content': add a visible highlight and store the id of a content item */
setCurrentContent: function (view) {
this.$(".history-content.current-content").removeClass("current-content");
if (view) {
view.$el.addClass("current-content");
this.currentContentId = view.model.id;
} else {
this.currentContentId = null;
}
},
/** find the view with the id and then call setCurrentContent on it */
_setCurrentContentById: function (id) {
var view = this.viewFromModelId(id) || null;
this.setCurrentContent(view);
},
/** Handle drill down by hiding this panels list and controls and showing the sub-panel */
_expandDrilldownPanel: function (drilldown) {
this.panelStack.push(drilldown);
// hide this panel's controls and list, set the name for back navigation, and attach to the $el
this.$controls().add(this.$list()).hide();
drilldown.parentName = this.model.get("name");
drilldown.delegateEvents().render().$el.appendTo(this.$el);
},
/** Handle drilldown close by freeing the panel and re-rendering this panel */
_collapseDrilldownPanel: function (drilldown) {
this.panelStack.pop();
//TODO: MEM: free the panel
this.$controls().add(this.$list()).show();
},
// ........................................................................ panel events
/** event map */
events: _.extend(_.clone(_super.prototype.events), {
// the two links in the empty message
"click .uploader-link": function (ev) {
openGlobalUploadModal();
},
"click .get-data-link": function (ev) {
var $toolMenu = $(".toolMenuContainer");
$toolMenu.parent().scrollTop(0);
$toolMenu.find('span:contains("Get Data")').click();
},
}),
// ........................................................................ external objects/MVC
listenToGalaxy: function (galaxy) {
this.listenTo(galaxy, {
// when the galaxy_main iframe is loaded with a new page,
// compare the url to the following list and if there's a match
// pull the id from url and indicate in the history view that
// the dataset with that id is the 'current'ly active dataset
"center-frame:load": function (data) {
var pathToMatch = data.fullpath;
var hdaId = null;
var useToURLRegexMap = {
display: /datasets\/([a-f0-9]+)\/display/,
edit: /datasets\/([a-f0-9]+)\/edit/,
report_error: /dataset\/errors\?id=([a-f0-9]+)/,
rerun: /tool_runner\/rerun\?id=([a-f0-9]+)/,
show_params: /datasets\/([a-f0-9]+)\/show_params/,
// no great way to do this here? (leave it in the dataset event handlers above?)
// 'visualization' : 'visualization',
};
_.find(useToURLRegexMap, (regex, use) => {
// grab the more specific match result (1), save, and use it as the find flag
hdaId = _.result(pathToMatch.match(regex), 1);
return hdaId;
});
// need to type mangle to go from web route to history contents
this._setCurrentContentById(hdaId ? `dataset-${hdaId}` : null);
},
// when the center panel is given a new view, clear the current indicator
"center-panel:load": function (view) {
try {
const hdaId = view.model.attributes.dataset_id || null;
if (hdaId === null) {
throw "Invalid id";
}
this._setCurrentContentById(`dataset-${hdaId}`);
} catch (e) {
this._setCurrentContentById();
}
},
"activate-hda": function (hdaId) {
this._setCurrentContentById(`dataset-${hdaId}`);
},
});
},
//TODO: remove quota meter from panel and remove this
/** add listeners to an external quota meter (mvc/user/user-quotameter.js) */
connectToQuotaMeter: function (quotaMeter) {
if (!quotaMeter) {
return this;
}
// show/hide the 'over quota message' in the history when the meter tells it to
this.listenTo(quotaMeter, "quota:over", this.showQuotaMessage);
this.listenTo(quotaMeter, "quota:under", this.hideQuotaMessage);
// having to add this to handle re-render of hview while overquota (the above do not fire)
this.on("rendered rendered:initial", function () {
if (quotaMeter && quotaMeter.isOverQuota()) {
this.showQuotaMessage();
}
});
return this;
},
/** Override to preserve the quota message */
clearMessages: function (ev) {
var $target = !_.isUndefined(ev) ? $(ev.currentTarget) : this.$messages().children('[class$="message"]');
$target = $target.not(".quota-message");
$target.fadeOut(this.fxSpeed, function () {
$(this).remove();
});
return this;
},
/** Show the over quota message (which happens to be in the history panel).
*/
showQuotaMessage: function () {
var $msg = this.$(".quota-message");
if ($msg.is(":hidden")) {
$msg.slideDown(this.fxSpeed);
}
},
/** Hide the over quota message (which happens to be in the history panel).
*/
hideQuotaMessage: function () {
var $msg = this.$(".quota-message");
if (!$msg.is(":hidden")) {
$msg.slideUp(this.fxSpeed);
}
},
// ........................................................................ options menu
//TODO: remove to batch
/** unhide any hidden datasets */
unhideHidden: function () {
var self = this;
if (window.confirm(_l("Really unhide all hidden datasets?"))) {
// get all hidden, regardless of deleted/purged
return self.model.contents
._filterAndUpdate({ visible: false, deleted: "", purged: "" }, { visible: true })
.done(() => {
// TODO: would be better to render these as they're unhidden instead of all at once
if (!self.model.contents.includeHidden) {
self.renderItems();
}
});
}
return $.when();
},
/** delete any hidden datasets */
deleteHidden: function () {
var self = this;
if (window.confirm(_l("Really delete all hidden datasets?"))) {
return self.model.contents._filterAndUpdate(
// get all hidden, regardless of deleted/purged
{ visible: false, deleted: "", purged: "" },
// both delete *and* unhide them
{ deleted: true, visible: true }
);
}
return $.when();
},
/** Return a string rep of the history */
toString: function () {
return `CurrentHistoryView(${this.model ? this.model.get("name") : ""})`;
},
}
);
//------------------------------------------------------------------------------ TEMPLATES
CurrentHistoryView.prototype.templates = (() => {
var quotaMsgTemplate = BASE_MVC.wrapTemplate(
[
'<div class="quota-message errormessage">',
_l("You are over your disk quota"),
". ",
_l("Tool execution is on hold until your disk usage drops below your allocated quota"),
".",
"</div>",
],
"history"
);
return _.extend(_.clone(_super.prototype.templates), {
quotaMsg: quotaMsgTemplate,
});
})();
//==============================================================================
export default {
CurrentHistoryView: CurrentHistoryView,
};
-723
View File
@@ -1,723 +0,0 @@
import _ from "underscore";
import jQuery from "jquery";
import { getGalaxyInstance } from "app";
import HISTORY_VIEW from "mvc/history/history-view";
import HDA_MODEL from "mvc/history/hda-model";
import HDA_LI_EDIT from "mvc/history/hda-li-edit";
import HDCA_LI_EDIT from "mvc/history/hdca-li-edit";
import { mountModelTags } from "components/Tags";
import ANNOTATIONS from "mvc/annotation";
import ListCollectionCreatorModal from "components/Collections/ListCollectionCreatorModal";
import RuleBasedCollectionCreatorModal from "components/Collections/RuleBasedCollectionCreatorModal";
import PairCollectionCreatorModal from "components/Collections/PairCollectionCreatorModal";
import PairedListCollectionCreatorModal from "components/Collections/PairedListCollectionCreatorModal";
import faIconButton from "ui/fa-icon-button";
import BASE_MVC from "mvc/base-mvc";
import _l from "utils/localization";
import "ui/editable-text";
var $ = jQuery;
/* =============================================================================
TODO:
============================================================================= */
var _super = HISTORY_VIEW.HistoryView;
// base class for history-view-edit-current and used as-is in history/view.mako
/** @class Editable View/Controller for the history model.
*
* Allows:
* (everything HistoryView allows)
* changing the name
* displaying and editing tags and annotations
* multi-selection and operations on mulitple content items
*/
var HistoryViewEdit = _super.extend(
/** @lends HistoryViewEdit.prototype */ {
/** class to use for constructing the HistoryDatasetAssociation views */
HDAViewClass: HDA_LI_EDIT.HDAListItemEdit,
/** class to use for constructing the HistoryDatasetCollectionAssociation views */
HDCAViewClass: HDCA_LI_EDIT.HDCAListItemEdit,
// ......................................................................... SET UP
/** Set up the view, set up storage, bind listeners to HistoryContents events
* @param {Object} attributes
*/
initialize: function (attributes) {
attributes = attributes || {};
_super.prototype.initialize.call(this, attributes);
// ---- set up instance vars
/** editor for tags - sub-view */
this.tagsEditor = null;
/** enable drag and drop - sub-view */
this.dragItems = true;
/** editor for annotations - sub-view */
this.annotationEditor = null;
/** allow user purge of dataset files? */
this.purgeAllowed = attributes.purgeAllowed || false;
// states/modes the panel can be in
/** is the panel currently showing the dataset selection controls? */
this.annotationEditorShown = attributes.annotationEditorShown || false;
this.tagsEditorShown = attributes.tagsEditorShown || false;
},
/** Override to handle history as drag-drop target */
_setUpListeners: function () {
_super.prototype._setUpListeners.call(this);
return this.on({
"droptarget:drop": function (ev, data) {
// process whatever was dropped and re-hide the drop target
this.dataDropped(data);
this.dropTargetOff();
},
"view:attached view:removed": function () {
this._renderCounts();
},
"search:loading-progress": this._renderSearchProgress,
"search:searching": this._renderSearchFindings,
});
},
// ------------------------------------------------------------------------ listeners
/** listening for history and HDA events */
_setUpModelListeners: function () {
_super.prototype._setUpModelListeners.call(this);
this.listenTo(this.model, "change:size", this.updateHistoryDiskSize);
return this;
},
/** listening for collection events */
_setUpCollectionListeners: function () {
_super.prototype._setUpCollectionListeners.call(this);
this.listenTo(this.collection, {
"change:deleted": this._handleItemDeletedChange,
"change:visible": this._handleItemVisibleChange,
"change:purged": function (model) {
// hafta get the new nice-size w/o the purged model
this.model.fetch();
},
// loading indicators for deleted/hidden
"fetching-deleted": function (collection) {
this.$("> .controls .deleted-count").html(`<i>${_l("loading...")}</i>`);
},
"fetching-hidden": function (collection) {
this.$("> .controls .hidden-count").html(`<i>${_l("loading...")}</i>`);
},
"fetching-deleted-done fetching-hidden-done": this._renderCounts,
});
return this;
},
// ------------------------------------------------------------------------ panel rendering
/** In this override, add tag and annotation editors and a btn to toggle the selectors */
_buildNewRender: function () {
var Galaxy = getGalaxyInstance();
// create a new render using a skeleton template, render title buttons, render body, and set up events, etc.
var $newRender = _super.prototype._buildNewRender.call(this);
if (!this.model) {
return $newRender;
}
if (Galaxy && Galaxy.user && Galaxy.user.id && Galaxy.user.id === this.model.get("user_id")) {
this._renderTags($newRender);
this._renderAnnotation($newRender);
}
return $newRender;
},
/** Update the history size display (curr. upper right of panel). */
updateHistoryDiskSize: function () {
this.$(".history-size").text(this.model.get("nice_size"));
},
/** override to render counts when the items are rendered */
renderItems: function ($whereTo) {
var views = _super.prototype.renderItems.call(this, $whereTo);
if (!this.searchFor) {
this._renderCounts($whereTo);
} else {
this._renderSearchFindings($whereTo);
}
return views;
},
/** override to show counts, what's deleted/hidden, and links to toggle those */
_renderCounts: function ($whereTo) {
$whereTo = $whereTo instanceof jQuery ? $whereTo : this.$el;
var html = this.templates.counts(this.model.toJSON(), this);
return $whereTo.find("> .controls .subtitle").html(html);
},
/** render the tags sub-view controller */
_renderTags: function ($where) {
const el = $where.find(".controls .tags-display")[0];
const propsData = {
model: this.model,
disabled: false,
context: "history-view-edit",
};
const vm = mountModelTags(propsData, el);
// tag icon button open/closes
const activator = faIconButton({
title: _l("Edit history tags"),
classes: "history-tag-btn",
faIcon: "fa-tags",
tooltipConfig: { placement: "top" },
}).appendTo($where.find(".controls .actions"));
activator.on("click", () => {
$(vm.$el).toggleClass("active");
});
return vm;
},
/** render the annotation sub-view controller */
_renderAnnotation: function ($where) {
var panel = this;
this.annotationEditor = new ANNOTATIONS.AnnotationEditor({
model: this.model,
el: $where.find(".controls .annotation-display"),
onshowFirstTime: function () {
this.render();
},
// show hide sub-view view annotation editors when this is shown/hidden
onshow: function () {
panel.toggleHDAAnnotationEditors(true, panel.fxSpeed);
},
onhide: function () {
panel.toggleHDAAnnotationEditors(false, panel.fxSpeed);
},
$activator: faIconButton({
title: _l("Edit history annotation"),
classes: "history-annotate-btn",
faIcon: "fa-comment",
tooltipConfig: { placement: "top" },
}).appendTo($where.find(".controls .actions")),
});
},
/** Set up HistoryViewEdit js/widget behaviours
* In this override, make the name editable
*/
_setUpBehaviors: function ($where) {
var Galaxy = getGalaxyInstance();
$where = $where || this.$el;
_super.prototype._setUpBehaviors.call(this, $where);
if (!this.model) {
return;
}
// anon users shouldn't have access to any of the following
if (!Galaxy.user || Galaxy.user.isAnonymous() || Galaxy.user.id !== this.model.get("user_id")) {
return;
}
var panel = this;
var nameSelector = "> .controls .name";
$where
.find(nameSelector)
.attr("title", _l("Rename history..."))
.tooltip({ placement: "bottom" })
.make_text_editable({
data_description: "name input",
on_finish: function (newName) {
var previousName = panel.model.get("name");
if (newName && newName !== previousName) {
panel.$el.find(nameSelector).text(newName);
panel.model.save({ name: newName }).fail(() => {
panel.$el.find(nameSelector).text(panel.model.previous("name"));
});
} else {
panel.$el.find(nameSelector).text(previousName);
}
},
});
},
/** return a new popup menu for choosing a multi selection action
* ajax calls made for multiple datasets are queued
*/
multiselectActions: function () {
var Galaxy = getGalaxyInstance();
var panel = this;
const batchUpdate = (key, value) => {
const actionModels = {};
const items = panel
.getSelectedModels()
.filter((model) => {
console.log(model.attributes[key]);
return model.attributes[key] != value;
})
.map((model) => {
const res = {
history_content_type: model.attributes.history_content_type,
id: model.attributes.id,
};
actionModels[model.id] = model;
// Tried "optimistically" deleting the dataset - but it didn't quite work -
// maybe history polling restored the previous state?
return res;
});
if (items.length == 0) {
return;
}
const options = {};
options.url = `${Galaxy.root}api/histories/${this.model.id}/contents`;
options.type = "PUT";
const requestData = { items: items };
requestData[key] = value;
options.data = JSON.stringify(requestData);
options.contentType = "application/json";
var xhr = jQuery.ajax(options);
xhr.done((message, status, responseObj) => {
for (const updated of message) {
const typeId = updated.history_content_type + "-" + updated.id;
actionModels[typeId].set(updated);
}
});
xhr.fail((xhr, status, message) => {
console.error(message);
});
return xhr;
};
var actions = [
{
html: _l("Hide datasets"),
func: function () {
batchUpdate("visible", false);
},
},
{
html: _l("Unhide datasets"),
func: function () {
batchUpdate("visible", true);
},
},
{
html: _l("Delete datasets"),
func: () => {
batchUpdate("deleted", true);
},
},
{
html: _l("Undelete datasets"),
func: function () {
batchUpdate("deleted", false);
},
},
];
if (panel.purgeAllowed) {
actions.push({
html: _l("Permanently delete datasets"),
func: function () {
if (
window.confirm(_l("This will permanently remove the data in your datasets. Are you sure?"))
) {
var action = HDA_MODEL.HistoryDatasetAssociation.prototype.purge;
const historyContents = panel.getSelectedModels();
const selectedDatasets = historyContents.filter(
(c) => c.get("history_content_type") == "dataset"
);
historyContents.ajaxQueue(action, {}, selectedDatasets);
}
},
});
}
actions = actions.concat(panel._collectionActions());
return actions;
},
/** */
_collectionActions: function () {
var panel = this;
return [
{
html: _l("Build Dataset List"),
func: () => panel.buildCollection("list"),
},
// TODO: Only show quick pair if two things selected.
{
html: _l("Build Dataset Pair"),
func: () => panel.buildCollection("paired"),
},
{
html: _l("Build List of Dataset Pairs"),
func: () => panel.buildCollection("list:paired"),
},
{
html: _l("Build Collection from Rules"),
func: () => panel.buildCollection("rules"),
},
];
},
buildCollection: function (collectionType, selection, hideSourceItems) {
var panel = this;
selection = selection || panel.getSelectedModels();
const defaultHideSourceItems = hideSourceItems || true;
var createModalFunc;
if (collectionType == "list") {
createModalFunc = ListCollectionCreatorModal.createListCollection;
} else if (collectionType == "paired") {
createModalFunc = PairCollectionCreatorModal.createPairCollection;
} else if (collectionType == "list:paired") {
createModalFunc = PairedListCollectionCreatorModal.createPairedListCollection;
} else if (collectionType.startsWith("rules")) {
createModalFunc = RuleBasedCollectionCreatorModal.createCollectionViaRules;
} else {
console.warn(`Unknown collectionType encountered ${collectionType}`);
}
createModalFunc(selection, defaultHideSourceItems)
.then(() => {
panel.model.refresh();
})
.catch(() => {});
},
// ------------------------------------------------------------------------ sub-views
/** In this override, add purgeAllowed and whether tags/annotation editors should be shown */
_getItemViewOptions: function (model) {
var options = _super.prototype._getItemViewOptions.call(this, model);
_.extend(options, {
purgeAllowed: this.purgeAllowed,
tagsEditorShown: this.tagsEditor && !this.tagsEditor.hidden,
annotationEditorShown: this.annotationEditor && !this.annotationEditor.hidden,
});
return options;
},
/** If this item is deleted and we're not showing deleted items, remove the view
* @param {Model} the item model to check
*/
_handleItemDeletedChange: function (itemModel) {
if (itemModel.get("deleted")) {
this._handleItemDeletion(itemModel);
} else {
this._handleItemUndeletion(itemModel);
}
this._renderCounts();
},
_handleItemDeletion: function (itemModel) {
var contentsShown = this.model.get("contents_active");
contentsShown.deleted += 1;
contentsShown.active -= 1;
if (!this.model.contents.includeDeleted) {
this.removeItemView(itemModel);
}
this.model.set("contents_active", contentsShown);
},
_handleItemUndeletion: function (itemModel) {
var contentsShown = this.model.get("contents_active");
contentsShown.deleted -= 1;
if (!this.model.contents.includeDeleted) {
contentsShown.active -= 1;
}
this.model.set("contents_active", contentsShown);
},
/** If this item is hidden and we're not showing hidden items, remove the view
* @param {Model} the item model to check
*/
_handleItemVisibleChange: function (itemModel) {
if (itemModel.hidden()) {
this._handleItemHidden(itemModel);
} else {
this._handleItemUnhidden(itemModel);
}
this._renderCounts();
},
_handleItemHidden: function (itemModel) {
var contentsShown = this.model.get("contents_active");
contentsShown.hidden += 1;
contentsShown.active -= 1;
if (!this.model.contents.includeHidden) {
this.removeItemView(itemModel);
}
this.model.set("contents_active", contentsShown);
},
_handleItemUnhidden: function (itemModel) {
var contentsShown = this.model.get("contents_active");
contentsShown.hidden -= 1;
if (!this.model.contents.includeHidden) {
contentsShown.active -= 1;
}
this.model.set("contents_active", contentsShown);
},
/** toggle the visibility of each content's tagsEditor applying all the args sent to this function */
// toggleHDATagEditors: function(showOrHide, speed) {
// _.each(this.views, view => {
// if (view.tagsEditor) {
// view.tagsEditor.toggle(showOrHide, speed);
// }
// });
// },
/** toggle the visibility of each content's annotationEditor applying all the args sent to this function */
toggleHDAAnnotationEditors: function (showOrHide, speed) {
_.each(this.views, (view) => {
if (view.annotationEditor) {
view.annotationEditor.toggle(showOrHide, speed);
}
});
},
// ------------------------------------------------------------------------ panel events
/** event map */
events: _.extend(_.clone(_super.prototype.events), {
"click .show-history-content-selectors-btn": "toggleSelectors",
"click .toggle-deleted-link": function (ev) {
this.toggleShowDeleted();
},
"click .toggle-hidden-link": function (ev) {
this.toggleShowHidden();
},
}),
// ------------------------------------------------------------------------ search
_renderSearchProgress: function (limit, offset) {
var stop = limit + offset;
return this.$("> .controls .subtitle").html(
["<i>", _l("Searching "), stop, "/", this.model.contentsShown(), "</i>"].join("")
);
},
/** override to display number found in subtitle */
_renderSearchFindings: function ($whereTo) {
$whereTo = $whereTo instanceof jQuery ? $whereTo : this.$el;
var html = this.templates.found(this.model.toJSON(), this);
$whereTo.find("> .controls .subtitle").html(html);
return this;
},
// ------------------------------------------------------------------------ as drop target
/** turn all the drag and drop handlers on and add some help text above the drop area */
dropTargetOn: function () {
if (this.dropTarget) {
return this;
}
this.dropTarget = true;
//TODO: to init
var dropHandlers = {
dragenter: _.bind(this.dragenter, this),
dragover: _.bind(this.dragover, this),
dragleave: _.bind(this.dragleave, this),
drop: _.bind(this.drop, this),
};
var $dropTarget = this._renderDropTarget();
this.$list().before([this._renderDropTargetHelp(), $dropTarget]);
for (var evName in dropHandlers) {
if (Object.prototype.hasOwnProperty.call(dropHandlers, evName)) {
$dropTarget.get(0).addEventListener(evName, dropHandlers[evName]);
}
}
return this;
},
/** render a box to serve as a 'drop here' area on the history */
_renderDropTarget: function () {
this.$(".history-drop-target").remove();
return $("<div/>").addClass("history-drop-target");
},
/** tell the user how it works */
_renderDropTargetHelp: function () {
this.$(".history-drop-target-help").remove();
return $("<div/>")
.addClass("history-drop-target-help")
.text(_l("Drag datasets here to copy them to the current history"));
},
/** shut down drag and drop event handlers and remove drop target */
dropTargetOff: function () {
if (!this.dropTarget) {
return this;
}
//this.log( 'dropTargetOff' );
this.dropTarget = false;
var dropTarget = this.$(".history-drop-target").get(0);
for (var evName in this._dropHandlers) {
if (Object.prototype.hasOwnProperty.call(this._dropHandlers, evName)) {
dropTarget.off(evName, this._dropHandlers[evName]);
}
}
this.$(".history-drop-target").remove();
this.$(".history-drop-target-help").remove();
return this;
},
/** toggle the target on/off */
dropTargetToggle: function () {
if (this.dropTarget) {
this.dropTargetOff();
} else {
this.dropTargetOn();
}
return this;
},
dragenter: function (ev) {
//console.debug( 'dragenter:', this, ev );
ev.preventDefault();
ev.stopPropagation();
this.$(".history-drop-target").css("border", "2px solid black");
},
dragover: function (ev) {
ev.preventDefault();
ev.stopPropagation();
},
dragleave: function (ev) {
//console.debug( 'dragleave:', this, ev );
ev.preventDefault();
ev.stopPropagation();
this.$(".history-drop-target").css("border", "1px dashed black");
},
/** when (text) is dropped try to parse as json and trigger an event */
drop: function (ev) {
ev.preventDefault();
//ev.stopPropagation();
var dataTransfer = ev.dataTransfer;
var data = dataTransfer.getData("text");
dataTransfer.dropEffect = "move";
try {
data = JSON.parse(data);
} catch (err) {
this.warn("error parsing JSON from drop:", data);
}
this.trigger("droptarget:drop", ev, data, this);
return false;
},
/** handler that copies data into the contents */
dataDropped: function (data) {
// HDA: dropping will copy it to the history
if (_.isObject(data) && data.model_class === "HistoryDatasetAssociation" && data.id) {
if (this.contents.currentPage !== 0) {
return this.contents.fetchPage(0).then(() => this.model.contents.copy(data.id));
}
return this.model.contents.copy(data.id);
}
return jQuery.when();
},
// ........................................................................ misc
/** Return a string rep of the history */
toString: function () {
return `HistoryViewEdit(${this.model ? this.model.get("name") : ""})`;
},
}
);
//------------------------------------------------------------------------------ TEMPLATES
HistoryViewEdit.prototype.templates = (() => {
var countsTemplate = BASE_MVC.wrapTemplate(
[
"<% var shown = Math.max( view.views.length, history.contents_active.active ) %>",
"<% if( shown ){ %>",
'<span class="shown-count">',
"<%- shown %> ",
_l("shown"),
"</span>",
"<% } %>",
"<% if( history.contents_active.deleted ){ %>",
'<span class="deleted-count">',
"<% if( view.model.contents.includeDeleted ){ %>",
'<a class="toggle-deleted-link" href="javascript:void(0);">',
_l("hide deleted"),
"</a>",
"<% } else { %>",
"<%- history.contents_active.deleted %> ",
'<a class="toggle-deleted-link" href="javascript:void(0);">',
_l("deleted"),
"</a>",
"<% } %>",
"</span>",
"<% } %>",
"<% if( history.contents_active.hidden ){ %>",
'<span class="hidden-count">',
"<% if( view.model.contents.includeHidden ){ %>",
'<a class="toggle-hidden-link" href="javascript:void(0);">',
_l("hide hidden"),
"</a>",
"<% } else { %>",
"<%- history.contents_active.hidden %> ",
'<a class="toggle-hidden-link" href="javascript:void(0);">',
_l("hidden"),
"</a>",
"<% } %>",
"</span>",
"<% } %>",
],
"history"
);
var foundTemplate = BASE_MVC.wrapTemplate(
[
_l("Found"),
" <%- view.views.length %>",
"<% if( history.contents_active.deleted ){ %>",
", ",
"<% if( view.model.contents.includeDeleted ){ %>",
'<a class="toggle-deleted-link" href="javascript:void(0);">',
_l("hide deleted"),
"</a>",
"<% } else { %>",
'<a class="toggle-deleted-link" href="javascript:void(0);">',
_l("show deleted"),
"</a>",
"<% } %>",
"<% } %>",
"<% if( history.contents_active.hidden ){ %>",
", ",
"<% if( view.model.contents.includeHidden ){ %>",
'<a class="toggle-hidden-link" href="javascript:void(0);">',
_l("hide hidden"),
"</a>",
"<% } else { %>",
'<a class="toggle-hidden-link" href="javascript:void(0);">',
_l("show hidden"),
"</a>",
"<% } %>",
"<% } %>",
],
"history"
);
return _.extend(_.clone(_super.prototype.templates), {
counts: countsTemplate,
found: foundTemplate,
});
})();
//==============================================================================
export default {
HistoryViewEdit: HistoryViewEdit,
};
-702
View File
@@ -1,702 +0,0 @@
import _ from "underscore";
import $ from "jquery";
import { getAppRoot } from "onload/loadConfig";
import { getGalaxyInstance } from "app";
import _l from "utils/localization";
import LIST_VIEW from "mvc/list/list-view";
import { History } from "mvc/history/history-model";
import { HistoryContents } from "mvc/history/history-contents";
import HDA_LI from "mvc/history/hda-li";
import HDCA_LI from "mvc/history/hdca-li";
import ERROR_MODAL from "mvc/ui/error-modal";
import faIconButton from "ui/fa-icon-button";
import BASE_MVC from "mvc/base-mvc";
import HistoryViewEdit from "mvc/history/history-view-edit";
import HistoryCopyDialog from "mvc/history/copy-dialog";
import "ui/search-input";
import "ui/mode-button";
/* =============================================================================
TODO:
============================================================================= */
/** @class non-editable, read-only View/Controller for a history model.
* Allows:
* changing the loaded history
* displaying data, info, and download
* tracking history attrs: size, tags, annotations, name, etc.
* Does not allow:
* changing the name
*/
var _super = LIST_VIEW.ModelListPanel;
var HistoryView = _super.extend(
/** @lends HistoryView.prototype */ {
_logNamespace: "history",
actionButtonClass: "history-contents-list-action-menu-btn",
/** class to use for constructing the HDA views */
HDAViewClass: HDA_LI.HDAListItemView,
/** class to use for constructing the HDCA views */
HDCAViewClass: HDCA_LI.HDCAListItemView,
/** class to used for constructing collection of sub-view models */
collectionClass: HistoryContents,
/** key of attribute in model to assign to this.collection */
modelCollectionKey: "contents",
tagName: "div",
className: `${_super.prototype.className} history-panel`,
/** string to display when the collection is empty */
emptyMsg: _l("This history is empty"),
/** displayed when no items match the search terms */
noneFoundMsg: _l("No matching datasets found"),
/** string used for search placeholder */
searchPlaceholder: _l("search datasets"),
// ......................................................................... SET UP
/** Set up the view, bind listeners.
* @param {Object} attributes optional settings for the panel
*/
initialize: function (attributes) {
_super.prototype.initialize.call(this, attributes);
// ---- instance vars
// control contents/behavior based on where (and in what context) the panel is being used
/** where should pages from links be displayed? (default to new tab/window) */
this.linkTarget = attributes.linkTarget || "_blank";
},
/** create and return a collection for when none is initially passed */
_createDefaultCollection: function () {
// override
return new this.collectionClass([], {
history: this.model,
});
},
/** In this override, clear the update timer on the model */
freeModel: function () {
_super.prototype.freeModel.call(this);
if (this.model) {
this.model.stopPolling();
}
return this;
},
/** create any event listeners for the panel
* @fires: rendered:initial on the first render
* @fires: empty-history when switching to a history with no contents or creating a new history
*/
_setUpListeners: function () {
_super.prototype._setUpListeners.call(this);
this.on({
error: function (model, xhr, options, msg, details) {
this.errorHandler(model, xhr, options, msg, details);
},
"views:ready view:attached view:removed": function (view) {
this._renderSelectButton();
},
"view:attached": function (view) {
this.scrollTo(0);
},
});
// this.on( 'all', function(){ console.debug( arguments ); });
},
// ------------------------------------------------------------------------ loading history/hda models
/** load the history with the given id then it's contents, sending ajax options to both */
loadHistory: function (historyId, options, contentsOptions) {
contentsOptions = _.extend(contentsOptions || { silent: true });
this.info("loadHistory:", historyId, options, contentsOptions);
this.setModel(new History({ id: historyId }));
contentsOptions.silent = true;
this.trigger("loading");
return this.model.fetchWithContents(options, contentsOptions).always(() => {
this.render();
this.trigger("loading-done");
});
},
/** convenience alias to the model. Updates the item list only (not the history) */
refreshContents: function (options) {
if (this.model) {
return this.model.refresh(options);
}
// may have callbacks - so return an empty promise
return $.when();
},
/** Override to reset web storage when the id changes (since it needs the id) */
_setUpCollectionListeners: function () {
_super.prototype._setUpCollectionListeners.call(this);
return this.listenTo(this.collection, {
// 'all' : function(){ console.log( this.collection + ':', arguments ); },
"fetching-more": function () {
this._toggleContentsLoadingIndicator(true);
this.$emptyMessage().hide();
},
"fetching-more-done": function () {
this._toggleContentsLoadingIndicator(false);
},
});
},
// ------------------------------------------------------------------------ panel rendering
/** hide the $el and display a loading indicator (in the $el's parent) when loading new data */
_showLoadingIndicator: function (msg, speed, callback) {
var $indicator = $('<div class="loading-indicator"/>');
this.$el.html($indicator.text(msg).slideDown(!_.isUndefined(speed) ? speed : this.fxSpeed));
},
/** hide the loading indicator */
_hideLoadingIndicator: function (speed) {
// make speed a bit slower to compensate for slow rendering of up to 500 contents
this.$(".loading-indicator").slideUp(!_.isUndefined(speed) ? speed : this.fxSpeed + 200, function () {
$(this).remove();
});
},
/** In this override, add a btn to toggle the selectors */
_buildNewRender: function () {
var $newRender = _super.prototype._buildNewRender.call(this);
this._renderSelectButton($newRender);
return $newRender;
},
/** button for starting select mode */
_renderSelectButton: function ($where) {
$where = $where || this.$el;
// do not render selector option if no actions
if (!this.multiselectActions().length) {
return null;
}
// do not render (and remove even) if nothing to select
if (!this.views.length) {
this.hideSelectors();
$where.find(".controls .actions .show-history-content-selectors-btn").remove();
return null;
}
// don't bother rendering if there's one already
var $existing = $where.find(".controls .actions .show-history-content-selectors-btn");
if ($existing.length) {
return $existing;
}
return faIconButton({
title: _l("Operations on multiple datasets"),
classes: "show-history-content-selectors-btn",
faIcon: "fa-check-square-o",
tooltipConfig: { placement: "top" },
}).prependTo($where.find(".controls .actions"));
},
/** override to avoid showing intial empty message using contents_active */
_renderEmptyMessage: function ($whereTo) {
var $emptyMsg = this.$emptyMessage($whereTo);
var empty = this.model.get("contents_active").active <= 0;
if (empty) {
return $emptyMsg.empty().append(this.emptyMsg).show();
} else if (this.searchFor && this.model.contents.haveSearchDetails() && !this.views.length) {
return $emptyMsg.empty().append(this.noneFoundMsg).show();
}
$emptyMsg.hide();
return $();
},
/** the scroll container for this panel - can be $el, $el.parent(), or grandparent depending on context */
$scrollContainer: function ($where) {
// override or set via attributes.$scrollContainer
return this.$list($where);
},
// ------------------------------------------------------------------------ subviews
_toggleContentsLoadingIndicator: function (show) {
if (!show) {
this.$list().find(".contents-loading-indicator").remove();
} else {
this.$list().html(
'<div class="contents-loading-indicator">' + '<span class="fa fa-2x fa-spinner fa-spin"/></div>'
);
}
},
/** override to render pagination also */
renderItems: function ($whereTo) {
// console.log( this + '.renderItems-----------------', new Date() );
$whereTo = $whereTo || this.$el;
var $list = this.$list($whereTo);
// TODO: bootstrap hack to remove orphaned tooltips
$(".tooltip").remove();
$list.empty();
this.views = [];
var models = this._filterCollection();
if (models.length) {
this._renderPagination($whereTo);
this.views = this._renderSomeItems(models, $list);
} else {
// TODO: consolidate with _renderPagination above by (???) passing in models/length?
$whereTo.find("> .controls .list-pagination").empty();
}
this._renderEmptyMessage($whereTo).toggle(!models.length);
this.trigger("views:ready", this.views);
return this.views;
},
/** render pagination controls if not searching and contents says we're paginating */
_renderPagination: function ($whereTo) {
var $paginationControls = $whereTo.find("> .controls .list-pagination");
if (this.searchFor || !this.model.contents.shouldPaginate()) {
return $paginationControls.empty();
}
$paginationControls.html(
this.templates.pagination(
{
// pagination is 1-based for the user
current: this.model.contents.currentPage + 1,
last: this.model.contents.getLastPage() + 1,
},
this
)
);
$paginationControls.find("select.pages").tooltip();
return $paginationControls;
},
/** render a subset of the entire collection (client-side pagination) */
_renderSomeItems: function (models, $list) {
var views = [];
$list.append(
models.map((m) => {
var view = this._createItemView(m);
views.push(view);
return this._renderItemView$el(view);
})
);
return views;
},
// ------------------------------------------------------------------------ sub-views
/** in this override, check if the contents would also display based on includeDeleted/hidden */
_filterItem: function (model) {
var contents = this.model.contents;
return (
(contents.includeHidden || !model.hidden()) &&
(contents.includeDeleted || !model.isDeletedOrPurged()) &&
_super.prototype._filterItem.call(this, model)
);
},
/** In this override, since history contents are mixed,
* get the appropo view class based on history_content_type
*/
_getItemViewClass: function (model) {
var contentType = model.get("history_content_type");
switch (contentType) {
case "dataset":
return this.HDAViewClass;
case "dataset_collection":
return this.HDCAViewClass;
}
throw new TypeError(`Unknown history_content_type: ${contentType}`);
},
/** in this override, add a linktarget, and expand if id is in web storage */
_getItemViewOptions: function (model) {
var options = _super.prototype._getItemViewOptions.call(this, model);
return _.extend(options, {
linkTarget: this.linkTarget,
expanded: this.model.contents.storage.isExpanded(model.id),
hasUser: this.model.ownedByCurrUser(),
});
},
/** In this override, add/remove expanded/collapsed model ids to/from web storage */
_setUpItemViewListeners: function (view) {
var panel = this;
_super.prototype._setUpItemViewListeners.call(panel, view);
//TODO: send from content view: this.model.collection.storage.addExpanded
// maintain a list of items whose bodies are expanded
return panel.listenTo(view, {
expanded: function (v) {
panel.model.contents.storage.addExpanded(v.model);
},
collapsed: function (v) {
panel.model.contents.storage.removeExpanded(v.model);
},
});
},
/** override to remove expandedIds from webstorage */
collapseAll: function () {
this.model.contents.storage.clearExpanded();
_super.prototype.collapseAll.call(this);
},
// ------------------------------------------------------------------------ selection
/** Override to correctly set the historyId of the new collection */
getSelectedModels: function () {
var collection = _super.prototype.getSelectedModels.call(this);
collection.historyId = this.collection.historyId;
return collection;
},
// ------------------------------------------------------------------------ panel events
/** event map */
events: _.extend(_.clone(_super.prototype.events), {
"click .show-history-content-selectors-btn": "toggleSelectors",
"click > .controls .prev": "_clickPrevPage",
"click > .controls .next": "_clickNextPage",
"change > .controls .pages": "_changePageSelect",
// allow (error) messages to be clicked away
"click .messages [class$=message]": "clearMessages",
}),
_clickPrevPage: function (ev) {
this.model.stopPolling();
this.model.contents.fetchPrevPage();
},
_clickNextPage: function (ev) {
this.model.stopPolling();
this.model.contents.fetchNextPage();
},
_changePageSelect: function (ev) {
this.model.stopPolling();
var page = $(ev.currentTarget).val();
this.model.contents.fetchPage(page);
},
/** Toggle and store the deleted visibility and re-render items
* @returns {Boolean} new setting
*/
toggleShowDeleted: function (show, options) {
show = show !== undefined ? show : !this.model.contents.includeDeleted;
var contents = this.model.contents;
contents.setIncludeDeleted(show, options);
this.trigger("show-deleted", show);
contents.fetchCurrentPage({ renderAll: true });
return show;
},
/** Toggle and store whether to render explicity hidden contents
* @returns {Boolean} new setting
*/
toggleShowHidden: function (show, store, options) {
// console.log( 'toggleShowHidden', show, store );
show = show !== undefined ? show : !this.model.contents.includeHidden;
var contents = this.model.contents;
contents.setIncludeHidden(show, options);
this.trigger("show-hidden", show);
contents.fetchCurrentPage({ renderAll: true });
return show;
},
/** On the first search, if there are no details - load them, then search */
_firstSearch: function (searchFor) {
var inputSelector = "> .controls .search-input";
this.log("onFirstSearch", searchFor);
// if the contents already have enough details to search, search and return now
if (this.model.contents.haveSearchDetails()) {
this.searchItems(searchFor);
return;
}
// otherwise, load the details progressively here
this.$(inputSelector).searchInput("toggle-loading");
// set this now so that only results will show during progress
this.searchFor = searchFor;
this.model.contents
.progressivelyFetchDetails({ silent: true })
.progress((response, limit, offset) => {
this.renderItems();
this.trigger("search:loading-progress", limit, offset);
})
.always(() => {
this.$el.find(inputSelector).searchInput("toggle-loading");
})
.done(() => {
this.searchItems(searchFor, "force");
});
},
/** clear the search filters and show all views that are normally shown */
clearSearch: function (searchFor) {
if (!this.searchFor) {
return this;
}
//this.log( 'onSearchClear', this );
this.searchFor = "";
this.trigger("search:clear", this);
this.$("> .controls .search-query").val("");
// NOTE: silent + render prevents collection update event with merge only
// - which causes an empty page due to event handler above
this.model.contents.fetchCurrentPage({ silent: true }).done(() => {
this.renderItems();
});
return this;
},
// ........................................................................ error handling
/** Event handler for errors (from the panel, the history, or the history's contents)
* Alternately use two strings for model and xhr to use custom message and title (respectively)
* @param {Model or View} model the (Backbone) source of the error
* @param {XMLHTTPRequest} xhr any ajax obj. assoc. with the error
* @param {Object} options the options map commonly used with bbone ajax
*/
errorHandler: function (model, xhr, options) {
//TODO: to mixin or base model
// interrupted ajax or no connection
if (xhr && xhr.status === 0 && xhr.readyState === 0) {
// return ERROR_MODAL.offlineErrorModal();
// fail silently
return;
}
// otherwise, leave something to report in the console
this.error(model, xhr, options);
// and feedback to a modal
// if sent two strings (and possibly details as 'options'), use those as message and title
if (_.isString(model) && _.isString(xhr)) {
var message = model;
return ERROR_MODAL.errorModal(message, xhr, options);
}
// bad gateway
// TODO: possibly to global handler
if (xhr && xhr.status === 502) {
return ERROR_MODAL.badGatewayErrorModal();
}
return ERROR_MODAL.ajaxErrorModal(model, xhr, options);
},
/** Remove all messages from the panel. */
clearMessages: function (ev) {
var $target = !_.isUndefined(ev) ? $(ev.currentTarget) : this.$messages().children('[class$="message"]');
$target.fadeOut(this.fxSpeed, function () {
$(this).remove();
});
return this;
},
// ........................................................................ scrolling
/** Scrolls the panel to show the content sub-view with the given hid.
* @param {Integer} hid the hid of item to scroll into view
* @returns {HistoryView} the panel
*/
scrollToHid: function (hid) {
return this.scrollToItem(_.first(this.viewsWhereModel({ hid: hid })));
},
// ........................................................................ misc
/** utility for adding -st, -nd, -rd, -th to numbers */
ordinalIndicator: function (number) {
var numStr = `${number}`;
switch (numStr.charAt(numStr.length - 1)) {
case "1":
return `${numStr}st`;
case "2":
return `${numStr}nd`;
case "3":
return `${numStr}rd`;
default:
return `${numStr}th`;
}
},
/** Return a string rep of the history */
toString: function () {
return `HistoryView(${this.model ? this.model.get("name") : ""})`;
},
}
);
//------------------------------------------------------------------------------ TEMPLATES
HistoryView.prototype.templates = (() => {
var mainTemplate = () =>
`<div>
<div class="controls details"></div>
<ul class="list-items"></ul>
<div class="empty-message alert alert-info"></div>',
</div>`;
var controlsTemplate = BASE_MVC.wrapTemplate(
[
'<div class="controls details">',
'<div class="title">',
'<div class="name history-title"><%- history.name %></div>',
"</div>",
'<div class="subtitle"></div>',
'<div class="history-size"><%- history.nice_size %></div>',
'<div class="actions"></div>',
'<div class="messages">',
"<% if( history.deleted && history.purged ){ %>",
'<div class="deleted-msg warningmessagesmall">',
_l("This history has been purged and deleted"),
"</div>",
"<% } else if( history.deleted ){ %>",
'<div class="deleted-msg warningmessagesmall">',
_l("This history has been deleted"),
"</div>",
"<% } else if( history.purged ){ %>",
'<div class="deleted-msg warningmessagesmall">',
_l("This history has been purged"),
"</div>",
"<% } %>",
"<% if( history.message ){ %>",
// should already be localized
'<div class="<%= history.message.level || "info" %>messagesmall">',
"<%= history.message.text %>",
"</div>",
"<% } %>",
"</div>",
// add tags and annotations
'<div class="tags-display"></div>',
'<div class="annotation-display history-annotation"></div>',
'<div class="search">',
'<div class="search-input"></div>',
"</div>",
'<div class="list-actions">',
'<div class="btn-group">',
'<button class="select-all btn btn-secondary"',
'data-mode="select">',
_l("All"),
"</button>",
'<button class="deselect-all btn btn-secondary"',
'data-mode="select">',
_l("None"),
"</button>",
"</div>",
'<div class="list-action-menu btn-group">',
"</div>",
"</div>",
'<div class="list-pagination form-inline"></div>',
"</div>",
],
"history"
);
var paginationTemplate = BASE_MVC.wrapTemplate(
[
'<button class="prev" <%- pages.current === 1 ? "disabled" : "" %>>previous</button>',
'<select class="pages form-control" ',
'title="',
_l("Click to open and select a page. Begin typing a page number to select it"),
'">',
"<% _.range( 1, pages.last + 1 ).forEach( function( i ){ %>",
'<option value="<%- i - 1 %>" <%- i === pages.current ? "selected" : "" %>>',
"<%- view.ordinalIndicator( i ) %> of <%- pages.last %> pages",
"</option>",
"<% }); %>",
"</select>",
'<button class="next" <%- pages.current === pages.last ? "disabled" : "" %>>next</button>',
],
"pages"
);
return _.extend(_.clone(_super.prototype.templates), {
el: mainTemplate,
controls: controlsTemplate,
pagination: paginationTemplate,
});
})();
export function historyEntry(options) {
$("#toggle-deleted").modeButton({
initialMode: options.initialModeDeleted,
modes: [
{ mode: "showing_deleted", html: _l("Exclude deleted") },
{ mode: "not_showing_deleted", html: _l("Include deleted") },
],
});
$("#toggle-hidden").modeButton({
initialMode: options.initialModeHidden,
modes: [
{ mode: "showing_hidden", html: _l("Exclude hidden") },
{ mode: "not_showing_hidden", html: _l("Include hidden") },
],
});
$("#switch").click(function () {
//##HACK:ity hack hack
//##TODO: remove when out of iframe
const Galaxy = getGalaxyInstance();
var hview = Galaxy.currHistoryPanel ? Galaxy.currHistoryPanel : null;
if (hview) {
hview.switchToHistory("${ history[ 'id' ] }");
} else {
window.location = "${ switch_to_url }";
}
});
// use_panels affects where the the center_panel() is rendered:
// w/o it renders to the body, w/ it renders to #center - we need to adjust a few things for scrolling to work
if (options.hasMasthead) {
$("#center").addClass("flex-vertical-container");
}
const viewClass = options.userIsOwner ? HistoryViewEdit.HistoryViewEdit : HistoryView.HistoryView;
const history = new History(options.historyJSON);
// attach the copy dialog to the import button now that we have a history
$("#import").click(function () {
const Galaxy = getGalaxyInstance();
HistoryCopyDialog(history, {
useImport: true,
// use default datasets option to match the toggle-deleted button
allDatasets: $("#toggle-deleted").modeButton("getMode").mode === "showing_deleted",
}).done(function () {
if (window === window.parent) {
window.location = getAppRoot();
} else if (Galaxy.currHistoryPanel) {
Galaxy.currHistoryPanel.loadCurrentHistory();
}
});
});
const historyView = new viewClass({
el: $("#history-" + options.historyJSON.id),
className: viewClass.prototype.className + " wide",
$scrollContainer: options.hasMasthead
? function () {
return this.$el.parent();
}
: undefined,
model: history,
show_deleted: options.showDeletedJson,
show_hidden: options.showHiddenJson,
purgeAllowed: options.allow_user_dataset_purge,
});
historyView.trigger("loading");
history
.fetchContents({ silent: true })
.fail(function () {
alert("Galaxy history failed to load");
})
.done(function () {
historyView.trigger("loading-done");
historyView.render();
});
$("#toggle-deleted").on("click", function () {
historyView.toggleShowDeleted();
});
$("#toggle-hidden").on("click", function () {
historyView.toggleShowHidden();
});
}
//==============================================================================
export default {
HistoryView: HistoryView,
historyEntry: historyEntry,
};
-331
View File
@@ -1,331 +0,0 @@
import _ from "underscore";
import GRAPH from "utils/graph";
import addLogging from "utils/add-logging";
// ============================================================================
var _super = GRAPH.Graph;
/** A Directed acyclic Graph built from a history's job data.
* Reads in job json, filters and process that json, and builds a graph
* using the connections between job inputs and outputs.
*/
var JobDAG = function (options) {
options = options || {};
var self = this;
//this.logger = console;
self.filters = [];
// instance vars
//TODO: needed?
self._jobsData = [];
self._historyContentsMap = {};
self._toolMap = {};
self._outputIdToJobMap = {};
self.noInputJobs = [];
self.noOutputJobs = [];
//TODO: save these?
self.filteredSetMetadata = [];
self.filteredErroredJobs = [];
self.dataKeys = ["jobs", "historyContents", "tools"];
_super.call(self, true, _.pick(options, self.dataKeys), _.omit(options, self.dataKeys));
};
JobDAG.prototype = new GRAPH.Graph();
JobDAG.prototype.constructor = JobDAG;
// add logging ability - turn off/on using the this.logger statement above
addLogging(JobDAG);
// ----------------------------------------------------------------------------
/** process jobs, options, filters, and any history data, then create the graph */
JobDAG.prototype.init = function _init(options) {
options = options || {};
var self = this;
self.options = _.defaults(options, {
excludeSetMetadata: false,
});
self.filters = self._initFilters();
_super.prototype.init.call(self, options);
return self;
};
/** add job filters based on options */
JobDAG.prototype._initFilters = function __initFilters() {
var self = this;
var filters = [];
if (self.options.excludeSetMetadata) {
self.filteredSetMetadata = [];
filters.push(function filterSetMetadata(jobData) {
if (jobData.job.tool_id !== "__SET_METADATA__") {
return true;
}
self.filteredSetMetadata.push(jobData.job.id);
return false;
});
}
if (self.options.excludeErroredJobs) {
self.filteredErroredJobs = [];
filters.push(function filterErrored(jobData) {
if (jobData.job.state !== "error") {
return true;
}
self.filteredErroredJobs.push(jobData.job.id);
return false;
});
}
// all outputs deleted
// all outputs hidden
if (_.isArray(self.options.filters)) {
filters = filters.concat(self.options.filters);
}
self.debug("filters len:", filters.length);
return filters;
};
/** */
JobDAG.prototype.read = function _read(data) {
var self = this;
if (_.has(data, "historyContents") && _.has(data, "jobs") && _.has(data, "tools")) {
// a job dag is composed of these three elements:
// clone the 3 data sources into the DAG, processing the jobs finally using the history and tools
self.preprocessHistoryContents(data.historyContents || [])
.preprocessTools(data.tools || {})
.preprocessJobs(data.jobs || []);
// filter jobs and create the vertices and edges of the job DAG
self.createGraph(self._filterJobs());
return self;
}
return _super.prototype.read.call(this, data);
};
/** */
JobDAG.prototype.preprocessHistoryContents = function _preprocessHistoryContents(historyContents) {
this.info("processing history");
var self = this;
self._historyContentsMap = {};
historyContents.forEach((content, i) => {
self._historyContentsMap[content.id] = _.clone(content);
});
return self;
};
/** */
JobDAG.prototype.preprocessTools = function _preprocessTools(tools) {
this.info("processing tools");
var self = this;
self._toolMap = {};
_.each(tools, (tool, id) => {
self._toolMap[id] = _.clone(tool);
});
return self;
};
/** sort the cloned jobs, decorate with tool and history contents info, and store in prop array */
JobDAG.prototype.preprocessJobs = function _preprocessJobs(jobs) {
this.info("processing jobs");
var self = this;
self._outputIdToJobMap = {};
self._jobsData = self.sort(jobs).map((job) => self.preprocessJob(_.clone(job)));
//console.debug( JSON.stringify( self._jobsData, null, ' ' ) );
//console.debug( JSON.stringify( self._outputIdToJobMap, null, ' ' ) );
return self;
};
/** sort the jobs based on update time */
JobDAG.prototype.sort = function _sort(jobs) {
function cmpCreate(a, b) {
if (a.create_time > b.create_time) {
return 1;
}
if (a.create_time < b.create_time) {
return -1;
}
return 0;
}
return jobs.sort(cmpCreate);
};
/** decorate with input/output datasets and tool */
JobDAG.prototype.preprocessJob = function _preprocessJob(job, index) {
//this.info( 'preprocessJob', job, index );
var self = this;
var jobData = { job: job };
jobData.inputs = self._processInputs(job);
if (_.size(jobData.inputs) === 0) {
self.noInputJobs.push(job.id);
}
jobData.outputs = self._processOutputs(job);
if (_.size(jobData.outputs) === 0) {
self.noOutputJobs.push(job.id);
}
jobData.tool = self._toolMap[job.tool_id];
//self.info( '\t jobData:', jobData );
return jobData;
};
/**
*/
JobDAG.prototype._processInputs = function __processInputs(job) {
var self = this;
var inputs = job.inputs;
var inputMap = {};
_.each(inputs, (input, nameInJob) => {
input = _.clone(self._validateInputOutput(input));
input.name = nameInJob;
// since this is a DAG and we're processing in order of create time,
// the inputs for this job will already be listed in _outputIdToJobMap
// TODO: we can possibly exploit this
//console.debug( 'input in _outputIdToJobMap', self._outputIdToJobMap[ input.id ] );
input.content = self._historyContentsMap[input.id];
inputMap[input.id] = input;
});
return inputMap;
};
/**
*/
JobDAG.prototype._validateInputOutput = function __validateInputOutput(inputOutput) {
if (!inputOutput.id) {
throw new Error("No id on job input/output: ", JSON.stringify(inputOutput));
}
if (!inputOutput.src || inputOutput.src !== "hda") {
throw new Error("Bad src on job input/output: ", JSON.stringify(inputOutput));
}
return inputOutput;
};
/**
*/
JobDAG.prototype._processOutputs = function __processOutputs(job) {
var self = this;
var outputs = job.outputs;
var outputMap = {};
_.each(outputs, (output, nameInJob) => {
output = _.clone(self._validateInputOutput(output));
output.name = nameInJob;
// add dataset content to jobData
output.content = self._historyContentsMap[output.id];
outputMap[output.id] = output;
self._outputIdToJobMap[output.id] = job.id;
});
return outputMap;
};
/** */
JobDAG.prototype._filterJobs = function __filterJobs() {
var self = this;
return self._jobsData.filter((j, i) => self._filterJob(j, i));
};
/**
*/
JobDAG.prototype._filterJob = function _filterJob(jobData, index) {
// apply filters after processing job allowing access to the additional data above inside the filters
var self = this;
for (var i = 0; i < self.filters.length; i++) {
if (!self.filters[i].call(self, jobData)) {
self.debug("\t job", jobData.job.id, " has been filtered out by function:\n", self.filters[i]);
return false;
}
}
return true;
};
/** Walk all the jobs (vertices), attempting to find connections
* between datasets used as both inputs and outputs (edges)
*/
JobDAG.prototype.createGraph = function _createGraph(jobsData) {
var self = this;
self.debug("connections:");
//console.debug( jobsData );
_.each(jobsData, (jobData) => {
var id = jobData.job.id;
self.debug("\t", id, jobData);
self.createVertex(id, jobData);
});
_.each(jobsData, (jobData) => {
var targetId = jobData.job.id;
_.each(jobData.inputs, (input, inputId) => {
//console.debug( '\t\t target input:', inputId, input );
var sourceId = self._outputIdToJobMap[inputId];
//console.debug( '\t\t source job id:', sourceId );
if (!sourceId) {
var joblessVertex = self.createJobLessVertex(inputId);
sourceId = joblessVertex.name;
}
//TODO:?? no checking here whether sourceId is actually in the vertex map
//console.debug( '\t\t creating edge, source:', sourceId, self.vertices[ sourceId ] );
//console.debug( '\t\t creating edge, target:', targetId, self.vertices[ targetId ] );
self.createEdge(sourceId, targetId, self.directed, {
dataset: inputId,
});
});
});
//console.debug( self.toVerticesAndEdges().edges );
self.debug("final graph: ", JSON.stringify(self.toVerticesAndEdges(), null, " "));
return self;
};
/** Return a 'mangled' version of history contents id to prevent contents <-> job id collision */
JobDAG.prototype.createJobLessVertex = function _createJobLessVertex(contentId) {
// currently, copied contents are the only history contents without jobs (that I know of)
//note: following needed to prevent id collision btwn content and jobs in vertex map
var JOBLESS_ID_MANGLER = "copy-";
var mangledId = JOBLESS_ID_MANGLER + contentId;
return this.createVertex(mangledId, this._historyContentsMap[contentId]);
};
/** Override to re-sort (ugh) jobs in each component by update time */
JobDAG.prototype.weakComponentGraphArray = function () {
var dag = this;
return this.weakComponents().map((component) => {
//TODO: this seems to belong above (in sort) - why isn't it preserved?
// note: using create_time (as opposed to update_time)
// since update_time for jobless/copied datasets is changes more often
component.vertices.sort(function cmpCreate(a, b) {
var aCreateTime = a.data.job ? a.data.job.create_time : a.data.create_time;
var bCreateTime = b.data.job ? b.data.job.create_time : b.data.create_time;
if (aCreateTime > bCreateTime) {
return 1;
}
if (aCreateTime < bCreateTime) {
return -1;
}
return 0;
});
return new GRAPH.Graph(dag.directed, component);
});
};
JobDAG.prototype._jobsDataMap = function () {
var jobsDataMap = {};
this._jobsData.forEach((jobData) => {
jobsDataMap[jobData.job.id] = jobData;
});
return jobsDataMap;
};
// ============================================================================
export default JobDAG;
-237
View File
@@ -1,237 +0,0 @@
import _ from "underscore";
import $ from "jquery";
import { getAppRoot } from "onload/loadConfig";
import { getGalaxyInstance } from "app";
import _l from "utils/localization";
import PopupMenu from "mvc/ui/popup-menu";
import historyCopyDialog from "mvc/history/copy-dialog";
import Webhooks from "mvc/webhooks";
// ============================================================================
var menu = [
{
html: _l("History Actions"),
header: true,
anon: true,
},
{
html: _l("Copy"),
func: function () {
const Galaxy = getGalaxyInstance();
historyCopyDialog(Galaxy.currHistoryPanel.model).done(() => {
Galaxy.currHistoryPanel.loadCurrentHistory();
});
},
},
{
html: _l("Share or Publish"),
func: function () {
const Galaxy = getGalaxyInstance();
if (Galaxy && Galaxy.currHistoryPanel && Galaxy.router) {
Galaxy.router.push(`/histories/sharing?id=${Galaxy.currHistoryPanel.model.id}`);
}
},
singleUserDisable: true,
},
{
html: _l("Show Structure"),
anon: true,
func: function () {
const Galaxy = getGalaxyInstance();
if (Galaxy && Galaxy.currHistoryPanel && Galaxy.router) {
Galaxy.router.push("/histories/show_structure");
}
},
},
{
html: _l("Extract Workflow"),
href: "workflow/build_from_current_history",
},
{
html: _l("Set Permissions"),
func: function () {
const Galaxy = getGalaxyInstance();
if (Galaxy && Galaxy.currHistoryPanel && Galaxy.router) {
Galaxy.router.push(`/histories/permissions?id=${Galaxy.currHistoryPanel.model.id}`);
}
},
singleUserDisable: true,
},
{
html: _l("Make Private"),
anon: true,
func: function () {
const Galaxy = getGalaxyInstance();
if (
Galaxy &&
Galaxy.currHistoryPanel &&
confirm(
_l(
"This will make all the data in this history private (excluding library datasets), and will set permissions such that all new data is created as private. Any datasets within that are currently shared will need to be re-shared or published. Are you sure you want to do this?"
)
)
) {
$.post(`${Galaxy.root}history/make_private`, { history_id: Galaxy.currHistoryPanel.model.id }, () => {
Galaxy.currHistoryPanel.loadCurrentHistory();
});
}
},
},
{
html: _l("Resume Paused Jobs"),
href: "history/resume_paused_jobs?current=True",
anon: true,
},
{
html: _l("Dataset Actions"),
header: true,
anon: true,
},
{
html: _l("Copy Datasets"),
href: "dataset/copy_datasets",
},
{
html: _l("Collapse Expanded Datasets"),
func: function () {
const Galaxy = getGalaxyInstance();
if (Galaxy && Galaxy.currHistoryPanel) {
Galaxy.currHistoryPanel.collapseAll();
}
},
},
{
html: _l("Unhide Hidden Datasets"),
anon: true,
func: function () {
const Galaxy = getGalaxyInstance();
// TODO: Deprecate this functionality and replace with group dataset selector and action combination
if (Galaxy && Galaxy.currHistoryPanel && confirm(_l("Really unhide all hidden datasets?"))) {
$.post(`${getAppRoot()}history/adjust_hidden`, { user_action: "unhide" }, () => {
Galaxy.currHistoryPanel.loadCurrentHistory();
});
}
},
},
{
html: _l("Delete Hidden Datasets"),
anon: true,
func: function () {
const Galaxy = getGalaxyInstance();
// TODO: Deprecate this functionality and replace with group dataset selector and action combination
if (Galaxy && Galaxy.currHistoryPanel && confirm(_l("Really delete all hidden datasets?"))) {
$.post(`${getAppRoot()}history/adjust_hidden`, { user_action: "delete" }, () => {
Galaxy.currHistoryPanel.loadCurrentHistory();
});
}
},
},
{
html: _l("Purge Deleted Datasets"),
confirm: _l("Really delete all deleted datasets permanently? This cannot be undone."),
href: "history/purge_deleted_datasets",
purge: true,
anon: true,
},
{
html: _l("Downloads"),
header: true,
},
{
html: _l("Export Tool Citations"),
anon: true,
func: function () {
const Galaxy = getGalaxyInstance();
if (Galaxy && Galaxy.currHistoryPanel && Galaxy.router) {
Galaxy.router.push(`/histories/citations?id=${Galaxy.currHistoryPanel.model.id}`);
}
},
},
{
html: _l("Export History to File"),
anon: true,
func: function () {
const Galaxy = getGalaxyInstance();
if (Galaxy && Galaxy.currHistoryPanel && Galaxy.router) {
Galaxy.router.push(`/histories/${Galaxy.currHistoryPanel.model.id}/export`);
}
},
},
{
html: _l("Beta Features"),
anon: false,
header: true,
},
];
// Webhooks
Webhooks.load({
type: "history-menu",
async: false, // (hypothetically) slows down the performance
callback: function (webhooks) {
var webhooks_menu = [];
webhooks.each((model) => {
var webhook = model.toJSON();
if (webhook.activate) {
webhooks_menu.push({
html: _l(webhook.config.title),
func: webhook.config.function && new Function(webhook.config.function),
anon: true,
});
}
});
if (webhooks_menu.length > 0) {
webhooks_menu.unshift({
html: _l("Webhooks"),
header: true,
});
$.merge(menu, webhooks_menu);
}
},
});
function buildMenu(isAnon, purgeAllowed, urlRoot) {
const Galaxy = getGalaxyInstance();
return _.clone(menu).filter((menuOption) => {
if (Galaxy.config.single_user && menuOption.singleUserDisable) {
return false;
}
if (isAnon && !menuOption.anon) {
return false;
}
if (!purgeAllowed && menuOption.purge) {
return false;
}
//TODO:?? hard-coded galaxy_main
if (menuOption.href) {
menuOption.href = urlRoot + menuOption.href;
menuOption.target = menuOption.target || "galaxy_main";
}
menuOption.title = menuOption.html;
if (menuOption.confirm) {
menuOption.func = () => {
const galaxy_main = window.parent.document.getElementById("galaxy_main");
if (confirm(menuOption.confirm) && galaxy_main) {
galaxy_main.src = menuOption.href;
}
};
}
return true;
});
}
var create = ($button, options) => {
options = options || {};
var isAnon = options.anonymous === undefined ? true : options.anonymous;
var purgeAllowed = options.purgeAllowed || false;
var menu = buildMenu(isAnon, purgeAllowed, getAppRoot());
return new PopupMenu($button, menu);
};
// ============================================================================
export default create;
-502
View File
@@ -1,502 +0,0 @@
import $ from "jquery";
import Backbone from "backbone";
import _ from "underscore";
import BASE_MVC from "mvc/base-mvc";
var logNamespace = "list";
//==============================================================================
/** 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(BASE_MVC.LoggableMixin).extend({
_logNamespace: logNamespace,
//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.fxSpeed = attributes.fxSpeed !== undefined ? attributes.fxSpeed : this.fxSpeed;
},
// ........................................................................ render main
/** jq fx speed */
fxSpeed: "fast",
/** Render this content, set up ui.
* @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.el(this.model.toJSON(), this));
if (this.expanded) {
this.$details($newRender).replaceWith(this._renderDetails().show());
}
return $newRender;
},
/** 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
*/
_queueNewRender: function ($newRender, speed) {
speed = speed === undefined ? this.fxSpeed : speed;
var view = this;
if (speed === 0) {
view._swapNewRender($newRender);
view.trigger("rendered", view);
} else {
$(view).queue("fx", [
(next) => {
view.$el.fadeOut(speed, next);
},
(next) => {
view._swapNewRender($newRender);
next();
},
(next) => {
view.$el.fadeIn(speed, next);
},
(next) => {
view.trigger("rendered", view);
next();
},
]);
}
},
/** empty out the current el, move the $newRender's children in */
_swapNewRender: function ($newRender) {
return this.$el
.empty()
.attr("class", _.isFunction(this.className) ? this.className() : this.className)
.append($newRender.children());
},
/** 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 ($where) {
$where = $where || this.$el;
// set up canned behavior on children (bootstrap, popupmenus, editable_text, etc.)
//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 () {
var $newDetails = $(this.templates.details(this.model.toJSON(), this));
this._setUpBehaviors($newDetails);
return $newDetails;
},
// ......................................................................... expansion/details
/** Show or hide the details
* @param {Boolean} expand if true, expand; if false, collapse
*/
toggleExpanded: function (expand) {
expand = expand === undefined ? !this.expanded : expand;
if (expand) {
this.expand();
} else {
this.collapse();
}
return this;
},
/** Render and show the full, detailed body of this view including extra data and controls.
* note: if the model does not have detailed data, fetch that data before showing the body
* @fires expanded when a body has been expanded
*/
expand: function () {
var view = this;
return view._fetchModelDetails().always(() => {
view._expand();
});
},
/** 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();
}
return $.when();
},
/** Inner fn called when expand (public) has fetched the details */
_expand: function () {
var view = this;
var $newDetails = view._renderDetails();
view.$details().replaceWith($newDetails);
// needs to be set after the above or the slide will not show
view.expanded = true;
view.$details().slideDown(view.fxSpeed, () => {
view.trigger("expanded", view);
});
},
/** Hide the body/details of an HDA.
* @fires collapsed when a body has been collapsed
*/
collapse: function () {
this.debug(`${this}(ExpandableView).collapse`);
var view = this;
view.expanded = false;
this.$details().slideUp(view.fxSpeed, () => {
view.trigger("collapsed", view);
});
},
});
//==============================================================================
/** 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).
*/
export var ListItemView = ExpandableView.extend(
BASE_MVC.mixin(BASE_MVC.SelectableViewMixin, BASE_MVC.DraggableViewMixin, {
tagName: "div",
className: "list-item",
/** Set up the base class and all mixins */
initialize: function (attributes) {
ExpandableView.prototype.initialize.call(this, attributes);
BASE_MVC.SelectableViewMixin.initialize.call(this, attributes);
BASE_MVC.DraggableViewMixin.initialize.call(this, attributes);
this._setUpListeners();
},
/** event listeners */
_setUpListeners: function () {
// hide the primary actions in the title bar when selectable and narrow
this.on(
"selectable",
function (isSelectable) {
if (isSelectable) {
this.$(".primary-actions").hide();
} else {
this.$(".primary-actions").show();
}
},
this
);
return this;
},
// ........................................................................ rendering
/** In this override, call methods to build warnings, titlebar and primary actions */
_buildNewRender: function () {
var $newRender = ExpandableView.prototype._buildNewRender.call(this);
$newRender.children(".warnings").replaceWith(this._renderWarnings());
$newRender.children(".title-bar").replaceWith(this._renderTitleBar());
$newRender.children(".primary-actions").append(this._renderPrimaryActions());
$newRender.find("> .title-bar .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;
var $warnings = $('<div class="warnings"></div>');
var json = view.model.toJSON();
//TODO:! unordered (map)
_.each(view.templates.warnings, (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",
"click .selector": "toggleSelect",
},
/** expand when the title bar is clicked */
_clickTitleBar: function (event) {
event.stopPropagation();
if (event.altKey) {
this.toggleSelect(event);
if (!this.selectable) {
this.showSelector();
}
} else {
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;
var 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 = (() => {
var elTemplato = BASE_MVC.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 = BASE_MVC.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 = BASE_MVC.wrapTemplate([
// override this
'<div class="subtitle"></div>',
]);
var detailsTemplate = BASE_MVC.wrapTemplate([
// override this
'<div class="details"></div>',
]);
return {
el: elTemplato,
warnings: warnings,
titleBar: titleBarTemplate,
subtitle: subtitleTemplate,
details: detailsTemplate,
};
})();
//==============================================================================
/** A view that is displayed in some larger list/grid/collection.
* *AND* can display some sub-list of it's own when expanded (e.g. dataset collections).
* This list will 'foldout' when the item is expanded depending on this.foldoutStyle:
* If 'foldout': will expand vertically to show the nested list
* If 'drilldown': will overlay the parent list
*
* Inherits from ListItemView.
*
* _renderDetails does the work of creating this.details: a sub-view that shows the nested list
*/
export var FoldoutListItemView = ListItemView.extend({
/** If 'foldout': show the sub-panel inside the expanded item
* If 'drilldown': only fire events and handle by pub-sub
* (allow the panel containing this item to attach it, hide itself, etc.)
*/
foldoutStyle: "foldout",
/** Panel view class to instantiate for the sub-panel */
foldoutPanelClass: null,
/** override to:
* add attributes foldoutStyle and foldoutPanelClass for config poly
* disrespect attributes.expanded if drilldown
*/
initialize: function (attributes) {
if (this.foldoutStyle === "drilldown") {
this.expanded = false;
}
this.foldoutStyle = attributes.foldoutStyle || this.foldoutStyle;
this.foldoutPanelClass = attributes.foldoutPanelClass || this.foldoutPanelClass;
ListItemView.prototype.initialize.call(this, attributes);
this.foldout = this._createFoldoutPanel();
},
/** in this override, attach the foldout panel when rendering details */
_renderDetails: function () {
if (this.foldoutStyle === "drilldown") {
return $();
}
var $newDetails = ListItemView.prototype._renderDetails.call(this);
return this._attachFoldout(this.foldout, $newDetails);
},
/** In this override, handle collection expansion. */
_createFoldoutPanel: function () {
var model = this.model;
var FoldoutClass = this._getFoldoutPanelClass(model);
var options = this._getFoldoutPanelOptions(model);
return new FoldoutClass(
_.extend(options, {
model: model,
})
);
},
/** Stub to return proper foldout panel class */
_getFoldoutPanelClass: function () {
// override
return this.foldoutPanelClass;
},
/** Stub to return proper foldout panel options */
_getFoldoutPanelOptions: function () {
return {
// propagate foldout style down
foldoutStyle: this.foldoutStyle,
fxSpeed: this.fxSpeed,
};
},
/** Render the foldout panel inside the view, hiding controls */
_attachFoldout: function (foldout, $whereTo) {
$whereTo = $whereTo || this.$("> .details");
this.foldout = foldout.render(0);
foldout.$("> .controls").hide();
return $whereTo.append(foldout.$el);
},
/** In this override, branch on foldoutStyle to show expanded */
expand: function () {
var view = this;
return view._fetchModelDetails().always(() => {
if (view.foldoutStyle === "foldout") {
view._expand();
} else if (view.foldoutStyle === "drilldown") {
view._expandByDrilldown();
}
});
},
/** For drilldown, set up close handler and fire expanded:drilldown
* containing views can listen to this and handle other things
* (like hiding themselves) by listening for expanded/collapsed:drilldown
*/
_expandByDrilldown: function () {
var view = this;
// attachment and rendering done by listener
view.listenTo(view.foldout, "close", () => {
view.trigger("collapsed:drilldown", view, view.foldout);
});
view.trigger("expanded:drilldown", view, view.foldout);
},
});
// ............................................................................ TEMPLATES
/** underscore templates */
FoldoutListItemView.prototype.templates = (() => {
var detailsTemplate = BASE_MVC.wrapTemplate(
[
'<div class="details">',
// override with more info (that goes above the panel)
"</div>",
],
"collection"
);
return _.extend({}, ListItemView.prototype.templates, {
details: detailsTemplate,
});
})();
//==============================================================================
export default {
ExpandableView: ExpandableView,
ListItemView: ListItemView,
FoldoutListItemView: FoldoutListItemView,
};
File diff suppressed because it is too large Load Diff
-136
View File
@@ -1,136 +0,0 @@
import _ from "underscore";
import $ from "jquery";
import _l from "utils/localization";
import { getGalaxyInstance } from "app";
//TODO: toastr is another possibility - I didn't see where I might add details, tho
/* ============================================================================
Error modals meant to replace the o-so-easy alerts.
These are currently styled as errormessages but use the Galaxy.modal
infrastructure to be shown/closed. They're capable of showing details in a
togglable dropdown and the details are formatted in a pre.
Example:
errorModal( 'Heres a message', 'A Title', { some_details: 'here' });
errorModal( 'Heres a message' ); // no details, title is 'Error'
There are three specialized forms:
offlineErrorModal a canned response for when there's no connection
badGatewayErrorModal canned response for when Galaxy is restarting
ajaxErrorModal plugable into any Backbone class as an
error event handler by accepting the error args: model, xhr, options
Examples:
if( navigator.offLine ){ offlineErrorModal(); }
if( xhr.status === 502 ){ badGatewayErrorModal(); }
this.listenTo( this.model, 'error', ajaxErrorModal );
============================================================================ */
var CONTACT_MSG = _l("Please contact a Galaxy administrator if the problem persists.");
var DEFAULT_AJAX_ERR_MSG = _l("An error occurred while updating information with the server.");
var DETAILS_MSG = _l("The following information can assist the developers in finding the source of the error:");
/** private helper that builds the modal and handles adding details */
function _errorModal(message, title, details) {
const Galaxy = getGalaxyInstance();
// create and return the modal, adding details button only if needed
Galaxy.modal.show({
title: title,
body: message,
closing_events: true,
buttons: {
Ok: function () {
Galaxy.modal.hide();
},
},
});
Galaxy.modal.$el.addClass("error-modal");
if (details) {
Galaxy.modal.$(".error-details").add(Galaxy.modal.$('button:contains("Details")')).remove();
$("<div/>")
.addClass("error-details")
.hide()
.appendTo(Galaxy.modal.$(".modal-content"))
.append([$("<p/>").text(DETAILS_MSG), $("<pre/>").text(JSON.stringify(details, null, " "))]);
$(`<button id="button-1" class="float-left">${_l("Details")}</button>`)
.appendTo(Galaxy.modal.$(".buttons"))
.click(() => {
Galaxy.modal.$(".error-details").toggle();
});
}
return Galaxy.modal;
}
/** Display a modal showing an error message but fallback to alert if there's no modal */
function errorModal(message, title, details) {
if (!message) {
return;
}
const Galaxy = getGalaxyInstance();
message = _l(message);
title = _l(title) || _l("Error:");
if (Galaxy && Galaxy.modal) {
return _errorModal(message, title, details);
}
alert(`${title}\n\n${message}`);
console.log("error details:", JSON.stringify(details));
}
// ----------------------------------------------------------------------------
/** display a modal when the user may be offline */
function offlineErrorModal() {
return errorModal(_l("You appear to be offline. Please check your connection and try again."), _l("Offline?"));
}
// ----------------------------------------------------------------------------
/** 502 messages that should be displayed when galaxy is restarting */
function badGatewayErrorModal() {
return errorModal(
`${_l("Galaxy is currently unreachable. Please try again in a few minutes.")} ${CONTACT_MSG}`,
_l("Cannot connect to Galaxy")
);
}
// ----------------------------------------------------------------------------
/** display a modal (with details) about a failed Backbone ajax operation */
function ajaxErrorModal(model, xhr, options, message, title) {
message = message || DEFAULT_AJAX_ERR_MSG;
message += ` ${CONTACT_MSG}`;
title = title || _l("An error occurred");
var details = _ajaxDetails(model, xhr, options);
return errorModal(message, title, details);
}
/** build details which may help debugging the ajax call */
function _ajaxDetails(model, xhr, options) {
const Galaxy = getGalaxyInstance();
return {
sentry: Galaxy?.Sentry.lastEventId(),
userAgent: navigator.userAgent,
onLine: navigator.onLine,
version: _.result(Galaxy.config, "version_major"),
xhr: _.omit(xhr, _.functions(xhr)),
options: _.omit(options, "xhr"),
// add ajax data from Galaxy object cache
url: _.result(Galaxy.lastAjax, "url"),
data: _.result(Galaxy.lastAjax, "data"),
// backbone stuff (auto-redacting email for user)
model: _.result(model, "toJSON", `${model}`),
user: _.omit(_.result(Galaxy.user, "toJSON"), "email"),
};
}
//=============================================================================
export default {
errorModal: errorModal,
offlineErrorModal: offlineErrorModal,
badGatewayErrorModal: badGatewayErrorModal,
ajaxErrorModal: ajaxErrorModal,
};
-1
View File
@@ -287,7 +287,6 @@ export default {
RadioButton: Options.RadioButton,
Checkbox: Options.Checkbox,
Radio: Options.Radio,
Switch: Switch,
Select: Select,
NullableText: NullableText,
TextSelect: TextSelect,
-51
View File
@@ -1,51 +0,0 @@
//Allow change tab on event=====================================================
function change_favicon(img) {
var favicon = document.createElement("link");
favicon.id = "tabicon";
favicon.setAttribute("rel", "shortcut icon");
var head = document.querySelector("head");
head.appendChild(favicon);
favicon.setAttribute("type", "image/png");
favicon.setAttribute("href", img);
}
//Check if browser is hidden-aware==============================================
function hidden_permit() {
var browsers = ["webkit", "moz", "ms", "o"];
if ("hidden" in document) {
return "hidden";
}
for (var i = 0; i < browsers.length; i++) {
if (browsers[i] + "Hidden" in document) {
return browsers[i] + "Hidden";
}
}
return null;
}
//Find out if browser tab is hidden=============================================
function is_hidden() {
var state = hidden_permit();
if (!state) {
return false;
}
return document[state];
}
//Generate new tab message to display on update in window=======================
function hidden_count(current) {
if (is_hidden()) {
if (current === 1) {
document.title = "Galaxy (1 job completed)";
} else {
document.title = "Galaxy (" + current + " jobs completed)";
}
}
}
export default {
change_favicon: change_favicon,
hidden_permit: hidden_permit,
is_hidden: is_hidden,
hidden_count: hidden_count,
};
@@ -5,7 +5,7 @@ import $ from "jquery";
import Backbone from "backbone";
import { getGalaxyInstance } from "app";
import Utils from "utils/utils";
import Portlet from "mvc/ui/ui-portlet";
import Portlet from "./portlet";
import Ui from "mvc/ui/ui-misc";
export var View = Backbone.View.extend({
@@ -1,4 +1,4 @@
import Webhooks from "mvc/webhooks";
import Webhooks from "utils/webhooks";
import Utils from "utils/utils";
export function onloadWebhooks(Galaxy) {
+17 -7
View File
@@ -32,13 +32,23 @@ const mutations = {
Vue.delete(state.histories, doomed.id);
},
setHistories(state, newHistories = []) {
// The incoming history list contains less information than the current history
// so we ensure that already available details are not getting lost
// The incoming history list may contain less information than the already stored
// histories, so we ensure that already available details are not getting lost.
const enrichedHistories = newHistories.map((history) => {
const historyState = state.histories[history.id] || {};
return Object.assign({}, history, historyState);
return Object.assign({}, historyState, history);
});
// Histories are provided as list but stored as map.
const newMap = enrichedHistories.reduce((acc, h) => ({ ...acc, [h.id]: h }), {});
// Ensure that already stored histories, which are not available in the incoming array,
// are not lost. This happens e.g. with shared histories since they have different owners.
Object.values(state.histories).forEach((history) => {
const historyId = history.id;
if (!newMap[historyId]) {
newMap[historyId] = history;
}
});
// Update stored histories
Vue.set(state, "histories", newMap);
},
setHistoriesLoading(state, isLoading) {
@@ -82,7 +92,7 @@ const getters = {
};
// flags to keep track of loading states
const isLoadingHistory = new Map();
const isLoadingHistory = new Set();
let isLoadingHistories = false;
const actions = {
@@ -125,7 +135,7 @@ const actions = {
},
loadHistoryById({ dispatch }, id) {
if (!isLoadingHistory.has(id)) {
const p = getHistoryById(id)
getHistoryById(id)
.then((history) => {
dispatch("setHistory", history);
})
@@ -135,7 +145,7 @@ const actions = {
.finally(() => {
isLoadingHistory.delete(id);
});
isLoadingHistory.set(id, p);
isLoadingHistory.add(id);
}
},
resetHistory({ commit }) {
@@ -150,7 +160,7 @@ const actions = {
commit("setHistory", history);
commit("setCurrentHistoryId", history.id);
},
async setCurrentHistory({ dispatch, getters }, id) {
async setCurrentHistory({ dispatch }, id) {
const changedHistory = await setCurrentHistoryOnServer(id);
return dispatch("selectHistory", changedHistory);
},
@@ -7,9 +7,29 @@ let nHistories = 3;
let currentHistory = 0;
const histories = {};
for (let i = 0; i < nHistories; i++) {
histories[i] = { id: i };
histories[i] = { id: i, name: `default` };
}
// add secondary sets of histories
const buildDetails = (key, count = 0) => {
const historyList = [];
for (let i = 0; i < nHistories + count; i++) {
const element = { id: i, name: `name_${key}` };
element[key] = true;
historyList.push(element);
}
return historyList;
};
// detail counter
const countDetails = (histories, key, value = true) => {
let n = 0;
histories.forEach((h) => {
h[key] == value && n++;
});
return n;
};
describe("historyStore", () => {
let axiosMock;
@@ -37,8 +57,24 @@ describe("historyStore", () => {
axiosMock.restore();
});
it("store initialization", async () => {
it("history lists", async () => {
expect(store.getters["history/currentHistoryId"]).toBe(null);
let hlist = store.getters["history/histories"];
expect(hlist.length).toBe(0);
await store.commit("history/setHistories", buildDetails("a", 5));
hlist = store.getters["history/histories"];
expect(countDetails(hlist, "a")).toBe(8);
expect(countDetails(hlist, "b")).toBe(0);
expect(countDetails(hlist, "name", "name_a")).toBe(8);
await store.commit("history/setHistories", buildDetails("b"));
hlist = store.getters["history/histories"];
expect(countDetails(hlist, "a")).toBe(8);
expect(countDetails(hlist, "b")).toBe(3);
expect(countDetails(hlist, "name", "name_a")).toBe(5);
expect(countDetails(hlist, "name", "name_b")).toBe(3);
});
it("store initialization", async () => {
await store.dispatch("history/loadCurrentHistory");
expect(store.getters["history/currentHistoryId"]).toBe(0);
await store.dispatch("history/loadHistoryById", 1);
+5 -17
View File
@@ -6,6 +6,7 @@
// Bootstrap-related style overrides
@import "overrides.scss";
@import "mixins.scss";
// Fontawesome configuration.
//$fa-font-path: "../../../node_modules/font-awesome/fonts/";
@@ -39,6 +40,10 @@ $fa-font-path: "../../../node_modules/@fortawesome/fontawesome-free/webfonts/";
@import "toolshed.scss";
@import "workflow.scss";
@import "multiselect.scss";
@import "icon-btn.scss";
@import "peek-columns.scss";
@import "dataset.scss";
@import "list-item.scss";
// ==== Select2 ====
/* fix for zero width select2 - remove when fixed there */
@@ -57,24 +62,7 @@ $fa-font-path: "../../../node_modules/@fortawesome/fontawesome-free/webfonts/";
color: $text-color;
}
// ==== Mixins ====
@mixin user-select($select) {
-webkit-touch-callout: #{$select};
@each $pre in -webkit-, -moz-, -ms-, -o-, -khtml- {
#{$pre + user-select}: #{$select};
}
#{user-select}: #{$select};
}
@mixin border-radius($radius) {
// from font-awesome 3.0
-webkit-border-radius: $radius;
-moz-border-radius: $radius;
border-radius: $radius;
}
// ==== Basic styles ====
body {
@extend .m-2;
}
-62
View File
@@ -1,62 +0,0 @@
//============================================================================= collection element
.dataset-collection-element {
@extend .has-job-state-mixin;
.title-bar {
.subtitle {
margin-top: 2px;
}
}
.details {
display: none;
}
}
//.dataset-collection-element.dataset {
//}
//
//.dataset-collection-element.dataset-collection {
//}
//============================================================================= collection
.history-content.dataset-collection {
@extend .has-job-state-mixin;
}
//============================================================================= collection panel
.dataset-collection-panel {
@extend .flex-vertical-container;
.vertically-spaced {
margin-top: 4px;
}
> .controls {
@extend .mx-3;
.navigation {
.back {
span {
font-weight: bold;
}
}
}
.title {
.name {
font-weight: bold;
}
}
}
> .list-items {
@extend .flex-column;
overflow-x: hidden;
overflow-y: auto;
}
}
.collection-progress {
.progress {
border: $border-default;
border-color: $text-muted;
.progress-bar {
color: $text-muted;
}
}
}
-353
View File
@@ -1,353 +0,0 @@
@import "scss/mixins";
// all histories
.history-panel {
@extend .flex-vertical-container;
> .controls {
@extend .px-3;
flex: 0 0 auto;
.title {
.name {
margin-left: -1px;
}
input {
font-weight: bold;
}
}
.subtitle:not(:empty) {
span + span:before {
content: ", ";
}
}
.history-size {
float: left;
@extend .mt-1;
}
.actions {
&:empty {
height: $icon-btn-size;
}
}
.messages {
clear: both;
.quota-message {
display: none;
}
}
//TODO: move these out
.annotation-display {
display: none;
margin-bottom: 8px;
color: $text-muted;
.prompt {
display: block;
margin: 0px;
padding: 0px;
font-size: 90%;
font-weight: normal;
}
.prompt:after {
content: ":";
}
}
.annotation-display {
.annotation {
background: white;
border-radius: 3px;
border: 1px solid fade-out($border-color, 0.5);
padding: 4px;
white-space: pre-wrap;
overflow: auto;
}
// fake placeholder for editable text annotation
.annotation:empty:after {
position: relative;
top: -4px;
font-size: 10px;
font-style: italic;
color: $text-muted;
//TODO: move out for localization
content: "Click here to edit annotation";
}
textarea {
margin: 0px 0px 2px 0px;
//display: block;
border-radius: 3px;
width: 100%;
}
}
.list-pagination {
width: 100%;
text-align: center;
margin: 0;
border-top: 1px solid $btn-default-border;
button {
margin: 0;
padding: 0;
width: 25%;
max-width: 128px;
height: 20px;
color: transparent;
overflow: hidden;
}
.pages {
// restyle closer to button
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
margin: 0;
padding: 0;
width: 48%;
max-width: 256px;
height: 20px;
box-shadow: none;
text-align: center;
text-align-last: center;
&:focus {
outline: none;
}
&:hover {
cursor: pointer;
}
}
&:empty {
display: none;
}
& > * {
border: none;
border-radius: 0px;
background: transparent;
&.pages {
color: $text-muted;
text-decoration: underline;
}
&:hover,
&:focus,
&:active {
background: $btn-default-bg;
color: $btn-default-color;
box-shadow: none;
text-decoration: none;
}
}
&:hover > *:not([disabled]) {
color: $btn-default-color;
}
}
}
// the help text that appears above the drop target
.history-drop-target-help {
margin: 10px 10px 4px 10px;
color: $text-muted;
font-size: 80%;
font-style: italic;
text-align: center;
}
.history-drop-target {
min-height: 64px;
margin: 0px 10px 10px 10px;
border: 1px dashed black;
border-radius: 3px;
}
> .list-items {
@extend .flex-column;
overflow-x: hidden;
overflow-y: auto;
&:empty {
flex-grow: 0 !important;
}
}
> .empty-message {
@extend .m-3;
}
.loading-indicator,
.contents-loading-indicator {
display: inline-block;
width: 100%;
padding: 16px;
text-align: center;
color: $text-color;
}
.history-content.dataset,
.history-content.dataset-collection {
.title-bar {
.title {
.hid:after {
content: ":";
}
}
}
}
}
// ---------------------------------------------------------------------------- collapsed history controls
// compact the history controls when scrolling away from the top
.history-panel > .controls.collapsed {
margin: 0px;
padding: 4px 8px 4px;
flex-basis: 32px;
.messages,
.title,
.subtitle,
.history-size,
//TODO: move these out
.annotation-display .prompt {
display: none;
}
.search {
margin-bottom: 4px;
}
.actions {
margin-bottom: 0;
}
.list-pagination {
.prev,
.next {
display: none;
}
.pages {
float: left;
}
}
}
// ---------------------------------------------------------------------------- added class for wider display
// since these are rendered *within parts* of other pages, we need something more than a media query
.history-panel.wide {
& > .controls {
.actions:empty {
height: auto;
}
}
}
// ---------------------------------------------------------------------------- collections nested in histories
.history-panel {
.dataset-collection {
.subtitle {
margin-top: 2px;
}
}
}
// ---------------------------------------------------------------------------- annotated-history-panel
.annotated-history-panel {
& > .controls {
margin: 0px;
.name {
font-size: 150%;
margin-bottom: 4px;
}
// annotations are displayed by default in this view
.subtitle {
display: block;
white-space: pre-wrap;
&:empty {
display: none;
}
}
.history-size {
float: none;
margin: 0;
}
}
// defined outside of list-items because .headers is actually added to controls
// so that it will stay in place despite scrolling
.headers.contents-container {
margin-bottom: 0;
border-bottom: 2px solid grey;
cursor: unset;
& > * {
vertical-align: middle;
padding: 8px;
font-weight: bold;
}
.additional-info {
text-align: right;
}
}
.contents-container {
display: table;
table-layout: fixed;
width: 100%;
// show clickable for expansion
cursor: pointer;
border-bottom: 1px solid rgba(grey, 0.5);
& > * {
display: table-cell;
vertical-align: top;
width: 50%;
}
.additional-info:not(.header) {
/* top and bottom should be == title-bar padding */
padding: 8px;
// do not html format
white-space: pre-wrap;
}
}
& > .list-items {
width: 100%;
border-bottom: 1px solid lightgrey;
}
.empty-message {
margin-top: 8px;
}
}
// ---------------------------------------------------------------------------- embedded in pages
.embedded-item.display.history .annotated-history-panel {
& > .controls {
.name {
display: none;
}
}
}
// ---------------------------------------------------------------------------- current-history-panel
.current-history-panel {
& > .controls .list-pagination {
// text a bit smaller for all
font-size: 90%;
& > * {
font-size: inherit;
}
}
// .current-content is added to dataset/collection when displayed/edited/visualized, etc@mixin
// (roughly: when it's being used in the center panel)
.list-item.history-content.current-content {
//TODO: Galaxy's 'bs-primary' color - not sure where this is defined
border-left: 5px solid #4e5777;
}
}
// ---------------------------------------------------------------------------- analyze data page - right panel
#right.history-right-panel {
.unified-panel-body {
display: -webkit-flex;
.middle {
overflow: auto;
width: 100%;
display: -webkit-flex !important;
}
}
}
// History panel tags editor
.history-panel .tags-display {
padding: 1px;
@include shutterFade(250px);
}
-55
View File
@@ -1,55 +0,0 @@
// ---------------------------------------------------------------------------- generic panel list items
.job.list-item {
border: 1px solid $border-color;
.title-bar {
.subtitle {
color: grey;
font-size: 80%;
}
}
& > .details {
padding: 0px;
.params {
padding: 0px 8px 0px 8px;
//display: table;
.param,
.input {
//display: table-row;
.prompt {
//display: block;
//display: table-cell;
//margin-right: 8px;
//text-align: right;
font-size: 80%;
//font-style: italic;
color: grey;
}
.value {
display: inline-block;
font-weight: bold;
//display: table-cell;
}
}
}
.list-panel {
padding: 0px;
.list-items {
border: 0px;
border-radius: 0px;
.list-item {
border: 0px;
border-radius: 0px;
border-top: 1px solid $border-color;
&:last-child {
}
}
}
}
}
}
+15
View File
@@ -5,6 +5,21 @@ $fa-font-path: "../../../node_modules/@fortawesome/fontawesome-free/webfonts/";
@import "~@fortawesome/fontawesome-free/scss/fontawesome";
@import "~@fortawesome/fontawesome-free/scss/_variables";
@mixin user-select($select) {
-webkit-touch-callout: #{$select};
@each $pre in -webkit-, -moz-, -ms-, -o-, -khtml- {
#{$pre + user-select}: #{$select};
}
#{user-select}: #{$select};
}
@mixin border-radius($radius) {
// from font-awesome 3.0
-webkit-border-radius: $radius;
-moz-border-radius: $radius;
border-radius: $radius;
}
// Utility mixin expands to container edges
@mixin fill() {
position: relative;
@@ -1,93 +0,0 @@
// component to display a dataset choice and activate a modal chooser (single *and* multiple datasets)
.dataset-choice {
border: 1px solid lightgrey;
border-radius: 3px;
overflow: hidden;
padding: 10px 8px 8px 8px;
&:hover {
border-color: black;
cursor: pointer;
& > * {
cursor: pointer;
}
}
.prompt {
margin-right: 8px;
&:after {
content: ":";
}
&:empty {
display: none;
}
}
.none-selected-msg {
color: grey;
}
.selected {
display: inline-block;
.title {
font-weight: bold;
}
.subtitle {
color: grey;
&:before {
content: "-";
margin: 0px 4px 0px 4px;
}
i {
font-style: normal;
&:not(:last-child):after {
content: ", ";
}
}
}
}
}
// component to display a dataset choice and activate a modal chooser (multiple datasets)
.dataset-choice.multi {
.selected {
display: block;
font-weight: normal;
}
table {
width: 100%;
margin-top: 8px;
cursor: pointer;
&:hover {
border-color: black;
}
tr:nth-child(even) {
background-color: aliceblue;
}
th {
padding: 0px;
font-weight: normal;
font-size: 80%;
color: grey;
}
th:not(:last-child),
td:not(:last-child) {
padding-right: 8px;
}
td.cell-name {
font-weight: bold;
}
}
}
// modal allowing single or multiple dataset selection - often activated by .dataset-choice above
.dataset-choice-modal .list-panel {
.list-item.dataset {
border-left-width: 1px;
border-right-width: 1px;
}
.controls .title .name {
font-size: 120%;
}
}
-34
View File
@@ -1,34 +0,0 @@
// augmented ui-modal meant to replace alert() and display error details
.error-modal .modal-content {
@extend .errormessagelarge;
& > * {
padding: 0;
margin: 0;
border: none;
}
// error details are in modal-content after modal-body
.error-details {
display: none;
margin-top: 8px;
// after a short preamble, show the details
pre {
// error details are black & white, word-wrapped
border-radius: 3px;
background: white;
padding: 8px;
white-space: pre-wrap;
color: black;
}
}
.modal-footer {
margin-top: 8px;
// float buttons individually, not as a div
.buttons {
width: 100%;
float: none;
& > * {
float: right;
}
}
}
}
-34
View File
@@ -1,34 +0,0 @@
// pagination & scrolling pagination: see scripts/jq-plugins/ui/pagination.js
.pagination {
margin: 0px;
}
.pagination-scroll-container {
display: inline-block;
background-color: #f8f8f8;
border-radius: 3px;
border: 1px solid #bfbfbf;
overflow: auto;
}
.pagination-scroll-container .pagination-page-list {
margin: 3px 0px 3px 0px;
}
.pagination-scroll-container .pagination-page-list > li:first-child > a,
.pagination-scroll-container .pagination-page-list > li:first-child > span {
border-radius: 0px;
border-left: 0px;
}
.pagination-scroll-container .pagination-page-list > li:last-child > a,
.pagination-scroll-container .pagination-page-list > li:last-child > span {
border-radius: 0px;
}
.pagination-scroll-container .pagination-page-list > li > a {
float: none;
position: static;
border: 1px solid #bfbfbf;
border-width: 0px 0px 0px 1px;
}
@@ -1,53 +0,0 @@
.search-input {
::placeholder {
color: $text-muted;
}
border: $border-default;
border-radius: 1rem;
background: #fff;
.search-advanced,
.search-clear,
.search-loading {
@extend .mr-2;
position: relative;
float: right;
top: -27px;
font-size: 1.2rem;
color: $text-muted;
}
.search-advanced {
margin-right: 5px !important;
}
.search-advanced:hover {
color: $brand-info;
cursor: pointer;
}
.search-clear:hover {
color: $brand-info;
cursor: pointer;
}
.search-loading {
display: none;
}
.search-query {
@extend .px-3;
@extend .form-control;
outline: 0px !important;
-webkit-appearance: none;
box-shadow: none !important;
width: 100%;
display: inline-block;
font-size: $font-size-base;
line-height: $line-height-base;
color: $text-color;
border: 0px;
@include border-radius($border-radius-extralarge);
max-width: auto;
background: $white;
}
.search-query:focus {
border-color: $brand-info;
outline: 0;
}
}
-14
View File
@@ -699,20 +699,6 @@ div.permissionContainer {
padding-left: 5px;
}
// ============================================================================ History
@import "ui/icon-btn.scss";
@import "list-item.scss";
@import "dataset.scss";
@import "history.scss";
@import "collection.scss";
@import "job.scss";
@import "ui/search-input.scss";
@import "ui/dataset-choice.scss";
@import "ui/peek-column-selector.scss";
@import "ui/pagination.scss";
@import "ui/error-modal.scss";
// ==== Tool menu styles
.toolMenuContainer {
color: $panel-text-color;

Some files were not shown because too many files have changed in this diff Show More