Merge pull request #18857 from ahmedhamidawan/guide_users_to_collection_builder

[24.2] Guide users to collection builders
This commit is contained in:
Dannon
2024-12-03 07:57:10 -05:00
committed by GitHub
48 changed files with 2993 additions and 1719 deletions
+3
View File
@@ -0,0 +1,3 @@
import { type components } from "@/api";
export type CompositeFileInfo = components["schemas"]["CompositeFileInfo"];
+3
View File
@@ -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");
+17 -10
View File
@@ -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(),
+6
View File
@@ -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,
+14
View File
@@ -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.
@@ -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);
@@ -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>
@@ -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(
+58 -8
View File
@@ -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>
+1 -1
View File
@@ -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");
}
-27
View File
@@ -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);
}
+9 -9
View File
@@ -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);
+16 -16
View File
@@ -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,
};
});
+3
View File
@@ -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;
-38
View File
@@ -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;
+45
View File
@@ -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;
}
+2 -2
View File
@@ -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 = {
+2 -2
View File
@@ -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"]