Merge pull request #13477 from guerler/new_history_data_plug

Enable infinite scrolling / pagination for history and collection items
This commit is contained in:
Dannon
2022-03-08 10:17:38 -05:00
committed by GitHub
101 changed files with 1542 additions and 3006 deletions
+1
View File
@@ -92,6 +92,7 @@
"vue-router": "^3.5.3",
"vue-rx": "^6.2.0",
"vue-scrollto": "^2.20.0",
"vue-virtual-scroll-list": "^2.3.3",
"vuedraggable": "2.24.3",
"vueisotope": "^3.1.2",
"vuex": "^3.6.2",
@@ -0,0 +1,35 @@
<template>
<h6 v-if="collectionType" class="description py-1">
<span>a {{ collectionLabel | localize }}</span>
<span v-if="elementCount == 1">with 1 item</span>
<span v-else-if="elementCount > 1">with {{ elementCount }} items</span>
</h6>
</template>
<script>
export default {
props: {
item: { type: Object, required: true },
},
data() {
return {
labels: {
list: "list",
paired: "dataset pair",
"list:paired": "list of pairs",
},
};
},
computed: {
collectionLabel() {
return this.labels[this.collectionType] || "nested list";
},
collectionType() {
return this.item.collection_type;
},
elementCount() {
return this.item.element_count;
},
},
};
</script>
@@ -0,0 +1,207 @@
<template>
<div
:id="contentId"
:class="['content-item m-1 p-0 rounded', clsStatus]"
draggable
:data-hid="id"
:data-state="state"
@dragstart="onDragStart">
<div class="p-1 cursor-pointer" @click.stop="onClick">
<div class="clearfix overflow-hidden">
<div class="btn-group float-right">
<b-button
v-if="isDataset"
:disabled="displayDisabled"
:title="displayButtonTitle"
class="px-1"
size="sm"
variant="link"
@click.stop="onDisplay">
<icon icon="eye" />
</b-button>
<b-button
v-if="isHistoryItem"
:disabled="editDisabled"
class="px-1"
title="Edit attributes"
size="sm"
variant="link"
@click.stop="onEdit">
<icon icon="pen" />
</b-button>
<b-button
v-if="isHistoryItem && !item.deleted"
class="px-1"
title="Delete"
size="sm"
variant="link"
:disabled="item.purged"
@click.stop="$emit('delete', item)">
<icon icon="trash" />
</b-button>
<b-button
v-if="isHistoryItem && item.deleted"
class="px-1"
title="Undelete"
size="sm"
variant="link"
:disabled="item.purged"
@click.stop="$emit('undelete', item)">
<icon icon="trash-restore" />
</b-button>
<b-button
v-if="isHistoryItem && !item.visible"
class="px-1"
title="Unhide"
size="sm"
variant="link"
@click.stop="$emit('unhide', item)">
<icon icon="unlock" />
</b-button>
</div>
<h5 class="float-left p-1 w-75 font-weight-bold">
<div v-if="selectable" class="selector float-left mr-2">
<span
v-if="selected"
class="fa fa-lg fa-check-square-o"
@click.stop="$emit('update:selected', false)" />
<span v-else class="fa fa-lg fa-square-o" @click.stop="$emit('update:selected', true)" />
</div>
<icon v-if="stateIcon" :icon="stateIcon" />
<span class="id hid">{{ id }}</span>
<span>:</span>
<span class="content-title name">{{ name }}</span>
<CollectionDescription v-if="!isDataset" :item="item" />
<div v-if="!expandDataset && item.tags && item.tags.length > 0" class="nametags">
<Nametag v-for="tag in item.tags" :key="tag" :tag="tag" />
</div>
</h5>
</div>
</div>
<!-- collections are not expandable, so we only need the DatasetDetails component here -->
<DatasetDetails v-if="expandDataset" @edit="onEdit" :item="item" />
</div>
</template>
<script>
import { backboneRoute, useGalaxy, iframeRedirect } from "components/plugins/legacyNavigation";
import { Nametag } from "components/Nametags";
import CollectionDescription from "./Collection/CollectionDescription";
import DatasetDetails from "./Dataset/DatasetDetails";
import CONTENTSTATE from "./contentState";
export default {
components: {
CollectionDescription,
DatasetDetails,
Nametag,
},
props: {
expandDataset: { type: Boolean, required: true },
item: { type: Object, required: true },
id: { type: Number, required: true },
isDataset: { type: Boolean, default: true },
isHistoryItem: { type: Boolean, default: true },
name: { type: String, required: true },
selected: { type: Boolean, default: false },
selectable: { type: Boolean, default: false },
},
computed: {
contentId() {
return `dataset-${this.item.id}`;
},
state() {
if (this.item.job_state_summary) {
for (const key of ["error", "failed", "paused", "upload", "running"]) {
if (this.item.job_state_summary[key] > 0) {
return key;
}
}
return "ok";
} else {
return this.item.state;
}
},
clsStatus() {
const status = CONTENTSTATE[this.state] && CONTENTSTATE[this.state].status;
if (this.selected) {
return "alert-info";
} else if (!status) {
return `alert-success`;
} else {
return `alert-${status}`;
}
},
displayButtonTitle() {
if (this.item.purged) {
return "Cannot display datasets removed from disk.";
}
if (this.displayDisabled) {
return "This dataset is not yet viewable.";
}
return "Display";
},
displayDisabled() {
return this.item.purged || ["discarded", "new", "upload"].includes(this.state);
},
editButtonTitle() {
if (this.item.purged) {
return "Cannot edit attributes of datasets removed from disk.";
}
if (this.editDisabled) {
return "This dataset is not yet editable.";
}
return "Edit Attributes";
},
editDisabled() {
return (
this.item.purged || ["discarded", "new", "upload", "queued", "running", "waiting"].includes(this.state)
);
},
stateIcon() {
return CONTENTSTATE[this.state] && CONTENTSTATE[this.state].icon;
},
},
methods: {
onDragStart(evt) {
evt.dataTransfer.dropEffect = "move";
evt.dataTransfer.effectAllowed = "move";
evt.dataTransfer.setData("text", JSON.stringify([this.item]));
},
onDisplay() {
const id = this.item.id;
useGalaxy((Galaxy) => {
if (Galaxy.frame && Galaxy.frame.active) {
Galaxy.frame.addDataset(id);
} else {
iframeRedirect(`datasets/${id}/display/?preview=True`);
}
});
},
onEdit() {
if (this.item.collection_type) {
backboneRoute(`collection/edit/${this.item.id}`);
} else {
backboneRoute("datasets/edit", { dataset_id: this.item.id });
}
},
onClick() {
if (this.isDataset) {
this.$emit("update:expand-dataset", !this.expandDataset);
} else {
this.$emit("view-collection", this.item);
}
},
},
};
</script>
<style>
.content-item:hover {
filter: brightness(105%);
}
.content-item {
.name {
word-wrap: break-word;
}
}
</style>
@@ -0,0 +1,159 @@
<template>
<div class="dataset-actions mb-1">
<div class="clearfix">
<div class="btn-group float-left">
<b-button v-if="showError" class="px-1" title="Error" size="sm" variant="link" @click.stop="onError">
<span class="fa fa-bug" />
</b-button>
<b-button
v-if="showDownloads"
class="download-btn px-1"
title="Download"
size="sm"
variant="link"
@click.stop="onDownload">
<span class="fa fa-save" />
</b-button>
<b-button
v-if="showDownloads"
class="px-1"
title="Copy Link"
size="sm"
variant="link"
@click.stop="onCopyLink">
<span class="fa fa-link" />
</b-button>
<b-button
v-if="showInfo"
class="params-btn px-1"
title="Dataset Details"
size="sm"
variant="link"
@click.stop="onInfo">
<span class="fa fa-info-circle" />
</b-button>
<b-button
v-if="showRerun"
class="px-1"
title="Run Job Again"
size="sm"
variant="link"
@click.stop="onRerun">
<span class="fa fa-redo" />
</b-button>
<b-button
v-if="showVisualizations"
class="px-1"
title="Visualize"
size="sm"
variant="link"
@click.stop="onVisualize">
<span class="fa fa-bar-chart-o" />
</b-button>
<b-button v-if="showRerun" class="px-1" title="Help" size="sm" variant="link" @click.stop="onRerun">
<span class="fa fa-question" />
</b-button>
</div>
<div class="btn-group float-right">
<b-button
class="px-1"
title="Edit Tags"
size="sm"
variant="link"
:pressed="showTags"
@click="onToggleTags">
<span class="fa fa-tags" />
</b-button>
</div>
</div>
<div v-if="showTags" class="mb-2">
<StatelessTags class="tags" v-model="tags" @input="onTags" />
</div>
</div>
</template>
<script>
import { StatelessTags } from "components/Tags";
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
import { prependPath } from "utils/redirect";
import { copy as sendToClipboard } from "utils/clipboard";
import { absPath } from "utils/redirect";
import { updateContentFields } from "components/History/model/queries";
export default {
mixins: [legacyNavigationMixin],
components: {
StatelessTags,
},
props: {
item: { type: Object, required: true },
},
data() {
return {
showTags: false,
tags: this.item.tags,
};
},
computed: {
downloadUrl() {
return prependPath(`datasets/${this.item.id}/display?to_ext=${this.item.extension}`);
},
showDownloads() {
return !this.item.purged && ["ok", "failed_metadata", "error"].includes(this.item.state);
},
showError() {
return this.item.state == "error";
},
showInfo() {
return this.item.state != "noPermission";
},
showRerun() {
return (
this.item.rerunnable &&
this.item.creating_job &&
this.item.state != "upload" &&
this.item.state != "noPermission"
);
},
showVisualizations() {
// TODO: Check hasViz, if visualizations are activated in the config
return !this.item.purged && ["ok", "failed_metadata", "error"].includes(this.item.state);
},
},
methods: {
onCopyLink() {
const msg = this.localize("Link is copied to your clipboard");
sendToClipboard(absPath(this.downloadUrl), msg);
},
onDownload() {
window.location.href = this.downloadUrl;
},
onError() {
this.backboneRoute("datasets/error", { dataset_id: this.item.id });
},
onInfo() {
this.backboneRoute(`datasets/${this.item.id}/details`);
},
onRerun() {
this.backboneRoute(`root?job_id=${this.item.creating_job}`);
},
onTags() {
updateContentFields(this.item, { tags: this.tags });
},
onToggleTags() {
this.showTags = !this.showTags;
},
onVisualize() {
const path = `visualizations?dataset_id=${this.item.id}`;
const redirectParams = {
path: path,
title: "Dataset details",
tryIframe: false,
};
if (!this.iframeAdd(redirectParams)) {
this.backboneRoute(path);
}
},
},
};
</script>
@@ -0,0 +1,52 @@
<template>
<UrlDataProvider :url="dataUrl" auto-refresh v-slot="{ loading, result }">
<div v-if="!loading" class="dataset">
<div class="p-2 details">
<div class="summary">
<div v-if="result.misc_blurb" class="blurb">
<span class="value">{{ result.misc_blurb }}</span>
</div>
<span v-if="result.file_ext" class="datatype">
<label class="prompt" v-localize>format</label>
<span class="value">{{ result.file_ext }}</span>
</span>
<span v-if="result.genome_build" class="dbkey">
<label class="prompt" v-localize>database</label>
<b-link class="value" @click.stop="$emit('edit', dataset)" data-label="Database/Build">{{
result.genome_build
}}</b-link>
</span>
<div v-if="result.misc_info" class="info">
<span class="value">{{ result.misc_info }}</span>
</div>
</div>
<DatasetActions :item="result" />
<pre v-if="result.peek" class="dataset-peek p-1" v-html="result.peek" />
</div>
</div>
</UrlDataProvider>
</template>
<script>
import { UrlDataProvider } from "components/providers/UrlDataProvider";
import DatasetActions from "./DatasetActions";
export default {
components: {
DatasetActions,
UrlDataProvider,
},
props: {
item: { type: Object, required: true },
},
computed: {
dataset() {
return this.item.object || this.item;
},
dataUrl() {
const datasetId = this.dataset.id;
return `api/datasets/${datasetId}`;
},
},
};
</script>
@@ -0,0 +1,37 @@
<template>
<div>
<ContentItem
:item="item.object"
:id="item.element_index"
:name="item.element_identifier"
:is-dataset="item.element_type == 'hda'"
:expand-dataset="expandDataset"
:is-history-item="false"
@update:expand-dataset="expandDataset = $event"
@view-collection="viewCollection = !viewCollection" />
<GenericItem v-if="viewCollection" :itemId="item.object.id" :itemSrc="item.object.history_content_type" />
</div>
</template>
<script>
import ContentItem from "./ContentItem";
export default {
components: {
ContentItem,
GenericItem: () => import("components/History/Content/GenericItem"),
},
props: {
item: {
type: Object,
required: true,
},
},
data() {
return {
viewCollection: false,
expandDataset: false,
};
},
};
</script>
@@ -0,0 +1,76 @@
<template>
<component :is="providerComponent" :id="itemId" auto-refresh v-slot="{ result: item, loading }">
<loading-span v-if="loading" message="Loading dataset" />
<div v-else>
<ContentItem
:item="item"
:id="item.hid"
:name="item.name"
:expand-dataset="expandDataset"
:is-dataset="item.history_content_type == 'dataset'"
@update:expand-dataset="expandDataset = $event"
@view-collection="viewCollection = !viewCollection"
@delete="onDelete"
@undelete="onUndelete"
@unhide="onUnhide" />
<div v-if="viewCollection">
<div v-for="(collectionItem, collectionIndex) in item.elements" :key="collectionIndex">
<GenericElement :item="collectionItem" />
</div>
</div>
</div>
</component>
</template>
<script>
import LoadingSpan from "components/LoadingSpan";
import { DatasetProvider, DatasetCollectionProvider } from "components/providers";
import { deleteContent, updateContentFields } from "components/History/model/queries";
import ContentItem from "./ContentItem";
import GenericElement from "./GenericElement";
export default {
components: {
ContentItem,
GenericElement,
DatasetProvider,
DatasetCollectionProvider,
LoadingSpan,
},
props: {
itemId: {
type: String,
required: true,
},
itemSrc: {
type: String,
required: true,
},
},
data() {
return {
viewCollection: false,
expandDataset: false,
};
},
computed: {
providerComponent() {
return this.itemSrc == "hda" ? "DatasetProvider" : "DatasetCollectionProvider";
},
},
methods: {
onDelete(item) {
deleteContent(item);
},
onUndelete(item) {
updateContentFields(item, { deleted: false });
},
onHide(item) {
updateContentFields(item, { visible: false });
},
onUnhide(item) {
updateContentFields(item, { visible: true });
},
},
};
</script>
@@ -28,10 +28,10 @@ export default {
const key = this.getItemKey(item);
return this.items.has(key);
},
setSelected(item, bSelected) {
setSelected(item, selected) {
const key = this.getItemKey(item);
const newSelected = new Map(this.items);
bSelected ? newSelected.set(key, item) : newSelected.delete(key);
selected ? newSelected.set(key, item) : newSelected.delete(key);
this.items = newSelected;
},
selectItems(items = []) {
@@ -0,0 +1,67 @@
/*
Client representation of state and state messages. See: https://github.com/galaxyproject/galaxy/blob/dev/lib/galaxy/model/__init__.py#L3292
for a list of available states.
*/
export default {
/** deleted while uploading */
discarded: {
status: "danger",
text: "The job creating this dataset was cancelled before completion.",
icon: "exclamation-triangle",
},
/** has no data */
empty: {
status: "success",
text: "No data",
},
/** the tool producing this dataset failed */
error: {
status: "danger",
text: "An error occurred with this dataset",
icon: "exclamation-triangle",
},
/** metadata discovery/setting failed or errored (but otherwise ok) */
failed_metadata: {
status: "danger",
},
/** was created without a tool */
new: {
status: "warning",
text: "This is a new dataset and not all of its data are available yet.",
icon: "clock",
},
/** has successfully completed running */
ok: {
status: "success",
},
/** the job that will produce the dataset paused */
paused: {
status: "info",
text: "This job is paused. Use the 'Resume Paused Jobs' in the history menu to resume.",
icon: "pause",
},
/** the job that will produce the dataset queued in the runner */
queued: {
status: "warning",
text: "This job is waiting to run.",
icon: "clock",
},
/** the job that will produce the dataset is running */
running: {
status: "warning",
text: "This job is currently running.",
icon: "clock",
},
/** metadata for the dataset is being discovered/set */
setting_metadata: {
status: "warning",
text: "Metadata is being auto-detected.",
icon: "clock",
},
/** is uploading and not ready */
upload: {
status: "warning",
text: "This dataset is currently uploading.",
icon: "clock",
},
};
@@ -1,19 +0,0 @@
import ContentItem from "./ContentItem";
export default {
mixins: [ContentItem],
computed: {
contentItemComponent() {
const { element_type } = this.item;
switch (element_type) {
case "hda":
return "Subdataset";
case "hdca":
return "Subcollection";
default:
return "Placeholder";
}
},
},
};
@@ -1,83 +0,0 @@
/**
* This is a single item in the list. The main job of the contentItem is to
* pick a specific selector for the :is attribute of the generic component
* placeholder. This should be a dataset, collection, or other component
* depending on the specific list implementation.
*
* The mixin also pulls out "selected", and "expanded" properties which are
* derived from the current component state rather than the data itself.
*/
import Placeholder from "./Placeholder";
import Dataset from "./Dataset";
import DatasetCollection from "./DatasetCollection";
import Subdataset from "./Subdataset";
import Subcollection from "./Subcollection";
export default {
template: `
<component :is="contentItemComponent"
:data-ci-type="contentItemComponent"
class="content-item"
:class="{ loading }"
:tabindex="index"
:writable="writable"
v-on="$listeners"
v-bind="$attrs"
:item="item"
:index="index"
:row-key="rowKey"
:writable="writable"
@mouseover.native.stop="setFocus(index)"
@keydown.native.arrow-up.self.stop="setFocus(index - 1)"
@keydown.native.arrow-down.self.stop="setFocus(index + 1)"
/>
`,
components: {
Placeholder,
Dataset,
DatasetCollection,
Subdataset,
Subcollection,
},
props: {
item: { type: Object, required: true },
index: { type: Number, required: false, default: null },
rowKey: { type: [Number, String], required: false, default: "" },
writable: { type: Boolean, required: false, default: true },
},
data: () => ({
suppressFocus: true,
}),
methods: {
setFocus(index) {
if (this.suppressFocus) {
return;
}
const ul = this.$el.closest(".scroller");
const el = ul.querySelector(`[tabindex="${index}"]`);
if (el) {
el.focus();
}
},
},
computed: {
loading() {
return !this.item;
},
contentItemComponent() {
// override me
return "Placeholder";
},
},
created() {
this.$root.$on("bv::dropdown::show", () => (this.suppressFocus = true));
this.$root.$on("bv::dropdown::hide", () => (this.suppressFocus = false));
},
};
@@ -1,53 +0,0 @@
<template>
<DatasetUI
v-if="dataset"
v-bind="$attrs"
v-on="$listeners"
:dataset="dataset"
@update:dataset="onUpdate"
@delete="onDelete"
@undelete="onUndelete"
@unhide="onUnhide"
@copy-link="onCopyLink" />
</template>
<script>
import DatasetUI from "./DatasetUI";
import { Dataset } from "../../model";
import { deleteContent, updateContentFields } from "../../model/queries";
import { copy as sendToClipboard } from "utils/clipboard";
import { absPath } from "utils/redirect";
export default {
components: {
DatasetUI,
},
props: {
item: { type: Object, required: true },
},
computed: {
dataset() {
return new Dataset(this.item);
},
},
methods: {
async onDelete(opts = {}) {
await deleteContent(this.item, opts);
},
async onUnhide() {
await this.onUpdate({ visible: true });
},
async onUndelete() {
await this.onUpdate({ deleted: false });
},
async onUpdate(changes) {
await updateContentFields(this.item, changes);
},
onCopyLink() {
const relPath = this.dataset.getUrl("download");
const msg = this.localize("Link is copied to your clipboard");
sendToClipboard(absPath(relPath), msg);
},
},
};
</script>
@@ -1,294 +0,0 @@
<template>
<div>
<b-button-group>
<IconButton
v-if="notIn(STATES.NOT_VIEWABLE, STATES.DISCARDED)"
icon="eye"
:title="displayButtonTitle"
:disabled="dataset.purged || isIn(STATES.UPLOAD, STATES.NEW)"
@click.stop="viewData"
variant="link"
class="px-1 display-btn" />
<IconButton
v-if="writable && notIn(STATES.DISCARDED)"
icon="pen"
:title="editButtonTitle"
:disabled="dataset.deleted || isIn(STATES.UPLOAD, STATES.NEW)"
@click.stop="$emit('edit')"
variant="link"
class="px-1 edit-btn" />
<IconButton
v-if="writable && dataset.accessible"
:icon="dataset.deleted ? 'trash-restore' : 'trash'"
:title="deleteButtonTitle"
:disabled="dataset.purged"
v-b-modal="bsId('delete-modal')"
variant="link"
class="px-1 delete-btn"
@click.stop />
<b-dropdown
right
size="sm"
variant="link"
:text="'Dataset Operations' | l"
toggle-class="p-1 pl-2"
class="flex-grow-0 dataset-operations-dropdown"
v-if="expanded"
boundary="window">
<template v-slot:button-content>
<Icon icon="ellipsis-v" variant="link" />
<span class="sr-only">Dataset Operations</span>
</template>
<b-dropdown-item
v-if="isIn(STATES.ERROR)"
key="show-error"
title="Error"
@click.stop="
backboneRoute('datasets/error', {
dataset_id: dataset.id,
})
">
<Icon icon="exclamation-triangle" class="mr-1" />
<span v-localize>Show Error</span>
</b-dropdown-item>
<b-dropdown-item
v-if="showDownloads && dataset.getUrl('download')"
title="Copy Link"
@click.stop="$emit('copy-link')">
<Icon icon="link" class="mr-1" />
<span v-localize>Copy Link</span>
</b-dropdown-item>
<b-dropdown-group v-if="showDownloads && dataset.hasMetaData">
<b-dropdown-item
v-for="mf in dataset.meta_files"
:key="'download-' + mf.download_url"
:title="'Download ' + mf.file_type"
:href="prependPath(dataset.getUrl('meta_download') + mf.file_type)"
target="_blank"
download>
<Icon icon="download" class="mr-1" />
<span v-localize>{{ "Download " + mf.file_type }}</span>
</b-dropdown-item>
</b-dropdown-group>
<b-dropdown-item
v-else-if="showDownloads"
title="Download"
:href="prependPath(dataset.getUrl('download'))"
target="_blank"
class="download-btn"
download>
<Icon icon="download" class="mr-1" />
<span v-localize>Download</span>
</b-dropdown-item>
<b-dropdown-item
v-if="dataset.rerunnable && dataset.creating_job && notIn(STATES.UPLOAD, STATES.NOT_VIEWABLE)"
title="Run job again"
:href="prependPath(dataset.getUrl('rerun'))"
@click.stop.prevent="
backboneRoute('/', {
job_id: dataset.creating_job,
})
">
<Icon icon="play" class="mr-1" />
<span v-localize>Run job again</span>
</b-dropdown-item>
<b-dropdown-item
v-if="showViz && hasViz && isIn(STATES.OK, STATES.FAILED_METADATA)"
title="Visualize Data"
@click.stop.prevent="visualize">
<Icon icon="chart-area" class="mr-1" />
<span v-localize>Visualize Data</span>
</b-dropdown-item>
<b-dropdown-item
v-if="currentUser && currentUser.id && dataset.creating_job"
title="Tool Help"
@click.stop="showToolHelp(dataset.creating_job)">
<Icon icon="question" class="mr-1" />
<span v-localize>Tool Help</span>
</b-dropdown-item>
<b-dropdown-item
v-if="notIn(STATES.NOT_VIEWABLE)"
key="dataset-details"
title="View Dataset Details"
class="params-btn"
@click.stop.prevent="showDetails">
<Icon icon="info-circle" class="mr-1" />
<span v-localize>View Dataset Details</span>
</b-dropdown-item>
</b-dropdown>
</b-button-group>
<!-- modals -->
<b-modal :id="bsId('delete-modal')" title="Delete Dataset?" title-tag="h2" @ok="onDeleteClick">
<p v-localize>Really delete this dataset?</p>
</b-modal>
</div>
</template>
<script>
import { mapGetters } from "vuex";
import { Dataset } from "../../model";
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
import IconButton from "components/IconButton";
export default {
mixins: [legacyNavigationMixin],
inject: ["STATES"],
components: {
IconButton,
},
props: {
dataset: { type: Dataset, required: true },
expanded: { type: Boolean, required: true },
writable: { type: Boolean, default: true },
},
data() {
return {
toolHelp: null,
testVal: false,
};
},
computed: {
...mapGetters("user", ["currentUser"]),
...mapGetters("config", ["config"]),
showViz() {
return this.config.visualizations_visible;
},
displayButtonTitle() {
if (this.dataset.purged) {
return "Cannot display datasets removed from disk";
}
if (this.dataset.state == this.STATES.UPLOAD) {
return "This dataset must finish uploading before it can be viewed";
}
if (this.dataset.state == this.STATES.NEW) {
return "This dataset is not yet viewable";
}
return "View data";
},
editButtonTitle() {
if (this.dataset.deleted) {
return "Undelete dataset to edit attributes";
}
if (this.dataset.purged) {
return "Cannot edit attributes of datasets removed from disk";
}
const unreadyStates = new Set([this.STATES.UPLOAD, this.STATES.NEW]);
if (unreadyStates.has(this.dataset.state)) {
return "This dataset is not yet editable";
}
return "Edit attributes";
},
deleteButtonTitle() {
return this.dataset.purged
? "Dataset has been permanently deleted"
: this.dataset.deleted
? "Undelete"
: "Delete";
},
showDownloads() {
if (this.dataset.purged) {
return false;
}
if (!this.dataset.hasData) {
return false;
}
const okStates = new Set([this.STATES.OK, this.STATES.FAILED_METADATA, this.STATES.ERROR]);
return okStates.has(this.dataset.state);
},
hasViz() {
const viz_count = this.dataset.viz_count || 0;
return viz_count && this.dataset.hasData && !this.dataset.deleted;
},
},
methods: {
notIn(...states) {
const badStates = new Set(states);
return !badStates.has(this.dataset.state);
},
isIn(...states) {
const goodStates = new Set(states);
return goodStates.has(this.dataset.state);
},
viewData() {
const id = this.dataset.id;
if (!id) {
return;
}
this.useGalaxy((Galaxy) => {
if (Galaxy.frame && Galaxy.frame.active) {
Galaxy.frame.addDataset(id);
} else {
const path = this.dataset.getUrl("display");
if (path) {
this.iframeRedirect(path);
}
}
});
},
// wierd iframe navigation
visualize() {
const showDetailsUrl = `/datasets/${this.dataset.id}/details`;
const redirectParams = {
path: showDetailsUrl,
title: "Dataset details",
tryIframe: false,
};
if (!this.iframeAdd(redirectParams)) {
this.backboneRoute("visualizations", {
dataset_id: this.dataset.id,
});
}
},
showDetails() {
const showDetailsUrl = `/datasets/${this.dataset.id}/details`;
const redirectParams = {
path: showDetailsUrl,
title: "Dataset details",
tryIframe: false,
};
if (!this.iframeAdd(redirectParams)) {
this.backboneRoute(showDetailsUrl);
}
},
showToolHelp(job_id) {
this.eventHub.$emit("toggleToolHelp", job_id);
},
onDeleteClick() {
const eventName = this.dataset.deleted ? "undelete" : "delete";
this.$emit(eventName);
},
// bootstrap demands ids instead of simply using a ref so I guess we're doing this
bsId(suffix) {
return `dataset-${this.dataset.id}-${suffix}`;
},
},
};
</script>
@@ -1,250 +0,0 @@
<!--
The guts and markup of the dataset item separated from the
data assignment so we can re-use it on the collection listing
DO NOT put data retrieval into this component, access the dataset
either through the props, and make updates through the events -->
<template>
<div
class="dataset history-content"
:id="typeId"
:class="{ expanded, collapsed, selected }"
:data-hid="dataset.hid"
:data-state="dataset.state"
@keydown.arrow-left.self.stop="$emit('update:expanded', false)"
@keydown.arrow-right.self.stop="$emit('update:expanded', true)"
@keydown.space.self.stop.prevent="$emit('update:selected', !selected)">
<!-- name, state buttons, menus -->
<nav class="content-top-menu p-1 d-flex cursor-pointer" @click.stop="$emit('update:expanded', !expanded)">
<div class="d-flex flex-grow-1 overflow-hidden">
<div class="pl-1" v-if="showSelectionBox">
<b-check class="selector" :checked="selected" @change="$emit('update:selected', $event)"></b-check>
</div>
<StatusIcon
v-if="!collapsed"
class="status-icon px-1"
:state="dataset.state"
@click.stop="onStatusClick" />
<IconButton
v-if="!collapsed && !dataset.visible"
class="px-1"
state="hidden"
title="Unhide"
icon="eye-slash"
@click.stop="$emit('unhide')" />
<IconButton
v-if="!collapsed && dataset.isDeleted && !dataset.purged"
class="px-1"
state="deleted"
title="Undelete"
icon="trash-restore"
@click.stop="$emit('undelete')" />
<div class="content-title title p-1 overflow-hidden">
<h5 class="text-truncate" v-if="collapsed">
<span class="hid" data-description="dataset hid">{{ dataset.hid }}</span>
<span class="name" data-description="dataset name">{{ dataset.title }}</span>
</h5>
</div>
</div>
<div class="d-flex">
<slot name="menu">
<DatasetMenu
:dataset="dataset"
:expanded="expanded"
:writable="writable"
@edit="edit"
v-on="$listeners" />
</slot>
</div>
</nav>
<!--- read-only tags with name: prefix -->
<div v-if="collapsed && dataset.nameTags.length" class="nametags px-2 pb-2">
<Nametag v-for="tag in dataset.nameTags" :key="tag" :tag="tag" />
</div>
<div v-if="expanded" class="p-3 details">
<div class="d-flex">
<div class="flex-grow-1">
<h4 data-description="dataset name">{{ name || "(Dataset Name)" }}</h4>
<template v-if="!editing">
<p v-if="annotation">{{ annotation }}</p>
<div v-if="dataset.nameTags.length" class="nametags mt-2">
<Nametag v-for="tag in dataset.nameTags" :key="tag" :tag="tag" />
</div>
</template>
<div v-else class="my-3">
<b-textarea
class="mb-3"
v-model="name"
placeholder="Dataset Name"
trim
max-rows="4"></b-textarea>
<b-textarea
class="mb-3"
v-model="annotation"
placeholder="Annotation (optional)"
trim
max-rows="4"></b-textarea>
<StatelessTags class="mt-2" v-model="tags" />
</div>
</div>
<EditorMenu
class="ml-3 flex-grow-0 d-flex flex-column"
v-if="writable"
model-name="Dataset"
:editing.sync="editing"
:writable="writable"
:valid="valid"
:dirty="dirty"
@save="save"
@revert="revert" />
</div>
<div class="details">
<DatasetSummary :dataset="dataset" class="summary" @edit="edit" />
<div class="display-applications" v-if="dataset.displayLinks.length">
<div class="display-application" v-for="app in dataset.displayLinks" :key="app.label">
<span class="display-application-location">
{{ app.label }}
</span>
<span class="display-application-links">
<a v-for="l in app.links" :key="l.href" :href="l.href" :target="l.target">
{{ l.text }}
</a>
</span>
</div>
</div>
<pre v-if="dataset.peek" class="dataset-peek p-1" v-html="dataset.peek"></pre>
</div>
</div>
</div>
</template>
<script>
import { Dataset, STATES } from "../../model";
import { Nametag } from "components/Nametags";
import StatusIcon from "../../StatusIcon";
import DatasetMenu from "./DatasetMenu";
import DatasetSummary from "./Summary";
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
import IconButton from "components/IconButton";
import EditorMenu from "../../EditorMenu";
import { StatelessTags } from "components/Tags";
export default {
mixins: [legacyNavigationMixin],
components: {
StatusIcon,
DatasetMenu,
DatasetSummary,
Nametag,
IconButton,
EditorMenu,
StatelessTags,
},
props: {
dataset: { type: Dataset, required: true },
expanded: { type: Boolean, required: true },
selected: { type: Boolean, required: false, default: false },
showSelection: { type: Boolean, required: false, default: false },
writable: { type: Boolean, required: false, default: true },
selectable: { type: Boolean, required: false, default: true },
},
data() {
return {
editing: false,
tempContent: this.dataset.clone(),
};
},
computed: {
collapsed() {
return !this.expanded;
},
typeId() {
return this.dataset.type_id;
},
isEditing() {
return this.writable && this.editing;
},
valid() {
return true;
},
dirty() {
return !this.tempContent.equals(this.dataset);
},
name: {
get() {
return this.tempContent?.name || "";
},
set(newVal) {
this.patchTempContent({ name: newVal });
},
},
annotation: {
get() {
return this.tempContent?.annotation || "";
},
set(newVal) {
this.patchTempContent({ annotation: newVal });
},
},
tags: {
get() {
return this.tempContent?.tags || [];
},
set(newTags) {
if (Array.isArray(newTags)) {
this.patchTempContent({ tags: newTags });
}
},
},
showSelectionBox() {
return this.selectable && this.showSelection;
},
},
methods: {
onStatusClick(evt) {
switch (this.dataset.state) {
case STATES.ERROR:
this.backboneRoute("datasets/error", { dataset_id: this.dataset.id });
break;
default:
console.log("unhandled status icon click", this.dataset.state);
}
},
patchTempContent(props = {}) {
this.tempContent = this.tempContent.patch(props);
},
save() {
if (this.valid) {
this.$emit("update:dataset", this.tempContent);
this.editing = false;
}
},
reset() {
this.tempContent = this.dataset.clone();
},
revert() {
if (this.dirty) {
this.reset();
}
},
edit() {
this.backboneRoute("datasets/edit", { dataset_id: this.dataset.id });
},
},
};
</script>
@@ -1,5 +0,0 @@
<template functional>
<div>
<p v-localize>The job creating this dataset was cancelled before completion.</p>
</div>
</template>
@@ -1,5 +0,0 @@
<template functional>
<div v-if="props.dataset">
<span v-localize>No data</span>: <i>{{ props.dataset.misc_blurb }}</i>
</div>
</template>
@@ -1,11 +0,0 @@
<template functional>
<div v-if="props.dataset">
<div v-if="!props.dataset.purged && props.dataset.misc_info">
<span>{{ props.dataset.misc_info }}</span>
</div>
<span class="help-text" v-localize>An error occurred with this dataset</span>
<div v-if="props.dataset.misc_info" class="job-error-text">
<span>{{ props.dataset.misc_info }}</span>
</div>
</div>
</template>
@@ -1,5 +0,0 @@
<template functional>
<div>
<p v-localize>This is a new dataset and not all of its data are available yet.</p>
</div>
</template>
@@ -1,5 +0,0 @@
<template functional>
<div>
<p v-localize>You do not have permission to view this dataset.</p>
</div>
</template>
@@ -1,18 +0,0 @@
<template functional>
<div v-if="props.dataset">
<div v-if="props.dataset.misc_blurb" class="blurb">
<span class="value">{{ props.dataset.misc_blurb }}</span>
</div>
<div v-if="props.dataset.file_ext" class="datatype">
<label class="prompt" v-localize>format</label>
<span class="value">{{ props.dataset.file_ext }}</span>
</div>
<div v-if="props.dataset.genome_build" class="dbkey">
<label class="prompt" v-localize>database</label>
<b-link class="value" @click.stop="listeners['edit']()">{{ props.dataset.genome_build }}</b-link>
</div>
<div v-if="props.dataset.misc_info" class="info">
<span class="value">{{ props.dataset.misc_info }}</span>
</div>
</div>
</template>
@@ -1,8 +0,0 @@
<template functional>
<div v-if="dataset">
<p v-localize>This job is paused. Use the "Resume Paused Jobs" in the history menu to resume.</p>
<div v-if="dataset.misc_info">
<span>{{ props.dataset.misc_info }}</span>
</div>
</div>
</template>
@@ -1,5 +0,0 @@
<template functional>
<div>
<p v-localize>This job is waiting to run.</p>
</div>
</template>
@@ -1,5 +0,0 @@
<template functional>
<div>
<p v-localize>This job is currently running.</p>
</div>
</template>
@@ -1,5 +0,0 @@
<template functional>
<div>
<p v-localize>Metadata is being auto-detected.</p>
</div>
</template>
@@ -1,3 +0,0 @@
<template functional>
<div v-if="props.dataset">Error: unknown dataset state "{{ props.dataset.state }}"</div>
</template>
@@ -1,5 +0,0 @@
<template functional>
<div>
<p v-localize>This dataset is currently uploading.</p>
</div>
</template>
@@ -1,53 +0,0 @@
<template>
<component :is="summaryComponent" :dataset="dataset" @edit="edit" />
</template>
<script>
import { capitalize, camelize } from "underscore.string";
import Discarded from "./Discarded";
import Empty from "./Empty";
import Error from "./Error";
import New from "./New";
import NotViewable from "./NotViewable";
import Ok from "./Ok";
import Paused from "./Paused";
import Queued from "./Queued";
import Running from "./Running";
import SettingMetadata from "./SettingMetadata";
import Upload from "./Upload";
export default {
inject: ["STATES"],
components: {
Discarded,
Empty,
Error,
New,
NotViewable,
Ok,
Paused,
Queued,
Running,
SettingMetadata,
Upload,
},
props: {
dataset: { type: Object, required: true },
},
computed: {
summaryComponent() {
let state = this.dataset.state;
if (state == this.STATES.FAILED_METADATA) {
state = this.STATES.OK;
}
return capitalize(camelize(state));
},
},
methods: {
edit() {
this.$emit("edit");
},
},
};
</script>
@@ -1,5 +0,0 @@
import Dataset from "./Dataset";
export { default as DatasetUI } from "./DatasetUI";
export { default as DatasetMenu } from "./DatasetMenu";
export default Dataset;
@@ -1,48 +0,0 @@
<template>
<DscUI
v-if="dsc"
v-bind="$attrs"
v-on="$listeners"
class="dataset-collection history-content"
:dsc="dsc"
@update:dsc="onUpdate"
@delete="onDelete"
@undelete="onUndelete"
@unhide="onUnhide" />
</template>
<script>
import DscUI from "./DscUI";
import { DatasetCollection } from "../../model";
import { deleteDatasetCollection, updateContentFields } from "../../model/queries";
export default {
components: {
DscUI,
},
props: {
item: { type: Object, required: true },
},
computed: {
dsc() {
return new DatasetCollection(this.item);
},
},
methods: {
async onDelete(flags = {}) {
const { recursive = false, purge = false } = flags;
const collection = this.item;
await deleteDatasetCollection(collection, recursive, purge);
},
async onUnhide() {
await this.onUpdate({ visible: true });
},
async onUndelete() {
await this.onUpdate({ deleted: false });
},
async onUpdate(changes) {
await updateContentFields(this.item, changes);
},
},
};
</script>
@@ -1,26 +0,0 @@
<template>
<div class="description">
{{ dsc.collectionType | localize }} of {{ dsc.totalElements }}
<b v-if="isHomogeneous">{{ homogeneousDatatype }} </b>datasets
</div>
</template>
<script>
import { DatasetCollection } from "../../model/DatasetCollection";
export default {
props: {
dsc: { type: DatasetCollection, required: true },
},
computed: {
/** @return {Boolean} */
isHomogeneous() {
return this.dsc.isHomogeneous;
},
/** @return {String} */
homogeneousDatatype() {
return this.dsc.homogeneousDatatype;
},
},
};
</script>
@@ -1,108 +0,0 @@
<template>
<div class="collection-menu">
<b-button-group>
<IconButton
icon="info-circle"
v-if="hasJob"
key="dataset-details"
title="View Dataset Collection Details"
@click.stop.prevent="backboneRoute(collectionDetailsPath)"
variant="link"
class="px-1">
</IconButton>
<IconButton
v-if="notIn(STATES.DISCARDED)"
icon="pen"
:title="editButtonTitle"
:disabled="dsc.deleted || isIn(STATES.UPLOAD, STATES.NEW)"
@click.stop="backboneRoute('collection/edit/' + dsc.hdca_id)"
variant="link"
class="px-1 dsc-edit-view" />
<b-dropdown v-if="notIn(STATES.DISCARDED)" no-caret right variant="link" size="sm" boundary="window">
<template v-slot:button-content>
<Icon icon="trash" variant="link" />
<span class="sr-only">Delete Collection</span>
</template>
<b-dropdown-item @click.stop="$emit('delete')">
<span v-localize>Delete Collection Only</span>
</b-dropdown-item>
<b-dropdown-item @click.stop="$emit('delete', { recursive: true })">
<span v-localize>Delete Contained Datasets</span>
</b-dropdown-item>
<b-dropdown-item @click.stop="$emit('delete', { recursive: true, purge: true })">
<span v-localize>Purge Contained Datasets</span>
</b-dropdown-item>
</b-dropdown>
</b-button-group>
</div>
</template>
<script>
import { DatasetCollection } from "../../model";
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import IconButton from "components/IconButton";
Vue.use(BootstrapVue);
export default {
data() {
return {
deleteSelected: null,
deleteCollectionModalShow: false,
};
},
components: {
IconButton,
},
mixins: [legacyNavigationMixin],
inject: ["STATES"],
props: {
dsc: { type: DatasetCollection, required: true },
},
computed: {
editButtonTitle() {
if (this.dsc.deleted) {
return "Collection must not be deleted to edit";
}
if (this.dsc.purged) {
return "Cannot edit attributes of collections removed from disk";
}
const unreadyStates = new Set([this.STATES.UPLOAD, this.STATES.NEW]);
if (unreadyStates.has(this.dsc.state)) {
return "This collection is not yet editable";
}
return "Edit attributes";
},
deleteCollectionButtonTitle() {
return "Delete Collection";
},
hasJob() {
return this.dsc?.job_source_type == "Job";
},
collectionDetailsPath() {
return `jobs/${this.dsc.job_source_id}/view`;
},
},
methods: {
runDelete: function (kind) {
if (kind) {
this.$emit("delete", kind);
} else {
this.$emit("delete");
}
},
notIn(...states) {
const badStates = new Set(states);
return !badStates.has(this.dsc.state);
},
isIn(...states) {
const goodStates = new Set(states);
return goodStates.has(this.dsc.state);
},
},
};
</script>
@@ -1,115 +0,0 @@
<!-- UI only, updates/changes to data should be handled outside
this component via events emissions -->
<template>
<div
class="dataset dataset-collection collapsed"
:class="{ selected }"
:id="typedId"
:data-hid="dsc.hid"
:data-state="dsc.state"
@keydown.arrow-right.self.stop="$emit('viewCollection')"
@keydown.space.self.stop.prevent="$emit('update:selected', !selected)">
<nav class="content-top-menu p-1 d-flex cursor-pointer">
<div class="d-flex flex-grow-1 overflow-hidden">
<div class="pl-1" v-if="showSelection">
<b-check
class="selector"
:checked="selected"
@click.stop
@change="$emit('update:selected', $event)" />
</div>
<StatusIcon
v-if="dsc.state != 'ok'"
class="status-icon px-1"
:state="dsc.state"
@click.stop="onStatusClick" />
<IconButton
v-if="!dsc.visible"
class="px-1"
state="hidden"
title="Unhide"
icon="eye-slash"
@click.stop="$emit('unhide')" />
<IconButton
v-if="dsc.deleted"
class="px-1"
state="deleted"
title="Undelete"
icon="trash-restore"
@click.stop="$emit('undelete')" />
<div class="content-title title flex-grow-1 overflow-hidden" @click.stop="$emit('viewCollection')">
<h5 class="text-truncate">
<span class="hid">{{ dsc.hid }}</span>
<span class="name">{{ dsc.name }}</span>
</h5>
<DscDescription :dsc="dsc" />
</div>
</div>
<div class="d-flex" v-if="writable">
<slot name="menu">
<DscMenu v-if="!dsc.deleted" v-on="$listeners" :dsc="dsc" />
</slot>
</div>
</nav>
<!--- read-only tags with name: prefix -->
<div v-if="collapsed && dsc.nameTags.length" class="nametags px-2 pb-2">
<Nametag v-for="tag in dsc.nameTags" :key="tag" :tag="tag" />
</div>
<JobStateProgress class="m-2" v-if="dsc.jobSummary" :summary="dsc.jobSummary" />
</div>
</template>
<script>
import { DatasetCollection } from "../../model/DatasetCollection";
import StatusIcon from "../../StatusIcon";
import JobStateProgress from "./JobStateProgress";
import DscDescription from "./DscDescription";
import DscMenu from "./DscMenu";
import { Nametag } from "components/Nametags";
import IconButton from "components/IconButton";
export default {
components: {
StatusIcon,
JobStateProgress,
DscMenu,
Nametag,
IconButton,
DscDescription,
},
props: {
dsc: { type: DatasetCollection, required: true },
selected: { type: Boolean, required: false, default: false },
showSelection: { type: Boolean, required: false, default: false },
writable: { type: Boolean, required: false, default: true },
},
methods: {
onStatusClick() {
console.log("onStatusClick", ...arguments);
},
},
computed: {
typedId() {
return this.dsc.type_id;
},
collapsed() {
return !this.expanded;
},
},
};
</script>
<style lang="scss" scoped>
header,
header * {
cursor: pointer;
}
</style>
@@ -1,44 +0,0 @@
<!-- Job state progress bar for a collection. There's another similar component
at components/JobStates/CollectionJobStates but it relies on the backbone data
model, so probably has to go eventually.-->
<template>
<b-progress v-if="maxJobs && runningJobs" :max="maxJobs" height="1em">
<b-progress-bar v-if="errorJobs" :value="errorJobs" variant="danger"></b-progress-bar>
<b-progress-bar v-if="okJobs" :value="okJobs" variant="success"></b-progress-bar>
<b-progress-bar v-if="runningJobs" :value="runningJobs" variant="info" animated></b-progress-bar>
<b-progress-bar v-if="waitingJobs" :value="waitingJobs" variant="dark" animated></b-progress-bar>
</b-progress>
</template>
<script>
import { JobStateSummary } from "../../model";
export default {
props: {
summary: { type: JobStateSummary, required: true },
},
computed: {
hasRunningJobs() {
return this.summary && this.summary.running > 0;
},
maxJobs() {
return this.summary.all_jobs;
},
okJobs() {
return this.summary.ok;
},
runningJobs() {
return this.summary.running;
},
errorJobs() {
const { failed = 0, error = 0 } = this.summary;
return failed + error;
},
waitingJobs() {
const { waiting = 0, queued = 0 } = this.summary;
return waiting + queued;
},
},
};
</script>
@@ -1,6 +0,0 @@
import DatasetCollection from "./DatasetCollection";
export { default as DscUI } from "./DscUI";
export { default as DscMenu } from "./DscMenu";
export { default as JobStateProgress } from "./JobStateProgress";
export default DatasetCollection;
@@ -1,38 +0,0 @@
<template>
<div>
<div v-for="contentItem in collectionContents" :index="1" :key="contentItem.id" style="margin-left: 2em">
<DatasetProvider
v-if="contentItem.element_type === 'hda'"
:id="contentItem.object.id"
v-slot="{ result: item, loading }">
<div>
<loading-span v-if="loading" message="Loading datasets" />
<DatasetUIWrapper v-else :item="item" :element_identifier="contentItem.element_identifier">
</DatasetUIWrapper>
</div>
</DatasetProvider>
<DatasetCollectionUIWrapper
v-if="contentItem.element_type === 'dataset_collection'"
:item="contentItem"
:element_count="collectionContents.length + 1">
</DatasetCollectionUIWrapper>
</div>
</div>
</template>
<script>
import { DatasetProvider } from "components/providers";
import DatasetUIWrapper from "./DatasetUIWrapper";
import LoadingSpan from "components/LoadingSpan";
export default {
components: {
DatasetProvider,
DatasetCollectionUIWrapper: () => import("./DatasetCollectionUIWrapper"),
DatasetUIWrapper,
LoadingSpan,
},
props: {
collectionContents: { type: Array, required: true },
},
};
</script>
@@ -1,59 +0,0 @@
import DatasetCollectionUIWrapper from "./DatasetCollectionUIWrapper";
import DscUI from "components/History/ContentItem/DatasetCollection/DscUI";
import { shallowMount } from "@vue/test-utils";
import datasetCollectionRaw from "components/providers/test/json/DatasetCollection.json";
import datasetCollectionContent from "components/providers/test/json/DatasetCollection.nested.json";
describe("DatasetUIWrapper.vue with Dataset", () => {
let wrapper;
let propsData;
let onHide;
let onUnhide;
let onDelete;
let onUndelete;
beforeEach(async () => {
propsData = {
item: datasetCollectionRaw,
};
onHide = jest.spyOn(DatasetCollectionUIWrapper.methods, "onHide").mockImplementation();
onUnhide = jest.spyOn(DatasetCollectionUIWrapper.methods, "onUnhide").mockImplementation();
onDelete = jest.spyOn(DatasetCollectionUIWrapper.methods, "onDelete").mockImplementation();
onUndelete = jest.spyOn(DatasetCollectionUIWrapper.methods, "onUndelete").mockImplementation();
wrapper = shallowMount(DatasetCollectionUIWrapper, {
propsData,
});
});
it("loads DscUI component", async () => {
expect(wrapper.findComponent(DscUI).exists()).toBeTruthy();
});
it("reacts to all emittted events", async () => {
expect(onHide).not.toHaveBeenCalled();
expect(onUnhide).not.toHaveBeenCalled();
expect(onDelete).not.toHaveBeenCalled();
expect(onUndelete).not.toHaveBeenCalled();
wrapper.findComponent(DscUI).vm.$emit("hide");
expect(onHide).toHaveBeenCalled();
wrapper.findComponent(DscUI).vm.$emit("unhide");
expect(onUnhide).toHaveBeenCalled();
wrapper.findComponent(DscUI).vm.$emit("delete");
expect(onDelete).toHaveBeenCalled();
wrapper.findComponent(DscUI).vm.$emit("undelete");
expect(onUndelete).toHaveBeenCalled();
});
it("creates DatasetCollection", async () => {
const datasetCollection = wrapper.vm.datasetCollection;
expect(datasetCollection.collectionType).toBe("list of pairs");
});
it("manages collection expansion", async () => {
expect(wrapper.vm.expand).toBeFalsy();
wrapper.findComponent(DscUI).vm.$emit("viewCollection", wrapper.vm.datasetCollection);
expect(wrapper.vm.expand).toBeTruthy();
});
it("build dsc from collection content", async () => {
await wrapper.setProps({ item: datasetCollectionContent });
expect(wrapper.vm.datasetCollection.name).toBe(datasetCollectionContent.element_identifier);
});
});
@@ -1,73 +0,0 @@
<template>
<div>
<DscUI
v-if="datasetCollection"
v-bind="$attrs"
v-on="$listeners"
:dsc="datasetCollection"
:show-tags="true"
@viewCollection="toggleExpand"
@hide="onHide(datasetCollection)"
@unhide="onUnhide(datasetCollection)"
@delete="onDelete(datasetCollection, $event)"
@undelete="onUndelete(datasetCollection)" />
<DatasetCollectionContentProvider v-if="expand" :id="datasetCollection.contents_url" v-slot="{ item, loading }">
<b-spinner v-if="loading">Loading Dataset Collection...</b-spinner>
<DatasetCollectionContents v-else :collection-contents="item" />
</DatasetCollectionContentProvider>
</div>
</template>
<script>
import { deleteDatasetCollection, updateContentFields } from "../../model/queries";
import { DatasetCollection } from "../../model/DatasetCollection";
import DscUI from "components/History/ContentItem/DatasetCollection/DscUI";
import { DatasetCollectionContentProvider } from "components/providers";
import DatasetCollectionContents from "./DatasetCollectionContents";
export default {
components: {
DatasetCollectionContentProvider,
DatasetCollectionContents,
DscUI,
},
props: {
item: { type: Object, required: true },
element_count: { type: Number, required: false },
},
data() {
return { expand: false };
},
computed: {
datasetCollection() {
var rawObject = this.item;
if (rawObject.model_class === "DatasetCollectionElement") {
rawObject = this.item.object;
rawObject.name = this.item.element_identifier;
rawObject.contents_url = this.item.contents_url || rawObject.contents_url;
rawObject.element_count = this.element_count;
rawObject.populated_state = "ok";
}
return new DatasetCollection(rawObject);
},
},
methods: {
toggleExpand() {
this.expand = !this.expand;
},
async onDelete(collection, flags = {}) {
const { recursive = false, purge = false } = flags;
await deleteDatasetCollection(collection, recursive, purge);
},
async onUndelete(collection) {
await updateContentFields(collection, { deleted: false });
},
async onHide(collection) {
await updateContentFields(collection, { visible: false });
},
async onUnhide(collection) {
await updateContentFields(collection, { visible: true });
},
},
};
</script>
@@ -1,51 +0,0 @@
import DatasetUIWrapper from "./DatasetUIWrapper";
import DatasetUI from "components/History/ContentItem/Dataset/DatasetUI";
import { shallowMount } from "@vue/test-utils";
import raw from "components/providers/test/json/Dataset.json";
describe("DatasetUIWrapper.vue with Dataset", () => {
let wrapper;
let propsData;
let onDelete;
let onUnhide;
let onUndelete;
let updateDataset;
beforeEach(async () => {
propsData = {
item: raw,
};
onDelete = jest.spyOn(DatasetUIWrapper.methods, "onDelete").mockImplementation();
onUnhide = jest.spyOn(DatasetUIWrapper.methods, "onUnhide").mockImplementation();
onUndelete = jest.spyOn(DatasetUIWrapper.methods, "onUndelete").mockImplementation();
updateDataset = jest.spyOn(DatasetUIWrapper.methods, "onUpdate").mockImplementation();
wrapper = shallowMount(DatasetUIWrapper, {
propsData,
});
});
it("loads DatasetUI component", async () => {
expect(wrapper.findComponent(DatasetUI).exists()).toBeTruthy();
});
it("reacts to all emittted events", async () => {
expect(onDelete).not.toHaveBeenCalled();
expect(onUnhide).not.toHaveBeenCalled();
expect(onUndelete).not.toHaveBeenCalled();
expect(updateDataset).not.toHaveBeenCalled();
wrapper.findComponent(DatasetUI).vm.$emit("delete");
expect(onDelete).toHaveBeenCalled();
wrapper.findComponent(DatasetUI).vm.$emit("undelete");
expect(onUndelete).toHaveBeenCalled();
wrapper.findComponent(DatasetUI).vm.$emit("unhide");
expect(onUnhide).toHaveBeenCalled();
wrapper.findComponent(DatasetUI).vm.$emit("update");
expect(updateDataset).toHaveBeenCalled();
});
it("handles element identifier", async () => {
expect(wrapper.vm.dataset.canEditName).toBeTruthy();
await wrapper.setProps({ element_identifier: "a cool identifier" });
expect(wrapper.vm.dataset.title).toBe("a cool identifier");
expect(wrapper.vm.dataset.canEditName).toBeFalsy();
});
});
@@ -1,59 +0,0 @@
<!-- controls display of DisplayUI and delegates modifications -->
<template>
<DatasetUI
v-if="dataset"
v-bind="$attrs"
v-on="$listeners"
@update:expanded="expand"
:expanded="expanded"
:dataset="dataset"
:show-tags="true"
@delete="onDelete"
@undelete="onUndelete"
@unhide="onUnhide"
@update="onUpdate">
</DatasetUI>
</template>
<script>
import DatasetUI from "components/History/ContentItem/Dataset/DatasetUI";
import { Dataset } from "../../model";
import { deleteContent, updateContentFields } from "../../model/queries";
export default {
components: {
DatasetUI,
},
props: {
item: { type: Object, required: true },
element_identifier: { type: String, required: false, default: "" },
},
computed: {
dataset() {
const element = { ...this.item, element_identifier: this.element_identifier };
return new Dataset(element);
},
},
data() {
return {
expanded: false,
};
},
methods: {
expand(event) {
this.expanded = !this.expanded;
},
async onDelete(opts = {}) {
await deleteContent(this.dataset, opts);
},
async onUnhide() {
await this.onUpdate({ visible: true });
},
async onUndelete() {
await this.onUpdate({ deleted: false });
},
async onUpdate(changes = {}) {
await updateContentFields(this.dataset, changes);
},
},
};
</script>
@@ -1,42 +0,0 @@
<template>
<component :is="providerComponent" :id="data_item.id" auto-refresh v-slot="{ result: item, loading }">
<div>
<loading-span v-if="loading" message="Loading dataset" />
<component :is="renderComponent" v-if="item" :item="item"> </component>
</div>
</component>
</template>
<script>
import { DatasetProvider, DatasetCollectionProvider } from "components/providers";
import DatasetCollectionUIWrapper from "./DatasetCollectionUIWrapper";
import DatasetUIWrapper from "./DatasetUIWrapper";
import { STATES } from "components/History/model";
import LoadingSpan from "components/LoadingSpan";
export default {
components: {
DatasetProvider,
DatasetCollectionProvider,
DatasetUIWrapper,
DatasetCollectionUIWrapper,
LoadingSpan,
},
props: {
data_item: {
required: true,
},
},
computed: {
renderComponent() {
return { hda: "DatasetUIWrapper", hdca: "DatasetCollectionUIWrapper" }[this.data_item.src];
},
providerComponent() {
return { hda: "DatasetProvider", hdca: "DatasetCollectionProvider" }[this.data_item.src];
},
},
provide: {
STATES,
},
};
</script>
@@ -1,22 +0,0 @@
import ContentItem from "./ContentItem";
export default {
mixins: [ContentItem],
computed: {
contentItemComponent() {
if (this.item.id === undefined) {
return "Placeholder";
}
const { history_content_type } = this.item;
switch (history_content_type) {
case "dataset":
return "Dataset";
case "dataset_collection":
return "DatasetCollection";
default:
return "Placeholder";
}
},
},
};
@@ -1,18 +0,0 @@
<template>
<div class="collapsed" data-state="waiting">
<nav class="content-top-menu d-flex align-items-center justify-content-between p-1">
<h5 class="flex-grow-1 overflow-hidden mr-auto text-nowrap text-truncate">
<span class="name">Placholder: {{ item.hid }}</span>
</h5>
</nav>
</div>
</template>
<script>
export default {
props: {
item: { type: Object, required: true },
index: { type: Number, required: true },
},
};
</script>
@@ -1,29 +0,0 @@
<!-- a separate collection content item for collections
inside of other collections -->
<template>
<DscUI v-if="dsc" v-bind="$attrs" v-on="$listeners" class="dataset-collection history-content" :dsc="dsc" />
</template>
<script>
import { DatasetCollection } from "../model/DatasetCollection";
import { STATES } from "../model";
import DscUI from "./DatasetCollection/DscUI";
export default {
components: {
DscUI,
},
props: {
item: { type: Object, required: true },
},
computed: {
dsc() {
// TODO: I'm sure this is not the right way to set the state, but the collection api
// doesn't return any kind of state value at because the api is inconsistent
// /api/dataset_collections/d071e794759ab192/contents/e144222fed44799a
return new DatasetCollection({ ...this.item, populated_state: STATES.OK });
},
},
};
</script>
@@ -1,61 +0,0 @@
<!-- a read-only dataset inside a collection. The collection query does not return enough
information to properly render the dataset, so what we do is look it up on expand, checking the
cache first to see if we already have the data -->
<template>
<DatasetUI
v-if="dataset"
v-bind="$attrs"
v-on="$listeners"
:dataset="dataset"
:writable="false"
:selectable="false"
@update:expanded="onExpand" />
</template>
<script>
import { Dataset } from "../model";
import { getContentDetails } from "../model/queries";
import DatasetUI from "./Dataset/DatasetUI";
export default {
components: {
DatasetUI,
},
props: {
item: { type: Object, required: true },
},
data() {
return {
datasetObject: this.extractDataset(this.item),
loading: false,
};
},
computed: {
dataset() {
return new Dataset({ ...this.datasetObject });
},
},
methods: {
extractDataset() {
return {
id: this.item.object.id,
element_identifier: this.item.element_identifier,
state: this.item.object.state,
history_id: this.item.object.history_id,
history_content_type: "dataset",
visible: true,
};
},
onExpand(val) {
if (val) {
this.loadDetails();
}
this.$emit("update:expand", val);
},
async loadDetails() {
this.datasetObject = await getContentDetails(this.datasetObject);
},
},
};
</script>
@@ -1,9 +0,0 @@
/**
* Scrolling components for history contents and collection contents.
*/
import "./styles.scss";
export { default as ContentItem } from "./ContentItem";
export { default as CollectionContentItem } from "./CollectionContentItem";
export { default as HistoryContentItem } from "./HistoryContentItem";
@@ -1,32 +0,0 @@
@import "theme/blue.scss";
@import "scss/mixins.scss";
.content-item {
outline: none;
border-bottom: 2px solid white;
&:focus-within {
box-shadow: inset 0 0 1em 0.25em rgba(255, 255, 255, 0.25);
}
}
// Nav across the top of the content item
.content-item .content-top-menu {
min-height: 2em;
.content-title {
.hid {
color: adjust-color($text-color, $alpha: -0.6);
font-size: 80%;
user-select: none;
display: inline-block;
text-align: right;
min-width: 0.25rem;
&:not(:empty)::after {
content: ":";
}
}
.name {
font-weight: 700;
}
}
}
@@ -0,0 +1,34 @@
<template>
<Details
:name="name"
:tags="dsc.tags"
:writeable="writeable"
:show-annotation="false"
@save="$emit('update:dsc', $event)">
<template v-slot:name>
<h3 data-description="collection name display">{{ name || "Collection" }}</h3>
<CollectionDescription :item="dsc" />
</template>
</Details>
</template>
<script>
import Details from "components/History/Layout/Details";
import CollectionDescription from "components/History/Content/Collection/CollectionDescription";
export default {
components: {
CollectionDescription,
Details,
},
props: {
dsc: { type: Object, required: true },
writeable: { type: Boolean, required: true },
},
computed: {
name() {
return this.dsc.name || this.dsc.element_identifier;
},
},
};
</script>
@@ -0,0 +1,37 @@
<template>
<div>
<b-button size="sm" variant="link" @click="close">
<icon icon="angle-double-left" class="mr-1" data-description="back to history" /><span>History</span>
</b-button>
<b-button size="sm" variant="link" v-if="previousName" @click="back">
<span class="fa fa-angle-left mr-1" /><span>{{ previousName }}</span>
</b-button>
</div>
</template>
<script>
export default {
props: {
selectedCollections: { type: Array, required: true, validate: (val) => val.length > 0 },
},
computed: {
previousName() {
const length = this.selectedCollections.length;
if (length > 1) {
const last = this.selectedCollections[length - 2];
return last.name || last.element_identifier;
}
return null;
},
},
methods: {
back() {
const newList = this.selectedCollections.slice(0, -1);
this.$emit("update:selected-collections", newList);
},
close() {
this.$emit("update:selected-collections", []);
},
},
};
</script>
@@ -1,32 +1,25 @@
<template>
<section>
<nav class="d-flex justify-content-end">
<nav class="content-operations d-flex justify-content-between bg-secondary">
<b-button-group>
<IconButton
v-if="isRoot"
class="mb-2 mx-3"
icon="download"
title="Download Collection"
:href="downloadCollectionUrl"
download />
<b-button title="Download Collection" size="sm" variant="link" download :href="downloadUrl">
<Icon class="mr-1" icon="download" />
<span>Download</span>
</b-button>
</b-button-group>
</nav>
</section>
</template>
<script>
import IconButton from "components/IconButton";
export default {
components: { IconButton },
props: {
isRoot: { type: Boolean, required: true },
collection: { type: Object, required: true },
dsc: { type: Object, required: true },
},
computed: {
/** @return {String} */
downloadCollectionUrl() {
return `${this.collection.url}/download`;
downloadUrl() {
return `${this.dsc.url}/download`;
},
},
};
@@ -0,0 +1,106 @@
<!-- When a dataset collection is being viewed, this panel shows the contents of that collection -->
<template>
<UrlDataProvider v-if="dsc" :key="dsc.id" :url="url" auto-refresh v-slot="{ result: payload }">
<ExpandedItems :scope-key="dsc.id" :get-item-key="(item) => item.id" v-slot="{ isExpanded, setExpanded }">
<Layout class="dataset-collection-panel">
<template v-slot:navigation>
<CollectionNavigation
:history="history"
:selected-collections="selectedCollections"
v-on="$listeners" />
</template>
<template v-slot:listcontrols>
<CollectionOperations v-if="isRoot" :dsc="dsc" />
</template>
<template v-slot:details>
<CollectionDetails :dsc="dsc" :writeable="isRoot" @update:dsc="updateDsc(dsc, $event)" />
</template>
<template v-slot:listing>
<Listing item-key="element_index" :payload="payload" :limit="limit" @scroll="onScroll">
<template v-slot:history-item="{ item }">
<ContentItem
:item="item.object"
:id="item.element_index"
:name="item.element_identifier"
:is-dataset="item.element_type == 'hda'"
:expand-dataset="isExpanded(item)"
:is-history-item="false"
@update:expand-dataset="setExpanded(item, $event)"
@view-collection="$emit('view-collection', item)" />
</template>
</Listing>
</template>
</Layout>
</ExpandedItems>
</UrlDataProvider>
</template>
<script>
import { UrlDataProvider } from "components/providers/UrlDataProvider";
import { History } from "components/History/model";
import { updateContentFields } from "components/History/model/queries";
import ContentItem from "components/History/Content/ContentItem";
import ExpandedItems from "components/History/Content/ExpandedItems";
import Listing from "components/History/Layout/Listing";
import Layout from "components/History/Layout/Layout";
import CollectionNavigation from "./CollectionNavigation";
import CollectionOperations from "./CollectionOperations";
import CollectionDetails from "./CollectionDetails";
export default {
components: {
CollectionDetails,
CollectionNavigation,
CollectionOperations,
ContentItem,
ExpandedItems,
Layout,
Listing,
UrlDataProvider,
},
props: {
history: { type: History, required: true },
selectedCollections: { type: Array, required: true },
},
data() {
return {
offset: 0,
limit: 100,
};
},
computed: {
dsc() {
const arr = this.selectedCollections;
return arr[arr.length - 1];
},
isRoot() {
return this.dsc == this.rootCollection;
},
rootCollection() {
return this.selectedCollections[0];
},
url() {
// either the observered source is a collection element or the initial history item i.e. root collection
const source = this.dsc.object || this.rootCollection;
const contentsUrl = source.contents_url.substring(1);
return `${contentsUrl}?offset=${this.offset}&limit=${this.limit}`;
},
},
methods: {
updateDsc(collection, fields) {
updateContentFields(collection, fields).then((response) => {
Object.keys(response).forEach((key) => {
collection[key] = response[key];
});
});
},
onScroll(offset) {
this.offset = offset;
},
},
};
</script>
@@ -1,128 +0,0 @@
<!-- Edits a collection, if that collection is editable. -->
<template>
<section class="history-details d-flex" data-description="edit details">
<div class="flex-grow-1">
<h3 class="history-title" data-description="collection name display">
{{ dscName || "(Collection Name)" }}
</h3>
<p class="mt-1">
<DscDescription :dsc="dsc" />
</p>
<div v-if="isEditing" class="mt-3" @keydown.esc="revertAndCancel" data-description="edit form">
<b-textarea
v-model="dscName"
placeholder="Collection Name"
trim
max-rows="4"
data-description="name input"></b-textarea>
<StatelessTags v-model="tags" class="mt-3 tags" />
</div>
<div v-else-if="dsc.tags && dsc.tags.length">
<Nametag v-for="tag in dsc.tags" :key="tag" :tag="tag" />
</div>
</div>
<EditorMenu
class="ml-3 flex-grow-0 d-flex flex-column"
v-if="writable"
model-name="Collection"
:editing.sync="editing"
:writable="writable"
:valid="valid"
:dirty="dirty"
@save="save"
@revert="revert" />
</section>
</template>
<script>
import { DatasetCollection } from "../model";
import { Nametag } from "components/Nametags";
import EditorMenu from "../EditorMenu";
import { StatelessTags } from "components/Tags";
import DscDescription from "components/History/ContentItem/DatasetCollection/DscDescription";
export default {
components: {
Nametag,
EditorMenu,
StatelessTags,
DscDescription,
},
props: {
dsc: { type: DatasetCollection, required: true },
writable: { type: Boolean, required: true },
},
data() {
return {
editing: false,
tempContent: this.dsc.clone(),
};
},
computed: {
dscId() {
return this.dsc.id;
},
isEditing() {
return this.writable && this.editing;
},
valid() {
return this.tempContent.name.length > 0;
},
dirty() {
return !this.tempContent.equals(this.dsc);
},
dscName: {
get() {
return this.tempContent.name;
},
set(newName) {
this.patchTempDsc({ name: newName });
},
},
tags: {
get() {
return this.tempContent?.tags || [];
},
set(newTags) {
if (Array.isArray(newTags)) {
this.patchTempDsc({ tags: newTags });
}
},
},
},
watch: {
dscId(newId, oldId) {
if (newId != oldId) {
this.reset();
}
},
},
methods: {
reset() {
this.tempContent = this.dsc.clone();
},
patchTempDsc(props = {}) {
this.tempContent = this.tempContent.patch(props);
},
save() {
if (this.valid) {
this.$emit("update:dsc", this.tempContent);
this.editing = false;
}
},
revert() {
if (this.dirty) {
this.reset();
}
},
revertAndCancel() {
this.revert();
this.editing = false;
},
},
};
</script>
@@ -1,126 +0,0 @@
<!-- When a dataset collection is being viewed, this panel shows the contents of that collection -->
<template>
<UrlDataProvider v-if="dsc" :url="getUrl(selectedCollection)" v-slot="{ result: payload }">
<ExpandedItems
:scope-key="selectedCollection.id"
:get-item-key="(item) => item.element_index"
v-slot="{ isExpanded, setExpanded }">
<Layout class="dataset-collection-panel">
<template v-slot:globalnav>
<TopNav :history="history" :selected-collections="selectedCollections" v-on="$listeners" />
</template>
<template v-slot:localnav>
<!-- Empty -->
<div />
</template>
<template v-slot:listcontrols>
<CollectionOperations :collection="selectedCollection" :is-root="isRoot" />
</template>
<template v-slot:details>
<Details :dsc="dsc" :writable="writable" @update:dsc="updateDsc(dsc, $event)" />
</template>
<template v-slot:listing>
<HistoryListing
item-key="element_index"
:page-size="pageSize"
:payload="payload"
@scroll="onScroll">
<template v-slot:history-item="{ item, index }">
<CollectionContentItem
:item="item"
:index="index"
:expanded="isExpanded(item)"
:writable="false"
:selectable="false"
@update:expanded="setExpanded(item, $event)"
@viewCollection="$emit('viewCollection', item)" />
</template>
</HistoryListing>
</template>
</Layout>
</ExpandedItems>
</UrlDataProvider>
</template>
<script>
import { DatasetCollection } from "../model";
import { History } from "../model";
import { updateContentFields } from "../model/queries";
import ExpandedItems from "../ExpandedItems";
import Layout from "../Layout";
import TopNav from "./TopNav";
import CollectionOperations from "./CollectionOperations";
import Details from "./Details";
import { CollectionContentItem } from "../ContentItem";
import HistoryListing from "components/History/HistoryListing";
import { UrlDataProvider } from "components/providers/UrlDataProvider";
export default {
components: {
UrlDataProvider,
Layout,
TopNav,
Details,
CollectionContentItem,
ExpandedItems,
CollectionOperations,
HistoryListing,
},
props: {
history: { type: History, required: true },
selectedCollections: { type: Array, required: true },
},
data() {
return {
pageSize: 50,
};
},
created() {
this.dsc = new DatasetCollection(this.selectedCollection);
},
computed: {
selectedCollection() {
const arr = this.selectedCollections;
const selected = arr[arr.length - 1];
return selected;
},
isRoot() {
return this.selectedCollection == this.rootCollection;
},
writable() {
return this.isRoot;
},
rootCollection() {
return this.selectedCollections[0];
},
downloadCollectionUrl() {
let url = "";
if (this.rootCollection) {
url = `${this.rootCollection.url}/download`;
}
return url;
},
},
methods: {
getUrl(dsc) {
return dsc.contents_url.substring(1);
},
// change the data of the root collection, anything past the root
// collection is part of the dataset collection, which i believe is supposed to
// be immutable, so only edit name, tags, blah of top-level selected collection,
async updateDsc(collection, fields) {
if (this.writable) {
await updateContentFields(collection, fields);
}
},
onScroll(newHid) {
this.maxHid = newHid;
},
},
};
</script>
@@ -1,71 +0,0 @@
<template>
<b-dropdown
v-if="breadCrumbOptions.length"
size="sm"
text="Return to..."
boundary="viewport"
data-description="collection breadcrumbs menu"
no-caret>
<template v-slot:button-content>
<Icon icon="arrow-up" class="mr-1" />
<b class="text-nowrap" v-localize>Return to...</b>
</template>
<b-dropdown-item @click="close" data-description="back to history">
<span>History: {{ history.name }}</span>
</b-dropdown-item>
<b-dropdown-item v-for="option in breadCrumbOptions" :key="option.key" @click="reselect(option.value)">
<span>{{ option.text }}</span>
</b-dropdown-item>
</b-dropdown>
<b-button
v-else
class="back"
size="sm"
title="`Return to: ${history.name}`"
@click="close"
data-description="back to history">
<Icon icon="arrow-up" class="mr-1" />
<b class="text-nowrap">Return to: {{ history.name }}</b>
</b-button>
</template>
<script>
import { History } from "../model";
export default {
props: {
history: { type: History, required: true },
selectedCollections: { type: Array, required: true, validate: (val) => val.length > 0 },
},
computed: {
// List for the dropdown. Should be the history and every parent
// collection excepting the current one.
breadCrumbOptions() {
const parents = this.selectedCollections.slice(0, -1);
const options = parents.map((bc, i) => {
return {
key: bc.type_id,
value: parents.slice(0, i + 1),
text: bc.name,
};
});
return options;
},
},
methods: {
reselect(newList) {
this.$emit("update:selectedCollections", newList);
},
back() {
const newList = this.selectedCollections.slice(0, -1);
this.reselect(newList);
},
close() {
this.reselect([]);
},
},
};
</script>
@@ -0,0 +1,37 @@
<template>
<Details
:name="history.name"
:annotation="history.annotation"
:tags="history.tags"
:writeable="writeable"
@save="$emit('update:currentHistory', $event)">
<template v-slot:name>
<h3 data-description="history name display">{{ history.name || "History" }}</h3>
<h5 class="history-size">
<span v-if="history.size">{{ history.size | niceFileSize }}</span>
<span v-else v-localize>(empty)</span>
</h5>
</template>
</Details>
</template>
<script>
import prettyBytes from "pretty-bytes";
import { History } from "components/History/model";
import Details from "components/History/Layout/Details";
export default {
components: {
Details,
},
filters: {
niceFileSize(rawSize = 0) {
return prettyBytes(rawSize);
},
},
props: {
history: { type: History, required: true },
writeable: { type: Boolean, default: true },
},
};
</script>
@@ -106,8 +106,8 @@
</template>
<script>
import { History } from "./model";
import CopyHistoryModal from "./CopyModal";
import { History } from "components/History/model";
import CopyHistoryModal from "components/History/Modals/CopyModal";
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
export default {
@@ -13,7 +13,7 @@
</template>
<script>
import { History } from "./model";
import { History } from "components/History/model";
export default {
props: {
@@ -61,10 +61,10 @@
</template>
<script>
import { History } from "./model";
import { History } from "components/History/model";
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
import { switchToLegacyHistoryPanel } from "./adapters/betaToggle";
import HistorySelectorModal from "./HistorySelectorModal";
import { switchToLegacyHistoryPanel } from "components/History/adapters/betaToggle";
import HistorySelectorModal from "components/History/Modals/HistorySelectorModal";
export default {
mixins: [legacyNavigationMixin],
@@ -1,117 +1,103 @@
<template>
<section>
<nav class="content-operations-menu d-flex justify-content-between bg-secondary">
<nav class="content-operations d-flex justify-content-between bg-secondary">
<b-button-group>
<IconButton
<b-button
title="Select Items"
class="show-history-content-selectors-btn"
icon="check-square"
class="show-history-content-selectors-btn rounded-0"
size="sm"
variant="link"
:disabled="!hasMatches"
:pressed="showSelection"
@click="toggleSelection" />
<IconButton
title="Filter Items"
icon="filter"
@click="toggleSelection">
<Icon icon="check-square" />
</b-button>
<b-button
title="Search Items"
class="rounded-0"
size="sm"
variant="link"
:pressed="showFilter"
@click="toggleFilter"
data-description="content filter toggle" />
<IconButton
data-description="content filter toggle">
<Icon icon="search" />
</b-button>
<b-button
title="Collapse Items"
icon="compress"
class="rounded-0"
size="sm"
variant="link"
:disabled="!expandedCount"
@click="$emit('collapse-all')" />
@click="$emit('collapse-all')">
<Icon icon="compress" />
</b-button>
</b-button-group>
<b-button-group>
<b-dropdown
class="history-contents-list-action-menu-btn"
size="sm"
text="Selection"
:disabled="!hasSelection"
data-description="selected content menu">
<b-dropdown-text id="history-op-selected-content">
<span v-localize v-if="hasSelection">With {{ numSelected }} selected items...</span>
<span v-localize v-else>You don't have any selected content.</span>
size="sm"
variant="link"
toggle-class="text-decoration-none"
data-description="selected content menu"
:disabled="!hasSelection">
<b-dropdown-text>
<span v-localize>With {{ numSelected }} selected...</span>
</b-dropdown-text>
<b-dropdown-item aria-describedby="history-op-selected-content" v-b-modal:hide-selected-content>
<span v-localize>Hide</span>
</b-dropdown-item>
<b-dropdown-item aria-describedby="history-op-selected-content" v-b-modal:show-selected-content>
<b-dropdown-item v-if="params.showHidden" v-b-modal:show-selected-content>
<span v-localize>Unhide</span>
</b-dropdown-item>
<b-dropdown-item aria-describedby="history-op-selected-content" v-b-modal:delete-selected-content>
<span v-localize>Delete</span>
<b-dropdown-item v-else v-b-modal:hide-selected-content>
<span v-localize>Hide</span>
</b-dropdown-item>
<b-dropdown-item aria-describedby="history-op-selected-content" v-b-modal:restore-selected-content>
<b-dropdown-item v-if="params.showDeleted" v-b-modal:restore-selected-content>
<span v-localize>Undelete</span>
</b-dropdown-item>
<b-dropdown-item
aria-describedby="history-op-selected-content"
v-b-modal:purge-selected-content
:disabled="!hasSelection">
<span v-localize>Permanently Delete</span>
<b-dropdown-item v-else v-b-modal:delete-selected-content>
<span v-localize>Delete</span>
</b-dropdown-item>
<b-dropdown-item
aria-describedby="history-op-selected-content"
@click="buildDatasetList"
data-description="build list">
<b-dropdown-item v-if="!params.showDeleted" v-b-modal:purge-selected-content>
<span v-localize>Delete (permanently)</span>
</b-dropdown-item>
<b-dropdown-item v-if="showBuildOptions" @click="buildDatasetList" data-description="build list">
<span v-localize>Build Dataset List</span>
</b-dropdown-item>
<b-dropdown-item
aria-describedby="history-op-selected-content"
@click="buildDatasetPair"
data-description="build pair">
<b-dropdown-item v-if="showBuildOptions" @click="buildDatasetPair" data-description="build pair">
<span v-localize>Build Dataset Pair</span>
</b-dropdown-item>
<b-dropdown-item
aria-describedby="history-op-selected-content"
v-if="showBuildOptions"
@click="buildListOfPairs"
data-description="build list of pairs">
<span v-localize>Build List of Dataset Pairs</span>
</b-dropdown-item>
<b-dropdown-item
aria-describedby="history-op-selected-content"
v-if="showBuildOptions"
@click="buildCollectionFromRules"
data-description="build collection from rules">
<span v-localize>Build Collection from Rules</span>
</b-dropdown-item>
</b-dropdown>
<b-dropdown
class="history-contents-list-action-menu-btn"
size="sm"
text="History"
:disabled="!hasMatches"
data-description="history action menu">
size="sm"
variant="link"
class="rounded-0"
toggle-class="text-decoration-none"
data-description="history action menu"
:disabled="!hasMatches">
<b-dropdown-text id="history-op-all-content">
<span v-localize>With entire history...</span>
</b-dropdown-text>
<b-dropdown-item
aria-describedby="history-op-all-content"
@click="iframeRedirect('/dataset/copy_datasets')"
data-description="copy datasets">
<b-dropdown-item @click="iframeRedirect('/dataset/copy_datasets')" data-description="copy datasets">
<span v-localize>Copy Datasets</span>
</b-dropdown-item>
<b-dropdown-item v-b-modal:show-all-hidden-content aria-describedby="history-op-all-content">
<b-dropdown-item v-b-modal:show-all-hidden-content>
<span v-localize>Unhide All Hidden Content</span>
</b-dropdown-item>
<b-dropdown-item v-b-modal:delete-all-hidden-content aria-describedby="history-op-all-content">
<b-dropdown-item v-b-modal:delete-all-hidden-content>
<span v-localize>Delete All Hidden Content</span>
</b-dropdown-item>
<b-dropdown-item v-b-modal:purge-all-deleted-content aria-describedby="history-op-all-content">
<b-dropdown-item v-b-modal:purge-all-deleted-content>
<span v-localize>Purge All Hidden Content</span>
</b-dropdown-item>
</b-dropdown>
@@ -119,7 +105,7 @@
</nav>
<transition name="shutterfade">
<content-filters v-if="showFilter" class="content-filters p-2" :params.sync="localParams" />
<history-filters v-if="showFilter" class="content-operations-filters p-2" :params.sync="localParams" />
</transition>
<b-modal id="hide-selected-content" title="Hide Selected Content?" title-tag="h2" @ok="hideSelected">
@@ -157,7 +143,7 @@
</template>
<script>
import { History } from "./model/History";
import { History } from "components/History/model/History";
import {
hideSelectedContent,
unhideSelectedContent,
@@ -167,18 +153,16 @@ import {
unhideAllHiddenContent,
deleteAllHiddenContent,
purgeAllDeletedContent,
} from "./model";
import { createDatasetCollection } from "./model/queries";
} from "components/History/model";
import { createDatasetCollection } from "components/History/model/queries";
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
import { buildCollectionModal } from "./adapters/buildCollectionModal";
import ContentFilters from "./ContentFilters";
import IconButton from "components/IconButton";
import { buildCollectionModal } from "components/History/adapters/buildCollectionModal";
import HistoryFilters from "./HistoryFilters";
export default {
mixins: [legacyNavigationMixin],
components: {
ContentFilters,
IconButton,
HistoryFilters,
},
props: {
history: { type: History, required: true },
@@ -194,7 +178,10 @@ export default {
};
},
computed: {
hasContentFilters() {
showBuildOptions() {
return !this.params.showHidden && !this.params.showDeleted;
},
hasHistoryFilters() {
const { showHidden, showDeleted, filterText } = this.params;
return showHidden || showDeleted || filterText;
},
@@ -212,9 +199,6 @@ export default {
hasSelection() {
return this.numSelected > 0;
},
countHidden() {
return this.history.contents_active.hidden;
},
},
methods: {
toggleSelection() {
@@ -302,7 +286,7 @@ export default {
<style lang="scss">
// remove borders around buttons in menu
.content-operations-menu .btn-group .btn {
.content-operations .btn-group .btn {
border-color: transparent !important;
}
</style>
@@ -17,11 +17,8 @@
resetSelection,
}">
<Layout>
<template v-slot:globalnav>
<slot name="globalnav" :history="history" />
</template>
<template v-slot:localnav>
<template v-slot:navigation>
<slot name="navigation" :history="history" />
<HistoryMenu :history="history" v-on="$listeners" />
</template>
@@ -34,7 +31,7 @@
</template>
<template v-slot:listcontrols>
<ContentOperations
<HistoryOperations
:history="history"
:params.sync="params"
:content-selection="selectedItems"
@@ -44,7 +41,7 @@
@update:content-selection="selectItems"
@update:show-selection="setShowSelection"
@reset-selection="resetSelection"
@hide-selection="onHiddenItems"
@hide-selection="onHideSelection"
@select-all="selectItems(payload)"
@collapse-all="collapseAll" />
</template>
@@ -54,24 +51,31 @@
<b-alert v-else-if="loading" class="m-2" variant="info" show>
<LoadingSpan message="Loading History" />
</b-alert>
<HistoryListing
<Listing
v-else
reversed
:query-key="queryKey"
:page-size="pageSize"
:limit="limit"
:payload="payload"
@scroll="onScroll">
<template v-slot:history-item="{ item }">
<HistoryContentItem
v-if="!hiddenItems[item.hid]"
<ContentItem
v-if="!hideSelection[item.hid]"
:item="item"
:expanded="isExpanded(item)"
:id="item.hid"
:name="item.name"
:expand-dataset="isExpanded(item)"
:is-dataset="item.history_content_type == 'dataset'"
:selected="isSelected(item)"
:show-selection="showSelection"
@update:expanded="setExpanded(item, $event)"
:selectable="showSelection"
@update:expand-dataset="setExpanded(item, $event)"
@update:selected="setSelected(item, $event)"
@viewCollection="$emit('viewCollection', item)" />
@view-collection="$emit('view-collection', item)"
@delete="onDelete"
@undelete="onUndelete"
@unhide="onUnhide" />
</template>
</HistoryListing>
</Listing>
</template>
<template v-slot:modals>
@@ -84,34 +88,34 @@
</template>
<script>
import { History } from "./model";
import { SearchParams } from "./model/SearchParams";
import LoadingSpan from "components/LoadingSpan";
import { UrlDataProvider } from "components/providers/UrlDataProvider";
import ExpandedItems from "./ExpandedItems";
import SelectedItems from "./SelectedItems";
import Layout from "./Layout";
import HistoryMessages from "./HistoryMessages";
import LoadingSpan from "components/LoadingSpan";
import ContentItem from "components/History/Content/ContentItem";
import { History } from "components/History/model";
import { deleteContent, updateContentFields } from "components/History/model/queries";
import ExpandedItems from "components/History/Content/ExpandedItems";
import SelectedItems from "components/History/Content/SelectedItems";
import Layout from "components/History/Layout/Layout";
import Listing from "components/History/Layout/Listing";
import ToolHelpModal from "components/History/Modals/ToolHelpModal";
import HistoryOperations from "./HistoryOperations";
import HistoryDetails from "./HistoryDetails";
import HistoryEmpty from "./HistoryEmpty";
import ContentOperations from "./ContentOperations";
import ToolHelpModal from "./ToolHelpModal";
import HistoryMenu from "./HistoryMenu";
import HistoryListing from "./HistoryListing";
import { HistoryContentItem } from "./ContentItem";
import HistoryMessages from "./HistoryMessages";
export default {
components: {
LoadingSpan,
UrlDataProvider,
Layout,
HistoryContentItem,
Listing,
ContentItem,
HistoryMessages,
HistoryDetails,
HistoryEmpty,
HistoryMenu,
HistoryListing,
ContentOperations,
HistoryOperations,
ToolHelpModal,
ExpandedItems,
SelectedItems,
@@ -121,24 +125,21 @@ export default {
},
data() {
return {
hiddenItems: {},
maxHid: this.history.hid_counter,
maxNew: 10,
pageSize: 50,
hideSelection: {},
offset: 0,
limit: 500,
params: {},
};
},
watch: {
queryKey() {
this.hiddenItems = {};
this.maxHid = this.history.hid_counter;
this.hideSelection = {};
this.offset = 0;
},
},
computed: {
dataUrl() {
return `api/histories/${this.historyId}/contents/before/${this.maxHid + this.maxNew}/${
this.pageSize
}?view=detailed&${this.queryString}`;
return `api/histories/${this.historyId}/contents?v=dev&order=hid&offset=${this.offset}&limit=${this.limit}&${this.queryString}`;
},
historyId() {
return this.history.id;
@@ -147,21 +148,35 @@ export default {
return `${this.history.id}&${this.queryString}`;
},
queryString() {
return new SearchParams(this.params).historyContentQueryString;
const deleted = this.params.showDeleted ? "True" : "False";
const visible = this.params.showHidden ? "False" : "True";
return `q=deleted&q=visible&qv=${deleted}&qv=${visible}`;
},
},
methods: {
hasMatches(payload) {
return !!payload && payload.length > 0;
},
onScroll(newHid) {
this.maxHid = newHid;
onScroll(offset) {
this.offset = offset;
},
onHiddenItems(selectedItems) {
onHideSelection(selectedItems) {
selectedItems.forEach((item) => {
this.hiddenItems[item.hid] = true;
this.hideSelection[item.hid] = true;
});
},
onDelete(item) {
this.hideSelection[item.hid] = true;
deleteContent(item);
},
onUndelete(item) {
this.hideSelection[item.hid] = true;
updateContentFields(item, { deleted: false });
},
onUnhide(item) {
this.hideSelection[item.hid] = true;
updateContentFields(item, { visible: true });
},
},
};
</script>
@@ -1,35 +0,0 @@
<template>
<CurrentUser class="d-flex flex-column" v-slot="{ user }">
<UserHistories v-if="user" :user="user" v-slot="{ currentHistory, histories, handlers }">
<HistoryPanel v-if="currentHistory" :history="currentHistory" v-on="handlers" id="current-history-panel">
<template v-slot:globalnav>
<HistoriesMenu
v-on="handlers"
:histories="histories"
:current-history="currentHistory"
title="Histories" />
</template>
</HistoryPanel>
<div v-else class="flex-grow-1 loadingBackground h-100">
<span class="sr-only" v-localize>Loading History...</span>
</div>
</UserHistories>
</CurrentUser>
</template>
<script>
import CurrentUser from "components/providers/CurrentUser";
import UserHistories from "components/providers/UserHistories";
import HistoryPanel from "./HistoryPanel";
import HistoriesMenu from "./HistoriesMenu";
export default {
components: {
CurrentUser,
UserHistories,
HistoryPanel,
HistoriesMenu,
},
};
</script>
@@ -1,28 +0,0 @@
<template>
<b-modal ref="deleteModal" title="Delete History" v-model="showModal" title-tag="h2">
<p>Delete?</p>
</b-modal>
</template>
<script>
import { History } from "./model";
export default {
props: {
history: { type: History, required: true },
value: { type: Boolean, required: false, default: false },
},
computed: {
showModal: {
get() {
return this.value;
},
set(newVal, oldVal) {
if (newVal !== oldVal) {
this.$emit("input", newVal);
}
},
},
},
};
</script>
@@ -1,73 +0,0 @@
<template>
<nav class="edit-controls">
<IconButton
:title="label('Edit') | l"
class="edit-button mb-1"
:pressed="editing"
icon="pen"
@click="$emit('update:editing', !editing)"
data-description="editor toggle" />
<template v-if="editing">
<!-- save -->
<IconButton
:title="label('Save') | l"
class="save-button mb-1"
:class="{ disabled: !valid }"
icon="save"
@click="$emit('save')"
data-description="editor save button" />
<!-- revert -->
<IconButton
:title="label('Revert') | l"
class="revert-changes-button mb-1"
:class="{ disabled: !dirty }"
icon="undo"
@click="$emit('revert')"
data-description="editor revert button" />
<!-- form status -->
<IconButton
:title="statusLabel | l"
class="status-button mb-1"
:class="{ valid: valid, disabled: !valid }"
:icon="valid ? 'check' : 'exclamation-triangle'"
variant="link"
data-description="editor status" />
</template>
</nav>
</template>
<script>
import IconButton from "components/IconButton";
export default {
components: {
IconButton,
},
props: {
modelName: { type: String, required: true },
editing: { type: Boolean, required: true },
dirty: { type: Boolean, required: false, default: false },
valid: { type: Boolean, required: false, default: false },
},
computed: {
statusLabel() {
const status = this.valid ? "Valid" : "Invalid";
return `${status} ${this.modelName}`;
},
},
methods: {
label(verb) {
return `${verb} ${this.modelName}`;
},
},
};
</script>
<style lang="scss">
.edit-controls .disabled {
opacity: 0.25;
}
</style>
@@ -1,151 +0,0 @@
<!--
History object editor. Change name, edit tags, description.
Emit new History model object when done via .sync syntax
-->
<template>
<section class="history-details d-flex" data-description="edit details">
<div class="flex-grow-1 overflow-hidden">
<h3 data-description="history name display">{{ historyName || "(History Name)" }}</h3>
<h5 class="history-size">
<span v-if="history.size">{{ history.size | niceFileSize }}</span>
<span v-else v-localize>(empty)</span>
</h5>
<!-- display title, annotation, tags -->
<div v-if="!editing">
<p data-description="history annotation" v-if="annotation" class="mt-1">{{ annotation }}</p>
<StatelessTags class="mt-2 tags" :value="tags" :disabled="true" />
</div>
<!-- edit form, change title, annotation, or tags -->
<div v-else class="mt-3" @keydown.esc="revertAndCancel" data-description="edit form">
<b-textarea
class="mb-2"
v-model="historyName"
placeholder="History Name"
trim
max-rows="4"
data-description="name input"></b-textarea>
<b-textarea
class="mb-2"
v-model="annotation"
placeholder="Annotation (optional)"
trim
max-rows="4"
data-description="annotation input"></b-textarea>
<StatelessTags class="mt-3 tags" v-model="tags" />
</div>
</div>
<EditorMenu
class="ml-3 flex-grow-0 d-flex flex-column"
v-if="writable"
model-name="History"
:editing.sync="editing"
:writable="writable"
:valid="valid"
:dirty="dirty"
@save="save"
@revert="revert" />
</section>
</template>
<script>
import prettyBytes from "pretty-bytes";
import { History } from "./model";
import EditorMenu from "./EditorMenu";
import { StatelessTags } from "components/Tags";
export default {
components: {
EditorMenu,
StatelessTags,
},
filters: {
niceFileSize(rawSize = 0) {
return prettyBytes(rawSize);
},
},
props: {
history: { type: History, required: true },
writable: { type: Boolean, required: false, default: true },
},
data() {
return {
editing: false,
tempHistory: this.history.clone(),
};
},
computed: {
historyId() {
return this.history.id;
},
isEditing() {
return this.writable && this.editing;
},
valid() {
return this.tempHistory.name.length > 0;
},
dirty() {
return !this.tempHistory.equals(this.history);
},
annotation: {
get() {
return this.tempHistory.annotation;
},
set(annotation) {
this.patchTempHistory({ annotation });
},
},
historyName: {
get() {
return this.tempHistory.name;
},
set(name) {
this.patchTempHistory({ name });
},
},
tags: {
get() {
return this.tempHistory.tags || [];
},
set(newTags) {
this.patchTempHistory({ tags: newTags });
},
},
},
watch: {
historyId(newId, oldId) {
if (newId != oldId) {
this.reset();
}
},
},
methods: {
reset() {
this.tempHistory = this.history.clone();
},
patchTempHistory(props = {}) {
this.tempHistory = this.tempHistory.patch(props);
},
save() {
if (this.valid) {
this.$emit("update:history", this.tempHistory);
this.editing = false;
}
},
revert() {
if (this.dirty) {
this.reset();
}
},
revertAndCancel() {
this.revert();
this.editing = false;
},
},
};
</script>
@@ -1,97 +0,0 @@
<template>
<div class="history-listing">
<div @scroll="onScrollThrottle" class="listing">
<div v-for="(item, index) in getItems" :key="index">
<slot name="history-item" :item="item" :index="index" />
</div>
<div v-if="loading" class="m-2">
<LoadingSpan message="Please wait" />
</div>
</div>
</div>
</template>
<script>
import { reverse, throttle } from "lodash";
import LoadingSpan from "components/LoadingSpan";
export default {
components: {
LoadingSpan,
},
props: {
payload: { required: true },
itemKey: { type: String, default: "hid" },
queryKey: { type: String, default: null },
pageSize: { type: Number, required: true },
},
data() {
return {
items: [],
throttlePeriod: 100,
queryCurrent: this.queryKey,
};
},
created() {
this.updateItems();
this.onScrollThrottle = throttle((event) => {
this.onScroll(event);
}, this.throttlePeriod);
},
watch: {
payload() {
this.updateItems();
},
},
computed: {
loading() {
return !!this.payload && this.payload.length == this.pageSize;
},
getItems() {
return reverse(this.items.filter((n) => n));
},
},
methods: {
updateItems() {
if (this.queryKey != this.queryCurrent) {
this.queryCurrent = this.queryKey;
this.items = [];
} else if (this.payload) {
for (const item of this.payload) {
const itemIndex = item[this.itemKey];
this.items[itemIndex] = item;
}
this.items = this.items.slice();
}
},
onScroll(event) {
const itemCount = this.items.length;
if (itemCount > 0) {
let topIndex = 0;
for (const index in event.target.childNodes) {
const child = event.target.childNodes[index];
if (child.offsetTop > event.target.scrollTop) {
topIndex = index - 1;
break;
}
}
topIndex = Math.min(Math.max(topIndex, 0), itemCount - 1);
const topItem = this.getItems[topIndex];
if (topItem) {
this.$emit("scroll", topItem[this.itemKey]);
}
}
},
},
};
</script>
<style scoped lang="scss">
@import "scss/mixins.scss";
.history-listing {
.listing {
@include absfill();
overflow-y: scroll;
overflow-x: hidden;
}
}
</style>
@@ -1,52 +0,0 @@
<!-- A single history panel is either showing the history or one of the
selected datset collections -->
<template>
<HistoryComponent
v-if="history && !breadcrumbs.length"
:history="history"
v-on="$listeners"
@viewCollection="onViewCollection">
<template v-for="(_, name) in $scopedSlots" :slot="name" slot-scope="slotData">
<slot :name="name" v-bind="slotData" :history="history" />
</template>
</HistoryComponent>
<CurrentCollection
v-else-if="breadcrumbs.length"
:history="history"
:selected-collections.sync="breadcrumbs"
@viewCollection="onViewCollection" />
<div v-else>
<span class="sr-only">Loading...</span>
</div>
</template>
<script>
import HistoryComponent from "./History";
import CurrentCollection from "./CurrentCollection/Panel";
import { History, STATES } from "./model";
export default {
components: {
HistoryComponent,
CurrentCollection,
},
props: {
history: { type: History, required: true },
},
data() {
return {
// list of collections we have drilled down into
breadcrumbs: [],
};
},
provide: {
STATES,
},
methods: {
onViewCollection(collection) {
this.breadcrumbs = [...this.breadcrumbs, collection];
},
},
};
</script>
@@ -1,62 +0,0 @@
<template>
<Tags :tags="history.tags" :store-key="storeKey" :tag-service="historyTagService" :disabled="disabled" />
</template>
<script>
// TODO: Re-do tags as a provider instead of dependency injected service
import { History } from "./model";
import { updateHistoryFields } from "./model/queries";
import { Tags } from "components/Tags";
import { TagService } from "components/Tags/tagService";
import { createTag } from "components/Tags/model";
class HistoryTagService extends TagService {
constructor(history) {
super({
id: history.id,
itemClass: "History",
context: "history-panel",
});
this.history = history;
}
async save(rawTag) {
const tag = createTag(rawTag);
const tagSet = new Set(this.history.tags);
tagSet.add(tag.text);
await this.saveHistoryTags(tagSet);
return tag;
}
async delete(rawTag) {
const tag = createTag(rawTag);
const tagSet = new Set(this.history.tags);
tagSet.delete(tag.text);
await this.saveHistoryTags(tagSet);
return tag;
}
async saveHistoryTags(rawTags) {
// console.log("saveHistoryTags", rawTags);
const tags = Array.from(rawTags);
return await updateHistoryFields(this.history.id, { tags });
}
}
export default {
components: { Tags },
props: {
history: { type: History, required: true },
disabled: { type: Boolean, required: false, default: false },
},
computed: {
storeKey() {
return `History-${this.history.id}`;
},
historyTagService() {
return new HistoryTagService(this.history);
},
},
};
</script>
+61
View File
@@ -0,0 +1,61 @@
<template>
<CurrentUser class="d-flex flex-column" v-slot="{ user }">
<UserHistories v-if="user" :user="user" v-slot="{ currentHistory, histories, handlers }">
<div v-if="currentHistory" id="current-history-panel" class="history-index">
<CurrentHistory
v-if="!breadcrumbs.length"
:history="currentHistory"
v-on="handlers"
@view-collection="onViewCollection">
<template v-slot:navigation>
<HistoryNavigation
v-on="handlers"
:histories="histories"
:current-history="currentHistory"
title="Histories" />
</template>
</CurrentHistory>
<CurrentCollection
v-else-if="breadcrumbs.length"
:history="currentHistory"
:selected-collections.sync="breadcrumbs"
@view-collection="onViewCollection" />
<div v-else>
<span class="sr-only">Loading...</span>
</div>
</div>
<div v-else class="flex-grow-1 loadingBackground h-100">
<span class="sr-only" v-localize>Loading History...</span>
</div>
</UserHistories>
</CurrentUser>
</template>
<script>
import CurrentUser from "components/providers/CurrentUser";
import UserHistories from "components/providers/UserHistories";
import HistoryNavigation from "./CurrentHistory/HistoryNavigation";
import CurrentHistory from "./CurrentHistory/HistoryPanel";
import CurrentCollection from "./CurrentCollection/CollectionPanel";
export default {
components: {
CurrentUser,
CurrentHistory,
CurrentCollection,
UserHistories,
HistoryNavigation,
},
data() {
return {
// list of collections we have drilled down into
breadcrumbs: [],
};
},
methods: {
onViewCollection(collection) {
this.breadcrumbs = [...this.breadcrumbs, collection];
},
},
};
</script>
-63
View File
@@ -1,63 +0,0 @@
<!-- Layout to house all the extraneous structural markup elements
and non-semantic utility classes that are necessary when you use an
amateur-hour baby-tool like Bootstrap instead of taking the afternoon
to learn how CSS is supposed to work. -->
<template>
<section class="history beta d-flex flex-column">
<header>
<nav class="d-flex justify-content-between bg-secondary">
<slot name="globalnav">Global Navigation Default</slot>
<slot name="localnav">Local Navigation Default</slot>
</nav>
<!-- details about the thing you're looking at -->
<div class="px-3 py-4 details">
<slot name="details"></slot>
</div>
</header>
<!-- warnings, messages, dirty limericks -->
<section v-if="$slots.messages">
<slot name="messages"></slot>
</section>
<!-- controls atop the list -->
<section v-if="$slots.listcontrols" class="list-controls">
<slot name="listcontrols"></slot>
</section>
<!-- scrolling list -->
<section class="position-relative flex-grow-1 scroller">
<slot name="listing"></slot>
</section>
<!-- no dom layout, holds modal markup -->
<section v-if="$slots.modals">
<slot name="modals"></slot>
</section>
</section>
</template>
<style lang="scss">
.history.beta {
/* TODO: css reset? */
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
}
.list-controls {
margin-bottom: 2px;
}
.list-controls:empty,
.messages:empty {
display: none;
}
}
</style>
@@ -0,0 +1,120 @@
<template>
<section data-description="edit details">
<b-button
v-if="writeable"
class="edit-button ml-1 float-right"
data-description="editor toggle"
size="sm"
variant="link"
:title="'Edit' | l"
:pressed="editing"
@click="onToggle">
<Icon icon="pen" />
</b-button>
<slot name="name" />
<!-- display annotation, tags -->
<div v-if="!editing">
<div v-if="annotation" class="mt-2" data-description="annotation value">{{ annotation }}</div>
<StatelessTags v-if="tags" class="tags mt-3" :value="tags" :disabled="true" />
</div>
<!-- edit form, change title, annotation, or tags -->
<div v-else class="mt-3" data-description="edit form">
<b-textarea
class="mb-2"
v-model="localProps.name"
placeholder="Name"
trim
max-rows="4"
data-description="name input"
@keyup.esc="onToggle" />
<b-textarea
v-if="showAnnotation"
class="mb-2"
v-model="localProps.annotation"
placeholder="Annotation (optional)"
trim
max-rows="4"
data-description="annotation input"
@keyup.esc="onToggle" />
<StatelessTags v-if="localProps.tags" class="mb-3 tags" v-model="localProps.tags" />
</div>
<nav class="edit-controls">
<template v-if="editing">
<b-button
class="save-button mb-1"
data-description="editor save button"
size="sm"
variant="primary"
:disabled="!localProps.name"
@click="onSave">
<Icon icon="save" />
<span v-localize>Save</span>
</b-button>
<b-button
class="cancel-button mb-1"
data-description="editor cancel button"
size="sm"
icon="undo"
@click="onToggle">
<Icon icon="undo" />
<span v-localize>Cancel</span>
</b-button>
</template>
</nav>
</section>
</template>
<script>
import { StatelessTags } from "components/Tags";
export default {
components: {
StatelessTags,
},
props: {
name: { type: String, default: null },
annotation: { type: String, default: null },
showAnnotation: { type: Boolean, default: true },
tags: { type: Array, default: null },
writeable: { type: Boolean, default: true },
},
data() {
return {
editing: false,
localProps: {},
};
},
watch: {
name() {
this.localProps.name = this.name;
},
annotation() {
this.localProps.annotation = this.annotation;
},
tags() {
this.localProps.tags = this.tags;
},
},
created() {
this.onReset();
},
methods: {
onReset() {
this.localProps = {
name: this.name,
annotation: this.annotation,
tags: this.tags,
};
},
onSave() {
this.editing = false;
this.$emit("save", Object.assign({}, this.localProps));
},
onToggle() {
this.editing = !this.editing;
this.onReset();
},
},
};
</script>
@@ -0,0 +1,52 @@
<template>
<section class="history-layout d-flex flex-column">
<header>
<!-- navigation bar at the top-->
<nav class="d-flex justify-content-between bg-secondary">
<slot name="navigation" />
</nav>
<!-- details about the thing you're looking at -->
<div class="px-3 py-4 details">
<slot name="details" />
</div>
</header>
<!-- warnings, messages -->
<section v-if="$slots.messages">
<slot name="messages" />
</section>
<!-- controls above list -->
<section v-if="$slots.listcontrols" class="list-controls">
<slot name="listcontrols" />
</section>
<!-- scrolling list -->
<section class="position-relative flex-grow-1 scroller">
<slot name="listing" />
</section>
<!-- no dom layout, holds modal markup -->
<section v-if="$slots.modals">
<slot name="modals" />
</section>
</section>
</template>
<style lang="scss">
.history-layout {
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
}
.list-controls:empty,
.messages:empty {
display: none;
}
}
</style>
@@ -0,0 +1,125 @@
<template>
<div class="history-listing">
<virtual-list
ref="listing"
class="listing"
:data-key="itemKey"
:data-sources="getItems"
:data-component="{}"
@scroll="onScroll">
<template v-slot:item="{ item }">
<slot name="history-item" :item="item" />
</template>
<template v-slot:footer>
<LoadingSpan v-if="loading" class="m-2" message="Loading" />
</template>
</virtual-list>
</div>
</template>
<script>
import Vue from "vue";
import VirtualList from "vue-virtual-scroll-list";
import { reverse, throttle } from "lodash";
import LoadingSpan from "components/LoadingSpan";
export default {
components: {
LoadingSpan,
VirtualList,
},
props: {
itemKey: { type: String, default: "hid" },
limit: { type: Number, required: true },
payload: { type: Array, default: null },
queryKey: { type: String, default: null },
reversed: { type: Boolean, default: false },
},
data() {
return {
items: [],
throttlePeriod: 20,
deltaMax: 20,
queryCurrent: this.queryKey,
};
},
created() {
this.updateItems();
this.onScrollThrottle = throttle((event) => {
this.onScroll(event);
}, this.throttlePeriod);
},
watch: {
payload() {
this.updateItems();
},
},
computed: {
loading() {
return !!this.payload && this.payload.length == this.limit;
},
getItems() {
const filtered = this.items.filter((n) => n);
return this.reversed ? reverse(filtered) : filtered;
},
},
methods: {
updateItems() {
/* This function merges the existing data with new incoming data. */
if (this.queryKey != this.queryCurrent) {
this.queryCurrent = this.queryKey;
this.items.splice(0);
} else if (this.payload) {
for (const item of this.payload) {
const itemIndex = item[this.itemKey];
if (this.items[itemIndex]) {
const localItem = this.items[itemIndex];
Object.keys(localItem).forEach((key) => {
localItem[key] = item[key];
});
} else {
Vue.set(this.items, itemIndex, item);
}
}
}
},
onScrollHandler(event) {
/* CURRENTLY UNUSED
// this avoids diagonal scrolling, we either scroll left/right or top/down
// both events are throttled and the default handler has been prevented.
if (Math.abs(event.deltaY) > Math.abs(event.deltaX)) {
// handle vertical scrolling with virtual scroller
const listing = this.$refs.listing;
const deltaMax = this.deltaMax;
const deltaY = Math.max(Math.min(event.deltaY, deltaMax), -deltaMax);
this.offset = Math.max(0, listing.getOffset() + deltaY);
this.$refs.listing.scrollToOffset(this.offset);
} else {
// dispatch horizontal scrolling as regular event
var wheelEvent = new WheelEvent("wheel", {
deltaX: event.deltaX,
bubbles: true,
cancelable: false,
});
event.target.dispatchEvent(wheelEvent);
}
*/
},
onScroll() {
const rangeStart = this.$refs.listing.range.start;
this.$emit("scroll", rangeStart);
},
},
};
</script>
<style scoped lang="scss">
@import "scss/mixins.scss";
.history-listing {
.listing {
@include absfill();
scroll-behavior: smooth;
overflow-y: scroll;
overflow-x: hidden;
}
}
</style>
@@ -45,7 +45,7 @@
</template>
<script>
import { History } from "./model/History";
import { History } from "components/History/model/History";
import { mapActions, mapGetters } from "vuex";
export default {
@@ -6,7 +6,7 @@
</template>
<script>
import { loadToolFromJob } from "./model/queries";
import { loadToolFromJob } from "components/History/model/queries";
export default {
data() {
+40 -52
View File
@@ -3,67 +3,55 @@ This is not intended to be a complete listing, but a general idea of how the com
to interact with each other.
```html static
<CurrentHistoryPanel>
<HistoryPanel :history="history">
<Index :history="history">
<!-- if main history selected -->
<CurrentHistory :history="history">
<!-- for the right-hand side history we show some
optional nav elements, can be ommitted for histories
shown in multi-history view -->
optional nav elements -->
<slot:nav>
<HistorySelector />
<HistoryMenu />
</slot:nav>
<!-- Data providers do the heavy-lifting of mixing params, history, and
scroll position to deliver the content for the scroller -->
<UrlDataProvider>
<HistoryNavigation />
<HistoryDetails />
<HistoryMessages />
<HistoryOperations />
<!-- if main history selected -->
<History :history="history">
<!-- Uses a virtual scroller plugin to render all ContentItems and throttles the scrolling pace by limiting
the frequency and magnitude of offset changes. -->
<Listing>
<!-- Data providers do the heavy-lifting of mixing params, history, and
scroll position to deliver the content for the scroller -->
<!-- The ContentItem renders a row in the list, showing the title, some attributes,
and basic operation buttons such as display and edit for either a dataset or a collection.
This component needs to be very efficient since it is re-rendered by the virtual scroller plugin. -->
(<ContentItem />)
<UrlDataProvider>
<HistoryDetails />
<HistoryMessages />
<ContentOperations />
<HistoryListing>
<!-- Is shown for datasets upon expansion, can be inefficent in comparison to the ContentItem component. -->
<DatasetDetails />
</Listing>
</UrlDataProvider>
</CurrentHistory>
<!-- HistoryContentItem is a dynamic component that becomes
either Dataset or DatasetCollection depending
on the props passed to it -->
<!-- When a collection is selected for viewing, send in a
breadcrumbs list of collections the user has selected -->
<CurrentCollection :selected-collections="breadcrumbs">
<UrlDataProvider>
<CollectionNavigation />
<CollectionDetails />
<CollectionOperations />
(<HistoryContentItem />)
<Dataset />
<!-- or -->
<DatasetCollection />
</HistoryListing>
</UrlDataProvider>
</History>
<!-- When a collection is selected for viewing, send in a
breadcrumbs list of collections the user has selected -->
<CurrentCollection :selected-collections="breadcrumbs">
<UrlDataProvider>
<CollectionNav />
<Details />
<HistoryListing>
<!-- Subdataset and Subcollection are similar to the Dataset
and DatasetCollection ContentItem components, but mostly
read-only since they are part of the collection-->
(<CollectionContentItem />)
<Subdataset />
<!-- or -->
<Subcollection />
</HistoryListing>
</UrlDataProvider>
</CurrentCollection>
</HistoryPanel>
</CurrentHistoryPanel>
<!-- As above, the same virtual scroller and ContentItem component is being used to render the elements. -->
<Listing>
(<ContentItem />)
<DatasetDetails />
</Listing>
</UrlDataProvider>
</CurrentCollection>
</Index>
```
@@ -1,54 +0,0 @@
<template>
<IconButton
v-if="stateIcon"
v-b-tooltip
:title="theTitle"
:icon="stateIcon"
:variant="variant"
v-on="$listeners"
v-bind="$attrs" />
</template>
<script>
import capitalize from "underscore.string/capitalize";
import IconButton from "components/IconButton";
export default {
inject: ["STATES"],
components: {
IconButton,
},
props: {
state: { type: String, required: true },
variant: { type: String, required: false, default: "link" },
},
computed: {
stateIcon() {
if (this.inState(this.STATES.QUEUED, this.STATES.NEW, this.STATES.RUNNING)) {
return "clock";
}
if (this.inState(this.STATES.PAUSED)) {
return "pause";
}
if (this.inState(this.STATES.ERROR)) {
return "exclamation-triangle";
}
if (this.inState(this.STATES.MIXED)) {
return "exclamation-circle";
}
if (this.inState(this.STATES.OK)) {
return "check";
}
return null;
},
theTitle() {
return this.title || capitalize(this.state);
},
},
methods: {
inState(...states) {
return states.includes(this.state);
},
},
};
</script>
@@ -5,8 +5,7 @@
*/
import { mountVueComponent } from "utils/mountVueComponent";
import CurrentHistoryPanel from "../CurrentHistoryPanel";
// import { genericProxy } from "utils/proxy";
import HistoryIndex from "components/History/Index";
// mvc nonsense
import $ from "jquery";
@@ -88,7 +87,7 @@ export const HistoryPanelProxy = Backbone.View.extend({
$("#right").addClass("beta").prepend(this.$el);
const container = this.$el[0];
const mountHistory = mountVueComponent(CurrentHistoryPanel);
const mountHistory = mountVueComponent(HistoryIndex);
mountHistory({}, container);
return this;
-6
View File
@@ -1,6 +0,0 @@
// Right-hand panel
export { default as HistoryPanel } from "./HistoryPanel";
export { default as CurrentHistoryPanel } from "./CurrentHistoryPanel";
// Adapter: backbone view that mounts HistoryPanel
export { default as HistoryPanelProxy } from "./adapters/HistoryPanelProxy";
-50
View File
@@ -1,50 +0,0 @@
<template>
<b-button
class="galaxy-icon-button"
v-bind="$attrs"
v-on="$listeners"
:title="title"
:size="size"
:pressed="pressed"
:disabled="disabled"
:variant="localVariant">
<Icon :icon="icon" />
<span class="sr-only">{{ title | l }}</span>
</b-button>
</template>
<script>
export default {
props: {
icon: { type: [String, Array, Object], required: true },
title: { type: String, required: true },
size: { type: String, required: false, default: "sm" },
pressed: { type: Boolean, required: false, default: false },
disabled: { type: Boolean, required: false, default: false },
// overrides variant set by pressed/disabled
variant: { type: String, required: false, default: null },
},
computed: {
localVariant() {
if (this.variant) {
// override with prop
return this.variant;
}
if (this.disabled) {
return "secondary";
}
return this.pressed ? "info" : "secondary";
},
},
};
</script>
<style lang="scss">
// removes irritating bootstrap outline
.galaxy-icon-button.btn {
box-shadow: none !important;
&.disabled > svg {
opacity: 0.5;
}
}
</style>
@@ -14,7 +14,11 @@
{{ value[0].label || name }}
</td>
<td>
<generic-history-content v-for="(item, index) in value" :key="index" :data_item="item.value" />
<generic-history-item
v-for="(item, index) in value"
:key="index"
:item-id="item.value.id"
:item-src="item.value.src" />
</td>
</tr>
</tbody>
@@ -23,11 +27,11 @@
</template>
<script>
import GenericHistoryContent from "components/History/ContentItem/GenericContentItem/GenericHistoryContent";
import GenericHistoryItem from "components/History/Content/GenericItem";
export default {
components: {
GenericHistoryContent,
GenericHistoryItem,
},
props: {
jobOutputs: Object,
@@ -97,7 +97,7 @@ describe("JobMetrics/JobMetrics.vue", () => {
expect(elements.length).toBe(3);
checkTableParameter(elements.at(0), "Add this value", "22");
checkTableParameter(elements.at(1), linkParam.text, `${raw.hid} ${raw.name}`);
checkTableParameter(elements.at(1), linkParam.text, `${raw.hid} : ${raw.name}`);
checkTableParameter(elements.at(2), "Iterate?", "NO");
});
@@ -1,22 +1,21 @@
<template>
<div>
<div v-for="(elVal, pvIndex) in parameter_value" :key="pvIndex">
<generic-history-content v-if="['hda', 'hdca'].includes(elVal.src)" :data_item="elVal" />
<generic-history-item
v-if="['hda', 'hdca'].includes(elVal.src)"
:item-id="elVal.id"
:item-src="elVal.src" />
<span v-else> {{ elVal.hid }}: {{ elVal.name }} </span>
</div>
</div>
</template>
<script>
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import GenericHistoryContent from "components/History/ContentItem/GenericContentItem/GenericHistoryContent";
Vue.use(BootstrapVue);
import GenericHistoryItem from "components/History/Content/GenericItem";
export default {
components: {
GenericHistoryContent,
GenericHistoryItem,
},
props: {
parameter_value: {
@@ -14,7 +14,7 @@
:key="input.id"
:data-label="dataInputStepLabel(key, input)">
<b>{{ dataInputStepLabel(key, input) }}</b>
<generic-history-content :data_item="input" />
<generic-history-item :item-id="input.id" :item-src="input.src" />
</div>
</details>
</div>
@@ -23,7 +23,7 @@
<summary><b>Outputs</b></summary>
<div v-for="(output, key) in invocation.outputs" :key="output.id">
<b>{{ key }}:</b>
<generic-history-content :data_item="output" />
<generic-history-item :item-id="output.id" :item-src="output.src" />
</div>
</details>
</div>
@@ -32,7 +32,7 @@
<summary><b>Output Collections</b></summary>
<div v-for="(output, key) in invocation.output_collections" :key="output.id">
<b>{{ key }}:</b>
<generic-history-content :data_item="output" />
<generic-history-item :item-id="output.id" :item-src="output.src" />
</div>
</details>
</div>
@@ -51,8 +51,8 @@
</div>
</template>
<script>
import ParameterStep from "./ParameterStep.vue";
import GenericHistoryContent from "components/History/ContentItem/GenericContentItem/GenericHistoryContent";
import ParameterStep from "./ParameterStep";
import GenericHistoryItem from "components/History/Content/GenericItem";
import WorkflowInvocationStep from "./WorkflowInvocationStep";
import { mapGetters } from "vuex";
@@ -60,7 +60,7 @@ import { mapCacheActions } from "vuex-cache";
export default {
components: {
GenericHistoryContent,
GenericHistoryItem,
WorkflowInvocationStep,
ParameterStep,
},
@@ -22,7 +22,7 @@
<summary><b>Output Datasets</b></summary>
<div v-for="(value, name) in stepDetails.outputs" :key="value.id">
<b>{{ name }}</b>
<generic-history-content :data_item="value" />
<generic-history-item :item-id="value.id" :item-src="value.src" />
</div>
</details>
<details
@@ -31,7 +31,7 @@
<summary><b>Output Dataset Collections</b></summary>
<div v-for="(value, name) in stepDetails.output_collections" :key="value.id">
<b>{{ name }}</b>
<generic-history-content :data_item="value" />
<generic-history-item :item-id="value.id" :item-src="value.src" />
</div>
</details>
<div class="portlet-body" style="width: 100%; overflow-x: auto">
@@ -41,16 +41,20 @@
</details>
<parameter-step
v-else-if="workflowStepType == 'parameter_input'"
:parameters="[
invocation.input_step_parameters[stepDetails.workflow_step_label],
]"></parameter-step>
<generic-history-content
v-else-if="isDataStep && invocation.inputs[workflowStep.id]"
:data_item="invocation.inputs[workflowStep.id]" />
:parameters="[invocation.input_step_parameters[stepDetails.workflow_step_label]]" />
<generic-history-item
v-else-if="
isDataStep &&
invocation &&
invocation.inputs &&
invocation.inputs[workflowStep.id]
"
:item-id="invocation.inputs[workflowStep.id].id"
:item-src="invocation.inputs[workflowStep.id].src" />
<div v-else-if="workflowStepType == 'subworkflow'">
<div v-if="!stepDetails.subworkflow_invocation_id">
Workflow invocation for this step is not yet scheduled.
<p></p>
<br />
This step consumes outputs from these steps:
<ul v-if="workflowStep">
<li
@@ -84,9 +88,9 @@ import { mapCacheActions } from "vuex-cache";
import { mapGetters, mapActions } from "vuex";
import JobStep from "./JobStep";
import ParameterStep from "./ParameterStep";
import GenericHistoryContent from "components/History/ContentItem/GenericContentItem/GenericHistoryContent";
import GenericHistoryItem from "components/History/Content/GenericItem";
import { InvocationStepProvider } from "components/providers";
import LoadingSpan from "../LoadingSpan";
import LoadingSpan from "components/LoadingSpan";
export default {
components: {
@@ -94,7 +98,7 @@ export default {
JobStep,
ParameterStep,
InvocationStepProvider,
GenericHistoryContent,
GenericHistoryItem,
WorkflowInvocationState: () => import("components/WorkflowInvocationState/WorkflowInvocationState"),
},
props: {
@@ -7,67 +7,71 @@ import { getGalaxyInstance } from "app";
import { redirectToUrl, prependPath } from "utils/redirect";
import _l from "utils/localization";
// wrapper for backbone router
export function backboneRoute(path, ...args) {
try {
getGalaxyInstance().router.push(path, ...args);
} catch (err) {
console.warn("Failed galaxy route change", err, ...arguments);
throw err;
}
}
// wrapper for scratchbook/multiview feature
export function iframeAdd({ path, title = "Galaxy", tryIframe = true }) {
const Galaxy = getGalaxyInstance();
if (Galaxy.frame && Galaxy.frame.active) {
Galaxy.frame.add({
url: prependPath(path),
title: _l(title),
});
return true;
} else if (tryIframe) {
return iframeRedirect(path);
} else {
return false;
}
}
// straight ifrme redirect
export function iframeRedirect(path, target = "galaxy_main") {
try {
const targetFrame = window.frames[target];
if (!targetFrame) {
throw new Error(`Requested frame ${target} doesn't exist`);
}
targetFrame.location = prependPath(path);
return true;
} catch (err) {
console.warn("Failed iframe redirect", err, ...arguments);
throw err;
}
}
// straight window.location
export function redirect(path) {
console.log("legacyNavigation: go", path);
redirectToUrl(prependPath(path));
}
// arbitrary Galaxy wrapper
export function useGalaxy(fn) {
return fn(getGalaxyInstance());
}
// wrapper for navigation to be used as mixin
export const legacyNavigationMixin = {
methods: {
// straight window.location
redirect(path) {
console.log("legacyNavigation: go", path);
redirectToUrl(prependPath(path));
},
// weird wrapper for Galaxy's wierd frame navigation
// checks for existing galaxy frame reference, uses that or
// loads a brand new document in the iframe
iframeAdd({ path, title = "Galaxy", tryIframe = true }) {
const Galaxy = getGalaxyInstance();
if (Galaxy.frame && Galaxy.frame.active) {
Galaxy.frame.add({
url: prependPath(path),
title: _l(title),
});
return true;
} else if (tryIframe) {
return this.iframeRedirect(path);
} else {
return false;
}
},
// straight ifrme redirect
iframeRedirect(path, target = "galaxy_main") {
try {
const targetFrame = window.frames[target];
if (!targetFrame) {
throw new Error(`Requested frame ${target} doesn't exist`);
}
targetFrame.location = prependPath(path);
return true;
} catch (err) {
console.warn("Failed iframe redirect", err, ...arguments);
throw err;
}
},
// galaxy router, wrapper for backbone router
backboneRoute(path, ...args) {
try {
getGalaxyInstance().router.push(path, ...args);
} catch (err) {
console.warn("Failed galaxy route change", err, ...arguments);
throw err;
}
},
// arbitrary Galaxy wrapper
useGalaxy(fn) {
return fn(getGalaxyInstance());
},
// prepend server prefix from configs to a url
redirect,
iframeAdd,
iframeRedirect,
backboneRoute,
useGalaxy,
prependPath,
},
};
// wrapper for navigation to be used as plugin
export const legacyNavigationPlugin = {
install(Vue) {
Vue.mixin(legacyNavigationMixin);
@@ -5,8 +5,9 @@ import { rethrowSimple } from "utils/simple-error";
async function urlData({ url }) {
try {
const { data } = await axios.get(`${getAppRoot()}${url}`);
console.debug("Requesting data from: ", url);
const { data } = await axios.get(`${getAppRoot()}${url}`);
console.debug("Received response");
return data;
} catch (e) {
rethrowSimple(e);
+1 -1
View File
@@ -5,7 +5,7 @@ import MvcHistoryPanel from "entry/panels/history-panel";
import Page from "layout/page";
// Vue adapter emulates current features of backbone history panel
import { HistoryPanelProxy } from "components/History";
import { HistoryPanelProxy } from "components/History/adapters/HistoryPanelProxy";
import { isBetaHistoryOpen } from "components/History/adapters/betaToggle";
addInitialization((Galaxy, { options = {} }) => {
+1 -1
View File
@@ -72,7 +72,7 @@ const HistoryPanel = Backbone.View.extend({
this.model = new Backbone.Model({
// define components
cls: "history-right-panel history-details",
cls: "history-right-panel details",
title: _l("History"),
buttons: panelHeaderButtons,
});
+1 -1
View File
@@ -54,7 +54,7 @@ var AnnotationEditor = Backbone.View.extend(baseMVC.LoggableMixin)
_l("Annotation"),
"</label>",
// set up initial tags by adding as CSV to input vals (necc. to init select2)
'<div class="annotation" data-description="history annotation">',
'<div class="annotation" data-description="annotation value">',
_.escape(annotation),
"</div>",
].join("");
+2 -2
View File
@@ -521,14 +521,14 @@ var HistoryView = _super.extend(
HistoryView.prototype.templates = (() => {
var mainTemplate = () =>
`<div>
<div class="controls history-details"></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 history-details">',
'<div class="controls details">',
'<div class="title">',
'<div class="name history-title"><%- history.name %></div>',
"</div>",
+5
View File
@@ -9915,6 +9915,11 @@ vue-template-es2015-compiler@^1.5.3, vue-template-es2015-compiler@^1.9.0:
resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825"
integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==
vue-virtual-scroll-list@^2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/vue-virtual-scroll-list/-/vue-virtual-scroll-list-2.3.3.tgz#290f0d04be814b93585f2ef232097b671141e5b1"
integrity sha512-heuwlZ+lEdcVSp66CpVXnyNdsiHl/XU1cvqJb0yUHGlLv6DGK8OX16FH8I3w/wKNXtHSPF9itdBBGJFrXkyHeg==
vue@^2.6.10, vue@^2.6.14:
version "2.6.14"
resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.14.tgz#e51aa5250250d569a3fbad3a8a5a687d6036e235"
+4 -4
View File
@@ -134,7 +134,7 @@ def retry_during_transitions(
return _retry
def edit_details(f, scope=".beta.history"):
def edit_details(f, scope=".history-index"):
"""Open the editor, run the edits, hit the save button"""
@wraps(f)
@@ -1342,7 +1342,7 @@ class NavigatesGalaxy(HasDriver):
return not self.components.history_panel.beta.is_absent
# avoids problematic ID and classes on markup
def beta_history_element(self, attribute_value, attribute_name="data-description", scope=".beta.history"):
def beta_history_element(self, attribute_value, attribute_name="data-description", scope=".history-index"):
return self.components._.by_attribute(name=attribute_name, value=attribute_value, scope=scope)
# join list of attrs into css attribute selectors and append to base beta_item selector
@@ -1852,7 +1852,7 @@ class NavigatesGalaxy(HasDriver):
def snapshot(self, description):
"""Test case subclass overrides this to provide detailed logging."""
def open_history_editor(self, scope=".beta.history"):
def open_history_editor(self, scope=".history-index"):
if self.is_beta_history():
panel = self.components.history_panel.editor.selector(scope=scope)
toggle = panel.toggle
@@ -1860,7 +1860,7 @@ class NavigatesGalaxy(HasDriver):
editor = panel.form
editor.wait_for_present()
def close_history_editor(self, scope=".beta.history"):
def close_history_editor(self, scope=".history-index"):
if self.is_beta_history():
toggle = self.components.history_panel.edit_toggle
toggle.wait_for_and_click()
+13 -12
View File
@@ -154,7 +154,7 @@ history_panel:
content_item:
selectors:
# specific rows selectable with attributes via suffix
_: '.beta.history .content-item${suffix}'
_: '.history-index .content-item${suffix}'
title: '${_} .content-title'
details: '${_} .details'
summary: '${_} .summary'
@@ -177,7 +177,7 @@ history_panel:
info_button: '${_} .params-btn'
nametags: '${_} .nametags .badge-tags'
dataset_operations_dropdown: '${_} .dataset-operations-dropdown'
dataset_operations_dropdown: '${_} .dataset-actions'
# re-usable history editor, scoped for use in different layout scenarios (multi, etc.)
editor:
@@ -193,10 +193,11 @@ history_panel:
# beta history tags
tag_editor:
selectors:
_: '${scope} .history-details .tags'
_: '${scope} .details .tags'
toggle: '${_} .toggle-link'
display: '${_} .tag-area .tag-name'
input: '${_} input'
tag_area: '${_} .tag-area'
tag_close_btn: '${_} .ti-icon-close'
multi_operations:
@@ -226,7 +227,7 @@ history_panel:
list_items_beta: '.dataset-collection-panel .listing .content-item'
selectors:
beta: '.beta.history'
beta: '.history-index'
_: '#current-history-panel'
search: '#current-history-panel input.search-query'
refresh_button: '.history-refresh-button'
@@ -240,25 +241,25 @@ history_panel:
empty_message: '.empty-message'
size: '.history-size'
tag_icon: '.actions .history-tag-btn'
tag_area: '.history-details .tags-display'
tag_area_input: '.history-details .tags-display .tags-input input'
tag_area: '.details .tags-display'
tag_area_input: '.details .tags-display .tags-input input'
tag_close_btn: '.tags-display .ti-icon-close'
tags: 'li.ti-tag.ti-valid .tag-name'
annotation_icon: '.actions .history-annotate-btn'
annotation_area: '.history-details .history-annotation'
annotation_area: '.details .history-annotation'
annotation_editable_text:
selector: 'history annotation'
selector: 'annotation value'
type: data-description
annotation_edit:
selector: 'annotation input'
type: data-description
annotation_done: '.history-details .history-annotation .annotation button'
annotation_done: '.details .history-annotation .annotation button'
options_button: '#history-options-button'
options_button_icon: '.history-details .menu-expand-button'
options_button_icon_beta: '.history-details .menu-expand-button'
options_button_icon: '.details .menu-expand-button'
options_button_icon_beta: '.details .menu-expand-button'
options_button_copy_datasets_beta: '.copy-datasets-menu-item'
options_menu: '#history-options-button-menu'
options_menu_item:
@@ -631,7 +632,7 @@ invocations:
step_output_collection_toggle: '.workflow-invocation-state-component .invocation-steps-details [data-step="${order_index}"] .invocation-step-output-collection-details .name'
step_output_collection_element_identifier:
type: xpath
selector: '//span[@class="name"][text()="${element_identifier}"]'
selector: '//span[@class="content-title name"][text()="${element_identifier}"]'
step_output_collection_element_datatype: '.workflow-invocation-state-component .invocation-steps-details [data-step="${order_index}"] .invocation-step-output-collection-details .datatype .value'
step_job_details: '.workflow-invocation-state-component .invocation-steps-details [data-step="${order_index}"] .invocation-step-job-details'
step_job_table: '.workflow-invocation-state-component .invocation-steps-details [data-step="${order_index}"] .invocation-step-job-details table'
+10 -7
View File
@@ -20,7 +20,7 @@ class HistoryPanelTestCase(SeleniumTestCase):
name_element = self.history_panel_name_element()
if self.is_beta_history():
# look for the editor icon
editor = self.components.history_panel.editor.selector(scope=".beta.history")
editor = self.components.history_panel.editor.selector(scope=".history-index")
toggle = editor.toggle
toggle.wait_for_visible()
else:
@@ -60,7 +60,7 @@ class HistoryPanelTestCase(SeleniumTestCase):
@edit_details
def test_history_tags_and_annotations_buttons(self):
if self.is_beta_history():
history_editor = self.components.history_panel.editor.selector(scope=".beta.history")
history_editor = self.components.history_panel.editor.selector(scope=".history-index")
history_editor.annotation_input.wait_for_clickable()
history_editor.tags_input.wait_for_clickable()
else:
@@ -158,7 +158,7 @@ class HistoryPanelTestCase(SeleniumTestCase):
# looks like this is intended to check if the tag editor is open
def assert_no_tags():
if self.is_beta_history():
tags_component = self.components.history_panel.tag_editor.selector(scope=".beta.history")
tags_component = self.components.history_panel.tag_editor.selector(scope=".history-index")
tags_component.display.assert_absent_or_hidden()
else:
self.components.history_panel.tag_area.assert_absent_or_hidden()
@@ -172,18 +172,21 @@ class HistoryPanelTestCase(SeleniumTestCase):
# add more tags to non-empty tags area
tags += add_tags(tags_size)
self.sleep_for(self.wait_types.UX_RENDER)
if self.is_beta_history():
tags.sort()
assert_current_tags(tags)
# delete all tags
expected_tags_len = len(tags)
self.clear_tags(expected_tags_len)
self.sleep_for(self.wait_types.UX_RENDER)
assert_no_tags()
# after about 5 tags, a toggle link shows up and you have to click it to see the full list
def open_tags(self):
tags_component = self.components.history_panel.tag_editor.selector(scope=".beta.history")
if not tags_component.toggle.is_absent:
tags_component = self.components.history_panel.tag_editor.selector(scope=".history-index")
if tags_component.tag_area.is_absent:
tags_component.toggle.wait_for_and_click()
tags_component.display.wait_for_visible()
return tags_component.display
@@ -192,7 +195,7 @@ class HistoryPanelTestCase(SeleniumTestCase):
def clear_tags(self, expected_tags_size):
if self.is_beta_history():
self.open_tags()
tags = self.components.history_panel.tag_editor.selector(scope=".beta.history")
tags = self.components.history_panel.tag_editor.selector(scope=".history-index")
close_tag_buttons = tags.tag_close_btn.all()
else:
close_tag_buttons = self.components.history_panel.tag_close_btn.all()
@@ -25,6 +25,8 @@ class HistoryPanelCollectionsTestCase(SeleniumTestCase):
input_collection, failed_collection = self._generate_partially_failed_collection_with_input()
input_hid = input_collection["hid"]
failed_hid = failed_collection["hid"]
self.home()
ok_inputs = {
"input1": {"batch": True, "values": [{"src": "hdca", "id": input_collection["id"]}]},
"sleep_time": 0,
@@ -36,8 +38,6 @@ class HistoryPanelCollectionsTestCase(SeleniumTestCase):
)
ok_hid = ok_response["implicit_collections"][0]["hid"]
self.home()
self.history_panel_wait_for_hid_state(input_hid, "ok")
self.history_panel_wait_for_hid_state(failed_hid, "error")
self.history_panel_wait_for_hid_state(ok_hid, "ok")
@@ -170,6 +170,7 @@ class HistoryPanelCollectionsTestCase(SeleniumTestCase):
if self.is_beta_history():
self._rename_collection(new_name)
self.screenshot("history_panel_collection_view_rename_beta")
self.sleep_for(WAIT_TYPES.UX_RENDER)
title_element = self.beta_history_element("collection name display").wait_for_present()
else:
title_element = collection_view.title.wait_for_visible()
@@ -215,16 +216,16 @@ class HistoryPanelCollectionsTestCase(SeleniumTestCase):
input_collection = self._populated_paired_and_wait_for_it()
collection_hid = input_collection["hid"]
collection_view = self._click_and_wait_for_collection_view(collection_hid)
self.sleep_for(WAIT_TYPES.UX_TRANSITION)
if self.is_beta_history():
self.sleep_for(WAIT_TYPES.HISTORY_POLL)
dataset_elements = collection_view.list_items_beta.all()
else:
dataset_elements = collection_view.list_items.all()
assert len(dataset_elements) == 2, dataset_elements
titles = [de.find_element_by_css_selector(".title .name").text for de in dataset_elements]
titles.sort()
selector = ".title .name"
if self.is_beta_history():
selector = ".content-title"
titles = [de.find_element_by_css_selector(selector).text for de in dataset_elements]
assert titles == ["forward", "reverse"]
self.screenshot("history_panel_collection_view_paired")
@@ -246,7 +247,10 @@ class HistoryPanelCollectionsTestCase(SeleniumTestCase):
else:
dataset_elements = collection_view.list_items.all()
assert len(dataset_elements) == 4, dataset_elements
title_elements = [de.find_element_by_css_selector(".title .name").text for de in dataset_elements]
selector = ".title .name"
if self.is_beta_history():
selector = ".content-title"
title_elements = [de.find_element_by_css_selector(selector).text for de in dataset_elements]
assert title_elements == ["data1", "data2", "data3", "data4"]
check_four_datasets_shown()

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