Merge remote-tracking branch 'upstream/release_21.01' into merge-2101-forward

This commit is contained in:
Dannon Baker
2021-02-03 15:39:30 -05:00
120 changed files with 2495 additions and 2344 deletions
+1 -1
View File
@@ -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 **/
-6
View File
@@ -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,
+5 -8
View File
@@ -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();
},
@@ -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>
@@ -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>
+87 -72
View File
@@ -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");
+23 -25
View File
@@ -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,
},
};
},
});
+15 -24
View File
@@ -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
@@ -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);
},
},
};
@@ -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;
@@ -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 })
);
});
};
@@ -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,
});
},
};
@@ -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);
},
},
};
@@ -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;
@@ -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;
}
@@ -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>
+3 -3
View File
@@ -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,
+1 -1
View File
@@ -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>
+18 -10
View File
@@ -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: {
+4 -7
View File
@@ -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();
});
+5 -5
View File
@@ -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 */
+4 -4
View File
@@ -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
+5 -5
View File
@@ -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 {
+22 -22
View File
@@ -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");
},
},
};
@@ -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);
});
});
+53 -310
View File
@@ -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 -2
View File
@@ -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);
}
+52
View File
@@ -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 -3
View File
@@ -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";
+25 -9
View File
@@ -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 },
},
+1 -1
View File
@@ -6,7 +6,7 @@
import Vue from "vue";
const eventHub = new Vue();
export const eventHub = new Vue();
export const eventHubMixin = {
created() {
+5 -1
View File
@@ -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,
});
},
};
};
-19
View File
@@ -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");
+48 -23
View File
@@ -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,
};
+2 -44
View File
@@ -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")
);
+2 -14
View File
@@ -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);
}
+3 -3
View File
@@ -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")
);
+21 -3
View File
@@ -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