mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Merge pull request #18857 from ahmedhamidawan/guide_users_to_collection_builder
[24.2] Guide users to collection builders
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
import { type components } from "@/api";
|
||||
|
||||
export type CompositeFileInfo = components["schemas"]["CompositeFileInfo"];
|
||||
@@ -0,0 +1,3 @@
|
||||
import { type components } from "@/api";
|
||||
|
||||
export type HistoryContentsResult = components["schemas"]["HistoryContentsResult"];
|
||||
@@ -0,0 +1,329 @@
|
||||
<script setup lang="ts">
|
||||
import { faCheckCircle, faUndo } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BAlert, BLink, BModal } from "bootstrap-vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import type { HDASummary, HistoryItemSummary, HistorySummary } from "@/api";
|
||||
import { createDatasetCollection } from "@/components/History/model/queries";
|
||||
import { useCollectionBuilderItemsStore } from "@/stores/collectionBuilderItemsStore";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
import localize from "@/utils/localization";
|
||||
import { orList } from "@/utils/strings";
|
||||
|
||||
import type { CollectionType, DatasetPair } from "../History/adapters/buildCollectionModal";
|
||||
|
||||
import ListCollectionCreator from "./ListCollectionCreator.vue";
|
||||
import PairCollectionCreator from "./PairCollectionCreator.vue";
|
||||
import PairedListCollectionCreator from "./PairedListCollectionCreator.vue";
|
||||
import Heading from "@/components/Common/Heading.vue";
|
||||
import GenericItem from "@/components/History/Content/GenericItem.vue";
|
||||
import LoadingSpan from "@/components/LoadingSpan.vue";
|
||||
|
||||
interface Props {
|
||||
historyId: string;
|
||||
showModal: boolean;
|
||||
collectionType: CollectionType;
|
||||
selectedItems?: HistoryItemSummary[];
|
||||
defaultHideSourceItems?: boolean;
|
||||
extensions?: string[];
|
||||
fromRulesInput?: boolean;
|
||||
hideModalOnCreate?: boolean;
|
||||
filterText?: string;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "created-collection", collection: any): void;
|
||||
(e: "update:show-modal", showModal: boolean): void;
|
||||
}>();
|
||||
|
||||
/** Computed toggle that handles opening and closing the modal */
|
||||
const localShowToggle = computed({
|
||||
get: () => props.showModal,
|
||||
set: (value: boolean) => {
|
||||
emit("update:show-modal", value);
|
||||
},
|
||||
});
|
||||
|
||||
// Create Collection refs
|
||||
const creatingCollection = ref(false);
|
||||
const createCollectionError = ref<string | null>(null);
|
||||
const createdCollection = ref<any>(null);
|
||||
|
||||
// History items variables
|
||||
const historyItemsError = ref<string | null>(null);
|
||||
const collectionItemsStore = useCollectionBuilderItemsStore();
|
||||
const historyStore = useHistoryStore();
|
||||
const history = computed(() => historyStore.getHistoryById(props.historyId));
|
||||
const historyId = computed(() => props.historyId);
|
||||
const localFilterText = computed(() => props.filterText || "");
|
||||
const historyUpdateTime = computed(() => history.value?.update_time);
|
||||
const isFetchingItems = computed(() => collectionItemsStore.isFetching[localFilterText.value]);
|
||||
const historyDatasets = computed(() => {
|
||||
if (collectionItemsStore.cachedDatasetsForFilterText) {
|
||||
return collectionItemsStore.cachedDatasetsForFilterText[localFilterText.value] || [];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
/** Flag for the initial fetch of history items */
|
||||
const initialFetch = ref(false);
|
||||
|
||||
/** Whether a list of items was selected to create a collection from */
|
||||
const fromSelection = computed(() => !!props.selectedItems?.length);
|
||||
|
||||
/** Items to create the collection from */
|
||||
const creatorItems = computed(() => (fromSelection.value ? props.selectedItems : historyDatasets.value));
|
||||
|
||||
watch(
|
||||
() => localShowToggle.value,
|
||||
async (show) => {
|
||||
if (show) {
|
||||
await fetchHistoryDatasets();
|
||||
if (!initialFetch.value) {
|
||||
initialFetch.value = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Fetch items when history ID or update time changes, only if localShowToggle is true
|
||||
watch([historyId, historyUpdateTime, localFilterText], async () => {
|
||||
if (localShowToggle.value) {
|
||||
await fetchHistoryDatasets();
|
||||
}
|
||||
});
|
||||
|
||||
// If there is a change in `historyDatasets`, but we have selected items, we should update the selected items
|
||||
watch(
|
||||
() => historyDatasets.value,
|
||||
(newDatasets) => {
|
||||
if (fromSelection.value) {
|
||||
// find each selected item in the new datasets, and update it
|
||||
props.selectedItems?.forEach((selectedItem) => {
|
||||
const newDataset = newDatasets.find((dataset) => dataset.id === selectedItem.id);
|
||||
if (newDataset) {
|
||||
Object.assign(selectedItem, newDataset);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
if (props.collectionType === "list") {
|
||||
return localize(
|
||||
`Create a collection from a list of ${fromSelection.value ? "selected" : ""} ${
|
||||
props.extensions?.length ? orList(props.extensions) : ""
|
||||
} datasets`
|
||||
);
|
||||
} else if (props.collectionType === "list:paired") {
|
||||
return localize(
|
||||
`Create a collection of ${fromSelection.value ? "selected" : ""} ${
|
||||
props.extensions?.length ? orList(props.extensions) : ""
|
||||
} dataset pairs`
|
||||
);
|
||||
} else if (props.collectionType === "paired") {
|
||||
return localize(
|
||||
`Create a ${props.extensions?.length ? orList(props.extensions) : ""} dataset pair collection ${
|
||||
fromSelection.value ? "from selected items" : ""
|
||||
}`
|
||||
);
|
||||
} else {
|
||||
return localize("Create a collection");
|
||||
}
|
||||
});
|
||||
|
||||
// Methods
|
||||
function createListCollection(elements: HDASummary[], name: string, hideSourceItems: boolean) {
|
||||
const returnedElems = elements.map((element) => ({
|
||||
id: element.id,
|
||||
name: element.name,
|
||||
//TODO: this allows for list:list even if the implementation does not - reconcile
|
||||
src: "src" in element ? element.src : element.history_content_type == "dataset" ? "hda" : "hdca",
|
||||
}));
|
||||
return createHDCA(returnedElems, "list", name, hideSourceItems);
|
||||
}
|
||||
|
||||
function createListPairedCollection(elements: DatasetPair[], name: string, hideSourceItems: boolean) {
|
||||
const returnedElems = elements.map((pair) => ({
|
||||
collection_type: "paired",
|
||||
src: "new_collection",
|
||||
name: pair.name,
|
||||
element_identifiers: [
|
||||
{
|
||||
name: "forward",
|
||||
id: pair.forward.id,
|
||||
src: "src" in pair.forward ? pair.forward.src : "hda",
|
||||
},
|
||||
{
|
||||
name: "reverse",
|
||||
id: pair.reverse.id,
|
||||
src: "src" in pair.reverse ? pair.reverse.src : "hda",
|
||||
},
|
||||
],
|
||||
}));
|
||||
return createHDCA(returnedElems, "list:paired", name, hideSourceItems);
|
||||
}
|
||||
|
||||
function createPairedCollection(elements: DatasetPair, name: string, hideSourceItems: boolean) {
|
||||
const { forward, reverse } = elements;
|
||||
const returnedElems = [
|
||||
{ name: "forward", src: "src" in forward ? forward.src : "hda", id: forward.id },
|
||||
{ name: "reverse", src: "src" in reverse ? reverse.src : "hda", id: reverse.id },
|
||||
];
|
||||
return createHDCA(returnedElems, "paired", name, hideSourceItems);
|
||||
}
|
||||
|
||||
async function createHDCA(
|
||||
element_identifiers: any[],
|
||||
collection_type: CollectionType,
|
||||
name: string,
|
||||
hide_source_items: boolean,
|
||||
options = {}
|
||||
) {
|
||||
try {
|
||||
creatingCollection.value = true;
|
||||
const collection = await createDatasetCollection(history.value as HistorySummary, {
|
||||
collection_type,
|
||||
name,
|
||||
hide_source_items,
|
||||
element_identifiers,
|
||||
options,
|
||||
});
|
||||
|
||||
emit("created-collection", collection);
|
||||
createdCollection.value = collection;
|
||||
|
||||
if (props.hideModalOnCreate) {
|
||||
hideModal();
|
||||
}
|
||||
} catch (error) {
|
||||
createCollectionError.value = error as string;
|
||||
} finally {
|
||||
creatingCollection.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHistoryDatasets() {
|
||||
const { error } = await collectionItemsStore.fetchDatasetsForFiltertext(
|
||||
historyId.value,
|
||||
historyUpdateTime.value,
|
||||
localFilterText.value
|
||||
);
|
||||
if (error) {
|
||||
historyItemsError.value = error;
|
||||
console.error("Error fetching history items:", historyItemsError.value);
|
||||
} else {
|
||||
historyItemsError.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function hideModal() {
|
||||
localShowToggle.value = false;
|
||||
}
|
||||
|
||||
function resetModal() {
|
||||
createCollectionError.value = null;
|
||||
createdCollection.value = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BModal
|
||||
id="collection-creator-modal"
|
||||
v-model="localShowToggle"
|
||||
:busy="(fromSelection && isFetchingItems) || creatingCollection"
|
||||
modal-class="ui-modal collection-creator-modal"
|
||||
:hide-footer="!createdCollection && !createCollectionError"
|
||||
:ok-disabled="!!createdCollection || !!createCollectionError"
|
||||
:cancel-title="localize('Exit')"
|
||||
footer-class="d-flex justify-content-between"
|
||||
:ok-title="localize('Create Collection')"
|
||||
@hidden="resetModal">
|
||||
<template v-slot:modal-header>
|
||||
<Heading class="w-100" size="sm">
|
||||
<div class="d-flex justify-content-between unselectable w-100">
|
||||
<div>{{ modalTitle }}</div>
|
||||
<div v-if="!!history">
|
||||
From history: <b>{{ history.name }}</b>
|
||||
</div>
|
||||
</div>
|
||||
</Heading>
|
||||
</template>
|
||||
<BAlert v-if="isFetchingItems && !initialFetch" variant="info" show>
|
||||
<LoadingSpan :message="localize('Loading items')" />
|
||||
</BAlert>
|
||||
<BAlert v-else-if="!fromSelection && historyItemsError" variant="danger" show>
|
||||
{{ historyItemsError }}
|
||||
</BAlert>
|
||||
<BAlert v-else-if="!creatorItems?.length" variant="info" show>
|
||||
{{ localize("No items available to create a collection.") }}
|
||||
</BAlert>
|
||||
<BAlert v-else-if="creatingCollection" variant="info" show>
|
||||
<LoadingSpan :message="localize('Creating collection')" />
|
||||
</BAlert>
|
||||
<BAlert v-else-if="createCollectionError" variant="danger" show>
|
||||
{{ createCollectionError }}
|
||||
<BLink class="text-decoration-none" @click.stop.prevent="resetModal">
|
||||
<FontAwesomeIcon :icon="faUndo" fixed-width />
|
||||
{{ localize("Try again") }}
|
||||
</BLink>
|
||||
</BAlert>
|
||||
<div v-else-if="createdCollection">
|
||||
<BAlert variant="success" show>
|
||||
<FontAwesomeIcon :icon="faCheckCircle" class="text-success" fixed-width />
|
||||
{{ localize("Collection created successfully.") }}
|
||||
<BLink v-if="!fromSelection" class="text-decoration-none" @click.stop.prevent="resetModal">
|
||||
<FontAwesomeIcon :icon="faUndo" fixed-width />
|
||||
{{ localize("Create another collection") }}
|
||||
</BLink>
|
||||
</BAlert>
|
||||
|
||||
<!-- TODO: This is a bit shady, better if we confirm it is a collection type -->
|
||||
<GenericItem :item-id="createdCollection.id" item-src="hdca" />
|
||||
</div>
|
||||
<ListCollectionCreator
|
||||
v-else-if="props.collectionType === 'list'"
|
||||
:history-id="props.historyId"
|
||||
:initial-elements="creatorItems"
|
||||
:default-hide-source-items="props.defaultHideSourceItems"
|
||||
:from-selection="fromSelection"
|
||||
:extensions="props.extensions"
|
||||
@clicked-create="createListCollection"
|
||||
@on-cancel="hideModal" />
|
||||
<PairedListCollectionCreator
|
||||
v-else-if="props.collectionType === 'list:paired'"
|
||||
:history-id="props.historyId"
|
||||
:initial-elements="creatorItems"
|
||||
:default-hide-source-items="props.defaultHideSourceItems"
|
||||
:from-selection="fromSelection"
|
||||
:extensions="props.extensions"
|
||||
@clicked-create="createListPairedCollection"
|
||||
@on-cancel="hideModal" />
|
||||
<PairCollectionCreator
|
||||
v-else-if="props.collectionType === 'paired'"
|
||||
:history-id="props.historyId"
|
||||
:initial-elements="creatorItems"
|
||||
:default-hide-source-items="props.defaultHideSourceItems"
|
||||
:from-selection="fromSelection"
|
||||
:extensions="props.extensions"
|
||||
@clicked-create="createPairedCollection"
|
||||
@on-cancel="hideModal" />
|
||||
</BModal>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
/** NOTE: Not using `<style scoped> here because these classes are
|
||||
`BModal` `body-class` and `content-class` and don't seem to work
|
||||
with scoped */
|
||||
.collection-creator-modal {
|
||||
.modal-dialog {
|
||||
width: 85%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,41 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import "ui/hoverhighlight";
|
||||
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faSortAlphaDown, faUndo } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faSquare } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faMinus, faSortAlphaDown, faTimes, faUndo } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BAlert, BButton } from "bootstrap-vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { BAlert, BButton, BButtonGroup } from "bootstrap-vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import draggable from "vuedraggable";
|
||||
|
||||
import type { HDCADetailed, HistoryItemSummary } from "@/api";
|
||||
import type { HDASummary, HistoryItemSummary } from "@/api";
|
||||
import { Toast } from "@/composables/toast";
|
||||
import STATES from "@/mvc/dataset/states";
|
||||
import { useDatatypesMapperStore } from "@/stores/datatypesMapperStore";
|
||||
import localize from "@/utils/localization";
|
||||
|
||||
import FormSelectMany from "../Form/Elements/FormSelectMany/FormSelectMany.vue";
|
||||
import CollectionCreator from "@/components/Collections/common/CollectionCreator.vue";
|
||||
import DatasetCollectionElementView from "@/components/Collections/ListDatasetCollectionElementView.vue";
|
||||
|
||||
library.add(faSortAlphaDown, faUndo);
|
||||
const NOT_VALID_ELEMENT_MSG: string = localize("is not a valid element for this collection");
|
||||
|
||||
interface Props {
|
||||
initialElements: Array<any>;
|
||||
oncancel: () => void;
|
||||
oncreate: () => void;
|
||||
creationFn: (workingElements: any, collectionName: string, hideSourceItems: boolean) => any;
|
||||
historyId: string;
|
||||
initialElements: HistoryItemSummary[];
|
||||
defaultHideSourceItems?: boolean;
|
||||
fromSelection?: boolean;
|
||||
extensions?: string[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "clicked-create", workingElements: any, collectionName: string, hideSourceItems: boolean): void;
|
||||
(e: "clicked-create", workingElements: HDASummary[], collectionName: string, hideSourceItems: boolean): void;
|
||||
(e: "on-cancel"): void;
|
||||
}>();
|
||||
|
||||
const state = ref("build");
|
||||
const originalNamedElements = ref([]);
|
||||
const duplicateNames = ref<string[]>([]);
|
||||
const invalidElements = ref<string[]>([]);
|
||||
const workingElements = ref<HDCADetailed[]>([]);
|
||||
const workingElements = ref<HDASummary[]>([]);
|
||||
const selectedDatasetElements = ref<string[]>([]);
|
||||
const hideSourceItems = ref(props.defaultHideSourceItems || false);
|
||||
|
||||
@@ -54,7 +57,7 @@ const returnInvalidElementsLength = computed(() => {
|
||||
const returnInvalidElements = computed(() => {
|
||||
return invalidElements.value;
|
||||
});
|
||||
const noElementsSelected = computed(() => {
|
||||
const noInitialElements = computed(() => {
|
||||
return props.initialElements.length == 0;
|
||||
});
|
||||
const showDuplicateError = computed(() => {
|
||||
@@ -64,17 +67,20 @@ const allElementsAreInvalid = computed(() => {
|
||||
return props.initialElements.length == invalidElements.value.length;
|
||||
});
|
||||
|
||||
/** set up instance vars function */
|
||||
function _instanceSetUp() {
|
||||
/** Ids of elements that have been selected by the user - to preserve over renders */
|
||||
selectedDatasetElements.value = [];
|
||||
}
|
||||
/** If not `fromSelection`, the list of elements that will become the collection */
|
||||
const inListElements = ref<HDASummary[]>([]);
|
||||
|
||||
// variables for datatype mapping and then filtering
|
||||
const datatypesMapperStore = useDatatypesMapperStore();
|
||||
const datatypesMapper = computed(() => datatypesMapperStore.datatypesMapper);
|
||||
|
||||
/** Are we filtering by datatype? */
|
||||
const filterExtensions = computed(() => !!datatypesMapper.value && !!props.extensions?.length);
|
||||
|
||||
// ----------------------------------------------------------------------- process raw list
|
||||
/** set up main data */
|
||||
function _elementsSetUp() {
|
||||
/** a list of invalid elements and the reasons they aren't valid */
|
||||
|
||||
invalidElements.value = [];
|
||||
|
||||
//TODO: handle fundamental problem of syncing DOM, views, and list here
|
||||
@@ -84,21 +90,44 @@ function _elementsSetUp() {
|
||||
// copy initial list, sort, add ids if needed
|
||||
workingElements.value = JSON.parse(JSON.stringify(props.initialElements.slice(0)));
|
||||
|
||||
_ensureElementIds();
|
||||
// reverse the order of the elements to emulate what we have in the history panel
|
||||
workingElements.value.reverse();
|
||||
|
||||
// for inListElements, reset their values (in order) to datasets from workingElements
|
||||
const inListElementsPrev = inListElements.value;
|
||||
inListElements.value = [];
|
||||
inListElementsPrev.forEach((prevElem) => {
|
||||
const element = workingElements.value.find((e) => e.id === prevElem.id);
|
||||
const problem = _isElementInvalid(prevElem);
|
||||
|
||||
if (element) {
|
||||
inListElements.value.push(element);
|
||||
} else if (problem) {
|
||||
const invalidMsg = `${prevElem.hid}: ${prevElem.name} ${problem} and ${NOT_VALID_ELEMENT_MSG}`;
|
||||
invalidElements.value.push(invalidMsg);
|
||||
Toast.error(invalidMsg, localize("Invalid element"));
|
||||
} else {
|
||||
const invalidMsg = `${prevElem.hid}: ${prevElem.name} ${localize("has been removed from the collection")}`;
|
||||
invalidElements.value.push(invalidMsg);
|
||||
Toast.error(invalidMsg, localize("Invalid element"));
|
||||
}
|
||||
});
|
||||
|
||||
// _ensureElementIds();
|
||||
_validateElements();
|
||||
_mangleDuplicateNames();
|
||||
}
|
||||
|
||||
/** add ids to dataset objs in initial list if none */
|
||||
function _ensureElementIds() {
|
||||
workingElements.value.forEach((element) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(element, "id")) {
|
||||
console.warn("Element missing id", element);
|
||||
}
|
||||
});
|
||||
// TODO: not sure if this is needed
|
||||
// function _ensureElementIds() {
|
||||
// workingElements.value.forEach((element) => {
|
||||
// if (!Object.prototype.hasOwnProperty.call(element, "id")) {
|
||||
// console.warn("Element missing id", element);
|
||||
// }
|
||||
// });
|
||||
|
||||
return workingElements.value;
|
||||
}
|
||||
// return workingElements.value;
|
||||
// }
|
||||
|
||||
// /** separate working list into valid and invalid elements for this collection */
|
||||
function _validateElements() {
|
||||
@@ -116,7 +145,7 @@ function _validateElements() {
|
||||
}
|
||||
|
||||
/** describe what is wrong with a particular element if anything */
|
||||
function _isElementInvalid(element: HistoryItemSummary) {
|
||||
function _isElementInvalid(element: HistoryItemSummary): string | null {
|
||||
if (element.history_content_type === "dataset_collection") {
|
||||
return localize("is a collection, this is not allowed");
|
||||
}
|
||||
@@ -131,6 +160,14 @@ function _isElementInvalid(element: HistoryItemSummary) {
|
||||
return localize("has been deleted or purged");
|
||||
}
|
||||
|
||||
// is the element's extension not a subtype of any of the required extensions?
|
||||
if (
|
||||
filterExtensions.value &&
|
||||
element.extension &&
|
||||
!datatypesMapper.value?.isSubTypeOfAny(element.extension, props.extensions!)
|
||||
) {
|
||||
return localize(`has an invalid extension: ${element.extension}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -152,17 +189,11 @@ function _mangleDuplicateNames() {
|
||||
});
|
||||
}
|
||||
|
||||
function saveOriginalNames() {
|
||||
// Deep copy elements
|
||||
originalNamedElements.value = JSON.parse(JSON.stringify(workingElements.value));
|
||||
function changeDatatypeFilter(newFilter: "all" | "datatype" | "ext") {
|
||||
_elementsSetUp();
|
||||
}
|
||||
|
||||
function getOriginalNames() {
|
||||
// Deep copy elements
|
||||
workingElements.value = JSON.parse(JSON.stringify(originalNamedElements.value));
|
||||
}
|
||||
|
||||
function elementSelected(e: HDCADetailed) {
|
||||
function elementSelected(e: HDASummary) {
|
||||
if (!selectedDatasetElements.value.includes(e.id)) {
|
||||
selectedDatasetElements.value.push(e.id);
|
||||
} else {
|
||||
@@ -170,8 +201,11 @@ function elementSelected(e: HDCADetailed) {
|
||||
}
|
||||
}
|
||||
|
||||
function elementDiscarded(e: HDCADetailed) {
|
||||
function elementDiscarded(e: HDASummary) {
|
||||
workingElements.value.splice(workingElements.value.indexOf(e), 1);
|
||||
selectedDatasetElements.value = selectedDatasetElements.value.filter((element) => {
|
||||
return element !== e.id;
|
||||
});
|
||||
|
||||
return workingElements.value;
|
||||
}
|
||||
@@ -180,18 +214,27 @@ function clickClearAll() {
|
||||
selectedDatasetElements.value = [];
|
||||
}
|
||||
|
||||
function clickRemoveSelected() {
|
||||
workingElements.value = workingElements.value.filter((element) => {
|
||||
return !selectedDatasetElements.value.includes(element.id);
|
||||
});
|
||||
|
||||
selectedDatasetElements.value = [];
|
||||
}
|
||||
|
||||
function clickSelectAll() {
|
||||
selectedDatasetElements.value = workingElements.value.map((element) => {
|
||||
return element.id;
|
||||
});
|
||||
}
|
||||
|
||||
function clickedCreate(collectionName: string) {
|
||||
checkForDuplicates();
|
||||
|
||||
if (state.value !== "error") {
|
||||
emit("clicked-create", workingElements.value, collectionName, hideSourceItems.value);
|
||||
const returnedElements = props.fromSelection ? workingElements.value : inListElements.value;
|
||||
|
||||
return props
|
||||
.creationFn(workingElements.value, collectionName, hideSourceItems.value)
|
||||
.done(props.oncreate)
|
||||
.fail(() => {
|
||||
state.value = "error";
|
||||
});
|
||||
if (state.value !== "error") {
|
||||
emit("clicked-create", returnedElements, collectionName, hideSourceItems.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,15 +258,16 @@ function checkForDuplicates() {
|
||||
|
||||
/** reset all data to the initial state */
|
||||
function reset() {
|
||||
_instanceSetUp();
|
||||
getOriginalNames();
|
||||
/** Ids of elements that have been selected by the user - to preserve over renders */
|
||||
selectedDatasetElements.value = [];
|
||||
_elementsSetUp();
|
||||
}
|
||||
|
||||
function sortByName() {
|
||||
workingElements.value.sort(compareNames);
|
||||
}
|
||||
|
||||
function compareNames(a: HDCADetailed, b: HDCADetailed) {
|
||||
function compareNames(a: HDASummary, b: HDASummary) {
|
||||
if (a.name && b.name && a.name < b.name) {
|
||||
return -1;
|
||||
}
|
||||
@@ -238,11 +282,55 @@ function onUpdateHideSourceItems(newHideSourceItems: boolean) {
|
||||
hideSourceItems.value = newHideSourceItems;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
_instanceSetUp();
|
||||
_elementsSetUp();
|
||||
saveOriginalNames();
|
||||
});
|
||||
watch(
|
||||
() => props.initialElements,
|
||||
() => {
|
||||
// for any new/removed elements, add them to working elements
|
||||
_elementsSetUp();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => datatypesMapper.value,
|
||||
async (mapper) => {
|
||||
if (props.extensions?.length && !mapper) {
|
||||
await datatypesMapperStore.createMapper();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function addUploadedFiles(files: HDASummary[]) {
|
||||
const returnedElements = props.fromSelection ? workingElements : inListElements;
|
||||
files.forEach((f) => {
|
||||
const file = props.fromSelection ? f : workingElements.value.find((e) => e.id === f.id);
|
||||
const problem = _isElementInvalid(f);
|
||||
if (file && !returnedElements.value.find((e) => e.id === file.id)) {
|
||||
returnedElements.value.push(file);
|
||||
} else if (problem) {
|
||||
invalidElements.value.push("Uploaded item: " + f.name + " " + problem);
|
||||
Toast.error(
|
||||
localize(`Dataset ${f.hid}: ${f.name} ${problem} and is an invalid element for this collection`),
|
||||
localize("Uploaded item is invalid")
|
||||
);
|
||||
} else {
|
||||
invalidElements.value.push("Uploaded item: " + f.name + " could not be added to the collection");
|
||||
Toast.error(
|
||||
localize(`Dataset ${f.hid}: ${f.name} could not be added to the collection`),
|
||||
localize("Uploaded item is invalid")
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** find the element in the workingElements array and update its name */
|
||||
function renameElement(element: any, name: string) {
|
||||
element = workingElements.value.find((e) => e.id === element.id);
|
||||
if (element) {
|
||||
element.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: issue #9497
|
||||
// const removeExtensions = ref(true);
|
||||
@@ -265,30 +353,45 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="list-collection-creator">
|
||||
<div v-if="state == 'error'">
|
||||
<div v-if="!showDuplicateError && state == 'error'">
|
||||
<BAlert show variant="danger">
|
||||
{{ localize("Galaxy could not be reached and may be updating. Try again in a few minutes.") }}
|
||||
{{ localize("There was a problem creating the collection.") }}
|
||||
</BAlert>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="noElementsSelected">
|
||||
<div v-if="noInitialElements">
|
||||
<BAlert show variant="warning" dismissible>
|
||||
{{ localize("No datasets were selected") }}
|
||||
{{ localize("At least one element is needed for the collection. You may need to") }}
|
||||
<a class="cancel-text" href="javascript:void(0)" role="button" @click="oncancel">
|
||||
<a class="cancel-text" href="javascript:void(0)" role="button" @click="emit('on-cancel')">
|
||||
{{ localize("cancel") }}
|
||||
</a>
|
||||
{{ localize("and reselect new elements.") }}
|
||||
</BAlert>
|
||||
|
||||
<div class="float-left">
|
||||
<button class="cancel-create btn" tabindex="-1" @click="oncancel">
|
||||
<button class="cancel-create btn" tabindex="-1" @click="emit('on-cancel')">
|
||||
{{ localize("Cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="allElementsAreInvalid">
|
||||
<BAlert show variant="warning" dismissible>
|
||||
<BAlert v-if="!fromSelection" show variant="warning">
|
||||
{{
|
||||
localize(
|
||||
"No elements in your history are valid for this list. You may need to switch to a different history."
|
||||
)
|
||||
}}
|
||||
<span v-if="extensions?.length">
|
||||
{{ localize("The following extensions are required for this list: ") }}
|
||||
<ul>
|
||||
<li v-for="extension in extensions" :key="extension">
|
||||
{{ extension }}
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
</BAlert>
|
||||
<BAlert v-else show variant="warning" dismissible>
|
||||
{{ localize("The following selections could not be included due to problems:") }}
|
||||
<ul>
|
||||
<li v-for="problem in returnInvalidElements" :key="problem">
|
||||
@@ -296,20 +399,20 @@ onMounted(() => {
|
||||
</li>
|
||||
</ul>
|
||||
{{ localize("At least one element is needed for the collection. You may need to") }}
|
||||
<a class="cancel-text" href="javascript:void(0)" role="button" @click="oncancel">
|
||||
<a class="cancel-text" href="javascript:void(0)" role="button" @click="emit('on-cancel')">
|
||||
{{ localize("cancel") }}
|
||||
</a>
|
||||
{{ localize("and reselect new elements.") }}
|
||||
</BAlert>
|
||||
|
||||
<div class="float-left">
|
||||
<button class="cancel-create btn" tabindex="-1" @click="oncancel">
|
||||
<button class="cancel-create btn" tabindex="-1" @click="emit('on-cancel')">
|
||||
{{ localize("Cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="returnInvalidElementsLength">
|
||||
<div v-if="fromSelection && returnInvalidElementsLength">
|
||||
<BAlert show variant="warning" dismissible>
|
||||
{{ localize("The following selections could not be included due to problems:") }}
|
||||
<ul>
|
||||
@@ -335,11 +438,16 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<CollectionCreator
|
||||
:oncancel="oncancel"
|
||||
:oncancel="() => emit('on-cancel')"
|
||||
:history-id="props.historyId"
|
||||
:hide-source-items="hideSourceItems"
|
||||
:extensions="extensions"
|
||||
@add-uploaded-files="addUploadedFiles"
|
||||
@on-update-datatype-toggle="changeDatatypeFilter"
|
||||
@onUpdateHideSourceItems="onUpdateHideSourceItems"
|
||||
@clicked-create="clickedCreate">
|
||||
<template v-slot:help-content>
|
||||
<!-- TODO: Update help content for case where `fromSelection` is false -->
|
||||
<p>
|
||||
{{
|
||||
localize(
|
||||
@@ -430,33 +538,67 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<template v-slot:middle-content>
|
||||
<div class="collection-elements-controls">
|
||||
<BButton class="reset" :title="localize('Undo all reordering and discards')" @click="reset">
|
||||
<FontAwesomeIcon :icon="faUndo" />
|
||||
</BButton>
|
||||
<div v-if="fromSelection">
|
||||
<div class="collection-elements-controls">
|
||||
<div>
|
||||
<BButton
|
||||
class="reset"
|
||||
:title="localize('Reset to original state')"
|
||||
size="sm"
|
||||
@click="reset">
|
||||
<FontAwesomeIcon :icon="faUndo" fixed-width />
|
||||
{{ localize("Reset") }}
|
||||
</BButton>
|
||||
<BButton
|
||||
class="sort-items"
|
||||
:title="localize('Sort datasets by name')"
|
||||
size="sm"
|
||||
@click="sortByName">
|
||||
<FontAwesomeIcon :icon="faSortAlphaDown" />
|
||||
</BButton>
|
||||
</div>
|
||||
|
||||
<BButton class="sort-items" :title="localize('Sort datasets by name')" @click="sortByName">
|
||||
<FontAwesomeIcon :icon="faSortAlphaDown" />
|
||||
</BButton>
|
||||
<div class="center-text">
|
||||
<u>{{ workingElements.length }}</u> {{ localize("elements in list") }}
|
||||
</div>
|
||||
|
||||
<a
|
||||
v-if="atLeastOneDatasetIsSelected"
|
||||
class="clear-selected"
|
||||
href="javascript:void(0);"
|
||||
role="button"
|
||||
:title="localize('De-select all selected datasets')"
|
||||
@click="clickClearAll">
|
||||
{{ localize("Clear selected") }}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<span v-if="atLeastOneDatasetIsSelected"
|
||||
>{{ localize("For selection") }} ({{ selectedDatasetElements.length }}):</span
|
||||
>
|
||||
<BButtonGroup class="" size="sm">
|
||||
<BButton
|
||||
v-if="atLeastOneDatasetIsSelected"
|
||||
:title="localize('Remove selected datasets from the list')"
|
||||
@click="clickRemoveSelected">
|
||||
<FontAwesomeIcon :icon="faMinus" fixed-width />
|
||||
{{ localize("Remove") }}
|
||||
</BButton>
|
||||
<BButton
|
||||
v-if="
|
||||
!atLeastOneDatasetIsSelected ||
|
||||
selectedDatasetElements.length < workingElements.length
|
||||
"
|
||||
:title="localize('Select all datasets')"
|
||||
size="sm"
|
||||
@click="clickSelectAll">
|
||||
<FontAwesomeIcon :icon="faSquare" fixed-width />
|
||||
{{ localize("Select all") }}
|
||||
</BButton>
|
||||
<BButton
|
||||
v-if="atLeastOneDatasetIsSelected"
|
||||
class="clear-selected"
|
||||
:title="localize('De-select all selected datasets')"
|
||||
@click="clickClearAll">
|
||||
<FontAwesomeIcon :icon="faTimes" fixed-width />
|
||||
{{ localize("Clear") }}
|
||||
</BButton>
|
||||
</BButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<draggable
|
||||
v-model="workingElements"
|
||||
class="collection-elements scroll-container flex-row drop-zone"
|
||||
@start="drag = true"
|
||||
@end="drag = false">
|
||||
<div v-if="noMoreValidDatasets">
|
||||
<BAlert show variant="warning" dismissible>
|
||||
<BAlert show variant="warning">
|
||||
{{ localize("No elements left. Would you like to") }}
|
||||
<a class="reset-text" href="javascript:void(0)" role="button" @click="reset">
|
||||
{{ localize("start over") }}
|
||||
@@ -465,16 +607,36 @@ onMounted(() => {
|
||||
</BAlert>
|
||||
</div>
|
||||
|
||||
<DatasetCollectionElementView
|
||||
v-for="element in workingElements"
|
||||
v-else
|
||||
:key="element.id"
|
||||
:class="{ selected: getSelectedDatasetElements.includes(element.id) }"
|
||||
:element="element"
|
||||
@element-is-selected="elementSelected"
|
||||
@element-is-discarded="elementDiscarded"
|
||||
@onRename="(name) => (element.name = name)" />
|
||||
</draggable>
|
||||
<draggable
|
||||
v-model="workingElements"
|
||||
class="collection-elements scroll-container flex-row drop-zone"
|
||||
chosen-class="bg-secondary">
|
||||
<DatasetCollectionElementView
|
||||
v-for="element in workingElements"
|
||||
:key="element.id"
|
||||
:class="{ selected: getSelectedDatasetElements.includes(element.id) }"
|
||||
:element="element"
|
||||
has-actions
|
||||
:selected="getSelectedDatasetElements.includes(element.id)"
|
||||
@element-is-selected="elementSelected"
|
||||
@element-is-discarded="elementDiscarded"
|
||||
@onRename="(name) => (element.name = name)" />
|
||||
</draggable>
|
||||
</div>
|
||||
|
||||
<FormSelectMany
|
||||
v-else
|
||||
v-model="inListElements"
|
||||
maintain-selection-order
|
||||
:placeholder="localize('Filter datasets by name')"
|
||||
:options="workingElements.map((e) => ({ label: e.name || '', value: e }))">
|
||||
<template v-slot:label-area="{ value }">
|
||||
<DatasetCollectionElementView
|
||||
class="w-100"
|
||||
:element="value"
|
||||
@onRename="(name) => renameElement(value, name)" />
|
||||
</template>
|
||||
</FormSelectMany>
|
||||
</template>
|
||||
</CollectionCreator>
|
||||
</div>
|
||||
@@ -483,6 +645,9 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "base.scss";
|
||||
@import "theme/blue.scss";
|
||||
|
||||
.list-collection-creator {
|
||||
.footer {
|
||||
margin-top: 8px;
|
||||
@@ -494,9 +659,15 @@ onMounted(() => {
|
||||
|
||||
.collection-elements-controls {
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
.clear-selected {
|
||||
float: right !important;
|
||||
.center-text {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
left: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,23 +681,30 @@ onMounted(() => {
|
||||
// TODO: taken from .dataset above - swap these out
|
||||
.collection-element {
|
||||
height: 32px;
|
||||
margin: 2px 4px 0px 4px;
|
||||
opacity: 1;
|
||||
border: 1px solid lightgrey;
|
||||
border-radius: 3px;
|
||||
padding: 0 8px 0 8px;
|
||||
line-height: 28px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
border: 1px solid lightgrey;
|
||||
border-radius: 3px;
|
||||
|
||||
&.with-actions {
|
||||
margin: 2px 4px 0px 4px;
|
||||
&:hover {
|
||||
border-color: black;
|
||||
}
|
||||
}
|
||||
&:not(.with-actions) {
|
||||
&:hover {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: black;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: black;
|
||||
background: rgb(118, 119, 131);
|
||||
@@ -535,20 +713,6 @@ onMounted(() => {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.discard {
|
||||
@extend .float-right !optional;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import _l from "utils/localization";
|
||||
import Vue from "vue";
|
||||
|
||||
import { collectionCreatorModalSetup } from "./common/modal";
|
||||
|
||||
function listCollectionCreatorModal(elements, options) {
|
||||
options = options || {};
|
||||
options.title = _l("Create a collection from a list of datasets");
|
||||
const { promise, showEl } = collectionCreatorModalSetup(options);
|
||||
return import(/* webpackChunkName: "ListCollectionCreator" */ "./ListCollectionCreator.vue").then((module) => {
|
||||
const listCollectionCreatorInstance = Vue.extend(module.default);
|
||||
const vm = document.createElement("div");
|
||||
showEl(vm);
|
||||
new listCollectionCreatorInstance({
|
||||
propsData: {
|
||||
initialElements: elements,
|
||||
creationFn: options.creationFn,
|
||||
oncancel: options.oncancel,
|
||||
oncreate: options.oncreate,
|
||||
defaultHideSourceItems: options.defaultHideSourceItems,
|
||||
},
|
||||
}).$mount(vm);
|
||||
return promise;
|
||||
});
|
||||
}
|
||||
|
||||
/** Use a modal to create a list collection, then add it to the given history contents.
|
||||
* @returns {Promise} resolved when the collection is added to the history.
|
||||
*/
|
||||
function createListCollection(contents, defaultHideSourceItems = true) {
|
||||
const elements = contents.toJSON();
|
||||
const promise = listCollectionCreatorModal(elements, {
|
||||
defaultHideSourceItems: defaultHideSourceItems,
|
||||
creationFn: function (elements, name, hideSourceItems) {
|
||||
elements = elements.map((element) => ({
|
||||
id: element.id,
|
||||
name: element.name,
|
||||
//TODO: this allows for list:list even if the filter above does not - reconcile
|
||||
src: element.src || (element.history_content_type == "dataset" ? "hda" : "hdca"),
|
||||
}));
|
||||
return contents.createHDCA(elements, "list", name, hideSourceItems);
|
||||
},
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
export default {
|
||||
listCollectionCreatorModal: listCollectionCreatorModal,
|
||||
createListCollection: createListCollection,
|
||||
};
|
||||
@@ -1,12 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { faCheck, faMinus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
import type { HDASummary } from "@/api";
|
||||
import localize from "@/utils/localization";
|
||||
|
||||
import ClickToEdit from "@/components/Collections/common/ClickToEdit.vue";
|
||||
|
||||
interface Props {
|
||||
element: any;
|
||||
element: HDASummary;
|
||||
selected?: boolean;
|
||||
hasActions?: boolean;
|
||||
notEditable?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
@@ -17,7 +23,7 @@ const emit = defineEmits<{
|
||||
(event: "element-is-discarded", element: any): void;
|
||||
}>();
|
||||
|
||||
const elementName = ref("");
|
||||
const elementName = ref(props.element.name || "...");
|
||||
|
||||
watch(elementName, () => {
|
||||
emit("onRename", elementName.value);
|
||||
@@ -26,28 +32,40 @@ watch(elementName, () => {
|
||||
function clickDiscard() {
|
||||
emit("element-is-discarded", props.element);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
elementName.value = props.element.name;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="collection-element" @click="emit('element-is-selected', element)">
|
||||
<ClickToEdit v-model="elementName" :title="localize('Click to rename')" />
|
||||
<div
|
||||
class="collection-element d-flex justify-content-between"
|
||||
:class="{ 'with-actions': hasActions }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@keyup.enter="emit('element-is-selected', element)"
|
||||
@click="emit('element-is-selected', element)">
|
||||
<span class="d-flex flex-gapx-1">
|
||||
<span v-if="element.hid">{{ element.hid }}:</span>
|
||||
<strong>
|
||||
<ClickToEdit v-if="!notEditable" v-model="elementName" :title="localize('Click to rename')" />
|
||||
<span v-else>{{ elementName }}</span>
|
||||
</strong>
|
||||
<i v-if="element.extension"> ({{ element.extension }}) </i>
|
||||
</span>
|
||||
|
||||
<button class="discard-btn btn-sm" :title="localize('Remove this dataset from the list')" @click="clickDiscard">
|
||||
{{ localize("Discard") }}
|
||||
</button>
|
||||
<div v-if="hasActions" class="float-right">
|
||||
<i v-if="!selected" class="mr-2">
|
||||
<FontAwesomeIcon :icon="faCheck" class="text-success" /> Added to collection
|
||||
</i>
|
||||
<i v-else class="text-secondary">Selected</i>
|
||||
<button class="btn-sm" :title="localize('Remove this dataset from the list')" @click="clickDiscard">
|
||||
<FontAwesomeIcon :icon="faMinus" fixed-width />
|
||||
{{ localize("Remove") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.collection-element {
|
||||
height: auto;
|
||||
|
||||
.discard-btn {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,32 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { BAlert } from "bootstrap-vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { faArrowsAltV } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BAlert, BButton } from "bootstrap-vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import type { HDCADetailed, HistoryItemSummary } from "@/api";
|
||||
import type { HDASummary, HistoryItemSummary } from "@/api";
|
||||
import { Toast } from "@/composables/toast";
|
||||
import STATES from "@/mvc/dataset/states";
|
||||
import { useDatatypesMapperStore } from "@/stores/datatypesMapperStore";
|
||||
import localize from "@/utils/localization";
|
||||
|
||||
import type { DatasetPair } from "../History/adapters/buildCollectionModal";
|
||||
|
||||
import DatasetCollectionElementView from "./ListDatasetCollectionElementView.vue";
|
||||
import CollectionCreator from "@/components/Collections/common/CollectionCreator.vue";
|
||||
|
||||
const NOT_VALID_ELEMENT_MSG: string = localize("is not a valid element for this collection");
|
||||
|
||||
interface SelectedDatasetPair {
|
||||
forward: HDASummary | undefined;
|
||||
reverse: HDASummary | undefined;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initialElements: Array<any>;
|
||||
oncancel: () => void;
|
||||
oncreate: () => void;
|
||||
creationFn: (workingElements: HDCADetailed[], collectionName: string, hideSourceItems: boolean) => any;
|
||||
historyId: string;
|
||||
initialElements: HistoryItemSummary[];
|
||||
defaultHideSourceItems?: boolean;
|
||||
fromSelection?: boolean;
|
||||
extensions?: string[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "clicked-create", workingElements: HDCADetailed[], collectionName: string, hideSourceItems: boolean): void;
|
||||
(event: "clicked-create", selectedPair: DatasetPair, collectionName: string, hideSourceItems: boolean): void;
|
||||
(event: "on-cancel"): void;
|
||||
}>();
|
||||
|
||||
const state = ref("build");
|
||||
const removeExtensions = ref(true);
|
||||
const initialSuggestedName = ref("");
|
||||
const invalidElements = ref<string[]>([]);
|
||||
const workingElements = ref<HDCADetailed[]>([]);
|
||||
const workingElements = ref<HDASummary[]>([]);
|
||||
|
||||
/** If not `fromSelection`, the manually added elements that will become the pair */
|
||||
const inListElements = ref<SelectedDatasetPair>({ forward: undefined, reverse: undefined });
|
||||
|
||||
const allElementsAreInvalid = computed(() => {
|
||||
return props.initialElements.length == invalidElements.value.length;
|
||||
@@ -35,9 +53,44 @@ const noElementsSelected = computed(() => {
|
||||
return props.initialElements.length == 0;
|
||||
});
|
||||
const exactlyTwoValidElements = computed(() => {
|
||||
return workingElements.value.length == 2;
|
||||
return pairElements.value.forward && pairElements.value.reverse;
|
||||
});
|
||||
const hideSourceItems = ref(props.defaultHideSourceItems || false);
|
||||
const pairElements = computed<SelectedDatasetPair>(() => {
|
||||
if (props.fromSelection) {
|
||||
return {
|
||||
forward: workingElements.value[0],
|
||||
reverse: workingElements.value[1],
|
||||
};
|
||||
} else {
|
||||
return inListElements.value;
|
||||
}
|
||||
});
|
||||
|
||||
// variables for datatype mapping and then filtering
|
||||
const datatypesMapperStore = useDatatypesMapperStore();
|
||||
const datatypesMapper = computed(() => datatypesMapperStore.datatypesMapper);
|
||||
|
||||
/** Are we filtering by datatype? */
|
||||
const filterExtensions = computed(() => !!datatypesMapper.value && !!props.extensions?.length);
|
||||
|
||||
watch(
|
||||
() => props.initialElements,
|
||||
() => {
|
||||
// initialize; and then for any new/removed elements, sync working elements
|
||||
_elementsSetUp();
|
||||
|
||||
// TODO: fix this, now either of those will almost always be empty
|
||||
if (props.fromSelection && !initialSuggestedName.value) {
|
||||
initialSuggestedName.value = _guessNameForPair(
|
||||
workingElements.value[0] as HDASummary,
|
||||
workingElements.value[1] as HDASummary,
|
||||
removeExtensions.value
|
||||
);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function _elementsSetUp() {
|
||||
/** a list of invalid elements and the reasons they aren't valid */
|
||||
@@ -50,6 +103,38 @@ function _elementsSetUp() {
|
||||
// copy initial list, sort, add ids if needed
|
||||
workingElements.value = JSON.parse(JSON.stringify(props.initialElements.slice(0)));
|
||||
|
||||
// reverse the order of the elements to emulate what we have in the history panel
|
||||
workingElements.value.reverse();
|
||||
|
||||
// for inListElements, reset their values (in order) to datasets from workingElements
|
||||
const inListElementsPrev = inListElements.value;
|
||||
inListElements.value = { forward: undefined, reverse: undefined };
|
||||
|
||||
for (const key of ["forward", "reverse"]) {
|
||||
const prevElem = inListElementsPrev[key as keyof SelectedDatasetPair];
|
||||
if (!prevElem) {
|
||||
continue;
|
||||
}
|
||||
const element = workingElements.value.find(
|
||||
(e) => e.id === inListElementsPrev[key as keyof SelectedDatasetPair]?.id
|
||||
);
|
||||
const problem = _isElementInvalid(prevElem);
|
||||
if (element) {
|
||||
inListElements.value[key as keyof SelectedDatasetPair] = element;
|
||||
} else if (problem) {
|
||||
const invalidMsg = `${prevElem.hid}: ${prevElem.name} ${problem} and ${NOT_VALID_ELEMENT_MSG}`;
|
||||
invalidElements.value.push(invalidMsg);
|
||||
Toast.error(invalidMsg, localize("Invalid element"));
|
||||
} else {
|
||||
const invalidMsg = `${prevElem.hid}: ${prevElem.name} ${localize("has been removed from the collection")}`;
|
||||
invalidElements.value.push(invalidMsg);
|
||||
Toast.error(invalidMsg, localize("Invalid element"));
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Next thing to add is: If the user adds an uploaded file, that is eventually nuked from workingElements
|
||||
// because it is invalid, Toast an error for that file by keeping track of uploaded ids in a separate list
|
||||
|
||||
_ensureElementIds();
|
||||
_validateElements();
|
||||
}
|
||||
@@ -96,23 +181,74 @@ function _isElementInvalid(element: HistoryItemSummary) {
|
||||
return localize("has been deleted or purged");
|
||||
}
|
||||
|
||||
// is the element's extension not a subtype of any of the required extensions?
|
||||
if (
|
||||
filterExtensions.value &&
|
||||
element.extension &&
|
||||
!datatypesMapper.value?.isSubTypeOfAny(element.extension, props.extensions!)
|
||||
) {
|
||||
return localize(`has an invalid extension: ${element.extension}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPairElement(key: string) {
|
||||
return pairElements.value[key as "forward" | "reverse"];
|
||||
}
|
||||
|
||||
function removeElement(key: string) {
|
||||
pairElements.value[key as "forward" | "reverse"] = undefined;
|
||||
}
|
||||
|
||||
function selectElement(element: HDASummary) {
|
||||
if (pairElements.value.forward?.id === element.id) {
|
||||
pairElements.value.forward = undefined;
|
||||
} else if (pairElements.value.reverse?.id === element.id) {
|
||||
pairElements.value.reverse = undefined;
|
||||
} else if (pairElements.value.forward === undefined) {
|
||||
pairElements.value.forward = element;
|
||||
} else if (pairElements.value.reverse === undefined) {
|
||||
pairElements.value.reverse = element;
|
||||
}
|
||||
}
|
||||
|
||||
function swapButton() {
|
||||
workingElements.value = [workingElements.value[1], workingElements.value[0]] as HDCADetailed[];
|
||||
if (!exactlyTwoValidElements.value) {
|
||||
return;
|
||||
}
|
||||
if (props.fromSelection) {
|
||||
workingElements.value = [workingElements.value[1], workingElements.value[0]] as HDASummary[];
|
||||
} else {
|
||||
inListElements.value = {
|
||||
forward: inListElements.value.reverse,
|
||||
reverse: inListElements.value.forward,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function addUploadedFiles(files: HDASummary[]) {
|
||||
// Any added files are added to workingElements in _elementsSetUp
|
||||
// The user will have to manually select the files to add them to the pair
|
||||
|
||||
// Check for validity of uploads
|
||||
files.forEach((file) => {
|
||||
const problem = _isElementInvalid(file);
|
||||
if (problem) {
|
||||
const invalidMsg = `${file.hid}: ${file.name} ${problem} and ${NOT_VALID_ELEMENT_MSG}`;
|
||||
invalidElements.value.push(invalidMsg);
|
||||
Toast.error(invalidMsg, localize("Uploaded item invalid for pair"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clickedCreate(collectionName: string) {
|
||||
if (state.value !== "error") {
|
||||
emit("clicked-create", workingElements.value, collectionName, hideSourceItems.value);
|
||||
|
||||
return props
|
||||
.creationFn(workingElements.value, collectionName, hideSourceItems.value)
|
||||
.done(props.oncreate)
|
||||
.fail(() => {
|
||||
state.value = "error";
|
||||
});
|
||||
if (state.value !== "error" && exactlyTwoValidElements.value) {
|
||||
const returnedPair = {
|
||||
forward: pairElements.value.forward as HDASummary,
|
||||
reverse: pairElements.value.reverse as HDASummary,
|
||||
name: collectionName,
|
||||
};
|
||||
emit("clicked-create", returnedPair, collectionName, hideSourceItems.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,13 +256,13 @@ function removeExtensionsToggle() {
|
||||
removeExtensions.value = !removeExtensions.value;
|
||||
|
||||
initialSuggestedName.value = _guessNameForPair(
|
||||
workingElements.value[0] as HDCADetailed,
|
||||
workingElements.value[1] as HDCADetailed,
|
||||
workingElements.value[0] as HDASummary,
|
||||
workingElements.value[1] as HDASummary,
|
||||
removeExtensions.value
|
||||
);
|
||||
}
|
||||
|
||||
function _guessNameForPair(fwd: HDCADetailed, rev: HDCADetailed, removeExtensions: boolean) {
|
||||
function _guessNameForPair(fwd: HDASummary, rev: HDASummary, removeExtensions: boolean) {
|
||||
removeExtensions = removeExtensions ? removeExtensions : removeExtensions;
|
||||
|
||||
var fwdName = fwd.name ?? "";
|
||||
@@ -199,16 +335,6 @@ function _naiveStartingAndEndingLCS(s1: string, s2: string) {
|
||||
|
||||
return fwdLCS + revLCS;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
_elementsSetUp();
|
||||
|
||||
initialSuggestedName.value = _guessNameForPair(
|
||||
workingElements.value[0] as HDCADetailed,
|
||||
workingElements.value[1] as HDCADetailed,
|
||||
removeExtensions.value
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -223,20 +349,35 @@ onMounted(() => {
|
||||
<BAlert show variant="warning" dismissible>
|
||||
{{ localize("No datasets were selected.") }}
|
||||
{{ localize("Exactly two elements needed for the collection. You may need to") }}
|
||||
<a class="cancel-text" href="javascript:void(0)" role="button" @click="oncancel">
|
||||
<a class="cancel-text" href="javascript:void(0)" role="button" @click="emit('on-cancel')">
|
||||
{{ localize("cancel") }}
|
||||
</a>
|
||||
{{ localize("and reselect new elements.") }}
|
||||
</BAlert>
|
||||
|
||||
<div class="float-left">
|
||||
<button class="cancel-create btn" tabindex="-1" @click="oncancel">
|
||||
<button class="cancel-create btn" tabindex="-1" @click="emit('on-cancel')">
|
||||
{{ localize("Cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="allElementsAreInvalid">
|
||||
<BAlert show variant="warning" dismissible>
|
||||
<BAlert v-if="!fromSelection" show variant="warning">
|
||||
{{
|
||||
localize(
|
||||
"No elements in your history are valid for this pair. You may need to switch to a different history."
|
||||
)
|
||||
}}
|
||||
<span v-if="extensions?.length">
|
||||
{{ localize("The following extensions are required for this pair: ") }}
|
||||
<ul>
|
||||
<li v-for="extension in extensions" :key="extension">
|
||||
{{ extension }}
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
</BAlert>
|
||||
<BAlert v-else show variant="warning" dismissible>
|
||||
{{ localize("The following selections could not be included due to problems:") }}
|
||||
<ul>
|
||||
<li v-for="problem in invalidElements" :key="problem">
|
||||
@@ -244,46 +385,20 @@ onMounted(() => {
|
||||
</li>
|
||||
</ul>
|
||||
{{ localize("Exactly two elements needed for the collection. You may need to") }}
|
||||
<a class="cancel-text" href="javascript:void(0)" role="button" @click="oncancel">
|
||||
<a class="cancel-text" href="javascript:void(0)" role="button" @click="emit('on-cancel')">
|
||||
{{ localize("cancel") }}
|
||||
</a>
|
||||
{{ localize("and reselect new elements.") }}
|
||||
</BAlert>
|
||||
|
||||
<div class="float-left">
|
||||
<button class="cancel-create btn" tabindex="-1" @click="oncancel">
|
||||
{{ localize("Cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!exactlyTwoValidElements">
|
||||
<div v-if="invalidElements.length">
|
||||
<BAlert show variant="warning" dismissible>
|
||||
{{ localize("The following selections could not be included due to problems:") }}
|
||||
<ul>
|
||||
<li v-for="problem in invalidElements" :key="problem">
|
||||
{{ problem }}
|
||||
</li>
|
||||
</ul>
|
||||
</BAlert>
|
||||
</div>
|
||||
|
||||
<BAlert show variant="warning" dismissible>
|
||||
{{ localize("Two (and only two) elements are needed for the pair. You may need to ") }}
|
||||
<a class="cancel-text" href="javascript:void(0)" role="button" @click="oncancel">
|
||||
{{ localize("cancel") }}
|
||||
</a>
|
||||
{{ localize("and reselect new elements.") }}
|
||||
</BAlert>
|
||||
|
||||
<div class="float-left">
|
||||
<button class="cancel-create btn" tabindex="-1" @click="oncancel">
|
||||
<button class="cancel-create btn" tabindex="-1" @click="emit('on-cancel')">
|
||||
{{ localize("Cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="invalidElements.length">
|
||||
<div v-if="fromSelection && invalidElements.length">
|
||||
<BAlert show variant="warning" dismissible>
|
||||
{{ localize("The following selections could not be included due to problems:") }}
|
||||
<ul>
|
||||
@@ -293,15 +408,37 @@ onMounted(() => {
|
||||
</ul>
|
||||
</BAlert>
|
||||
</div>
|
||||
<div v-if="!exactlyTwoValidElements">
|
||||
<BAlert show variant="warning" dismissible>
|
||||
{{ localize("Exactly two elements are needed for the pair.") }}
|
||||
<span v-if="fromSelection">
|
||||
<a class="cancel-text" href="javascript:void(0)" role="button" @click="emit('on-cancel')">
|
||||
{{ localize("Cancel") }}
|
||||
</a>
|
||||
{{ localize("and reselect new elements.") }}
|
||||
</span>
|
||||
</BAlert>
|
||||
|
||||
<div v-if="fromSelection" class="float-left">
|
||||
<button class="cancel-create btn" tabindex="-1" @click="emit('on-cancel')">
|
||||
{{ localize("Cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CollectionCreator
|
||||
:oncancel="oncancel"
|
||||
:oncancel="() => emit('on-cancel')"
|
||||
:history-id="props.historyId"
|
||||
:hide-source-items="hideSourceItems"
|
||||
:suggested-name="initialSuggestedName"
|
||||
:extensions="props.extensions"
|
||||
:extensions-toggle="removeExtensions"
|
||||
@add-uploaded-files="addUploadedFiles"
|
||||
@onUpdateHideSourceItems="onUpdateHideSourceItems"
|
||||
@clicked-create="clickedCreate"
|
||||
@remove-extensions-toggle="removeExtensionsToggle">
|
||||
<template v-slot:help-content>
|
||||
<!-- TODO: Update help content for case where `fromSelection` is false -->
|
||||
<p>
|
||||
{{
|
||||
localize(
|
||||
@@ -352,21 +489,46 @@ onMounted(() => {
|
||||
|
||||
<template v-slot:middle-content>
|
||||
<div class="collection-elements-controls">
|
||||
<a
|
||||
<BButton
|
||||
class="swap"
|
||||
href="javascript:void(0);"
|
||||
title="l('Swap forward and reverse datasets')"
|
||||
size="sm"
|
||||
:disabled="!exactlyTwoValidElements"
|
||||
:title="localize('Swap forward and reverse datasets')"
|
||||
@click="swapButton">
|
||||
<FontAwesomeIcon :icon="faArrowsAltV" fixed-width />
|
||||
{{ localize("Swap") }}
|
||||
</a>
|
||||
</BButton>
|
||||
</div>
|
||||
|
||||
<div class="collection-elements scroll-container flex-row">
|
||||
<div
|
||||
v-for="(element, index) in workingElements"
|
||||
:key="element.id"
|
||||
class="collection-element">
|
||||
{{ index == 0 ? localize("forward") : localize("reverse") }}: {{ element.name }}
|
||||
<div class="collection-elements flex-row mb-3">
|
||||
<div v-for="dataset in ['forward', 'reverse']" :key="dataset">
|
||||
{{ localize(dataset) }}:
|
||||
<DatasetCollectionElementView
|
||||
v-if="getPairElement(dataset)"
|
||||
:key="getPairElement(dataset)?.id"
|
||||
:element="getPairElement(dataset)"
|
||||
has-actions
|
||||
@element-is-discarded="removeElement(dataset)" />
|
||||
<div v-else class="collection-element alert-info">
|
||||
<i>{{ localize("No dataset selected") }}</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!fromSelection">
|
||||
{{ localize("Manually select a forward and reverse dataset to create a pair collection:") }}
|
||||
<div class="collection-elements">
|
||||
<DatasetCollectionElementView
|
||||
v-for="element in workingElements"
|
||||
:key="element.id"
|
||||
:class="{
|
||||
selected: [pairElements.forward, pairElements.reverse].includes(element),
|
||||
}"
|
||||
:element="element"
|
||||
not-editable
|
||||
:selected="[pairElements.forward, pairElements.reverse].includes(element)"
|
||||
@element-is-selected="selectElement"
|
||||
@onRename="(name) => (element.name = name)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -382,10 +544,6 @@ onMounted(() => {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.main-help {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.collection-elements-controls {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@@ -433,6 +591,15 @@ onMounted(() => {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: black;
|
||||
background: rgb(118, 119, 131);
|
||||
color: white;
|
||||
a {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import _l from "utils/localization";
|
||||
import Vue from "vue";
|
||||
|
||||
import { collectionCreatorModalSetup } from "./common/modal";
|
||||
|
||||
function pairCollectionCreatorModal(elements, options) {
|
||||
options = options || {};
|
||||
options.title = _l("Create a collection from a pair of datasets");
|
||||
const { promise, showEl } = collectionCreatorModalSetup(options);
|
||||
return import(/* webpackChunkName: "PairCollectionCreator" */ "./PairCollectionCreator.vue").then((module) => {
|
||||
var pairCollectionCreatorInstance = Vue.extend(module.default);
|
||||
var vm = document.createElement("div");
|
||||
showEl(vm);
|
||||
new pairCollectionCreatorInstance({
|
||||
propsData: {
|
||||
initialElements: elements,
|
||||
creationFn: options.creationFn,
|
||||
oncancel: options.oncancel,
|
||||
oncreate: options.oncreate,
|
||||
defaultHideSourceItems: options.defaultHideSourceItems,
|
||||
},
|
||||
}).$mount(vm);
|
||||
return promise;
|
||||
});
|
||||
}
|
||||
function createPairCollection(contents, defaultHideSourceItems = true) {
|
||||
var elements = contents.toJSON();
|
||||
var promise = pairCollectionCreatorModal(elements, {
|
||||
defaultHideSourceItems: defaultHideSourceItems,
|
||||
creationFn: function (elements, name, hideSourceItems) {
|
||||
elements = [
|
||||
{ name: "forward", src: elements[0].src || "hda", id: elements[0].id },
|
||||
{ name: "reverse", src: elements[1].src || "hda", id: elements[1].id },
|
||||
];
|
||||
return contents.createHDCA(elements, "paired", name, hideSourceItems);
|
||||
},
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
export default {
|
||||
pairCollectionCreatorModal: pairCollectionCreatorModal,
|
||||
createPairCollection: createPairCollection,
|
||||
};
|
||||
@@ -1,69 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faUnlink } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faArrowLeft, faArrowRight, faUnlink } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { BButton } from "bootstrap-vue";
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
import type { HDASummary } from "@/api";
|
||||
import localize from "@/utils/localization";
|
||||
|
||||
import ClickToEdit from "@/components/Collections/common/ClickToEdit.vue";
|
||||
|
||||
library.add(faUnlink);
|
||||
|
||||
interface Props {
|
||||
// TODO: Define the type of the pair prop
|
||||
pair: {
|
||||
name: string;
|
||||
forward: { name: string };
|
||||
reverse: { name: string };
|
||||
forward: HDASummary;
|
||||
reverse: HDASummary;
|
||||
};
|
||||
unlinkFn: () => void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "onPairRename", name: string): void;
|
||||
(event: "onUnpair"): void;
|
||||
}>();
|
||||
|
||||
const name = ref("");
|
||||
const elementName = ref(props.pair.name);
|
||||
|
||||
watch(
|
||||
() => props.pair,
|
||||
() => {
|
||||
name.value = props.pair.name;
|
||||
}
|
||||
);
|
||||
|
||||
watch(name, () => {
|
||||
emit("onPairRename", name.value);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
name.value = props.pair.name;
|
||||
watch(elementName, () => {
|
||||
emit("onPairRename", elementName.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<li class="dataset paired">
|
||||
<li class="d-flex align-items-center justify-content-between">
|
||||
<div class="dataset paired w-100">
|
||||
<span class="forward-dataset-name flex-column">
|
||||
{{ pair.forward.name }}
|
||||
<FontAwesomeIcon :icon="faArrowRight" fixed-width />
|
||||
</span>
|
||||
|
||||
<span class="pair-name-column flex-column">
|
||||
<span class="pair-name">
|
||||
<ClickToEdit v-model="name" :title="localize('Click to rename')" />
|
||||
<ClickToEdit v-model="elementName" :title="localize('Click to rename')" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="reverse-dataset-name flex-column">
|
||||
<FontAwesomeIcon :icon="faArrowLeft" fixed-width />
|
||||
{{ pair.reverse.name }}
|
||||
</span>
|
||||
</li>
|
||||
</div>
|
||||
|
||||
<button class="unpair-btn" @click="unlinkFn">
|
||||
<BButton class="unpair-btn" variant="link" @click="emit('onUnpair')">
|
||||
<FontAwesomeIcon :icon="faUnlink" :title="localize('Unpair')" />
|
||||
</button>
|
||||
</div>
|
||||
</BButton>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -1,48 +1,69 @@
|
||||
import { createTestingPinia } from "@pinia/testing";
|
||||
import DATA from "@tests/test-data/paired-collection-creator.data.js";
|
||||
import { mount, shallowMount } from "@vue/test-utils";
|
||||
import PairedListCollectionCreator from "components/Collections/PairedListCollectionCreator";
|
||||
import flushPromises from "flush-promises";
|
||||
import Vue from "vue";
|
||||
|
||||
import { useServerMock } from "@/api/client/__mocks__";
|
||||
|
||||
// Mock the localize directive
|
||||
// (otherwise we get: [Vue warn]: Failed to resolve directive: localize)
|
||||
Vue.directive("localize", {
|
||||
bind(el, binding) {
|
||||
el.textContent = binding.value;
|
||||
},
|
||||
});
|
||||
|
||||
const { server, http } = useServerMock();
|
||||
|
||||
describe("PairedListCollectionCreator", () => {
|
||||
let wrapper;
|
||||
const pinia = createTestingPinia();
|
||||
|
||||
it("autopairs the dataset", async () => {
|
||||
wrapper = shallowMount(PairedListCollectionCreator, {
|
||||
propsData: {
|
||||
initialElements: DATA._1,
|
||||
creationFn: () => {
|
||||
return;
|
||||
},
|
||||
oncreate: () => {
|
||||
return;
|
||||
},
|
||||
oncancel: () => {
|
||||
return;
|
||||
},
|
||||
hideSourceItems: false,
|
||||
},
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
// Autopair is called on startup
|
||||
expect(wrapper.findAll("li.dataset unpaired").length == 0).toBeTruthy();
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.get("/api/configuration", ({ response }) => {
|
||||
return response(200).json({
|
||||
chunk_upload_size: 100,
|
||||
file_sources_configured: true,
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("selects the correct name for an auotpair", async () => {
|
||||
it("performs an autopair on startup if we have a selection", async () => {
|
||||
// Kind of deprecated because we are never using `props.fromSelection: true` anywhere
|
||||
|
||||
wrapper = shallowMount(PairedListCollectionCreator, {
|
||||
propsData: {
|
||||
historyId: "history_id",
|
||||
initialElements: DATA._1,
|
||||
fromSelection: true,
|
||||
},
|
||||
pinia,
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
// Autopair is called on startup
|
||||
const pairsCountDisplay = wrapper.find('[data-description="number of pairs"]');
|
||||
expect(pairsCountDisplay.text()).toContain(`${DATA._1.length / 2} pairs`);
|
||||
});
|
||||
|
||||
it("selects the correct name for an autopair", async () => {
|
||||
wrapper = mount(PairedListCollectionCreator, {
|
||||
propsData: {
|
||||
historyId: "history_id",
|
||||
initialElements: DATA._2,
|
||||
creationFn: () => {
|
||||
return;
|
||||
},
|
||||
oncreate: () => {
|
||||
return;
|
||||
},
|
||||
oncancel: () => {
|
||||
return;
|
||||
},
|
||||
hideSourceItems: false,
|
||||
},
|
||||
pinia,
|
||||
stubs: {
|
||||
FontAwesomeIcon: true,
|
||||
BPopover: true,
|
||||
},
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await flushPromises();
|
||||
//change filter to .1.fastq/.2.fastq
|
||||
await wrapper.find("div.forward-unpaired-filter > div.input-group-append > button").trigger("click");
|
||||
await wrapper
|
||||
@@ -56,7 +77,7 @@ describe("PairedListCollectionCreator", () => {
|
||||
const reverseFilter = wrapper.find("div.reverse-unpaired-filter > input").element.value;
|
||||
expect(reverseFilter).toBe(".2.fastq");
|
||||
// click Autopair
|
||||
await wrapper.find("a.autopair-link").trigger("click");
|
||||
await wrapper.find(".autopair-link").trigger("click");
|
||||
//assert pair-name longer name
|
||||
const pairname = wrapper.find("span.pair-name");
|
||||
expect(pairname.text()).toBe("DP134_1_FS_PSII_FSB_42C_A10");
|
||||
@@ -65,20 +86,17 @@ describe("PairedListCollectionCreator", () => {
|
||||
it("removes the period from autopair name", async () => {
|
||||
wrapper = mount(PairedListCollectionCreator, {
|
||||
propsData: {
|
||||
historyId: "history_id",
|
||||
initialElements: DATA._3,
|
||||
creationFn: () => {
|
||||
return;
|
||||
},
|
||||
oncreate: () => {
|
||||
return;
|
||||
},
|
||||
oncancel: () => {
|
||||
return;
|
||||
},
|
||||
hideSourceItems: false,
|
||||
},
|
||||
pinia,
|
||||
stubs: {
|
||||
FontAwesomeIcon: true,
|
||||
BPopover: true,
|
||||
},
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await flushPromises();
|
||||
//change filter to .1.fastq/.2.fastq
|
||||
await wrapper.find("div.forward-unpaired-filter > div.input-group-append > button").trigger("click");
|
||||
await wrapper
|
||||
@@ -92,7 +110,7 @@ describe("PairedListCollectionCreator", () => {
|
||||
const reverseFilter = wrapper.find("div.reverse-unpaired-filter > input").element.value;
|
||||
expect(reverseFilter).toBe(".2.fastq");
|
||||
// click Autopair
|
||||
await wrapper.find("a.autopair-link").trigger("click");
|
||||
await wrapper.find(".autopair-link").trigger("click");
|
||||
//assert pair-name longer name
|
||||
const pairname = wrapper.find("span.pair-name");
|
||||
expect(pairname.text()).toBe("UII_moo_1");
|
||||
@@ -101,20 +119,17 @@ describe("PairedListCollectionCreator", () => {
|
||||
it("autopairs correctly when filters are typed in", async () => {
|
||||
wrapper = mount(PairedListCollectionCreator, {
|
||||
propsData: {
|
||||
historyId: "history_id",
|
||||
initialElements: DATA._4,
|
||||
creationFn: () => {
|
||||
return;
|
||||
},
|
||||
oncreate: () => {
|
||||
return;
|
||||
},
|
||||
oncancel: () => {
|
||||
return;
|
||||
},
|
||||
hideSourceItems: false,
|
||||
},
|
||||
pinia,
|
||||
stubs: {
|
||||
FontAwesomeIcon: true,
|
||||
BPopover: true,
|
||||
},
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
await flushPromises();
|
||||
//change filter to _R1/_R2
|
||||
await wrapper.find("div.forward-unpaired-filter > input").setValue("_R1");
|
||||
await wrapper.find("div.reverse-unpaired-filter > input").setValue("_R2");
|
||||
@@ -125,7 +140,7 @@ describe("PairedListCollectionCreator", () => {
|
||||
const reverseFilter = wrapper.find("div.reverse-unpaired-filter > input").element.value;
|
||||
expect(reverseFilter).toBe("_R2");
|
||||
// click Autopair
|
||||
await wrapper.find("a.autopair-link").trigger("click");
|
||||
await wrapper.find(".autopair-link").trigger("click");
|
||||
//assert all pairs matched
|
||||
expect(wrapper.findAll("li.dataset unpaired").length == 0).toBeTruthy();
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,60 +0,0 @@
|
||||
import _l from "utils/localization";
|
||||
import Vue from "vue";
|
||||
|
||||
import { collectionCreatorModalSetup } from "./common/modal";
|
||||
|
||||
import PairedListCollectionCreator from "./PairedListCollectionCreator.vue";
|
||||
|
||||
function pairedListCollectionCreatorModal(elements, options) {
|
||||
options = options || {};
|
||||
options.title = _l("Create a collection of paired datasets");
|
||||
const { promise, showEl } = collectionCreatorModalSetup(options);
|
||||
var pairedListCollectionCreatorInstance = Vue.extend(PairedListCollectionCreator);
|
||||
var vm = document.createElement("div");
|
||||
showEl(vm);
|
||||
new pairedListCollectionCreatorInstance({
|
||||
propsData: {
|
||||
initialElements: elements,
|
||||
creationFn: options.creationFn,
|
||||
oncancel: options.oncancel,
|
||||
oncreate: options.oncreate,
|
||||
defaultHideSourceItems: options.defaultHideSourceItems,
|
||||
},
|
||||
}).$mount(vm);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Use a modal to create a list collection, then add it to the given history contents.
|
||||
* @returns {Promise} resolved when the collection is added to the history.
|
||||
*/
|
||||
function createPairedListCollection(contents, defaultHideSourceItems) {
|
||||
const elements = contents.toJSON();
|
||||
const promise = pairedListCollectionCreatorModal(elements, {
|
||||
defaultHideSourceItems: defaultHideSourceItems,
|
||||
creationFn: function (elements, name, hideSourceItems) {
|
||||
elements = elements.map((pair) => ({
|
||||
collection_type: "paired",
|
||||
src: "new_collection",
|
||||
name: pair.name,
|
||||
element_identifiers: [
|
||||
{
|
||||
name: "forward",
|
||||
id: pair.forward.id,
|
||||
src: pair.forward.src || "hda",
|
||||
},
|
||||
{
|
||||
name: "reverse",
|
||||
id: pair.reverse.id,
|
||||
src: pair.reverse.src || "hda",
|
||||
},
|
||||
],
|
||||
}));
|
||||
return contents.createHDCA(elements, "list:paired", name, hideSourceItems);
|
||||
},
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
export default {
|
||||
pairedListCollectionCreatorModal: pairedListCollectionCreatorModal,
|
||||
createPairedListCollection: createPairedListCollection,
|
||||
};
|
||||
@@ -1,21 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
import type { HDASummary } from "@/api";
|
||||
import { useAnimationFrameSize } from "@/composables/sensors/animationFrameSize";
|
||||
|
||||
interface Props {
|
||||
// TODO: Define the type of the element
|
||||
element: any;
|
||||
element: HDASummary;
|
||||
disabled?: boolean;
|
||||
showExtension?: boolean;
|
||||
showHid?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
showExtension: true,
|
||||
showHid: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
// TODO: Define the type of the element
|
||||
(event: "element-is-selected", element: any): void;
|
||||
(event: "element-is-selected", element: HDASummary): void;
|
||||
}>();
|
||||
|
||||
const unpairedElement = ref<HTMLElement | null>(null);
|
||||
const elementName = ref<HTMLElement | null>(null);
|
||||
|
||||
const { width: parentWidth } = useAnimationFrameSize(unpairedElement);
|
||||
|
||||
const childWidth = computed(() => elementName.value?.clientWidth);
|
||||
const shouldMoveElement = computed(() => {
|
||||
if (childWidth.value && parentWidth.value && childWidth.value <= parentWidth.value) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
function getExtension(): string {
|
||||
return "extension" in props.element && props.element.extension ? props.element.extension : "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="dataset unpaired" @click="emit('element-is-selected', element)">
|
||||
<label>
|
||||
{{ element.name }}
|
||||
</label>
|
||||
<li
|
||||
ref="unpairedElement"
|
||||
class="dataset unpaired"
|
||||
:class="{ disabled: props.disabled }"
|
||||
:aria-disabled="props.disabled"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@keydown.enter="emit('element-is-selected', props.element)"
|
||||
@click="emit('element-is-selected', props.element)">
|
||||
<span ref="elementName" class="element-name" :class="{ moves: shouldMoveElement }">
|
||||
<span v-if="props.element.hid && props.showHid">{{ props.element.hid }}:</span>
|
||||
<strong>{{ props.element.name }}</strong>
|
||||
<i v-if="props.element.extension && props.showExtension"> ({{ getExtension() }}) </i>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { faSave } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BButton } from "bootstrap-vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
@@ -15,38 +18,52 @@ const emit = defineEmits<{
|
||||
const editable = ref(false);
|
||||
const localValue = ref(props.value);
|
||||
|
||||
watch(
|
||||
() => localValue.value,
|
||||
(newLocalValue) => {
|
||||
emit("input", newLocalValue);
|
||||
}
|
||||
);
|
||||
const computedValue = computed(() => props.value);
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
(newValue) => {
|
||||
localValue.value = newValue;
|
||||
() => editable.value,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
emit("input", localValue.value);
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
v-if="editable"
|
||||
v-model="localValue"
|
||||
class="click-to-edit-input"
|
||||
contenteditable
|
||||
@blur="editable = false"
|
||||
@keyup.enter="editable = false" />
|
||||
<div v-if="editable">
|
||||
<input
|
||||
id="click-to-edit-input"
|
||||
v-model="localValue"
|
||||
tabindex="0"
|
||||
contenteditable
|
||||
@blur.prevent.stop="editable = false"
|
||||
@keyup.prevent.stop.enter="editable = false"
|
||||
@keyup.prevent.stop.escape="editable = false"
|
||||
@click.prevent.stop />
|
||||
<BButton class="p-0" style="border: none" variant="link" size="sm" @click.prevent.stop="editable = false">
|
||||
<FontAwesomeIcon :icon="faSave" />
|
||||
</BButton>
|
||||
</div>
|
||||
|
||||
<label v-else @click="editable = true">
|
||||
{{ localValue }}
|
||||
<label
|
||||
v-else
|
||||
role="button"
|
||||
for="click-to-edit-input"
|
||||
class="click-to-edit-label"
|
||||
tabindex="0"
|
||||
@keyup.enter="editable = true"
|
||||
@click.stop="editable = true">
|
||||
<span v-if="computedValue">{{ computedValue }}</span>
|
||||
<i v-else>{{ title }}</i>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.click-to-edit-input {
|
||||
width: 600px;
|
||||
line-height: 1 !important;
|
||||
.click-to-edit-label {
|
||||
cursor: text;
|
||||
&:hover > * {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,38 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faChevronDown, faChevronUp, faPlus, faUpload } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BAlert, BButton, BFormCheckbox, BFormGroup, BFormInput, BTab, BTabs } from "bootstrap-vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import type { HDASummary } from "@/api";
|
||||
import type { CompositeFileInfo } from "@/api/datatypes";
|
||||
import { AUTO_EXTENSION, getUploadDatatypes } from "@/components/Upload/utils";
|
||||
import { useConfig } from "@/composables/config";
|
||||
import { useUserStore } from "@/stores/userStore";
|
||||
import localize from "@/utils/localization";
|
||||
import { orList } from "@/utils/strings";
|
||||
|
||||
library.add(faChevronDown, faChevronUp);
|
||||
import HelpText from "@/components/Help/HelpText.vue";
|
||||
import DefaultBox from "@/components/Upload/DefaultBox.vue";
|
||||
|
||||
const Tabs = {
|
||||
create: 0,
|
||||
upload: 1,
|
||||
};
|
||||
|
||||
type ExtensionDetails = {
|
||||
id: string;
|
||||
text: string;
|
||||
description: string | null;
|
||||
description_url: string | null;
|
||||
composite_files?: CompositeFileInfo[] | null;
|
||||
upload_warning?: string | null;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
oncancel: () => void;
|
||||
historyId: string;
|
||||
hideSourceItems: boolean;
|
||||
suggestedName?: string;
|
||||
renderExtensionsToggle?: boolean;
|
||||
extensions?: string[];
|
||||
extensionsToggle?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
suggestedName: "",
|
||||
extensions: undefined,
|
||||
extensionsToggle: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "remove-extensions-toggle"): void;
|
||||
(e: "clicked-create", value: string): void;
|
||||
(e: "onUpdateHideSourceItems", value: boolean): void;
|
||||
(e: "on-update-datatype-toggle", value: "all" | "datatype" | "ext"): void;
|
||||
(e: "add-uploaded-files", value: HDASummary[]): void;
|
||||
}>();
|
||||
|
||||
const isExpanded = ref(false);
|
||||
const currentTab = ref(Tabs.create);
|
||||
const collectionName = ref(props.suggestedName);
|
||||
const localHideSourceItems = ref(props.hideSourceItems);
|
||||
const listExtensions = ref<ExtensionDetails[]>([]);
|
||||
const extensionsSet = ref(false);
|
||||
|
||||
const validInput = computed(() => {
|
||||
return collectionName.value.length > 0;
|
||||
});
|
||||
|
||||
// If there are props.extensions, filter the list of extensions to only include those
|
||||
const validExtensions = computed(() => {
|
||||
return listExtensions.value.filter((ext) => props.extensions?.includes(ext.id));
|
||||
});
|
||||
|
||||
// Upload properties
|
||||
const { config, isConfigLoaded } = useConfig();
|
||||
|
||||
const { currentUser } = storeToRefs(useUserStore());
|
||||
|
||||
const configOptions = computed(() =>
|
||||
isConfigLoaded.value
|
||||
? {
|
||||
chunkUploadSize: config.value.chunk_upload_size,
|
||||
fileSourcesConfigured: config.value.file_sources_configured,
|
||||
ftpUploadSite: config.value.ftp_upload_site,
|
||||
defaultDbKey: config.value.default_genome || "",
|
||||
defaultExtension: config.value.default_extension || "",
|
||||
}
|
||||
: {}
|
||||
);
|
||||
|
||||
const ftpUploadSite = computed(() =>
|
||||
currentUser.value && "id" in currentUser.value ? configOptions.value.ftpUploadSite : null
|
||||
);
|
||||
|
||||
const defaultExtension = computed(() => {
|
||||
if (!configOptions.value || !extensionsSet.value) {
|
||||
return "auto";
|
||||
} else if (!props.extensions?.length) {
|
||||
return configOptions.value.defaultExtension || "auto";
|
||||
} else {
|
||||
return props.extensions[0];
|
||||
}
|
||||
});
|
||||
|
||||
function addUploadedFiles(value: HDASummary[]) {
|
||||
// TODO: We really need to wait for each of these items to get `state = 'ok'`
|
||||
// before we can add them to the collection.
|
||||
emit("add-uploaded-files", value);
|
||||
}
|
||||
|
||||
function clickForHelp() {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
return isExpanded.value;
|
||||
@@ -42,6 +116,13 @@ function cancelCreate() {
|
||||
props.oncancel();
|
||||
}
|
||||
|
||||
async function loadExtensions() {
|
||||
listExtensions.value = await getUploadDatatypes(false, AUTO_EXTENSION);
|
||||
extensionsSet.value = true;
|
||||
}
|
||||
|
||||
loadExtensions();
|
||||
|
||||
watch(
|
||||
() => localHideSourceItems.value,
|
||||
() => {
|
||||
@@ -51,90 +132,146 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="collection-creator">
|
||||
<div class="header flex-row no-flex">
|
||||
<div class="main-help well clear" :class="{ expanded: isExpanded }">
|
||||
<a
|
||||
class="more-help"
|
||||
href="javascript:void(0);"
|
||||
role="button"
|
||||
:title="localize('Expand or Close Help')"
|
||||
@click="clickForHelp">
|
||||
<div v-if="!isExpanded">
|
||||
<FontAwesomeIcon :icon="faChevronDown" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<FontAwesomeIcon :icon="faChevronUp" />
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="help-content">
|
||||
<!-- each collection that extends this will add their own help content -->
|
||||
<slot name="help-content"></slot>
|
||||
|
||||
<BTabs v-model="currentTab" fill justified>
|
||||
<BTab class="collection-creator" :title="localize('Create Collection')">
|
||||
<div class="header flex-row no-flex">
|
||||
<div class="main-help well clear" :class="{ expanded: isExpanded }">
|
||||
<a
|
||||
class="more-help"
|
||||
href="javascript:void(0);"
|
||||
role="button"
|
||||
:title="localize('Expand or Close Help')"
|
||||
@click="clickForHelp">
|
||||
<div v-if="!isExpanded">
|
||||
<FontAwesomeIcon :icon="faChevronDown" />
|
||||
<span class="sr-only">{{ localize("Expand Help") }}</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<FontAwesomeIcon :icon="faChevronUp" />
|
||||
<span class="sr-only">{{ localize("Close Help") }}</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="middle flex-row flex-row-container">
|
||||
<slot name="middle-content"></slot>
|
||||
</div>
|
||||
<div class="help-content">
|
||||
<!-- each collection that extends this will add their own help content -->
|
||||
<slot name="help-content"></slot>
|
||||
|
||||
<div class="footer flex-row no-flex">
|
||||
<div class="attributes clear">
|
||||
<div class="clear">
|
||||
<label v-if="renderExtensionsToggle" class="setting-prompt float-right">
|
||||
{{ localize("Remove file extensions?") }}
|
||||
<input
|
||||
class="remove-extensions float-right"
|
||||
type="checkbox"
|
||||
checked
|
||||
@click="emit('remove-extensions-toggle')" />
|
||||
</label>
|
||||
|
||||
<label class="setting-prompt float-right">
|
||||
{{ localize("Hide original elements?") }}
|
||||
<input v-model="localHideSourceItems" class="hide-originals float-right" type="checkbox" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="clear">
|
||||
<input
|
||||
v-model="collectionName"
|
||||
class="collection-name form-control float-right"
|
||||
:placeholder="localize('Enter a name for your new collection')" />
|
||||
|
||||
<div class="collection-name-prompt float-right">
|
||||
{{ localize("Name:") }}
|
||||
<a
|
||||
class="more-help"
|
||||
href="javascript:void(0);"
|
||||
role="button"
|
||||
:title="localize('Expand or Close Help')"
|
||||
@click="clickForHelp">
|
||||
<span class="sr-only">{{ localize("Expand Help") }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions clear vertically-spaced">
|
||||
<div class="float-left">
|
||||
<button class="cancel-create btn" tabindex="-1" @click="cancelCreate">
|
||||
{{ localize("Cancel") }}
|
||||
</button>
|
||||
<div class="middle flex-row flex-row-container">
|
||||
<slot name="middle-content"></slot>
|
||||
</div>
|
||||
|
||||
<div class="footer flex-row">
|
||||
<div class="vertically-spaced">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<BAlert v-if="extensions?.length" class="w-100 py-0" variant="secondary" show>
|
||||
<HelpText
|
||||
uri="galaxy.collections.collectionBuilder.filteredExtensions"
|
||||
:text="localize('Filtered extensions: ')" />
|
||||
<strong>{{ orList(extensions) }}</strong>
|
||||
</BAlert>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<BFormGroup class="inputs-form-group">
|
||||
<BFormCheckbox
|
||||
v-if="renderExtensionsToggle"
|
||||
name="remove-extensions"
|
||||
switch
|
||||
:checked="extensionsToggle"
|
||||
@input="emit('remove-extensions-toggle')">
|
||||
{{ localize("Remove file extensions?") }}
|
||||
</BFormCheckbox>
|
||||
|
||||
<div data-description="hide original elements">
|
||||
<BFormCheckbox v-model="localHideSourceItems" name="hide-originals" switch>
|
||||
<HelpText
|
||||
uri="galaxy.collections.collectionBuilder.hideOriginalElements"
|
||||
:text="localize('Hide original elements')" />
|
||||
</BFormCheckbox>
|
||||
</div>
|
||||
</BFormGroup>
|
||||
|
||||
<BFormGroup
|
||||
class="flex-gapx-1 d-flex align-items-center w-50 inputs-form-group"
|
||||
:label="localize('Name:')"
|
||||
label-for="collection-name">
|
||||
<BFormInput
|
||||
id="collection-name"
|
||||
v-model="collectionName"
|
||||
class="collection-name"
|
||||
:placeholder="localize('Enter a name for your new collection')"
|
||||
size="sm"
|
||||
required
|
||||
:state="!collectionName ? false : null" />
|
||||
</BFormGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-options float-right">
|
||||
<button
|
||||
class="create-collection btn btn-primary"
|
||||
<div class="actions vertically-spaced d-flex justify-content-between">
|
||||
<BButton tabindex="-1" @click="cancelCreate">
|
||||
{{ localize("Cancel") }}
|
||||
</BButton>
|
||||
|
||||
<BButton
|
||||
class="create-collection"
|
||||
variant="primary"
|
||||
:disabled="!validInput"
|
||||
@click="emit('clicked-create', collectionName)">
|
||||
{{ localize("Create collection") }}
|
||||
</button>
|
||||
</BButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BTab>
|
||||
<BTab>
|
||||
<template v-slot:title>
|
||||
<FontAwesomeIcon :icon="faUpload" fixed-width />
|
||||
<span>{{ localize("Upload Files to Add to Collection") }}</span>
|
||||
</template>
|
||||
<!-- TODO: This is incomplete; need to return uploadValues to parent -->
|
||||
<DefaultBox
|
||||
v-if="configOptions && extensionsSet"
|
||||
:chunk-upload-size="configOptions.chunkUploadSize"
|
||||
:default-db-key="configOptions.defaultDbKey"
|
||||
:default-extension="defaultExtension"
|
||||
:effective-extensions="props.extensions?.length ? validExtensions : listExtensions"
|
||||
:file-sources-configured="configOptions.fileSourcesConfigured"
|
||||
:ftp-upload-site="ftpUploadSite"
|
||||
:has-callback="false"
|
||||
:history-id="historyId"
|
||||
:list-db-keys="[]"
|
||||
disable-footer
|
||||
emit-uploaded
|
||||
@uploaded="addUploadedFiles"
|
||||
@dismiss="currentTab = Tabs.create">
|
||||
<template v-slot:footer>
|
||||
<div class="d-flex align-items-center justify-content-between mt-2">
|
||||
<BAlert v-if="extensions?.length" class="w-100 py-0" variant="secondary" show>
|
||||
<HelpText
|
||||
uri="galaxy.collections.collectionBuilder.requiredUploadExtensions"
|
||||
:text="localize('Required extensions: ')" />
|
||||
<strong>{{ orList(extensions) }}</strong>
|
||||
</BAlert>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:emit-btn-txt>
|
||||
<FontAwesomeIcon :icon="faPlus" fixed-width />
|
||||
{{ localize("Add Uploaded") }}
|
||||
</template>
|
||||
</DefaultBox>
|
||||
</BTab>
|
||||
</BTabs>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -146,9 +283,6 @@ $fa-font-path: "../../../../node_modules/@fortawesome/fontawesome-free/webfonts/
|
||||
.collection-creator {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
// ------------------------------------------------------------------------ general
|
||||
ol,
|
||||
li {
|
||||
@@ -367,38 +501,11 @@ $fa-font-path: "../../../../node_modules/@fortawesome/fontawesome-free/webfonts/
|
||||
}
|
||||
// ------------------------------------------------------------------------ footer
|
||||
.footer {
|
||||
.attributes {
|
||||
.setting-prompt {
|
||||
//margin-right: 32px;
|
||||
line-height: 32px;
|
||||
padding-left: 10px;
|
||||
.remove-extensions {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.hide-originals {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
// actually appears/floats to the left of the input
|
||||
.collection-name-prompt {
|
||||
margin: 5px 4px 0 0;
|
||||
}
|
||||
.collection-name-prompt.validation-warning:before {
|
||||
//TODO: localize (somehow)
|
||||
content: "(required)";
|
||||
margin-right: 4px;
|
||||
color: red;
|
||||
}
|
||||
.collection-name {
|
||||
width: 50%;
|
||||
&.validation-warning {
|
||||
border-color: red;
|
||||
}
|
||||
}
|
||||
.inputs-form-group > div {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 0.25rem;
|
||||
}
|
||||
.actions {
|
||||
.other-options > * {
|
||||
|
||||
@@ -17,8 +17,14 @@ export function collectionCreatorModalSetup(options, Galaxy = null) {
|
||||
});
|
||||
const showEl = function (el) {
|
||||
const close_event = options.closing_events === undefined || options.closing_events;
|
||||
const title = options.title || _l("Create a collection");
|
||||
const titleSuffix = options.historyName ? `From history: <b>${options.historyName}</b>` : "";
|
||||
const titleHtml = `<div class='d-flex justify-content-between unselectable'>
|
||||
<span>${title}</span>
|
||||
<span>${titleSuffix}</span>
|
||||
</div>`;
|
||||
modal.show({
|
||||
title: options.title || _l("Create a collection"),
|
||||
title: titleHtml,
|
||||
body: el,
|
||||
width: "85%",
|
||||
height: "100%",
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("modal.js", () => {
|
||||
it("should create showEl and resolve oncreate", async () => {
|
||||
expect(showOptions).toBe(null);
|
||||
showEl();
|
||||
expect(showOptions.title).toEqual("Create a collection");
|
||||
expect(showOptions.title).toContain("Create a collection");
|
||||
|
||||
expect(hidden).toBeFalsy();
|
||||
options.oncreate(null, "testres");
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faPlay, faSpinner } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BButton } from "bootstrap-vue";
|
||||
|
||||
library.add(faPlay, faSpinner);
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
title?: string;
|
||||
wait?: boolean;
|
||||
tooltip?: string;
|
||||
disabled?: boolean;
|
||||
size?: string;
|
||||
variant?: string;
|
||||
icon?: IconDefinition;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
title: "",
|
||||
wait: false,
|
||||
tooltip: undefined,
|
||||
tooltip: "",
|
||||
disabled: false,
|
||||
size: "md",
|
||||
variant: "primary",
|
||||
icon: undefined,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -31,19 +34,23 @@ withDefaults(defineProps<Props>(), {
|
||||
variant="info"
|
||||
title="Please Wait..."
|
||||
class="d-flex flex-nowrap align-items-center text-nowrap">
|
||||
<FontAwesomeIcon :icon="faSpinner" class="mr-2" spin />
|
||||
{{ title }}
|
||||
<FontAwesomeIcon :icon="faSpinner" fixed-width spin />
|
||||
<slot>
|
||||
<span v-if="title">{{ title }}</span>
|
||||
</slot>
|
||||
</BButton>
|
||||
<BButton
|
||||
v-else
|
||||
v-b-tooltip.hover.bottom
|
||||
variant="primary"
|
||||
:variant="variant"
|
||||
class="d-flex flex-nowrap align-items-center text-nowrap"
|
||||
:title="tooltip"
|
||||
:disabled="disabled"
|
||||
:size="size"
|
||||
@click="$emit('onClick')">
|
||||
<FontAwesomeIcon :icon="faPlay" class="mr-2" />
|
||||
{{ title }}
|
||||
<FontAwesomeIcon :icon="!icon ? faPlay : icon" fixed-width />
|
||||
<slot>
|
||||
<span v-if="title">{{ title }}</span>
|
||||
</slot>
|
||||
</BButton>
|
||||
</template>
|
||||
|
||||
@@ -32,7 +32,7 @@ const FAKE_MONITOR: TaskMonitor = {
|
||||
isCompleted: ref(false),
|
||||
hasFailed: ref(false),
|
||||
requestHasFailed: ref(false),
|
||||
status: ref(),
|
||||
taskStatus: ref(""),
|
||||
expirationTime: FAKE_EXPIRATION_TIME,
|
||||
isFinalState: jest.fn(),
|
||||
loadStatus: jest.fn(),
|
||||
|
||||
@@ -39,4 +39,10 @@ export class DatatypesMapperModel {
|
||||
isSubTypeOfAny(child: string, parents: DatatypesCombinedMap["datatypes"]): boolean {
|
||||
return parents.some((parent) => this.isSubType(child, parent));
|
||||
}
|
||||
|
||||
/** For classes like `galaxy.datatypes.{parent}.{extension}`, get the extension's parent */
|
||||
getParentDatatype(extension: string) {
|
||||
const fullClassName = this.datatypesMapping.ext_to_class_name[extension];
|
||||
return fullClassName?.split(".")[2];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faCopy, faFile, faFolder } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faCaretDown, faCaretUp, faExclamation, faLink, faUnlink } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faCaretDown,
|
||||
faCaretUp,
|
||||
faExclamation,
|
||||
faLink,
|
||||
faPlus,
|
||||
faSpinner,
|
||||
faUnlink,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BAlert, BButton, BButtonGroup, BCollapse, BFormCheckbox, BTooltip } from "bootstrap-vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed, onMounted, type Ref, ref, watch } from "vue";
|
||||
|
||||
import { isDatasetElement, isDCE } from "@/api";
|
||||
@@ -11,16 +18,22 @@ import { getGalaxyInstance } from "@/app";
|
||||
import { useDatatypesMapper } from "@/composables/datatypesMapper";
|
||||
import { useUid } from "@/composables/utils/uid";
|
||||
import { type EventData, useEventStore } from "@/stores/eventStore";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
import { orList } from "@/utils/strings";
|
||||
|
||||
import type { DataOption } from "./types";
|
||||
import { BATCH, SOURCE, VARIANTS } from "./variants";
|
||||
|
||||
import FormSelection from "../FormSelection.vue";
|
||||
import CollectionCreatorModal from "@/components/Collections/CollectionCreatorModal.vue";
|
||||
import FormSelect from "@/components/Form/Elements/FormSelect.vue";
|
||||
import HelpText from "@/components/Help/HelpText.vue";
|
||||
|
||||
library.add(faCopy, faFile, faFolder, faCaretDown, faCaretUp, faExclamation, faLink, faUnlink);
|
||||
const COLLECTION_TYPE_TO_LABEL: Record<string, string> = {
|
||||
list: "list",
|
||||
"list:paired": "list of dataset pairs",
|
||||
paired: "dataset pair",
|
||||
};
|
||||
|
||||
type SelectOption = {
|
||||
label: string;
|
||||
@@ -73,6 +86,11 @@ const currentHighlighting: Ref<string | null> = ref(null);
|
||||
const dragData: Ref<EventData | null> = ref(null);
|
||||
const dragTarget: Ref<EventTarget | null> = ref(null);
|
||||
|
||||
// Collection creator modal settings
|
||||
const collectionModalShow = ref(false);
|
||||
const collectionModalType = ref<"list" | "list:paired" | "paired">("list");
|
||||
const { currentHistoryId } = storeToRefs(useHistoryStore());
|
||||
|
||||
/** Store options which need to be preserved **/
|
||||
const keepOptions: Record<string, SelectOption> = {};
|
||||
|
||||
@@ -471,6 +489,23 @@ function canAcceptSrc(historyContentType: "dataset" | "dataset_collection", coll
|
||||
}
|
||||
}
|
||||
|
||||
/** Allowed collection types for collection creation */
|
||||
const effectiveCollectionTypes = props.collectionTypes?.filter((collectionType) =>
|
||||
["list", "list:paired", "paired"].includes(collectionType)
|
||||
);
|
||||
|
||||
function buildNewCollection(collectionType: string) {
|
||||
if (!["list", "list:paired", "paired"].includes(collectionType)) {
|
||||
throw Error(`Unknown collection type: ${collectionType}`);
|
||||
}
|
||||
collectionModalType.value = collectionType as "list" | "list:paired" | "paired";
|
||||
collectionModalShow.value = true;
|
||||
}
|
||||
|
||||
function createdCollection(collection: any) {
|
||||
handleIncoming(collection);
|
||||
}
|
||||
|
||||
// Drag/Drop event handlers
|
||||
function onDragEnter(evt: MouseEvent) {
|
||||
const eventData = eventStore.getDragData();
|
||||
@@ -599,15 +634,26 @@ const noOptionsWarningMessage = computed(() => {
|
||||
<FontAwesomeIcon :icon="['far', v.icon]" />
|
||||
</BButton>
|
||||
<BButton v-if="canBrowse" v-b-tooltip.hover.bottom title="Browse or Upload Datasets" @click="onBrowse">
|
||||
<FontAwesomeIcon v-if="loading" icon="fa-spinner" spin />
|
||||
<FontAwesomeIcon v-if="loading" :icon="faSpinner" spin />
|
||||
<span v-else class="font-weight-bold">...</span>
|
||||
</BButton>
|
||||
<BButtonGroup v-if="effectiveCollectionTypes?.length > 0" size="sm" buttons>
|
||||
<BButton
|
||||
v-for="collectionType in effectiveCollectionTypes"
|
||||
:key="collectionType"
|
||||
v-b-tooltip.hover.bottom
|
||||
:title="`Create a new ${COLLECTION_TYPE_TO_LABEL[collectionType]}`"
|
||||
:variant="formattedOptions.length === 0 ? 'warning' : 'secondary'"
|
||||
@click="buildNewCollection(collectionType)">
|
||||
<FontAwesomeIcon :icon="faPlus" fixed-width />
|
||||
</BButton>
|
||||
</BButtonGroup>
|
||||
</BButtonGroup>
|
||||
<div v-if="extensions && extensions.length > 0">
|
||||
<BButton :id="formatsButtonId" class="ui-link" @click="formatsVisible = !formatsVisible">
|
||||
accepted formats
|
||||
<FontAwesomeIcon v-if="formatsVisible" icon="fa-caret-up" />
|
||||
<FontAwesomeIcon v-else icon="fa-caret-down" />
|
||||
<FontAwesomeIcon v-if="formatsVisible" :icon="faCaretUp" />
|
||||
<FontAwesomeIcon v-else :icon="faCaretDown" />
|
||||
</BButton>
|
||||
<BCollapse v-model="formatsVisible">
|
||||
<ul class="pl-3 m-0">
|
||||
@@ -631,7 +677,7 @@ const noOptionsWarningMessage = computed(() => {
|
||||
:options="formattedOptions"
|
||||
:placeholder="`Select a ${placeholder}`">
|
||||
<template v-slot:no-options>
|
||||
<BAlert variant="warning" show>
|
||||
<BAlert class="w-100 align-items-center" variant="warning" show>
|
||||
{{ noOptionsWarningMessage }}
|
||||
</BAlert>
|
||||
</template>
|
||||
@@ -643,6 +689,14 @@ const noOptionsWarningMessage = computed(() => {
|
||||
optional
|
||||
multiple />
|
||||
|
||||
<CollectionCreatorModal
|
||||
v-if="currentHistoryId && effectiveCollectionTypes?.length > 0"
|
||||
:history-id="currentHistoryId"
|
||||
:collection-type="collectionModalType"
|
||||
:extensions="props.extensions.filter((ext) => ext !== 'data')"
|
||||
:show-modal.sync="collectionModalShow"
|
||||
@created-collection="createdCollection" />
|
||||
|
||||
<template v-if="currentVariant && currentVariant.batch !== BATCH.DISABLED">
|
||||
<BFormCheckbox
|
||||
v-if="currentVariant.batch === BATCH.ENABLED"
|
||||
@@ -650,18 +704,18 @@ const noOptionsWarningMessage = computed(() => {
|
||||
class="checkbox no-highlight"
|
||||
switch>
|
||||
<span v-if="currentLinked">
|
||||
<FontAwesomeIcon icon="fa-link" />
|
||||
<FontAwesomeIcon :icon="faLink" />
|
||||
<b v-localize class="mr-1">Linked:</b>
|
||||
<span v-localize>Datasets will be run in matched order with other datasets.</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
<FontAwesomeIcon icon="fa-unlink" />
|
||||
<FontAwesomeIcon :icon="faUnlink" />
|
||||
<b v-localize class="mr-1">Unlinked:</b>
|
||||
<span v-localize>Dataset will be run against *all* other datasets.</span>
|
||||
</span>
|
||||
</BFormCheckbox>
|
||||
<div class="info text-info">
|
||||
<FontAwesomeIcon icon="fa-exclamation" />
|
||||
<FontAwesomeIcon :icon="faExclamation" />
|
||||
<span v-if="props.type == 'data' && currentVariant.src == SOURCE.COLLECTION" class="ml-1">
|
||||
The supplied input will be <HelpText text="mapped over" uri="galaxy.collections.mapOver" /> this
|
||||
tool.
|
||||
|
||||
@@ -39,6 +39,10 @@ const props = defineProps({
|
||||
type: Array as PropType<SelectValue | SelectValue[]>,
|
||||
default: null,
|
||||
},
|
||||
maintainSelectionOrder: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -48,6 +52,7 @@ const emit = defineEmits<{
|
||||
const searchValue = ref("");
|
||||
const useRegex = ref(false);
|
||||
const caseSensitive = ref(false);
|
||||
const localSelectionOrder = computed(() => props.maintainSelectionOrder);
|
||||
|
||||
const searchRegex = computed(() => {
|
||||
if (useRegex.value) {
|
||||
@@ -94,6 +99,7 @@ const { unselectedOptionsFiltered, selectedOptionsFiltered, running, moreUnselec
|
||||
selectedDisplayCount,
|
||||
unselectedDisplayCount,
|
||||
caseSensitive,
|
||||
maintainSelectionOrder: localSelectionOrder,
|
||||
});
|
||||
|
||||
// debounced to it doesn't blink, and only appears when relevant
|
||||
@@ -364,7 +370,9 @@ const selectedCount = computed(() => {
|
||||
:class="{ highlighted: highlightUnselected.highlightedIndexes.includes(i) }"
|
||||
@click="(e) => selectOption(e, i)"
|
||||
@keydown="(e) => optionOnKey('unselected', e, i)">
|
||||
{{ option.label }}
|
||||
<slot name="label-area" v-bind="option">
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</button>
|
||||
|
||||
<span v-if="moreUnselected" class="show-more-indicator">
|
||||
@@ -396,7 +404,9 @@ const selectedCount = computed(() => {
|
||||
:class="{ highlighted: highlightSelected.highlightedIndexes.includes(i) }"
|
||||
@click="(e) => deselectOption(e, i)"
|
||||
@keydown="(e) => optionOnKey('selected', e, i)">
|
||||
{{ option.label }}
|
||||
<slot name="label-area" v-bind="option">
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</button>
|
||||
|
||||
<span v-if="moreSelected" class="show-more-indicator">
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface UseSelectManyOptions {
|
||||
unselectedDisplayCount: Ref<number>;
|
||||
selectedDisplayCount: Ref<number>;
|
||||
caseSensitive: Ref<boolean>;
|
||||
maintainSelectionOrder: Ref<boolean>;
|
||||
}
|
||||
|
||||
export interface UseSelectManyReturn {
|
||||
|
||||
@@ -13,6 +13,7 @@ export function useSelectMany({
|
||||
unselectedDisplayCount,
|
||||
selectedDisplayCount,
|
||||
caseSensitive,
|
||||
maintainSelectionOrder,
|
||||
}) {
|
||||
// only start a single worker
|
||||
if (!worker) {
|
||||
@@ -64,6 +65,7 @@ export function useSelectMany({
|
||||
unselectedDisplayCount: unselectedDisplayCount.value,
|
||||
selectedDisplayCount: selectedDisplayCount.value,
|
||||
caseSensitive: caseSensitive.value,
|
||||
maintainSelectionOrder: maintainSelectionOrder.value,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const createOptions = () => {
|
||||
unselectedDisplayCount: 1000,
|
||||
selectedDisplayCount: 1000,
|
||||
caseSensitive: false,
|
||||
maintainSelectionOrder: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -42,6 +43,7 @@ self.addEventListener("message", (e) => {
|
||||
options.unselectedDisplayCount = message.unselectedDisplayCount;
|
||||
options.selectedDisplayCount = message.selectedDisplayCount;
|
||||
options.caseSensitive = message.caseSensitive;
|
||||
options.maintainSelectionOrder = message.maintainSelectionOrder;
|
||||
break;
|
||||
|
||||
case "clear":
|
||||
|
||||
@@ -43,6 +43,19 @@ export function main(options: UnwrapNestedRefs<UseSelectManyOptions>): UnwrapNes
|
||||
}
|
||||
}
|
||||
|
||||
// In case maintainSelectionOrder is enabled, we need to maintain the order from selectedValues
|
||||
if (options.maintainSelectionOrder) {
|
||||
const selectedValuesArray = Array.from(selectedValues);
|
||||
selectedOptionsFiltered.sort((a, b) => {
|
||||
const aAsString = stringifyObject(a.value);
|
||||
const bAsString = stringifyObject(b.value);
|
||||
|
||||
const aIndex = selectedValuesArray.findIndex((v) => v === aAsString);
|
||||
const bIndex = selectedValuesArray.findIndex((v) => v === bAsString);
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
unselectedOptionsFiltered,
|
||||
selectedOptionsFiltered,
|
||||
|
||||
@@ -59,6 +59,20 @@ galaxy:
|
||||
These lists will be gathered together in a nested list structured (collection type ``list:list``) where
|
||||
the outer element count and structure matches that of the input and the inner list for each of those
|
||||
is just the outputs of the tool for the corresponding element of the input.
|
||||
collectionBuilder:
|
||||
hideOriginalElements: |
|
||||
Toggling this on means that the original history items that will become a part of the collection
|
||||
will be hidden from the history panel (they will still be searchable via the 'visible: false' filter).
|
||||
filteredExtensions: |
|
||||
The history is filtered for the extensions that are required for this collection. You might see some
|
||||
items with other extensions since those can still be valid inputs via implicit conversion.
|
||||
requiredUploadExtensions: |
|
||||
The extensions that are required for this collection. The files you upload will be assumed to have
|
||||
these extensions. In the case of more than one extension, you can select a specific extension for
|
||||
each individual file above. If there is only one extension, Galaxy will attempt to set that as the
|
||||
extension for each file.
|
||||
|
||||
|
||||
jobs:
|
||||
states:
|
||||
# upload, waiting, failed, paused, deleting, deleted, stop, stopped, skipped.
|
||||
|
||||
+34
-32
@@ -138,11 +138,19 @@
|
||||
<p v-localize>Remove the following tags from {{ numSelected }} items:</p>
|
||||
<StatelessTags v-model="selectedTags" class="tags" />
|
||||
</b-modal>
|
||||
<CollectionCreatorModal
|
||||
v-if="collectionModalType"
|
||||
:history-id="history.id"
|
||||
:collection-type="collectionModalType"
|
||||
:filter-text="filterText"
|
||||
:selected-items="collectionSelection"
|
||||
:show-modal.sync="collectionModalShow"
|
||||
default-hide-source-items
|
||||
@created-collection="createdCollection" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { buildCollectionModal } from "components/History/adapters/buildCollectionModal";
|
||||
import { HistoryFilters } from "components/History/HistoryFilters";
|
||||
import {
|
||||
addTagsToSelectedContent,
|
||||
@@ -159,13 +167,16 @@ import { DatatypesProvider, DbKeyProvider } from "components/providers";
|
||||
import SingleItemSelector from "components/SingleItemSelector";
|
||||
import { StatelessTags } from "components/Tags";
|
||||
|
||||
import { GalaxyApi } from "@/api";
|
||||
import { createDatasetCollection, filtersToQueryValues } from "@/components/History/model/queries";
|
||||
import { createDatasetCollection } from "@/components/History/model/queries";
|
||||
import { useConfig } from "@/composables/config";
|
||||
import { rethrowSimple } from "@/utils/simple-error";
|
||||
|
||||
import { buildRuleCollectionModal } from "../../adapters/buildCollectionModal";
|
||||
|
||||
import CollectionCreatorModal from "@/components/Collections/CollectionCreatorModal.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CollectionCreatorModal,
|
||||
DbKeyProvider,
|
||||
DatatypesProvider,
|
||||
SingleItemSelector,
|
||||
@@ -186,6 +197,9 @@ export default {
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
collectionModalShow: false,
|
||||
collectionModalType: null,
|
||||
collectionSelection: undefined,
|
||||
selectedDbKey: { id: "?", text: "unspecified (?)" },
|
||||
selectedDatatype: { id: "auto", text: "Auto-detect" },
|
||||
selectedTags: [],
|
||||
@@ -361,38 +375,26 @@ export default {
|
||||
},
|
||||
|
||||
// collection creation, fires up a modal
|
||||
async buildDatasetList() {
|
||||
await this.buildNewCollection("list");
|
||||
buildDatasetList() {
|
||||
this.collectionModalType = "list";
|
||||
this.collectionSelection = Array.from(this.contentSelection.values());
|
||||
this.collectionModalShow = true;
|
||||
},
|
||||
async buildDatasetListAll() {
|
||||
let allContents = [];
|
||||
const filters = HistoryFilters.getQueryDict(this.filterText);
|
||||
const filterQuery = filtersToQueryValues(filters);
|
||||
const { data, error } = await GalaxyApi().GET("/api/histories/{history_id}/contents/{type}s", {
|
||||
params: {
|
||||
path: { history_id: this.history.id, type: "dataset" },
|
||||
query: { ...filterQuery, v: "dev" },
|
||||
},
|
||||
});
|
||||
if (error) {
|
||||
rethrowSimple(error);
|
||||
}
|
||||
|
||||
allContents = data;
|
||||
|
||||
this.buildNewCollection("list", allContents);
|
||||
buildDatasetListAll() {
|
||||
this.collectionModalType = "list";
|
||||
this.collectionSelection = undefined;
|
||||
this.collectionModalShow = true;
|
||||
},
|
||||
async buildListOfPairs() {
|
||||
await this.buildNewCollection("list:paired");
|
||||
buildListOfPairs() {
|
||||
this.collectionModalType = "list:paired";
|
||||
this.collectionSelection = Array.from(this.contentSelection.values());
|
||||
this.collectionModalShow = true;
|
||||
},
|
||||
createdCollection(collection) {
|
||||
this.$emit("reset-selection");
|
||||
},
|
||||
async buildCollectionFromRules() {
|
||||
await this.buildNewCollection("rules");
|
||||
},
|
||||
async buildNewCollection(collectionType, contents) {
|
||||
if (contents === undefined) {
|
||||
contents = this.contentSelection;
|
||||
}
|
||||
const modalResult = await buildCollectionModal(collectionType, contents, this.history.id);
|
||||
const modalResult = await buildRuleCollectionModal(this.contentSelection, this.history.id);
|
||||
await createDatasetCollection(this.history, modalResult);
|
||||
|
||||
// have to hide the source items if that was requested
|
||||
|
||||
@@ -17,6 +17,11 @@ jest.mock("stores/services/history.services");
|
||||
|
||||
const { server, http } = useServerMock();
|
||||
|
||||
jest.mock("vue-router/composables", () => ({
|
||||
useRoute: jest.fn(() => ({})),
|
||||
useRouter: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
function create_history(historyId, userId, purged = false, archived = false) {
|
||||
const historyName = `${userId}'s History ${historyId}`;
|
||||
return {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { startWatchingHistory } from "store/historyStore/model/watchHistory";
|
||||
import { useHistoryItemsStore } from "stores/historyItemsStore";
|
||||
import { useHistoryStore } from "stores/historyStore";
|
||||
|
||||
import { buildCollectionModal } from "./buildCollectionModal";
|
||||
import { buildRuleCollectionModal } from "./buildCollectionModal";
|
||||
|
||||
// extend existing current history panel
|
||||
export class HistoryPanelProxy {
|
||||
@@ -45,7 +45,7 @@ export class HistoryPanelProxy {
|
||||
this.model.id = historyId;
|
||||
this.historyStore.setCurrentHistory(historyId);
|
||||
}
|
||||
async buildCollection(collectionType, selection, historyId = null, fromRulesInput = false) {
|
||||
async buildCollectionFromRules(selection, historyId = null, fromRulesInput = false) {
|
||||
let selectionContent = null;
|
||||
historyId = historyId || this.model.id;
|
||||
if (fromRulesInput) {
|
||||
@@ -56,7 +56,7 @@ export class HistoryPanelProxy {
|
||||
selectionContent.set(obj.id, obj);
|
||||
});
|
||||
}
|
||||
const modalResult = await buildCollectionModal(collectionType, selectionContent, historyId, fromRulesInput);
|
||||
const modalResult = await buildRuleCollectionModal(selectionContent, historyId, fromRulesInput);
|
||||
if (modalResult) {
|
||||
console.debug("Submitting collection build request.", modalResult);
|
||||
await createDatasetCollection({ id: historyId }, modalResult);
|
||||
|
||||
+28
-20
@@ -1,4 +1,5 @@
|
||||
/**
|
||||
* TODO: Update this description...
|
||||
* Temporary adapter to launch bootstrap modals from Vue components, for use with
|
||||
* the collection assembly modals. i.e. With selected..... create dataset collection,
|
||||
* create paired collection, etc.
|
||||
@@ -8,44 +9,50 @@
|
||||
* deprecated jquery Deferred object.
|
||||
*/
|
||||
|
||||
import LIST_COLLECTION_CREATOR from "components/Collections/ListCollectionCreatorModal";
|
||||
import PAIR_COLLECTION_CREATOR from "components/Collections/PairCollectionCreatorModal";
|
||||
import LIST_OF_PAIRS_COLLECTION_CREATOR from "components/Collections/PairedListCollectionCreatorModal";
|
||||
import RULE_BASED_COLLECTION_CREATOR from "components/Collections/RuleBasedCollectionCreatorModal";
|
||||
import jQuery from "jquery";
|
||||
|
||||
import type { HDASummary, HistoryItemSummary } from "@/api";
|
||||
import RULE_BASED_COLLECTION_CREATOR from "@/components/Collections/RuleBasedCollectionCreatorModal";
|
||||
|
||||
export type CollectionType = "list" | "paired" | "list:paired" | "rules";
|
||||
export type DatasetPair = {
|
||||
forward: HDASummary;
|
||||
reverse: HDASummary;
|
||||
name: string;
|
||||
};
|
||||
|
||||
// stand-in for buildCollection from history-view-edit.js
|
||||
export async function buildCollectionModal(collectionType, selectedContent, historyId, fromRulesInput = false) {
|
||||
export async function buildRuleCollectionModal(
|
||||
selectedContent: HistoryItemSummary[],
|
||||
historyId: string,
|
||||
fromRulesInput = false,
|
||||
defaultHideSourceItems = true
|
||||
) {
|
||||
// select legacy function
|
||||
let createFunc;
|
||||
if (collectionType == "list") {
|
||||
createFunc = LIST_COLLECTION_CREATOR.createListCollection;
|
||||
} else if (collectionType == "paired") {
|
||||
createFunc = PAIR_COLLECTION_CREATOR.createPairCollection;
|
||||
} else if (collectionType == "list:paired") {
|
||||
createFunc = LIST_OF_PAIRS_COLLECTION_CREATOR.createPairedListCollection;
|
||||
} else if (collectionType.startsWith("rules")) {
|
||||
createFunc = RULE_BASED_COLLECTION_CREATOR.createCollectionViaRules;
|
||||
} else {
|
||||
throw new Error(`Unknown collectionType encountered ${collectionType}`);
|
||||
}
|
||||
const createFunc = RULE_BASED_COLLECTION_CREATOR.createCollectionViaRules;
|
||||
// pull up cached content by type_ids;
|
||||
if (fromRulesInput) {
|
||||
return await createFunc(selectedContent);
|
||||
} else {
|
||||
const fakeBackboneContent = createBackboneContent(historyId, selectedContent);
|
||||
const fakeBackboneContent = createBackboneContent(historyId, selectedContent, defaultHideSourceItems);
|
||||
return await createFunc(fakeBackboneContent);
|
||||
}
|
||||
}
|
||||
|
||||
const createBackboneContent = (historyId, selection) => {
|
||||
const createBackboneContent = (historyId: string, selection: HistoryItemSummary[], defaultHideSourceItems: boolean) => {
|
||||
const selectionJson = Array.from(selection.values());
|
||||
return {
|
||||
historyId,
|
||||
toJSON: () => selectionJson,
|
||||
// result must be a $.Deferred object instead of a promise because
|
||||
// that's the kind of deprecated data format that backbone likes to use.
|
||||
createHDCA(element_identifiers, collection_type, name, hide_source_items, options = {}) {
|
||||
createHDCA(
|
||||
element_identifiers: any,
|
||||
collection_type: CollectionType,
|
||||
name: string,
|
||||
hide_source_items: boolean,
|
||||
options = {}
|
||||
) {
|
||||
const def = jQuery.Deferred();
|
||||
return def.resolve(null, {
|
||||
collection_type,
|
||||
@@ -55,5 +62,6 @@ const createBackboneContent = (historyId, selection) => {
|
||||
options,
|
||||
});
|
||||
},
|
||||
defaultHideSourceItems,
|
||||
};
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { computed, reactive, ref } from "vue";
|
||||
|
||||
import { GalaxyApi } from "@/api";
|
||||
import type { CollectionType } from "@/components/History/adapters/buildCollectionModal";
|
||||
import { Services } from "@/components/Libraries/LibraryFolder/services";
|
||||
import { deleteSelectedItems } from "@/components/Libraries/LibraryFolder/TopToolbar/delete-selected";
|
||||
import download from "@/components/Libraries/LibraryFolder/TopToolbar/download";
|
||||
@@ -25,6 +26,7 @@ import { Toast } from "@/composables/toast";
|
||||
import { getAppRoot } from "@/onload";
|
||||
import { useUserStore } from "@/stores/userStore";
|
||||
|
||||
import CollectionCreatorModal from "@/components/Collections/CollectionCreatorModal.vue";
|
||||
import FolderDetails from "@/components/Libraries/LibraryFolder/FolderDetails/FolderDetails.vue";
|
||||
import LibraryBreadcrumb from "@/components/Libraries/LibraryFolder/LibraryBreadcrumb.vue";
|
||||
import SearchField from "@/components/Libraries/LibraryFolder/SearchField.vue";
|
||||
@@ -32,6 +34,13 @@ import DirectoryDatasetPicker from "@/components/Libraries/LibraryFolder/TopTool
|
||||
import ProgressBar from "@/components/ProgressBar.vue";
|
||||
import HistoryDatasetPicker from "@/components/SelectionDialog/HistoryDatasetPicker.vue";
|
||||
|
||||
type CollectionElement = {
|
||||
deleted: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
src: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
metadata: any;
|
||||
selected: any[];
|
||||
@@ -90,6 +99,12 @@ const totalRows = computed(() => {
|
||||
return props.metadata?.total_rows ?? 0;
|
||||
});
|
||||
|
||||
// Variables for Collection Creation
|
||||
const collectionModalType = ref<CollectionType | null>(null);
|
||||
const collectionModalShow = ref(false);
|
||||
const collectionSelection = ref<any[]>([]);
|
||||
const collectionHistoryId = ref<string | null>(null);
|
||||
|
||||
function updateSearch(value: string) {
|
||||
emit("updateSearch", value);
|
||||
}
|
||||
@@ -187,6 +202,20 @@ async function importToHistoryModal(isCollection: boolean) {
|
||||
if (isCollection) {
|
||||
new mod_import_collection.ImportCollectionModal({
|
||||
selected: checkedItems,
|
||||
onCollectionImport: async (
|
||||
collectionType: string,
|
||||
selection: { models: CollectionElement[] },
|
||||
historyId: string
|
||||
) => {
|
||||
try {
|
||||
collectionHistoryId.value = historyId;
|
||||
collectionModalType.value = collectionType as CollectionType;
|
||||
collectionSelection.value = selection.models;
|
||||
collectionModalShow.value = true;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
new mod_import_dataset.ImportDatasetModal({
|
||||
@@ -410,5 +439,13 @@ function onAddDatasetsDirectory(selectedDatasets: Record<string, string | boolea
|
||||
:folder-id="props.folderId"
|
||||
@onSelect="onAddDatasetsDirectory"
|
||||
@onClose="onAddDatasets" />
|
||||
|
||||
<CollectionCreatorModal
|
||||
v-if="collectionModalType && collectionHistoryId"
|
||||
:history-id="collectionHistoryId"
|
||||
:collection-type="collectionModalType"
|
||||
:selected-items="collectionSelection"
|
||||
:show-modal.sync="collectionModalShow"
|
||||
default-hide-source-items />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+11
-8
@@ -54,6 +54,7 @@ var ImportCollectionModal = Backbone.View.extend({
|
||||
buttons: {
|
||||
Continue: () => {
|
||||
this.showCollectionBuilder(checked_items.dataset_ids);
|
||||
Galaxy.modal.hide();
|
||||
},
|
||||
Close: () => {
|
||||
Galaxy.modal.hide();
|
||||
@@ -106,17 +107,19 @@ var ImportCollectionModal = Backbone.View.extend({
|
||||
},
|
||||
collectionImport: function (collectionElements, historyId) {
|
||||
const collectionType = this.modal.$el.find("#library-collection-type-select").val();
|
||||
let selection = null;
|
||||
let selection = {};
|
||||
if (collectionType == "rules") {
|
||||
selection = collectionElements;
|
||||
selection.selectionType = "library_datasets";
|
||||
} else {
|
||||
selection = {
|
||||
models: collectionElements,
|
||||
};
|
||||
}
|
||||
const Galaxy = getGalaxyInstance();
|
||||
Galaxy.currHistoryPanel.buildCollection(collectionType, selection, historyId);
|
||||
selection = {
|
||||
models: collectionElements,
|
||||
};
|
||||
if (collectionType === "rules") {
|
||||
const Galaxy = getGalaxyInstance();
|
||||
Galaxy.currHistoryPanel.buildCollectionFromRules(selection, historyId);
|
||||
} else if (this.options.onCollectionImport) {
|
||||
this.options.onCollectionImport(collectionType, selection, historyId);
|
||||
}
|
||||
},
|
||||
templateCollectionSelectModal: function () {
|
||||
return _.template(
|
||||
|
||||
@@ -8,10 +8,10 @@ import Vue, { computed, ref } from "vue";
|
||||
|
||||
import { UploadQueue } from "@/utils/upload-queue.js";
|
||||
|
||||
import { collectionBuilder } from "./builders.js";
|
||||
import { defaultModel } from "./model.js";
|
||||
import { COLLECTION_TYPES, DEFAULT_FILE_NAME, hasBrowserSupport } from "./utils";
|
||||
|
||||
import CollectionCreatorModal from "../Collections/CollectionCreatorModal.vue";
|
||||
import DefaultRow from "./DefaultRow.vue";
|
||||
import UploadBox from "./UploadBox.vue";
|
||||
import UploadSelect from "./UploadSelect.vue";
|
||||
@@ -68,10 +68,20 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disableFooter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
emitUploaded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["dismiss", "progress"]);
|
||||
const emit = defineEmits(["dismiss", "progress", "uploaded"]);
|
||||
|
||||
const collectionModalShow = ref(false);
|
||||
const collectionSelection = ref([]);
|
||||
const collectionType = ref("list");
|
||||
const counterAnnounce = ref(0);
|
||||
const counterError = ref(0);
|
||||
@@ -147,8 +157,28 @@ function eventAnnounce(index, file) {
|
||||
}
|
||||
|
||||
/** Populates collection builder with uploaded files */
|
||||
function eventBuild() {
|
||||
collectionBuilder(historyId, collectionType.value, uploadValues.value);
|
||||
async function eventBuild(openModal = false) {
|
||||
try {
|
||||
collectionSelection.value = [];
|
||||
uploadValues.value.forEach((model) => {
|
||||
const outputs = model.outputs;
|
||||
if (outputs) {
|
||||
Object.entries(outputs).forEach((output) => {
|
||||
const outputDetails = output[1];
|
||||
collectionSelection.value.push(outputDetails);
|
||||
});
|
||||
} else {
|
||||
console.debug("Warning, upload response does not contain outputs.", model);
|
||||
}
|
||||
});
|
||||
if (openModal) {
|
||||
collectionModalShow.value = true;
|
||||
} else {
|
||||
emit("uploaded", collectionSelection.value);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
counterRunning.value = 0;
|
||||
eventReset();
|
||||
emit("dismiss");
|
||||
@@ -369,8 +399,8 @@ defineExpose({
|
||||
:file-name="uploadItem.fileName"
|
||||
:file-size="uploadItem.fileSize"
|
||||
:info="uploadItem.info"
|
||||
:list-extensions="isCollection ? null : listExtensions"
|
||||
:list-db-keys="isCollection ? null : listDbKeys"
|
||||
:list-extensions="!isCollection && listExtensions.length > 1 ? listExtensions : null"
|
||||
:list-db-keys="!isCollection && listDbKeys.length > 1 ? listDbKeys : null"
|
||||
:percentage="uploadItem.percentage"
|
||||
:space-to-tab="uploadItem.spaceToTab"
|
||||
:status="uploadItem.status"
|
||||
@@ -387,7 +417,7 @@ defineExpose({
|
||||
</div>
|
||||
<input ref="uploadFile" type="file" :multiple="multiple" @change="addFiles($event.target.files)" />
|
||||
</UploadBox>
|
||||
<div class="upload-footer text-center">
|
||||
<div v-if="!disableFooter" class="upload-footer text-center">
|
||||
<span v-if="isCollection" class="upload-footer-title">Collection:</span>
|
||||
<UploadSelect
|
||||
v-if="isCollection"
|
||||
@@ -416,6 +446,7 @@ defineExpose({
|
||||
placeholder="Select Reference"
|
||||
@input="updateDbKey" />
|
||||
</div>
|
||||
<slot name="footer" />
|
||||
<div class="upload-buttons d-flex justify-content-end">
|
||||
<BButton id="btn-local" :disabled="!enableSources" @click="uploadFile.click()">
|
||||
<FontAwesomeIcon icon="fa-laptop" />
|
||||
@@ -443,9 +474,21 @@ defineExpose({
|
||||
:disabled="!enableBuild"
|
||||
title="Build"
|
||||
:variant="enableBuild ? 'primary' : null"
|
||||
@click="eventBuild">
|
||||
@click="() => eventBuild(true)">
|
||||
<span v-localize>Build</span>
|
||||
</BButton>
|
||||
<BButton
|
||||
v-if="emitUploaded"
|
||||
id="btn-emit"
|
||||
:disabled="!enableBuild"
|
||||
title="Use Uploaded Files"
|
||||
:variant="enableBuild ? 'primary' : null"
|
||||
@click="() => eventBuild(false)">
|
||||
<slot name="emit-btn-txt">
|
||||
<span v-localize>Use Uploaded</span>
|
||||
</slot>
|
||||
({{ counterSuccess }})
|
||||
</BButton>
|
||||
<BButton id="btn-stop" title="Pause" :disabled="!isRunning" @click="eventStop">
|
||||
<span v-localize>Pause</span>
|
||||
</BButton>
|
||||
@@ -457,5 +500,12 @@ defineExpose({
|
||||
<span v-else v-localize>Close</span>
|
||||
</BButton>
|
||||
</div>
|
||||
<CollectionCreatorModal
|
||||
v-if="isCollection && historyId"
|
||||
:history-id="historyId"
|
||||
:collection-type="collectionType"
|
||||
:selected-items="collectionSelection"
|
||||
:show-modal.sync="collectionModalShow"
|
||||
default-hide-source-items />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -60,7 +60,7 @@ function eventBuild() {
|
||||
} else if (entry.selectionType == "remote_files") {
|
||||
entry.elements = uris.value;
|
||||
}
|
||||
Galaxy.currHistoryPanel.buildCollection("rules", entry, null, true);
|
||||
Galaxy.currHistoryPanel.buildCollectionFromRules(entry, null, true);
|
||||
emit("dismiss");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
* Interface with rule builder
|
||||
*/
|
||||
import { getGalaxyInstance } from "app";
|
||||
|
||||
/** Populates collection builder with uploaded files */
|
||||
export function collectionBuilder(historyId, type, uploadValues) {
|
||||
const Galaxy = getGalaxyInstance();
|
||||
const models = {};
|
||||
uploadValues.forEach((model) => {
|
||||
const outputs = model.outputs;
|
||||
if (outputs) {
|
||||
Object.entries(outputs).forEach((output) => {
|
||||
const outputDetails = output[1];
|
||||
models[outputDetails.id] = outputDetails;
|
||||
});
|
||||
} else {
|
||||
console.debug("Warning, upload response does not contain outputs.", model);
|
||||
}
|
||||
});
|
||||
// Build selection object
|
||||
const selection = {
|
||||
models: Object.values(models),
|
||||
historyId: historyId,
|
||||
};
|
||||
Galaxy.currHistoryPanel.buildCollection(type, selection);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export interface TaskMonitor {
|
||||
* The meaning of the status string is up to the monitor implementation.
|
||||
* In case of an error, this will be the error message.
|
||||
*/
|
||||
status: Readonly<Ref<string | undefined>>;
|
||||
taskStatus: Readonly<Ref<string | undefined>>;
|
||||
|
||||
/**
|
||||
* Loads the status of the task from a stored value.
|
||||
@@ -96,19 +96,19 @@ export function useGenericMonitor(options: {
|
||||
let pollDelay = options.defaultPollDelay ?? DEFAULT_POLL_DELAY;
|
||||
|
||||
const isRunning = ref(false);
|
||||
const status = ref<string>();
|
||||
const taskStatus = ref<string>();
|
||||
const requestId = ref<string>();
|
||||
const requestHasFailed = ref(false);
|
||||
|
||||
const isCompleted = computed(() => options.completedCondition(status.value));
|
||||
const hasFailed = computed(() => options.failedCondition(status.value));
|
||||
const isCompleted = computed(() => options.completedCondition(taskStatus.value));
|
||||
const hasFailed = computed(() => options.failedCondition(taskStatus.value));
|
||||
|
||||
function isFinalState(status?: string) {
|
||||
return options.completedCondition(status) || options.failedCondition(status);
|
||||
}
|
||||
|
||||
function loadStatus(storedStatus: string) {
|
||||
status.value = storedStatus;
|
||||
taskStatus.value = storedStatus;
|
||||
}
|
||||
|
||||
async function waitForTask(taskId: string, pollDelayInMs?: number) {
|
||||
@@ -122,7 +122,7 @@ export function useGenericMonitor(options: {
|
||||
async function fetchTaskStatus(taskId: string) {
|
||||
try {
|
||||
const result = await options.fetchStatus(taskId);
|
||||
status.value = result;
|
||||
taskStatus.value = result;
|
||||
if (isCompleted.value || hasFailed.value) {
|
||||
isRunning.value = false;
|
||||
} else {
|
||||
@@ -141,7 +141,7 @@ export function useGenericMonitor(options: {
|
||||
}
|
||||
|
||||
function handleError(err: string) {
|
||||
status.value = err.toString();
|
||||
taskStatus.value = err.toString();
|
||||
requestHasFailed.value = true;
|
||||
isRunning.value = false;
|
||||
resetTimeout();
|
||||
@@ -156,7 +156,7 @@ export function useGenericMonitor(options: {
|
||||
|
||||
function resetState() {
|
||||
resetTimeout();
|
||||
status.value = undefined;
|
||||
taskStatus.value = undefined;
|
||||
requestHasFailed.value = false;
|
||||
isRunning.value = false;
|
||||
}
|
||||
@@ -169,7 +169,7 @@ export function useGenericMonitor(options: {
|
||||
isCompleted: readonly(isCompleted),
|
||||
hasFailed: readonly(hasFailed),
|
||||
requestHasFailed: readonly(requestHasFailed),
|
||||
status: readonly(status),
|
||||
taskStatus: readonly(taskStatus),
|
||||
expirationTime: options.expirationTime,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ jest.mock("@vueuse/core", () => ({
|
||||
|
||||
function useMonitorMock(): TaskMonitor {
|
||||
const isRunning = ref(false);
|
||||
const status = ref();
|
||||
const taskStatus = ref();
|
||||
|
||||
return {
|
||||
waitForTask: jest.fn().mockImplementation(() => {
|
||||
@@ -30,11 +30,11 @@ function useMonitorMock(): TaskMonitor {
|
||||
isCompleted: ref(false),
|
||||
hasFailed: ref(false),
|
||||
requestHasFailed: ref(false),
|
||||
status,
|
||||
taskStatus,
|
||||
expirationTime: 1000,
|
||||
isFinalState: jest.fn(),
|
||||
loadStatus(storedStatus) {
|
||||
status.value = storedStatus;
|
||||
taskStatus.value = storedStatus;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export interface MonitoringData {
|
||||
* The meaning of the status string is up to the monitor implementation.
|
||||
* In case of an error, this will be the error message.
|
||||
*/
|
||||
status?: string;
|
||||
taskStatus?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,7 +120,7 @@ export function usePersistentProgressTaskMonitor(
|
||||
isCompleted,
|
||||
hasFailed,
|
||||
requestHasFailed,
|
||||
status,
|
||||
taskStatus,
|
||||
expirationTime,
|
||||
} = useMonitor;
|
||||
|
||||
@@ -152,12 +152,12 @@ export function usePersistentProgressTaskMonitor(
|
||||
});
|
||||
|
||||
watch(
|
||||
status,
|
||||
() => taskStatus.value,
|
||||
(newStatus) => {
|
||||
if (newStatus && currentMonitoringData.value) {
|
||||
currentMonitoringData.value = {
|
||||
...currentMonitoringData.value,
|
||||
status: newStatus,
|
||||
taskStatus: newStatus,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -173,10 +173,10 @@ export function usePersistentProgressTaskMonitor(
|
||||
throw new Error("No monitoring data provided or stored. Cannot start monitoring progress.");
|
||||
}
|
||||
|
||||
if (isFinalState(currentMonitoringData.value.status)) {
|
||||
if (isFinalState(currentMonitoringData.value.taskStatus)) {
|
||||
// The task has already finished no need to start monitoring again.
|
||||
// Instead, reload the stored status to update the UI.
|
||||
return loadStatus(currentMonitoringData.value.status!);
|
||||
return loadStatus(currentMonitoringData.value.taskStatus!);
|
||||
}
|
||||
|
||||
if (hasExpired.value) {
|
||||
@@ -240,7 +240,7 @@ export function usePersistentProgressTaskMonitor(
|
||||
* The meaning of the status string is up to the monitor implementation.
|
||||
* In case of an error, this will be the error message.
|
||||
*/
|
||||
status,
|
||||
status: taskStatus,
|
||||
|
||||
/**
|
||||
* True if the monitoring data can expire.
|
||||
|
||||
@@ -31,28 +31,28 @@ describe("useShortTermStorageMonitor", () => {
|
||||
});
|
||||
|
||||
it("should indicate the task is running when it is still not ready", async () => {
|
||||
const { waitForTask, isRunning, status } = useShortTermStorageMonitor();
|
||||
const { waitForTask, isRunning, taskStatus } = useShortTermStorageMonitor();
|
||||
|
||||
expect(isRunning.value).toBe(false);
|
||||
waitForTask(PENDING_TASK_ID);
|
||||
await flushPromises();
|
||||
expect(isRunning.value).toBe(true);
|
||||
expect(status.value).toBe("PENDING");
|
||||
expect(taskStatus.value).toBe("PENDING");
|
||||
});
|
||||
|
||||
it("should indicate the task is successfully completed when the state is ready", async () => {
|
||||
const { waitForTask, isRunning, isCompleted, status } = useShortTermStorageMonitor();
|
||||
const { waitForTask, isRunning, isCompleted, taskStatus } = useShortTermStorageMonitor();
|
||||
|
||||
expect(isCompleted.value).toBe(false);
|
||||
waitForTask(COMPLETED_TASK_ID);
|
||||
await flushPromises();
|
||||
expect(isCompleted.value).toBe(true);
|
||||
expect(isRunning.value).toBe(false);
|
||||
expect(status.value).toBe("READY");
|
||||
expect(taskStatus.value).toBe("READY");
|
||||
});
|
||||
|
||||
it("should indicate the task status request failed when the request failed", async () => {
|
||||
const { waitForTask, requestHasFailed, isRunning, isCompleted, status } = useShortTermStorageMonitor();
|
||||
const { waitForTask, requestHasFailed, isRunning, isCompleted, taskStatus } = useShortTermStorageMonitor();
|
||||
|
||||
expect(requestHasFailed.value).toBe(false);
|
||||
waitForTask(REQUEST_FAILED_TASK_ID);
|
||||
@@ -60,16 +60,16 @@ describe("useShortTermStorageMonitor", () => {
|
||||
expect(requestHasFailed.value).toBe(true);
|
||||
expect(isRunning.value).toBe(false);
|
||||
expect(isCompleted.value).toBe(false);
|
||||
expect(status.value).toBe("Request failed");
|
||||
expect(taskStatus.value).toBe("Request failed");
|
||||
});
|
||||
|
||||
it("should load the status from the stored monitoring data", async () => {
|
||||
const { loadStatus, isRunning, isCompleted, hasFailed, status } = useShortTermStorageMonitor();
|
||||
const { loadStatus, isRunning, isCompleted, hasFailed, taskStatus } = useShortTermStorageMonitor();
|
||||
const storedStatus = "READY";
|
||||
|
||||
loadStatus(storedStatus);
|
||||
|
||||
expect(status.value).toBe(storedStatus);
|
||||
expect(taskStatus.value).toBe(storedStatus);
|
||||
expect(isRunning.value).toBe(false);
|
||||
expect(isCompleted.value).toBe(true);
|
||||
expect(hasFailed.value).toBe(false);
|
||||
@@ -77,26 +77,26 @@ describe("useShortTermStorageMonitor", () => {
|
||||
|
||||
describe("isFinalState", () => {
|
||||
it("should indicate is final state when the task is completed", async () => {
|
||||
const { waitForTask, isFinalState, isRunning, isCompleted, hasFailed, status } =
|
||||
const { waitForTask, isFinalState, isRunning, isCompleted, hasFailed, taskStatus } =
|
||||
useShortTermStorageMonitor();
|
||||
|
||||
expect(isFinalState(status.value)).toBe(false);
|
||||
expect(isFinalState(taskStatus.value)).toBe(false);
|
||||
waitForTask(COMPLETED_TASK_ID);
|
||||
await flushPromises();
|
||||
expect(isFinalState(status.value)).toBe(true);
|
||||
expect(isFinalState(taskStatus.value)).toBe(true);
|
||||
expect(isRunning.value).toBe(false);
|
||||
expect(isCompleted.value).toBe(true);
|
||||
expect(hasFailed.value).toBe(false);
|
||||
});
|
||||
|
||||
it("should indicate is final state when the task has failed", async () => {
|
||||
const { waitForTask, isFinalState, isRunning, isCompleted, hasFailed, status } =
|
||||
const { waitForTask, isFinalState, isRunning, isCompleted, hasFailed, taskStatus } =
|
||||
useShortTermStorageMonitor();
|
||||
|
||||
expect(isFinalState(status.value)).toBe(false);
|
||||
expect(isFinalState(taskStatus.value)).toBe(false);
|
||||
waitForTask(REQUEST_FAILED_TASK_ID);
|
||||
await flushPromises();
|
||||
expect(isFinalState(status.value)).toBe(true);
|
||||
expect(isFinalState(taskStatus.value)).toBe(true);
|
||||
expect(isRunning.value).toBe(false);
|
||||
expect(isCompleted.value).toBe(false);
|
||||
expect(hasFailed.value).toBe(true);
|
||||
|
||||
@@ -35,39 +35,39 @@ describe("useTaskMonitor", () => {
|
||||
});
|
||||
|
||||
it("should indicate the task is running when it is still pending", async () => {
|
||||
const { waitForTask, isRunning, status } = useTaskMonitor();
|
||||
const { waitForTask, isRunning, taskStatus } = useTaskMonitor();
|
||||
|
||||
expect(isRunning.value).toBe(false);
|
||||
waitForTask(PENDING_TASK_ID);
|
||||
await flushPromises();
|
||||
expect(isRunning.value).toBe(true);
|
||||
expect(status.value).toBe("PENDING");
|
||||
expect(taskStatus.value).toBe("PENDING");
|
||||
});
|
||||
|
||||
it("should indicate the task is successfully completed when the state is SUCCESS", async () => {
|
||||
const { waitForTask, isRunning, isCompleted, status } = useTaskMonitor();
|
||||
const { waitForTask, isRunning, isCompleted, taskStatus } = useTaskMonitor();
|
||||
|
||||
expect(isCompleted.value).toBe(false);
|
||||
waitForTask(COMPLETED_TASK_ID);
|
||||
await flushPromises();
|
||||
expect(isCompleted.value).toBe(true);
|
||||
expect(isRunning.value).toBe(false);
|
||||
expect(status.value).toBe("SUCCESS");
|
||||
expect(taskStatus.value).toBe("SUCCESS");
|
||||
});
|
||||
|
||||
it("should indicate the task has failed when the state is FAILED", async () => {
|
||||
const { waitForTask, isRunning, hasFailed, status } = useTaskMonitor();
|
||||
const { waitForTask, isRunning, hasFailed, taskStatus } = useTaskMonitor();
|
||||
|
||||
expect(hasFailed.value).toBe(false);
|
||||
waitForTask(FAILED_TASK_ID);
|
||||
await flushPromises();
|
||||
expect(hasFailed.value).toBe(true);
|
||||
expect(isRunning.value).toBe(false);
|
||||
expect(status.value).toBe("FAILURE");
|
||||
expect(taskStatus.value).toBe("FAILURE");
|
||||
});
|
||||
|
||||
it("should indicate the task status request failed when the request failed", async () => {
|
||||
const { waitForTask, requestHasFailed, isRunning, isCompleted, status } = useTaskMonitor();
|
||||
const { waitForTask, requestHasFailed, isRunning, isCompleted, taskStatus } = useTaskMonitor();
|
||||
|
||||
expect(requestHasFailed.value).toBe(false);
|
||||
waitForTask(REQUEST_FAILED_TASK_ID);
|
||||
@@ -75,16 +75,16 @@ describe("useTaskMonitor", () => {
|
||||
expect(requestHasFailed.value).toBe(true);
|
||||
expect(isRunning.value).toBe(false);
|
||||
expect(isCompleted.value).toBe(false);
|
||||
expect(status.value).toBe("Request failed");
|
||||
expect(taskStatus.value).toBe("Request failed");
|
||||
});
|
||||
|
||||
it("should load the status from the stored monitoring data", async () => {
|
||||
const { loadStatus, isRunning, isCompleted, hasFailed, status } = useTaskMonitor();
|
||||
const { loadStatus, isRunning, isCompleted, hasFailed, taskStatus } = useTaskMonitor();
|
||||
const storedStatus = "SUCCESS";
|
||||
|
||||
loadStatus(storedStatus);
|
||||
|
||||
expect(status.value).toBe(storedStatus);
|
||||
expect(taskStatus.value).toBe(storedStatus);
|
||||
expect(isRunning.value).toBe(false);
|
||||
expect(isCompleted.value).toBe(true);
|
||||
expect(hasFailed.value).toBe(false);
|
||||
@@ -92,24 +92,24 @@ describe("useTaskMonitor", () => {
|
||||
|
||||
describe("isFinalState", () => {
|
||||
it("should indicate is final state when the task is completed", async () => {
|
||||
const { waitForTask, isFinalState, isRunning, isCompleted, hasFailed, status } = useTaskMonitor();
|
||||
const { waitForTask, isFinalState, isRunning, isCompleted, hasFailed, taskStatus } = useTaskMonitor();
|
||||
|
||||
expect(isFinalState(status.value)).toBe(false);
|
||||
expect(isFinalState(taskStatus.value)).toBe(false);
|
||||
waitForTask(COMPLETED_TASK_ID);
|
||||
await flushPromises();
|
||||
expect(isFinalState(status.value)).toBe(true);
|
||||
expect(isFinalState(taskStatus.value)).toBe(true);
|
||||
expect(isRunning.value).toBe(false);
|
||||
expect(isCompleted.value).toBe(true);
|
||||
expect(hasFailed.value).toBe(false);
|
||||
});
|
||||
|
||||
it("should indicate is final state when the task has failed", async () => {
|
||||
const { waitForTask, isFinalState, isRunning, isCompleted, hasFailed, status } = useTaskMonitor();
|
||||
const { waitForTask, isFinalState, isRunning, isCompleted, hasFailed, taskStatus } = useTaskMonitor();
|
||||
|
||||
expect(isFinalState(status.value)).toBe(false);
|
||||
expect(isFinalState(taskStatus.value)).toBe(false);
|
||||
waitForTask(FAILED_TASK_ID);
|
||||
await flushPromises();
|
||||
expect(isFinalState(status.value)).toBe(true);
|
||||
expect(isFinalState(taskStatus.value)).toBe(true);
|
||||
expect(isRunning.value).toBe(false);
|
||||
expect(isCompleted.value).toBe(false);
|
||||
expect(hasFailed.value).toBe(true);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, set } from "vue";
|
||||
|
||||
import { GalaxyApi, type HDASummary } from "@/api";
|
||||
import { HistoryFilters } from "@/components/History/HistoryFilters";
|
||||
import { filtersToQueryValues } from "@/components/History/model/queries";
|
||||
import { errorMessageAsString } from "@/utils/simple-error";
|
||||
|
||||
const DEFAULT_FILTERS = { visible: true, deleted: false };
|
||||
|
||||
/**
|
||||
* Fetches datasets for the Collection Builder modal. It caches the datasets for each filter text.
|
||||
*/
|
||||
export const useCollectionBuilderItemsStore = defineStore("collectionBuilderItemsStore", () => {
|
||||
const cachedDatasetsForFilterText = ref<Record<string, HDASummary[]>>({});
|
||||
const errorMessage = ref<string | null>(null);
|
||||
const isFetching = ref<Record<string, boolean>>({});
|
||||
const lastFetch = ref<{ id: string; time: string; text: string } | null>(null);
|
||||
|
||||
async function fetchDatasetsForFiltertext(historyId: string, historyUpdateTime?: string, filterText = "") {
|
||||
if (isFetching.value[filterText]) {
|
||||
return { data: [], error: null };
|
||||
}
|
||||
|
||||
if (
|
||||
lastFetch.value &&
|
||||
lastFetch.value.id === historyId &&
|
||||
lastFetch.value.time === historyUpdateTime &&
|
||||
lastFetch.value.text === filterText
|
||||
) {
|
||||
return {
|
||||
data: cachedDatasetsForFilterText.value[filterText] || [],
|
||||
error: errorMessage.value,
|
||||
};
|
||||
}
|
||||
|
||||
set(isFetching.value, filterText, true);
|
||||
|
||||
const filterQuery = getFilterQuery(filterText);
|
||||
|
||||
// Note: Had "/api/histories/{history_id}/contents/datasets" before but it was returning datasets and collections
|
||||
const { data, error: apiError } = await GalaxyApi().GET("/api/histories/{history_id}/contents", {
|
||||
params: {
|
||||
path: { history_id: historyId },
|
||||
query: { ...filterQuery, v: "dev" },
|
||||
},
|
||||
});
|
||||
const returnedData = data as HDASummary[];
|
||||
|
||||
let error = null;
|
||||
if (apiError) {
|
||||
error = errorMessageAsString(apiError);
|
||||
errorMessage.value = error;
|
||||
} else {
|
||||
set(cachedDatasetsForFilterText.value, filterText, returnedData);
|
||||
}
|
||||
|
||||
set(isFetching.value, filterText, false);
|
||||
lastFetch.value = { id: historyId, time: historyUpdateTime || "", text: filterText };
|
||||
|
||||
return { data: returnedData, error };
|
||||
}
|
||||
|
||||
function getFilterQuery(filterText: string) {
|
||||
let filters = filterText ? HistoryFilters.getQueryDict(filterText) : DEFAULT_FILTERS;
|
||||
if (filters.history_content_type) {
|
||||
delete filters.history_content_type;
|
||||
}
|
||||
filters = { ...filters, history_content_type: "dataset" };
|
||||
return filtersToQueryValues(filters);
|
||||
}
|
||||
|
||||
return {
|
||||
cachedDatasetsForFilterText,
|
||||
isFetching,
|
||||
fetchDatasetsForFiltertext,
|
||||
};
|
||||
});
|
||||
@@ -71,6 +71,9 @@ $ui-margin-horizontal-large: $margin-v * 2;
|
||||
.ui-modal {
|
||||
display: none;
|
||||
overflow: auto;
|
||||
.title {
|
||||
width: 100%;
|
||||
}
|
||||
.modal-header.no-separator {
|
||||
border: none !important;
|
||||
padding-bottom: 0px !important;
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// Alphanumeric/natural sort fn
|
||||
function naturalSort(a, b) {
|
||||
// setup temp-scope variables for comparison evauluation
|
||||
var re = /(-?[0-9.]+)/g;
|
||||
|
||||
var x = a.toString().toLowerCase() || "";
|
||||
var y = b.toString().toLowerCase() || "";
|
||||
var nC = String.fromCharCode(0);
|
||||
var xN = x.replace(re, `${nC}$1${nC}`).split(nC);
|
||||
var yN = y.replace(re, `${nC}$1${nC}`).split(nC);
|
||||
var xD = new Date(x).getTime();
|
||||
var yD = xD ? new Date(y).getTime() : null;
|
||||
// natural sorting of dates
|
||||
if (yD) {
|
||||
if (xD < yD) {
|
||||
return -1;
|
||||
} else if (xD > yD) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// natural sorting through split numeric strings and default strings
|
||||
var oFxNcL;
|
||||
|
||||
var oFyNcL;
|
||||
for (var cLoc = 0, numS = Math.max(xN.length, yN.length); cLoc < numS; cLoc++) {
|
||||
oFxNcL = parseFloat(xN[cLoc]) || xN[cLoc];
|
||||
oFyNcL = parseFloat(yN[cLoc]) || yN[cLoc];
|
||||
if (oFxNcL < oFyNcL) {
|
||||
return -1;
|
||||
} else if (oFxNcL > oFyNcL) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export default naturalSort;
|
||||
@@ -0,0 +1,45 @@
|
||||
// Alphanumeric/natural sort fn
|
||||
export function naturalSort(a = "", b = ""): number {
|
||||
// setup temp-scope variables for comparison evauluation
|
||||
const re = /(-?[0-9.]+)/g;
|
||||
|
||||
const x = a.toString().toLowerCase() || "";
|
||||
const y = b.toString().toLowerCase() || "";
|
||||
const nC = String.fromCharCode(0);
|
||||
const xN = x.replace(re, `${nC}$1${nC}`).split(nC);
|
||||
const yN = y.replace(re, `${nC}$1${nC}`).split(nC);
|
||||
const xD = new Date(x).getTime();
|
||||
const yD = xD ? new Date(y).getTime() : null;
|
||||
// natural sorting of dates
|
||||
if (yD) {
|
||||
if (xD < yD) {
|
||||
return -1;
|
||||
} else if (xD > yD) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// natural sorting through split numeric strings and default strings
|
||||
let oFxNcL;
|
||||
|
||||
let oFyNcL;
|
||||
for (let cLoc = 0, numS = Math.max(xN.length, yN.length); cLoc < numS; cLoc++) {
|
||||
oFxNcL = parseFloat(xN[cLoc] || "") || xN[cLoc];
|
||||
oFyNcL = parseFloat(yN[cLoc] || "") || yN[cLoc];
|
||||
|
||||
// Check if either part is undefined
|
||||
if (oFxNcL === undefined) {
|
||||
return -1;
|
||||
}
|
||||
if (oFyNcL === undefined) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (oFxNcL < oFyNcL) {
|
||||
return -1;
|
||||
} else if (oFxNcL > oFyNcL) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -438,7 +438,7 @@ history_copy_elements:
|
||||
|
||||
collection_builders:
|
||||
selectors:
|
||||
clear_filters: "a.clear-filters-link"
|
||||
clear_filters: "button.clear-filters-link"
|
||||
forward_datasets: ".forward-column .column-datasets"
|
||||
reverse_datasets: ".reverse-column .column-datasets"
|
||||
name: "input.collection-name"
|
||||
@@ -1053,7 +1053,7 @@ libraries:
|
||||
export_to_history_paired_option: 'option[value="${collection_option}"]'
|
||||
export_to_history_collection_name: '.collection-name'
|
||||
export_to_history_new_history: 'input[name=history_name]'
|
||||
clear_filters: '.clear-filters-link'
|
||||
clear_filters: "button.clear-filters-link"
|
||||
import_progress_bar: '.progress-bar-import'
|
||||
import_from_path_textarea: '#import_paths'
|
||||
select_all: '#select-all-checkboxes'
|
||||
|
||||
@@ -2,39 +2,39 @@ import STATES from "mvc/dataset/states";
|
||||
// ============================================================================
|
||||
// plain 3 step job chain
|
||||
var datasets1 = [
|
||||
{ name: "SET1-01_1.fastq", state: STATES.OK },
|
||||
{ name: "SET1-01_2.fastq", state: STATES.OK },
|
||||
{ name: "SET1-02_1.fastq", state: STATES.OK },
|
||||
{ name: "SET1-02_2.fastq", state: STATES.OK },
|
||||
{ name: "SET1-03_1.fastq", state: STATES.OK },
|
||||
{ name: "SET1-03_2.fastq", state: STATES.OK },
|
||||
{ name: "SET1-04_1.fastq", state: STATES.OK },
|
||||
{ name: "SET1-04_2.fastq", state: STATES.OK },
|
||||
{ name: "SET1-05_1.fastq", state: STATES.OK },
|
||||
{ name: "SET1-05_2.fastq", state: STATES.OK },
|
||||
{ name: "SET1-06_1.fastq", state: STATES.OK },
|
||||
{ name: "SET1-06_2.fastq", state: STATES.OK },
|
||||
{ name: "SET1-07_1.fastq", state: STATES.OK },
|
||||
{ name: "SET1-07_2.fastq", state: STATES.OK },
|
||||
{ name: "SET1-01_1.fastq", state: STATES.OK, id: "011" },
|
||||
{ name: "SET1-01_2.fastq", state: STATES.OK, id: "012" },
|
||||
{ name: "SET1-02_1.fastq", state: STATES.OK, id: "021" },
|
||||
{ name: "SET1-02_2.fastq", state: STATES.OK, id: "022" },
|
||||
{ name: "SET1-03_1.fastq", state: STATES.OK, id: "031" },
|
||||
{ name: "SET1-03_2.fastq", state: STATES.OK, id: "032" },
|
||||
{ name: "SET1-04_1.fastq", state: STATES.OK, id: "041" },
|
||||
{ name: "SET1-04_2.fastq", state: STATES.OK, id: "042" },
|
||||
{ name: "SET1-05_1.fastq", state: STATES.OK, id: "051" },
|
||||
{ name: "SET1-05_2.fastq", state: STATES.OK, id: "052" },
|
||||
{ name: "SET1-06_1.fastq", state: STATES.OK, id: "061" },
|
||||
{ name: "SET1-06_2.fastq", state: STATES.OK, id: "062" },
|
||||
{ name: "SET1-07_1.fastq", state: STATES.OK, id: "071" },
|
||||
{ name: "SET1-07_2.fastq", state: STATES.OK, id: "072" },
|
||||
];
|
||||
|
||||
var datasets2 = [
|
||||
{ name: "DP134_1_FS_PSII_FSB_42C_A10.1.fastq", state: STATES.OK },
|
||||
{ name: "DP134_1_FS_PSII_FSB_42C_A10.2.fastq", state: STATES.OK },
|
||||
{ name: "DP134_1_FS_PSII_FSB_42C_A10.1.fastq", state: STATES.OK, id: "101" },
|
||||
{ name: "DP134_1_FS_PSII_FSB_42C_A10.2.fastq", state: STATES.OK, id: "102" },
|
||||
];
|
||||
|
||||
var datasets3 = [
|
||||
{ name: "UII_moo_1.1.fastq", state: STATES.OK },
|
||||
{ name: "UII_moo_1.2.fastq", state: STATES.OK },
|
||||
{ name: "UII_moo_1.1.fastq", state: STATES.OK, id: "11" },
|
||||
{ name: "UII_moo_1.2.fastq", state: STATES.OK, id: "12" },
|
||||
];
|
||||
|
||||
var datasets4 = [
|
||||
{ name: "SET1-01_R1.fastq", state: STATES.OK },
|
||||
{ name: "SET1-01_R2.fastq", state: STATES.OK },
|
||||
{ name: "SET1-02_R1.fastq", state: STATES.OK },
|
||||
{ name: "SET1-02_R2.fastq", state: STATES.OK },
|
||||
{ name: "SET1-03_R1.fastq", state: STATES.OK },
|
||||
{ name: "SET1-03_R2.fastq", state: STATES.OK },
|
||||
{ name: "SET1-01_R1.fastq", state: STATES.OK, id: "111" },
|
||||
{ name: "SET1-01_R2.fastq", state: STATES.OK, id: "112" },
|
||||
{ name: "SET1-02_R1.fastq", state: STATES.OK, id: "211" },
|
||||
{ name: "SET1-02_R2.fastq", state: STATES.OK, id: "212" },
|
||||
{ name: "SET1-03_R1.fastq", state: STATES.OK, id: "311" },
|
||||
{ name: "SET1-03_R2.fastq", state: STATES.OK, id: "312" },
|
||||
];
|
||||
|
||||
var datasets1CreateRequestJSON = {
|
||||
|
||||
@@ -2002,13 +2002,13 @@ class NavigatesGalaxy(HasDriver):
|
||||
target_element.send_keys(text)
|
||||
|
||||
def collection_builder_hide_originals(self):
|
||||
self.wait_for_and_click_selector("input.hide-originals")
|
||||
self.wait_for_and_click_selector('[data-description="hide original elements"]')
|
||||
|
||||
def collection_builder_create(self):
|
||||
self.wait_for_and_click_selector("button.create-collection")
|
||||
|
||||
def collection_builder_clear_filters(self):
|
||||
self.wait_for_and_click_selector("a.clear-filters-link")
|
||||
self.wait_for_and_click_selector("button.clear-filters-link")
|
||||
|
||||
def collection_builder_click_paired_item(self, forward_or_reverse, item):
|
||||
assert forward_or_reverse in ["forward", "reverse"]
|
||||
|
||||
Reference in New Issue
Block a user