mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Merge remote-tracking branch 'upstream/release_21.01' into merge-2101-forward
This commit is contained in:
+1
-1
@@ -74,11 +74,11 @@
|
||||
"underscore": "^1.10.2",
|
||||
"underscore.string": "^3.3.5",
|
||||
"vue": "^2.6.11",
|
||||
"vuedraggable": "2.24.1",
|
||||
"vue-infinite-scroll": "^2.0.2",
|
||||
"vue-multiselect": "^2.1.0",
|
||||
"vue-router": "^3.3.1",
|
||||
"vue-rx": "^6.2.0",
|
||||
"vuedraggable": "2.24.1",
|
||||
"vueisotope": "^3.1.2",
|
||||
"vuex": "^3.4.0",
|
||||
"vuex-cache": "^3.2.0",
|
||||
|
||||
@@ -49,7 +49,7 @@ import { UrlTracker } from "./utilities";
|
||||
import { Model } from "./model";
|
||||
import { Services } from "./services";
|
||||
import { getAppRoot } from "onload/loadConfig";
|
||||
import { mountUploadModal } from "components/Upload";
|
||||
import { openUploadModal } from "components/Upload";
|
||||
|
||||
Vue.use(BootstrapVue);
|
||||
|
||||
@@ -135,7 +135,7 @@ export default {
|
||||
callback: this.callback,
|
||||
modalShow: true,
|
||||
};
|
||||
mountUploadModal(propsData);
|
||||
openUploadModal(propsData);
|
||||
this.modalShow = false;
|
||||
},
|
||||
/** Called when selection is complete, values are formatted and parsed to external callback **/
|
||||
|
||||
@@ -2,15 +2,9 @@
|
||||
* Renderless component, used to debounce various types of form inputs
|
||||
*/
|
||||
|
||||
import Vue from "vue";
|
||||
import VueRx from "vue-rx";
|
||||
import { filter, debounceTime, distinctUntilChanged, finalize } from "rxjs/operators";
|
||||
import { vueRxShortcuts } from "./plugins";
|
||||
|
||||
Vue.use(VueRx);
|
||||
|
||||
export default {
|
||||
mixins: [vueRxShortcuts],
|
||||
props: {
|
||||
value: { required: true },
|
||||
delay: { type: Number, required: false, default: 500 },
|
||||
|
||||
@@ -5,12 +5,6 @@ export default {
|
||||
|
||||
computed: {
|
||||
contentItemComponent() {
|
||||
if (this.item === null) {
|
||||
return "Loading";
|
||||
}
|
||||
if (this.scrolling) {
|
||||
return "Placeholder";
|
||||
}
|
||||
const { history_content_type } = this.item;
|
||||
switch (history_content_type) {
|
||||
case "dataset":
|
||||
|
||||
@@ -11,22 +11,17 @@
|
||||
import Placeholder from "./Placeholder";
|
||||
import Dataset from "./Dataset";
|
||||
import DatasetCollection from "./DatasetCollection";
|
||||
// import SubDataset from "./SubDataset";
|
||||
import Subcollection from "./Subcollection";
|
||||
|
||||
export default {
|
||||
inject: ["listState", "isSelected", "isExpanded", "setSelected", "setExpanded"],
|
||||
|
||||
template: `
|
||||
<component :is="contentItemComponent"
|
||||
class="content-item p-1"
|
||||
:class="{ loading }"
|
||||
:index="index"
|
||||
:tabindex="index"
|
||||
:item="item"
|
||||
:selected.sync="selected"
|
||||
:expanded.sync="expanded"
|
||||
:scrolling="scrolling"
|
||||
v-on='$listeners'
|
||||
v-on="$listeners"
|
||||
v-bind="bindProps"
|
||||
@mouseover.native.self.stop="setFocus(index)"
|
||||
@keydown.native.arrow-up.self.stop="setFocus(index - 1)"
|
||||
@keydown.native.arrow-down.self.stop="setFocus(index + 1)"
|
||||
@@ -37,6 +32,7 @@ export default {
|
||||
Placeholder,
|
||||
Dataset,
|
||||
DatasetCollection,
|
||||
// SubDataset,
|
||||
Subcollection,
|
||||
},
|
||||
|
||||
@@ -66,24 +62,8 @@ export default {
|
||||
// override me
|
||||
return "Placeholder";
|
||||
},
|
||||
selected: {
|
||||
get() {
|
||||
return this.isSelected(this.item);
|
||||
},
|
||||
set(val) {
|
||||
this.setSelected(this.item, val);
|
||||
},
|
||||
},
|
||||
expanded: {
|
||||
get() {
|
||||
return this.isExpanded(this.item);
|
||||
},
|
||||
set(val) {
|
||||
this.setExpanded(this.item, val);
|
||||
},
|
||||
},
|
||||
scrolling() {
|
||||
return this.listState.scrolling;
|
||||
bindProps() {
|
||||
return { ...this.$props, ...this.$attrs };
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<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 { cacheContent } from "../../caching";
|
||||
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 = {}) {
|
||||
const ajaxResult = await deleteContent(this.item, opts);
|
||||
await cacheContent(ajaxResult);
|
||||
},
|
||||
async onUnhide() {
|
||||
await this.onUpdate({ visible: true });
|
||||
},
|
||||
async onUndelete() {
|
||||
await this.onUpdate({ deleted: false });
|
||||
},
|
||||
async onUpdate(changes) {
|
||||
const newContent = await updateContentFields(this.item, changes);
|
||||
await cacheContent(newContent);
|
||||
},
|
||||
onCopyLink() {
|
||||
const relPath = this.dataset.getUrl("download");
|
||||
const msg = this.localize("Link is copied to your clipboard");
|
||||
sendToClipboard(absPath(relPath), msg);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -72,6 +72,14 @@
|
||||
icon="fas fa-file-download"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
v-if="!dataset.purged && dataset.getUrl('download')"
|
||||
key="copy-link"
|
||||
title="Copy Link"
|
||||
icon="fa fa-chain"
|
||||
@click.stop="$emit('copy-link')"
|
||||
/>
|
||||
|
||||
<div v-if="showDownloads && dataset.hasMetaData">
|
||||
<PriorityMenuItem
|
||||
v-for="mf in dataset.meta_files"
|
||||
@@ -130,12 +138,11 @@
|
||||
|
||||
<script>
|
||||
import { mapGetters } from "vuex";
|
||||
import { Dataset } from "../../model";
|
||||
import { Dataset, STATES } from "../../model";
|
||||
import { PriorityMenu, PriorityMenuItem } from "components/PriorityMenu";
|
||||
import { legacyNavigationMixin } from "components/plugins";
|
||||
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
|
||||
|
||||
export default {
|
||||
inject: ["STATES"],
|
||||
mixins: [legacyNavigationMixin],
|
||||
|
||||
components: {
|
||||
@@ -156,6 +163,11 @@ export default {
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
// make available to template
|
||||
this.STATES = STATES;
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters("user", ["currentUser"]),
|
||||
...mapGetters("config", ["config"]),
|
||||
@@ -168,10 +180,10 @@ export default {
|
||||
if (this.dataset.purged) {
|
||||
return "Cannot display datasets removed from disk";
|
||||
}
|
||||
if (this.dataset.state == this.STATES.UPLOAD) {
|
||||
if (this.dataset.state == STATES.UPLOAD) {
|
||||
return "This dataset must finish uploading before it can be viewed";
|
||||
}
|
||||
if (this.dataset.state == this.STATES.NEW) {
|
||||
if (this.dataset.state == STATES.NEW) {
|
||||
return "This dataset is not yet viewable";
|
||||
}
|
||||
return "View data";
|
||||
@@ -184,7 +196,7 @@ export default {
|
||||
if (this.dataset.purged) {
|
||||
return "Cannot edit attributes of datasets removed from disk";
|
||||
}
|
||||
const unreadyStates = new Set([this.STATES.UPLOAD, this.STATES.NEW]);
|
||||
const unreadyStates = new Set([STATES.UPLOAD, STATES.NEW]);
|
||||
if (unreadyStates.has(this.dataset.state)) {
|
||||
return "This dataset is not yet editable";
|
||||
}
|
||||
@@ -206,7 +218,7 @@ export default {
|
||||
if (!this.dataset.hasData) {
|
||||
return false;
|
||||
}
|
||||
const okStates = new Set([this.STATES.OK, this.STATES.FAILED_METADATA, this.STATES.ERROR]);
|
||||
const okStates = new Set([STATES.OK, STATES.FAILED_METADATA, STATES.ERROR]);
|
||||
return okStates.has(this.dataset.state);
|
||||
},
|
||||
|
||||
@@ -261,9 +273,8 @@ export default {
|
||||
},
|
||||
|
||||
onDeleteClick() {
|
||||
const eventName = this.dataset.deleted ? "undeleteDataset" : "deleteDataset";
|
||||
// console.log("emitting", eventName, this.dataset);
|
||||
this.$emit(eventName, this.dataset);
|
||||
const eventName = this.dataset.deleted ? "undelete" : "delete";
|
||||
this.$emit(eventName);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ either through the props, and make updates through the events -->
|
||||
@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 d-flex align-items-center justify-content-between"
|
||||
@click.stop="$emit('update:expanded', !expanded)"
|
||||
@@ -29,16 +30,15 @@ either through the props, and make updates through the events -->
|
||||
state="hidden"
|
||||
title="Unhide"
|
||||
icon="fa fa-eye-slash"
|
||||
@click.stop="$emit('unhideDataset')"
|
||||
@click.stop="$emit('unhide')"
|
||||
/>
|
||||
|
||||
<StateBtn
|
||||
v-if="dataset.isDeleted && !dataset.purged"
|
||||
class="px-1"
|
||||
state="deleted"
|
||||
title="Undelete"
|
||||
icon="fas fa-trash-restore"
|
||||
@click.stop="$emit('undeleteDataset')"
|
||||
@click.stop="$emit('undelete')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -46,25 +46,30 @@ either through the props, and make updates through the events -->
|
||||
<span class="hid">{{ dataset.hid }}</span>
|
||||
<span v-if="collapsed || !dataset.canEditName" class="name">{{ dataset.title }}</span>
|
||||
</h5>
|
||||
<!-- <div v-if="collapsed && dataset.tags.length" class="nametags mt-1">
|
||||
<nametag v-for="tag in dataset.tags" :key="tag" :tag="tag" />
|
||||
</div> -->
|
||||
|
||||
<DatasetMenu
|
||||
class="content-item-menu"
|
||||
:dataset="dataset"
|
||||
:expanded="expanded"
|
||||
v-on="$listeners"
|
||||
:show-tags.sync="showTags"
|
||||
/>
|
||||
<slot name="menu">
|
||||
<DatasetMenu
|
||||
class="content-item-menu"
|
||||
:dataset="dataset"
|
||||
:expanded="expanded"
|
||||
v-on="$listeners"
|
||||
:show-tags.sync="showTags"
|
||||
/>
|
||||
</slot>
|
||||
</nav>
|
||||
|
||||
<!--- read-only tags with name: prefix -->
|
||||
<div v-if="collapsed && dataset.nameTags.length" class="nametags p-1">
|
||||
<Nametag v-for="tag in dataset.nameTags" :key="tag" :tag="tag" />
|
||||
</div>
|
||||
|
||||
<!-- expanded view with editors -->
|
||||
<header v-if="expanded" class="p-2">
|
||||
<ClickToEdit
|
||||
v-if="dataset.canEditName"
|
||||
tag-name="h4"
|
||||
:value="dataset.name"
|
||||
@input="$emit('updateDataset', { name: $event })"
|
||||
@input="$emit('update:dataset', { name: $event })"
|
||||
:display-label="dataset.title"
|
||||
:tooltip-title="'Edit dataset name...' | localize"
|
||||
tooltip-placement="left"
|
||||
@@ -73,12 +78,13 @@ either through the props, and make updates through the events -->
|
||||
<Annotation
|
||||
class="mt-1"
|
||||
:value="dataset.annotation"
|
||||
@input="$emit('updateDataset', { annotation: $event })"
|
||||
@input="$emit('update:dataset', { annotation: $event })"
|
||||
/>
|
||||
|
||||
<transition name="shutterfade">
|
||||
<ContentTags v-if="showTags" class="mt-2" :content="dataset" />
|
||||
</transition>
|
||||
<ContentTags v-if="showTags" class="mt-2" :content="dataset" />
|
||||
<div v-else-if="dataset.nameTags.length" class="nametags mt-2">
|
||||
<Nametag v-for="tag in dataset.nameTags" :key="tag" :tag="tag" />
|
||||
</div>
|
||||
|
||||
<div class="details">
|
||||
<DatasetSummary :dataset="dataset" />
|
||||
@@ -102,20 +108,16 @@ either through the props, and make updates through the events -->
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/* eslint-disable no-undef */
|
||||
// STATES is injected, eslint doesn't know that
|
||||
|
||||
import { Dataset } from "../../model";
|
||||
import { Dataset, STATES } from "../../model";
|
||||
import ClickToEdit from "components/ClickToEdit";
|
||||
import Annotation from "components/Annotation";
|
||||
import { Nametag } from "components/Nametags";
|
||||
import { StatusIcon, StateBtn } from "../../StatusIcon";
|
||||
import DatasetMenu from "./DatasetMenu";
|
||||
import DatasetSummary from "./Summary";
|
||||
import ContentTags from "../../ContentTags";
|
||||
import { legacyNavigationMixin } from "components/plugins";
|
||||
|
||||
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
|
||||
export default {
|
||||
inject: ["listState", "STATES"],
|
||||
mixins: [legacyNavigationMixin],
|
||||
components: {
|
||||
ClickToEdit,
|
||||
@@ -125,12 +127,13 @@ export default {
|
||||
DatasetMenu,
|
||||
DatasetSummary,
|
||||
ContentTags,
|
||||
Nametag,
|
||||
},
|
||||
props: {
|
||||
dataset: { type: Dataset, required: true },
|
||||
expanded: { type: Boolean, required: true },
|
||||
selected: { type: Boolean, required: false, default: false },
|
||||
showHid: { type: Boolean, required: false, default: true },
|
||||
showSelection: { type: Boolean, required: false, default: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -147,12 +150,6 @@ export default {
|
||||
ok() {
|
||||
return this.dataset.state == "ok";
|
||||
},
|
||||
counter() {
|
||||
return this.showHid ? this.dataset.hid : "";
|
||||
},
|
||||
showSelection() {
|
||||
return this.listState.showSelection;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onStatusClick(evt) {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import Dataset from "./Dataset";
|
||||
|
||||
export { default as DatasetUI } from "./DatasetUI";
|
||||
export { default as DatasetMenu } from "./DatasetMenu";
|
||||
export default Dataset;
|
||||
@@ -1,49 +0,0 @@
|
||||
<template>
|
||||
<DatasetUI
|
||||
v-if="dataset"
|
||||
v-bind="$attrs"
|
||||
v-on="$listeners"
|
||||
:dataset="dataset"
|
||||
@deleteDataset="onDelete(dataset, $event)"
|
||||
@undeleteDataset="onUndelete(dataset, $event)"
|
||||
@unhideDataset="onUnhide(dataset, $event)"
|
||||
@updateDataset="onUpdate(dataset, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DatasetUI from "./DatasetUI";
|
||||
import { Dataset } from "../../model";
|
||||
import { deleteContent, updateContentFields } from "../../model/queries";
|
||||
import { cacheContent } from "../../caching";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DatasetUI,
|
||||
},
|
||||
props: {
|
||||
item: { type: Object, required: true },
|
||||
},
|
||||
computed: {
|
||||
dataset() {
|
||||
return new Dataset(this.item);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async onDelete(dataset, opts = {}) {
|
||||
const ajaxResult = await deleteContent(dataset, opts);
|
||||
await cacheContent(ajaxResult);
|
||||
},
|
||||
async onUnhide(dataset) {
|
||||
await this.onUpdate(dataset, { visible: true });
|
||||
},
|
||||
async onUndelete(dataset) {
|
||||
await this.onUpdate(dataset, { deleted: false });
|
||||
},
|
||||
async onUpdate(dataset, changes) {
|
||||
const newContent = await updateContentFields(dataset, changes);
|
||||
await cacheContent(newContent);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<DscUI
|
||||
v-if="dsc"
|
||||
v-bind="$attrs"
|
||||
v-on="$listeners"
|
||||
class="dataset-collection"
|
||||
: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";
|
||||
import { cacheContent } from "../../caching";
|
||||
|
||||
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;
|
||||
const result = await deleteDatasetCollection(collection, recursive, purge);
|
||||
if (result.deleted) {
|
||||
const newFields = Object.assign(collection, {
|
||||
isDeleted: result.deleted,
|
||||
});
|
||||
await cacheContent(newFields);
|
||||
}
|
||||
},
|
||||
async onUnhide() {
|
||||
await this.onUpdate({ visible: true });
|
||||
},
|
||||
async onUndelete() {
|
||||
await this.onUpdate({ deleted: false });
|
||||
},
|
||||
async onUpdate(changes) {
|
||||
const newContent = await updateContentFields(this.item, changes);
|
||||
await cacheContent(newContent);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -5,13 +5,13 @@
|
||||
<span class="sr-only">Delete Collection</span>
|
||||
</template>
|
||||
|
||||
<b-dropdown-item @click.stop="$emit('deleteCollection')"> Delete Collection Only </b-dropdown-item>
|
||||
<b-dropdown-item @click.stop="$emit('delete')"> Delete Collection Only </b-dropdown-item>
|
||||
|
||||
<b-dropdown-item @click.stop="$emit('deleteCollection', { recursive: true })">
|
||||
<b-dropdown-item @click.stop="$emit('delete', { recursive: true })">
|
||||
Delete Contained Datasets
|
||||
</b-dropdown-item>
|
||||
|
||||
<b-dropdown-item @click.stop="$emit('deleteCollection', { recursive: true, purge: true })">
|
||||
<b-dropdown-item @click.stop="$emit('delete', { recursive: true, purge: true })">
|
||||
Purge Contained Datasets
|
||||
</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
|
||||
@@ -6,12 +6,9 @@
|
||||
class="dataset dataset-collection collapsed"
|
||||
:class="{ selected }"
|
||||
:data-state="dsc.state"
|
||||
@keydown.arrow-right.self.stop="$emit('select-collection', dsc)"
|
||||
@keydown.arrow-right.self.stop="$emit('viewCollection')"
|
||||
@keydown.space.self.stop.prevent="$emit('update:selected', !selected)"
|
||||
@click.stop="
|
||||
$emit('select-collection', dsc);
|
||||
$emit('update:expanded', dsc);
|
||||
"
|
||||
@click.stop="$emit('viewCollection')"
|
||||
>
|
||||
<nav class="d-flex content-top-menu align-items-center justify-content-between">
|
||||
<div class="d-flex mr-1 align-items-center" @click.stop>
|
||||
@@ -30,7 +27,7 @@
|
||||
state="hidden"
|
||||
title="Unhide"
|
||||
icon="fa fa-eye-slash"
|
||||
@click.stop="$emit('unhideCollection')"
|
||||
@click.stop="$emit('unhide')"
|
||||
/>
|
||||
|
||||
<StateBtn
|
||||
@@ -38,7 +35,7 @@
|
||||
state="ok"
|
||||
title="Collection"
|
||||
icon="fas fa-folder"
|
||||
@click.stop="$emit('select-collection', dsc)"
|
||||
@click.stop="$emit('viewCollection')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -50,19 +47,26 @@
|
||||
</span>
|
||||
</h5>
|
||||
|
||||
<DscMenu v-if="!dsc.deleted" class="content-item-menu" v-on="$listeners" />
|
||||
<slot name="menu">
|
||||
<DscMenu v-if="!dsc.deleted" class="content-item-menu" v-on="$listeners" />
|
||||
</slot>
|
||||
|
||||
<StateBtn
|
||||
v-if="dsc.deleted"
|
||||
class="px-1"
|
||||
state="deleted"
|
||||
title="Undelete"
|
||||
icon="fas fa-trash-restore"
|
||||
@click.stop="$emit('undeleteCollection')"
|
||||
@click.stop="$emit('undelete')"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<!--- read-only tags with name: prefix -->
|
||||
<div v-if="dsc.nameTags.length" class="nametags p-1">
|
||||
<Nametag v-for="tag in dsc.nameTags" :key="tag" :tag="tag" />
|
||||
</div>
|
||||
|
||||
<JobStateProgress class="m-2" v-if="dsc.jobSummary" :summary="dsc.jobSummary" />
|
||||
<div v-else>No summary</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -71,27 +75,20 @@ import { DatasetCollection } from "../../model/DatasetCollection";
|
||||
import { StatusIcon, StateBtn } from "../../StatusIcon";
|
||||
import JobStateProgress from "./JobStateProgress";
|
||||
import DscMenu from "./DscMenu";
|
||||
import { Nametag } from "components/Nametags";
|
||||
|
||||
export default {
|
||||
inject: ["listState", "STATES"],
|
||||
components: {
|
||||
StatusIcon,
|
||||
StateBtn,
|
||||
JobStateProgress,
|
||||
DscMenu,
|
||||
Nametag,
|
||||
},
|
||||
props: {
|
||||
dsc: { type: DatasetCollection, required: true },
|
||||
selected: { type: Boolean, required: false, default: false },
|
||||
showHid: { type: Boolean, required: false, default: true },
|
||||
},
|
||||
computed: {
|
||||
counter() {
|
||||
return this.showHid ? this.dsc.hid : "";
|
||||
},
|
||||
showSelection() {
|
||||
return this.listState.showSelection;
|
||||
},
|
||||
showSelection: { type: Boolean, required: false, default: false },
|
||||
},
|
||||
methods: {
|
||||
onStatusClick() {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
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,67 +0,0 @@
|
||||
<template>
|
||||
<DscUI
|
||||
v-if="dsc"
|
||||
v-bind="$attrs"
|
||||
v-on="$listeners"
|
||||
class="dataset-collection"
|
||||
:dsc="dsc"
|
||||
@hideCollection="onHide(dsc)"
|
||||
@unhideCollection="onUnhide(dsc, $event)"
|
||||
@deleteCollection="onDelete(dsc, $event)"
|
||||
@undeleteCollection="onUndelete(dsc)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DscUI from "./DscUI";
|
||||
import { DatasetCollection } from "../../model";
|
||||
import { deleteDatasetCollection } from "../../model/queries";
|
||||
import { cacheContent } from "../../caching";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DscUI,
|
||||
},
|
||||
props: {
|
||||
item: { type: Object, required: true },
|
||||
},
|
||||
computed: {
|
||||
dsc() {
|
||||
return new DatasetCollection(this.item);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async onDelete(collection, flags = {}) {
|
||||
const { recursive = false, purge = false } = flags;
|
||||
const result = await deleteDatasetCollection(collection, recursive, purge);
|
||||
if (result.deleted) {
|
||||
const newFields = Object.assign(collection, {
|
||||
isDeleted: result.deleted,
|
||||
});
|
||||
await cacheContent(newFields);
|
||||
}
|
||||
},
|
||||
|
||||
async onUndelete(collection) {
|
||||
console.log("onUndelete", ...arguments);
|
||||
// await updateDatasetCollection(collection, {
|
||||
// deleted: false
|
||||
// });
|
||||
},
|
||||
// This a valid thing to do?
|
||||
async onHide(collection) {
|
||||
console.log("onHide", ...arguments);
|
||||
// await updateDatasetCollection(collection, {
|
||||
// visible: false
|
||||
// });
|
||||
},
|
||||
async onUnhide(collection) {
|
||||
console.log("onUnhide", ...arguments);
|
||||
// console.log("hideCollection, can we do this?", collection);
|
||||
// await updateDatasetCollection(collection, {
|
||||
// visible: true
|
||||
// });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -7,10 +7,6 @@ export default {
|
||||
contentItemComponent() {
|
||||
if (this.item.id === undefined) {
|
||||
return "Placeholder";
|
||||
// return "Loading";
|
||||
}
|
||||
if (this.scrolling) {
|
||||
return "Placeholder";
|
||||
}
|
||||
const { history_content_type } = this.item;
|
||||
switch (history_content_type) {
|
||||
|
||||
@@ -7,12 +7,10 @@
|
||||
v-on="$listeners"
|
||||
class="collapsed"
|
||||
:data-state="state"
|
||||
@keydown.arrow-right.self.stop.prevent="$emit('select-collection', dsc)"
|
||||
@keydown.arrow-right.self.stop.prevent="$emit('viewCollection')"
|
||||
@click.stop="$emit('viewCollection')"
|
||||
>
|
||||
<nav
|
||||
class="content-top-menu d-flex align-items-center justify-content-between p-1"
|
||||
@click.stop="$emit('select-collection', dsc)"
|
||||
>
|
||||
<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">{{ dsc.name }}</span>
|
||||
<span class="description">
|
||||
@@ -25,9 +23,9 @@
|
||||
|
||||
<script>
|
||||
import { DatasetCollection } from "../model/DatasetCollection";
|
||||
import { STATES } from "../model";
|
||||
|
||||
export default {
|
||||
inject: ["STATES"],
|
||||
props: {
|
||||
item: { type: Object, required: true },
|
||||
index: { type: Number, required: true },
|
||||
@@ -40,7 +38,7 @@ export default {
|
||||
// TODO: see if there are situations where this rule won't work
|
||||
// subcollection doesn't have as much information as the collection
|
||||
// did, but it's probably ok
|
||||
return this.dsc.state || this.STATES.OK;
|
||||
return this.dsc.state || STATES.OK;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<section>
|
||||
<header class="d-flex align-items-center justify-content-between">
|
||||
<h6 class="text-nowrap mr-3">
|
||||
<span>Matches</span>
|
||||
<span v-localize>Matches</span>
|
||||
<a href="#" @click="showFilter = !showFilter">
|
||||
<span>{{ totalMatches }}</span>
|
||||
</a>
|
||||
@@ -21,7 +21,7 @@
|
||||
key="selection"
|
||||
title="Operations on multiple datasets"
|
||||
icon="fas fa-check-square"
|
||||
@click="$emit('update:showSelection', !showSelection)"
|
||||
@click="$emit('update:show-selection', !showSelection)"
|
||||
:pressed="showSelection"
|
||||
/>
|
||||
|
||||
@@ -143,11 +143,13 @@ import {
|
||||
import { createDatasetCollection } from "./model/queries";
|
||||
import { cacheContent } from "./caching";
|
||||
|
||||
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
|
||||
import ContentFilters from "./ContentFilters";
|
||||
import { PriorityMenu, PriorityMenuItem } from "components/PriorityMenu";
|
||||
import { buildCollectionModal } from "./adapters/buildCollectionModal";
|
||||
|
||||
export default {
|
||||
mixins: [legacyNavigationMixin],
|
||||
components: {
|
||||
ContentFilters,
|
||||
PriorityMenu,
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
<div slot="modal-footer" slot-scope="{ ok, cancel }">
|
||||
<div>
|
||||
<b-button @click="cancel()" class="mr-3"> Cancel </b-button>
|
||||
<b-button @click="copyHistory(ok)" :variant="saveVariant" :disabled="!formValid">
|
||||
{{ saveTitle }}
|
||||
<b-button @click="copy(ok)" :variant="saveVariant" :disabled="!formValid">
|
||||
{{ saveTitle | localize }}
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,7 +46,6 @@
|
||||
|
||||
<script>
|
||||
import { History } from "./model/History";
|
||||
import { cloneHistory } from "./model/queries";
|
||||
import { mapActions, mapGetters } from "vuex";
|
||||
|
||||
export default {
|
||||
@@ -94,14 +93,12 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions("betaHistory", ["storeCurrentHistoryId", "storeHistory"]),
|
||||
...mapActions("betaHistory", ["copyHistory"]),
|
||||
|
||||
async copyHistory(close) {
|
||||
async copy(close) {
|
||||
this.loading = true;
|
||||
const { history, name, copyAll } = this;
|
||||
const newHistory = await cloneHistory(history, name, copyAll);
|
||||
await this.storeHistory(newHistory);
|
||||
await this.storeCurrentHistoryId(newHistory.id);
|
||||
await this.copyHistory({ history, name, copyAll });
|
||||
this.loading = false;
|
||||
close();
|
||||
},
|
||||
|
||||
+6
-4
@@ -17,27 +17,29 @@
|
||||
</p>
|
||||
|
||||
<transition name="shutterfade">
|
||||
<ContentTags v-if="writable && showTags" :content="dsc" />
|
||||
<ContentTags v-if="writable" :content="dsc" />
|
||||
<div v-else-if="dsc.tags && dsc.tags.length" class="nametags p-1">
|
||||
<Nametag v-for="tag in dsc.tags" :key="tag" :tag="tag" />
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- <pre style="height:100px;overflow: scroll;">{{ dsc }}</pre> -->
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { DatasetCollection } from "../model";
|
||||
import ClickToEdit from "components/ClickToEdit";
|
||||
import { Nametag } from "components/Nametags";
|
||||
import ContentTags from "../ContentTags";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ClickToEdit,
|
||||
ContentTags,
|
||||
Nametag,
|
||||
},
|
||||
props: {
|
||||
dsc: { type: DatasetCollection, required: true },
|
||||
writable: { type: Boolean, required: true },
|
||||
showTags: { type: Boolean, required: true },
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,109 @@
|
||||
<!-- When a dataset collection is being viewed, this panel shows the contents of that collection -->
|
||||
|
||||
<template>
|
||||
<DscProvider :is-root="isRoot" :collection="selectedCollection" v-slot="{ dsc }">
|
||||
<CollectionContentProvider
|
||||
v-if="dsc"
|
||||
:parent="dsc"
|
||||
v-slot="{
|
||||
payload: { contents = [], startKey = null, topRows = 0, bottomRows = 0 },
|
||||
handlers: { setScrollPos },
|
||||
}"
|
||||
>
|
||||
<ExpandedItems
|
||||
:scope-key="selectedCollection.id"
|
||||
:get-item-key="(item) => item.type_id"
|
||||
v-slot="{ isExpanded, setExpanded }"
|
||||
>
|
||||
<Layout>
|
||||
<template v-slot:nav>
|
||||
<TopNav :history="history" :selected-collections="selectedCollections" v-on="$listeners" />
|
||||
</template>
|
||||
|
||||
<template v-slot:details>
|
||||
<Details :dsc="dsc" :writable="writable" @update:dsc="updateDsc(dsc, $event)" />
|
||||
</template>
|
||||
|
||||
<template v-slot:listing>
|
||||
<VirtualScroller
|
||||
key-field="element_index"
|
||||
:item-height="36"
|
||||
:items="contents"
|
||||
:scroll-to="startKey"
|
||||
:top-placeholders="topRows"
|
||||
:bottom-placeholders="bottomRows"
|
||||
@scroll="setScrollPos"
|
||||
>
|
||||
<template v-slot="{ item, index }">
|
||||
<CollectionContentItem
|
||||
:item="item"
|
||||
:index="index"
|
||||
:expanded="isExpanded(item)"
|
||||
:writable="writable"
|
||||
@update:expanded="setExpanded(item, $event)"
|
||||
@viewCollection="$emit('viewCollection', item)"
|
||||
/>
|
||||
</template>
|
||||
</VirtualScroller>
|
||||
</template>
|
||||
</Layout>
|
||||
</ExpandedItems>
|
||||
</CollectionContentProvider>
|
||||
</DscProvider>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { History } from "../model";
|
||||
import { updateContentFields } from "../model/queries";
|
||||
import { cacheContent } from "../caching";
|
||||
|
||||
import { DscProvider, CollectionContentProvider, ExpandedItems } from "../providers";
|
||||
import Layout from "../Layout";
|
||||
import TopNav from "./TopNav";
|
||||
import Details from "./Details";
|
||||
import VirtualScroller from "../../VirtualScroller";
|
||||
import { CollectionContentItem } from "../ContentItem";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DscProvider,
|
||||
CollectionContentProvider,
|
||||
Layout,
|
||||
TopNav,
|
||||
Details,
|
||||
VirtualScroller,
|
||||
CollectionContentItem,
|
||||
ExpandedItems,
|
||||
},
|
||||
props: {
|
||||
history: { type: History, required: true },
|
||||
selectedCollections: { type: Array, required: true },
|
||||
},
|
||||
computed: {
|
||||
selectedCollection() {
|
||||
const arr = this.selectedCollections;
|
||||
const selected = arr[arr.length - 1];
|
||||
return selected;
|
||||
},
|
||||
isRoot() {
|
||||
return this.selectedCollection == this.selectedCollections[0];
|
||||
},
|
||||
writable() {
|
||||
return this.isRoot;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// 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) {
|
||||
// console.log("updateDsc", this.writable, collection, fields);
|
||||
if (this.writable) {
|
||||
const ajaxResult = await updateContentFields(collection, fields);
|
||||
await cacheContent({ ...collection, ...ajaxResult });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
-16
@@ -10,13 +10,6 @@
|
||||
<b-button v-else size="sm" class="mr-auto" @click="close"> Back to: {{ history.name }} </b-button>
|
||||
|
||||
<PriorityMenu :starting-height="27">
|
||||
<PriorityMenuItem
|
||||
key="filter"
|
||||
title="Filter History Content"
|
||||
icon="fa fa-filter"
|
||||
@click="$emit('update:showFilter', !showFilter)"
|
||||
:pressed="showFilter"
|
||||
/>
|
||||
<PriorityMenuItem
|
||||
key="download-collection"
|
||||
title="Download Collection"
|
||||
@@ -25,13 +18,6 @@
|
||||
download
|
||||
:href="downloadCollectionUrl"
|
||||
/>
|
||||
<PriorityMenuItem
|
||||
key="edit-tags"
|
||||
title="Edit Collection Tags"
|
||||
icon="fas fa-tags"
|
||||
:pressed="showTags"
|
||||
@click="$emit('update:showTags', !showTags)"
|
||||
/>
|
||||
<PriorityMenuItem key="back-up" title="Back One" icon="fas fa-level-up-alt" @click="back" />
|
||||
</PriorityMenu>
|
||||
</div>
|
||||
@@ -49,8 +35,6 @@ export default {
|
||||
props: {
|
||||
history: { type: History, required: true },
|
||||
selectedCollections: { type: Array, required: true, validate: (val) => val.length > 0 },
|
||||
showFilter: { type: Boolean, required: true },
|
||||
showTags: { type: Boolean, required: true },
|
||||
},
|
||||
computed: {
|
||||
rootCollection() {
|
||||
@@ -1,38 +1,40 @@
|
||||
<template>
|
||||
<HistoryPanel v-if="historyId" :history-id="historyId">
|
||||
<template v-slot:nav>
|
||||
<HistoryTopNav :selected-history-id.sync="historyId" />
|
||||
</template>
|
||||
</HistoryPanel>
|
||||
<div v-else class="flex-grow-1 loadingBackground h-100">
|
||||
<span class="sr-only">Loading History...</span>
|
||||
</div>
|
||||
<CurrentUser v-slot="{ user }">
|
||||
<UserHistories v-if="user" :user="user" v-slot="{ currentHistory, histories, handlers }">
|
||||
<HistoryPanel v-if="currentHistory" :history="currentHistory" v-on="handlers">
|
||||
<template v-slot:nav>
|
||||
<div>
|
||||
<HistorySelector
|
||||
:histories="histories"
|
||||
:current-history="currentHistory"
|
||||
@update:currentHistory="handlers.setCurrentHistory"
|
||||
/>
|
||||
<HistoriesMenu v-on="handlers" />
|
||||
</div>
|
||||
</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 { mapActions, mapGetters } from "vuex";
|
||||
import HistoryTopNav from "./HistoryTopNav";
|
||||
import CurrentUser from "components/providers/CurrentUser";
|
||||
import UserHistories from "./providers/UserHistories";
|
||||
import HistoryPanel from "./HistoryPanel";
|
||||
import HistorySelector from "./HistorySelector";
|
||||
import HistoriesMenu from "./HistoriesMenu";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CurrentUser,
|
||||
UserHistories,
|
||||
HistoryPanel,
|
||||
HistoryTopNav,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters("betaHistory", ["currentHistoryId"]),
|
||||
|
||||
historyId: {
|
||||
get() {
|
||||
return this.currentHistoryId;
|
||||
},
|
||||
set(newId) {
|
||||
this.storeCurrentHistoryId(newId);
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions("betaHistory", ["storeCurrentHistoryId"]),
|
||||
HistorySelector,
|
||||
HistoriesMenu,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<PriorityMenu :starting-height="27">
|
||||
<PriorityMenuItem
|
||||
key="create-new-history"
|
||||
title="Create New History"
|
||||
icon="fa fa-plus"
|
||||
@click="$emit('createNewHistory')"
|
||||
/>
|
||||
<PriorityMenuItem
|
||||
key="view-all-histories"
|
||||
title="View All Histories"
|
||||
icon="fa fa-columns"
|
||||
@click="redirect('/history/view_multiple')"
|
||||
/>
|
||||
<PriorityMenuItem
|
||||
key="saved-histories"
|
||||
title="Saved Histories"
|
||||
icon="fas fa-save"
|
||||
@click="backboneRoute('/histories/list')"
|
||||
/>
|
||||
<PriorityMenuItem key="clear-history-cache" title="Clear Cache" icon="fa fa-refresh" @click.stop="clearCache" />
|
||||
<PriorityMenuItem
|
||||
key="use-legacy-history"
|
||||
title="Return to legacy history panel"
|
||||
icon="fas fa-exchange-alt"
|
||||
@click="switchToLegacyHistoryPanel"
|
||||
/>
|
||||
</PriorityMenu>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { PriorityMenuItem, PriorityMenu } from "components/PriorityMenu";
|
||||
import { wipeDatabase, clearHistoryDateStore } from "./caching";
|
||||
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
|
||||
import { switchToLegacyHistoryPanel } from "./adapters/betaToggle";
|
||||
import { reloadPage } from "utils/redirect";
|
||||
|
||||
export default {
|
||||
mixins: [legacyNavigationMixin],
|
||||
components: {
|
||||
PriorityMenu,
|
||||
PriorityMenuItem,
|
||||
},
|
||||
methods: {
|
||||
async clearCache() {
|
||||
await wipeDatabase();
|
||||
await clearHistoryDateStore();
|
||||
reloadPage();
|
||||
},
|
||||
switchToLegacyHistoryPanel,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -3,90 +3,111 @@
|
||||
:parent="history"
|
||||
v-slot="{
|
||||
loading,
|
||||
scrolling,
|
||||
params,
|
||||
pageSize,
|
||||
payload: {
|
||||
contents = [],
|
||||
startKey = null,
|
||||
topRows = 0,
|
||||
bottomRows = 0,
|
||||
totalMatches = 0,
|
||||
},
|
||||
updateParams,
|
||||
setScrollPos,
|
||||
manualReload,
|
||||
payload: { contents = [], startKey = null, topRows = 0, bottomRows = 0, totalMatches = 0 },
|
||||
handlers: { updateParams, manualReload, setScrollPos },
|
||||
}"
|
||||
>
|
||||
<Layout>
|
||||
<!-- optional top-nav slot, for right-side history panel -->
|
||||
<template v-slot:nav>
|
||||
<slot name="nav"></slot>
|
||||
</template>
|
||||
|
||||
<template v-slot:details>
|
||||
<HistoryDetails class="history-details" :history="history" />
|
||||
</template>
|
||||
|
||||
<template v-slot:messages>
|
||||
<HistoryMessages class="history-messages m-2" :history="history" />
|
||||
<HistoryEmpty v-if="history.empty" class="m-2" />
|
||||
</template>
|
||||
|
||||
<template v-slot:listcontrols>
|
||||
<ContentOperations
|
||||
:history="history"
|
||||
:params="params"
|
||||
@update:params="updateParams"
|
||||
:total-matches="totalMatches"
|
||||
:loading="loading"
|
||||
:content-selection.sync="listState.selected"
|
||||
:show-selection.sync="listState.showSelection"
|
||||
@resetSelection="resetSelection"
|
||||
@selectAllContent="selectItems(contents)"
|
||||
@manualReload="manualReload"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-slot:listing>
|
||||
<VirtualScroller
|
||||
:class="{ loadingBackground: loading }"
|
||||
key-field="hid"
|
||||
:item-height="36"
|
||||
:items="contents"
|
||||
:scroll-to="startKey"
|
||||
:top-placeholders="topRows"
|
||||
:bottom-placeholders="bottomRows"
|
||||
@scroll="setScrollPos"
|
||||
>
|
||||
<template v-slot="{ item, index }">
|
||||
<HistoryContentItem :item="item" :index="index" v-on="$listeners" />
|
||||
<ExpandedItems
|
||||
:scope-key="history.id"
|
||||
:get-item-key="(item) => item.type_id"
|
||||
v-slot="{ isExpanded, setExpanded }"
|
||||
>
|
||||
<SelectedItems
|
||||
:scope-key="history.id"
|
||||
:get-item-key="(item) => item.type_id"
|
||||
v-slot="{
|
||||
selectedItems,
|
||||
showSelection,
|
||||
setShowSelection,
|
||||
selectItems,
|
||||
isSelected,
|
||||
setSelected,
|
||||
resetSelection,
|
||||
}"
|
||||
>
|
||||
<Layout>
|
||||
<template v-slot:nav>
|
||||
<slot name="nav"></slot>
|
||||
</template>
|
||||
</VirtualScroller>
|
||||
</template>
|
||||
|
||||
<template v-slot:modals>
|
||||
<ToolHelpModal />
|
||||
</template>
|
||||
</Layout>
|
||||
<template v-slot:details>
|
||||
<HistoryDetails class="history-details" :history="history" v-on="$listeners" />
|
||||
</template>
|
||||
|
||||
<template v-slot:messages>
|
||||
<HistoryMessages class="history-messages m-2" :history="history" />
|
||||
</template>
|
||||
|
||||
<template v-slot:listcontrols>
|
||||
<ContentOperations
|
||||
v-if="!history.empty"
|
||||
:history="history"
|
||||
:total-matches="totalMatches"
|
||||
:loading="loading"
|
||||
:params="params"
|
||||
@update:params="updateParams"
|
||||
:content-selection="selectedItems"
|
||||
@update:content-selection="selectItems"
|
||||
:show-selection="showSelection"
|
||||
@update:show-selection="setShowSelection"
|
||||
@resetSelection="resetSelection"
|
||||
@selectAllContent="selectItems(contents)"
|
||||
@manualReload="manualReload"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-slot:listing>
|
||||
<HistoryEmpty v-if="history.empty" class="m-2" />
|
||||
|
||||
<VirtualScroller
|
||||
v-else
|
||||
:class="{ loadingBackground: loading }"
|
||||
key-field="hid"
|
||||
:item-height="36"
|
||||
:items="contents"
|
||||
:scroll-to="startKey"
|
||||
:top-placeholders="topRows"
|
||||
:bottom-placeholders="bottomRows"
|
||||
@scroll="setScrollPos"
|
||||
>
|
||||
<template v-slot="{ item, index }">
|
||||
<HistoryContentItem
|
||||
:item="item"
|
||||
:index="index"
|
||||
:show-selection="showSelection"
|
||||
:expanded="isExpanded(item)"
|
||||
@update:expanded="setExpanded(item, $event)"
|
||||
:selected="isSelected(item)"
|
||||
@update:selected="setSelected(item, $event)"
|
||||
@viewCollection="$emit('viewCollection', item)"
|
||||
/>
|
||||
</template>
|
||||
</VirtualScroller>
|
||||
</template>
|
||||
|
||||
<template v-slot:modals>
|
||||
<ToolHelpModal />
|
||||
</template>
|
||||
</Layout>
|
||||
</SelectedItems>
|
||||
</ExpandedItems>
|
||||
</HistoryContentProvider>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { History } from "./model";
|
||||
import { HistoryContentProvider } from "./providers";
|
||||
import { HistoryContentProvider, ExpandedItems, SelectedItems } from "./providers";
|
||||
import Layout from "./Layout";
|
||||
import HistoryMessages from "./HistoryMessages";
|
||||
import HistoryDetails from "./HistoryDetails";
|
||||
import HistoryEmpty from "./HistoryEmpty";
|
||||
import ContentOperations from "./ContentOperations";
|
||||
import ToolHelpModal from "./ToolHelpModal";
|
||||
import ListMixin from "./ListMixin";
|
||||
import VirtualScroller from "../VirtualScroller";
|
||||
import { HistoryContentItem } from "./ContentItem";
|
||||
|
||||
export default {
|
||||
mixins: [ListMixin],
|
||||
components: {
|
||||
HistoryContentProvider,
|
||||
Layout,
|
||||
@@ -97,6 +118,8 @@ export default {
|
||||
ToolHelpModal,
|
||||
VirtualScroller,
|
||||
HistoryContentItem,
|
||||
ExpandedItems,
|
||||
SelectedItems,
|
||||
},
|
||||
props: {
|
||||
history: { type: History, required: true },
|
||||
@@ -106,13 +129,5 @@ export default {
|
||||
return this.history.id;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
historyId(newId, oldId) {
|
||||
if (newId && newId !== oldId) {
|
||||
this.resetSelection();
|
||||
this.resetExpanded();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<section>
|
||||
<header class="d-flex justify-content-between align-items-center">
|
||||
<header>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h6 class="text-nowrap text-truncate mr-auto">
|
||||
<span class="size">{{ history.niceSize | localize }}</span>
|
||||
</h6>
|
||||
|
||||
<PriorityMenu style="max-width: 50%;" :starting-height="27">
|
||||
<PriorityMenu style="max-width: 50%;" :starting-height="28">
|
||||
<PriorityMenuItem
|
||||
key="edit-history-tags"
|
||||
title="Edit History Tags"
|
||||
@@ -13,7 +13,6 @@
|
||||
:pressed="showTags"
|
||||
@click="showTags = !showTags"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="edit-history-annotation"
|
||||
title="Edit Annotation"
|
||||
@@ -21,109 +20,76 @@
|
||||
:pressed="showAnnotation"
|
||||
@click="showAnnotation = !showAnnotation"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="copy-history"
|
||||
title="Copy History"
|
||||
icon="fas fa-copy"
|
||||
@click="showCopyModal = true"
|
||||
@click="showCopyModal = !showCopyModal"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="share"
|
||||
title="Share or Publish"
|
||||
icon="fas fa-handshake"
|
||||
@click="backboneRoute('/histories/sharing', { id: history.id })"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="delete-this-history"
|
||||
title="Delete this History"
|
||||
icon="fas fa-trash"
|
||||
@click="$bvModal.show('delete-history-modal')"
|
||||
@click="showDeleteModal = !showDeleteModal"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="purge-this-history"
|
||||
title="Purge this History"
|
||||
icon="fas fa-burn"
|
||||
@click="$bvModal.show('purge-history-modal')"
|
||||
@click="showPurgeModal = !showPurgeModal"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="structure"
|
||||
title="Show Structure"
|
||||
icon="fas fa-code-branch"
|
||||
@click="backboneRoute('/histories/show_structure')"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="extract-workflow"
|
||||
title="Extract Workflow"
|
||||
icon="fas fa-file-export"
|
||||
@click="iframeRedirect('/workflow/build_from_current_history')"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="set-permissions"
|
||||
title="Set Permissions"
|
||||
icon="fas fa-user-lock"
|
||||
@click="backboneRoute('/histories/permissions', { id: history.id })"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="make-private"
|
||||
title="Make Private"
|
||||
icon="fas fa-lock"
|
||||
@click="$bvModal.show('make-private-modal')"
|
||||
@click="showPrivacyModal = !showPrivacyModal"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="resume-paused-jobs"
|
||||
title="Resume Paused Jobs"
|
||||
icon="fas fa-play"
|
||||
@click="iframeRedirect('/history/resume_paused_jobs?current=True')"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="export-citations"
|
||||
title="Export Tool Citations"
|
||||
icon="fas fa-stream"
|
||||
@click="backboneRoute('/histories/citations', { id: history.id })"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="export-history-to-file"
|
||||
title="Export History to File"
|
||||
icon="fas fa-file-archive"
|
||||
@click="backboneRoute(`/histories/${history.id}/export`)"
|
||||
@click="iframeRedirect('/history/export_archive?preview=True')"
|
||||
/>
|
||||
</PriorityMenu>
|
||||
</div>
|
||||
|
||||
<CopyModal v-model="showCopyModal" :history="history" />
|
||||
|
||||
<b-modal id="make-private-modal" title="Make History Private" title-tag="h2" @ok="makePrivate">
|
||||
<p v-localize>
|
||||
This will make all the data in this history private (excluding library datasets), and will set
|
||||
permissions such that all new data is created as private. Any datasets within that are currently
|
||||
shared will need to be re-shared or published. Are you sure you want to do this?
|
||||
</p>
|
||||
</b-modal>
|
||||
|
||||
<b-modal id="delete-history-modal" title="Delete History?" title-tag="h2" @ok="deleteHistoryClick">
|
||||
<p v-localize>Really delete the current history?</p>
|
||||
</b-modal>
|
||||
|
||||
<b-modal
|
||||
id="purge-history-modal"
|
||||
title="Permanently Delete History?"
|
||||
title-tag="h2"
|
||||
@ok="purgeHistoryClick"
|
||||
>
|
||||
<p v-localize>Really delete the current history permanently? This cannot be undone.</p>
|
||||
</b-modal>
|
||||
</header>
|
||||
|
||||
<!-- click to edit the name of the history -->
|
||||
<ClickToEdit
|
||||
class="history-title mt-2"
|
||||
tag-name="h3"
|
||||
@@ -139,31 +105,49 @@
|
||||
<transition name="shutterfade">
|
||||
<HistoryTags v-if="showTags" class="mt-2" :history="history" />
|
||||
</transition>
|
||||
</section>
|
||||
|
||||
<!-- #region modals -->
|
||||
|
||||
<CopyHistoryModal v-model="showCopyModal" :history="history" />
|
||||
|
||||
<b-modal v-model="showPrivacyModal" title="Make History Private" title-tag="h2" @ok="makePrivate">
|
||||
<p v-localize>
|
||||
This will make all the data in this history private (excluding library datasets), and will set
|
||||
permissions such that all new data is created as private. Any datasets within that are currently shared
|
||||
will need to be re-shared or published. Are you sure you want to do this?
|
||||
</p>
|
||||
</b-modal>
|
||||
|
||||
<b-modal v-model="showDeleteModal" title="Delete History?" title-tag="h2" @ok="deleteHistory">
|
||||
<p v-localize>Really delete the current history?</p>
|
||||
</b-modal>
|
||||
|
||||
<b-modal v-model="showPurgeModal" title="Permanently Delete History?" title-tag="h2" @ok="purgeHistory">
|
||||
<p v-localize>Really delete the current history permanently? This cannot be undone.</p>
|
||||
</b-modal>
|
||||
|
||||
<!-- #endregion -->
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions } from "vuex";
|
||||
|
||||
import { History } from "./model";
|
||||
import { deleteHistoryById, updateHistoryFields, secureHistory } from "./model/queries";
|
||||
|
||||
import { PriorityMenuItem, PriorityMenu } from "components/PriorityMenu";
|
||||
import { legacyNavigationMixin } from "components/plugins/legacyNavigation";
|
||||
import ClickToEdit from "components/ClickToEdit";
|
||||
import Annotation from "components/Annotation";
|
||||
import { PriorityMenuItem, PriorityMenu } from "components/PriorityMenu";
|
||||
import HistoryTags from "./HistoryTags";
|
||||
import CopyModal from "./CopyModal";
|
||||
import { legacyNavigationMixin } from "components/plugins";
|
||||
import CopyHistoryModal from "./CopyModal";
|
||||
|
||||
export default {
|
||||
mixins: [legacyNavigationMixin],
|
||||
components: {
|
||||
HistoryTags,
|
||||
CopyModal,
|
||||
Annotation,
|
||||
ClickToEdit,
|
||||
PriorityMenu,
|
||||
PriorityMenuItem,
|
||||
HistoryTags,
|
||||
Annotation,
|
||||
ClickToEdit,
|
||||
CopyHistoryModal,
|
||||
},
|
||||
props: {
|
||||
history: { type: History, required: true },
|
||||
@@ -173,7 +157,9 @@ export default {
|
||||
showTags: false,
|
||||
showAnnotation: false,
|
||||
showCopyModal: false,
|
||||
editAnnotation: false,
|
||||
showPrivacyModal: false,
|
||||
showDeleteModal: false,
|
||||
showPurgeModal: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -190,7 +176,7 @@ export default {
|
||||
|
||||
historyName: {
|
||||
get() {
|
||||
return this.history.name;
|
||||
return this.history?.name || "";
|
||||
},
|
||||
set(name) {
|
||||
if (name.length && name !== this.history.name) {
|
||||
@@ -200,30 +186,19 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions("betaHistory", ["storeHistory"]),
|
||||
|
||||
async updateFields(fields = {}) {
|
||||
const updatedHistory = await updateHistoryFields(this.history, fields);
|
||||
await this.storeHistory(updatedHistory);
|
||||
this.$emit("updateHistory", { id: this.history.id, ...fields });
|
||||
},
|
||||
|
||||
async deleteHistoryClick(evt) {
|
||||
const updatedHistory = await deleteHistoryById(this.history.id);
|
||||
// console.log("deleteHistoryClick", updatedHistory);
|
||||
await this.storeHistory(updatedHistory);
|
||||
async deleteHistory(evt) {
|
||||
this.$emit("deleteHistory", this.history);
|
||||
evt.vueTarget.hide();
|
||||
},
|
||||
|
||||
async purgeHistoryClick(evt) {
|
||||
const updatedHistory = await deleteHistoryById(this.history.id, true);
|
||||
// console.log("purgeHistoryClick", updatedHistory);
|
||||
await this.storeHistory(updatedHistory);
|
||||
async purgeHistory(evt) {
|
||||
this.$emit("purgeHistory", this.history);
|
||||
evt.vueTarget.hide();
|
||||
},
|
||||
|
||||
async makePrivate(evt) {
|
||||
const updatedHistory = await secureHistory(this.history.id);
|
||||
await this.storeHistory(updatedHistory);
|
||||
this.$emit("secureHistory", this.history);
|
||||
evt.vueTarget.hide();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
<b-alert show>
|
||||
<h4>
|
||||
<i class="fa fa-info-circle"></i>
|
||||
This history is empty.
|
||||
<span v-localize>This history is empty.</span>
|
||||
</h4>
|
||||
<p>
|
||||
You can <a href="#" @click.prevent="openUploader" v-localize>load your own data</a> or
|
||||
<a href="#" @click.prevent="openUploader" v-localize>You can load your own data</a> or
|
||||
<a href="#" @click.prevent="clickDataLink" v-localize>get data from an external source</a>.
|
||||
</p>
|
||||
</b-alert>
|
||||
@@ -15,10 +15,7 @@
|
||||
export default {
|
||||
methods: {
|
||||
openUploader() {
|
||||
this.eventHub.$emit("openToolSection", "getext");
|
||||
this.$nextTick(() => {
|
||||
this.eventHub.$emit("clickTool", "upload1");
|
||||
});
|
||||
this.eventHub.$emit("upload:open");
|
||||
},
|
||||
clickDataLink() {
|
||||
this.eventHub.$emit("openToolSection", "getext");
|
||||
|
||||
@@ -2,20 +2,22 @@
|
||||
selected datset collections -->
|
||||
|
||||
<template>
|
||||
<History
|
||||
v-on:select-collection="selectCollection($event)"
|
||||
v-if="history && !selectedCollections.length"
|
||||
<HistoryComponent
|
||||
v-if="history && !breadcrumbs.length"
|
||||
:history="history"
|
||||
v-on="$listeners"
|
||||
@viewCollection="drillDown"
|
||||
>
|
||||
<template v-for="(_, name) in $scopedSlots" :slot="name" slot-scope="slotData">
|
||||
<slot :name="name" v-bind="slotData" :history="history" />
|
||||
</template>
|
||||
</History>
|
||||
</HistoryComponent>
|
||||
|
||||
<SelectedCollection
|
||||
v-else-if="selectedCollections.length"
|
||||
<CurrentCollection
|
||||
v-else-if="breadcrumbs.length"
|
||||
:history="history"
|
||||
:selected-collections.sync="selectedCollections"
|
||||
:selected-collections.sync="breadcrumbs"
|
||||
@viewCollection="drillDown"
|
||||
/>
|
||||
|
||||
<div v-else>
|
||||
@@ -24,34 +26,30 @@ selected datset collections -->
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from "vuex";
|
||||
import History from "./History";
|
||||
import SelectedCollection from "./SelectedCollection/Panel";
|
||||
import { STATES } from "./model";
|
||||
import HistoryComponent from "./History";
|
||||
import CurrentCollection from "./CurrentCollection/Panel";
|
||||
import { History, STATES } from "./model";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
History,
|
||||
SelectedCollection,
|
||||
HistoryComponent,
|
||||
CurrentCollection,
|
||||
},
|
||||
props: {
|
||||
historyId: { type: String, required: true },
|
||||
history: { type: History, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// list of collections we have drilled down into
|
||||
breadcrumbs: [],
|
||||
};
|
||||
},
|
||||
data: () => ({
|
||||
selectedCollections: [],
|
||||
}),
|
||||
provide: {
|
||||
STATES,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters("betaHistory", ["getHistoryById"]),
|
||||
history() {
|
||||
return this.getHistoryById(this.historyId);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
selectCollection(coll) {
|
||||
this.selectedCollections = [...this.selectedCollections, coll];
|
||||
drillDown(coll) {
|
||||
this.breadcrumbs = [...this.breadcrumbs, coll];
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<b-dropdown text="Select history..." size="sm">
|
||||
<b-dropdown-item
|
||||
v-for="h in availableHistories"
|
||||
v-for="h in histories"
|
||||
:key="h.id"
|
||||
:active="value == h.id"
|
||||
@click="$emit('input', h.id)"
|
||||
:active="currentHistory.id == h.id"
|
||||
@click="$emit('update:currentHistory', h)"
|
||||
>
|
||||
{{ h.name }}
|
||||
</b-dropdown-item>
|
||||
@@ -12,22 +12,10 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from "vuex";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: { type: String, required: true },
|
||||
},
|
||||
computed: {
|
||||
...mapGetters("betaHistory", ["activeHistories"]),
|
||||
|
||||
availableHistories() {
|
||||
return Array.from(this.activeHistories).sort((a, b) => {
|
||||
const aa = a.name.toLowerCase();
|
||||
const bb = b.name.toLowerCase();
|
||||
return bb - aa;
|
||||
});
|
||||
},
|
||||
currentHistory: { type: Object, required: true },
|
||||
histories: { type: Array, default: () => [] },
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -40,7 +40,7 @@ class HistoryTagService extends TagService {
|
||||
async saveHistoryTags(rawTags) {
|
||||
// console.log("saveHistoryTags", rawTags);
|
||||
const tags = Array.from(rawTags);
|
||||
return await updateHistoryFields(this.history, { tags });
|
||||
return await updateHistoryFields(this.history.id, { tags });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
<template>
|
||||
<div v-if="historyId">
|
||||
<HistorySelector v-model="historyId" />
|
||||
|
||||
<PriorityMenu style="max-width: 50%;" :starting-height="27">
|
||||
<PriorityMenuItem
|
||||
key="create-new-history"
|
||||
title="Create New History"
|
||||
icon="fa fa-plus"
|
||||
@click="createHistory"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="view-all-histories"
|
||||
title="View All Histories"
|
||||
icon="fa fa-columns"
|
||||
@click="redirect('/history/view_multiple')"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="saved-histories"
|
||||
title="Saved Histories"
|
||||
icon="fas fa-save"
|
||||
@click="backboneRoute('/histories/list')"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="clear-history-cache"
|
||||
title="Clear cache"
|
||||
icon="fa fa-refresh"
|
||||
@click.stop="clearCache"
|
||||
/>
|
||||
|
||||
<PriorityMenuItem
|
||||
key="use-legacy-history"
|
||||
title="Return to legacy history panel"
|
||||
icon="fas fa-exchange-alt"
|
||||
@click="switchToLegacyHistoryPanel"
|
||||
/>
|
||||
</PriorityMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions } from "vuex";
|
||||
import { PriorityMenuItem, PriorityMenu } from "components/PriorityMenu";
|
||||
import HistorySelector from "./HistorySelector";
|
||||
import { createNewHistory } from "./model/queries";
|
||||
import { wipeDatabase, clearHistoryDateStore } from "./caching";
|
||||
import { legacyNavigationMixin } from "components/plugins";
|
||||
import { switchToLegacyHistoryPanel } from "./adapters/betaToggle";
|
||||
|
||||
export default {
|
||||
mixins: [legacyNavigationMixin],
|
||||
components: {
|
||||
PriorityMenu,
|
||||
PriorityMenuItem,
|
||||
HistorySelector,
|
||||
},
|
||||
props: {
|
||||
selectedHistoryId: { type: String, required: false, default: null },
|
||||
},
|
||||
computed: {
|
||||
historyId: {
|
||||
get() {
|
||||
return this.selectedHistoryId;
|
||||
},
|
||||
set(id) {
|
||||
this.$emit("update:selectedHistoryId", id);
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions("betaHistory", ["storeHistory"]),
|
||||
|
||||
switchToLegacyHistoryPanel,
|
||||
|
||||
// create new server, store in vuex, switch to it
|
||||
async createHistory() {
|
||||
const newHistory = await createNewHistory();
|
||||
await this.storeHistory(newHistory);
|
||||
this.historyId = newHistory.id;
|
||||
},
|
||||
|
||||
async clearCache() {
|
||||
await wipeDatabase();
|
||||
await clearHistoryDateStore();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,73 +0,0 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
listState: {
|
||||
showSelection: false,
|
||||
selected: new Map(),
|
||||
expanded: new Set(),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
isSelected(item) {
|
||||
const { type_id } = item;
|
||||
return this.listState.selected.has(type_id);
|
||||
},
|
||||
setSelected(item, bSelected) {
|
||||
const { type_id } = item;
|
||||
const newSelected = new Map(this.listState.selected);
|
||||
bSelected ? newSelected.set(type_id, item) : newSelected.delete(type_id);
|
||||
this.listState.selected = newSelected;
|
||||
},
|
||||
selectItems(items = []) {
|
||||
const newItems = [...this.listState.selected.values(), ...items];
|
||||
const newEntries = newItems.map((item) => [item.type_id, item]);
|
||||
this.listState.selected = new Map(newEntries);
|
||||
},
|
||||
unselectItems(items = []) {
|
||||
const doomedKeys = items.map((item) => item.type_id);
|
||||
const existing = this.listState.selected.entries();
|
||||
const newEntries = Array.from(existing).filter(([key]) => {
|
||||
return !doomedKeys.includes(key);
|
||||
});
|
||||
this.listState.selected = new Map(newEntries);
|
||||
},
|
||||
isExpanded({ type_id }) {
|
||||
return this.listState.expanded.has(type_id);
|
||||
},
|
||||
setExpanded({ type_id }, val) {
|
||||
const newSet = new Set(this.listState.expanded);
|
||||
val ? newSet.add(type_id) : newSet.delete(type_id);
|
||||
this.listState.expanded = newSet;
|
||||
},
|
||||
resetSelection() {
|
||||
this.listState.selected = new Map();
|
||||
},
|
||||
resetExpanded() {
|
||||
this.listState.expanded = new Set();
|
||||
},
|
||||
},
|
||||
|
||||
provide() {
|
||||
return {
|
||||
listState: this.listState,
|
||||
isSelected: this.isSelected,
|
||||
setSelected: this.setSelected,
|
||||
select: this.select,
|
||||
unselect: this.unselect,
|
||||
isExpanded: this.isExpanded,
|
||||
setExpanded: this.setExpanded,
|
||||
resetSelection: this.resetSelection,
|
||||
resetExpanded: this.resetExpanded,
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
"listState.selected": function (newSet) {
|
||||
if (newSet.size > 0) {
|
||||
this.showSelection = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,123 +0,0 @@
|
||||
<!-- When a collection is selected, the panel shows the contents of that selection -->
|
||||
|
||||
<template>
|
||||
<DscProvider :is-root="isRoot" :collection="selectedCollection" v-slot="{ dsc }">
|
||||
<CollectionContentProvider
|
||||
v-if="dsc"
|
||||
:parent="dsc"
|
||||
v-slot="{
|
||||
payload: {
|
||||
contents = [],
|
||||
startKey = null,
|
||||
topRows = 0,
|
||||
bottomRows = 0,
|
||||
totalMatches = 0
|
||||
},
|
||||
busy: loading,
|
||||
params,
|
||||
pageSize,
|
||||
updateParams,
|
||||
setScrollPos,
|
||||
}"
|
||||
>
|
||||
<Layout>
|
||||
<template v-slot:nav>
|
||||
<TopNav
|
||||
:history="history"
|
||||
:selected-collections="selectedCollections"
|
||||
:show-tags.sync="showTags"
|
||||
:show-filter.sync="showFilter"
|
||||
v-on="$listeners"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-slot:details>
|
||||
<Details
|
||||
:dsc="dsc"
|
||||
:writable="writable"
|
||||
:show-tags.sync="showTags"
|
||||
:show-filter.sync="showFilter"
|
||||
@update:dsc="updateDsc(dsc, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-slot:listing>
|
||||
<VirtualScroller
|
||||
key-field="element_index"
|
||||
:item-height="36"
|
||||
:items="contents"
|
||||
:scroll-to="startKey"
|
||||
:top-placeholders="topRows"
|
||||
:bottom-placeholders="bottomRows"
|
||||
@scroll="setScrollPos"
|
||||
>
|
||||
<template v-slot="{ item, index }">
|
||||
<CollectionContentItem :item="item" :index="index" />
|
||||
</template>
|
||||
</VirtualScroller>
|
||||
</template>
|
||||
</Layout>
|
||||
</CollectionContentProvider>
|
||||
</DscProvider>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { History } from "../model";
|
||||
import { updateContentFields } from "../model/queries";
|
||||
import { cacheContent } from "../caching";
|
||||
|
||||
import { DscProvider, CollectionContentProvider } from "../providers";
|
||||
import Layout from "../Layout";
|
||||
import TopNav from "./TopNav";
|
||||
import Details from "./Details";
|
||||
import ListMixin from "../ListMixin";
|
||||
import VirtualScroller from "../../VirtualScroller";
|
||||
import { CollectionContentItem } from "../ContentItem";
|
||||
|
||||
export default {
|
||||
mixins: [ListMixin],
|
||||
components: {
|
||||
DscProvider,
|
||||
CollectionContentProvider,
|
||||
Layout,
|
||||
TopNav,
|
||||
Details,
|
||||
VirtualScroller,
|
||||
CollectionContentItem,
|
||||
},
|
||||
props: {
|
||||
history: { type: History, required: true },
|
||||
selectedCollections: { type: Array, required: true },
|
||||
},
|
||||
data: () => ({
|
||||
showTags: false,
|
||||
showFilter: false,
|
||||
}),
|
||||
computed: {
|
||||
selectedCollection() {
|
||||
const arr = this.selectedCollections;
|
||||
const selected = arr[arr.length - 1];
|
||||
return selected;
|
||||
},
|
||||
isRoot() {
|
||||
return this.selectedCollection == this.selectedCollections[0];
|
||||
},
|
||||
writable() {
|
||||
return this.isRoot;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// 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) {
|
||||
// console.log("updateDsc", this.writable, collection, fields);
|
||||
if (this.writable) {
|
||||
const ajaxResult = await updateContentFields(collection, fields);
|
||||
await cacheContent({ ...collection, ...ajaxResult });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -65,7 +65,6 @@ export const HistoryPanelProxy = Backbone.View.extend({
|
||||
// Watch the store, change the fake history model when it changs
|
||||
store.subscribe(({ type, payload: newId }) => {
|
||||
if (type == "betaHistory/setCurrentHistoryId") {
|
||||
console.log("setCurrentHistoryId", newId);
|
||||
Galaxy.currHistoryPanel.setModel(new History({ id: newId }));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ export const bulkCacheContent = toPromise("bulkCacheContent");
|
||||
export const cacheCollectionContent = toPromise("cacheCollectionContent");
|
||||
export const getCachedCollectionContent = toPromise("getCachedCollectionContent");
|
||||
export const bulkCacheDscContent = toPromise("bulkCacheDscContent");
|
||||
export const getContentByTypeId = toPromise("getContentByTypeId");
|
||||
|
||||
// Debugging
|
||||
export const wipeDatabase = toPromise("wipeDatabase");
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { map, withLatestFrom, pluck } from "rxjs/operators";
|
||||
import { throttleDistinct } from "utils/observable/throttleDistinct";
|
||||
import { nth } from "utils/observable/nth";
|
||||
|
||||
import { bulkCacheDscContent } from "./db";
|
||||
import { prependPath } from "./workerConfig";
|
||||
@@ -33,9 +34,7 @@ export const loadDscContent = (cfg = {}) => (src$) => {
|
||||
);
|
||||
|
||||
// add contents_url to cache data, will use it as a key
|
||||
const url$ = inputs$.pipe(
|
||||
map((inputs) => inputs[0])
|
||||
);
|
||||
const url$ = inputs$.pipe(nth(0));
|
||||
|
||||
// request, throttle frequently repeated requests
|
||||
const ajaxResponse$ = inputs$.pipe(
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Lossless backpressure queue for processing observables one at
|
||||
* a time without spamming the server or the cache.
|
||||
*/
|
||||
import { Subject } from "rxjs";
|
||||
import { concatMap, publish, finalize, delay } from "rxjs/operators";
|
||||
|
||||
// put stuff on this Subject to process
|
||||
// format { task: observable to run, label: some identifier }
|
||||
const queue$ = new Subject();
|
||||
|
||||
// process each item sequentially
|
||||
// prettier-ignore
|
||||
const process$ = queue$.pipe(
|
||||
concatMap(({ task, label }) => {
|
||||
task.connect();
|
||||
return task.pipe(
|
||||
delay(100),
|
||||
finalize(() => console.log("[queue] task done", label))
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// just a counter to help debugging
|
||||
let taskCounter = 0;
|
||||
|
||||
// publish passed observable. This won't emit until later
|
||||
// when connect() is called, throw the new task on the queue$;
|
||||
export function enqueue(obs$) {
|
||||
const label = taskCounter++;
|
||||
const task = obs$.pipe(publish());
|
||||
queue$.next({ task, label });
|
||||
return task;
|
||||
}
|
||||
|
||||
// subscribe to processor
|
||||
export const queueSubscription = process$.subscribe(
|
||||
(result) => console.log("[queue] result", result),
|
||||
(err) => console.warn("[queue] error", err),
|
||||
() => console.warn("[queue] complete, why is queue complete?")
|
||||
);
|
||||
@@ -55,4 +55,10 @@ export class Content extends dateMixin(ModelBase) {
|
||||
get hdca_id() {
|
||||
return this.isCollection ? this.id : null;
|
||||
}
|
||||
|
||||
// subset of tags that start with name:
|
||||
get nameTags() {
|
||||
const allTags = this.tags || [];
|
||||
return allTags.filter((t) => t.includes("name:"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +1,147 @@
|
||||
import Vue from "vue";
|
||||
import VuexPersistence from "vuex-persist";
|
||||
import { History } from "./History";
|
||||
import { getHistoriesForCurrentUser, getCurrentHistoryFromServer, setCurrentHistoryOnServer } from "./queries";
|
||||
// import { changeGalaxyHistory } from "../adapters/changeGalaxyHistory";
|
||||
import { sortByObjectProp } from "utils/sorting";
|
||||
|
||||
const persist = new VuexPersistence();
|
||||
Vue.use(persist);
|
||||
import {
|
||||
createNewHistory,
|
||||
deleteHistoryById,
|
||||
getHistoryList,
|
||||
getHistoryById,
|
||||
getCurrentHistoryFromServer,
|
||||
setCurrentHistoryOnServer,
|
||||
updateHistoryFields,
|
||||
cloneHistory,
|
||||
secureHistory,
|
||||
} from "./queries";
|
||||
|
||||
// default state
|
||||
export const state = {
|
||||
// owner of the histories in this store
|
||||
// need this to track whether or not the right histories have been loaded
|
||||
loadedForUser: null,
|
||||
// selected history
|
||||
currentHistoryId: null,
|
||||
histories: [],
|
||||
// histories for current user
|
||||
histories: {},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
setCurrentHistoryId(state, id) {
|
||||
state.currentHistoryId = id;
|
||||
},
|
||||
setHistory(state, rawHistory) {
|
||||
const history = new History(rawHistory);
|
||||
const newHistories = new Map(state.histories);
|
||||
state.histories = newHistories.set(history.id, new History(history));
|
||||
setHistory(state, newHistory) {
|
||||
Vue.set(state.histories, newHistory.id, newHistory);
|
||||
},
|
||||
setHistories(state, list) {
|
||||
const newHistories = list.reduce((histories, rawHistory) => {
|
||||
const h = new History(rawHistory);
|
||||
return histories.set(h.id, h);
|
||||
}, new Map());
|
||||
state.histories = newHistories;
|
||||
deleteHistory(state, doomed) {
|
||||
Vue.delete(state.histories, doomed.id);
|
||||
},
|
||||
setHistories(state, newHistories = []) {
|
||||
const newMap = newHistories.reduce((acc, h) => ({ ...acc, [h.id]: h }), {});
|
||||
Vue.set(state, "histories", newMap);
|
||||
},
|
||||
setLoadedForUser(state, userId) {
|
||||
state.loadedForUser = userId;
|
||||
},
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
histories: (state) => {
|
||||
return Array.from(state.histories.values());
|
||||
},
|
||||
|
||||
activeHistories: (state, getters) => {
|
||||
const list = Array.from(getters.histories);
|
||||
const okHistory = (h) => !h.deleted && !h.purged;
|
||||
const activeHistories = list.filter(okHistory);
|
||||
return activeHistories;
|
||||
},
|
||||
|
||||
currentHistoryId: (state, getters) => {
|
||||
const activeHistories = getters.activeHistories;
|
||||
const validIds = activeHistories.map((h) => h.id);
|
||||
|
||||
if (state.currentHistoryId && validIds.includes(state.currentHistoryId)) {
|
||||
return state.currentHistoryId;
|
||||
}
|
||||
if (validIds.length) {
|
||||
return validIds[0];
|
||||
}
|
||||
return null;
|
||||
const { histories, currentHistoryId: id } = state;
|
||||
return id in histories ? id : getters.firstHistoryId;
|
||||
},
|
||||
firstHistoryId: (state, getters) => {
|
||||
const { histories } = getters;
|
||||
return histories.length ? histories[0].id : null;
|
||||
},
|
||||
|
||||
currentHistory: (state, getters) => {
|
||||
const currentId = getters.currentHistoryId;
|
||||
return getters.getHistoryById(currentId);
|
||||
const { currentHistoryId: id } = getters;
|
||||
return id ? getters.getHistoryById(id) : null;
|
||||
},
|
||||
|
||||
getHistoryById: (state) => (historyId) => {
|
||||
const raw = state.histories.get(historyId);
|
||||
const history = raw ? new History(raw) : null;
|
||||
return history;
|
||||
histories: (state) => {
|
||||
return Object.values(state.histories).sort(sortByObjectProp("name"));
|
||||
},
|
||||
getHistoryById: (state) => (id) => {
|
||||
return id in state.histories ? state.histories[id] : null;
|
||||
},
|
||||
};
|
||||
|
||||
// Holds promises for in-flight loads
|
||||
const promises = {
|
||||
load: null,
|
||||
byId: new Map(),
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
async $init({ dispatch }, { store }) {
|
||||
store.watch(
|
||||
(state) => state.user.currentUser,
|
||||
async () => {
|
||||
const myHistories = await getHistoriesForCurrentUser();
|
||||
dispatch("storeHistories", myHistories);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
dispatch("loadCurrentHistoryId");
|
||||
},
|
||||
|
||||
async loadCurrentHistoryId({ dispatch }) {
|
||||
const serverSaysWhat = await getCurrentHistoryFromServer();
|
||||
if (serverSaysWhat && serverSaysWhat.id) {
|
||||
dispatch("storeCurrentHistoryId", serverSaysWhat.id);
|
||||
loadUserHistories({ dispatch, commit }) {
|
||||
if (!promises.load) {
|
||||
promises.load = Promise.all([getHistoryList(), getCurrentHistoryFromServer()])
|
||||
.then(([list, history]) => {
|
||||
commit("setHistories", list);
|
||||
dispatch("selectHistory", history);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("loadUserHistories error", err);
|
||||
})
|
||||
.finally(() => {
|
||||
promises.load = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async storeCurrentHistoryId({ commit, getters }, id) {
|
||||
if (id == getters.currentHistoryId) return;
|
||||
const rawHistory = await setCurrentHistoryOnServer(id);
|
||||
if (rawHistory && rawHistory.id) {
|
||||
// tell legacy code that we changed the history,
|
||||
// remove when the backbone history management is not required
|
||||
// changeGalaxyHistory(rawHistory.id);
|
||||
commit("setCurrentHistoryId", rawHistory.id);
|
||||
loadHistoryById({ commit, getters }, id) {
|
||||
if (!promises.byId.has(id)) {
|
||||
const existing = getters.getHistoryById(id);
|
||||
const lastUpdated = existing?.update_time || null;
|
||||
const p = getHistoryById(id, lastUpdated)
|
||||
.then((history) => {
|
||||
commit("setHistory", history);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("loadHistoryById error", id, err);
|
||||
})
|
||||
.finally(() => {
|
||||
promises.byId.delete(id);
|
||||
});
|
||||
promises.byId.set(id, p);
|
||||
}
|
||||
},
|
||||
|
||||
// boilerplate (tm): let's think of 3 names for functions
|
||||
// that all do the same thing
|
||||
|
||||
async storeHistories({ commit }, list) {
|
||||
commit("setHistories", list);
|
||||
async setCurrentHistoryId({ dispatch }, id) {
|
||||
const nextHistory = await setCurrentHistoryOnServer(id);
|
||||
dispatch("selectHistory", nextHistory);
|
||||
},
|
||||
|
||||
async storeHistory({ commit }, history) {
|
||||
async createNewHistory({ dispatch }) {
|
||||
const newHistory = await createNewHistory();
|
||||
dispatch("selectHistory", newHistory);
|
||||
},
|
||||
async updateHistory({ commit }, { id, ...updateFields }) {
|
||||
// save new history params should be an object with an id property and any additional
|
||||
// properties that are to be updated on the server. A full history object is not required
|
||||
const saveResult = await updateHistoryFields(id, updateFields);
|
||||
commit("setHistory", saveResult);
|
||||
},
|
||||
async deleteHistory({ dispatch, commit, getters }, { history, purge = false }) {
|
||||
const deletedHistory = await deleteHistoryById(history.id, purge);
|
||||
commit("deleteHistory", deletedHistory);
|
||||
if (getters.histories.length == 0) {
|
||||
await dispatch("createNewHistory");
|
||||
}
|
||||
},
|
||||
async copyHistory({ dispatch }, { history, name, copyAll }) {
|
||||
const newHistory = await cloneHistory(history, name, copyAll);
|
||||
dispatch("selectHistory", newHistory);
|
||||
},
|
||||
async secureHistory({ commit }, history) {
|
||||
const updatedHistory = await secureHistory(history);
|
||||
commit("setHistory", updatedHistory);
|
||||
},
|
||||
setHistory({ commit }, history) {
|
||||
commit("setHistory", history);
|
||||
},
|
||||
selectHistory({ commit }, history) {
|
||||
commit("setHistory", history);
|
||||
commit("setCurrentHistoryId", history.id);
|
||||
},
|
||||
reset({ commit }) {
|
||||
commit("setHistories", []);
|
||||
commit("setCurrentHistoryId", null);
|
||||
},
|
||||
};
|
||||
|
||||
export const historyStore = {
|
||||
@@ -118,23 +151,3 @@ export const historyStore = {
|
||||
mutations,
|
||||
actions,
|
||||
};
|
||||
|
||||
/**
|
||||
* Localstorage plugin
|
||||
*
|
||||
* Caches simple user settings like "currentHistory" in localStorage instead
|
||||
* of having the server do that. Hopefully doing this in the client
|
||||
* will mean we can move closer towards a completely stateless REST api.
|
||||
*/
|
||||
|
||||
export const historyPersist = new VuexPersistence({
|
||||
key: "vuex-state-history",
|
||||
modules: ["betaHistory"],
|
||||
reducer: (state) => {
|
||||
return {
|
||||
history: {
|
||||
currentHistoryId: state.betaHistory.currentHistoryId,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import axios from "axios";
|
||||
import moment from "moment";
|
||||
import { prependPath } from "utils/redirect";
|
||||
// import { historyFields } from "./fields";
|
||||
|
||||
// #region setup & utils
|
||||
|
||||
@@ -82,22 +81,8 @@ const stdHistoryParams = {
|
||||
/**
|
||||
* Return list of available histories
|
||||
*/
|
||||
export async function getHistoriesForCurrentUser(lastUpdate = null) {
|
||||
const url = `/histories`;
|
||||
|
||||
// TODO: remove q,qv syntax
|
||||
const legacyParams = {
|
||||
deleted: "None",
|
||||
};
|
||||
if (lastUpdate) {
|
||||
legacyParams["update_time-gt"] = moment.utc(lastUpdate);
|
||||
}
|
||||
const incomptentQueryString = buildLegacyQueryString(legacyParams);
|
||||
|
||||
const response = await api.get(`${url}?${incomptentQueryString}`, {
|
||||
params: stdHistoryParams,
|
||||
});
|
||||
|
||||
export async function getHistoryList() {
|
||||
const response = await api.get("/histories?view=summary");
|
||||
return doResponse(response);
|
||||
}
|
||||
|
||||
@@ -105,8 +90,10 @@ export async function getHistoriesForCurrentUser(lastUpdate = null) {
|
||||
* Load one history by id
|
||||
* @param {String} id
|
||||
*/
|
||||
export async function getHistoryById(id) {
|
||||
const url = `/histories/${id}`;
|
||||
export async function getHistoryById(id, since) {
|
||||
const path = `/histories/${id}`;
|
||||
const sinceParam = since !== undefined ? moment.utc(since).toISOString() : null;
|
||||
const url = sinceParam ? path : `${path}?q=update_time-gt&qv=${sinceParam}`;
|
||||
const response = await api.get(url, { params: stdHistoryParams });
|
||||
return doResponse(response);
|
||||
}
|
||||
@@ -166,8 +153,8 @@ export async function undeleteHistoryById(id) {
|
||||
* @param {Object} history
|
||||
* @param {Object} payload fields to update
|
||||
*/
|
||||
export async function updateHistoryFields(history, payload) {
|
||||
const url = `/histories/${history.id}`;
|
||||
export async function updateHistoryFields(id, payload) {
|
||||
const url = `/histories/${id}`;
|
||||
const response = await api.put(url, payload, { params: stdHistoryParams });
|
||||
return doResponse(response);
|
||||
}
|
||||
@@ -177,14 +164,18 @@ export async function updateHistoryFields(history, payload) {
|
||||
* TODO: rewrite API endpoint for this
|
||||
* @param {String} history_id
|
||||
*/
|
||||
export async function secureHistory(history_id) {
|
||||
export async function secureHistory(history) {
|
||||
// NOTE: does not hit normal api/ endpoint
|
||||
const { id } = history;
|
||||
const url = prependPath("/history/make_private");
|
||||
const response = await axios.post(url, formData({ history_id }));
|
||||
const response = await axios.post(url, formData({ history_id: id }));
|
||||
if (response.status != 200) {
|
||||
throw new Error(response);
|
||||
}
|
||||
return await getHistoryById(history_id);
|
||||
// instead of a full lookup we could alternately figure out if
|
||||
// just a couple fields are changed and return the model with those
|
||||
// fields updated. it would avoid an extraneous ajax call
|
||||
return await getHistoryById(id);
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
+16
-156
@@ -13,17 +13,9 @@
|
||||
* page through the results.
|
||||
*/
|
||||
|
||||
import { NEVER, merge, combineLatest } from "rxjs";
|
||||
import { map, pluck, switchMap, distinctUntilChanged, share, shareReplay, mapTo, debounceTime } from "rxjs/operators";
|
||||
import { tag } from "rxjs-spy/operators/tag";
|
||||
import { whenAny } from "utils/observable/whenAny";
|
||||
import { activity } from "utils/observable/activity";
|
||||
import { chunk } from "../../caching/operators/chunk";
|
||||
|
||||
import { DatasetCollection, SearchParams } from "../../model";
|
||||
import { watchCollectionCache } from "./watchCollectionCache";
|
||||
import { loadCollectionContents } from "./loadCollectionContents";
|
||||
import { ContentProvider, scrollPosEquals, inputsSame } from "../ContentProvider";
|
||||
import { DatasetCollection } from "../../model";
|
||||
import { ContentProvider } from "../ContentProvider";
|
||||
import { processCollectionStreams } from "./processCollectionStreams";
|
||||
|
||||
export default {
|
||||
mixins: [ContentProvider],
|
||||
@@ -37,153 +29,21 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
dsc(newDsc, oldDsc) {
|
||||
if (!(newDsc.id == oldDsc.id)) {
|
||||
this.resetScrollPos();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
// prettier-ignore
|
||||
initStreams() {
|
||||
const {
|
||||
debouncePeriod,
|
||||
pageSize,
|
||||
params$,
|
||||
scrollPos$: rawScrollPos$
|
||||
} = this;
|
||||
|
||||
//#region Raw Inputs
|
||||
|
||||
const dsc$ = this.watch$("dsc", true).pipe(
|
||||
distinctUntilChanged(DatasetCollection.equals),
|
||||
tag("CollectionContentProvider-dsc"),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
const distinctParams$ = params$.pipe(
|
||||
distinctUntilChanged(SearchParams.equals),
|
||||
);
|
||||
|
||||
const inputs$ = whenAny(dsc$, distinctParams$).pipe(
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
const totalMatches$ = dsc$.pipe(
|
||||
pluck('totalElements'),
|
||||
tag("CollectionContentProvider-totalMatches"),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Scrolling
|
||||
|
||||
const scrolling$ = rawScrollPos$.pipe(
|
||||
activity({ period: debouncePeriod }),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
const scrollStart$ = inputs$.pipe(
|
||||
mapTo({ cursor: 0.0, key: null }),
|
||||
);
|
||||
|
||||
const scrollPos$ = merge(scrollStart$, rawScrollPos$).pipe(
|
||||
distinctUntilChanged(scrollPosEquals),
|
||||
tag("CollectionContentProvider-scrollPos"),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Estimate element index from cursor + dsc
|
||||
|
||||
const calculatedIndex$ = combineLatest([scrollPos$, totalMatches$]).pipe(
|
||||
debounceTime(0),
|
||||
map(([pos, totalMatches]) => this.getIndexFromPos(pos, totalMatches)),
|
||||
tag("CollectionContentProvider-calculatedIndex"),
|
||||
);
|
||||
|
||||
const cursor$ = scrolling$.pipe(
|
||||
switchMap(isScrolling => isScrolling ? NEVER : calculatedIndex$),
|
||||
tag("CollectionContentProvider-cursor"),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Loading
|
||||
|
||||
const loadCursor$ = cursor$.pipe(
|
||||
chunk(pageSize),
|
||||
tag('loadCursor'),
|
||||
);
|
||||
|
||||
const loadInputs$ = whenAny(inputs$, loadCursor$).pipe(
|
||||
tag('loadInputs'),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
const loadInputsThrottled$ = scrolling$.pipe(
|
||||
switchMap(isScrolling => isScrolling ? NEVER : loadInputs$),
|
||||
distinctUntilChanged(([inputsA, idxA], [inputsB, idxB]) => {
|
||||
return idxA == idxB && inputsSame(inputsA, inputsB);
|
||||
}),
|
||||
map(([inputs, idx]) => [...inputs, idx]),
|
||||
tag('loadInputsThrottled'),
|
||||
share(),
|
||||
);
|
||||
|
||||
const loader$ = loadInputsThrottled$.pipe(
|
||||
loadCollectionContents({ windowSize: 2 * pageSize }),
|
||||
tag('loader'),
|
||||
share(),
|
||||
);
|
||||
|
||||
const loading$ = merge(loadInputsThrottled$, loader$).pipe(
|
||||
activity({ period: debouncePeriod }),
|
||||
tag('loading'),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Cache watcher
|
||||
|
||||
const cacheCursor$ = cursor$.pipe(
|
||||
tag('cacheCursor'),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
const cacheFromMonitor$ = inputs$.pipe(
|
||||
watchCollectionCache({
|
||||
cursor$: cacheCursor$,
|
||||
pageSize,
|
||||
debouncePeriod
|
||||
}),
|
||||
tag("cacheFromMonitor"),
|
||||
);
|
||||
|
||||
const cache$ = combineLatest([cacheFromMonitor$, totalMatches$]).pipe(
|
||||
debounceTime(0),
|
||||
map((inputs) => this.buildPayload(...inputs)),
|
||||
tag("cache"),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
return { scrollPos$, loader$, cache$, scrolling$, loading$ };
|
||||
},
|
||||
|
||||
getIndexFromPos(pos, totalMatches) {
|
||||
const { cursor = null, key = null } = pos;
|
||||
if (key !== null) {
|
||||
return key;
|
||||
}
|
||||
if (cursor !== null) {
|
||||
return Math.round(Number(cursor) * totalMatches);
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
|
||||
buildPayload(result, totalMatches) {
|
||||
// console.log("buildPayload", result, totalMatches);
|
||||
const { contents } = result;
|
||||
const topRows = contents.length ? contents[0].element_index : 0;
|
||||
const bottomRows = Math.max(0, totalMatches - contents.length - topRows);
|
||||
return { ...result, topRows, bottomRows, totalMatches };
|
||||
const { debouncePeriod, pageSize, params$, scrollPos$ } = this;
|
||||
const dsc$ = this.watch$("dsc", true);
|
||||
const sources = { params$, dsc$, scrollPos$ };
|
||||
const settings = { debouncePeriod, pageSize };
|
||||
return processCollectionStreams(sources, settings);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+2
-5
@@ -1,10 +1,8 @@
|
||||
import { NEVER } from "rxjs";
|
||||
import { createLocalVue } from "@vue/test-utils";
|
||||
import { cacheContent, cacheCollectionContent, bulkCacheDscContent, wipeDatabase } from "../../caching";
|
||||
import { SearchParams } from "../../model/SearchParams";
|
||||
import CollectionContentProvider from "./CollectionContentProvider";
|
||||
import { watchForChange, mountRenderless } from "jest/helpers";
|
||||
import { vueRxShortcutPlugin } from "components/plugins";
|
||||
import { watchForChange, mountRenderless, getLocalVue } from "jest/helpers";
|
||||
|
||||
// sample data
|
||||
import rawRootCollection from "../../test/json/DatasetCollection.json";
|
||||
@@ -28,8 +26,7 @@ loadCollectionContents.mockImplementation((config) => (src$) => {
|
||||
//#endregion
|
||||
|
||||
const mountProvider = async (Component, propsData) => {
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(vueRxShortcutPlugin);
|
||||
const localVue = getLocalVue();
|
||||
const wrapper = mountRenderless(Component, { localVue, propsData });
|
||||
await wrapper.vm.$nextTick();
|
||||
return wrapper;
|
||||
|
||||
+11
-19
@@ -1,25 +1,17 @@
|
||||
import { of, pipe } from "rxjs";
|
||||
import { switchMap, catchError } from "rxjs/operators";
|
||||
import { of } from "rxjs";
|
||||
import { switchMap } from "rxjs/operators";
|
||||
import { tag } from "rxjs-spy/operators/tag";
|
||||
import { loadDscContent } from "../../caching";
|
||||
|
||||
// prettier-ignore
|
||||
export const loadCollectionContents = (cfg = {}) => {
|
||||
const {
|
||||
windowSize = 100
|
||||
} = cfg;
|
||||
const { windowSize = 100 } = cfg;
|
||||
|
||||
return pipe(
|
||||
switchMap(([{contents_url}, params, idx]) => {
|
||||
const singleLoad$ = of([contents_url, params, idx]).pipe(
|
||||
tag('ajaxLoadInputs'),
|
||||
loadDscContent({ windowSize }),
|
||||
);
|
||||
return singleLoad$;
|
||||
}),
|
||||
catchError(err => {
|
||||
console.warn("Error in loadCollectionContents", err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
// prettier-ignore
|
||||
return switchMap(([dsc, params, idx]) => {
|
||||
const { contents_url } = dsc;
|
||||
return of([contents_url, params, idx]).pipe(
|
||||
tag("ajaxLoadInputs"),
|
||||
loadDscContent({ windowSize })
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { merge, combineLatest } from "rxjs";
|
||||
import { map, pluck, distinctUntilChanged, share, debounceTime } from "rxjs/operators";
|
||||
import { tag } from "rxjs-spy/operators/tag";
|
||||
import { whenAny } from "utils/observable/whenAny";
|
||||
import { activity } from "utils/observable/activity";
|
||||
import { shareButDie } from "utils/observable/shareButDie";
|
||||
import { chunk } from "../../caching/operators/chunk";
|
||||
|
||||
import { DatasetCollection, SearchParams } from "../../model";
|
||||
import { watchCollectionCache } from "./watchCollectionCache";
|
||||
import { loadCollectionContents } from "./loadCollectionContents";
|
||||
import { loadInputsSame } from "../HistoryContentProvider/processHistoryStreams";
|
||||
|
||||
// prettier-ignore
|
||||
export function processCollectionStreams(sources, settings) {
|
||||
const { debouncePeriod, pageSize } = settings;
|
||||
const { dsc$: rawDsc$, params$: rawParams$, scrollPos$ } = sources;
|
||||
|
||||
//#region Clean up, filter, debounce raw inputs
|
||||
|
||||
const dsc$ = rawDsc$.pipe(distinctUntilChanged(DatasetCollection.equals));
|
||||
const params$ = rawParams$.pipe(distinctUntilChanged(SearchParams.equals));
|
||||
const inputs$ = whenAny(dsc$, params$).pipe(tag('inputs'), share());
|
||||
const scrolling$ = scrollPos$.pipe(activity());
|
||||
const totalMatches$ = dsc$.pipe(pluck("totalElements"));
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Estimate element index from cursor + dsc
|
||||
|
||||
const cursor$ = whenAny(scrollPos$, totalMatches$).pipe(
|
||||
map(([pos, totalMatches]) => getIndexFromPos(pos, totalMatches)),
|
||||
distinctUntilChanged(),
|
||||
tag('cursor'),
|
||||
shareButDie(1),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Loading
|
||||
|
||||
const chunkedCursor$ = cursor$.pipe(
|
||||
chunk(pageSize),
|
||||
);
|
||||
|
||||
const loadInputs$ = whenAny(inputs$, chunkedCursor$).pipe(
|
||||
distinctUntilChanged(loadInputsSame),
|
||||
map(([inputs, idx]) => [...inputs, idx]),
|
||||
tag('loadInputs'),
|
||||
shareButDie(1)
|
||||
);
|
||||
|
||||
const loadResult$ = loadInputs$.pipe(
|
||||
loadCollectionContents({ windowSize: 2 * pageSize }),
|
||||
tag('loadResult'),
|
||||
);
|
||||
|
||||
const loading$ = merge(loadInputs$, loadResult$).pipe(
|
||||
activity(),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Cache watcher
|
||||
|
||||
const cacheFromMonitor$ = inputs$.pipe(
|
||||
watchCollectionCache({
|
||||
cursor$,
|
||||
pageSize,
|
||||
debouncePeriod,
|
||||
}),
|
||||
tag('cacheFromMonitor'),
|
||||
);
|
||||
|
||||
const payload$ = combineLatest(cacheFromMonitor$, totalMatches$).pipe(
|
||||
debounceTime(debouncePeriod),
|
||||
map((inputs) => buildPayload(...inputs)),
|
||||
tag('payload'),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
return { payload$, scrolling$, loading$ };
|
||||
}
|
||||
|
||||
function getIndexFromPos(pos, totalMatches) {
|
||||
const { cursor = null, key = null } = pos;
|
||||
if (key !== null) {
|
||||
return key;
|
||||
}
|
||||
if (cursor !== null) {
|
||||
return Math.round(Number(cursor) * totalMatches);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildPayload(result, totalMatches) {
|
||||
const { contents } = result;
|
||||
const topRows = contents.length ? contents[0].element_index : 0;
|
||||
const bottomRows = Math.max(0, totalMatches - contents.length - topRows);
|
||||
return { ...result, topRows, bottomRows, totalMatches };
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import Vue from "vue";
|
||||
import { vueRxShortcutPlugin } from "components/plugins";
|
||||
import { NEVER, BehaviorSubject } from "rxjs";
|
||||
import { SearchParams } from "../model/SearchParams";
|
||||
|
||||
Vue.use(vueRxShortcutPlugin);
|
||||
|
||||
// dumb math util
|
||||
export const clamp = (val, [bottom, top]) => Math.max(bottom, Math.min(top, val));
|
||||
|
||||
@@ -55,32 +51,31 @@ export const ContentProvider = {
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
params(newParams, oldParams) {
|
||||
if (!SearchParams.equals(newParams, oldParams)) {
|
||||
this.resetScrollPos();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.initParams();
|
||||
this.initScrollPos();
|
||||
|
||||
const { cache$, loader$, loading$, scrolling$ } = this.initStreams();
|
||||
const { payload$, loading$, scrolling$ } = this.initStreams();
|
||||
|
||||
this.listenTo(scrolling$, (val) => (this.scrolling = val));
|
||||
this.listenTo(loading$, (val) => (this.loading = val));
|
||||
|
||||
// render output
|
||||
if (!this.disableWatch) {
|
||||
this.listenTo(cache$, {
|
||||
this.listenTo(payload$, {
|
||||
next: (payload) => this.setPayload(payload),
|
||||
error: (err) => console.warn("error in cache$", err),
|
||||
complete: () => console.warn("why did cache$ complete?"),
|
||||
});
|
||||
}
|
||||
|
||||
// keep sub to loader which popualates the cache
|
||||
if (!this.disableLoad) {
|
||||
this.listenTo(loader$, {
|
||||
// next: (result) => console.log("loader$ result", result),
|
||||
error: (err) => console.warn("error in loader$", err),
|
||||
complete: () => console.warn("why did loader$ complete?"),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -93,6 +88,10 @@ export const ContentProvider = {
|
||||
this.scrollPos$ = new BehaviorSubject({ cursor: 0.0, key: null });
|
||||
},
|
||||
|
||||
resetScrollPos() {
|
||||
this.scrollPos$.next({ cursor: 0.0, key: null });
|
||||
},
|
||||
|
||||
initStreams() {
|
||||
console.warn("Override initStreams in ContentProvider");
|
||||
return { cache$: NEVER, loader$: NEVER, loading$: NEVER, scrolling$: NEVER };
|
||||
@@ -137,6 +136,7 @@ export const ContentProvider = {
|
||||
|
||||
render() {
|
||||
return this.$scopedSlots.default({
|
||||
// actual content delivery
|
||||
payload: this.payload,
|
||||
|
||||
// local vars/settings/props passthrough
|
||||
@@ -147,9 +147,11 @@ export const ContentProvider = {
|
||||
pageSize: this.pageSize,
|
||||
|
||||
// update methods
|
||||
updateParams: this.updateParams,
|
||||
setScrollPos: this.setScrollPos,
|
||||
manualReload: this.manualReload,
|
||||
handlers: {
|
||||
updateParams: this.updateParams,
|
||||
setScrollPos: this.setScrollPos,
|
||||
manualReload: this.manualReload,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { createLocalVue } from "@vue/test-utils";
|
||||
import { wait, mountRenderless } from "jest/helpers";
|
||||
import { wait, mountRenderless, getLocalVue } from "jest/helpers";
|
||||
import { wipeDatabase } from "../../caching";
|
||||
import { cacheContent, getCachedContent, cacheCollectionContent, getCachedCollectionContent } from "../../caching";
|
||||
import { DatasetCollection } from "../../model/DatasetCollection";
|
||||
import DscProvider from "./DscProvider";
|
||||
import { vueRxShortcutPlugin } from "components/plugins";
|
||||
|
||||
// test data as root-level collections
|
||||
import rootContents from "../../test/json/historyContentWithCollection.json";
|
||||
@@ -18,8 +16,7 @@ jest.mock("app");
|
||||
jest.mock("../../caching");
|
||||
|
||||
const mountProvider = async (Component, propsData) => {
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(vueRxShortcutPlugin);
|
||||
const localVue = getLocalVue();
|
||||
const wrapper = mountRenderless(Component, { localVue, propsData });
|
||||
await wrapper.vm.$nextTick();
|
||||
return wrapper;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
export default {
|
||||
props: {
|
||||
scopeKey: { type: String, required: true },
|
||||
getItemKey: { type: Function, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
items: new Set(),
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
isExpanded(item) {
|
||||
const key = this.getItemKey(item);
|
||||
return this.items.has(key);
|
||||
},
|
||||
setExpanded(item, val) {
|
||||
const key = this.getItemKey(item);
|
||||
const newSet = new Set(this.items);
|
||||
val ? newSet.add(key) : newSet.delete(key);
|
||||
this.items = newSet;
|
||||
},
|
||||
reset() {
|
||||
this.items = new Set();
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
scopeKey(newKey, oldKey) {
|
||||
if (newKey !== oldKey) {
|
||||
this.reset();
|
||||
}
|
||||
},
|
||||
},
|
||||
render() {
|
||||
return this.$scopedSlots.default({
|
||||
isExpanded: this.isExpanded,
|
||||
setExpanded: this.setExpanded,
|
||||
});
|
||||
},
|
||||
};
|
||||
+15
-327
@@ -1,25 +1,5 @@
|
||||
import { combineLatest, merge, NEVER, Subject } from "rxjs";
|
||||
import {
|
||||
tap,
|
||||
pluck,
|
||||
map,
|
||||
mapTo,
|
||||
switchMap,
|
||||
distinctUntilChanged,
|
||||
shareReplay,
|
||||
withLatestFrom,
|
||||
debounceTime,
|
||||
share,
|
||||
} from "rxjs/operators";
|
||||
import { tag } from "rxjs-spy/operators/tag";
|
||||
import { activity } from "utils/observable/activity";
|
||||
import { whenAny } from "utils/observable/whenAny";
|
||||
import { chunk } from "../../caching/operators/chunk";
|
||||
|
||||
import { History, SearchParams, CurveFit } from "../../model";
|
||||
import { watchHistoryContents } from "./watchHistoryContents";
|
||||
import { loadContents } from "./loadContents";
|
||||
import { ContentProvider, isValidNumber, scrollPosEquals, inputsSame } from "../ContentProvider";
|
||||
import { ContentProvider } from "../ContentProvider";
|
||||
import { processHistoryStreams } from "./processHistoryStreams";
|
||||
|
||||
export default {
|
||||
mixins: [ContentProvider],
|
||||
@@ -30,317 +10,25 @@ export default {
|
||||
|
||||
computed: {
|
||||
history() {
|
||||
if (this.parent instanceof History) {
|
||||
return this.parent;
|
||||
return this.parent;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
history(newHistory, oldHistory) {
|
||||
if (!(newHistory.id == oldHistory.id)) {
|
||||
this.resetScrollPos();
|
||||
}
|
||||
return new History(this.parent);
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
// prettier-ignore
|
||||
initStreams() {
|
||||
const {
|
||||
disablePoll,
|
||||
debouncePeriod,
|
||||
pageSize,
|
||||
params$,
|
||||
scrollPos$: rawScrollPos$
|
||||
} = this;
|
||||
|
||||
//#region Raw Inputs
|
||||
|
||||
const history$ = this.watch$("history", true).pipe(
|
||||
distinctUntilChanged(History.equals),
|
||||
);
|
||||
|
||||
const distinctParams$ = params$.pipe(
|
||||
distinctUntilChanged(SearchParams.equals),
|
||||
);
|
||||
|
||||
const inputs$ = whenAny(history$, distinctParams$).pipe(
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Total Matches, from history and returned ajax stats
|
||||
|
||||
const serverTotalMatches$ = new Subject();
|
||||
|
||||
const historyMatches$ = history$.pipe(
|
||||
pluck('hidItems')
|
||||
);
|
||||
|
||||
const totalMatches$ = merge(historyMatches$, serverTotalMatches$).pipe(
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Scrolling
|
||||
|
||||
const scrolling$ = rawScrollPos$.pipe(
|
||||
activity({ period: debouncePeriod }),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
const scrollStart$ = inputs$.pipe(
|
||||
mapTo({ cursor: 0.0, key: null }),
|
||||
);
|
||||
|
||||
const scrollPos$ = merge(scrollStart$, rawScrollPos$).pipe(
|
||||
distinctUntilChanged(scrollPosEquals),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Curve-fit objects, used to estimate hid & topRows
|
||||
|
||||
const updateCursorToHid$ = new Subject();
|
||||
|
||||
const freshCursorToHid$ = inputs$.pipe(
|
||||
map(([history]) => {
|
||||
const fit = new CurveFit();
|
||||
fit.domain = [0.0, 1.0];
|
||||
fit.xPrecision = 3;
|
||||
fit.yPrecision = 2;
|
||||
fit.set(0.0, history.hidItems);
|
||||
fit.set(1.0, 1);
|
||||
return fit;
|
||||
}),
|
||||
);
|
||||
|
||||
const cursorToHid$ = merge(freshCursorToHid$, updateCursorToHid$).pipe(
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
const updateHidToTopRows$ = new Subject();
|
||||
|
||||
const freshHidToTopRows$ = inputs$.pipe(
|
||||
map(([history]) => {
|
||||
const fit = new CurveFit();
|
||||
fit.domain = [0.0, +history.hidItems];
|
||||
fit.xPrecision = 3;
|
||||
fit.yPrecision = 3;
|
||||
fit.set(history.hidItems, 0.0);
|
||||
fit.set(0.0, history.hidItems);
|
||||
return fit;
|
||||
}),
|
||||
);
|
||||
|
||||
const hidToTopRows$ = merge(freshHidToTopRows$, updateHidToTopRows$).pipe(
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Estimate HID from cursor + history + curveFit
|
||||
|
||||
const hid$ = whenAny(scrollPos$, cursorToHid$).pipe(
|
||||
map(([pos, fit]) => {
|
||||
const hid = pos.key ? pos.key : this.estimateHid(pos, fit);
|
||||
return parseInt(hid);
|
||||
}),
|
||||
// tag('hid'),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Loading
|
||||
|
||||
const loadCursor$ = hid$.pipe(
|
||||
chunk(pageSize, true),
|
||||
);
|
||||
|
||||
const loadInputs$ = whenAny(inputs$, loadCursor$);
|
||||
|
||||
const loadInputsThrottled$ = scrolling$.pipe(
|
||||
switchMap(isScrolling => isScrolling ? NEVER : loadInputs$),
|
||||
distinctUntilChanged(([inputsA, hidA], [inputsB, hidB]) => {
|
||||
return hidA == hidB && inputsSame(inputsA, inputsB);
|
||||
}),
|
||||
map(([inputs, hid]) => [...inputs, hid]),
|
||||
debounceTime(debouncePeriod),
|
||||
tag('loadInputsThrottled'),
|
||||
share(),
|
||||
);
|
||||
|
||||
const loadContent$ = loadInputsThrottled$.pipe(
|
||||
loadContents({ disablePoll, windowSize: 2 * pageSize }),
|
||||
tag('loadContent'),
|
||||
);
|
||||
|
||||
const loader$ = loadContent$.pipe(
|
||||
withLatestFrom(cursorToHid$, hidToTopRows$),
|
||||
tap(([result, cursorToHid, hidToTopRows]) => {
|
||||
this.adjustCursorToHid(result, cursorToHid);
|
||||
const newHidToTopRows = this.adjustHidToTopRows(result, hidToTopRows);
|
||||
const newFit = newHidToTopRows.clone();
|
||||
updateHidToTopRows$.next(newFit);
|
||||
const { totalMatches } = result;
|
||||
serverTotalMatches$.next(totalMatches);
|
||||
}),
|
||||
share(),
|
||||
);
|
||||
|
||||
const loading$ = merge(loadInputsThrottled$, loader$).pipe(
|
||||
activity({ period: debouncePeriod }),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Caching
|
||||
|
||||
const cacheHid$ = hid$.pipe(
|
||||
debounceTime(debouncePeriod),
|
||||
tag('cacheHid'),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
const cacheFromMonitor$ = inputs$.pipe(
|
||||
watchHistoryContents({
|
||||
hid$: cacheHid$,
|
||||
pageSize,
|
||||
debouncePeriod
|
||||
}),
|
||||
tag('cacheFromMonitor'),
|
||||
);
|
||||
|
||||
const cacheBuildInputs$ = combineLatest([
|
||||
cacheFromMonitor$,
|
||||
hidToTopRows$,
|
||||
totalMatches$,
|
||||
]).pipe(
|
||||
debounceTime(2 * debouncePeriod),
|
||||
tag('cacheBuildInputs'),
|
||||
);
|
||||
|
||||
const cache$ = cacheBuildInputs$.pipe(
|
||||
map((inputs) => this.buildPayload(...inputs)),
|
||||
tag('cache'),
|
||||
share(),
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
return { scrollPos$, loader$, cache$, scrolling$, loading$ };
|
||||
},
|
||||
|
||||
/**
|
||||
* Calculate or estimate hid from cursor position.
|
||||
* @param {Object} scrollPos { cursor, key }
|
||||
* @param {CurveFit} fit
|
||||
*/
|
||||
estimateHid(scrollPos, cursorToHid) {
|
||||
const { cursor, key } = scrollPos;
|
||||
const fit = cursorToHid;
|
||||
|
||||
// do an estimate/retrieval
|
||||
const result = fit.get(cursor, { interpolate: true });
|
||||
if (isValidNumber(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// give them the top of the data
|
||||
if (fit.hasData) {
|
||||
console.log("invalid curve fit", result, cursor, key, fit.domain);
|
||||
return fit.get(0);
|
||||
}
|
||||
|
||||
// we're screwed
|
||||
return undefined;
|
||||
},
|
||||
|
||||
/**
|
||||
* Assemble payload variables from cache result, need to calculate
|
||||
* topRows and bottomRows from the curveFit
|
||||
* @param {*} result Cache obsevable emission
|
||||
* @param {*} hidToTopRows topRows(hid) curveFit
|
||||
* @param {*} totalMatches Total number of matches in current history/params
|
||||
*/
|
||||
buildPayload(result, hidToTopRows, totalMatches) {
|
||||
const { pageSize } = this;
|
||||
const { contents = [], startKey = null, targetKey } = result;
|
||||
const fit = hidToTopRows;
|
||||
|
||||
let topRows = 0;
|
||||
let bottomRows = Math.max(0, totalMatches - contents.length);
|
||||
|
||||
// missing rows above & below content
|
||||
if (fit.size && contents.length && totalMatches > pageSize) {
|
||||
const maxHid = contents[0].hid;
|
||||
const rowsAboveTop = fit.get(maxHid, { interpolate: true });
|
||||
topRows = Math.max(0, Math.round(rowsAboveTop));
|
||||
bottomRows = Math.max(0, totalMatches - topRows - contents.length);
|
||||
}
|
||||
|
||||
return { contents, startKey, targetKey, topRows, bottomRows, totalMatches };
|
||||
},
|
||||
|
||||
/**
|
||||
* Update curve fit after an ajax load.
|
||||
* @param {Object} result Ajax load result
|
||||
* @param {CurveFit} fit hidToTopRows
|
||||
*/
|
||||
adjustHidToTopRows(result, fit) {
|
||||
const {
|
||||
// min and max of returned data
|
||||
minContentHid,
|
||||
maxContentHid,
|
||||
|
||||
// endpoints for all of history, that match filters
|
||||
minHid,
|
||||
maxHid,
|
||||
|
||||
// num matches up/down from request hid
|
||||
matchesUp,
|
||||
matchesDown,
|
||||
matches,
|
||||
|
||||
// total matches up/down from request hid
|
||||
totalMatchesUp,
|
||||
totalMatches,
|
||||
} = result;
|
||||
|
||||
if (matches > 0) {
|
||||
fit.set(maxContentHid, totalMatchesUp - matchesUp);
|
||||
fit.set(minContentHid, Math.max(0, totalMatchesUp + matchesDown - 1));
|
||||
}
|
||||
|
||||
fit.set(maxHid, 0);
|
||||
if (totalMatches > 0) {
|
||||
fit.set(minHid, totalMatches - 1);
|
||||
}
|
||||
|
||||
fit.domain = [minHid, maxHid];
|
||||
|
||||
return fit;
|
||||
},
|
||||
|
||||
/**
|
||||
* Calculate cursor values for edges of returned values and insert into
|
||||
* the datapoints of the curveFit object
|
||||
* @param {Object} result Ajax load result
|
||||
* @param {CurveFit} cursorToHid
|
||||
*/
|
||||
adjustCursorToHid(result, fit) {
|
||||
const { maxHid, minHid, maxContentHid, minContentHid, matches, totalMatchesUp, totalMatches } = result;
|
||||
|
||||
// set 0, 1 for min/max hids
|
||||
fit.set(0, maxHid);
|
||||
fit.set(1, minHid);
|
||||
|
||||
// find cursor for top & bottom of returned content
|
||||
if (totalMatches > 0) {
|
||||
fit.set(totalMatchesUp / totalMatches, maxContentHid);
|
||||
fit.set((matches + totalMatchesUp) / totalMatches, minContentHid);
|
||||
}
|
||||
|
||||
return fit;
|
||||
const { disablePoll, debouncePeriod, pageSize, params$, scrollPos$ } = this;
|
||||
const history$ = this.watch$("history");
|
||||
const sources = { params$, history$, scrollPos$ };
|
||||
const settings = { disablePoll, debouncePeriod, pageSize };
|
||||
return processHistoryStreams(sources, settings);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+2
-4
@@ -1,13 +1,12 @@
|
||||
import { pipe } from "rxjs";
|
||||
import { map } from "rxjs/operators";
|
||||
import { createLocalVue } from "@vue/test-utils";
|
||||
import { History } from "../../model/History";
|
||||
import { SearchParams } from "../../model/SearchParams";
|
||||
import HistoryContentProvider from "./HistoryContentProvider";
|
||||
import { bulkCacheContent, wipeDatabase } from "../../caching";
|
||||
import { getPropRange } from "../../caching/loadHistoryContents";
|
||||
import { watchForChange, mountRenderless } from "jest/helpers";
|
||||
import { vueRxShortcutPlugin } from "components/plugins";
|
||||
import { getLocalVue } from "jest/helpers";
|
||||
|
||||
//#region Test Data
|
||||
|
||||
@@ -65,8 +64,7 @@ const payloadChange = async ({ vm, label = "payload change" }) => {
|
||||
};
|
||||
|
||||
const mountProvider = async (Component, propsData) => {
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(vueRxShortcutPlugin);
|
||||
const localVue = getLocalVue();
|
||||
const wrapper = mountRenderless(Component, { localVue, propsData });
|
||||
await wrapper.vm.$nextTick();
|
||||
return wrapper;
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
import { combineLatest, merge, Subject } from "rxjs";
|
||||
import { tap, pluck, map, distinctUntilChanged, withLatestFrom, debounceTime, share } from "rxjs/operators";
|
||||
import { tag } from "rxjs-spy/operators/tag";
|
||||
import { whenAny } from "utils/observable/whenAny";
|
||||
import { activity } from "utils/observable/activity";
|
||||
import { shareButDie } from "utils/observable/shareButDie";
|
||||
import { chunk } from "../../caching/operators/chunk";
|
||||
import { nth } from "utils/observable/nth";
|
||||
|
||||
import { SearchParams, CurveFit } from "../../model";
|
||||
import { watchHistoryContents } from "./watchHistoryContents";
|
||||
import { loadContents } from "./loadContents";
|
||||
import { isValidNumber, inputsSame } from "../ContentProvider";
|
||||
|
||||
// inputs + hid/cursor comparison
|
||||
export const loadInputsSame = ([inputsA, cursorA], [inputsB, cursorB]) => {
|
||||
return cursorA == cursorB && inputsSame(inputsA, inputsB);
|
||||
};
|
||||
|
||||
// prettier-ignore
|
||||
export function processHistoryStreams(sources, settings) {
|
||||
const { disablePoll, debouncePeriod, pageSize } = settings;
|
||||
const { params$: rawParams$, history$: rawHistory$, scrollPos$ } = sources;
|
||||
|
||||
//#region Clean up, filter, debounce raw inputs
|
||||
|
||||
const history$ = rawHistory$.pipe(distinctUntilChanged((a, b) => a.id == b.id));
|
||||
const params$ = rawParams$.pipe(distinctUntilChanged(SearchParams.equals));
|
||||
const inputs$ = whenAny(history$, params$).pipe(share());
|
||||
const scrolling$ = scrollPos$.pipe(activity());
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Total Matches, from history and returned ajax stats
|
||||
|
||||
const serverTotalMatches$ = new Subject();
|
||||
const historyMatches$ = history$.pipe(pluck("hidItems"));
|
||||
const totalMatches$ = merge(historyMatches$, serverTotalMatches$).pipe(distinctUntilChanged());
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Curve-fit objects, used to estimate hid & topRows
|
||||
|
||||
const updateCursorToHid$ = new Subject();
|
||||
const updateHidToTopRows$ = new Subject();
|
||||
|
||||
// reset curve-fit data whenever inputs change
|
||||
// same as history but emits each time inputs emits
|
||||
const h$ = inputs$.pipe(nth(0));
|
||||
const freshCursorToHid$ = h$.pipe(map(createCursorToHid));
|
||||
const freshHidToTopRows$ = h$.pipe(map(createHidToTopRows));
|
||||
|
||||
// merge create + manual updates
|
||||
const cursorToHid$ = merge(freshCursorToHid$, updateCursorToHid$).pipe(shareButDie(1));
|
||||
const hidToTopRows$ = merge(freshHidToTopRows$, updateHidToTopRows$).pipe(shareButDie(1));
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Estimate HID from cursor + history + curveFit
|
||||
|
||||
const hid$ = whenAny(scrollPos$, cursorToHid$).pipe(
|
||||
map(([pos, fit]) => (pos.key ? pos.key : estimateHid(pos, fit))),
|
||||
map((hid) => parseInt(hid)),
|
||||
distinctUntilChanged(),
|
||||
tag('hid'),
|
||||
shareButDie(1)
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Loading
|
||||
|
||||
const chunkedHid$ = hid$.pipe(chunk(pageSize, true));
|
||||
|
||||
const loadInput$ = whenAny(inputs$, chunkedHid$).pipe(
|
||||
distinctUntilChanged(loadInputsSame),
|
||||
map(([inputs, hid]) => [...inputs, hid]),
|
||||
shareButDie(1)
|
||||
);
|
||||
|
||||
const loadResult$ = loadInput$.pipe(
|
||||
loadContents({ disablePoll, windowSize: 2 * pageSize })
|
||||
);
|
||||
|
||||
// side-effects of loading
|
||||
const loadEffects$ = loadResult$.pipe(
|
||||
withLatestFrom(cursorToHid$, hidToTopRows$),
|
||||
tap(([result, cursorToHid, hidToTopRows]) => {
|
||||
// can result in infinite loops, adjust curve-data by reference
|
||||
adjustCursorToHid(result, cursorToHid);
|
||||
adjustHidToTopRows(result, hidToTopRows);
|
||||
// updateHidToTopRows$.next();
|
||||
serverTotalMatches$.next(result.totalMatches);
|
||||
})
|
||||
);
|
||||
|
||||
// loading flag
|
||||
const loading$ = merge(loadInput$, loadEffects$).pipe(activity());
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Use inputs to read from cache
|
||||
|
||||
const cacheFromMonitor$ = inputs$.pipe(
|
||||
watchHistoryContents({
|
||||
hid$,
|
||||
pageSize,
|
||||
debouncePeriod,
|
||||
})
|
||||
);
|
||||
|
||||
const payload$ = combineLatest(cacheFromMonitor$, hidToTopRows$, totalMatches$).pipe(
|
||||
debounceTime(debouncePeriod),
|
||||
map((inputs) => buildPayload(...inputs, pageSize))
|
||||
);
|
||||
|
||||
//#endregion
|
||||
|
||||
return { payload$, scrolling$, loading$ };
|
||||
}
|
||||
|
||||
function estimateHid(scrollPos, cursorToHid) {
|
||||
const { cursor, key } = scrollPos;
|
||||
const fit = cursorToHid;
|
||||
|
||||
// do an estimate/retrieval
|
||||
const result = fit.get(cursor, { interpolate: true });
|
||||
if (isValidNumber(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// give them the top of the data
|
||||
if (fit.hasData) {
|
||||
console.log("invalid curve fit", result, cursor, key, fit.domain);
|
||||
return fit.get(0);
|
||||
}
|
||||
|
||||
// we're screwed
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function adjustCursorToHid(result, fit) {
|
||||
const { maxHid, minHid, maxContentHid, minContentHid, matches, totalMatchesUp, totalMatches } = result;
|
||||
|
||||
// set 0, 1 for min/max hids
|
||||
fit.set(0, maxHid);
|
||||
fit.set(1, minHid);
|
||||
|
||||
// find cursor for top & bottom of returned content
|
||||
if (totalMatches > 0) {
|
||||
fit.set(totalMatchesUp / totalMatches, maxContentHid);
|
||||
fit.set((matches + totalMatchesUp) / totalMatches, minContentHid);
|
||||
}
|
||||
}
|
||||
|
||||
function adjustHidToTopRows(result, fit) {
|
||||
const {
|
||||
// min and max of returned data
|
||||
minContentHid,
|
||||
maxContentHid,
|
||||
|
||||
// endpoints for all of history, that match filters
|
||||
minHid,
|
||||
maxHid,
|
||||
|
||||
// num matches up/down from request hid
|
||||
matchesUp,
|
||||
matchesDown,
|
||||
matches,
|
||||
|
||||
// total matches up/down from request hid
|
||||
totalMatchesUp,
|
||||
totalMatches,
|
||||
} = result;
|
||||
|
||||
if (matches > 0) {
|
||||
fit.set(maxContentHid, totalMatchesUp - matchesUp);
|
||||
fit.set(minContentHid, Math.max(0, totalMatchesUp + matchesDown - 1));
|
||||
}
|
||||
|
||||
fit.set(maxHid, 0);
|
||||
if (totalMatches > 0) {
|
||||
fit.set(minHid, totalMatches - 1);
|
||||
}
|
||||
|
||||
fit.domain = [minHid, maxHid];
|
||||
}
|
||||
|
||||
function buildPayload(result, hidToTopRows, totalMatches, pageSize) {
|
||||
const { contents = [], startKey = null, targetKey } = result;
|
||||
const fit = hidToTopRows;
|
||||
|
||||
let topRows = 0;
|
||||
let bottomRows = Math.max(0, totalMatches - contents.length);
|
||||
// missing rows above & below content
|
||||
if (fit.size && contents.length && totalMatches > pageSize) {
|
||||
const maxHid = contents[0].hid;
|
||||
const rowsAboveTop = fit.get(maxHid, { interpolate: true });
|
||||
topRows = Math.max(0, Math.round(rowsAboveTop));
|
||||
bottomRows = Math.max(0, totalMatches - topRows - contents.length);
|
||||
}
|
||||
|
||||
return { contents, startKey, targetKey, topRows, bottomRows, totalMatches };
|
||||
}
|
||||
|
||||
function createHidToTopRows(history) {
|
||||
const fit = new CurveFit();
|
||||
fit.domain = [0.0, +history.hidItems];
|
||||
fit.xPrecision = 3;
|
||||
fit.yPrecision = 3;
|
||||
fit.set(history.hidItems, 0.0);
|
||||
fit.set(0.0, history.hidItems);
|
||||
return fit;
|
||||
}
|
||||
|
||||
function createCursorToHid(history) {
|
||||
const fit = new CurveFit();
|
||||
fit.domain = [0.0, 1.0];
|
||||
fit.xPrecision = 3;
|
||||
fit.yPrecision = 2;
|
||||
fit.set(0.0, history.hidItems);
|
||||
fit.set(1.0, 1);
|
||||
return fit;
|
||||
}
|
||||
+5
-8
@@ -1,11 +1,13 @@
|
||||
import { of, combineLatest } from "rxjs";
|
||||
import { map, switchMap, debounceTime, scan, catchError, distinctUntilChanged } from "rxjs/operators";
|
||||
import { tag } from "rxjs-spy/operators/tag";
|
||||
import { map, switchMap, debounceTime, scan, distinctUntilChanged } from "rxjs/operators";
|
||||
import { chunk } from "../../caching/operators/chunk";
|
||||
import { monitorHistoryContent } from "../../caching";
|
||||
import { SearchParams } from "../../model/SearchParams";
|
||||
import { processContentUpdate, newUpdateMap, buildContentResult, getKeyForUpdateMap } from "../aggregation";
|
||||
|
||||
// disable debugging
|
||||
const tag = () => (src$) => src$;
|
||||
|
||||
/**
|
||||
* Monitor history in the region of the cursor for the provided inputs
|
||||
*
|
||||
@@ -71,10 +73,5 @@ export const watchHistoryContents = (cfg = {}) => input$ => {
|
||||
tag('watchHistoryContents-contentWindow'),
|
||||
);
|
||||
|
||||
return contentWindow$.pipe(
|
||||
catchError(err => {
|
||||
console.warn("Error in watchHistoryContents", err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
return contentWindow$;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* This is simple list management for the monent, but if I remember right we have plans for a more
|
||||
* abstracted selection scheme in the future for purposes of handling large bulk operations on the
|
||||
* server, this is a good place to implement that logic.
|
||||
*/
|
||||
|
||||
export default {
|
||||
props: {
|
||||
scopeKey: { type: String, required: true },
|
||||
getItemKey: { type: Function, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
items: new Map(),
|
||||
showSelection: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
selectionSize() {
|
||||
return this.items.size;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
setShowSelection(val) {
|
||||
this.showSelection = val;
|
||||
},
|
||||
isSelected(item) {
|
||||
const key = this.getItemKey(item);
|
||||
return this.items.has(key);
|
||||
},
|
||||
setSelected(item, bSelected) {
|
||||
const key = this.getItemKey(item);
|
||||
const newSelected = new Map(this.items);
|
||||
bSelected ? newSelected.set(key, item) : newSelected.delete(key);
|
||||
this.items = newSelected;
|
||||
},
|
||||
selectItems(items = []) {
|
||||
const newItems = [...this.items.values(), ...items];
|
||||
const newEntries = newItems.map((item) => {
|
||||
const key = this.getItemKey(item);
|
||||
return [key, item];
|
||||
});
|
||||
this.items = new Map(newEntries);
|
||||
},
|
||||
// unselectItems(items = []) {
|
||||
// const doomedKeys = items.map((item) => this.getItemKey(item));
|
||||
// const existing = this.items.entries();
|
||||
// const newEntries = Array.from(existing).filter(([key]) => {
|
||||
// return !doomedKeys.includes(key);
|
||||
// });
|
||||
// this.items = new Map(newEntries);
|
||||
// },
|
||||
reset() {
|
||||
this.items = new Map();
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
scopeKey(newKey, oldKey) {
|
||||
if (newKey !== oldKey) {
|
||||
this.reset();
|
||||
}
|
||||
},
|
||||
selectionSize(newSize) {
|
||||
if (newSize > 0) {
|
||||
this.showSelection = true;
|
||||
}
|
||||
},
|
||||
showSelection(newVal) {
|
||||
if (!newVal) {
|
||||
this.reset();
|
||||
}
|
||||
},
|
||||
},
|
||||
render() {
|
||||
return this.$scopedSlots.default({
|
||||
selectedItems: this.items,
|
||||
showSelection: this.showSelection,
|
||||
setShowSelection: this.setShowSelection,
|
||||
selectItems: this.selectItems,
|
||||
// unselectItems: this.unselectItems,
|
||||
isSelected: this.isSelected,
|
||||
setSelected: this.setSelected,
|
||||
resetSelection: this.reset,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Replacement for skiplist, aggregates history contents as updates come in,
|
||||
* then sorts the keys of the map on demand. Also can find the closest map key
|
||||
* to a given target value (cursor).
|
||||
*/
|
||||
|
||||
export class UpdateMap extends Map {
|
||||
sortKeys(direction) {
|
||||
const entries = Array.from(this.entries());
|
||||
if (entries.length == 0) return;
|
||||
const intDirection = direction == "asc" ? 1 : -1;
|
||||
const sortFn = (a, b) => intDirection * (a[0] - b[0]);
|
||||
const newContents = entries.sort(sortFn);
|
||||
this.replaceEntries(newContents);
|
||||
}
|
||||
|
||||
replaceEntries(entries) {
|
||||
this.clear();
|
||||
for (const [key, val] of entries) {
|
||||
this.set(key, val);
|
||||
}
|
||||
}
|
||||
|
||||
findClosestKey(target) {
|
||||
let minDist = Infinity;
|
||||
let closestKey = null;
|
||||
let closestIndex = null;
|
||||
let index = 0;
|
||||
|
||||
for (const [key] of this) {
|
||||
// This is an exact match. We're done here.
|
||||
if (key == target) {
|
||||
closestKey = key;
|
||||
closestIndex = index;
|
||||
break;
|
||||
}
|
||||
|
||||
// How far is this key from our target cursor?
|
||||
const dist = Math.abs(key - target);
|
||||
|
||||
// because the incoming sourceMap is pre-sorted by key we can stop looping
|
||||
// when the distance is getting larger, this means we're moving further
|
||||
// away from the right answer which we already found
|
||||
if (dist > minDist) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
closestKey = key;
|
||||
closestIndex = index;
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
return { key: closestKey, index: closestIndex };
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// Replacement for Skiplist, hoping native implementation will be faster
|
||||
import { UpdateMap } from "./UpdateMap";
|
||||
|
||||
describe("UpdateMap", () => {
|
||||
let content;
|
||||
beforeEach(() => {
|
||||
content = new UpdateMap();
|
||||
content.set(100, "a");
|
||||
content.set(5, "a");
|
||||
content.set(4, "b");
|
||||
content.set(1, "e");
|
||||
content.set(3, "c");
|
||||
content.set(2, "d");
|
||||
});
|
||||
|
||||
describe("sortMap", () => {
|
||||
test("it should sort a map in ascending key order", () => {
|
||||
content.sortKeys("asc");
|
||||
expect(Array.from(content.keys())).toEqual([1, 2, 3, 4, 5, 100]);
|
||||
});
|
||||
|
||||
test("it should sort a map in descending key order", () => {
|
||||
content.sortKeys("desc");
|
||||
expect(Array.from(content.keys())).toEqual([100, 5, 4, 3, 2, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findClosestKey", () => {
|
||||
beforeEach(() => content.sortKeys("desc"));
|
||||
|
||||
test("it should find an exact match", () => {
|
||||
const { key, index } = content.findClosestKey(3);
|
||||
expect(key).toEqual(3);
|
||||
expect(index).toEqual(3);
|
||||
});
|
||||
|
||||
test("it should find the closest key if not an exact match (closest less than target)", () => {
|
||||
const { key, index } = content.findClosestKey(8);
|
||||
expect(key).toEqual(5);
|
||||
expect(index).toEqual(1);
|
||||
});
|
||||
|
||||
test("it should find the closest key if not exact match (closest greater than target)", () => {
|
||||
const { key, index } = content.findClosestKey(80);
|
||||
expect(key).toEqual(100);
|
||||
expect(index).toEqual(0);
|
||||
});
|
||||
|
||||
test("should return nulls for an empty map", () => {
|
||||
const empty = new UpdateMap();
|
||||
const { key, index } = empty.findClosestKey(100);
|
||||
expect(key).toBe(null);
|
||||
expect(index).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
const MockCurrentHistory = (currentHistory = { id: "xyz" }) => ({
|
||||
render() {
|
||||
return this.$scopedSlots.default({
|
||||
currentHistory: currentHistory,
|
||||
currentHistoryId: currentHistory.id,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default MockCurrentHistory;
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Given that almost everything in here simply passes through to Vuex, an argument can be made for
|
||||
* not even having this provider, but I'm making anyway because:
|
||||
*
|
||||
* (1) Vuex requires a component to kick off initializations through a lifecycle event handler. Vuex
|
||||
* lacks the ability to lazy-load data because you can't do an async watch internally, thus that
|
||||
* kind of behavior needs to be managed by a component (which has lifecyle)
|
||||
* (2) We should separate history list management from the rendering UI elements, just as a matter of
|
||||
* good code design.
|
||||
* (2) Having this space gives us a place to house additional update logic if we start caching
|
||||
* history objects in IndexDB or reading updates from a websocket. Even if we continue to store
|
||||
* data in Vuex, we will need a place to subscribe and unsubscribe from sockets/observables
|
||||
*/
|
||||
|
||||
import { mapActions, mapGetters } from "vuex";
|
||||
import { History } from "../../model/History";
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
...mapGetters("betaHistory", ["currentHistoryId", "currentHistory", "histories"]),
|
||||
|
||||
currentHistoryModel() {
|
||||
if (this.currentHistory !== null) {
|
||||
return new History(this.currentHistory);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
historyModels() {
|
||||
return this.histories.map((h) => new History(h));
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions("betaHistory", [
|
||||
"loadHistoryById",
|
||||
"createNewHistory",
|
||||
"updateHistory",
|
||||
"deleteHistory",
|
||||
"setCurrentHistoryId",
|
||||
"setHistory",
|
||||
"loadUserHistories",
|
||||
"secureHistory",
|
||||
]),
|
||||
},
|
||||
watch: {
|
||||
// load history when the current id changes
|
||||
currentHistoryId: {
|
||||
immediate: true,
|
||||
handler(newId, oldId) {
|
||||
if (newId && newId !== oldId) {
|
||||
this.loadHistoryById(newId);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.loadUserHistories();
|
||||
},
|
||||
render() {
|
||||
return this.$scopedSlots.default({
|
||||
// list of available histories
|
||||
histories: this.historyModels,
|
||||
|
||||
// currently selected history object, should be a full object not just a summary
|
||||
currentHistory: this.currentHistoryModel,
|
||||
currentHistoryId: this.currentHistoryModel?.id || null,
|
||||
|
||||
handlers: {
|
||||
// Updates the history in the store without a trip to the server, in the event that a
|
||||
// downstream component does the ajax update itself.
|
||||
setHistory: this.setHistory,
|
||||
|
||||
// select new history, basically just needs the id
|
||||
setCurrentHistory: (h) => this.setCurrentHistoryId(h.id),
|
||||
|
||||
// create new history then select it
|
||||
createNewHistory: this.createNewHistory,
|
||||
|
||||
// save new history params should be an object with an id property and any additional
|
||||
// properties that are to be updated on the server. A full history object is not required
|
||||
updateHistory: this.updateHistory,
|
||||
updateCurrentHistory: () => this.updateHistory(this.currentHistory),
|
||||
|
||||
// delete history then clear currentHistoryId
|
||||
deleteHistory: (history) => this.deleteHistory({ history }),
|
||||
deleteCurrentHistory: () => this.deleteHistory({ history: this.currentHistory }),
|
||||
|
||||
// purge history then clearn currentHistoryId
|
||||
purgeHistory: (history) => this.deleteHistory({ history, purge: true }),
|
||||
purgeCurrentHistory: () => this.deleteHistory({ history: this.currentHistory, purge: true }),
|
||||
|
||||
secureHistory: this.secureHistory,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
import Vuex from "vuex";
|
||||
import { shallowMount } from "@vue/test-utils";
|
||||
import { getLocalVue, wait, waitForLifecyleEvent } from "jest/helpers";
|
||||
import { historyStore } from "../../model/historyStore";
|
||||
import { History } from "../../model";
|
||||
import UserHistories from "./UserHistories";
|
||||
|
||||
// #region Test Data
|
||||
|
||||
let historySummaries;
|
||||
let fullHistories;
|
||||
let currentHistoryId;
|
||||
|
||||
const resetTestData = () => {
|
||||
const ids = [1, 2, 3, 4];
|
||||
currentHistoryId = ids[0];
|
||||
historySummaries = ids.map((i) => ({ id: i, name: `History #${i}` }));
|
||||
fullHistories = historySummaries.map((h) => ({ ...h, detailView: true }));
|
||||
};
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Mock server responses
|
||||
|
||||
import {
|
||||
getHistoryList,
|
||||
getHistoryById,
|
||||
getCurrentHistoryFromServer,
|
||||
setCurrentHistoryOnServer,
|
||||
createNewHistory,
|
||||
updateHistoryFields,
|
||||
deleteHistoryById,
|
||||
} from "../../model/queries";
|
||||
|
||||
jest.mock("app");
|
||||
jest.mock("../../caching");
|
||||
jest.mock("../../model/queries");
|
||||
|
||||
const maxServerDelay = 60;
|
||||
const serverDelay = async (min = 0, max = maxServerDelay) => {
|
||||
const delay = min + Math.random() * (max - min);
|
||||
await wait(delay);
|
||||
};
|
||||
|
||||
getHistoryList.mockImplementation(async () => {
|
||||
await serverDelay();
|
||||
return [...historySummaries];
|
||||
});
|
||||
|
||||
getCurrentHistoryFromServer.mockImplementation(async () => {
|
||||
await serverDelay();
|
||||
return fullHistories.find((o) => o.id == currentHistoryId);
|
||||
});
|
||||
|
||||
getHistoryById.mockImplementation(async (id) => {
|
||||
await serverDelay();
|
||||
return fullHistories.find((o) => o.id == id);
|
||||
});
|
||||
|
||||
const createdHistory = { id: 666, name: "I am new" };
|
||||
createNewHistory.mockImplementation(async () => {
|
||||
await serverDelay();
|
||||
historySummaries.push(createdHistory);
|
||||
const newFullHistory = { ...createdHistory, detailView: true };
|
||||
fullHistories.push(newFullHistory);
|
||||
return Object.assign({}, newFullHistory);
|
||||
});
|
||||
|
||||
setCurrentHistoryOnServer.mockImplementation(async (id) => {
|
||||
await serverDelay();
|
||||
currentHistoryId = id;
|
||||
const result = fullHistories.find((h) => h.id == id);
|
||||
return Object.assign({}, result);
|
||||
});
|
||||
|
||||
updateHistoryFields.mockImplementation(async (id, payload = {}) => {
|
||||
await serverDelay();
|
||||
const existingHistory = fullHistories.find((h) => h.id == id);
|
||||
return { ...existingHistory, ...payload };
|
||||
});
|
||||
|
||||
deleteHistoryById.mockImplementation(async (id) => {
|
||||
await serverDelay();
|
||||
return fullHistories.find((h) => h.id == id);
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Mounting
|
||||
|
||||
const localVue = getLocalVue();
|
||||
|
||||
// Generate store for tesint, just need the one module we talk to
|
||||
const historiesStore = new Vuex.Store({
|
||||
modules: {
|
||||
betaHistory: historyStore,
|
||||
},
|
||||
});
|
||||
|
||||
// #endregion
|
||||
|
||||
describe("UserHistories", () => {
|
||||
let wrapper;
|
||||
let slotProps;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestData();
|
||||
wrapper = shallowMount(UserHistories, {
|
||||
localVue,
|
||||
store: historiesStore,
|
||||
scopedSlots: {
|
||||
default(props) {
|
||||
slotProps = props;
|
||||
},
|
||||
},
|
||||
});
|
||||
await waitForLifecyleEvent(wrapper.vm, "updated");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
historiesStore.dispatch("betaHistory/reset");
|
||||
if (wrapper) await wrapper.destroy();
|
||||
});
|
||||
|
||||
describe("slotProps: values", () => {
|
||||
test("histories", async () => {
|
||||
const { histories } = slotProps;
|
||||
expect(histories).toBeInstanceOf(Array);
|
||||
expect(histories.length).toEqual(historySummaries.length);
|
||||
histories.forEach((h) => {
|
||||
expect(h).toBeInstanceOf(History);
|
||||
});
|
||||
});
|
||||
|
||||
test("currentHistory", async () => {
|
||||
const { histories, currentHistory } = slotProps;
|
||||
expect(currentHistory).toBeInstanceOf(History);
|
||||
expect(histories.find((h) => h.id == currentHistory.id)).toEqual(currentHistory);
|
||||
});
|
||||
});
|
||||
|
||||
describe("slotProp handlers", () => {
|
||||
test("setCurrentHistory", async () => {
|
||||
const { setCurrentHistory } = slotProps.handlers;
|
||||
expect(setCurrentHistory).toBeInstanceOf(Function);
|
||||
|
||||
// set another history as current
|
||||
const nextHistory = historySummaries[1];
|
||||
setCurrentHistory(nextHistory);
|
||||
await waitForLifecyleEvent(wrapper.vm, "updated");
|
||||
|
||||
// wait for it to register
|
||||
const { currentHistory: changedHistory, histories: newHistories } = slotProps;
|
||||
expect(changedHistory.id).toEqual(nextHistory.id);
|
||||
expect(changedHistory).toBeInstanceOf(History);
|
||||
expect(newHistories.find((h) => h.id == changedHistory.id)).toEqual(changedHistory);
|
||||
});
|
||||
|
||||
test("createNewHistory", async () => {
|
||||
const { createNewHistory } = slotProps.handlers;
|
||||
expect(createNewHistory).toBeInstanceOf(Function);
|
||||
|
||||
// create new history
|
||||
await createNewHistory();
|
||||
await waitForLifecyleEvent(wrapper.vm, "updated");
|
||||
|
||||
// wait for it to come out of the slot
|
||||
expect(slotProps.currentHistory.id).toEqual(createdHistory.id);
|
||||
});
|
||||
|
||||
test("updateHistory", async () => {
|
||||
const {
|
||||
currentHistory,
|
||||
handlers: { updateHistory },
|
||||
} = slotProps;
|
||||
expect(updateHistory).toBeInstanceOf(Function);
|
||||
expect(currentHistory).toBeInstanceOf(History);
|
||||
const modifiedHistory = { ...currentHistory, foo: "bar" };
|
||||
|
||||
expect(modifiedHistory.id).toBeDefined();
|
||||
await updateHistory(modifiedHistory);
|
||||
|
||||
await waitForLifecyleEvent(wrapper.vm, "updated");
|
||||
expect(slotProps.histories.find((h) => h.foo == "bar")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("deleteHistory", async () => {
|
||||
const {
|
||||
currentHistory,
|
||||
handlers: { deleteHistory },
|
||||
} = slotProps;
|
||||
|
||||
expect(deleteHistory).toBeInstanceOf(Function);
|
||||
|
||||
await deleteHistory(currentHistory);
|
||||
await waitForLifecyleEvent(wrapper.vm, "updated");
|
||||
expect(slotProps.histories.find((h) => h.id == currentHistory.id)).toBeFalsy();
|
||||
expect(slotProps.currentHistory.id).not.toEqual(currentHistory.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
import UserHistories from "./UserHistories";
|
||||
export default UserHistories;
|
||||
@@ -15,3 +15,10 @@ export { default as CollectionContentProvider } from "./CollectionContentProvide
|
||||
// dataset collection is stored as history content. But a collection can nest
|
||||
// other collections, and sub-collections are stored as collection-content
|
||||
export { default as DscProvider } from "./DscProvider";
|
||||
|
||||
// Management for current user's histories. Largely a passthrough of store methods
|
||||
export { default as UserHistories } from "./UserHistories";
|
||||
|
||||
// list management functionality
|
||||
export { default as ExpandedItems } from "./ExpandedItems";
|
||||
export { default as SelectedItems } from "./SelectedItems";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-if="isSection && hasElements" class="tool-panel-section">
|
||||
<div :class="['toolSectionTitle', `tool-menu-section-${sectionName}`]">
|
||||
<a @click="toggleMenu" href="javascript:void(0)" role="button">
|
||||
<a @click="toggleMenu()" href="javascript:void(0)" role="button">
|
||||
<span class="name">
|
||||
{{ this.name }}
|
||||
</span>
|
||||
@@ -99,13 +99,19 @@ export default {
|
||||
queryFilter() {
|
||||
this.opened = this.checkFilter();
|
||||
},
|
||||
opened(newVal, oldVal) {
|
||||
if (newVal !== oldVal) {
|
||||
const currentState = newVal ? "opened" : "closed";
|
||||
ariaAlert(`${this.name} tools menu ${currentState}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
name() {
|
||||
return this.category.title || this.category.name;
|
||||
},
|
||||
isSection() {
|
||||
return this.category.elems;
|
||||
return this.category.elems !== undefined;
|
||||
},
|
||||
hasElements() {
|
||||
return this.category.elems && this.category.elems.length > 0;
|
||||
@@ -121,12 +127,17 @@ export default {
|
||||
onOperation(tool, evt) {
|
||||
this.$emit("onOperation", tool, evt);
|
||||
},
|
||||
toggleMenu() {
|
||||
this.opened = !this.opened;
|
||||
const currentState = this.opened ? "opened" : "closed";
|
||||
ariaAlert(`${this.name} tools menu ${currentState}`);
|
||||
toggleMenu(nextState = !this.opened) {
|
||||
this.opened = nextState;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.eventHub.$on("openToolSection", (sectionId) => {
|
||||
if (this.isSection && sectionId == this.category?.id) {
|
||||
this.toggleMenu(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<script>
|
||||
import ToolSection from "./Common/ToolSection";
|
||||
import ToolSearch from "./Common/ToolSearch";
|
||||
import UploadButton from "./Buttons/UploadButton";
|
||||
import { UploadButton } from "components/Upload";
|
||||
import FavoritesButton from "./Buttons/FavoritesButton";
|
||||
import { filterToolSections, filterTools } from "./utilities";
|
||||
import { getGalaxyInstance } from "app";
|
||||
@@ -143,12 +143,12 @@ export default {
|
||||
this.query = term;
|
||||
},
|
||||
onOpen(tool, evt) {
|
||||
const Galaxy = getGalaxyInstance();
|
||||
if (tool.id === "upload1") {
|
||||
evt.preventDefault();
|
||||
Galaxy.upload.show();
|
||||
this.eventHub.$emit("upload:open");
|
||||
} else if (tool.form_style === "regular") {
|
||||
evt.preventDefault();
|
||||
const Galaxy = getGalaxyInstance();
|
||||
Galaxy.router.push("/", {
|
||||
tool_id: tool.id,
|
||||
version: tool.version,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<span class="sr-only">...</span>
|
||||
</template>
|
||||
<b-dropdown-item v-for="item in overflow" :key="item.key" v-bind="item.attrs" v-on="item.on">
|
||||
{{ item.attrs.title }}
|
||||
{{ item.attrs.title | localize }}
|
||||
</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<b-button class="p-1" v-bind="$attrs" v-on="$listeners" :title="title" variant="link" size="sm" v-b-tooltip>
|
||||
<b-button class="p-1" v-bind="$attrs" v-on="$listeners" :title="title | localize" variant="link" size="sm">
|
||||
<i v-if="icon" :class="icon" />
|
||||
<span class="sr-only">{{ title }}</span>
|
||||
<span class="sr-only">{{ title | localize }}</span>
|
||||
</b-button>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1650,18 +1650,26 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
UploadUtils.getUploadDatatypes(
|
||||
(extensions) => {
|
||||
|
||||
// TODO: provider...
|
||||
UploadUtils.getUploadDatatypes(false, UploadUtils.AUTO_EXTENSION)
|
||||
.then((extensions) => {
|
||||
this.extensions = extensions;
|
||||
this.extension = UploadUtils.DEFAULT_EXTENSION;
|
||||
},
|
||||
false,
|
||||
UploadUtils.AUTO_EXTENSION
|
||||
);
|
||||
UploadUtils.getUploadGenomes((genomes) => {
|
||||
this.genomes = genomes;
|
||||
this.genome = UploadUtils.DEFAULT_GENOME;
|
||||
}, UploadUtils.DEFAULT_GENOME);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Error in RuleCollectionBuilder, unable to load datatypes", err);
|
||||
});
|
||||
|
||||
// TODO: provider...
|
||||
UploadUtils.getUploadGenomes(UploadUtils.DEFAULT_GENOME)
|
||||
.then((genomes) => {
|
||||
this.genomes = genomes;
|
||||
this.genome = UploadUtils.DEFAULT_GENOME;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Error in RuleCollectionBuilder, unable to load genomes", err);
|
||||
});
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import Vuex from "vuex";
|
||||
import { mount, createLocalVue } from "@vue/test-utils";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import Tags from "./Tags";
|
||||
import _l from "utils/localization";
|
||||
import { TagService } from "./tagService";
|
||||
import { tagStore } from "store/tagStore";
|
||||
import { getLocalVue } from "jest/helpers";
|
||||
|
||||
jest.mock("./tagService");
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
const localVue = getLocalVue();
|
||||
|
||||
const testStore = new Vuex.Store({
|
||||
modules: {
|
||||
tagStore,
|
||||
},
|
||||
});
|
||||
|
||||
localVue.filter("localize", (value) => _l(value));
|
||||
|
||||
describe("Tags/Tags.vue", () => {
|
||||
const id = "testId";
|
||||
const itemClass = "TestObject";
|
||||
@@ -56,7 +54,6 @@ describe("Tags/Tags.vue", () => {
|
||||
emitted = wrapper.emitted();
|
||||
|
||||
// Waits for lifecycle handlers to execute
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
});
|
||||
|
||||
|
||||
@@ -36,13 +36,13 @@
|
||||
<span class="upload-footer-extension-info upload-icon-button fa fa-search" />
|
||||
<span class="upload-footer-title">Genome (set all):</span>
|
||||
<select2 container-class="upload-footer-genome" ref="footerGenome" v-model="genome" :enabled="!running">
|
||||
<option v-for="(listGenome, index) in listGenomes" :key="index" :value="listGenome.id">{{
|
||||
listGenome.text
|
||||
}}</option>
|
||||
<option v-for="(listGenome, index) in listGenomes" :key="index" :value="listGenome.id">
|
||||
{{ listGenome.text }}
|
||||
</option>
|
||||
</select2>
|
||||
</template>
|
||||
<template v-slot:buttons>
|
||||
<b-button ref="btnClose" class="ui-button-default" id="btn-close" @click="app.dismiss()">
|
||||
<b-button ref="btnClose" class="ui-button-default" id="btn-close" @click="$emit('dismiss')">
|
||||
{{ btnCloseTitle }}
|
||||
</b-button>
|
||||
<b-button
|
||||
@@ -262,7 +262,7 @@ export default {
|
||||
this.counterRunning = 0;
|
||||
this._updateStateForCounters();
|
||||
this._eventReset();
|
||||
this.app.hide();
|
||||
this.$emit("hide");
|
||||
},
|
||||
|
||||
/** Start upload process */
|
||||
|
||||
@@ -28,13 +28,13 @@
|
||||
<span class="upload-footer-extension-info upload-icon-button fa fa-search" ref="footerExtensionInfo" />
|
||||
<span class="upload-footer-title">Genome/Build:</span>
|
||||
<select2 container-class="upload-footer-genome" ref="footerGenome" v-model="genome" :enabled="!running">
|
||||
<option v-for="(listGenome, index) in listGenomes" :key="index" :value="listGenome.id">{{
|
||||
listGenome.text
|
||||
}}</option>
|
||||
<option v-for="(listGenome, index) in listGenomes" :key="index" :value="listGenome.id">
|
||||
{{ listGenome.text }}
|
||||
</option>
|
||||
</select2>
|
||||
</template>
|
||||
<template v-slot:buttons>
|
||||
<b-button ref="btnClose" class="ui-button-default" @click="app.dismiss()">
|
||||
<b-button ref="btnClose" class="ui-button-default" @click="$emit('dismiss')">
|
||||
{{ btnCloseTitle }}
|
||||
</b-button>
|
||||
<b-button
|
||||
|
||||
@@ -28,13 +28,13 @@
|
||||
<span class="upload-footer-extension-info upload-icon-button fa fa-search" />
|
||||
<span class="upload-footer-title">Genome (set all):</span>
|
||||
<select2 container-class="upload-footer-genome" ref="footerGenome" v-model="genome" :enabled="!running">
|
||||
<option v-for="(listGenome, index) in listGenomes" :key="index" :value="listGenome.id">{{
|
||||
listGenome.text
|
||||
}}</option>
|
||||
<option v-for="(listGenome, index) in listGenomes" :key="index" :value="listGenome.id">
|
||||
{{ listGenome.text }}
|
||||
</option>
|
||||
</select2>
|
||||
</template>
|
||||
<template v-slot:buttons>
|
||||
<b-button ref="btnClose" class="ui-button-default" id="btn-close" @click="app.dismiss()">
|
||||
<b-button ref="btnClose" class="ui-button-default" id="btn-close" @click="$emit('dismiss')">
|
||||
{{ btnCloseTitle }}
|
||||
</b-button>
|
||||
<b-button
|
||||
@@ -235,7 +235,7 @@ export default {
|
||||
};
|
||||
});
|
||||
this.callback(asDict);
|
||||
this.app.cancel();
|
||||
this.$emit("cancel");
|
||||
},
|
||||
_newUploadModelProps: function (index, file) {
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<upload-wrapper ref="wrapper" :top-info="topInfo">
|
||||
<upload-wrapper ref="wrapper" :top-info="topInfo | l">
|
||||
<span style="width: 25%; display: inline; height: 100%;" class="float-left">
|
||||
<div class="upload-rule-option">
|
||||
<div class="upload-rule-option-title">{{ l("Upload data as") }}</div>
|
||||
<div class="upload-rule-option-title">{{ "Upload data as" | l }}</div>
|
||||
<div class="rule-data-type">
|
||||
<select2 v-model="dataType" container-class="upload-footer-selection">
|
||||
<option value="datasets">Datasets</option>
|
||||
@@ -11,23 +11,21 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="upload-rule-option">
|
||||
<div class="upload-rule-option-title">{{ l("Load tabular data from") }}</div>
|
||||
<div class="upload-rule-option-title">{{ "Load tabular data from" | l }}</div>
|
||||
<div class="rule-select-type">
|
||||
<select2 container-class="upload-footer-selection" v-model="selectionType">
|
||||
<option value="paste">{{ l("Pasted Table") }}</option>
|
||||
<option value="dataset">{{ l("History Dataset") }}</option>
|
||||
<option v-if="ftpUploadSite" value="ftp">{{ l("FTP Directory") }} </option>
|
||||
<option value="remote_files">{{ l("Remote Files Directory") }}</option>
|
||||
<option value="paste">{{ "Pasted Table" | l }}</option>
|
||||
<option value="dataset">{{ "History Dataset" | l }}</option>
|
||||
<option v-if="ftpUploadSite" value="ftp">{{ "FTP Directory" | l }}</option>
|
||||
<option value="remote_files">{{ "Remote Files Directory" | l }}</option>
|
||||
</select2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="upload-rule-dataset-option" class="upload-rule-option" v-if="selectionType == 'dataset'">
|
||||
<div class="upload-rule-option-title">
|
||||
History dataset
|
||||
</div>
|
||||
<div class="upload-rule-option-title">History dataset</div>
|
||||
<div>
|
||||
<b-link @click="onSelectDataset" v-if="selectedDatasetName == null">
|
||||
{{ l("Select") }}
|
||||
{{ "Select" | l }}
|
||||
</b-link>
|
||||
<span v-else>
|
||||
{{ selectedDatasetName }} <font-awesome-icon icon="edit" @click="onSelectDataset" />
|
||||
@@ -44,8 +42,8 @@
|
||||
></textarea>
|
||||
</span>
|
||||
<template v-slot:buttons>
|
||||
<b-button ref="btnClose" class="ui-button-default" id="btn-close" @click="app.dismiss()">
|
||||
{{ btnCloseTitle }}
|
||||
<b-button ref="btnClose" class="ui-button-default" id="btn-close" @click="$emit('dismiss')">
|
||||
{{ btnCloseTitle | l }}
|
||||
</b-button>
|
||||
<b-button
|
||||
ref="btnBuild"
|
||||
@@ -55,7 +53,7 @@
|
||||
:disabled="!sourceContent"
|
||||
:variant="sourceContent ? 'primary' : ''"
|
||||
>
|
||||
{{ btnBuildTitle }}
|
||||
{{ btnBuildTitle | l }}
|
||||
</b-button>
|
||||
<b-button
|
||||
ref="btnReset"
|
||||
@@ -64,14 +62,13 @@
|
||||
@click="_eventReset"
|
||||
:disabled="!enableReset"
|
||||
>
|
||||
{{ btnResetTitle }}
|
||||
{{ btnResetTitle | l }}
|
||||
</b-button>
|
||||
</template>
|
||||
</upload-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import _l from "utils/localization";
|
||||
import { getGalaxyInstance } from "app";
|
||||
import UploadBoxMixin from "./UploadBoxMixin";
|
||||
import UploadUtils from "mvc/upload/upload-utils";
|
||||
@@ -90,11 +87,10 @@ export default {
|
||||
components: { BLink, BButton, FontAwesomeIcon },
|
||||
data() {
|
||||
return {
|
||||
l: _l,
|
||||
datasets: [],
|
||||
ftpFiles: [],
|
||||
uris: [],
|
||||
topInfo: _l("Tabular source data to extract collection files and metadata from"),
|
||||
topInfo: "Tabular source data to extract collection files and metadata from",
|
||||
enableReset: false,
|
||||
enableBuild: false,
|
||||
dataType: "datasets",
|
||||
@@ -102,11 +98,15 @@ export default {
|
||||
selectedDatasetName: null,
|
||||
sourceContent: "",
|
||||
selectionType: "paste",
|
||||
btnBuildTitle: _l("Build"),
|
||||
btnResetTitle: _l("Reset"),
|
||||
btnCloseTitle: this.app.callback ? _l("Cancel") : _l("Close"),
|
||||
btnBuildTitle: "Build",
|
||||
btnResetTitle: "Reset",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
btnCloseTitle() {
|
||||
return this.app.callback ? "Cancel" : "Close";
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.initCollection();
|
||||
this.initAppProperties();
|
||||
@@ -194,7 +194,7 @@ export default {
|
||||
}
|
||||
selection.dataType = this.dataType;
|
||||
Galaxy.currHistoryPanel.buildCollection("rules", selection, true);
|
||||
this.app.hide();
|
||||
this.$emit("hide");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+20
-13
@@ -3,8 +3,8 @@
|
||||
id="tool-panel-upload-button"
|
||||
@click="showUploadDialog"
|
||||
v-b-tooltip.hover
|
||||
aria-label="Download from URL or upload files from disk"
|
||||
title="Download from URL or upload files from disk"
|
||||
:aria-label="title | localize"
|
||||
:title="title | localize"
|
||||
class="upload-button"
|
||||
size="sm"
|
||||
>
|
||||
@@ -19,25 +19,27 @@
|
||||
</div>
|
||||
<span class="position-relative">
|
||||
<font-awesome-icon icon="upload" class="mr-1" />
|
||||
<b>Upload Data</b>
|
||||
<b v-localize>Upload Data</b>
|
||||
</span>
|
||||
</b-button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { VBTooltip } from "bootstrap-vue";
|
||||
import { getGalaxyInstance } from "app";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faUpload } from "@fortawesome/free-solid-svg-icons";
|
||||
import { openUploadModal } from "./mount";
|
||||
library.add(faUpload);
|
||||
|
||||
export default {
|
||||
name: "UploadButton",
|
||||
components: { FontAwesomeIcon },
|
||||
directives: {
|
||||
"v-b-tooltip": VBTooltip,
|
||||
},
|
||||
props: {
|
||||
title: { type: String, default: "Download from URL or upload files from disk" },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
status: "",
|
||||
@@ -46,17 +48,22 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
showUploadDialog(e) {
|
||||
const Galaxy = getGalaxyInstance();
|
||||
e.preventDefault();
|
||||
Galaxy.upload.show();
|
||||
openUploadModal();
|
||||
},
|
||||
setStatus(val) {
|
||||
this.status = val;
|
||||
},
|
||||
setPercentage(val) {
|
||||
this.percentage = Math.round(val);
|
||||
},
|
||||
},
|
||||
created() {
|
||||
const Galaxy = getGalaxyInstance();
|
||||
Galaxy.upload.model.on("change", () => {
|
||||
this.status = Galaxy.upload.model.attributes.status;
|
||||
this.percentage = Galaxy.upload.model.attributes.percentage;
|
||||
});
|
||||
this.eventHub.$on("upload:status", this.setStatus);
|
||||
this.eventHub.$on("upload:percentage", this.setPercentage);
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.eventHub.$off("upload:status", this.setStatus);
|
||||
this.eventHub.$off("upload:percentage", this.setPercentage);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,8 +1,13 @@
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import axios from "axios";
|
||||
import UploadModal from "./UploadModal.vue";
|
||||
import UploadModal from "./UploadModal";
|
||||
import UploadModalContent from "./UploadModalContent";
|
||||
import store from "../../store";
|
||||
import { shallowMount, createLocalVue } from "@vue/test-utils";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { getLocalVue } from "jest/helpers";
|
||||
|
||||
import MockCurrentUser from "../providers/MockCurrentUser";
|
||||
import MockCurrentHistory from "../History/providers/UserHistories/MockCurrentHistory";
|
||||
|
||||
jest.mock("app");
|
||||
jest.mock("../History/caching");
|
||||
@@ -10,6 +15,7 @@ jest.mock("../History/caching");
|
||||
const propsData = {
|
||||
chunkUploadSize: 1024,
|
||||
uploadPath: "/api/tools",
|
||||
fileSourcesConfigured: true,
|
||||
};
|
||||
|
||||
describe("UploadModal.vue", () => {
|
||||
@@ -37,12 +43,29 @@ describe("UploadModal.vue", () => {
|
||||
];
|
||||
axiosMock.onGet(`/api/genomes`).reply(200, genomesResponse);
|
||||
|
||||
const localVue = createLocalVue();
|
||||
// mock current user & history
|
||||
|
||||
wrapper = await shallowMount(UploadModal, {
|
||||
axiosMock.onGet(`/api/datatypes?extension_only=False`).reply(200, datatypesResponse);
|
||||
|
||||
const localVue = getLocalVue();
|
||||
|
||||
wrapper = await mount(UploadModal, {
|
||||
store,
|
||||
propsData,
|
||||
localVue,
|
||||
stubs: {
|
||||
// Need to stub all this horrible-ness because of the last 2 tests
|
||||
// which need to dig into the first layer of the mount tree, will remove
|
||||
// all of this shortly with a PR that completely replaces Upload
|
||||
CurrentUser: MockCurrentUser({ id: "fakeuser" }),
|
||||
UserHistories: MockCurrentHistory({ id: "fakehistory" }),
|
||||
BTabs: true,
|
||||
BTab: true,
|
||||
Collection: true,
|
||||
Composite: true,
|
||||
Default: true,
|
||||
RulesInput: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,12 +80,17 @@ describe("UploadModal.vue", () => {
|
||||
});
|
||||
|
||||
it("should fetch datatypes and parse them", async () => {
|
||||
expect(wrapper.vm.listExtensions.length).toBe(2);
|
||||
expect(wrapper.vm.listExtensions[0].id).toBe("auto");
|
||||
expect(wrapper.vm.listExtensions[1].id).toBe("fasta");
|
||||
// lists are one layer deeper now, it won't matter after refactoring
|
||||
const contentWrapper = wrapper.findComponent(UploadModalContent);
|
||||
expect(contentWrapper.exists()).toBe(true);
|
||||
expect(contentWrapper.vm.listExtensions.length).toBe(2);
|
||||
expect(contentWrapper.vm.listExtensions[0].id).toBe("auto");
|
||||
expect(contentWrapper.vm.listExtensions[1].id).toBe("fasta");
|
||||
});
|
||||
|
||||
it("should fetch genomes and parse them", async () => {
|
||||
expect(wrapper.vm.listGenomes.length).toBe(3);
|
||||
// lists are one yaer deeper now, it won't matter after refactoring
|
||||
const contentWrapper = wrapper.findComponent(UploadModalContent);
|
||||
expect(contentWrapper.vm.listGenomes.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,241 +1,64 @@
|
||||
<!--
|
||||
Temporary modal wrapper until I replace the entire UploadModal which desperately needs a rewrite.
|
||||
Provides user and current history to modal because it currently has initialization sequence issues
|
||||
-->
|
||||
|
||||
<template>
|
||||
<b-modal
|
||||
v-model="modalShow"
|
||||
:static="modalStatic"
|
||||
header-class="no-separator"
|
||||
modal-class="ui-modal"
|
||||
dialog-class="upload-dialog"
|
||||
body-class="upload-dialog-body"
|
||||
ref="modal"
|
||||
no-enforce-focus
|
||||
hide-footer
|
||||
:id="id"
|
||||
>
|
||||
<template v-slot:modal-header>
|
||||
<h4 class="title" tabindex="0">{{ title }}</h4>
|
||||
</template>
|
||||
<b-tabs v-if="ready">
|
||||
<b-tab title="Regular" id="regular" button-id="tab-title-link-regular" v-if="showRegular">
|
||||
<default :app="this" :lazy-load-max="50" :multiple="multiple" :selectable="callback != null" />
|
||||
</b-tab>
|
||||
<b-tab title="Composite" id="composite" button-id="tab-title-link-composite" v-if="showComposite">
|
||||
<composite :app="this" />
|
||||
</b-tab>
|
||||
<b-tab title="Collection" id="collection" button-id="tab-title-link-collection" v-if="showCollection">
|
||||
<collection :app="this" />
|
||||
</b-tab>
|
||||
<b-tab title="Rule-based" id="rule-based" button-id="tab-title-link-rule-based" v-if="showRules">
|
||||
<rules-input :app="this" />
|
||||
</b-tab>
|
||||
</b-tabs>
|
||||
<div v-else>
|
||||
<loading-span message="Loading required information from Galaxy server." />
|
||||
</div>
|
||||
</b-modal>
|
||||
<CurrentUser v-slot="{ user }">
|
||||
<UserHistories v-if="user" :user="user" v-slot="{ currentHistoryId }">
|
||||
<b-modal
|
||||
:id="id"
|
||||
v-model="modalShow"
|
||||
:static="modalStatic"
|
||||
ref="uploadModal"
|
||||
header-class="no-separator"
|
||||
modal-class="ui-modal"
|
||||
dialog-class="upload-dialog"
|
||||
body-class="upload-dialog-body"
|
||||
no-enforce-focus
|
||||
hide-footer
|
||||
>
|
||||
<template v-slot:modal-header>
|
||||
<h4 class="title" tabindex="0">{{ title | localize }}</h4>
|
||||
</template>
|
||||
|
||||
<UploadModalContent
|
||||
v-if="currentHistoryId"
|
||||
:current-user-id="user.id"
|
||||
:current-history-id="currentHistoryId"
|
||||
v-bind="{ ...$props, ...$attrs }"
|
||||
@dismiss="dismiss"
|
||||
@hide="hide"
|
||||
@cancel="cancel"
|
||||
/>
|
||||
</b-modal>
|
||||
</UserHistories>
|
||||
</CurrentUser>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import _l from "utils/localization";
|
||||
import Backbone from "backbone";
|
||||
import UploadUtils from "mvc/upload/upload-utils";
|
||||
import { getDatatypesMapper } from "components/Datatypes";
|
||||
import Composite from "./Composite";
|
||||
import Collection from "./Collection";
|
||||
import Default from "./Default";
|
||||
import RulesInput from "./RulesInput";
|
||||
import LoadingSpan from "components/LoadingSpan";
|
||||
import { mapState } from "vuex";
|
||||
import { BModal, BTabs, BTab } from "bootstrap-vue";
|
||||
import { isBetaHistoryOpen } from "components/History/adapters/betaToggle";
|
||||
import CurrentUser from "../providers/CurrentUser";
|
||||
import UserHistories from "../History/providers/UserHistories";
|
||||
import UploadModalContent from "./UploadModalContent";
|
||||
import { commonProps } from "./helpers";
|
||||
|
||||
const UploadModal = {
|
||||
export default {
|
||||
components: {
|
||||
Collection,
|
||||
Composite,
|
||||
Default,
|
||||
RulesInput,
|
||||
LoadingSpan,
|
||||
BModal,
|
||||
BTabs,
|
||||
BTab,
|
||||
CurrentUser,
|
||||
UserHistories,
|
||||
UploadModalContent,
|
||||
},
|
||||
props: {
|
||||
modalStatic: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
chunkUploadSize: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
uploadPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
modalShow: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
ftpUploadSite: {
|
||||
type: String,
|
||||
default: "n/a",
|
||||
},
|
||||
fileSourcesConfigured: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
defaultGenome: {
|
||||
type: String,
|
||||
default: UploadUtils.DEFAULT_GENOME,
|
||||
},
|
||||
defaultExtension: {
|
||||
type: String,
|
||||
default: UploadUtils.DEFAULT_EXTENSION,
|
||||
},
|
||||
datatypesDisableAuto: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
formats: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
multiple: {
|
||||
// Restrict the forms to a single dataset upload if false
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
callback: {
|
||||
// Return uploads when done if supplied.
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
auto: {
|
||||
type: Object,
|
||||
default: function () {
|
||||
return UploadUtils.AUTO_EXTENSION;
|
||||
},
|
||||
},
|
||||
title: { type: String, default: "Download from web or upload from disk" },
|
||||
modalStatic: { type: Boolean, default: true },
|
||||
...commonProps,
|
||||
},
|
||||
data: function () {
|
||||
data() {
|
||||
return {
|
||||
id: "",
|
||||
title: _l("Download from web or upload from disk"),
|
||||
listGenomes: [],
|
||||
listExtensions: [],
|
||||
genomesSet: false,
|
||||
extensionsSet: false,
|
||||
datatypesMapper: null,
|
||||
datatypesMapperReady: true,
|
||||
id: null,
|
||||
modalShow: false,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.model = new Backbone.Model({
|
||||
label: "Load Data",
|
||||
percentage: 0,
|
||||
status: "",
|
||||
onunload: function () {},
|
||||
onclick: function () {},
|
||||
});
|
||||
|
||||
// load extensions
|
||||
UploadUtils.getUploadDatatypes(
|
||||
(listExtensions) => {
|
||||
this.extensionsSet = true;
|
||||
this.listExtensions = listExtensions;
|
||||
},
|
||||
this.datatypesDisableAuto,
|
||||
this.auto
|
||||
);
|
||||
|
||||
// load genomes
|
||||
UploadUtils.getUploadGenomes((listGenomes) => {
|
||||
this.genomesSet = true;
|
||||
this.listGenomes = listGenomes;
|
||||
}, this.defaultGenome);
|
||||
|
||||
if (this.formats !== null) {
|
||||
this.datatypesMapperReady = false;
|
||||
getDatatypesMapper().then((datatypesMapper) => {
|
||||
this.datatypesMapper = datatypesMapper;
|
||||
this.datatypesMapperReady = true;
|
||||
});
|
||||
} else {
|
||||
this.datatypesMapperReady = true;
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
const modelUnload = this.model.get("onunload");
|
||||
modelUnload();
|
||||
},
|
||||
computed: {
|
||||
...mapState("user", {
|
||||
currentUserId: (state) => state.currentUser.id,
|
||||
}),
|
||||
|
||||
// go straight to "state" instead of getter because the getter would filter out a current id
|
||||
// that wasn't in the list
|
||||
...mapState("betaHistory", {
|
||||
currentHistoryId: (state) => state.currentHistoryId,
|
||||
}),
|
||||
|
||||
historyAvailable() {
|
||||
return Boolean(this.currentHistoryId);
|
||||
},
|
||||
|
||||
ready() {
|
||||
return this.genomesSet && this.extensionsSet && this.historyAvailable && this.datatypesMapperReady;
|
||||
},
|
||||
|
||||
unrestricted() {
|
||||
return this.formats === null && this.multiple;
|
||||
},
|
||||
|
||||
effectiveExtensions() {
|
||||
if (this.formats === null || !this.datatypesMapperReady) {
|
||||
return this.listExtensions;
|
||||
}
|
||||
const effectiveExtensions = [];
|
||||
this.listExtensions.forEach((extension) => {
|
||||
if (extension && extension.id == "auto") {
|
||||
effectiveExtensions.push(extension);
|
||||
} else if (this.datatypesMapper.isSubTypeOfAny(extension.id, this.formats)) {
|
||||
effectiveExtensions.push(extension);
|
||||
}
|
||||
});
|
||||
return effectiveExtensions;
|
||||
},
|
||||
formatRestricted() {
|
||||
return this.formats !== null;
|
||||
},
|
||||
showComposite() {
|
||||
if (!this.formatRestricted) {
|
||||
return true;
|
||||
}
|
||||
return this.effectiveExtensions.some((extension) => !!extension.composite_files);
|
||||
},
|
||||
showRegular() {
|
||||
if (!this.formatRestricted) {
|
||||
return true;
|
||||
}
|
||||
return this.effectiveExtensions.some((extension) => !extension.composite_files);
|
||||
},
|
||||
showCollection() {
|
||||
if (this.unrestricted) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
showRules() {
|
||||
if (this.unrestricted) {
|
||||
return true;
|
||||
}
|
||||
return this.multiple;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.id = String(this._uid);
|
||||
},
|
||||
methods: {
|
||||
show() {
|
||||
this.modalShow = true;
|
||||
@@ -248,7 +71,6 @@ const UploadModal = {
|
||||
this.hide();
|
||||
this.$nextTick(() => {
|
||||
this.$bvModal.hide(this.id, "cancel");
|
||||
this.$destroy();
|
||||
});
|
||||
},
|
||||
dismiss() {
|
||||
@@ -259,89 +81,10 @@ const UploadModal = {
|
||||
this.hide();
|
||||
}
|
||||
},
|
||||
currentFtp: function () {
|
||||
return this.currentUserId && this.ftpUploadSite;
|
||||
},
|
||||
|
||||
/**
|
||||
* Package API data from array of models
|
||||
* @param{Array} items - Upload items/rows filtered from a collection
|
||||
*/
|
||||
toData: function (items, history_id) {
|
||||
// create dictionary for data submission
|
||||
var data = {
|
||||
payload: {
|
||||
tool_id: "upload1",
|
||||
history_id: history_id || this.currentHistoryId,
|
||||
inputs: {},
|
||||
},
|
||||
files: [],
|
||||
error_message: null,
|
||||
};
|
||||
// add upload tools input data
|
||||
if (items && items.length > 0) {
|
||||
var inputs = {
|
||||
file_count: items.length,
|
||||
dbkey: items[0].get("genome", "?"),
|
||||
// sometimes extension set to "" in automated testing after first upload of
|
||||
// a session. https://github.com/galaxyproject/galaxy/issues/5169
|
||||
file_type: items[0].get("extension") || "auto",
|
||||
};
|
||||
for (var index in items) {
|
||||
var it = items[index];
|
||||
it.set("status", "running");
|
||||
if (it.get("file_size") > 0) {
|
||||
var prefix = `files_${index}|`;
|
||||
inputs[`${prefix}type`] = "upload_dataset";
|
||||
if (it.get("file_name") != "New File") {
|
||||
inputs[`${prefix}NAME`] = it.get("file_name");
|
||||
}
|
||||
inputs[`${prefix}space_to_tab`] = (it.get("space_to_tab") && "Yes") || null;
|
||||
inputs[`${prefix}to_posix_lines`] = (it.get("to_posix_lines") && "Yes") || null;
|
||||
inputs[`${prefix}dbkey`] = it.get("genome", null);
|
||||
inputs[`${prefix}file_type`] = it.get("extension", null);
|
||||
let uri;
|
||||
let how;
|
||||
switch (it.get("file_mode")) {
|
||||
case "new":
|
||||
inputs[`${prefix}url_paste`] = it.get("url_paste");
|
||||
break;
|
||||
case "ftp":
|
||||
uri = it.get("file_path");
|
||||
how = "ftp_files";
|
||||
if (uri.indexOf("://") >= 0) {
|
||||
how = "url_paste";
|
||||
}
|
||||
inputs[`${prefix}${how}`] = uri;
|
||||
break;
|
||||
case "local":
|
||||
data.files.push({
|
||||
name: `${prefix}file_data`,
|
||||
file: it.get("file_data"),
|
||||
});
|
||||
}
|
||||
} else if (it.get("optional")) {
|
||||
continue;
|
||||
} else {
|
||||
data.error_message = "Upload content incomplete.";
|
||||
it.set("status", "error");
|
||||
it.set("info", data.error_message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
data.payload.inputs = JSON.stringify(inputs);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.eventHub.$on("upload:open", this.show);
|
||||
this.id = String(this._uid);
|
||||
},
|
||||
};
|
||||
|
||||
// Beta history patch
|
||||
if (isBetaHistoryOpen()) {
|
||||
UploadModal.computed.currentHistoryId = function () {
|
||||
return this.$store.getters["betaHistory/currentHistoryId"];
|
||||
};
|
||||
}
|
||||
|
||||
export default UploadModal;
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<b-tabs v-if="ready">
|
||||
<b-tab title="Regular" id="regular" button-id="tab-title-link-regular" v-if="showRegular">
|
||||
<default :app="this" :lazy-load-max="50" :multiple="multiple" :selectable="selectable" v-on="$listeners" />
|
||||
</b-tab>
|
||||
<b-tab title="Composite" id="composite" button-id="tab-title-link-composite" v-if="showComposite">
|
||||
<composite :app="this" :selectable="selectable" v-on="$listeners" />
|
||||
</b-tab>
|
||||
<b-tab title="Collection" id="collection" button-id="tab-title-link-collection" v-if="showCollection">
|
||||
<collection :app="this" :selectable="selectable" v-on="$listeners" />
|
||||
</b-tab>
|
||||
<b-tab title="Rule-based" id="rule-based" button-id="tab-title-link-rule-based" v-if="showRules">
|
||||
<rules-input :app="this" :selectable="selectable" v-on="$listeners" />
|
||||
</b-tab>
|
||||
</b-tabs>
|
||||
<div v-else>
|
||||
<loading-span message="Loading required information from Galaxy server." />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Backbone from "backbone";
|
||||
import UploadUtils from "mvc/upload/upload-utils";
|
||||
import { getDatatypesMapper } from "components/Datatypes";
|
||||
import Composite from "./Composite";
|
||||
import Collection from "./Collection";
|
||||
import Default from "./Default";
|
||||
import RulesInput from "./RulesInput";
|
||||
import LoadingSpan from "components/LoadingSpan";
|
||||
import { BTabs, BTab } from "bootstrap-vue";
|
||||
import { commonProps } from "./helpers";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Collection,
|
||||
Composite,
|
||||
Default,
|
||||
RulesInput,
|
||||
LoadingSpan,
|
||||
BTabs,
|
||||
BTab,
|
||||
},
|
||||
props: {
|
||||
currentHistoryId: { type: String, required: true },
|
||||
currentUserId: { type: String, default: "" },
|
||||
selectable: { type: Boolean, required: false, default: false },
|
||||
...commonProps,
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
listGenomes: [],
|
||||
listExtensions: [],
|
||||
genomesSet: false,
|
||||
extensionsSet: false,
|
||||
datatypesMapper: null,
|
||||
datatypesMapperReady: true,
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.model = new Backbone.Model({
|
||||
label: "Load Data",
|
||||
percentage: 0,
|
||||
status: "",
|
||||
onunload: function () {},
|
||||
onclick: function () {},
|
||||
});
|
||||
|
||||
this.model.on("change", (model) => {
|
||||
const { changed } = model;
|
||||
for (const [field, val] of Object.entries(changed)) {
|
||||
this.eventHub.$emit(`upload:${field}`, val);
|
||||
}
|
||||
});
|
||||
|
||||
// load extensions
|
||||
// TODO: provider...
|
||||
UploadUtils.getUploadDatatypes(this.datatypesDisableAuto, this.auto)
|
||||
.then((listExtensions) => {
|
||||
this.extensionsSet = true;
|
||||
this.listExtensions = listExtensions;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Error in UploadModalContent, unable to load datatypes", err);
|
||||
});
|
||||
|
||||
// load genomes
|
||||
// TODO: provider...
|
||||
UploadUtils.getUploadGenomes(this.defaultGenome)
|
||||
.then((listGenomes) => {
|
||||
this.genomesSet = true;
|
||||
this.listGenomes = listGenomes;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Error in uploadModalContent, unable to load genomes", err);
|
||||
});
|
||||
|
||||
if (this.formats !== null) {
|
||||
this.datatypesMapperReady = false;
|
||||
getDatatypesMapper().then((datatypesMapper) => {
|
||||
this.datatypesMapper = datatypesMapper;
|
||||
this.datatypesMapperReady = true;
|
||||
});
|
||||
} else {
|
||||
this.datatypesMapperReady = true;
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
const modelUnload = this.model.get("onunload");
|
||||
modelUnload();
|
||||
},
|
||||
computed: {
|
||||
historyAvailable() {
|
||||
return Boolean(this.currentHistoryId);
|
||||
},
|
||||
ready() {
|
||||
return this.genomesSet && this.extensionsSet && this.historyAvailable && this.datatypesMapperReady;
|
||||
},
|
||||
unrestricted() {
|
||||
return this.formats === null && this.multiple;
|
||||
},
|
||||
effectiveExtensions() {
|
||||
if (this.formats === null || !this.datatypesMapperReady) {
|
||||
return this.listExtensions;
|
||||
}
|
||||
const effectiveExtensions = [];
|
||||
this.listExtensions.forEach((extension) => {
|
||||
if (extension && extension.id == "auto") {
|
||||
effectiveExtensions.push(extension);
|
||||
} else if (this.datatypesMapper.isSubTypeOfAny(extension.id, this.formats)) {
|
||||
effectiveExtensions.push(extension);
|
||||
}
|
||||
});
|
||||
return effectiveExtensions;
|
||||
},
|
||||
formatRestricted() {
|
||||
return this.formats !== null;
|
||||
},
|
||||
showComposite() {
|
||||
if (!this.formatRestricted) {
|
||||
return true;
|
||||
}
|
||||
return this.effectiveExtensions.some((extension) => !!extension.composite_files);
|
||||
},
|
||||
showRegular() {
|
||||
if (!this.formatRestricted) {
|
||||
return true;
|
||||
}
|
||||
return this.effectiveExtensions.some((extension) => !extension.composite_files);
|
||||
},
|
||||
showCollection() {
|
||||
if (this.unrestricted) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
showRules() {
|
||||
if (this.unrestricted) {
|
||||
return true;
|
||||
}
|
||||
return this.multiple;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.id = String(this._uid);
|
||||
},
|
||||
methods: {
|
||||
currentFtp: function () {
|
||||
return this.currentUserId && this.ftpUploadSite;
|
||||
},
|
||||
|
||||
/**
|
||||
* Package API data from array of models
|
||||
* @param{Array} items - Upload items/rows filtered from a collection
|
||||
*/
|
||||
toData: function (items, history_id) {
|
||||
// create dictionary for data submission
|
||||
var data = {
|
||||
payload: {
|
||||
tool_id: "upload1",
|
||||
history_id: history_id || this.currentHistoryId,
|
||||
inputs: {},
|
||||
},
|
||||
files: [],
|
||||
error_message: null,
|
||||
};
|
||||
// add upload tools input data
|
||||
if (items && items.length > 0) {
|
||||
var inputs = {
|
||||
file_count: items.length,
|
||||
dbkey: items[0].get("genome", "?"),
|
||||
// sometimes extension set to "" in automated testing after first upload of
|
||||
// a session. https://github.com/galaxyproject/galaxy/issues/5169
|
||||
file_type: items[0].get("extension") || "auto",
|
||||
};
|
||||
for (var index in items) {
|
||||
var it = items[index];
|
||||
it.set("status", "running");
|
||||
if (it.get("file_size") > 0) {
|
||||
var prefix = `files_${index}|`;
|
||||
inputs[`${prefix}type`] = "upload_dataset";
|
||||
if (it.get("file_name") != "New File") {
|
||||
inputs[`${prefix}NAME`] = it.get("file_name");
|
||||
}
|
||||
inputs[`${prefix}space_to_tab`] = (it.get("space_to_tab") && "Yes") || null;
|
||||
inputs[`${prefix}to_posix_lines`] = (it.get("to_posix_lines") && "Yes") || null;
|
||||
inputs[`${prefix}dbkey`] = it.get("genome", null);
|
||||
inputs[`${prefix}file_type`] = it.get("extension", null);
|
||||
let uri;
|
||||
let how;
|
||||
switch (it.get("file_mode")) {
|
||||
case "new":
|
||||
inputs[`${prefix}url_paste`] = it.get("url_paste");
|
||||
break;
|
||||
case "ftp":
|
||||
uri = it.get("file_path");
|
||||
how = "ftp_files";
|
||||
if (uri.indexOf("://") >= 0) {
|
||||
how = "url_paste";
|
||||
}
|
||||
inputs[`${prefix}${how}`] = uri;
|
||||
break;
|
||||
case "local":
|
||||
data.files.push({
|
||||
name: `${prefix}file_data`,
|
||||
file: it.get("file_data"),
|
||||
});
|
||||
}
|
||||
} else if (it.get("optional")) {
|
||||
continue;
|
||||
} else {
|
||||
data.error_message = "Upload content incomplete.";
|
||||
it.set("status", "error");
|
||||
it.set("info", data.error_message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
data.payload.inputs = JSON.stringify(inputs);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -4,12 +4,14 @@ import { getAppRoot } from "onload";
|
||||
export function initializeUploadDefaults(propsData = {}) {
|
||||
const Galaxy = getGalaxyInstance();
|
||||
const appRoot = getAppRoot();
|
||||
return Object.assign({}, propsData, {
|
||||
const defaults = {
|
||||
multiple: true,
|
||||
uploadPath: Galaxy.config.nginx_upload_path || `${appRoot}api/tools`,
|
||||
chunkUploadSize: Galaxy.config.chunk_upload_size,
|
||||
fileSourcesConfigured: Galaxy.config.file_sources_configured,
|
||||
ftpUploadSite: Galaxy.config.ftp_upload_site,
|
||||
defaultGenome: Galaxy.config.default_genome,
|
||||
defaultExtension: Galaxy.config.default_extension,
|
||||
});
|
||||
};
|
||||
return Object.assign({}, defaults, propsData);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import UploadUtils from "mvc/upload/upload-utils";
|
||||
|
||||
export const commonProps = {
|
||||
uploadPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
chunkUploadSize: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
fileSourcesConfigured: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
ftpUploadSite: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
defaultGenome: {
|
||||
type: String,
|
||||
default: UploadUtils.DEFAULT_GENOME,
|
||||
},
|
||||
defaultExtension: {
|
||||
type: String,
|
||||
default: UploadUtils.DEFAULT_EXTENSION,
|
||||
},
|
||||
datatypesDisableAuto: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
formats: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
multiple: {
|
||||
// Restrict the forms to a single dataset upload if false
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
callback: {
|
||||
// Return uploads when done if supplied.
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
auto: {
|
||||
type: Object,
|
||||
default: function () {
|
||||
return UploadUtils.AUTO_EXTENSION;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -2,7 +2,6 @@
|
||||
* External entry point for Upload components (currently only the modal).
|
||||
*/
|
||||
export { default as UploadModal } from "./UploadModal";
|
||||
|
||||
export { initializeUploadDefaults } from "./config";
|
||||
|
||||
export { mount as mountUploadModal } from "./mount";
|
||||
export { mountUploadModal, openUploadModal } from "./mount";
|
||||
export { default as UploadButton } from "./UploadButton";
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
import Vue from "vue";
|
||||
import UploadModal from "./UploadModal";
|
||||
import { initializeUploadDefaults } from "./config";
|
||||
import { mountVueComponent } from "utils/mountVueComponent";
|
||||
import { eventHub } from "components/plugins/eventHub";
|
||||
|
||||
export function mount(propsData = {}) {
|
||||
propsData = initializeUploadDefaults(propsData);
|
||||
const instance = Vue.extend(UploadModal);
|
||||
const vm = document.createElement("div");
|
||||
document.getElementsByTagName("body")[0].appendChild(vm);
|
||||
new instance({
|
||||
propsData: propsData,
|
||||
}).$mount(vm);
|
||||
// Upload dialog instance
|
||||
let uploadVm = null;
|
||||
|
||||
export function mountUploadModal(options = {}) {
|
||||
const propsData = initializeUploadDefaults(options);
|
||||
|
||||
if (!uploadVm) {
|
||||
const mounter = mountVueComponent(UploadModal);
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
uploadVm = mounter(propsData, container);
|
||||
} else {
|
||||
for (const [propName, v] of Object.entries(propsData)) {
|
||||
if (propName in uploadVm.$options.props) {
|
||||
uploadVm[propName] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function openUploadModal(options) {
|
||||
mountUploadModal(options);
|
||||
eventHub.$emit("upload:open");
|
||||
}
|
||||
|
||||
@@ -36,13 +36,13 @@ describe("DatasetUIWrapper.vue with Dataset", () => {
|
||||
expect(onUnhide).not.toHaveBeenCalled();
|
||||
expect(onDelete).not.toHaveBeenCalled();
|
||||
expect(onUndelete).not.toHaveBeenCalled();
|
||||
wrapper.findComponent(DscUI).vm.$emit("hideCollection");
|
||||
wrapper.findComponent(DscUI).vm.$emit("hide");
|
||||
expect(onHide).toHaveBeenCalled();
|
||||
wrapper.findComponent(DscUI).vm.$emit("unhideCollection");
|
||||
wrapper.findComponent(DscUI).vm.$emit("unhide");
|
||||
expect(onUnhide).toHaveBeenCalled();
|
||||
wrapper.findComponent(DscUI).vm.$emit("deleteCollection");
|
||||
wrapper.findComponent(DscUI).vm.$emit("delete");
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
wrapper.findComponent(DscUI).vm.$emit("undeleteCollection");
|
||||
wrapper.findComponent(DscUI).vm.$emit("undelete");
|
||||
expect(onUndelete).toHaveBeenCalled();
|
||||
});
|
||||
it("creates DatasetCollection", async () => {
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
v-bind="$attrs"
|
||||
v-on="$listeners"
|
||||
:dsc="datasetCollection"
|
||||
:showTags="true"
|
||||
:show-tags="true"
|
||||
@update:expanded="toggleExpand"
|
||||
@hideCollection="onHide(datasetCollection)"
|
||||
@unhideCollection="onUnhide(datasetCollection)"
|
||||
@deleteCollection="onDelete(datasetCollection, $event)"
|
||||
@undeleteCollection="onUndelete(datasetCollection)"
|
||||
@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>
|
||||
|
||||
@@ -35,13 +35,13 @@ describe("DatasetUIWrapper.vue with Dataset", () => {
|
||||
expect(onUnhide).not.toHaveBeenCalled();
|
||||
expect(onUndelete).not.toHaveBeenCalled();
|
||||
expect(updateDataset).not.toHaveBeenCalled();
|
||||
wrapper.findComponent(DatasetUI).vm.$emit("deleteDataset");
|
||||
wrapper.findComponent(DatasetUI).vm.$emit("delete");
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
wrapper.findComponent(DatasetUI).vm.$emit("undeleteDataset");
|
||||
wrapper.findComponent(DatasetUI).vm.$emit("undelete");
|
||||
expect(onUndelete).toHaveBeenCalled();
|
||||
wrapper.findComponent(DatasetUI).vm.$emit("unhideDataset");
|
||||
wrapper.findComponent(DatasetUI).vm.$emit("unhide");
|
||||
expect(onUnhide).toHaveBeenCalled();
|
||||
wrapper.findComponent(DatasetUI).vm.$emit("updateDataset");
|
||||
wrapper.findComponent(DatasetUI).vm.$emit("update");
|
||||
expect(updateDataset).toHaveBeenCalled();
|
||||
});
|
||||
it("handles element identifier", async () => {
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
:expanded="expanded"
|
||||
:dataset="dataset"
|
||||
:show-tags="true"
|
||||
@deleteDataset="onDelete(dataset, $event)"
|
||||
@undeleteDataset="onUndelete(dataset, $event)"
|
||||
@unhideDataset="onUnhide(dataset, $event)"
|
||||
@updateDataset="onUpdate(dataset, $event)"
|
||||
@delete="onDelete"
|
||||
@undelete="onUndelete"
|
||||
@unhide="onUnhide"
|
||||
@update="onUpdate"
|
||||
>
|
||||
</DatasetUI>
|
||||
</template>
|
||||
@@ -44,18 +44,18 @@ export default {
|
||||
expand(event) {
|
||||
this.expanded = !this.expanded;
|
||||
},
|
||||
async onDelete(dataset, opts = {}) {
|
||||
const ajaxResult = await deleteContent(dataset, opts);
|
||||
async onDelete(opts = {}) {
|
||||
const ajaxResult = await deleteContent(this.dataset, opts);
|
||||
await cacheContent(ajaxResult);
|
||||
},
|
||||
async onUnhide(dataset) {
|
||||
await this.onUpdate(dataset, { visible: true });
|
||||
async onUnhide() {
|
||||
await this.onUpdate({ visible: true });
|
||||
},
|
||||
async onUndelete(dataset) {
|
||||
await this.onUpdate(dataset, { deleted: false });
|
||||
async onUndelete() {
|
||||
await this.onUpdate({ deleted: false });
|
||||
},
|
||||
async onUpdate(dataset, changes) {
|
||||
const newContent = await updateContentFields(dataset, changes);
|
||||
async onUpdate(changes = {}) {
|
||||
const newContent = await updateContentFields(this.dataset, changes);
|
||||
await cacheContent(newContent);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div v-if="invocation">
|
||||
<div v-if="Object.keys(invocation.input_step_parameters).length">
|
||||
<details class="invocation-parameters-details"
|
||||
><summary><b>Parameters</b></summary>
|
||||
<details class="invocation-parameters-details">
|
||||
<summary><b>Parameters</b></summary>
|
||||
<parameter-step :parameters="Object.values(invocation.input_step_parameters)" />
|
||||
</details>
|
||||
</div>
|
||||
<div v-if="Object.keys(invocation.inputs).length">
|
||||
<details class="invocation-inputs-details"
|
||||
><summary><b>Inputs</b></summary>
|
||||
<details class="invocation-inputs-details">
|
||||
<summary><b>Inputs</b></summary>
|
||||
<div
|
||||
v-for="(input, key) in invocation.inputs"
|
||||
:key="input.id"
|
||||
@@ -20,8 +20,8 @@
|
||||
</details>
|
||||
</div>
|
||||
<div v-if="Object.keys(invocation.outputs).length">
|
||||
<details class="invocation-outputs-details"
|
||||
><summary><b>Outputs</b></summary>
|
||||
<details class="invocation-outputs-details">
|
||||
<summary><b>Outputs</b></summary>
|
||||
<div v-for="(output, key) in invocation.outputs" :key="output.id">
|
||||
<b>{{ key }}:</b>
|
||||
<workflow-invocation-data-contents :data_item="output" />
|
||||
@@ -29,8 +29,8 @@
|
||||
</details>
|
||||
</div>
|
||||
<div v-if="Object.keys(invocation.output_collections).length">
|
||||
<details class="invocation-output-collections-details"
|
||||
><summary><b>Output Collections</b></summary>
|
||||
<details class="invocation-output-collections-details">
|
||||
<summary><b>Output Collections</b></summary>
|
||||
<div v-for="(output, key) in invocation.output_collections" :key="output.id">
|
||||
<b>{{ key }}:</b>
|
||||
<workflow-invocation-data-contents :data_item="output" />
|
||||
@@ -38,8 +38,8 @@
|
||||
</details>
|
||||
</div>
|
||||
<div v-if="workflow">
|
||||
<details v-if="workflow" class="invocation-steps-details"
|
||||
><summary><b>Steps</b></summary>
|
||||
<details v-if="workflow" class="invocation-steps-details">
|
||||
<summary><b>Steps</b></summary>
|
||||
<workflow-invocation-step
|
||||
v-for="step in Object.values(workflow.steps)"
|
||||
:invocation="invocation"
|
||||
@@ -56,15 +56,12 @@
|
||||
import ParameterStep from "./ParameterStep.vue";
|
||||
import WorkflowInvocationDataContents from "./WorkflowInvocationDataContents";
|
||||
import WorkflowInvocationStep from "./WorkflowInvocationStep";
|
||||
import ListMixin from "components/History/ListMixin";
|
||||
import { monitorHistoryUntilTrue } from "./providers/monitors";
|
||||
|
||||
import { mapGetters } from "vuex";
|
||||
import { mapCacheActions } from "vuex-cache";
|
||||
import { vueRxShortcuts } from "components/plugins";
|
||||
|
||||
export default {
|
||||
mixins: [ListMixin, vueRxShortcuts],
|
||||
components: {
|
||||
WorkflowInvocationDataContents,
|
||||
WorkflowInvocationStep,
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { vueRxShortcuts } from "components/plugins";
|
||||
|
||||
export default {
|
||||
mixins: [vueRxShortcuts],
|
||||
props: {
|
||||
id: { type: String, required: true },
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import Vue from "vue";
|
||||
|
||||
const eventHub = new Vue();
|
||||
export const eventHub = new Vue();
|
||||
|
||||
export const eventHubMixin = {
|
||||
created() {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export { legacyNavigationMixin, legacyNavigationPlugin } from "./legacyNavigation";
|
||||
export { eventHubMixin, eventHubPlugin } from "./eventHub";
|
||||
export { vueRxShortcutPlugin, vueRxShortcuts } from "./vueRxShortcuts";
|
||||
export { localizationPlugin } from "./localization";
|
||||
|
||||
// I'm explicitly ommitting this because of its ugly dependencies, I want us to have to import the
|
||||
// legacy nav features manually (when required) rather than it coming in every time somebody accesses
|
||||
// the plugin module/index.js
|
||||
// export { legacyNavigationMixin, legacyNavigationPlugin } from "./legacyNavigation";
|
||||
|
||||
@@ -17,10 +17,19 @@ const localizeDirective = {
|
||||
},
|
||||
};
|
||||
|
||||
// adds localizeText mixin
|
||||
const localizeMixin = {
|
||||
methods: {
|
||||
l: _l,
|
||||
localize: _l,
|
||||
},
|
||||
};
|
||||
|
||||
export const localizationPlugin = {
|
||||
install(Vue) {
|
||||
Vue.filter("localize", _l);
|
||||
Vue.filter("l", _l);
|
||||
Vue.directive("localize", localizeDirective);
|
||||
Vue.mixin(localizeMixin);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* ConfigProvider ensures that the configs have been loaded from the server and
|
||||
* delivers all the config values onto the slotProps. Basically it just exists
|
||||
* to house the load method in a lifecycle handler because Vuex can't do that
|
||||
* for itself.
|
||||
*/
|
||||
|
||||
import { mapGetters, mapActions } from "vuex";
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
...mapGetters("config", ["config"]),
|
||||
},
|
||||
methods: {
|
||||
...mapActions("config", ["loadConfigs"]),
|
||||
},
|
||||
created() {
|
||||
this.loadConfigs();
|
||||
},
|
||||
render() {
|
||||
return this.$scopedSlots.default(this.config);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { mapGetters, mapActions } from "vuex";
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
...mapGetters("user", ["currentUser"]),
|
||||
},
|
||||
methods: {
|
||||
...mapActions("user", ["loadUser"]),
|
||||
},
|
||||
created() {
|
||||
this.loadUser();
|
||||
},
|
||||
render() {
|
||||
return this.$scopedSlots.default({
|
||||
user: this.currentUser,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
const MockCurrentUser = (fakeUser) => ({
|
||||
render() {
|
||||
return this.$scopedSlots.default({
|
||||
user: fakeUser,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default MockCurrentUser;
|
||||
@@ -0,0 +1,30 @@
|
||||
export const SingleQueryProvider = (cfg = {}) => {
|
||||
const { resultName = "result", lookup } = cfg;
|
||||
|
||||
let cached_result;
|
||||
|
||||
return {
|
||||
data() {
|
||||
return {
|
||||
result: undefined,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
loading() {
|
||||
return this.result === undefined;
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
if (undefined === cached_result) {
|
||||
cached_result = await lookup();
|
||||
}
|
||||
this.result = cached_result;
|
||||
},
|
||||
render() {
|
||||
return this.$scopedSlots.default({
|
||||
loading: this.loading,
|
||||
[resultName]: this.result,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,31 +1,12 @@
|
||||
import $ from "jquery";
|
||||
import Backbone from "backbone";
|
||||
import { UploadModal, initializeUploadDefaults } from "components/Upload";
|
||||
import _l from "utils/localization";
|
||||
import { getGalaxyInstance } from "app";
|
||||
import Vue from "vue";
|
||||
import ToolBox from "../../components/Panels/ToolBox";
|
||||
import SidePanel from "../../components/Panels/SidePanel";
|
||||
import { mountVueComponent } from "../../utils/mountVueComponent";
|
||||
import store from "../../store";
|
||||
|
||||
const ToolPanel = Backbone.View.extend({
|
||||
initialize: function () {
|
||||
const Galaxy = getGalaxyInstance();
|
||||
|
||||
// add upload modal
|
||||
const modalInstance = Vue.extend(UploadModal);
|
||||
const propsData = initializeUploadDefaults();
|
||||
const vm = document.createElement("div");
|
||||
$("body").append(vm);
|
||||
const upload = new modalInstance({
|
||||
propsData: propsData,
|
||||
store,
|
||||
}).$mount(vm);
|
||||
|
||||
// attach upload entrypoint to Galaxy object
|
||||
Galaxy.upload = upload;
|
||||
|
||||
// components for panel definition
|
||||
this.model = new Backbone.Model({
|
||||
title: _l("Tools"),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getGalaxyInstance } from "app";
|
||||
import HISTORY_VIEW_EDIT from "mvc/history/history-view-edit";
|
||||
import BASE_MVC from "mvc/base-mvc";
|
||||
import _l from "utils/localization";
|
||||
import { eventHub } from "components/plugins/eventHub";
|
||||
|
||||
// ============================================================================
|
||||
/** session storage for history panel preferences (and to maintain state)
|
||||
@@ -337,8 +338,7 @@ var CurrentHistoryView = _super.extend(
|
||||
events: _.extend(_.clone(_super.prototype.events), {
|
||||
// the two links in the empty message
|
||||
"click .uploader-link": function (ev) {
|
||||
const Galaxy = getGalaxyInstance();
|
||||
Galaxy.upload.show(ev);
|
||||
eventHub.$emit("upload:open");
|
||||
},
|
||||
"click .get-data-link": function (ev) {
|
||||
var $toolMenu = $(".toolMenuContainer");
|
||||
|
||||
@@ -12,9 +12,23 @@ const AUTO_EXTENSION = {
|
||||
const DEFAULT_GENOME = "?";
|
||||
const DEFAULT_EXTENSION = "auto";
|
||||
|
||||
function getUploadDatatypes(callback, datatypesDisableAuto, auto) {
|
||||
export function getUploadDatatypes(datatypesDisableAuto, auto) {
|
||||
return loadUploadDatatypes().then((result) => {
|
||||
const listExtensions = [...result];
|
||||
if (!datatypesDisableAuto) {
|
||||
listExtensions.unshift(auto);
|
||||
}
|
||||
return listExtensions;
|
||||
});
|
||||
}
|
||||
|
||||
let _cachedDatatypes;
|
||||
function loadUploadDatatypes() {
|
||||
if (_cachedDatatypes) {
|
||||
return Promise.resolve(_cachedDatatypes);
|
||||
}
|
||||
const url = `${getAppRoot()}api/datatypes?extension_only=False`;
|
||||
axios
|
||||
return axios
|
||||
.get(url)
|
||||
.then((response) => {
|
||||
const datatypes = response.data;
|
||||
@@ -33,43 +47,54 @@ function getUploadDatatypes(callback, datatypesDisableAuto, auto) {
|
||||
var b_text = b.text && b.text.toLowerCase();
|
||||
return a_text > b_text ? 1 : a_text < b_text ? -1 : 0;
|
||||
});
|
||||
if (!datatypesDisableAuto) {
|
||||
listExtensions.unshift(auto);
|
||||
}
|
||||
callback(listExtensions);
|
||||
return listExtensions;
|
||||
})
|
||||
.catch((errorMessage) => {
|
||||
console.log(errorMessage);
|
||||
.then((result) => {
|
||||
_cachedDatatypes = result;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function getUploadGenomes(callback, defaultGenome) {
|
||||
export function getUploadGenomes(defaultGenome) {
|
||||
return loadGenomes().then((result) => {
|
||||
const listGenomes = [...result];
|
||||
listGenomes.sort(genomeSort(defaultGenome));
|
||||
return listGenomes;
|
||||
});
|
||||
}
|
||||
|
||||
const genomeSort = (defaultGenome) => (a, b) => {
|
||||
if (a.id == defaultGenome) {
|
||||
return -1;
|
||||
}
|
||||
if (b.id == defaultGenome) {
|
||||
return 1;
|
||||
}
|
||||
return a.text > b.text ? 1 : a.text < b.text ? -1 : 0;
|
||||
};
|
||||
|
||||
let _cachedGenomes;
|
||||
function loadGenomes() {
|
||||
if (_cachedGenomes) {
|
||||
return Promise.resolve(_cachedGenomes);
|
||||
}
|
||||
const url = `${getAppRoot()}api/genomes`;
|
||||
axios
|
||||
return axios
|
||||
.get(url)
|
||||
.then((response) => {
|
||||
const genomes = response.data;
|
||||
const listGenomes = [];
|
||||
|
||||
for (var key in genomes) {
|
||||
listGenomes.push({
|
||||
id: genomes[key][1],
|
||||
text: genomes[key][0],
|
||||
});
|
||||
}
|
||||
listGenomes.sort((a, b) => {
|
||||
if (a.id == defaultGenome) {
|
||||
return -1;
|
||||
}
|
||||
if (b.id == defaultGenome) {
|
||||
return 1;
|
||||
}
|
||||
return a.text > b.text ? 1 : a.text < b.text ? -1 : 0;
|
||||
});
|
||||
callback(listGenomes);
|
||||
return listGenomes;
|
||||
})
|
||||
.catch((errorMessage) => {
|
||||
console.log(errorMessage);
|
||||
.then((result) => {
|
||||
_cachedGenomes = result;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { getConfig } from "./queries";
|
||||
|
||||
const state = {
|
||||
config: null,
|
||||
};
|
||||
|
||||
const getters = {
|
||||
config(state) {
|
||||
return state?.config || {};
|
||||
},
|
||||
configIsLoaded(state) {
|
||||
return state?.config !== null;
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
setConfigs(state, newConfigs = {}) {
|
||||
state.config = Object.assign({}, state.config || {}, newConfigs);
|
||||
},
|
||||
};
|
||||
|
||||
// Holds promise for in-flight loads
|
||||
let loadPromise;
|
||||
|
||||
const actions = {
|
||||
loadConfigs({ commit }) {
|
||||
if (!loadPromise) {
|
||||
loadPromise = getConfig()
|
||||
.then((configs) => commit("setConfigs", configs))
|
||||
.catch((err) => console.warn("loadConfigs error", err))
|
||||
.finally(() => (loadPromise = null));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const configStore = {
|
||||
namespaced: true,
|
||||
getters,
|
||||
state,
|
||||
mutations,
|
||||
actions,
|
||||
};
|
||||
@@ -1,44 +1,2 @@
|
||||
import { getConfig } from "./queries";
|
||||
|
||||
const state = {
|
||||
// null if not loaded yet
|
||||
config: null,
|
||||
};
|
||||
|
||||
const getters = {
|
||||
config({ state }) {
|
||||
return state?.config || {};
|
||||
},
|
||||
configIsLoaded({ state }) {
|
||||
return state?.config !== null;
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
setConfigs(state, newConfigs = {}) {
|
||||
state.config = Object.assign({}, state.config || {}, newConfigs);
|
||||
},
|
||||
};
|
||||
|
||||
const actions = {
|
||||
setConfigs({ commit }, newConfigs = {}) {
|
||||
commit("setConfigs", newConfigs);
|
||||
},
|
||||
async loadConfigs({ state, commit }) {
|
||||
// only load once
|
||||
if (state.config == null) {
|
||||
const configs = await getConfig();
|
||||
commit("setConfigs", configs);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const configStore = {
|
||||
namespaced: true,
|
||||
getters,
|
||||
state,
|
||||
mutations,
|
||||
actions,
|
||||
};
|
||||
|
||||
export default configStore;
|
||||
export { configStore } from "./configStore";
|
||||
export { syncConfigToGalaxy } from "./syncConfigToGalaxy";
|
||||
|
||||
@@ -9,9 +9,7 @@ export function syncConfigToGalaxy(galaxy$, store) {
|
||||
);
|
||||
|
||||
return cfg$.subscribe(
|
||||
(cfg) => {
|
||||
store.commit("config/setConfigs", cfg);
|
||||
},
|
||||
(cfg) => store.commit("config/setConfigs", cfg),
|
||||
(err) => console.log("syncConfigToGalaxy error", err),
|
||||
() => console.log("syncConfigToGalaxy complete")
|
||||
);
|
||||
|
||||
@@ -52,21 +52,9 @@ export function createStore() {
|
||||
},
|
||||
};
|
||||
|
||||
// Initializes global store elements.
|
||||
// Vuex can't lazy-load, so we much manually insert global data at some point and we currently
|
||||
// don't have a root App component container (which is traditionally used for such operations).
|
||||
// Watches for changes in Galaxy and sets those values on Vuex until Galaxy is gone
|
||||
// TODO: remove subscriptions in syncVuexToGalaxy as legacy functionality is ported to Vue
|
||||
if (!config.testBuild) {
|
||||
// TODO: remove all automagic inits in favor of provider mechanism which will trigger a load
|
||||
// using a lifecycle hook, or alternatively switch from Vuex to an observable framework
|
||||
// capable of lazy-loading on request
|
||||
storeConfig.plugins.push((store) => {
|
||||
store.dispatch("user/loadUser", { store });
|
||||
store.dispatch("config/loadConfigs", { store });
|
||||
store.dispatch("betaHistory/$init", { store });
|
||||
});
|
||||
|
||||
// Watches for changes in Galaxy and sets those values on Vuex until Galaxy is gone
|
||||
// TODO: remove subscriptions in syncVuexToGalaxy as legacy functionality is ported to Vue
|
||||
storeConfig.plugins.push(syncVuextoGalaxy);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,14 +10,14 @@ import { getGalaxyInstance } from "app";
|
||||
import { waitForInit } from "utils/observable/waitForInit";
|
||||
|
||||
// store subscriptions
|
||||
import { syncUserToGalaxy } from "store/userStore/syncUserToGalaxy";
|
||||
import { syncConfigToGalaxy } from "store/configStore/syncConfigToGalaxy";
|
||||
import { syncUserToGalaxy } from "store/userStore";
|
||||
import { syncConfigToGalaxy } from "store/configStore";
|
||||
import { syncCurrentHistoryToGalaxy } from "components/History/model/syncCurrentHistoryToGalaxy";
|
||||
import { isBetaHistoryOpen } from "components/History/adapters/betaToggle";
|
||||
|
||||
export const syncVuextoGalaxy = (store) => {
|
||||
const globalGalaxy$ = defer(() => {
|
||||
return waitForInit(getGalaxyInstance).pipe(shareReplay(1));
|
||||
return waitForInit(() => getGalaxyInstance()).pipe(shareReplay(1));
|
||||
});
|
||||
|
||||
// sets current user when glaaxy changes
|
||||
|
||||
@@ -12,10 +12,7 @@ export function syncUserToGalaxy(galaxy$, store) {
|
||||
);
|
||||
|
||||
return result$.subscribe(
|
||||
(user) => {
|
||||
console.log("SETTING USER", user);
|
||||
store.commit("user/setCurrentUser", user);
|
||||
},
|
||||
(user) => store.commit("user/setCurrentUser", user),
|
||||
(err) => console.log("syncUserToGalaxy error", err),
|
||||
() => console.log("syncUserToGalaxy complete")
|
||||
);
|
||||
|
||||
@@ -11,7 +11,8 @@ const getters = {
|
||||
// hydrate with model object using the getter
|
||||
currentUser(state) {
|
||||
const userProps = state.currentUser || {};
|
||||
return new User(userProps);
|
||||
const user = new User(userProps);
|
||||
return Object.freeze(user);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -21,10 +22,27 @@ const mutations = {
|
||||
},
|
||||
};
|
||||
|
||||
// Holds promise for in-flight loads
|
||||
let loadPromise;
|
||||
|
||||
const actions = {
|
||||
async loadUser({ commit }) {
|
||||
const user = await getCurrentUser();
|
||||
loadUser({ dispatch }) {
|
||||
if (!loadPromise) {
|
||||
loadPromise = getCurrentUser()
|
||||
.then((user) => {
|
||||
dispatch("setCurrentUser", user);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("loadUser error", err);
|
||||
})
|
||||
.finally(() => {
|
||||
loadPromise = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
async setCurrentUser({ commit, dispatch }, user) {
|
||||
commit("setCurrentUser", user);
|
||||
await dispatch("betaHistory/loadUserHistories", user, { root: true });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user