Merge pull request #22165 from davelopez/add_new_upload_modal

Add new Upload Modal Dialog
This commit is contained in:
Ahmed Hamid Awan
2026-03-24 11:21:15 -05:00
committed by GitHub
38 changed files with 3238 additions and 1453 deletions
@@ -1,11 +1,14 @@
<script setup lang="ts">
import { faUpload } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BBadge } from "bootstrap-vue";
import { onMounted, type Ref, ref, watch } from "vue";
import Vue from "vue";
import type { DataOption } from "@/components/Form/Elements/FormData/types";
import type { SelectionItem } from "@/components/SelectionDialog/selectionTypes";
import { useGlobalUploadModal } from "@/composables/globalUploadModal";
import { useUploadMethodModal } from "@/composables/upload/useUploadMethodModal";
import { useUrlTracker } from "@/composables/urlTracker";
import { getAppRoot } from "@/onload/loadConfig";
import { errorMessageAsString } from "@/utils/simple-error";
@@ -20,7 +23,7 @@ type Record = SelectionItem;
interface Props {
allowUpload?: boolean;
callback?: (results: Array<Record>) => void;
callback?: (results: Record[] | DataOption[]) => void;
filterOkState?: boolean;
filterByTypeIds?: string[];
format?: string;
@@ -28,6 +31,8 @@ interface Props {
multiple?: boolean;
title?: string;
history: string;
/** Optional formats to constrain the upload modal */
uploadModalFormats?: string[];
}
const props = withDefaults(defineProps<Props>(), {
@@ -39,6 +44,7 @@ const props = withDefaults(defineProps<Props>(), {
library: true,
multiple: false,
title: "",
uploadModalFormats: undefined,
});
const emit = defineEmits<{
@@ -48,6 +54,7 @@ const emit = defineEmits<{
}>();
const { openGlobalUploadModal } = useGlobalUploadModal();
const { openUploadModal } = useUploadMethodModal();
const errorMessage = ref("");
const filter = ref("");
@@ -125,7 +132,7 @@ function onClick(record: Record) {
function onOk() {
const results = model.finalize();
modalShow.value = false;
props.callback(results);
props.callback?.(results);
emit("onOk", results);
}
@@ -135,7 +142,7 @@ function onOpen(record: Record) {
}
/** Called when user decides to upload new data */
function onUpload() {
function onLegacyUpload() {
const propsData = {
multiple: props.multiple,
format: props.format,
@@ -148,6 +155,21 @@ function onUpload() {
emit("onUpload");
}
async function onBetaUpload() {
const result = await openUploadModal({
formats: props.uploadModalFormats,
multiple: props.multiple,
hideTips: true,
});
modalShow.value = false;
if (!result.cancelled) {
const uploadedOptions = result.toDataOptions();
props.callback?.(uploadedOptions);
emit("onOk", uploadedOptions);
}
emit("onUpload");
}
/** Performs server request to retrieve data records **/
function load(url?: string) {
if (url) {
@@ -207,10 +229,14 @@ watch(
@onOpen="onOpen"
@onUndo="load()">
<template v-slot:buttons>
<GButton v-if="allowUpload" size="small" @click="onUpload">
<GButton v-if="allowUpload" size="small" class="mr-1" @click="onLegacyUpload">
<FontAwesomeIcon :icon="faUpload" />
Upload
</GButton>
<GButton v-if="allowUpload" size="small" title="Try our new upload experience" @click="onBetaUpload">
<FontAwesomeIcon :icon="faUpload" />
<span v-localize>New upload<BBadge variant="warning" class="ml-1">Beta</BBadge></span>
</GButton>
</template>
</SelectionDialog>
</template>
@@ -57,3 +57,10 @@ export function sortFileSources(a: SelectionItem, b: SelectionItem): number {
}
return a.label.localeCompare(b.label);
}
/**
* Normalize SelectionDialog's Model.finalize() return value (single item or array) into an array.
*/
export function selectionToArray(selection: SelectionItem | SelectionItem[]): SelectionItem[] {
return Array.isArray(selection) ? selection : selection ? [selection] : [];
}
@@ -28,7 +28,7 @@ import { type EventData, useEventStore } from "@/stores/eventStore";
import { orList } from "@/utils/strings";
import type { DataOption, ExtendedCollectionType } from "./types";
import { containsDataOption } from "./types";
import { containsDataOption, isDataOption } from "./types";
import { BATCH, SOURCE, VARIANTS } from "./variants";
import FormSelection from "../FormSelection.vue";
@@ -38,13 +38,25 @@ import FormDataWorkflowRunTabs from "./FormDataWorkflowRunTabs.vue";
import FormSelect from "@/components/Form/Elements/FormSelect.vue";
import HelpText from "@/components/Help/HelpText.vue";
type HistoryOrCollectionItem = HistoryItemSummary | DCESummary;
/**
* These are raw API items that need to be converted to DataOption format.
*/
type SingleOrMultipleHistoryItems = HistoryOrCollectionItem | HistoryOrCollectionItem[];
/**
* Response types from the data dialog callback.
* DataOption[] is returned by the beta upload path for fresh uploads.
* SingleOrMultipleHistoryItems (HistoryItemSummary and DCESummary) are returned for dataset/collection selection.
*/
type DialogResponse = DataOption[] | SingleOrMultipleHistoryItems;
type SelectOption = {
label: string;
value: DataOption | null;
};
type HistoryOrCollectionItem = HistoryItemSummary | DCESummary;
const props = withDefaults(
defineProps<{
loading?: boolean;
@@ -225,6 +237,10 @@ const formattedOptions = computed(() => {
// check if option (with same id) is already in result, if yes replace it with keepOption
const existingOptionIndex = result.findIndex((v) => v.value?.id === option.value?.id);
if (existingOptionIndex >= 0) {
const existingOption = result[existingOptionIndex];
if (existingOption?.value && shouldPreferCanonicalOption(existingOption.value, option.value)) {
return;
}
result[existingOptionIndex] = option;
} else {
result.unshift(option);
@@ -351,8 +367,16 @@ function getSourceType(val: DataOption) {
}
}
/** Add values from drag/drop or data dialog sources */
function handleIncoming(incoming: Record<string, unknown> | Record<string, unknown>[], partial = true) {
/**
* Handle incoming data from sources that require validation and transformation.
* This includes drag-drop operations and data dialog selections.
* Validates datatype compatibility, source type compatibility, and converts to DataOption format.
*
* @param incoming - The incoming data objects to process
* @param partial - If true, merge with existing selection; if false, replace selection
* @returns true if processing succeeded, false otherwise
*/
function handleIncoming(incoming: SingleOrMultipleHistoryItems, partial = true) {
if (incoming) {
const values = Array.isArray(incoming) ? incoming : [incoming];
@@ -478,6 +502,81 @@ function toDataOption(item: HistoryOrCollectionItem): DataOption | null {
return newValue;
}
/**
* Normalize an uploaded option by finding matching options in existing props.
* Returns the canonical option if found, otherwise returns the uploaded option.
*/
function normalizeOption(option: DataOption): DataOption {
const keepKey = `${option.id}_${option.src}`;
const existingOptions = props.options?.[option.src];
const foundOption = existingOptions?.find((existing) => existing.id === option.id);
if (foundOption) {
return foundOption;
}
// Cache new option in keepOptions if not already present
if (!isInKeepOptions(keepKey, option)) {
keepOptions[keepKey] = {
label: `${option.hid || "Selected"}: ${option.name}`,
value: option,
};
keepOptionsUpdate.value++;
}
return option;
}
/**
* Normalize an array of uploaded options, preferring existing matches.
*/
function normalizeUploadedOptions(options: DataOption[]): DataOption[] {
return options.map(normalizeOption);
}
/**
* Update currentValue based on the current variant configuration.
* For multiple dataset fields, merges new options. Otherwise, selects the first option.
*/
function updateCurrentValue(options: DataOption[]): void {
const config = currentVariant.value;
if (config?.src === SOURCE.DATASET && config.multiple) {
// Merge new options into existing selection, avoiding duplicates
const merged = currentValue.value ? [...currentValue.value] : [];
for (const option of options) {
if (!containsDataOption(merged, option)) {
merged.push(option);
}
}
currentValue.value = merged;
} else {
// Single selection: use first option
currentValue.value = [options[0]!];
}
}
/**
* Handle data options freshly uploaded through the upload dialog.
* Normalizes options against existing props and updates the current selection.
*/
function handleUploadedDataOptions(uploadedOptions: DataOption[]): void {
if (!uploadedOptions?.length) {
return;
}
const normalized = normalizeUploadedOptions(uploadedOptions);
updateCurrentValue(normalized);
}
function isUnavailableName(name: string | undefined): boolean {
return Boolean(name && name.toLowerCase().startsWith("(unavailable)"));
}
function shouldPreferCanonicalOption(canonical: DataOption, keep: DataOption): boolean {
return (isUnavailableName(keep.name) && !isUnavailableName(canonical.name)) || (!keep.hid && !!canonical.hid);
}
/**
* Check if the new value is already in the keepOptions.
* This doesn't only check if the value is already stored by the `keepKey`, but also if the new value
@@ -495,23 +594,35 @@ function isInKeepOptions(keepKey: string, newValue: DataOption): boolean {
}
/**
* Open file dialog
* Callback handler for the data dialog.
* Routes responses to appropriate handlers based on their type.
*
* @param response - The response from the data dialog
*/
function onDataDialogResponse(response: DialogResponse): void {
// The data dialog's beta upload path returns DataOption[] directly
if (isDataOptionArray(response)) {
handleUploadedDataOptions(response);
return;
}
// Handle responses that require validation and transformation
handleIncoming(response, false);
}
/**
* Open file dialog for data selection or upload.
*/
function onBrowse() {
if (currentVariant.value) {
const library = !!currentVariant.value.library;
const multiple = !!currentVariant.value.multiple;
getGalaxyInstance().data.dialog(
(response: Record<string, unknown>) => {
handleIncoming(response, false);
},
{
allowUpload: true,
format: null,
library,
multiple,
},
);
const options = {
allowUpload: true,
format: null,
library,
multiple,
};
getGalaxyInstance().data.dialog(onDataDialogResponse, options);
}
}
@@ -716,6 +827,10 @@ function isHistoryOrCollectionItem(item: EventData): item is HistoryOrCollection
return isHistoryItem(item) || isDCE(item);
}
function isDataOptionArray(value: unknown): value is DataOption[] {
return Array.isArray(value) && value.every((item) => isDataOption(item as object));
}
/**
* Helper function to handle collection type changes safely
*/
@@ -792,7 +907,10 @@ function onDragLeave(evt: DragEvent) {
function onDrop(e: DragEvent) {
if (dragData.value.length) {
if (handleIncoming(dragData.value, dragData.value.length === 1)) {
// Filter to only valid history/collection items
const filteredItems = dragData.value.filter(isHistoryOrCollectionItem) as HistoryOrCollectionItem[];
const partial = filteredItems.length === 1;
if (handleIncoming(filteredItems, partial)) {
currentHighlighting.value = "success";
if (props.workflowRun) {
workflowTab.value = "view";
@@ -895,10 +1013,13 @@ const noOptionsWarningMessage = computed(() => {
:collection-types="props.collectionTypes"
:current-source="currentSource || undefined"
:is-populated="currentValue && currentValue.length > 0"
:extensions="props.extensions"
:multiple="Boolean(currentVariant?.multiple)"
show-field-options
:show-view-create-options="props.workflowRun && !usingSimpleSelect"
:workflow-tab.sync="workflowTab"
@create-collection-type="handleCollectionTypeChange"
@uploaded-data="handleUploadedDataOptions"
@on-browse="onBrowse"
@set-current-field="(value) => (currentField = value)" />
@@ -946,9 +1067,12 @@ const noOptionsWarningMessage = computed(() => {
:collection-types="props.collectionTypes"
:current-source="currentSource || undefined"
:is-populated="currentValue && currentValue.length > 0"
:extensions="props.extensions"
:multiple="Boolean(currentVariant?.multiple)"
show-view-create-options
:workflow-tab.sync="workflowTab"
@create-collection-type="handleCollectionTypeChange" />
@create-collection-type="handleCollectionTypeChange"
@uploaded-data="handleUploadedDataOptions" />
</div>
<FormDataExtensions
@@ -2,7 +2,7 @@
import { faFolder } from "@fortawesome/free-regular-svg-icons";
import { faEye, faPlus, faSpinner, faTimes, faUpload } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BButton, BButtonGroup, BDropdown, BDropdownItem } from "bootstrap-vue";
import { BBadge, BButton, BButtonGroup, BDropdown, BDropdownItem } from "bootstrap-vue";
import { computed } from "vue";
import type { CollectionType } from "@/api/datasetCollections";
@@ -10,6 +10,8 @@ import {
COLLECTION_TYPE_TO_LABEL,
type CollectionBuilderType,
} from "@/components/Collections/common/buildCollectionModal";
import type { DataOption } from "@/components/Form/Elements/FormData/types";
import { useUploadMethodModal } from "@/composables/upload/useUploadMethodModal";
import localize from "@/utils/localization";
import { capitalizeFirstLetter } from "@/utils/strings";
@@ -29,6 +31,8 @@ const props = defineProps<{
isPopulated?: boolean;
showFieldOptions?: boolean;
showViewCreateOptions?: boolean;
extensions?: string[];
multiple?: boolean;
}>();
const emit = defineEmits<{
@@ -36,8 +40,11 @@ const emit = defineEmits<{
(e: "set-current-field", value: number): void;
(e: "update:workflow-tab", value: string): void;
(e: "create-collection-type", value: CollectionType): void;
(e: "uploaded-data", value: DataOption[]): void;
}>();
const { openUploadModal } = useUploadMethodModal();
const createTitle = computed(() => {
const defaultBuilderType = defaultCollectionBuilderType.value;
return sourceIsCollection.value
@@ -49,6 +56,19 @@ function clickedTab(tab: string) {
emit("update:workflow-tab", props.workflowTab === tab ? "" : tab);
}
async function onUploadBeta() {
const result = await openUploadModal({
formats: props.extensions,
multiple: props.multiple,
hideTips: true,
});
if (!result.cancelled) {
emit("uploaded-data", result.toDataOptions());
emit("update:workflow-tab", "view");
}
}
function createCollectionType(colType: CollectionBuilderType) {
emit("create-collection-type", colType);
emit("update:workflow-tab", "create");
@@ -157,16 +177,27 @@ const defaultCollectionBuilderType = computed<CollectionBuilderType>(() => {
<FontAwesomeIcon :icon="faPlus" />
<span v-localize>Create</span>
</BButton>
<BButton
v-else-if="props.showViewCreateOptions && !sourceIsCollection"
v-b-tooltip.bottom.hover.noninteractive
class="d-flex flex-gapx-1 align-items-center"
data-description="upload"
:title="createTitle"
:pressed="props.workflowTab === 'create'"
@click="clickedTab('create')">
<FontAwesomeIcon :icon="faUpload" />
<span v-localize>Upload</span>
</BButton>
<template v-else-if="props.showViewCreateOptions && !sourceIsCollection">
<BButton
v-b-tooltip.bottom.hover.noninteractive
class="d-flex flex-gapx-1 align-items-center"
data-description="upload"
:title="createTitle"
:pressed="props.workflowTab === 'create'"
@click="clickedTab('create')">
<FontAwesomeIcon :icon="faUpload" />
<span v-localize>Upload</span>
</BButton>
<BButton
v-b-tooltip.bottom.hover.noninteractive
class="d-flex flex-gapx-1 align-items-center"
data-description="upload-beta"
title="Try our new upload experience"
@click="onUploadBeta">
<FontAwesomeIcon :icon="faUpload" />
<span v-localize>Upload</span>
<BBadge variant="warning" class="ml-1">Beta</BBadge>
</BButton>
</template>
</BButtonGroup>
</template>
@@ -120,6 +120,7 @@ watch(
:has-callback="false"
:history-id="currentHistoryId"
:list-db-keys="listDbKeys"
:show-beta-upload="false"
disable-footer
emit-uploaded
size="small"
@@ -0,0 +1,315 @@
<script setup lang="ts">
import { faUpload } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { storeToRefs } from "pinia";
import { computed, ref, watch } from "vue";
import { useTargetHistoryUploadState } from "@/composables/history/useTargetHistoryUploadState";
import { useUploadSubmission } from "@/composables/upload/useUploadSubmission";
import { useHistoryStore } from "@/stores/historyStore";
import { errorMessageAsString } from "@/utils/simple-error";
import type { UploadMethodComponent, UploadMethodConfig } from "./types";
import { useFilteredUploadMethods } from "./uploadMethodRegistry";
import type { UploadedDataset, UploadModalConfig } from "./uploadModalTypes";
import GButton from "@/components/BaseComponents/GButton.vue";
import GModal from "@/components/BaseComponents/GModal.vue";
import GTip from "@/components/BaseComponents/GTip.vue";
import GCard from "@/components/Common/GCard.vue";
import Heading from "@/components/Common/Heading.vue";
interface UploadMethodModalProps {
config: UploadModalConfig;
show: boolean;
hideTips?: boolean;
}
interface UploadMethodModalEmits {
(e: "update:show", show: boolean): void;
(e: "uploaded", datasets: UploadedDataset[]): void;
(e: "cancelled"): void;
}
const props = defineProps<UploadMethodModalProps>();
const emit = defineEmits<UploadMethodModalEmits>();
const historyStore = useHistoryStore();
const { currentHistoryId } = storeToRefs(historyStore);
const { submitPreparedUpload } = useUploadSubmission();
const modalConfig = computed<UploadModalConfig>(() => props.config ?? {});
const availableMethods = useFilteredUploadMethods(modalConfig);
const selectedMethod = ref<UploadMethodConfig | null>(null);
const uploadMethodRef = ref<UploadMethodComponent | null>(null);
const canUpload = ref(false);
const uploading = ref(false);
const uploadProgress = ref(0);
const error = ref<string | null>(null);
const completed = ref(false);
const cancelled = ref(false);
const effectiveHistoryId = computed(() => modalConfig.value.targetHistoryId ?? currentHistoryId.value ?? "");
const targetHistoryName = computed(() => {
if (!effectiveHistoryId.value) {
return null;
}
const name = historyStore.getHistoryNameById(effectiveHistoryId.value);
return name === "..." ? null : name;
});
const title = computed(() => {
if (modalConfig.value.title) {
return modalConfig.value.title;
}
return modalConfig.value.multiple
? "Upload datasets to current history"
: "Upload a single dataset to current history";
});
const { uploadBlockReason } = useTargetHistoryUploadState(effectiveHistoryId);
const canStartUpload = computed(() => {
return Boolean(selectedMethod.value) && !uploading.value && canUpload.value && !uploadBlockReason.value;
});
const startButtonTitle = computed(() => {
if (uploadBlockReason.value) {
return uploadBlockReason.value;
}
if (!canUpload.value) {
return "Configure upload options first";
}
return "Start upload";
});
watch(
() => availableMethods.value,
(methods) => {
if (!methods.length) {
selectedMethod.value = null;
return;
}
if (!selectedMethod.value || !methods.some((method) => method.id === selectedMethod.value?.id)) {
selectedMethod.value = methods[0] ?? null;
}
},
{ immediate: true },
);
watch(
() => props.show,
(newShow) => {
if (newShow) {
completed.value = false;
cancelled.value = false;
uploading.value = false;
uploadProgress.value = 0;
error.value = null;
uploadMethodRef.value?.reset?.();
}
},
);
function selectMethod(method: UploadMethodConfig) {
selectedMethod.value = method;
canUpload.value = false;
error.value = null;
}
function handleReadyStateChange(ready: boolean) {
canUpload.value = ready;
}
function closeModal() {
emit("update:show", false);
}
function handleShowUpdate(value: boolean) {
emit("update:show", value);
}
function handleCancelClick() {
if (uploading.value) {
return;
}
cancelled.value = true;
emit("cancelled");
closeModal();
}
function handleModalClosed() {
if (!completed.value && !cancelled.value) {
emit("cancelled");
}
}
async function handleStartClick() {
if (!canStartUpload.value) {
return;
}
if (!effectiveHistoryId.value) {
error.value = "No target history is available for upload.";
return;
}
if (!selectedMethod.value) {
error.value = "Select an upload method first.";
return;
}
const prepared = uploadMethodRef.value?.prepareUpload();
if (!prepared) {
error.value = "This upload method did not return any upload items.";
return;
}
uploadProgress.value = 0;
uploading.value = true;
error.value = null;
try {
const datasets = await submitPreparedUpload(effectiveHistoryId.value, prepared, (pct) => {
uploadProgress.value = pct;
});
completed.value = true;
emit("uploaded", datasets);
closeModal();
} catch (uploadError) {
error.value = errorMessageAsString(uploadError);
} finally {
uploading.value = false;
}
}
</script>
<template>
<GModal :show="show" size="large" fixed-height footer @update:show="handleShowUpdate" @cancel="handleModalClosed">
<template v-slot:header>
<Heading h2 separator size="lg" class="g-modal-title mb-0">
<FontAwesomeIcon :icon="faUpload" />
{{ title }}
<template v-if="targetHistoryName">
— <strong>{{ targetHistoryName }}</strong>
</template>
</Heading>
</template>
<div class="upload-method-modal h-100 d-flex flex-column">
<div class="upload-method-modal-content d-flex flex-grow-1 overflow-hidden">
<aside class="methods-pane pr-2">
<div class="methods-scroll pr-1">
<GCard
v-for="method in availableMethods"
:key="method.id"
clickable
container-class="mt-1 mb-1"
:title="method.name"
title-size="text"
:description="method.description"
:title-icon="{ icon: method.icon, class: 'text-primary', size: 'lg' }"
:selected="selectedMethod?.id === method.id"
@click="selectMethod(method)" />
</div>
</aside>
<section class="method-pane d-flex flex-column overflow-hidden">
<GTip
v-if="selectedMethod?.tips && !hideTips"
:tips="selectedMethod.tips"
variant="info"
class="mb-2" />
<div v-if="error" class="alert alert-danger py-1 px-2 mb-2">
{{ error }}
</div>
<div v-if="uploading" class="mb-2">
<div class="d-flex justify-content-between small text-muted mb-1">
<span>Uploading</span>
<span>{{ Math.round(uploadProgress) }}%</span>
</div>
<div class="upload-progress-track">
<div class="upload-progress-fill" :style="{ width: `${uploadProgress}%` }"></div>
</div>
</div>
<div class="method-content overflow-auto">
<component
:is="selectedMethod?.component"
v-if="selectedMethod"
ref="uploadMethodRef"
:method="selectedMethod"
:target-history-id="effectiveHistoryId"
:allow-collections="config.allowCollections"
:formats="config.formats"
:multiple="config.multiple"
:transient="true"
@ready="handleReadyStateChange" />
</div>
</section>
</div>
</div>
<template v-slot:footer>
<div class="d-flex justify-content-end gap-2 w-100">
<GButton outline color="grey" :disabled="uploading" @click="handleCancelClick">Cancel</GButton>
<GButton
color="blue"
:disabled="!canStartUpload"
:title="startButtonTitle"
data-test-id="upload-method-modal-start"
@click="handleStartClick">
Start
</GButton>
</div>
</template>
</GModal>
</template>
<style scoped lang="scss">
@import "@/style/scss/theme/blue.scss";
.upload-method-modal-content {
min-height: 0;
}
.methods-pane {
width: 320px;
border-right: $border-default;
}
.methods-scroll {
max-height: 100%;
overflow: auto;
}
.method-pane {
min-width: 0;
flex: 1;
padding-left: 0.5rem;
}
.method-content {
min-height: 0;
flex: 1;
display: flex;
flex-direction: column;
}
.upload-progress-track {
width: 100%;
height: 0.45rem;
border-radius: 999px;
background: $gray-200;
overflow: hidden;
}
.upload-progress-fill {
height: 100%;
background: $brand-primary;
transition: width 0.2s ease;
}
.gap-2 {
gap: 0.5rem;
}
</style>
@@ -4,6 +4,7 @@ import { computed, ref, watch } from "vue";
import { useRouter } from "vue-router/composables";
import { useTargetHistoryUploadState } from "@/composables/history/useTargetHistoryUploadState";
import { useUploadSubmission } from "@/composables/upload/useUploadSubmission";
import { useHistoryStore } from "@/stores/historyStore";
import type { UploadMethod, UploadMethodComponent } from "./types";
@@ -25,6 +26,8 @@ const router = useRouter();
const uploadMethodRef = ref<UploadMethodComponent | null>(null);
const canUpload = ref(false);
const { submitPreparedUpload } = useUploadSubmission();
const historyStore = useHistoryStore();
const { currentHistoryId } = storeToRefs(historyStore);
@@ -75,7 +78,13 @@ function handleStart() {
if (!canStartUpload.value) {
return;
}
uploadMethodRef.value?.startUpload();
const prepared = uploadMethodRef.value?.prepareUpload();
if (!prepared) {
return;
}
// Fire-and-forget: progress is tracked in uploadState, visible in the progress view
void submitPreparedUpload(targetHistoryId.value, prepared);
uploadMethodRef.value?.reset?.();
router.push("/upload/progress");
}
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useUploadQueue } from "@/composables/uploadQueue";
import { useUploadBatchOperations } from "@/composables/upload/useUploadBatchOperations";
import { useUserLocalStorage } from "@/composables/userLocalStorage";
import { getUploadRootBreadcrumb } from "./uploadBreadcrumb";
@@ -12,7 +12,7 @@ import UploadFileRow from "./UploadFileRow.vue";
import GButton from "@/components/BaseComponents/GButton.vue";
import BreadcrumbHeading from "@/components/Common/BreadcrumbHeading.vue";
const uploadQueue = useUploadQueue();
const uploadBatchOperations = useUploadBatchOperations();
const uploadState = useUploadState();
const { orderedUploadItems, batchesWithProgress, standaloneUploads, activeItems, hasCompleted } = uploadState;
@@ -43,17 +43,17 @@ function toggleBatch(batchId: string) {
}
function onClearCompleted() {
uploadQueue.clearCompleted();
uploadBatchOperations.clearCompleted();
cleanupExpandedBatches();
}
function onClearAll() {
uploadQueue.clearAll();
uploadBatchOperations.clearAll();
cleanupExpandedBatches();
}
async function retryBatch(batchId: string) {
await uploadQueue.retryCollectionCreation(batchId);
await uploadBatchOperations.retryCollectionCreation(batchId);
}
const uploadItemCount = computed(() => {
@@ -6,10 +6,15 @@ import { computed, ref, watch } from "vue";
import { useUploadDefaults } from "@/composables/upload/uploadDefaults";
import { useUploadStaging } from "@/composables/upload/useUploadStaging";
import type { ExtensionDetails } from "@/composables/uploadConfigurations";
import { useUploadQueue } from "@/composables/uploadQueue";
import {
buildPreparedUploadWithOptions,
createFileUploadItem,
createPastedUploadItem,
createUrlUploadItem,
} from "@/utils/upload";
import { mapToCompositeFileUpload } from "@/utils/upload/itemMappers";
import type { UploadMethodComponent, UploadMethodConfig } from "../types";
import type { PreparedUpload, UploadMethodComponent, UploadMethodConfig } from "../types";
import type { CompositeFileItem, CompositeSlot } from "../types/uploadItem";
import UploadTableDbKeyCell from "../shared/UploadTableDbKeyCell.vue";
@@ -30,7 +35,6 @@ const emit = defineEmits<{
(e: "ready", ready: boolean): void;
}>();
const uploadQueue = useUploadQueue();
const { compositeExtensions, listDbKeys, configurationsReady, defaultDbKey } = useUploadDefaults();
const compositeItems = ref<CompositeFileItem[]>([]);
@@ -146,20 +150,68 @@ function clearAll() {
clearStaging();
}
function startUpload() {
function prepareUpload(): PreparedUpload | null {
const item = currentItem.value;
if (!item) {
return;
return null;
}
const queueItem = mapToCompositeFileUpload(item, props.targetHistoryId);
uploadQueue.enqueue([queueItem]);
const uploadItem = mapToCompositeFileUpload(item, props.targetHistoryId);
const baseOptions = {
dbkey: uploadItem.dbkey,
ext: uploadItem.extension,
space_to_tab: uploadItem.spaceToTab,
to_posix_lines: uploadItem.toPosixLines,
deferred: false,
};
const apiItems = uploadItem.slots
.filter((slot) => slot.src !== "files" || !!slot.file)
.map((slot) => {
const slotOptions = {
name: slot.slotName,
...baseOptions,
};
if (slot.src === "files") {
return createFileUploadItem(slot.file!, uploadItem.targetHistoryId, {
...slotOptions,
size: slot.fileSize ?? slot.file!.size,
});
}
if (slot.src === "url") {
return createUrlUploadItem(slot.url ?? "", uploadItem.targetHistoryId, {
...slotOptions,
size: slot.fileSize ?? 0,
});
}
return createPastedUploadItem(slot.content ?? "", uploadItem.targetHistoryId, {
...slotOptions,
size: slot.fileSize ?? (slot.content ?? "").length,
});
});
if (apiItems.length === 0) {
return null;
}
return buildPreparedUploadWithOptions([uploadItem], undefined, {
apiItems,
uploadOptions: {
composite: true,
compositeName: uploadItem.name,
},
});
}
function reset() {
compositeItems.value = [];
clearStaging();
}
defineExpose<UploadMethodComponent>({ startUpload });
defineExpose<UploadMethodComponent>({ prepareUpload, reset });
</script>
<template>
@@ -15,17 +15,18 @@ import { getFolderContents, getLibraries, isLibraryFile } from "@/api/libraries"
import type { BreadcrumbItem } from "@/components/Common";
import type { TableField } from "@/components/Common/GTable.types";
import { Model } from "@/components/FilesDialog/model";
import { selectionToArray } from "@/components/FilesDialog/utilities";
import type { SelectionItem } from "@/components/SelectionDialog/selectionTypes";
import { useCollectionCreation } from "@/composables/upload/collectionCreation";
import { useUploadReadyState } from "@/composables/upload/uploadReadyState";
import { useUploadStaging } from "@/composables/upload/useUploadStaging";
import { useUploadQueue } from "@/composables/uploadQueue";
import { useUrlTracker } from "@/composables/urlTracker";
import { errorMessageAsString } from "@/utils/simple-error";
import { buildPreparedUpload } from "@/utils/upload";
import { mapToLibraryDatasetUpload } from "@/utils/upload/itemMappers";
import { bytesToString } from "@/utils/utils";
import type { UploadMethodComponent, UploadMethodConfig } from "../types";
import type { PreparedUpload, UploadMethodComponent, UploadMethodConfig } from "../types";
import type { LibraryDatasetItem } from "../types/uploadItem";
import CollectionCreationConfig from "../CollectionCreationConfig.vue";
@@ -37,23 +38,34 @@ import UtcDate from "@/components/UtcDate.vue";
interface Props {
method: UploadMethodConfig;
/** History ID where uploaded datasets will be added. */
targetHistoryId: string;
/** Allow creating dataset collections from selected library datasets. */
allowCollections?: boolean;
/** When false, restrict selection to a single dataset. */
multiple?: boolean;
/** When true, do not persist staging to the shared store (modal use). */
transient?: boolean;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
allowCollections: true,
multiple: true,
transient: false,
});
const emit = defineEmits<{
(e: "ready", ready: boolean): void;
}>();
const uploadQueue = useUploadQueue();
const tableContainerRef = ref<HTMLElement | null>(null);
const collectionConfigComponent = ref<InstanceType<typeof CollectionCreationConfig> | null>(null);
const libraryDatasetItems = ref<LibraryDatasetItem[]>([]);
const { clear: clearStaging } = useUploadStaging<LibraryDatasetItem>(props.method.id, libraryDatasetItems);
const { clear: clearStaging } = useUploadStaging<LibraryDatasetItem>(props.method.id, libraryDatasetItems, {
disableStore: props.transient,
});
const { collectionState, handleCollectionStateChange, buildCollectionConfig, resetCollection } =
const { buildCollectionConfig, collectionState, handleCollectionStateChange, resetCollection } =
useCollectionCreation(collectionConfigComponent);
let nextId = 1;
@@ -106,7 +118,8 @@ function createLibraryDatasetItem(item: AnyLibraryFolderItem, libraryId: string,
const showBrowser = ref(true);
const selectionModel = ref<Model>(new Model({ multiple: true }));
const isSingleMode = computed(() => props.multiple === false);
const selectionModel = ref<Model>(new Model({ multiple: !isSingleMode.value }));
const selectionCount = ref(0);
// Navigation state
@@ -174,6 +187,10 @@ const hasPagination = computed(() => {
const hasItems = computed(() => libraryDatasetItems.value.length > 0);
const hasSelection = computed(() => selectionCount.value > 0);
const addMoreDatasetsTitle = computed(() =>
isSingleMode.value ? "Change selected dataset" : "Add more datasets to the upload list",
);
const addMoreDatasetsLabel = computed(() => (isSingleMode.value ? "Change selected dataset" : "Add More Datasets"));
const searchTitle = computed(() => {
if (!currentLibrary.value) {
@@ -420,7 +437,12 @@ async function navigateToBreadcrumb(index: number) {
}
function addSelectedDatasets() {
const selectedItems = selectionModel.value.finalize() as SelectionItem[];
let selectedItems = selectionToArray(selectionModel.value.finalize());
if (isSingleMode.value) {
selectedItems = selectedItems.slice(0, 1);
libraryDatasetItems.value = [];
}
// Filter out any items that already exist in libraryDatasetItems
const existingUrls = new Set(libraryDatasetItems.value.map((item) => item.url));
@@ -435,7 +457,7 @@ function addSelectedDatasets() {
}
// Clear selection and switch to table view
selectionModel.value = new Model({ multiple: true });
selectionModel.value = new Model({ multiple: !isSingleMode.value });
selectionCount.value = 0;
showBrowser.value = false;
scrollToBottom();
@@ -460,18 +482,24 @@ function scrollToBottom() {
function removeItem(id: number) {
libraryDatasetItems.value = libraryDatasetItems.value.filter((item) => item.id !== id);
if (libraryDatasetItems.value.length === 0) {
showBrowser.value = true;
resetCollection();
}
}
function clearAll() {
function reset() {
libraryDatasetItems.value = [];
resetCollection();
selectionModel.value = new Model({ multiple: true });
selectionModel.value = new Model({ multiple: !isSingleMode.value });
selectionCount.value = 0;
searchQuery.value = "";
urlTracker.reset();
currentPage.value = 1;
loadLibraries();
showBrowser.value = true;
clearStaging();
}
function clearSearch() {
@@ -482,21 +510,14 @@ function updateSearchQuery(newQuery: string) {
searchQuery.value = newQuery;
}
function startUpload() {
function prepareUpload(): PreparedUpload | null {
if (libraryDatasetItems.value.length === 0) {
return null;
}
const uploads = libraryDatasetItems.value.map((item) => mapToLibraryDatasetUpload(item, props.targetHistoryId));
const collectionConfig = buildCollectionConfig(props.targetHistoryId);
uploadQueue.enqueue(uploads, collectionConfig);
// Reset state
libraryDatasetItems.value = [];
clearStaging();
showBrowser.value = true;
resetCollection();
selectionModel.value = new Model({ multiple: true });
selectionCount.value = 0;
clearSearch();
urlTracker.reset();
return buildPreparedUpload(uploads, buildCollectionConfig(props.targetHistoryId));
}
const libraryFields: TableField[] = [
@@ -694,7 +715,7 @@ onMounted(async () => {
}
});
defineExpose<UploadMethodComponent>({ startUpload });
defineExpose<UploadMethodComponent>({ prepareUpload, reset });
</script>
<template>
@@ -771,7 +792,7 @@ defineExpose<UploadMethodComponent>({ startUpload });
@row-click="onBrowserRowClick">
<template v-slot:head(select)>
<BFormCheckbox
v-if="datasetsOnCurrentPage.length > 0"
v-if="props.multiple !== false && datasetsOnCurrentPage.length > 0"
:checked="allDatasetsSelected"
:indeterminate="someDatasetsSelected"
@change="toggleSelectAll" />
@@ -888,6 +909,7 @@ defineExpose<UploadMethodComponent>({ startUpload });
<!-- Collection Creation Section -->
<CollectionCreationConfig
v-if="props.allowCollections !== false"
ref="collectionConfigComponent"
:files="libraryDatasetItems"
@update:state="handleCollectionStateChange" />
@@ -897,10 +919,10 @@ defineExpose<UploadMethodComponent>({ startUpload });
color="grey"
tooltip
tooltip-placement="top"
title="Add more datasets to the upload list"
:title="addMoreDatasetsTitle"
@click="showFolderBrowser">
<FontAwesomeIcon :icon="faPlus" class="mr-1" />
Add More Datasets
{{ addMoreDatasetsLabel }}
</GButton>
<GButton
outline
@@ -908,7 +930,7 @@ defineExpose<UploadMethodComponent>({ startUpload });
tooltip
tooltip-placement="top"
title="Remove all datasets from the upload list"
@click="clearAll">
@click="reset">
Clear All
</GButton>
</div>
@@ -12,11 +12,11 @@ import { useUploadDefaults } from "@/composables/upload/uploadDefaults";
import { useUploadItemValidation } from "@/composables/upload/uploadItemValidation";
import { useUploadReadyState } from "@/composables/upload/uploadReadyState";
import { useUploadStaging } from "@/composables/upload/useUploadStaging";
import { useUploadQueue } from "@/composables/uploadQueue";
import { buildPreparedUpload } from "@/utils/upload";
import { mapToLocalFileUpload } from "@/utils/upload/itemMappers";
import { bytesToString } from "@/utils/utils";
import type { UploadMethodComponent, UploadMethodConfig } from "../types";
import type { PreparedUpload, UploadMethodComponent, UploadMethodConfig } from "../types";
import type { LocalFileItem } from "../types/uploadItem";
import CollectionCreationConfig from "../CollectionCreationConfig.vue";
@@ -32,10 +32,24 @@ import GTable from "@/components/Common/GTable.vue";
interface Props {
method: UploadMethodConfig;
/** History ID where uploaded datasets will be added. */
targetHistoryId: string;
/** Allow creating dataset collections from selected files. */
allowCollections?: boolean;
/** Optional list of allowed formats to constrain selectable extensions. */
formats?: string[];
/** When false, restrict selection to a single file. */
multiple?: boolean;
/** When true, do not persist staging to the shared store (modal use). */
transient?: boolean;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
allowCollections: true,
formats: undefined,
multiple: true,
transient: false,
});
const emit = defineEmits<{
(e: "ready", ready: boolean): void;
@@ -43,20 +57,21 @@ const emit = defineEmits<{
const { advancedMode } = useUploadAdvancedMode();
const uploadQueue = useUploadQueue();
const { effectiveExtensions, listDbKeys, configurationsReady, createItemDefaults } = useUploadDefaults();
const { effectiveExtensions, listDbKeys, configurationsReady, createItemDefaults } = useUploadDefaults(props.formats);
const selectedFiles = ref<LocalFileItem[]>([]);
const { clear: clearStaging } = useUploadStaging<LocalFileItem>(props.method.id, selectedFiles);
const { clear: clearStaging } = useUploadStaging<LocalFileItem>(props.method.id, selectedFiles, {
disableStore: props.transient,
});
const uploadFile = ref<HTMLInputElement | null>(null);
const dropZoneElement = ref<HTMLElement | null>(null);
const collectionConfigComponent = ref<InstanceType<typeof CollectionCreationConfig> | null>(null);
const { collectionState, handleCollectionStateChange, buildCollectionConfig, resetCollection } =
const { buildCollectionConfig, collectionState, handleCollectionStateChange, resetCollection } =
useCollectionCreation(collectionConfigComponent);
const hasFiles = computed(() => selectedFiles.value.length > 0);
const canAddMoreFiles = computed(() => props.multiple !== false || selectedFiles.value.length === 0);
const totalSize = computed(() => {
const bytes = selectedFiles.value.reduce((sum, item) => sum + item.file.size, 0);
return bytesToString(bytes);
@@ -141,7 +156,14 @@ function addFiles(files: FileList | File[] | null) {
const fileArray = Array.from(files);
const defaults = createItemDefaults();
for (const file of fileArray) {
// Enforce single file limit if multiple is false
const filesToAdd = props.multiple === false ? fileArray.slice(0, 1) : fileArray;
for (const file of filesToAdd) {
// If multiple is false, replace existing files
if (props.multiple === false) {
selectedFiles.value = [];
}
selectedFiles.value.push({
file,
name: file.name,
@@ -166,22 +188,22 @@ function handleBrowse() {
uploadFile.value?.click();
}
function clearAll() {
function reset() {
selectedFiles.value = [];
resetCollection();
}
function startUpload() {
const uploads = selectedFiles.value.map((item) => mapToLocalFileUpload(item, props.targetHistoryId));
const collectionConfig = buildCollectionConfig(props.targetHistoryId);
uploadQueue.enqueue(uploads, collectionConfig);
selectedFiles.value = [];
clearStaging();
resetCollection();
}
defineExpose<UploadMethodComponent>({ startUpload });
function prepareUpload(): PreparedUpload | null {
if (selectedFiles.value.length === 0) {
return null;
}
const uploads = selectedFiles.value.map((item) => mapToLocalFileUpload(item, props.targetHistoryId));
return buildPreparedUpload(uploads, buildCollectionConfig(props.targetHistoryId));
}
defineExpose<UploadMethodComponent>({ prepareUpload, reset });
</script>
<template>
@@ -300,6 +322,7 @@ defineExpose<UploadMethodComponent>({ startUpload });
<!-- Collection Creation Section -->
<CollectionCreationConfig
v-if="props.allowCollections !== false"
ref="collectionConfigComponent"
:files="selectedFiles"
@update:state="handleCollectionStateChange" />
@@ -309,7 +332,12 @@ defineExpose<UploadMethodComponent>({ startUpload });
color="grey"
tooltip
tooltip-placement="top"
title="Browse and add more files to the upload list"
:disabled="!canAddMoreFiles"
:title="
canAddMoreFiles
? 'Browse and add more files to the upload list'
: 'Only one file can be selected'
"
@click="handleBrowse">
<FontAwesomeIcon :icon="faLaptop" class="mr-1" />
Add More Files
@@ -320,7 +348,7 @@ defineExpose<UploadMethodComponent>({ startUpload });
tooltip
tooltip-placement="top"
title="Remove all files from the upload list"
@click="clearAll">
@click="reset">
Clear All
</GButton>
</div>
@@ -340,7 +368,7 @@ defineExpose<UploadMethodComponent>({ startUpload });
id="local-file-input"
ref="uploadFile"
type="file"
multiple
:multiple="props.multiple !== false"
class="d-none"
@change="addFileFromInput($event.target)" />
</div>
@@ -11,11 +11,11 @@ import { useUploadDefaults } from "@/composables/upload/uploadDefaults";
import { useUploadItemValidation } from "@/composables/upload/uploadItemValidation";
import { useUploadReadyState } from "@/composables/upload/uploadReadyState";
import { useUploadStaging } from "@/composables/upload/useUploadStaging";
import { useUploadQueue } from "@/composables/uploadQueue";
import { buildPreparedUpload } from "@/utils/upload";
import { mapToPasteContentUpload } from "@/utils/upload/itemMappers";
import { bytesToString } from "@/utils/utils";
import type { UploadMethodComponent, UploadMethodConfig } from "../types";
import type { PreparedUpload, UploadMethodComponent, UploadMethodConfig } from "../types";
import type { PasteContentItem } from "../types/uploadItem";
import CollectionCreationConfig from "../CollectionCreationConfig.vue";
@@ -31,10 +31,24 @@ import GTable from "@/components/Common/GTable.vue";
interface Props {
method: UploadMethodConfig;
/** History ID where uploaded datasets will be added. */
targetHistoryId: string;
/** Allow creating dataset collections from pasted datasets. */
allowCollections?: boolean;
/** Optional list of allowed formats to constrain selectable extensions. */
formats?: string[];
/** When false, restrict to a single pasted dataset. */
multiple?: boolean;
/** When true, do not persist staging to the shared store (modal use). */
transient?: boolean;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
allowCollections: true,
formats: undefined,
multiple: true,
transient: false,
});
const emit = defineEmits<{
(e: "ready", ready: boolean): void;
@@ -42,14 +56,12 @@ const emit = defineEmits<{
const { advancedMode } = useUploadAdvancedMode();
const uploadQueue = useUploadQueue();
const { effectiveExtensions, listDbKeys, configurationsReady, createItemDefaults } = useUploadDefaults();
const { effectiveExtensions, listDbKeys, configurationsReady, createItemDefaults } = useUploadDefaults(props.formats);
const tableContainerRef = ref<HTMLElement | null>(null);
const collectionConfigComponent = ref<InstanceType<typeof CollectionCreationConfig> | null>(null);
const { collectionState, handleCollectionStateChange, buildCollectionConfig, resetCollection } =
const { buildCollectionConfig, collectionState, handleCollectionStateChange, resetCollection } =
useCollectionCreation(collectionConfigComponent);
let nextId = 1;
@@ -66,7 +78,11 @@ function createPasteContentItem(id: number, name: string): PasteContentItem {
const pasteItems = ref<PasteContentItem[]>([createPasteContentItem(nextId++, "Pasted Dataset 1")]);
const expandedItemIds = ref<Set<number>>(new Set());
const rowToggleMap = ref<Map<number, () => void>>(new Map());
const { clear: clearStaging } = useUploadStaging<PasteContentItem>(props.method.id, pasteItems);
const { clear: clearStaging } = useUploadStaging<PasteContentItem>(props.method.id, pasteItems, {
disableStore: props.transient,
});
const isSingleMode = computed(() => props.multiple === false);
const hasItems = computed(() => pasteItems.value.some((item) => item.content.trim().length > 0));
@@ -111,6 +127,16 @@ function removeItem(id: number) {
expandedItemIds.value = nextExpanded;
}
function reset() {
clearStaging();
// Reset to single empty item
const newItemId = nextId++;
rowToggleMap.value = new Map();
expandedItemIds.value = new Set();
pasteItems.value = [createPasteContentItem(newItemId, "Pasted Dataset 1")];
resetCollection();
}
function getItemSize(content: string) {
return bytesToString(new Blob([content]).size);
}
@@ -252,28 +278,17 @@ onMounted(() => {
});
});
function startUpload() {
function prepareUpload(): PreparedUpload | null {
const validItems = pasteItems.value.filter((item) => item.content.trim().length > 0);
if (validItems.length === 0) {
return;
return null;
}
const uploads = validItems.map((item) => mapToPasteContentUpload(item, props.targetHistoryId));
const collectionConfig = buildCollectionConfig(props.targetHistoryId);
uploadQueue.enqueue(uploads, collectionConfig);
// Reset to single empty item
const newItemId = nextId++;
pasteItems.value = [createPasteContentItem(newItemId, "Pasted Dataset 1")];
rowToggleMap.value = new Map();
expandedItemIds.value = new Set();
nextTick(() => expandRow(newItemId));
clearStaging();
resetCollection();
return buildPreparedUpload(uploads, buildCollectionConfig(props.targetHistoryId));
}
defineExpose<UploadMethodComponent>({ startUpload });
defineExpose<UploadMethodComponent>({ prepareUpload, reset });
</script>
<template>
@@ -454,12 +469,14 @@ defineExpose<UploadMethodComponent>({ startUpload });
<!-- Collection Creation Section -->
<CollectionCreationConfig
v-if="props.allowCollections !== false"
ref="collectionConfigComponent"
:files="pasteItems"
@update:state="handleCollectionStateChange" />
<div class="paste-list-actions mt-2">
<GButton
v-if="!isSingleMode"
color="grey"
tooltip
tooltip-placement="top"
@@ -12,11 +12,11 @@ import { useUploadDefaults } from "@/composables/upload/uploadDefaults";
import { useUploadItemValidation } from "@/composables/upload/uploadItemValidation";
import { useUploadReadyState } from "@/composables/upload/uploadReadyState";
import { useUploadStaging } from "@/composables/upload/useUploadStaging";
import { useUploadQueue } from "@/composables/uploadQueue";
import { buildPreparedUpload } from "@/utils/upload";
import { mapToPasteUrlUpload } from "@/utils/upload/itemMappers";
import { extractNameFromUrl, isValidUrl, validateUrl } from "@/utils/url";
import type { UploadMethodComponent, UploadMethodConfig } from "../types";
import type { PreparedUpload, UploadMethodComponent, UploadMethodConfig } from "../types";
import type { PasteUrlItem } from "../types/uploadItem";
import CollectionCreationConfig from "../CollectionCreationConfig.vue";
@@ -32,10 +32,24 @@ import GTable from "@/components/Common/GTable.vue";
interface Props {
method: UploadMethodConfig;
/** History ID where uploaded datasets will be added. */
targetHistoryId: string;
/** Allow creating dataset collections from pasted URLs. */
allowCollections?: boolean;
/** Optional list of allowed formats to constrain selectable extensions. */
formats?: string[];
/** When false, restrict to a single URL entry. */
multiple?: boolean;
/** When true, do not persist staging to the shared store (modal use). */
transient?: boolean;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
allowCollections: true,
formats: undefined,
multiple: true,
transient: false,
});
const emit = defineEmits<{
(e: "ready", ready: boolean): void;
@@ -43,14 +57,12 @@ const emit = defineEmits<{
const { advancedMode } = useUploadAdvancedMode();
const uploadQueue = useUploadQueue();
const { effectiveExtensions, listDbKeys, configurationsReady, createItemDefaults } = useUploadDefaults();
const { effectiveExtensions, listDbKeys, configurationsReady, createItemDefaults } = useUploadDefaults(props.formats);
const tableContainerRef = ref<HTMLElement | null>(null);
const collectionConfigComponent = ref<InstanceType<typeof CollectionCreationConfig> | null>(null);
const { collectionState, handleCollectionStateChange, buildCollectionConfig, resetCollection } =
const { buildCollectionConfig, collectionState, handleCollectionStateChange, resetCollection } =
useCollectionCreation(collectionConfigComponent);
let nextId = 1;
@@ -68,11 +80,18 @@ function createPasteUrlItem(id: number, url: string, name: string): PasteUrlItem
const urlItems = ref<PasteUrlItem[]>([]);
const urlText = ref("");
const showInputArea = ref(true);
const { clear: clearStaging } = useUploadStaging<PasteUrlItem>(props.method.id, urlItems);
const { clear: clearStaging } = useUploadStaging<PasteUrlItem>(props.method.id, urlItems, {
disableStore: props.transient,
});
const placeholder = "https://example.org/data1.txt\nhttps://example.org/data2.txt";
const hasItems = computed(() => urlItems.value.length > 0);
const isSingleMode = computed(() => props.multiple === false);
const addMoreUrlsTitle = computed(() =>
isSingleMode.value ? "Change selected URL" : "Add more URLs to the upload list",
);
const addMoreUrlsLabel = computed(() => (isSingleMode.value ? "Change selected URL" : "Add More URLs"));
const { isNameValid, restoreOriginalName } = useUploadItemValidation();
@@ -103,7 +122,14 @@ function addUrlsFromText() {
.map((u) => u.trim())
.filter((u) => u.length > 0);
for (const url of urls) {
// Enforce single URL when multiple is false
const urlsToAdd = isSingleMode.value ? urls.slice(0, 1) : urls;
if (isSingleMode.value) {
urlItems.value = [];
}
for (const url of urlsToAdd) {
urlItems.value.push(createPasteUrlItem(nextId++, url, extractNameFromUrl(url)));
}
@@ -131,6 +157,11 @@ function scrollToBottom() {
function removeItem(id: number) {
urlItems.value = urlItems.value.filter((item) => item.id !== id);
if (urlItems.value.length === 0) {
showInputArea.value = true;
resetCollection();
}
}
const tableFields: TableField[] = [
@@ -176,33 +207,25 @@ const tableFields: TableField[] = [
},
];
function clearAll() {
function reset() {
urlItems.value = [];
urlText.value = "";
clearStaging();
showInputArea.value = true;
resetCollection();
}
function startUpload() {
function prepareUpload(): PreparedUpload | null {
const validItems = urlItems.value.filter((item) => item.url.trim().length > 0);
if (validItems.length === 0) {
return;
return null;
}
const uploads = validItems.map((item) => mapToPasteUrlUpload(item, props.targetHistoryId));
const collectionConfig = buildCollectionConfig(props.targetHistoryId);
uploadQueue.enqueue(uploads, collectionConfig);
// Reset state
urlItems.value = [];
clearStaging();
urlText.value = "";
showInputArea.value = true;
resetCollection();
return buildPreparedUpload(uploads, buildCollectionConfig(props.targetHistoryId));
}
defineExpose<UploadMethodComponent>({ startUpload });
defineExpose<UploadMethodComponent>({ prepareUpload, reset });
</script>
<template>
@@ -347,19 +370,15 @@ defineExpose<UploadMethodComponent>({ startUpload });
<!-- Collection Creation Section -->
<CollectionCreationConfig
v-if="props.allowCollections !== false"
ref="collectionConfigComponent"
:files="urlItems"
@update:state="handleCollectionStateChange" />
<div class="url-list-actions mt-2">
<GButton
color="grey"
tooltip
tooltip-placement="top"
title="Add more URLs to the upload list"
@click="showUrlInput">
<GButton color="grey" tooltip tooltip-placement="top" :title="addMoreUrlsTitle" @click="showUrlInput">
<FontAwesomeIcon :icon="faPlus" class="mr-1" />
Add More URLs
{{ addMoreUrlsLabel }}
</GButton>
<GButton
outline
@@ -367,7 +386,7 @@ defineExpose<UploadMethodComponent>({ startUpload });
tooltip
tooltip-placement="top"
title="Remove all URLs from the upload list"
@click="clearAll">
@click="reset">
Clear All
</GButton>
</div>
@@ -1,12 +1,17 @@
<script setup lang="ts">
import { faPlus, faTimes } from "@fortawesome/free-solid-svg-icons";
import { faFolder, faGlobe, faPlus, faTimes, faUser } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BFormInput } from "bootstrap-vue";
import { computed, nextTick, ref, watch } from "vue";
import { BAlert, BFormCheckbox, BFormInput, BPagination } from "bootstrap-vue";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router/composables";
import type { RemoteEntry } from "@/api/remoteFiles";
import { browseRemoteFiles, fetchFileSources, type RemoteEntry } from "@/api/remoteFiles";
import type { BreadcrumbItem } from "@/components/Common";
import type { TableField } from "@/components/Common/GTable.types";
import { Model } from "@/components/FilesDialog/model";
import { fileSourcePluginToItem, selectionToArray } from "@/components/FilesDialog/utilities";
import type { SelectionItem } from "@/components/SelectionDialog/selectionTypes";
import { useFileSources } from "@/composables/fileSources";
import { useBulkUploadOperations } from "@/composables/upload/bulkUploadOperations";
import { useCollectionCreation } from "@/composables/upload/collectionCreation";
import { useUploadAdvancedMode } from "@/composables/upload/uploadAdvancedMode";
@@ -14,14 +19,18 @@ import { useUploadDefaults } from "@/composables/upload/uploadDefaults";
import { useUploadItemValidation } from "@/composables/upload/uploadItemValidation";
import { useUploadReadyState } from "@/composables/upload/uploadReadyState";
import { useUploadStaging } from "@/composables/upload/useUploadStaging";
import { useUploadQueue } from "@/composables/uploadQueue";
import { useUrlTracker } from "@/composables/urlTracker";
import { errorMessageAsString } from "@/utils/simple-error";
import { buildPreparedUpload } from "@/utils/upload";
import { mapToRemoteFileUpload } from "@/utils/upload/itemMappers";
import { USER_FILE_PREFIX } from "@/utils/url";
import { bytesToString } from "@/utils/utils";
import type { UploadMethodComponent, UploadMethodConfig } from "../types";
import type { PreparedUpload, UploadMethodComponent, UploadMethodConfig } from "../types";
import type { RemoteFileItem } from "../types/uploadItem";
import CollectionCreationConfig from "../CollectionCreationConfig.vue";
import RemoteEntryMetadata from "../shared/RemoteEntryMetadata.vue";
import UploadTableBulkDbKeyHeader from "../shared/UploadTableBulkDbKeyHeader.vue";
import UploadTableBulkExtensionHeader from "../shared/UploadTableBulkExtensionHeader.vue";
import UploadTableDbKeyCell from "../shared/UploadTableDbKeyCell.vue";
@@ -30,15 +39,30 @@ import UploadTableNameCell from "../shared/UploadTableNameCell.vue";
import UploadTableOptionsCell from "../shared/UploadTableOptionsCell.vue";
import UploadTableOptionsHeader from "../shared/UploadTableOptionsHeader.vue";
import GButton from "@/components/BaseComponents/GButton.vue";
import BreadcrumbNavigation from "@/components/Common/BreadcrumbNavigation.vue";
import GTable from "@/components/Common/GTable.vue";
import RemoteFileBrowserContent from "@/components/FileBrowser/RemoteFileBrowserContent.vue";
import DataDialogSearch from "@/components/SelectionDialog/DataDialogSearch.vue";
interface Props {
method: UploadMethodConfig;
/** History ID where uploaded datasets will be added. */
targetHistoryId: string;
/** Allow creating dataset collections from selected remote files. */
allowCollections?: boolean;
/** Optional list of allowed formats to constrain selectable extensions. */
formats?: string[];
/** When false, restrict selection to a single remote file. */
multiple?: boolean;
/** When true, do not persist staging to the shared store (modal use). */
transient?: boolean;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
allowCollections: true,
formats: undefined,
multiple: true,
transient: false,
});
const emit = defineEmits<{
(e: "ready", ready: boolean): void;
@@ -46,16 +70,19 @@ const emit = defineEmits<{
const { advancedMode } = useUploadAdvancedMode();
const uploadQueue = useUploadQueue();
const router = useRouter();
const filesSources = useFileSources();
const { effectiveExtensions, listDbKeys, configurationsReady, createItemDefaults } = useUploadDefaults();
const { effectiveExtensions, listDbKeys, configurationsReady, createItemDefaults } = useUploadDefaults(props.formats);
const tableContainerRef = ref<HTMLElement | null>(null);
const collectionConfigComponent = ref<InstanceType<typeof CollectionCreationConfig> | null>(null);
const remoteFileItems = ref<RemoteFileItem[]>([]);
const { clear: clearStaging } = useUploadStaging<RemoteFileItem>(props.method.id, remoteFileItems);
const { clear: clearStaging } = useUploadStaging<RemoteFileItem>(props.method.id, remoteFileItems, {
disableStore: props.transient,
});
const { collectionState, handleCollectionStateChange, buildCollectionConfig, resetCollection } =
const { buildCollectionConfig, collectionState, handleCollectionStateChange, resetCollection } =
useCollectionCreation(collectionConfigComponent);
let nextId = 1;
@@ -75,10 +102,86 @@ function createRemoteFileItem(id: number, selectionItem: SelectionItem): RemoteF
};
}
const browserRef = ref<InstanceType<typeof RemoteFileBrowserContent> | null>(null);
const isSingleMode = computed(() => props.multiple === false);
const showBrowser = ref(true);
const selectionModel = ref<Model>(new Model({ multiple: !isSingleMode.value }));
const selectionCount = ref(0);
const urlTracker = useUrlTracker<SelectionItem>();
const browserItems = ref<SelectionItem[]>([]);
const allFetchedItems = ref<SelectionItem[]>([]); // Store all items for client-side pagination
const searchQuery = ref("");
const isBusy = ref(false);
const errorMessage = ref<string>();
const currentPage = ref(1);
const perPage = ref(25);
const totalMatches = ref(0);
/**
* Sequence id to track latest load request. Incremented on each navigation/load.
*/
const loadSequenceId = ref(0);
async function executeIfLatest<T>(
operation: () => Promise<T>,
onSuccess: (data: T) => void,
onError?: (error: unknown) => void,
): Promise<void> {
const seq = loadSequenceId.value;
isBusy.value = true;
errorMessage.value = undefined;
try {
const result = await operation();
// Ignore if a newer load started while we were fetching
if (seq === loadSequenceId.value) {
onSuccess(result);
}
} catch (error) {
if (seq === loadSequenceId.value) {
if (onError) {
onError(error);
} else {
errorMessage.value = errorMessageAsString(error);
}
}
} finally {
if (seq === loadSequenceId.value) {
isBusy.value = false;
}
}
}
const currentFileSourceUri = computed(() => urlTracker.current.value?.url);
const supportsServerPagination = computed(() => filesSources.supportsPagination(currentFileSourceUri.value));
const supportsServerSearch = computed(() => filesSources.supportsSearch(currentFileSourceUri.value));
const hasPagination = computed(() => {
if (urlTracker.isAtRoot.value || isBusy.value) {
return false;
}
const itemCount = supportsServerPagination.value ? totalMatches.value : allFetchedItems.value.length;
return itemCount > perPage.value;
});
const hasItems = computed(() => remoteFileItems.value.length > 0);
const hasSelection = computed(() => selectionCount.value > 0);
const addMoreFilesTitle = computed(() =>
isSingleMode.value ? "Change selected file" : "Add more remote files to the upload list",
);
const addMoreFilesLabel = computed(() => (isSingleMode.value ? "Change selected file" : "Add More Files"));
const breadcrumbs = computed(() => {
const crumbs: BreadcrumbItem[] = [{ title: "Sources", index: -1 }];
urlTracker.navigationHistory.value.forEach((item, index) => {
crumbs.push({ title: item.label, index });
});
return crumbs;
});
const { isNameValid, restoreOriginalName } = useUploadItemValidation();
@@ -88,18 +191,231 @@ const { isReadyToUpload } = useUploadReadyState(hasItems, collectionState);
watch(isReadyToUpload, (ready) => emit("ready", ready), { immediate: true });
/**
* Called when the user confirms a file selection in the browser.
* Adds newly selected files to the upload list (deduplicating by URL) and switches to the list view.
*/
function onFilesConfirmed(items: SelectionItem[]) {
const existingUrls = new Set(remoteFileItems.value.map((item) => item.url));
const newItems = items.filter((item) => !existingUrls.has(item.url));
const filesOnCurrentPage = computed(() => browserItems.value.filter((item) => item.isLeaf));
const allFilesSelected = computed(() => {
selectionCount.value;
const files = filesOnCurrentPage.value;
return files.length > 0 && files.every((file) => isSelected(file));
});
const someFilesSelected = computed(() => {
selectionCount.value;
const files = filesOnCurrentPage.value;
const selectedCount = files.filter((file) => isSelected(file)).length;
return selectedCount > 0 && selectedCount < files.length;
});
function toggleSelectAll() {
const files = filesOnCurrentPage.value;
const allSelected = files.every((file) => isSelected(file));
files.forEach((file) => {
const needsToggle = allSelected ? isSelected(file) : !isSelected(file);
if (needsToggle) {
selectionModel.value.add(file);
}
});
selectionCount.value = selectionModel.value.count();
}
function entryToSelectionItem(entry: RemoteEntry): SelectionItem {
return {
id: entry.uri,
label: entry.name,
url: entry.uri,
isLeaf: entry.class === "File",
details: "No details available",
entry: entry,
};
}
/**
* Comparator for sorting file sources.
* User-created sources come first, then alphabetical by label.
*/
function sortFileSources(a: SelectionItem, b: SelectionItem): number {
const aIsUser = a.url.startsWith(USER_FILE_PREFIX);
const bIsUser = b.url.startsWith(USER_FILE_PREFIX);
if (aIsUser && !bIsUser) {
return -1;
}
if (!aIsUser && bIsUser) {
return 1;
}
return a.label.localeCompare(b.label);
}
/**
* Apply client-side search filter to items
*/
function filterItemsBySearch(items: SelectionItem[]): SelectionItem[] {
if (!searchQuery.value) {
return items;
}
const query = searchQuery.value.toLowerCase();
return items.filter((item) => item.label.toLowerCase().includes(query));
}
/**
* Apply client-side pagination to items
*/
function paginateItems(items: SelectionItem[]): SelectionItem[] {
const start = (currentPage.value - 1) * perPage.value;
const end = start + perPage.value;
return items.slice(start, end);
}
/**
* Update browser items with client-side filtering and pagination
*/
function updateClientSideView() {
const filteredItems = filterItemsBySearch(allFetchedItems.value);
browserItems.value = paginateItems(filteredItems);
totalMatches.value = filteredItems.length;
}
async function loadFileSources() {
await executeIfLatest(
() => fetchFileSources(),
(sources) => {
let items = sources.map(fileSourcePluginToItem);
items = items.sort(sortFileSources);
// Apply search filter if present
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
items = items.filter((item) => item.label.toLowerCase().includes(query));
}
browserItems.value = items;
},
);
}
async function loadDirectory(uri: string) {
const hasServerPagination = supportsServerPagination.value;
const hasServerSearch = supportsServerSearch.value;
// Only pass pagination/search params if the file source supports them
const limit = hasServerPagination ? perPage.value : undefined;
const offset = hasServerPagination ? (currentPage.value - 1) * perPage.value : undefined;
const query = hasServerSearch ? searchQuery.value || undefined : undefined;
await executeIfLatest(
() => browseRemoteFiles(uri, false, false, limit, offset, query),
(result) => {
const allEntries = result.entries.map(entryToSelectionItem);
if (hasServerPagination) {
// Server handles pagination and search
browserItems.value = allEntries;
totalMatches.value = result.totalMatches;
} else {
// Client-side filtering and pagination
allFetchedItems.value = allEntries;
updateClientSideView();
}
},
(error) => {
browserItems.value = [];
allFetchedItems.value = [];
totalMatches.value = 0;
errorMessage.value = errorMessageAsString(error);
},
);
}
async function load() {
// Bump sequence id so any in-flight requests are considered stale
loadSequenceId.value += 1;
if (urlTracker.isAtRoot.value) {
allFetchedItems.value = [];
await loadFileSources();
} else if (urlTracker.current.value) {
await loadDirectory(urlTracker.current.value.url);
}
}
function onItemClick(item: SelectionItem) {
// Don't allow navigation or selection while loading or if there's an error
if (isBusy.value || errorMessage.value) {
return;
}
if (!item.isLeaf) {
// Navigate into directory
open(item);
} else {
// Toggle file selection
selectionModel.value.add(item);
selectionCount.value = selectionModel.value.count();
}
}
function onRowClick(event: { item: SelectionItem }) {
onItemClick(event.item);
}
function open(item: SelectionItem) {
urlTracker.forward(item);
currentPage.value = 1;
clearSearch();
load();
}
function navigateToBreadcrumb(index: number) {
// Navigate to a specific breadcrumb by resetting navigation to that point
if (index === -1) {
// Navigate to root
urlTracker.reset();
} else {
// Navigate to specific index in history
const targetDepth = index + 1;
const currentDepth = urlTracker.navigationHistory.value.length;
const stepsBack = currentDepth - targetDepth;
for (let i = 0; i < stepsBack; i++) {
urlTracker.backward();
}
}
currentPage.value = 1;
clearSearch();
load();
}
function toggleFileSelection(item: SelectionItem) {
if (item.isLeaf) {
selectionModel.value.add(item);
selectionCount.value = selectionModel.value.count();
}
}
function isSelected(item: SelectionItem): boolean {
return selectionModel.value.exists(item.id);
}
function addSelectedFiles() {
let selectedItems = selectionToArray(selectionModel.value.finalize());
if (isSingleMode.value) {
selectedItems = selectedItems.slice(0, 1);
remoteFileItems.value = [];
}
// Filter out any items that already exist in remoteFileItems
const existingUrls = new Set(remoteFileItems.value.map((item) => item.url));
const newItems = selectedItems.filter((item) => !existingUrls.has(item.url));
// Add new items
for (const item of newItems) {
remoteFileItems.value.push(createRemoteFileItem(nextId++, item));
}
// Clear selection and switch to table view
selectionModel.value = new Model({ multiple: !isSingleMode.value });
selectionCount.value = 0;
showBrowser.value = false;
scrollToBottom();
}
@@ -123,8 +439,46 @@ function scrollToBottom() {
function removeItem(id: number) {
remoteFileItems.value = remoteFileItems.value.filter((item) => item.id !== id);
if (remoteFileItems.value.length === 0) {
showBrowser.value = true;
resetCollection();
}
}
function createNewFileSource() {
router.push("/file_source_instances/create");
}
// Browser table fields
const browserFields: TableField[] = [
{
key: "select",
label: "",
width: "40px",
align: "center",
},
{
key: "user",
label: "",
sortable: false,
width: "35px",
align: "center",
},
{
key: "name",
label: "Name",
sortable: false,
align: "center",
},
{
key: "details",
label: "Details",
sortable: false,
align: "left",
},
];
// File list table fields
const tableFields: TableField[] = [
{
@@ -179,44 +533,226 @@ const tableFields: TableField[] = [
},
];
function clearAll() {
function reset() {
remoteFileItems.value = [];
resetCollection();
browserRef.value?.reset();
selectionModel.value = new Model({ multiple: !isSingleMode.value });
selectionCount.value = 0;
allFetchedItems.value = [];
clearSearch();
urlTracker.reset();
currentPage.value = 1;
load();
showBrowser.value = true;
}
function startUpload() {
const uploads = remoteFileItems.value.map((item) => mapToRemoteFileUpload(item, props.targetHistoryId));
const collectionConfig = buildCollectionConfig(props.targetHistoryId);
uploadQueue.enqueue(uploads, collectionConfig);
remoteFileItems.value = [];
clearStaging();
resetCollection();
browserRef.value?.reset();
showBrowser.value = true;
}
defineExpose<UploadMethodComponent>({ startUpload });
function clearSearch() {
searchQuery.value = "";
}
function updateSearchQuery(newQuery: string) {
searchQuery.value = newQuery;
}
function prepareUpload(): PreparedUpload | null {
if (remoteFileItems.value.length === 0) {
return null;
}
const uploads = remoteFileItems.value.map((item) => mapToRemoteFileUpload(item, props.targetHistoryId));
return buildPreparedUpload(uploads, buildCollectionConfig(props.targetHistoryId));
}
onMounted(() => {
load();
});
watch(searchQuery, () => {
currentPage.value = 1;
if (urlTracker.isAtRoot.value || supportsServerSearch.value) {
load();
} else if (allFetchedItems.value.length > 0) {
updateClientSideView();
}
});
watch(currentPage, () => {
if (urlTracker.isAtRoot.value) {
return;
}
if (supportsServerPagination.value) {
load();
} else if (allFetchedItems.value.length > 0) {
updateClientSideView();
}
});
function onErrorRetry() {
errorMessage.value = undefined;
currentPage.value = 1;
allFetchedItems.value = [];
clearSearch();
load();
}
function getItemEntry(item: SelectionItem): RemoteEntry {
return item.entry as RemoteEntry;
}
defineExpose<UploadMethodComponent>({ prepareUpload, reset });
</script>
<template>
<div class="remote-files-upload">
<!-- File Browser Phase -->
<div v-if="showBrowser" class="file-browser">
<div v-if="hasItems" class="browser-staged-notice mb-2">
<GButton size="small" color="grey" outline @click="showFileList">
View {{ remoteFileItems.length }} staged file(s)
<!-- Navigation breadcrumb -->
<div class="browser-header mb-2">
<BreadcrumbNavigation
v-if="!urlTracker.isAtRoot.value"
:items="breadcrumbs"
@navigate="navigateToBreadcrumb" />
<div v-else>
<span class="font-weight-bold">Browse a File Source below</span> or
<GButton color="grey" size="small" @click="createNewFileSource">
<FontAwesomeIcon :icon="faPlus" class="mr-1" />
Connect New Remote Source
</GButton>
</div>
</div>
<!-- Search bar -->
<div class="search-bar-container mb-2">
<DataDialogSearch
:value="searchQuery"
:title="urlTracker.isAtRoot.value ? 'file sources' : 'files and folders'"
@input="updateSearchQuery" />
</div>
<!-- Error message -->
<BAlert v-if="errorMessage" variant="danger" show dismissible @dismissed="errorMessage = undefined">
<div class="d-flex justify-content-between align-items-center">
<span>{{ errorMessage }}</span>
<GButton color="red" class="ml-2" @click="onErrorRetry"> Retry </GButton>
</div>
</BAlert>
<!-- Browser table -->
<div class="browser-table-container">
<GTable
v-if="!errorMessage && (browserItems.length > 0 || isBusy)"
:items="browserItems"
:fields="browserFields"
:overlay-loading="isBusy"
hover
striped
clickable-rows
compact
fixed
class="browser-table"
@row-click="onRowClick">
<!-- Select column header (select all) -->
<template v-slot:head(select)>
<BFormCheckbox
v-if="
props.multiple !== false && !urlTracker.isAtRoot.value && filesOnCurrentPage.length > 0
"
:checked="allFilesSelected"
:indeterminate="someFilesSelected"
@change="toggleSelectAll"
@click.stop />
</template>
<!-- Select column (only for files) -->
<template v-slot:cell(select)="{ item }">
<BFormCheckbox
v-if="item.isLeaf"
:checked="isSelected(item)"
@change="toggleFileSelection(item)"
@click.stop />
</template>
<!-- Icon column (for highlighting user-created file sources) -->
<template v-slot:cell(user)="{ item }">
<span
v-if="urlTracker.isAtRoot.value && !item.isLeaf && item.url.startsWith(USER_FILE_PREFIX)"
v-b-tooltip.hover.noninteractive
title="You created this file source">
<FontAwesomeIcon :icon="faUser" class="text-primary" fixed-width />
</span>
<span
v-else-if="urlTracker.isAtRoot.value && !item.isLeaf"
v-b-tooltip.hover.noninteractive
title="This file source was created by an administrator and is globally available">
<FontAwesomeIcon :icon="faGlobe" class="text-primary" fixed-width />
</span>
</template>
<!-- Name column with icons -->
<template v-slot:cell(name)="{ item }">
<div class="d-flex align-items-center" :class="{ 'cursor-pointer': !item.isLeaf }">
<FontAwesomeIcon
v-if="!item.isLeaf"
:icon="faFolder"
class="mr-2 text-warning"
fixed-width />
<span>{{ item.label }}</span>
</div>
</template>
<!-- Details column -->
<template v-slot:cell(details)="{ item }">
<RemoteEntryMetadata v-if="item.isLeaf && getItemEntry(item)" :entry="getItemEntry(item)" />
<span v-else-if="urlTracker.isAtRoot && item.details">
{{ item.details }}
</span>
</template>
<!-- Loading slot -->
</GTable>
<!-- Empty state message when no items are available -->
<div v-else-if="!errorMessage && !isBusy" class="text-center text-muted my-5">
<p v-if="searchQuery" class="lead">
No {{ urlTracker.isAtRoot.value ? "file sources" : "files or folders" }} match your search "{{
searchQuery
}}"
</p>
<p v-else-if="urlTracker.isAtRoot.value" class="lead">
No file sources available. Connect a new remote source to get started.
</p>
<p v-else class="lead">This directory is empty.</p>
</div>
</div>
<!-- Pagination -->
<div v-if="hasPagination" class="mt-2">
<BPagination
v-model="currentPage"
:total-rows="totalMatches"
:per-page="perPage"
align="center"
size="sm" />
</div>
<!-- Action buttons -->
<div class="browser-actions mt-3">
<GButton v-if="hasItems && !hasSelection" color="grey" outline @click="showFileList">
View Selected Files ({{ remoteFileItems.length }})
</GButton>
<GButton
v-if="!urlTracker.isAtRoot.value"
color="blue"
:disabled="!hasSelection"
class="ml-auto"
@click="addSelectedFiles">
<FontAwesomeIcon :icon="faPlus" class="mr-1" />
Add Selected Files ({{ selectionCount }})
</GButton>
</div>
<RemoteFileBrowserContent
ref="browserRef"
:multiple="true"
mode="file"
ok-text="Add Selected Files"
@confirm="onFilesConfirmed" />
</div>
<!-- File List Phase -->
@@ -318,22 +854,20 @@ defineExpose<UploadMethodComponent>({ startUpload });
<!-- Actions column -->
<template v-slot:cell(actions)="{ item }">
<GButton
<button
v-b-tooltip.hover.noninteractive
class="remove-btn"
color="red"
outline
transparent
class="btn btn-link text-danger remove-btn"
title="Remove file from list"
@click="removeItem(item.id)">
<FontAwesomeIcon :icon="faTimes" />
</GButton>
</button>
</template>
</GTable>
</div>
<!-- Collection Creation Section -->
<CollectionCreationConfig
v-if="props.allowCollections !== false"
ref="collectionConfigComponent"
:files="remoteFileItems"
@update:state="handleCollectionStateChange" />
@@ -343,10 +877,10 @@ defineExpose<UploadMethodComponent>({ startUpload });
color="grey"
tooltip
tooltip-placement="top"
title="Add more remote files to the upload list"
:title="addMoreFilesTitle"
@click="showFileBrowser">
<FontAwesomeIcon :icon="faPlus" class="mr-1" />
Add More Files
{{ addMoreFilesLabel }}
</GButton>
<GButton
outline
@@ -354,7 +888,7 @@ defineExpose<UploadMethodComponent>({ startUpload });
tooltip
tooltip-placement="top"
title="Remove all files from the upload list"
@click="clearAll">
@click="reset">
Clear All
</GButton>
</div>
@@ -379,11 +913,43 @@ defineExpose<UploadMethodComponent>({ startUpload });
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.browser-staged-notice {
flex-shrink: 0;
.browser-header {
flex-shrink: 0;
@include upload-list-header;
}
.search-bar-container {
flex-shrink: 0;
}
.browser-table-container {
flex: 1;
overflow: auto;
min-height: 0;
:deep(.browser-table thead) {
@include upload-table-header;
}
:deep(tbody tr) {
cursor: pointer;
&:hover {
background-color: rgba($brand-primary, 0.05);
}
}
}
.browser-actions {
flex-shrink: 0;
display: flex;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid $border-color;
}
.file-list {
width: 100%;
height: 100%;
+37 -1
View File
@@ -1,5 +1,9 @@
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import type { UploadCollectionConfig } from "@/composables/upload/collectionTypes";
import type { NewUploadItem } from "@/composables/upload/uploadItemTypes";
import type { ApiUploadItem } from "@/utils/upload";
export type UploadMethod =
| "local-file"
| "paste-content"
@@ -100,6 +104,38 @@ export interface UploadMethodConfig {
tips?: string[];
}
/**
* Upload data prepared by an upload method component, ready for API submission.
* Returned by `UploadMethodComponent.prepareUpload()` and consumed by `useUploadSubmission`.
*/
export interface PreparedUpload {
/** Items formatted for the Galaxy upload API. */
apiItems: ApiUploadItem[];
/** Optional dataset collection configuration for atomic collection creation. */
collectionConfig?: UploadCollectionConfig;
/** Items used for progress tracking in the upload state store. */
uploadItems?: NewUploadItem[];
/** Optional upload behavior flags forwarded to uploadDatasets. */
uploadOptions?: {
/** Treat multiple API items as one composite dataset. */
composite?: boolean;
/** Display name for the composite dataset. */
compositeName?: string;
};
}
/**
* Interface that upload method components must implement to be used in the upload panel.
*/
export interface UploadMethodComponent {
startUpload: () => void;
/**
* Returns prepared upload data for API submission, or null when there is
* nothing to upload (e.g. no files selected or no content entered).
*/
prepareUpload: () => PreparedUpload | null;
/**
* Clears transient UI state without remounting the component.
* Called by the modal when it reopens so stale input is not carried over.
*/
reset?: () => void;
}
@@ -11,11 +11,13 @@ import {
faSitemap,
faTable,
} from "@fortawesome/free-solid-svg-icons";
import { computed, type ComputedRef, defineAsyncComponent } from "vue";
import { computed, type ComputedRef, defineAsyncComponent, type Ref } from "vue";
import { useConfig } from "@/composables/config";
import { useUploadAdvancedMode } from "@/composables/upload/uploadAdvancedMode";
import type { UploadMethod, UploadMethodConfig } from "./types";
import type { UploadModalConfig } from "./uploadModalTypes";
export const uploadMethodRegistry: Record<UploadMethod, UploadMethodConfig> = {
"local-file": {
@@ -203,3 +205,27 @@ export function useAllUploadMethods(): ComputedRef<UploadMethodConfig[]> {
Object.values(uploadMethodRegistry).filter((method) => !method.requiresAdvancedMode || advancedMode.value),
);
}
/**
* Reactive list of upload methods filtered by modal config and Galaxy config requirements.
*/
export function useFilteredUploadMethods(config: Ref<UploadModalConfig>): ComputedRef<UploadMethodConfig[]> {
const allMethods = useAllUploadMethods();
const { config: galaxyConfig, isConfigLoaded } = useConfig();
return computed(() => {
const allowedMethods = config.value.allowedMethods;
return allMethods.value.filter((method) => {
if (allowedMethods && !allowedMethods.some((allowedMethod) => allowedMethod === method.id)) {
return false;
}
if (!isConfigLoaded.value || !method.requiresConfig) {
return true;
}
return method.requiresConfig.every((configKey) => Boolean(galaxyConfig.value[configKey]));
});
});
}
@@ -0,0 +1,57 @@
import type { DataOption } from "@/components/Form/Elements/FormData/types";
import type { UploadedDataset } from "@/composables/upload/uploadItemTypes";
import type { UploadMethod } from "./types";
export type { UploadedDataset } from "@/composables/upload/uploadItemTypes";
/**
* Subset of UploadMethod that directly creates new datasets in the history.
* These are the basic upload methods available in the upload modal.
*/
export type DatasetUploadMethod = Extract<
UploadMethod,
"local-file" | "paste-content" | "paste-links" | "remote-files" | "data-library"
>;
/**
* Configuration options for the upload modal.
*/
export interface UploadModalConfig {
/** List of upload methods that users can select from. */
allowedMethods?: DatasetUploadMethod[];
/** Whether to allow creating dataset collections. */
allowCollections?: boolean;
/** Allowed file format extensions. */
formats?: string[];
/** Whether multiple files can be uploaded at once. */
multiple?: boolean;
/** Target history ID where datasets will be uploaded. */
targetHistoryId?: string;
/** Custom title for the upload modal. */
title?: string;
/** Whether to hide user tips/help text. */
hideTips?: boolean;
}
/**
* Result returned when the upload modal is closed.
*/
export interface UploadModalResult {
/** List of datasets that were uploaded. */
datasets: UploadedDataset[];
/** Whether the user cancelled the upload operation. */
cancelled: boolean;
/** Function to convert uploaded datasets to form data options. */
toDataOptions: () => DataOption[];
}
/**
* Promise resolvers for handling the upload modal result.
*/
export interface UploadModalResolvers {
/** Resolve the promise with the upload result. */
resolve: (result: UploadModalResult) => void;
/** Reject the promise if the upload fails. */
reject: (reason?: unknown) => void;
}
@@ -1,8 +1,8 @@
import { suppressExpectedErrorMessages } from "@tests/vitest/helpers";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { UploadCollectionConfig } from "@/composables/upload/collectionTypes";
import type { NewUploadItem } from "@/composables/upload/uploadItemTypes";
import type { CollectionConfig } from "@/composables/uploadQueue";
import { useUploadState } from "./uploadState";
@@ -24,7 +24,7 @@ function makePastedItem(name = "file.txt", content = "hello world"): NewUploadIt
};
}
const BATCH_CONFIG: CollectionConfig = {
const BATCH_CONFIG: UploadCollectionConfig = {
name: "My Collection",
type: "list",
hideSourceItems: false,
@@ -46,6 +46,15 @@ export interface UploadFileListItem extends UploadListItemBase {
/** Union of all upload list UI items */
export type UploadListItem = UploadBatchListItem | UploadFileListItem;
/**
* Subset of the upload state API used by upload tracking utilities.
* Provides only the functions needed for tracking and managing upload progress.
*/
export type UploadStateTrackingApi = Pick<
ReturnType<typeof useUploadState>,
"addBatch" | "addUploadItem" | "getBatch" | "setStatus" | "updateProgress" | "setError"
>;
/** Collection batch state tracking */
export interface CollectionBatchState {
/** Unique batch identifier */
@@ -46,6 +46,7 @@ interface Props {
disableFooter?: boolean;
emitUploaded?: boolean;
size?: ComponentSize;
showBetaUpload?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
@@ -54,6 +55,7 @@ const props = withDefaults(defineProps<Props>(), {
lazyLoad: 150,
size: "medium",
isCollection: false,
showBetaUpload: true,
});
const emit = defineEmits<{
@@ -495,6 +497,7 @@ defineExpose({
}">
<div class="d-flex">
<GButton
v-if="props.showBetaUpload"
id="btn-beta-upload"
size="small"
title="Try our new upload experience"
@@ -5,9 +5,7 @@
import { type Ref, ref } from "vue";
import type { CollectionConfig } from "@/composables/uploadQueue";
import type { CollectionCreationState } from "./collectionTypes";
import type { CollectionCreationState, UploadCollectionConfig } from "./collectionTypes";
export function useCollectionCreation(collectionConfigComponent?: Ref<{ reset: () => void } | null>) {
const collectionState = ref<CollectionCreationState>({
@@ -27,9 +25,9 @@ export function useCollectionCreation(collectionConfigComponent?: Ref<{ reset: (
}
/**
* Builds a CollectionConfig for the upload queue if collection creation is active
* Builds an UploadCollectionConfig when collection creation is active.
*/
function buildCollectionConfig(targetHistoryId: string): CollectionConfig | undefined {
function buildCollectionConfig(targetHistoryId: string): UploadCollectionConfig | undefined {
if (!collectionState.value.config) {
return undefined;
}
@@ -0,0 +1,92 @@
import type { CollectionElementIdentifiers } from "@/api";
import {
COMMON_FILTERS,
DEFAULT_FILTER,
guessInitialFilterType,
guessNameForPair,
} from "@/components/Collections/pairing";
import type { SupportedCollectionType } from "./collectionTypes";
interface CollectionUploadItem {
name: string;
}
/**
* Builds collection element identifiers based on collection type.
* Uses the shared pairing abstractions from @/components/Collections/pairing for
* pair name extraction (synchronized with the backend via auto_pairing_spec.yml).
*
* @param items - Upload items with dataset IDs
* @param datasetIds - Array of created dataset IDs (in upload order)
* @param collectionType - Type of collection to create
* @returns Collection element identifiers ready for API
*/
export function buildCollectionElements(
items: CollectionUploadItem[],
datasetIds: string[],
collectionType: SupportedCollectionType,
): CollectionElementIdentifiers {
if (collectionType === "list") {
// Simple list: one element per dataset
return items.map((item, index) => ({
name: item.name || `element_${index + 1}`,
src: "hda" as const,
id: datasetIds[index],
}));
}
// List of pairs: group consecutive files into pairs
const pairs: CollectionElementIdentifiers = [];
const usedNames = new Set<string>();
// Use the shared filter detection to determine forward/reverse naming convention
const filterType = guessInitialFilterType(items) ?? DEFAULT_FILTER;
const [forwardFilter, reverseFilter] = COMMON_FILTERS[filterType];
for (let i = 0; i < items.length; i += 2) {
if (i + 1 >= items.length) {
// Odd number of files - skip last one or handle as error
console.warn(`Skipping unpaired file at index ${i}: ${items[i]?.name}`);
break;
}
const item1 = items[i];
const item2 = items[i + 1];
if (!item1 || !item2) {
continue;
}
const basePairName =
guessNameForPair(item1, item2, forwardFilter, reverseFilter, true) || `pair_${Math.floor(i / 2) + 1}`;
let pairName = basePairName;
// Ensure unique pair names by adding suffix if needed
let counter = 1;
while (usedNames.has(pairName)) {
pairName = `${basePairName}_${counter}`;
counter++;
}
usedNames.add(pairName);
pairs.push({
collection_type: "paired",
src: "new_collection" as const,
name: pairName,
element_identifiers: [
{
name: "forward",
src: "hda" as const,
id: datasetIds[i],
},
{
name: "reverse",
src: "hda" as const,
id: datasetIds[i + 1],
},
],
});
}
return pairs;
}
@@ -18,6 +18,16 @@ export interface CollectionCreationInput {
type: SupportedCollectionType;
}
/**
* Collection configuration used by upload orchestration.
*/
export interface UploadCollectionConfig extends CollectionCreationInput {
/** Whether to hide source datasets after collection creation */
hideSourceItems: boolean;
/** Target history ID where the collection will be created */
historyId: string;
}
/**
* Validation state for a collection configuration
*/
@@ -0,0 +1,102 @@
import type { UploadCollectionConfig } from "@/composables/upload/collectionTypes";
import type {
LibraryDatasetUploadItem,
LocalFileUploadItem,
PastedContentUploadItem,
RemoteFileUploadItem,
UrlUploadItem,
} from "@/composables/upload/uploadItemTypes";
export function makePastedItem(overrides: Partial<PastedContentUploadItem> = {}): PastedContentUploadItem {
return {
uploadMode: "paste-content",
name: "file.txt",
content: "hello world",
size: 11,
targetHistoryId: "hist_1",
dbkey: "?",
extension: "auto",
spaceToTab: false,
toPosixLines: false,
deferred: false,
...overrides,
};
}
export function makeUrlItem(overrides: Partial<UrlUploadItem> = {}): UrlUploadItem {
return {
uploadMode: "paste-links",
name: "file.txt",
url: "http://example.com/file.txt",
size: 0,
targetHistoryId: "hist_1",
dbkey: "?",
extension: "auto",
spaceToTab: false,
toPosixLines: false,
deferred: false,
...overrides,
};
}
export function makeRemoteFilesItem(overrides: Partial<RemoteFileUploadItem> = {}): RemoteFileUploadItem {
return {
uploadMode: "remote-files",
name: "file.txt",
url: "ftp://server/file.txt",
size: 0,
targetHistoryId: "hist_1",
dbkey: "?",
extension: "auto",
spaceToTab: false,
toPosixLines: false,
deferred: false,
...overrides,
};
}
export function makeLibraryItem(overrides: Partial<LibraryDatasetUploadItem> = {}): LibraryDatasetUploadItem {
return {
uploadMode: "data-library",
name: "library.txt",
size: 0,
targetHistoryId: "hist_1",
dbkey: "?",
extension: "auto",
spaceToTab: false,
toPosixLines: false,
deferred: false,
libraryId: "lib_1",
folderId: "folder_1",
lddaId: "ldda_1",
url: "/api/libraries/lib_1/datasets/ldda_1",
...overrides,
};
}
export function makeLocalFileItem(overrides: Partial<LocalFileUploadItem> = {}): LocalFileUploadItem {
const file = new File(["content"], "test.txt");
return {
uploadMode: "local-file",
name: "test.txt",
size: file.size,
targetHistoryId: "hist_1",
dbkey: "?",
extension: "auto",
spaceToTab: false,
toPosixLines: false,
deferred: false,
fileData: file,
...overrides,
};
}
export function makeCollectionConfig(overrides: Partial<UploadCollectionConfig> = {}): UploadCollectionConfig {
return {
name: "My Collection",
type: "list",
historyId: "hist_1",
hideSourceItems: false,
...overrides,
};
}
@@ -8,9 +8,9 @@ import { computed } from "vue";
import type { BaseUploadItem } from "@/components/Panels/Upload/types/uploadItem";
import { useUploadConfigurations } from "@/composables/uploadConfigurations";
export function useUploadDefaults() {
export function useUploadDefaults(formats?: string[]) {
const { configOptions, effectiveExtensions, compositeExtensions, listDbKeys, ready } =
useUploadConfigurations(undefined);
useUploadConfigurations(formats);
const defaultExtension = computed(() => configOptions.value?.defaultExtension || "auto");
const defaultDbKey = computed(() => configOptions.value?.defaultDbKey || "?");
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
makeLibraryItem,
makeLocalFileItem,
makePastedItem,
makeRemoteFilesItem,
makeUrlItem,
} from "@/composables/upload/testHelpers/uploadFixtures";
import type { NewUploadItem } from "@/composables/upload/uploadItemTypes";
import { validateUploadItem } from "./uploadItemTypes";
describe("validateUploadItem", () => {
it.each([
["paste-content", makePastedItem()],
["paste-links", makeUrlItem()],
["remote-files", makeRemoteFilesItem()],
["data-library", makeLibraryItem()],
["local-file", makeLocalFileItem()],
] as [string, NewUploadItem][])("accepts a valid %s item", (_mode, item) => {
expect(validateUploadItem(item)).toBeUndefined();
});
it("rejects paste-content with empty content", () => {
expect(validateUploadItem(makePastedItem({ content: " " }))).toMatch(/No content provided/);
});
it("rejects paste-links with missing URL", () => {
expect(validateUploadItem(makeUrlItem({ url: "" }))).toMatch(/No URL provided/);
});
it("rejects remote-files with missing URL", () => {
expect(validateUploadItem(makeRemoteFilesItem({ url: " " }))).toMatch(/No URL provided/);
});
it("rejects data-library with no lddaId", () => {
expect(validateUploadItem(makeLibraryItem({ lddaId: "" }))).toMatch(/No library dataset ID/);
});
it("rejects local-file with no file data", () => {
const item: NewUploadItem = {
uploadMode: "local-file",
name: "missing.txt",
size: 0,
targetHistoryId: "hist_1",
dbkey: "?",
extension: "auto",
spaceToTab: false,
toPosixLines: false,
deferred: false,
};
expect(validateUploadItem(item)).toMatch(/No file selected/);
});
it("rejects local-file with an empty file", () => {
const emptyFile = new File([], "empty.txt");
const item: NewUploadItem = {
uploadMode: "local-file",
name: "empty.txt",
size: 0,
targetHistoryId: "hist_1",
dbkey: "?",
extension: "auto",
spaceToTab: false,
toPosixLines: false,
deferred: false,
fileData: emptyFile,
};
expect(validateUploadItem(item)).toMatch(/is empty/);
});
it("rejects an unknown upload mode", () => {
const item = { ...makePastedItem(), uploadMode: "unknown-mode" } as unknown as NewUploadItem;
expect(validateUploadItem(item)).toMatch(/Unknown upload mode/);
});
});
@@ -3,6 +3,7 @@
* These types represent items in various stages of the upload lifecycle.
*/
import type { HistoryContentSource } from "@/api/datasets";
import type { FetchDatasetHash } from "@/api/tools";
import type { UploadMethod } from "@/components/Panels/Upload/types";
@@ -123,3 +124,60 @@ export interface UploadState {
/** Upload item with state tracking (used in active upload queue) */
export type UploadItem = NewUploadItem & UploadState;
/** Sources returned for uploaded history contents. */
export type UploadedDatasetSource = Extract<HistoryContentSource, "hda" | "hdca">;
/**
* Validates a UI upload item before submission.
* Returns an error message if invalid, undefined if valid.
*/
export function validateUploadItem(item: NewUploadItem): string | undefined {
switch (item.uploadMode) {
case "local-file":
if (!item.fileData) {
return `No file selected for "${item.name}"`;
}
if (item.fileData.size === 0) {
return `File "${item.name}" is empty`;
}
break;
case "paste-content":
if (!item.content || item.content.trim().length === 0) {
return `No content provided for "${item.name}"`;
}
break;
case "paste-links":
case "remote-files":
if (!item.url || item.url.trim().length === 0) {
return `No URL provided for "${item.name}"`;
}
break;
case "data-library":
if (!item.lddaId) {
return `No library dataset ID provided for "${item.name}"`;
}
break;
default:
return `Unknown upload mode: ${(item as NewUploadItem).uploadMode}`;
}
return undefined;
}
/**
* Represents a dataset that was successfully uploaded.
*/
export interface UploadedDataset {
/** Unique identifier for the dataset. */
id: string;
/** Display name of the dataset. */
name: string;
/** History item ID (sequential index). */
hid?: number;
/** Type of dataset: history dataset (hda) or history dataset collection (hdca). */
src: UploadedDatasetSource;
}
@@ -0,0 +1,91 @@
import type { FetchDataResponse } from "@/api/tools";
import type { UploadedDataset } from "./uploadItemTypes";
/**
* Raw dataset data structure from API response.
*/
interface UploadResponseData {
id: string;
name?: string;
label?: string;
hid?: number;
src?: string;
}
function isUploadResponseData(value: unknown): value is UploadResponseData {
if (!value || typeof value !== "object") {
return false;
}
return "id" in value && typeof value.id === "string";
}
function toUploadedDataset(output: unknown): UploadedDataset | null {
if (!isUploadResponseData(output) || !output.id) {
return null;
}
return {
id: output.id,
name: output.name ?? output.label ?? output.id,
hid: output.hid,
src: output.src === "hdca" ? "hdca" : "hda",
};
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function collectUploadedDatasets(responseOutputs: unknown, datasets: UploadedDataset[]): void {
if (!responseOutputs) {
return;
}
if (Array.isArray(responseOutputs)) {
responseOutputs.forEach((item) => collectUploadedDatasets(item, datasets));
return;
}
const converted = toUploadedDataset(responseOutputs);
if (converted) {
datasets.push(converted);
return;
}
if (isPlainObject(responseOutputs)) {
Object.values(responseOutputs).forEach((nested) => collectUploadedDatasets(nested, datasets));
}
}
/**
* Extracts uploaded datasets from a fetch response, recursively searching through outputs.
* @param response - The fetch response containing outputs
* @returns Array of unique uploaded datasets
*/
export function datasetsFromFetchResponse(response: FetchDataResponse): UploadedDataset[] {
if (!response.outputs) {
return [];
}
const datasets: UploadedDataset[] = [];
collectUploadedDatasets(response.outputs, datasets);
const seen = new Set<string>();
return datasets.filter((dataset) => {
if (seen.has(dataset.id)) {
return false;
}
seen.add(dataset.id);
return true;
});
}
/**
* Extracts dataset IDs from a fetch response.
* @param response - The fetch response containing outputs
* @returns Array of dataset IDs
*/
export function datasetIdsFromFetchResponse(response: FetchDataResponse): string[] {
return datasetsFromFetchResponse(response).map((dataset) => dataset.id);
}
@@ -0,0 +1,145 @@
import type { UploadStateTrackingApi } from "@/components/Panels/Upload/uploadState";
import type { UploadCollectionConfig } from "./collectionTypes";
import type { LibraryDatasetUploadItem, NewUploadItem } from "./uploadItemTypes";
/**
* Represents an upload item being tracked through its lifecycle.
* @template T - The specific type of upload item (NewUploadItem or subtypes)
*/
export interface TrackedUpload<T extends NewUploadItem = NewUploadItem> {
/** The upload item configuration */
item: T;
/** Unique identifier for this tracked upload */
id: string;
}
/**
* Result of initializing tracked uploads.
* Contains the tracked uploads and optional batch ID for collection uploads.
*/
export interface InitializedUploads {
/** Array of tracked uploads created from the input items */
trackedUploads: TrackedUpload[];
/** Batch ID if uploads were initialized as part of a collection */
batchId?: string;
}
/**
* Options for initializing tracked uploads.
*/
interface InitializeTrackedUploadsOptions {
/** Collection configuration if uploads should be part of a collection */
collectionConfig?: UploadCollectionConfig;
/** Whether to use direct HDCA creation (no separate collection creation step) */
directCreation?: boolean;
/** Whether to immediately start uploading (sets status to "uploading") */
startUploading?: boolean;
}
/**
* Initializes tracked uploads for a set of upload items.
* Creates upload items in the upload state and optionally creates a collection batch.
*
* @param uploadState - Upload state tracking API
* @param items - Upload items to initialize (file, URL, paste uploads, etc.)
* @param options - Initialization options
* @returns Initialized uploads with tracked items and optional batch ID
*/
export function initializeTrackedUploads(
uploadState: UploadStateTrackingApi,
items: NewUploadItem[] | undefined,
options: InitializeTrackedUploadsOptions = {},
): InitializedUploads {
if (!items || items.length === 0) {
return { trackedUploads: [] };
}
const batchId = options.collectionConfig
? uploadState.addBatch(options.collectionConfig, [], options.directCreation ?? false)
: undefined;
const trackedUploads = items.map((item) => ({
item,
id: uploadState.addUploadItem(item, batchId),
}));
if (batchId) {
const batch = uploadState.getBatch(batchId);
if (batch) {
batch.uploadIds = trackedUploads.map((tracked) => tracked.id);
}
}
if (options.startUploading) {
trackedUploads.forEach((tracked) => uploadState.setStatus(tracked.id, "uploading"));
}
return { trackedUploads, batchId };
}
/**
* Updates the progress of multiple tracked uploads.
*
* @param uploadState - Upload state tracking API
* @param ids - Upload item IDs to update
* @param percentage - Progress percentage (0-100)
*/
export function updateTrackedProgress(uploadState: UploadStateTrackingApi, ids: string[], percentage: number): void {
ids.forEach((id) => uploadState.updateProgress(id, percentage));
}
/**
* Marks multiple tracked uploads as completed.
* Sets progress to 100% and status to "completed".
*/
export function markTrackedCompleted(uploadState: UploadStateTrackingApi, ids: string[]): void {
ids.forEach((id) => {
uploadState.updateProgress(id, 100);
uploadState.setStatus(id, "completed");
});
}
/**
* Marks multiple tracked uploads as failed with an error message.
*
* @param uploadState - Upload state tracking API
* @param trackedUploads - Tracked uploads to mark as failed
* @param message - Error message describing the failure
*/
export function markTrackedError(
uploadState: UploadStateTrackingApi,
trackedUploads: Array<Pick<TrackedUpload, "id">>,
message: string,
): void {
trackedUploads.forEach((tracked) => uploadState.setError(tracked.id, message));
}
function isTrackedLibraryUpload(tracked: TrackedUpload): tracked is TrackedUpload<LibraryDatasetUploadItem> {
return tracked.item.uploadMode === "data-library";
}
/**
* Splits tracked uploads by their type (API uploads vs library uploads).
* Useful for handling different upload modes after initialization.
*
* @param trackedUploads - Tracked uploads to split
* @returns Object containing arrays of API upload IDs and library uploads
*/
export function splitTrackedUploadsByType(trackedUploads: TrackedUpload[]): {
apiIds: string[];
libraryUploads: TrackedUpload<LibraryDatasetUploadItem>[];
} {
const apiIds: string[] = [];
const libraryUploads: TrackedUpload<LibraryDatasetUploadItem>[] = [];
trackedUploads.forEach((tracked) => {
if (isTrackedLibraryUpload(tracked)) {
libraryUploads.push(tracked);
} else {
apiIds.push(tracked.id);
}
});
return { apiIds, libraryUploads };
}
@@ -0,0 +1,107 @@
import { suppressExpectedErrorMessages } from "@tests/vitest/helpers";
import flushPromises from "flush-promises";
import { http, HttpResponse } from "msw";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useServerMock } from "@/api/client/__mocks__";
import { useUploadState } from "@/components/Panels/Upload/uploadState";
import { makeCollectionConfig, makePastedItem, makeUrlItem } from "@/composables/upload/testHelpers/uploadFixtures";
import { useUploadBatchOperations } from "./useUploadBatchOperations";
const { server } = useServerMock();
describe("useUploadBatchOperations", () => {
beforeEach(() => {
setActivePinia(createPinia());
useUploadState().clearAll();
});
afterEach(() => {
vi.restoreAllMocks();
useUploadState().clearAll();
});
it("processes the direct collection path atomically", async () => {
server.use(
http.get("/api/configuration", () => HttpResponse.json({ chunk_upload_size: 42 })),
http.post("/api/tools/fetch", async ({ request }) => {
const body = await request.json();
expect(body).toMatchObject({
history_id: "hist_1",
targets: [
{
destination: { type: "hdca" },
collection_type: "list",
name: "My Collection",
},
],
});
return HttpResponse.json({ outputs: [{ id: "hdca_1", src: "hdca" }] });
}),
);
const state = useUploadState();
const operations = useUploadBatchOperations({ autoRecover: false });
const first = makeUrlItem({ name: "a.txt" });
const second = makeUrlItem({ name: "b.txt" });
const batchId = state.addBatch(makeCollectionConfig(), [], true);
const id1 = state.addUploadItem(first, batchId);
const id2 = state.addUploadItem(second, batchId);
state.getBatch(batchId)!.uploadIds = [id1, id2];
await operations.processDirectBatch(batchId, [id1, id2], [first, second]);
expect(state.getBatch(batchId)?.status).toBe("completed");
expect(state.activeItems.value.every((item) => item.status === "completed")).toBe(true);
});
it("retries collection creation after an earlier two-step failure", async () => {
suppressExpectedErrorMessages(["Temporary error"]);
server.use(http.post("/api/dataset_collections", () => HttpResponse.json({ id: "col_retried" })));
const state = useUploadState();
const operations = useUploadBatchOperations({ autoRecover: false });
const uploadId = state.addUploadItem(makePastedItem());
state.updateProgress(uploadId, 100);
const batchId = state.addBatch(makeCollectionConfig(), [uploadId], false);
state.addBatchDatasetId(batchId, "ds_1");
state.setBatchError(batchId, "Temporary error");
const item = state.activeItems.value.find((entry) => entry.id === uploadId);
if (item) {
item.error = "Uploaded successfully, but collection creation failed";
}
await operations.retryCollectionCreation(batchId);
expect(state.getBatch(batchId)?.status).toBe("completed");
expect(state.getBatch(batchId)?.collectionId).toBe("col_retried");
expect(state.activeItems.value.find((entry) => entry.id === uploadId)?.error).toBeUndefined();
});
it("recovers interrupted two-step collection creation from persisted state", async () => {
server.use(http.post("/api/dataset_collections", () => HttpResponse.json({ id: "col_recovered" })));
const state = useUploadState();
const itemId = state.addUploadItem(makePastedItem({ name: "recovered.txt" }));
state.setStatus(itemId, "uploading");
state.updateProgress(itemId, 100);
const batchId = state.addBatch(
{ name: "Recovery Collection", type: "list", hideSourceItems: false, historyId: "hist_1" },
[itemId],
false,
);
state.addBatchDatasetId(batchId, "ds_recovered");
const operations = useUploadBatchOperations({ autoRecover: false });
operations.recoverIncompleteBatches();
await flushPromises();
expect(state.getBatch(batchId)?.collectionId).toBe("col_recovered");
expect(state.getBatch(batchId)?.status).toBe("completed");
});
});
@@ -0,0 +1,255 @@
import { createHistoryDatasetCollectionInstanceFull } from "@/api/datasetCollections";
import { useUploadState } from "@/components/Panels/Upload/uploadState";
import { buildCollectionElements } from "@/composables/upload/collectionElements";
import type { NewUploadItem, UploadItem } from "@/composables/upload/uploadItemTypes";
import { validateUploadItem } from "@/composables/upload/uploadItemTypes";
import { useHistoryStore } from "@/stores/historyStore";
import { getHistoryUploadActionErrorMessage, getHistoryUploadBlockReason } from "@/utils/historyUpload";
import { errorMessageAsString } from "@/utils/simple-error";
import { toApiUploadItem, uploadCollectionDatasets } from "@/utils/upload";
interface UploadBatchOperationsOptions {
autoRecover?: boolean;
}
let didRecoverIncompleteBatches = false;
export function useUploadBatchOperations(options: UploadBatchOperationsOptions = {}) {
const uploadState = useUploadState();
function findUploadItem(id: string): UploadItem | undefined {
return uploadState.activeItems.value.find((item) => item.id === id);
}
async function validateTargetHistory(targetHistoryId: string): Promise<string | null> {
const historyStore = useHistoryStore();
let history = historyStore.getHistoryById(targetHistoryId, false) ?? null;
if (!history) {
await historyStore.loadHistoryById(targetHistoryId);
history = historyStore.getHistoryById(targetHistoryId, false) ?? null;
}
if (!history) {
return null;
}
const blockReason = getHistoryUploadBlockReason(history);
return blockReason ? getHistoryUploadActionErrorMessage(blockReason) : null;
}
/**
* Creates a dataset collection from uploaded datasets.
*
* @param batchId - Batch ID in upload state
* @throws {Error} If the batch is not found, has missing data, or collection creation fails
*/
async function createCollection(batchId: string): Promise<void> {
const batch = uploadState.getBatch(batchId);
if (!batch) {
const errorMsg = `Batch not found: ${batchId}`;
console.error(errorMsg);
throw new Error(errorMsg);
}
if (batch.collectionId) {
return;
}
if (!batch.datasetIds || batch.datasetIds.length === 0) {
const errorMsg = "No dataset IDs available for collection creation";
uploadState.setBatchError(batchId, errorMsg);
throw new Error(errorMsg);
}
const items = batch.uploadIds
.map((id) => findUploadItem(id))
.filter((item): item is UploadItem => item !== undefined);
if (items.length === 0) {
const errorMsg = "No upload items available for collection creation";
uploadState.setBatchError(batchId, errorMsg);
throw new Error(errorMsg);
}
if (items.length !== batch.uploadIds.length) {
const errorMsg = `Cannot create collection: only ${items.length} of ${batch.uploadIds.length} upload items found. This can happen after a page refresh. Please re-upload the files or manually create the collection.`;
uploadState.setBatchError(batchId, errorMsg);
throw new Error(errorMsg);
}
uploadState.updateBatchStatus(batchId, "creating-collection");
try {
const elementIdentifiers = buildCollectionElements(items, batch.datasetIds, batch.type);
if (elementIdentifiers.length === 0) {
throw new Error("No valid collection elements to create");
}
const response = await createHistoryDatasetCollectionInstanceFull({
name: batch.name,
collection_type: batch.type,
element_identifiers: elementIdentifiers,
history_id: batch.historyId,
hide_source_items: batch.hideSourceItems,
instance_type: "history",
copy_elements: true,
fields: "auto",
});
uploadState.setBatchCollectionId(batchId, response.id);
uploadState.updateBatchStatus(batchId, "completed");
} catch (err) {
const errorMsg = errorMessageAsString(err);
uploadState.setBatchError(batchId, errorMsg);
const currentBatch = uploadState.getBatch(batchId);
currentBatch?.uploadIds.forEach((id) => {
const item = findUploadItem(id);
if (item && !item.error) {
item.error = "Uploaded successfully, but collection creation failed";
}
});
throw new Error(errorMsg);
}
}
/**
* Processes a collection batch using the direct HDCA creation path.
* All items are uploaded together in one /api/tools/fetch request that
* creates the collection atomically — no separate collection creation step.
*
* Used for non-library batches where all items can be fed to the upload API directly.
*/
async function processDirectBatch(batchId: string, ids: string[], items: NewUploadItem[]): Promise<void> {
const batch = uploadState.getBatch(batchId);
if (!batch) {
console.error(`Batch not found: ${batchId}`);
return;
}
ids.forEach((id) => uploadState.setStatus(id, "uploading"));
uploadState.updateBatchStatus(batchId, "uploading");
try {
const historyError = await validateTargetHistory(batch.historyId);
if (historyError) {
throw new Error(historyError);
}
for (const item of items) {
const validationError = validateUploadItem(item);
if (validationError) {
throw new Error(validationError);
}
}
const apiItems = items.map((item) => toApiUploadItem(item));
await uploadCollectionDatasets(
apiItems,
{
collectionName: batch.name,
collectionType: batch.type,
},
{
progress: (percentage) => {
ids.forEach((id) => uploadState.updateProgress(id, percentage));
},
success: () => {
ids.forEach((id) => uploadState.updateProgress(id, 100));
uploadState.updateBatchStatus(batchId, "completed");
},
error: (err) => {
const errorMsg = errorMessageAsString(err);
ids.forEach((id) => uploadState.setError(id, errorMsg));
uploadState.setBatchError(batchId, errorMsg);
},
},
);
} catch (err) {
const errorMsg = errorMessageAsString(err);
ids.forEach((id) => uploadState.setError(id, errorMsg));
uploadState.setBatchError(batchId, errorMsg);
}
}
async function retryCollectionCreation(batchId: string): Promise<void> {
const batch = uploadState.getBatch(batchId);
if (!batch) {
console.error(`Batch not found: ${batchId}`);
return;
}
batch.error = undefined;
uploadState.updateBatchStatus(batchId, "uploading");
batch.uploadIds.forEach((id) => {
const item = findUploadItem(id);
if (item?.error?.includes("collection creation failed")) {
item.error = undefined;
}
});
try {
await createCollection(batchId);
} catch (err) {
uploadState.setBatchError(batchId, `Retry failed: ${errorMessageAsString(err)}`);
}
}
function recoverIncompleteBatches(): void {
uploadState.activeBatches.value.forEach((batch) => {
if (batch.collectionId || batch.status === "error" || batch.directCreation) {
return;
}
const allCompleted = batch.uploadIds.every((uploadId) => {
const upload = findUploadItem(uploadId);
return upload?.status === "completed";
});
if (allCompleted && batch.uploadIds.length > 0 && batch.datasetIds.length > 0) {
const availableItems = batch.uploadIds.filter((uploadId) => findUploadItem(uploadId) !== undefined);
if (availableItems.length !== batch.uploadIds.length) {
uploadState.setBatchError(
batch.id,
"Collection creation failed: upload data lost after page refresh. Please re-upload the files to create the collection or manually create the collection.",
);
return;
}
createCollection(batch.id).catch((err) => {
console.error("Recovery failed:", err);
uploadState.setBatchError(batch.id, "Collection creation interrupted. Please retry manually.");
});
} else if (allCompleted && batch.uploadIds.length > 0) {
uploadState.setBatchError(
batch.id,
"Collection creation interrupted and cannot be recovered. Dataset IDs not available. Please create the collection manually.",
);
}
});
}
function clearCompleted(): void {
uploadState.clearCompleted();
}
function clearAll(): void {
uploadState.clearAll();
}
if (options.autoRecover !== false && !didRecoverIncompleteBatches) {
recoverIncompleteBatches();
didRecoverIncompleteBatches = true;
}
return {
clearAll,
clearCompleted,
createCollection,
processDirectBatch,
recoverIncompleteBatches,
retryCollectionCreation,
};
}
@@ -0,0 +1,159 @@
import Vue, { h, reactive } from "vue";
import type { DataOption } from "@/components/Form/Elements/FormData/types";
import type {
DatasetUploadMethod,
UploadedDataset,
UploadModalConfig,
UploadModalResolvers,
UploadModalResult,
} from "@/components/Panels/Upload/uploadModalTypes";
import UploadMethodModal from "@/components/Panels/Upload/UploadMethodModal.vue";
interface ModalState {
modalVisible: boolean;
modalConfig: UploadModalConfig;
}
interface UploadMethodModalHostInstance extends Vue {
state: ModalState;
render: () => ReturnType<typeof h>;
}
const DEFAULT_ALLOWED_METHODS: DatasetUploadMethod[] = [
"local-file",
"paste-content",
"paste-links",
"remote-files",
"data-library",
];
let hostElement: HTMLDivElement | null = null;
let modalVm: UploadMethodModalHostInstance | null = null;
let pendingResolvers: UploadModalResolvers | null = null;
function toDataOptions(datasets: UploadedDataset[]): DataOption[] {
return datasets.map((dataset) => ({
id: dataset.id,
name: dataset.name,
hid: dataset.hid,
src: dataset.src,
batch: false,
keep: true,
tags: [],
}));
}
function applyConfigDefaults(config?: UploadModalConfig): UploadModalConfig {
return {
allowedMethods: config?.allowedMethods ?? DEFAULT_ALLOWED_METHODS,
allowCollections: config?.allowCollections ?? false,
formats: config?.formats,
multiple: config?.multiple ?? true,
targetHistoryId: config?.targetHistoryId,
title: config?.title,
hideTips: config?.hideTips ?? false,
};
}
function buildResult(datasets: UploadedDataset[], cancelled: boolean): UploadModalResult {
return {
datasets,
cancelled,
toDataOptions: () => toDataOptions(datasets),
};
}
function resolveAndCleanup(resolvers: UploadModalResolvers | null, result: UploadModalResult): void {
if (resolvers) {
resolvers.resolve(result);
}
}
function ensureMounted(): UploadMethodModalHostInstance {
if (modalVm) {
return modalVm;
}
hostElement = document.createElement("div");
hostElement.id = "upload-method-modal-host";
document.body.appendChild(hostElement);
const state: ModalState = reactive({
modalVisible: false,
modalConfig: applyConfigDefaults(),
});
const finishUploaded = (datasets: UploadedDataset[]) => {
const resolvers = pendingResolvers;
pendingResolvers = null;
state.modalVisible = false;
resolveAndCleanup(resolvers, buildResult(datasets, false));
};
const finishCancelled = () => {
const resolvers = pendingResolvers;
pendingResolvers = null;
state.modalVisible = false;
resolveAndCleanup(resolvers, buildResult([], true));
};
const render = () =>
h(UploadMethodModal, {
props: {
show: state.modalVisible,
config: state.modalConfig,
hideTips: state.modalConfig.hideTips ?? false,
},
on: {
"update:show": (show: boolean) => {
state.modalVisible = show;
},
uploaded: finishUploaded,
cancelled: finishCancelled,
},
});
const instance = new Vue({
name: "UploadMethodModalHost",
setup() {
return {
state,
finishUploaded,
finishCancelled,
render,
};
},
render(): ReturnType<typeof h> {
return this.render();
},
}).$mount(hostElement);
modalVm = instance as UploadMethodModalHostInstance;
return modalVm;
}
export function useUploadMethodModal() {
async function openUploadModal(config?: UploadModalConfig): Promise<UploadModalResult> {
const vm = ensureMounted();
if (pendingResolvers) {
return Promise.reject(new Error("An upload modal is already open."));
}
const defaultedConfig = applyConfigDefaults(config);
vm.state.modalConfig = defaultedConfig;
vm.state.modalVisible = true;
return new Promise<UploadModalResult>((resolve, reject) => {
pendingResolvers = { resolve, reject };
});
}
return {
openUploadModal,
};
}
export { toDataOptions };
@@ -10,6 +10,13 @@ interface UseUploadStagingReturn {
clear: () => void;
}
interface UploadStagingOptions {
/** Whether to disable the upload staging store.
* This is useful for short-term staging sessions that don't require persistence.
*/
disableStore?: boolean;
}
/**
* Composable to synchronize a local reactive items ref with the upload staging store.
*
@@ -21,9 +28,19 @@ interface UseUploadStagingReturn {
export function useUploadStaging<T extends StagedUploadItem>(
mode: UploadMethod,
items: Ref<T[]>,
options: UploadStagingOptions = {},
): UseUploadStagingReturn {
const stagingStore = useUploadStagingStore();
// When disabled, act as a no-op passthrough using only the provided ref
if (options.disableStore) {
return {
clear() {
items.value = [];
},
};
}
// Restore staged items on mount
onMounted(() => {
const staged = stagingStore.getItems<T>(mode);
@@ -0,0 +1,316 @@
import { getLocalVue, suppressExpectedErrorMessages } from "@tests/vitest/helpers";
import { mount } from "@vue/test-utils";
import flushPromises from "flush-promises";
import { http, HttpResponse } from "msw";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { defineComponent, ref } from "vue";
import { useServerMock } from "@/api/client/__mocks__";
import type { PreparedUpload } from "@/components/Panels/Upload/types";
import { useUploadState } from "@/components/Panels/Upload/uploadState";
import { makeCollectionConfig, makeLibraryItem, makeUrlItem } from "@/composables/upload/testHelpers/uploadFixtures";
import { buildPreparedUpload } from "@/utils/upload";
import { useUploadSubmission } from "./useUploadSubmission";
const SELECTORS = {
RUN: "[data-test-id='run']",
RESULT: "[data-test-id='result']",
ERROR: "[data-test-id='error']",
};
const localVue = getLocalVue();
const { server } = useServerMock();
function mountHarness(prepared: PreparedUpload) {
const Harness = defineComponent({
setup() {
const { submitPreparedUpload } = useUploadSubmission();
const result = ref("");
const error = ref("");
async function run() {
try {
const uploaded = await submitPreparedUpload("hist_1", prepared);
result.value = JSON.stringify(uploaded);
} catch (err) {
error.value = String(err);
}
}
return { error, result, run };
},
template: `
<div>
<button data-test-id="run" @click="run">run</button>
<div data-test-id="result">{{ result }}</div>
<div data-test-id="error">{{ error }}</div>
</div>
`,
});
return mount(Harness, { localVue, pinia: createPinia() });
}
function makeSubmissionCollectionConfig() {
return makeCollectionConfig({
name: "Uploaded Collection",
hideSourceItems: true,
});
}
describe("useUploadSubmission", () => {
beforeEach(() => {
setActivePinia(createPinia());
useUploadState().clearAll();
server.use(http.get("/api/configuration", () => HttpResponse.json({ chunk_upload_size: 42 })));
});
afterEach(() => {
useUploadState().clearAll();
});
it("submits mixed uploads, tracks completion, and flattens nested fetch outputs", async () => {
server.use(
http.post("/api/tools/fetch", () =>
HttpResponse.json({
jobs: [{ id: "job_1" }],
outputs: {
first: { id: "hda_1", name: "api dataset", hid: 1, src: "hda" },
nested: [
{ duplicate: { id: "hda_1", name: "duplicate", hid: 1, src: "hda" } },
{ id: "hdca_1", name: "api collection", src: "hdca" },
],
},
}),
),
http.post("/api/histories/hist_1/contents/datasets", async ({ request }) => {
const body = await request.json();
expect(body).toMatchObject({
content: "ldda_1",
source: "library",
type: "dataset",
});
return HttpResponse.json({ id: "hda_2", name: "copied library", hid: 2 });
}),
);
const apiItem = makeUrlItem({ name: "remote.txt", url: "https://example.org/remote.txt" });
const apiPrepared = buildPreparedUpload([apiItem]);
const wrapper = mountHarness({
apiItems: apiPrepared.apiItems,
uploadItems: [apiItem, makeLibraryItem()],
});
await flushPromises();
await wrapper.find(SELECTORS.RUN).trigger("click");
await flushPromises();
expect(wrapper.find(SELECTORS.RESULT).text()).toContain('"id":"hda_1"');
expect(wrapper.find(SELECTORS.RESULT).text()).toContain('"id":"hdca_1"');
expect(wrapper.find(SELECTORS.RESULT).text()).toContain('"id":"hda_2"');
const state = useUploadState();
const pastedEntry = state.activeItems.value.find((item) => item.name === "remote.txt");
const libraryEntry = state.activeItems.value.find((item) => item.name === "library.txt");
expect(pastedEntry?.status).toBe("completed");
expect(pastedEntry?.progress).toBe(100);
expect(libraryEntry?.status).toBe("completed");
expect(libraryEntry?.progress).toBe(100);
});
it("marks all tracked uploads as errored when the fetch request fails", async () => {
server.use(
http.post("/api/tools/fetch", () => HttpResponse.json({ err_msg: "upload failed" }, { status: 500 })),
);
const apiItem = makeUrlItem({ name: "remote.txt", url: "https://example.org/broken.txt" });
const apiPrepared = buildPreparedUpload([apiItem]);
const wrapper = mountHarness({
apiItems: apiPrepared.apiItems,
uploadItems: [apiItem, makeLibraryItem()],
});
await flushPromises();
await wrapper.find(SELECTORS.RUN).trigger("click");
await flushPromises();
expect(wrapper.find(SELECTORS.ERROR).text()).toContain("upload failed");
const state = useUploadState();
expect(state.activeItems.value).toHaveLength(2);
for (const item of state.activeItems.value) {
expect(item.status).toBe("error");
expect(item.error).toBe("upload failed");
}
});
it("falls back to the staged library item name when the copy response omits metadata", async () => {
server.use(
http.post("/api/histories/hist_1/contents/datasets", async ({ request }) => {
const body = await request.json();
expect(body).toMatchObject({
content: "ldda_3",
source: "library",
type: "dataset",
});
return HttpResponse.json({ id: "hda_3" });
}),
);
const wrapper = mountHarness({
apiItems: [],
uploadItems: [makeLibraryItem({ name: "fallback-name.txt", lddaId: "ldda_3" })],
});
await flushPromises();
await wrapper.find(SELECTORS.RUN).trigger("click");
await flushPromises();
expect(wrapper.find(SELECTORS.RESULT).text()).toContain('"name":"fallback-name.txt"');
expect(wrapper.find(SELECTORS.RESULT).text()).toContain('"id":"hda_3"');
});
it("groups direct collection uploads into a batch in upload state", async () => {
server.use(
http.post("/api/tools/fetch", async ({ request }) => {
const body = await request.json();
expect(body).toMatchObject({
history_id: "hist_1",
targets: [
{
destination: { type: "hdca" },
collection_type: "list",
name: "Uploaded Collection",
},
],
});
return HttpResponse.json({
jobs: [{ id: "job_1" }],
outputs: [{ id: "hdca_2", name: "Uploaded Collection", src: "hdca" }],
});
}),
);
const firstItem = makeUrlItem({ name: "1.bed", url: "https://example.org/1.bed" });
const secondItem = makeUrlItem({ name: "2.bed", url: "https://example.org/2.bed" });
const prepared = buildPreparedUpload([firstItem, secondItem], makeSubmissionCollectionConfig());
const wrapper = mountHarness(prepared);
await flushPromises();
await wrapper.find(SELECTORS.RUN).trigger("click");
await flushPromises();
expect(wrapper.find(SELECTORS.RESULT).text()).toContain('"id":"hdca_2"');
const state = useUploadState();
const batch = state.activeBatches.value[0];
expect(batch?.name).toBe("Uploaded Collection");
expect(batch?.status).toBe("completed");
expect(batch?.collectionId).toBe("hdca_2");
expect(batch?.uploadIds).toHaveLength(2);
expect(state.standaloneUploads.value).toHaveLength(0);
expect(state.orderedUploadItems.value[0]?.type).toBe("batch");
expect(state.activeItems.value.every((item) => item.batchId === batch?.id)).toBe(true);
});
it("creates a two-step collection for library-only uploads", async () => {
server.use(
http.post("/api/histories/hist_1/contents/datasets", () => HttpResponse.json({ id: "hda_lib_1", hid: 3 })),
http.post("/api/dataset_collections", async ({ request }) => {
const body = await request.json();
expect(body).toMatchObject({
history_id: "hist_1",
collection_type: "list",
name: "Uploaded Collection",
element_identifiers: [{ id: "hda_lib_1", src: "hda" }],
});
return HttpResponse.json({ id: "hdca_lib_1" });
}),
);
const prepared = buildPreparedUpload([makeLibraryItem()], makeSubmissionCollectionConfig());
const wrapper = mountHarness(prepared);
await flushPromises();
await wrapper.find(SELECTORS.RUN).trigger("click");
await flushPromises();
expect(wrapper.find(SELECTORS.RESULT).text()).toContain('"id":"hda_lib_1"');
const batch = useUploadState().activeBatches.value[0];
expect(batch?.status).toBe("completed");
expect(batch?.collectionId).toBe("hdca_lib_1");
expect(batch?.datasetIds).toEqual(["hda_lib_1"]);
});
it("creates a two-step collection for mixed api and library uploads", async () => {
server.use(
http.post("/api/tools/fetch", () =>
HttpResponse.json({
outputs: [{ id: "hda_api_1", name: "api dataset", hid: 1, src: "hda" }],
}),
),
http.post("/api/histories/hist_1/contents/datasets", () => HttpResponse.json({ id: "hda_lib_2", hid: 2 })),
http.post("/api/dataset_collections", async ({ request }) => {
const body = await request.json();
expect(body).toMatchObject({
history_id: "hist_1",
collection_type: "list",
name: "Uploaded Collection",
element_identifiers: [
{ id: "hda_api_1", src: "hda" },
{ id: "hda_lib_2", src: "hda" },
],
});
return HttpResponse.json({ id: "hdca_mixed_1" });
}),
);
const apiItem = makeUrlItem({ name: "api-first.txt" });
const libraryItem = makeLibraryItem({ name: "library-second.txt", lddaId: "ldda_2" });
const wrapper = mountHarness(buildPreparedUpload([apiItem, libraryItem], makeSubmissionCollectionConfig()));
await flushPromises();
await wrapper.find(SELECTORS.RUN).trigger("click");
await flushPromises();
expect(wrapper.find(SELECTORS.RESULT).text()).toContain('"id":"hda_api_1"');
expect(wrapper.find(SELECTORS.RESULT).text()).toContain('"id":"hda_lib_2"');
const batch = useUploadState().activeBatches.value[0];
expect(batch?.status).toBe("completed");
expect(batch?.collectionId).toBe("hdca_mixed_1");
expect(batch?.datasetIds).toEqual(["hda_api_1", "hda_lib_2"]);
});
it("surfaces two-step collection creation failures after uploads succeed", async () => {
suppressExpectedErrorMessages(["Collection error"]);
server.use(
http.post("/api/histories/hist_1/contents/datasets", () => HttpResponse.json({ id: "hda_lib_3", hid: 4 })),
http.post("/api/dataset_collections", () =>
HttpResponse.json({ err_msg: "Collection error" }, { status: 500 }),
),
);
const wrapper = mountHarness(
buildPreparedUpload([makeLibraryItem({ lddaId: "ldda_3" })], makeSubmissionCollectionConfig()),
);
await flushPromises();
await wrapper.find(SELECTORS.RUN).trigger("click");
await flushPromises();
expect(wrapper.find(SELECTORS.ERROR).text()).toContain("Collection error");
const batch = useUploadState().activeBatches.value[0];
expect(batch?.status).toBe("error");
expect(batch?.collectionId).toBeUndefined();
});
});
@@ -0,0 +1,206 @@
import { copyDataset } from "@/api/datasets";
import type { PreparedUpload } from "@/components/Panels/Upload/types";
import { useUploadState } from "@/components/Panels/Upload/uploadState";
import { useConfig } from "@/composables/config";
import type { LibraryDatasetUploadItem, UploadedDataset } from "@/composables/upload/uploadItemTypes";
import { datasetsFromFetchResponse } from "@/composables/upload/uploadResponse";
import type { InitializedUploads, TrackedUpload } from "@/composables/upload/uploadTracking";
import {
initializeTrackedUploads,
markTrackedCompleted,
markTrackedError,
splitTrackedUploadsByType,
updateTrackedProgress,
} from "@/composables/upload/uploadTracking";
import { useUploadBatchOperations } from "@/composables/upload/useUploadBatchOperations";
import { errorMessageAsString } from "@/utils/simple-error";
import type { UploadDatasetsConfig } from "@/utils/upload";
import { isFetchApiCompatible, uploadCollectionDatasets, uploadDatasets } from "@/utils/upload";
/**
* Composable that provides a centralized handler for submitting a prepared upload
* to the Galaxy API.
*/
export function useUploadSubmission() {
const uploadState = useUploadState();
const uploadBatchOperations = useUploadBatchOperations({ autoRecover: false });
const { config: galaxyConfig } = useConfig();
function initializeUploads(prepared: PreparedUpload): InitializedUploads {
const collectionConfig = prepared.collectionConfig;
const directCreation = isDirectCollectionCreation(prepared);
return initializeTrackedUploads(uploadState, prepared.uploadItems, {
collectionConfig,
directCreation,
startUploading: true,
});
}
/**
* Determines if the prepared upload is for direct collection creation.
* Data library items are not compatible with direct collection creation because
* they require copying datasets rather than uploading files, so the presence of any
* data library items means we cannot do direct collection creation.
*/
function isDirectCollectionCreation(prepared: PreparedUpload): boolean {
return Boolean(prepared.collectionConfig && prepared.uploadItems?.every(isFetchApiCompatible));
}
/**
* Process API-based uploads with progress tracking.
*
* This function handles the upload of file-based items through the Galaxy API,
* supporting both regular dataset uploads and direct collection creation. Progress
* is tracked via callbacks and the upload state store.
*/
async function processApiUploads(
prepared: PreparedUpload,
apiIds: string[],
datasets: UploadedDataset[],
trackedUploads: TrackedUpload[],
batchId?: string,
directCollectionCreation?: boolean,
onProgress?: (percentage: number) => void,
): Promise<void> {
if (prepared.apiItems.length === 0) {
return;
}
return new Promise<void>((resolve, reject) => {
const config: UploadDatasetsConfig = {
chunkSize: galaxyConfig.value.chunk_upload_size as number,
success: (response) => {
const uploadedDatasets = datasetsFromFetchResponse(response);
markTrackedCompleted(uploadState, apiIds);
datasets.push(...uploadedDatasets);
if (batchId) {
if (directCollectionCreation) {
const createdCollection = uploadedDatasets.find((dataset) => dataset.src === "hdca");
if (createdCollection) {
uploadState.setBatchCollectionId(batchId, createdCollection.id);
}
uploadState.updateBatchStatus(batchId, "completed");
} else {
uploadedDatasets
.filter((dataset) => dataset.src === "hda")
.forEach((dataset) => uploadState.addBatchDatasetId(batchId, dataset.id));
}
}
resolve();
},
error: (uploadError) => {
const errorMessage = errorMessageAsString(uploadError);
markTrackedError(uploadState, trackedUploads, errorMessage);
if (batchId) {
uploadState.setBatchError(batchId, errorMessage);
}
reject(uploadError);
},
progress: (percentage) => {
onProgress?.(percentage);
updateTrackedProgress(uploadState, apiIds, percentage);
},
};
if (prepared.collectionConfig && directCollectionCreation) {
uploadCollectionDatasets(
prepared.apiItems,
{
collectionName: prepared.collectionConfig.name,
collectionType: prepared.collectionConfig.type,
},
config,
);
} else {
uploadDatasets(prepared.apiItems, {
...config,
composite: prepared.uploadOptions?.composite,
compositeName: prepared.uploadOptions?.compositeName,
});
}
});
}
/**
* Process library dataset uploads with progress tracking.
*
* This function copies datasets from data libraries into the current history.
* Each library dataset is processed sequentially, with progress tracking for each item.
*
* @param libraryUploads - Array of tracked library upload items to process
* @param historyId - The target history ID to copy datasets into
* @param datasets - Array to collect successfully copied datasets
* @param batchId - Optional batch ID for batch upload tracking
* @returns Promise that resolves when all library uploads complete
*/
async function processLibraryUploads(
libraryUploads: TrackedUpload<LibraryDatasetUploadItem>[],
historyId: string,
datasets: UploadedDataset[],
batchId?: string,
): Promise<void> {
for (const tracked of libraryUploads) {
uploadState.updateProgress(tracked.id, 50);
const copied = await copyDataset(tracked.item.lddaId, historyId, "dataset", "library");
if (copied && "id" in copied && copied.id) {
const copiedName =
"name" in copied && typeof copied.name === "string" ? copied.name : tracked.item.name;
const copiedHid = "hid" in copied && typeof copied.hid === "number" ? copied.hid : undefined;
datasets.push({
id: copied.id,
name: copiedName,
hid: copiedHid,
src: "hda",
});
if (batchId) {
uploadState.addBatchDatasetId(batchId, copied.id);
}
}
markTrackedCompleted(uploadState, [tracked.id]);
}
}
/**
* Submit a prepared upload to Galaxy and return the resulting datasets.
*
* Progress is tracked automatically in the upload state store so the
* progress panel reflects upload status. An optional `onProgress` callback
* can be used by the caller to update its own local progress indicator
* (e.g. the modal progress bar).
*/
async function submitPreparedUpload(
historyId: string,
prepared: PreparedUpload,
onProgress?: (percentage: number) => void,
): Promise<UploadedDataset[]> {
const datasets: UploadedDataset[] = [];
const directCollectionCreation = isDirectCollectionCreation(prepared);
const { trackedUploads, batchId } = initializeUploads(prepared);
const { apiIds, libraryUploads } = splitTrackedUploadsByType(trackedUploads);
await processApiUploads(
prepared,
apiIds,
datasets,
trackedUploads,
batchId,
directCollectionCreation,
onProgress,
);
await processLibraryUploads(libraryUploads, historyId, datasets, batchId);
if (batchId && prepared.collectionConfig && !directCollectionCreation) {
await uploadBatchOperations.createCollection(batchId);
}
return datasets;
}
return { submitPreparedUpload };
}
-405
View File
@@ -1,405 +0,0 @@
import { suppressExpectedErrorMessages } from "@tests/vitest/helpers";
import assert from "assert";
import flushPromises from "flush-promises";
import { http, HttpResponse } from "msw";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useServerMock } from "@/api/client/__mocks__";
import { useUploadState } from "@/components/Panels/Upload/uploadState";
import type { NewUploadItem } from "@/composables/upload/uploadItemTypes";
import { type CollectionConfig, useUploadQueue, validateUploadItem } from "./uploadQueue";
// TUS upload is a non-HTTP protocol — mock it so tests never attempt real TUS connections.
vi.mock("@/utils/tusUpload", () => ({
createTusUpload: vi.fn(),
NamedBlob: class {},
}));
const { server } = useServerMock();
/** Creates a paste-content upload item. */
function makePastedItem(overrides: Partial<NewUploadItem> = {}): NewUploadItem {
return {
uploadMode: "paste-content",
name: "file.txt",
content: "hello world",
size: 11,
targetHistoryId: "hist_1",
dbkey: "?",
extension: "auto",
spaceToTab: false,
toPosixLines: false,
deferred: false,
...overrides,
} as NewUploadItem;
}
/** Creates a paste-links (URL) upload item. URL items use direct fetchDatasets — no TUS involved. */
function makeUrlItem(overrides: Partial<NewUploadItem> = {}): NewUploadItem {
return {
uploadMode: "paste-links",
name: "file.txt",
url: "http://example.com/file.txt",
size: 0,
targetHistoryId: "hist_1",
dbkey: "?",
extension: "auto",
spaceToTab: false,
toPosixLines: false,
deferred: false,
...overrides,
} as NewUploadItem;
}
/** Creates a remote-files upload item. Same shape as paste-links but with a different uploadMode. */
function makeRemoteFilesItem(overrides: Partial<NewUploadItem> = {}): NewUploadItem {
return {
uploadMode: "remote-files",
name: "file.txt",
url: "ftp://server/file.txt",
size: 0,
targetHistoryId: "hist_1",
dbkey: "?",
extension: "auto",
spaceToTab: false,
toPosixLines: false,
deferred: false,
...overrides,
} as NewUploadItem;
}
/** Creates a data-library upload item for testing the two-step collection path. */
function makeLibraryItem(overrides: Partial<NewUploadItem> = {}): NewUploadItem {
return {
uploadMode: "data-library",
name: "library.txt",
size: 0,
targetHistoryId: "hist_1",
dbkey: "?",
extension: "auto",
spaceToTab: false,
toPosixLines: false,
deferred: false,
libraryId: "lib_1",
folderId: "folder_1",
lddaId: "ldda_1",
url: "/api/libraries/lib_1/datasets/ldda_1",
...overrides,
} as NewUploadItem;
}
function makeCollectionConfig(overrides: Partial<CollectionConfig> = {}): CollectionConfig {
return {
name: "My Collection",
type: "list",
historyId: "hist_1",
hideSourceItems: false,
...overrides,
};
}
/** Creates a local-file upload item with a real File object. */
function makeLocalFileItem(overrides: Partial<NewUploadItem> = {}): NewUploadItem {
const file = new File(["content"], "test.txt");
return {
uploadMode: "local-file",
name: "test.txt",
size: file.size,
targetHistoryId: "hist_1",
dbkey: "?",
extension: "auto",
spaceToTab: false,
toPosixLines: false,
deferred: false,
fileData: file,
...overrides,
} as NewUploadItem;
}
describe("validateUploadItem", () => {
it.each([
["paste-content", makePastedItem()],
["paste-links", makeUrlItem()],
["remote-files", makeRemoteFilesItem()],
["data-library", makeLibraryItem()],
["local-file", makeLocalFileItem()],
] as [string, NewUploadItem][])("accepts a valid %s item", (_mode, item) => {
expect(validateUploadItem(item)).toBeUndefined();
});
it("rejects paste-content with empty content", () => {
const item = makePastedItem({ content: " " });
expect(validateUploadItem(item)).toMatch(/No content provided/);
});
it("rejects paste-links with missing URL", () => {
expect(validateUploadItem(makeUrlItem({ url: "" }))).toMatch(/No URL provided/);
});
it("rejects remote-files with missing URL", () => {
expect(validateUploadItem(makeRemoteFilesItem({ url: " " }))).toMatch(/No URL provided/);
});
it("rejects data-library with no lddaId", () => {
expect(validateUploadItem(makeLibraryItem({ lddaId: "" }))).toMatch(/No library dataset ID/);
});
it("rejects local-file with no file data", () => {
const item: NewUploadItem = {
uploadMode: "local-file",
name: "missing.txt",
size: 0,
targetHistoryId: "hist_1",
dbkey: "?",
extension: "auto",
spaceToTab: false,
toPosixLines: false,
deferred: false,
};
expect(validateUploadItem(item)).toMatch(/No file selected/);
});
it("rejects local-file with an empty file", () => {
const emptyFile = new File([], "empty.txt");
const item: NewUploadItem = {
uploadMode: "local-file",
name: "empty.txt",
size: 0,
targetHistoryId: "hist_1",
dbkey: "?",
extension: "auto",
spaceToTab: false,
toPosixLines: false,
deferred: false,
fileData: emptyFile,
};
expect(validateUploadItem(item)).toMatch(/is empty/);
});
it("rejects an unknown upload mode", () => {
const item = { ...makePastedItem(), uploadMode: "unknown-mode" } as unknown as NewUploadItem;
expect(validateUploadItem(item)).toMatch(/Unknown upload mode/);
});
});
describe("useUploadQueue", () => {
let queue: ReturnType<typeof useUploadQueue>;
beforeEach(() => {
// Fresh Pinia instance for each test to prevent cross-test store state.
setActivePinia(createPinia());
// Reset the shared upload state singleton before creating a new queue.
useUploadState().clearAll();
// Each call creates fresh local queue/batch state and re-runs recoverIncompleteBatches.
queue = useUploadQueue();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("enqueue — single item", () => {
it("marks the item as completed after a successful upload", async () => {
server.use(http.post("/api/tools/fetch", () => HttpResponse.json({})));
const [id] = queue.enqueue([makeUrlItem()]);
await flushPromises();
expect(queue.state.activeItems.value.find((i) => i.id === id)?.status).toBe("completed");
});
it("all items reach completed status when multiple items are enqueued", async () => {
server.use(http.post("/api/tools/fetch", () => HttpResponse.json({})));
const ids = queue.enqueue([
makeUrlItem({ name: "a.txt" }),
makeUrlItem({ name: "b.txt" }),
makeUrlItem({ name: "c.txt" }),
]);
await flushPromises();
for (const id of ids) {
expect(queue.state.activeItems.value.find((i) => i.id === id)?.status).toBe("completed");
}
});
it("marks the item as error when the upload endpoint returns an error", async () => {
server.use(
http.post("/api/tools/fetch", () =>
HttpResponse.json({ err_msg: "Server unavailable" }, { status: 500 }),
),
);
const [id] = queue.enqueue([makeUrlItem()]);
await flushPromises();
const item = queue.state.activeItems.value.find((i) => i.id === id);
expect(item?.status).toBe("error");
expect(item?.error).toBeTruthy();
});
it("marks item as error when enqueued with invalid content (no server call needed)", async () => {
const [id] = queue.enqueue([makePastedItem({ content: " " })]);
await flushPromises();
const item = queue.state.activeItems.value.find((i) => i.id === id);
expect(item?.status).toBe("error");
expect(item?.error).toMatch(/No content provided/);
});
});
describe("enqueue — direct collection batch", () => {
it("marks the batch as completed after a successful collection upload", async () => {
server.use(http.post("/api/tools/fetch", () => HttpResponse.json({})));
queue.enqueue([makeUrlItem({ name: "a.txt" }), makeUrlItem({ name: "b.txt" })], makeCollectionConfig());
await flushPromises();
const batch = queue.state.activeBatches.value[0];
expect(batch?.status).toBe("completed");
});
it("marks the batch as error when the upload endpoint fails", async () => {
suppressExpectedErrorMessages(["Upload failed"]);
server.use(
http.post("/api/tools/fetch", () => HttpResponse.json({ err_msg: "Upload failed" }, { status: 500 })),
);
queue.enqueue([makeUrlItem()], makeCollectionConfig());
await flushPromises();
const batch = queue.state.activeBatches.value[0];
expect(batch?.status).toBe("error");
});
});
// ── Two-step collection batch (data-library items) ───────────────────────
//
// data-library items cannot use direct HDCA creation (they use copyDataset instead
// of /api/tools/fetch). Each item is uploaded individually; after all items complete,
// the collection is created via POST /api/dataset_collections.
describe("enqueue — two-step collection batch (data-library)", () => {
it("creates the collection after all library items are successfully copied", async () => {
server.use(
http.post("/api/histories/:historyId/contents/:type", () => HttpResponse.json({ id: "ds_lib_1" })),
http.post("/api/dataset_collections", () => HttpResponse.json({ id: "col_1" })),
);
queue.enqueue([makeLibraryItem()], makeCollectionConfig());
await flushPromises();
const batch = queue.state.activeBatches.value[0];
expect(batch?.status).toBe("completed");
expect(batch?.collectionId).toBe("col_1");
});
it("marks the batch as error when collection creation fails after successful uploads", async () => {
suppressExpectedErrorMessages(["Collection creation failed:", "Collection error"]);
server.use(
http.post("/api/histories/:historyId/contents/:type", () => HttpResponse.json({ id: "ds_lib_1" })),
http.post("/api/dataset_collections", () =>
HttpResponse.json({ err_msg: "Collection error" }, { status: 500 }),
),
);
queue.enqueue([makeLibraryItem()], makeCollectionConfig());
await flushPromises();
const batch = queue.state.activeBatches.value[0];
expect(batch?.status).toBe("error");
expect(batch?.collectionId).toBeUndefined();
});
});
describe("retryCollectionCreation", () => {
it("re-attempts collection creation and resolves the batch after a previous failure", async () => {
suppressExpectedErrorMessages(["Collection creation failed:", "Temporary error"]);
// Phase 1: uploads succeed, collection creation fails.
server.use(
http.post("/api/histories/:historyId/contents/:type", () => HttpResponse.json({ id: "ds_lib_1" })),
http.post("/api/dataset_collections", () =>
HttpResponse.json({ err_msg: "Temporary error" }, { status: 500 }),
),
);
queue.enqueue([makeLibraryItem()], makeCollectionConfig());
await flushPromises();
const batchId = queue.state.activeBatches.value[0]?.id;
assert(batchId, "Expected a batch to be created");
expect(queue.state.getBatch(batchId)?.status).toBe("error");
// Phase 2: collection creation now succeeds.
server.use(http.post("/api/dataset_collections", () => HttpResponse.json({ id: "col_retried" })));
await queue.retryCollectionCreation(batchId);
await flushPromises();
expect(queue.state.getBatch(batchId)?.status).toBe("completed");
expect(queue.state.getBatch(batchId)?.collectionId).toBe("col_retried");
});
});
describe("clearCompleted", () => {
it("removes completed uploads from state", async () => {
server.use(http.post("/api/tools/fetch", () => HttpResponse.json({})));
const [completedId] = queue.enqueue([makeUrlItem()]);
await flushPromises();
expect(queue.state.activeItems.value.find((i) => i.id === completedId)?.status).toBe("completed");
queue.clearCompleted();
expect(queue.state.activeItems.value.find((i) => i.id === completedId)).toBeUndefined();
});
});
describe("clearAll", () => {
it("empties all items, batches, and internal queue state", async () => {
server.use(http.post("/api/tools/fetch", () => HttpResponse.json({})));
queue.enqueue([makeUrlItem(), makeUrlItem()], makeCollectionConfig());
await flushPromises();
queue.clearAll();
expect(queue.state.activeItems.value).toHaveLength(0);
expect(queue.state.activeBatches.value).toHaveLength(0);
});
});
// On initialization, the queue checks for two-step batches where uploads completed
// in a previous session but collection creation did not finish (e.g. page was refreshed).
describe("recoverIncompleteBatches", () => {
it("automatically creates the collection for a two-step batch with completed uploads and dataset IDs", async () => {
const uploadState = useUploadState();
// Simulate state left from a previous browser session:
// one completed upload item and a pending two-step batch with a dataset ID.
const itemId = uploadState.addUploadItem(makeUrlItem({ name: "recovered.txt" }));
uploadState.setStatus(itemId, "uploading");
uploadState.updateProgress(itemId, 100); // auto-marks as completed
const batchId = uploadState.addBatch(
{ name: "Recovery Collection", type: "list", hideSourceItems: false, historyId: "hist_1" },
[itemId],
false, // not directCreation — qualifies for recovery
);
uploadState.addBatchDatasetId(batchId, "ds_recovered");
server.use(http.post("/api/dataset_collections", () => HttpResponse.json({ id: "col_recovered" })));
// Creating a new queue instance triggers recoverIncompleteBatches.
const recoveryQueue = useUploadQueue();
await flushPromises();
expect(recoveryQueue.state.getBatch(batchId)?.collectionId).toBe("col_recovered");
expect(recoveryQueue.state.getBatch(batchId)?.status).toBe("completed");
});
});
});
-825
View File
@@ -1,825 +0,0 @@
/**
* Upload queue composable for managing dataset uploads to Galaxy.
*
* This composable provides a queue-based upload system that:
* - Converts UI upload items to API-ready format
* - Processes uploads sequentially with progress tracking
* - Persists upload state for UI monitoring
* - Creates dataset collections after successful batch uploads
*/
import type { CollectionElementIdentifiers } from "@/api";
import { createHistoryDatasetCollectionInstanceFull } from "@/api/datasetCollections";
import { copyDataset } from "@/api/datasets";
import type { FetchDataResponse } from "@/api/tools";
import {
COMMON_FILTERS,
DEFAULT_FILTER,
guessInitialFilterType,
guessNameForPair,
} from "@/components/Collections/pairing";
import { useUploadState } from "@/components/Panels/Upload/uploadState";
import type { CollectionCreationInput, SupportedCollectionType } from "@/composables/upload/collectionTypes";
import type { CompositeFileUploadItem, NewUploadItem } from "@/composables/upload/uploadItemTypes";
import { useHistoryStore } from "@/stores/historyStore";
import { getHistoryUploadActionErrorMessage, getHistoryUploadBlockReason } from "@/utils/historyUpload";
import { errorMessageAsString } from "@/utils/simple-error";
import {
createFileUploadItem,
createPastedUploadItem,
createUrlUploadItem,
toApiUploadItem,
uploadCollectionDatasets,
uploadDatasets,
} from "@/utils/upload";
/**
* Collection configuration for batch uploads that will be combined
* into a collection after uploads complete.
*/
export interface CollectionConfig extends CollectionCreationInput {
/** Whether to hide source datasets after collection creation */
hideSourceItems: boolean;
/** Target history ID where the collection will be created */
historyId: string;
}
/**
* Represents a batch of uploads that will be combined into a dataset collection
* after all uploads complete successfully.
*/
interface CollectionBatch {
/** Batch ID used in the persisted upload state */
batchId: string;
/** Upload item IDs belonging to this batch */
ids: string[];
/** Original upload items (needed for building collection elements) */
items: NewUploadItem[];
/** Dataset IDs collected from successful upload responses */
datasetIds: string[];
/** Collection configuration specifying name, type, target history, and options */
collectionConfig: CollectionConfig;
}
/**
* Builds collection element identifiers based on collection type.
* Uses the shared pairing abstractions from @/components/Collections/pairing for
* pair name extraction (synchronized with the backend via auto_pairing_spec.yml).
*
* @param items - Upload items with dataset IDs
* @param datasetIds - Array of created dataset IDs (in upload order)
* @param collectionType - Type of collection to create
* @returns Collection element identifiers ready for API
*/
function buildCollectionElements(
items: NewUploadItem[],
datasetIds: string[],
collectionType: SupportedCollectionType,
): CollectionElementIdentifiers {
if (collectionType === "list") {
// Simple list: one element per dataset
return items.map((item, index) => ({
name: item.name || `element_${index + 1}`,
src: "hda" as const,
id: datasetIds[index],
}));
} else {
// List of pairs: group consecutive files into pairs
const pairs: CollectionElementIdentifiers = [];
const usedNames = new Set<string>();
// Use the shared filter detection to determine forward/reverse naming convention
const filterType = guessInitialFilterType(items) ?? DEFAULT_FILTER;
const [forwardFilter, reverseFilter] = COMMON_FILTERS[filterType];
for (let i = 0; i < items.length; i += 2) {
if (i + 1 >= items.length) {
// Odd number of files - skip last one or handle as error
console.warn(`Skipping unpaired file at index ${i}: ${items[i]?.name}`);
break;
}
const item1 = items[i];
const item2 = items[i + 1];
if (!item1 || !item2) {
continue;
}
const basePairName =
guessNameForPair(item1, item2, forwardFilter, reverseFilter, true) || `pair_${Math.floor(i / 2) + 1}`;
let pairName = basePairName;
// Ensure unique pair names by adding suffix if needed
let counter = 1;
while (usedNames.has(pairName)) {
pairName = `${basePairName}_${counter}`;
counter++;
}
usedNames.add(pairName);
pairs.push({
collection_type: "paired",
src: "new_collection" as const,
name: pairName,
element_identifiers: [
{
name: "forward",
src: "hda" as const,
id: datasetIds[i],
},
{
name: "reverse",
src: "hda" as const,
id: datasetIds[i + 1],
},
],
});
}
return pairs;
}
}
/**
* Validates a UI upload item before conversion.
* Returns an error message if invalid, undefined if valid.
*
* @param item - The upload item to validate
* @returns Error message if invalid, undefined if valid
*/
export function validateUploadItem(item: NewUploadItem): string | undefined {
switch (item.uploadMode) {
case "local-file":
if (!item.fileData) {
return `No file selected for "${item.name}"`;
}
if (item.fileData.size === 0) {
return `File "${item.name}" is empty`;
}
break;
case "paste-content":
if (!item.content || item.content.trim().length === 0) {
return `No content provided for "${item.name}"`;
}
break;
case "paste-links":
case "remote-files":
if (!item.url || item.url.trim().length === 0) {
return `No URL provided for "${item.name}"`;
}
break;
case "data-library":
if (!item.lddaId) {
return `No library dataset ID provided for "${item.name}"`;
}
break;
case "composite-file": {
if (!item.extension || item.extension === "auto") {
return `No composite type selected for "${item.name}"`;
}
for (const slot of item.slots) {
if (slot.optional) {
continue;
}
const isEmpty =
(slot.src === "files" && !slot.file) ||
(slot.src === "url" && !slot.url?.trim()) ||
(slot.src === "paste" && !slot.content?.trim());
if (isEmpty) {
return `Required slot "${slot.slotName}" has no content for "${item.name}"`;
}
}
break;
}
default:
return `Unknown upload mode: ${(item as NewUploadItem).uploadMode}`;
}
return undefined;
}
/**
* Composable for managing upload queue and processing.
*
* Provides methods to enqueue different types of uploads and track their progress.
* Uses the shared upload state for persistence and UI updates.
* Supports automatic collection creation after batch uploads.
*
* @example
* ```typescript
* const uploadQueue = useUploadQueue();
*
* // Enqueue files for upload
* const ids = uploadQueue.enqueue([
* { uploadMode: "local-file", fileData: file, ... },
* { uploadMode: "paste-links", url: "http://...", ... },
* ]);
*
* // Clear completed uploads
* uploadQueue.clearCompleted();
* ```
*/
export function useUploadQueue() {
const uploadState = useUploadState();
const historyStore = useHistoryStore();
const queue: string[] = [];
const batches: CollectionBatch[] = [];
let processing = false;
/**
* Helper to find an upload item by ID from the active items.
*/
function findUploadItem(id: string) {
return uploadState.activeItems.value.find((i) => i.id === id);
}
/**
* Creates a dataset collection from uploaded datasets.
*
* @param batchId - Batch ID in upload state
*/
async function createCollection(batchId: string): Promise<void> {
const batchState = uploadState.getBatch(batchId);
if (!batchState) {
console.error(`Batch not found: ${batchId}`);
return;
}
// Check if collection already created (avoid duplicates on retry)
if (batchState.collectionId) {
console.log(`Collection already created for batch ${batchId}: ${batchState.collectionId}`);
return;
}
const { name, type, hideSourceItems, historyId, datasetIds } = batchState;
// Check if we have dataset IDs (either from persisted state or internal batch)
if (!datasetIds || datasetIds.length === 0) {
const errorMsg = "No dataset IDs available for collection creation";
uploadState.setBatchError(batchId, errorMsg);
return;
}
// Get upload items to build collection elements
// Try internal batch first (has full item data), fall back to state
const batch = batches.find((b) => b.batchId === batchId);
const items =
batch?.items ||
batchState.uploadIds
.map((id) => findUploadItem(id))
.filter((item): item is NonNullable<typeof item> => item !== undefined);
if (items.length === 0) {
const errorMsg = "No upload items available for collection creation";
uploadState.setBatchError(batchId, errorMsg);
return;
}
// Validate items have required data
if (items.length !== batchState.uploadIds.length) {
const errorMsg = `Cannot create collection: only ${items.length} of ${batchState.uploadIds.length} upload items found. This can happen after a page refresh. Please re-upload the files or manually create the collection.`;
uploadState.setBatchError(batchId, errorMsg);
return;
}
uploadState.updateBatchStatus(batchId, "creating-collection");
try {
// Build element identifiers based on collection type
const elementIdentifiers = buildCollectionElements(items, datasetIds, type);
if (elementIdentifiers.length === 0) {
throw new Error("No valid collection elements to create");
}
// Create collection using the API
const response = await createHistoryDatasetCollectionInstanceFull({
name,
collection_type: type,
element_identifiers: elementIdentifiers,
history_id: historyId,
hide_source_items: hideSourceItems,
instance_type: "history",
copy_elements: true,
fields: "auto",
});
uploadState.setBatchCollectionId(batchId, response.id);
uploadState.updateBatchStatus(batchId, "completed");
console.log(`Successfully created ${type} collection: ${name} (${response.id})`);
} catch (err) {
// Collection creation failed - datasets are still uploaded
const errorMsg = errorMessageAsString(err);
console.error("Collection creation failed:", errorMsg);
uploadState.setBatchError(batchId, errorMsg);
// Mark all batch items with error message (non-fatal)
const batchForError = uploadState.getBatch(batchId);
batchForError?.uploadIds.forEach((id) => {
const item = findUploadItem(id);
if (item && !item.error) {
item.error = `Uploaded successfully, but collection creation failed`;
}
});
}
}
/**
* Retries collection creation for a failed batch.
* @param batchId - Batch ID to retry
*/
async function retryCollectionCreation(batchId: string): Promise<void> {
const batch = uploadState.getBatch(batchId);
if (!batch) {
console.error(`Batch not found: ${batchId}`);
return;
}
// Reset error state
batch.error = undefined;
uploadState.updateBatchStatus(batchId, "uploading");
// Clear error messages from individual upload items
batch.uploadIds.forEach((id) => {
const item = findUploadItem(id);
if (item?.error?.includes("collection creation failed")) {
item.error = undefined;
}
});
try {
await createCollection(batchId);
} catch (err) {
uploadState.setBatchError(batchId, `Retry failed: ${errorMessageAsString(err)}`);
}
}
/**
* Stores a dataset ID in both the internal batch and persisted state.
*
* @param batch - Internal batch tracking object
* @param datasetId - Dataset ID to store
*/
function collectDatasetId(batch: CollectionBatch | undefined, datasetId: string) {
if (batch) {
batch.datasetIds.push(datasetId);
uploadState.addBatchDatasetId(batch.batchId, datasetId);
}
}
/**
* Checks if all uploads in a batch have completed (success or error).
*
* @param batch - Internal batch tracking object
* @returns True if all uploads are complete
*/
function isBatchComplete(batch: CollectionBatch): boolean {
return batch.ids.every((uploadId) => {
const item = findUploadItem(uploadId);
return item?.status === "completed" || item?.status === "error";
});
}
/**
* Checks batch completion status and triggers collection creation if ready.
*
* @param batch - Internal batch tracking object
*/
async function checkAndCompleteBatch(batch?: CollectionBatch): Promise<void> {
if (!batch) {
return;
}
if (isBatchComplete(batch)) {
await createCollection(batch.batchId).catch((err) => {
uploadState.setBatchError(batch.batchId, errorMessageAsString(err));
});
}
}
/**
* Determines if a collection batch should use direct HDCA creation.
* Returns false for batches containing data-library items (which need the two-step approach
* since they use copyDataset instead of /api/tools/fetch).
*/
function canUseDirectCollection(batch: CollectionBatch): boolean {
return batch.items.every((item) => item.uploadMode !== "data-library");
}
async function validateTargetHistory(targetHistoryId: string): Promise<string | null> {
let history = historyStore.getHistoryById(targetHistoryId, false) ?? null;
if (!history) {
await historyStore.loadHistoryById(targetHistoryId);
history = historyStore.getHistoryById(targetHistoryId, false) ?? null;
}
// If history still cannot be resolved, treat as no validation error here
// (downstream upload logic will surface appropriate API errors if needed)
if (!history) {
return null;
}
const blockReason = getHistoryUploadBlockReason(history);
return blockReason ? getHistoryUploadActionErrorMessage(blockReason) : null;
}
/**
* Processes an entire collection batch as a single HDCA upload.
* All items are uploaded together in one /api/tools/fetch request with
* destination { type: "hdca" }, creating the collection atomically.
*/
async function processCollectionBatch(batch: CollectionBatch): Promise<void> {
const { batchId, items, collectionConfig } = batch;
// Mark all items as uploading
batch.ids.forEach((id) => uploadState.setStatus(id, "uploading"));
uploadState.updateBatchStatus(batchId, "uploading");
try {
const historyError = await validateTargetHistory(collectionConfig.historyId);
if (historyError) {
throw new Error(historyError);
}
// Validate all items first
for (const item of items) {
const validationError = validateUploadItem(item);
if (validationError) {
throw new Error(validationError);
}
}
// Convert all UI items to API items
const apiItems = items.map((item) => toApiUploadItem(item));
await uploadCollectionDatasets(
apiItems,
{
collectionName: collectionConfig.name,
collectionType: collectionConfig.type,
},
{
progress: (percentage) => {
batch.ids.forEach((id) => uploadState.updateProgress(id, percentage));
},
success: (_response: FetchDataResponse) => {
batch.ids.forEach((id) => uploadState.updateProgress(id, 100));
uploadState.updateBatchStatus(batchId, "completed");
},
error: (err) => {
const errorMsg = errorMessageAsString(err);
batch.ids.forEach((id) => uploadState.setError(id, errorMsg));
uploadState.setBatchError(batchId, errorMsg);
},
},
);
} catch (err) {
const errorMsg = errorMessageAsString(err);
batch.ids.forEach((id) => uploadState.setError(id, errorMsg));
uploadState.setBatchError(batchId, errorMsg);
}
}
/**
* Processes a library dataset upload by importing it to the target history.
*
* @param id - Upload item ID
* @param item - Upload item with library dataset details
* @param batch - Internal batch tracking object (if part of a batch)
*/
async function processLibraryDatasetUpload(
id: string,
item: NewUploadItem,
batch?: CollectionBatch,
): Promise<void> {
if (item.uploadMode !== "data-library") {
throw new Error("Invalid upload mode for library dataset upload");
}
uploadState.updateProgress(id, 50);
// Import library dataset to history
const response = await copyDataset(item.lddaId, item.targetHistoryId, "dataset", "library");
uploadState.updateProgress(id, 100);
// Collect dataset ID for collection creation
// Response is an HDA (HistoryDatasetAssociation) which has an id field
if (response && "id" in response && response.id) {
collectDatasetId(batch, response.id);
}
// Check if batch is ready for collection creation
await checkAndCompleteBatch(batch);
}
/**
* Processes a composite upload by converting each slot into an ApiUploadItem
* and submitting them together as a single composite dataset.
*
* @param id - Upload item ID
* @param item - CompositeFileUploadItem with all slot data
*/
async function processCompositeFileUpload(id: string, item: CompositeFileUploadItem): Promise<void> {
const baseOptions = {
dbkey: item.dbkey,
ext: item.extension,
space_to_tab: item.spaceToTab,
to_posix_lines: item.toPosixLines,
deferred: false,
};
// Build one ApiUploadItem per slot, all sharing the dataset-level ext/dbkey/name
const slotApiItems = item.slots
.filter((slot) => slot.src !== "files" || !!slot.file) // skip empty optional local-file slots
.map((slot) => {
const slotOptions = {
name: slot.slotName,
...baseOptions,
};
if (slot.src === "files") {
return createFileUploadItem(slot.file!, item.targetHistoryId, {
...slotOptions,
size: slot.file!.size,
});
} else if (slot.src === "url") {
return createUrlUploadItem(slot.url!, item.targetHistoryId, {
...slotOptions,
size: 0,
});
} else {
return createPastedUploadItem(slot.content ?? "", item.targetHistoryId, {
...slotOptions,
size: (slot.content ?? "").length,
});
}
});
await uploadDatasets(slotApiItems, {
composite: true,
compositeName: item.name,
progress: (percentage) => uploadState.updateProgress(id, percentage),
success: () => {
uploadState.updateProgress(id, 100);
},
error: (err) => {
uploadState.setError(id, errorMessageAsString(err));
},
});
}
/**
* Processes a regular upload (file, URL, or pasted content) via the upload API.
*
* @param id - Upload item ID
* @param item - Upload item to process
* @param batch - Internal batch tracking object (if part of a batch)
*/
async function processRegularUpload(id: string, item: NewUploadItem, batch?: CollectionBatch): Promise<void> {
const uploadItem = toApiUploadItem(item);
await uploadDatasets([uploadItem], {
progress: (percentage) => uploadState.updateProgress(id, percentage),
success: (response: FetchDataResponse) => {
uploadState.updateProgress(id, 100);
// Collect dataset IDs for collection creation
if (batch && response.outputs) {
// The outputs field is Record<string, unknown> but is actually an array of dataset objects
const outputs = response.outputs as unknown as Array<{
id: string;
hid?: number;
name?: string;
}>;
const datasetId = outputs[0]?.id;
if (datasetId) {
collectDatasetId(batch, datasetId);
}
// Check if batch is ready for collection creation
checkAndCompleteBatch(batch);
}
},
error: (err) => {
uploadState.setError(id, errorMessageAsString(err));
},
});
}
/**
* Processes the next upload in the queue.
* Orchestrates upload execution by delegating to specialized handlers.
*
* When the next item belongs to a collection batch that can use direct
* HDCA creation, all items in the batch are processed together in a
* single /api/tools/fetch request instead of one-by-one.
*/
async function processNext(): Promise<void> {
if (processing || queue.length === 0) {
return;
}
const id = queue[0]!; // Peek, don't remove yet
const item = findUploadItem(id);
if (!item) {
queue.shift();
// Item was removed from state (e.g., user cleared it), skip to next
return processNext();
}
// Check if this item belongs to a collection batch that can use direct creation
const batch = batches.find((b) => b.ids.includes(id));
if (batch && canUseDirectCollection(batch)) {
// Remove all batch items from queue at once
for (const batchItemId of batch.ids) {
const idx = queue.indexOf(batchItemId);
if (idx !== -1) {
queue.splice(idx, 1);
}
}
processing = true;
try {
await processCollectionBatch(batch);
} finally {
processing = false;
processNext();
}
return;
}
// Single-item processing (non-collection or data-library fallback)
queue.shift();
processing = true;
uploadState.setStatus(id, "uploading");
try {
const historyError = await validateTargetHistory(item.targetHistoryId);
if (historyError) {
throw new Error(historyError);
}
// Pre-validate before attempting upload
const validationError = validateUploadItem(item);
if (validationError) {
throw new Error(validationError);
}
// Delegate to appropriate upload handler based on upload mode
if (item.uploadMode === "data-library") {
await processLibraryDatasetUpload(id, item, batch);
} else if (item.uploadMode === "composite-file") {
await processCompositeFileUpload(id, item);
} else {
await processRegularUpload(id, item, batch);
}
} catch (err) {
// This catches validation errors and any unexpected errors
uploadState.setError(id, errorMessageAsString(err));
} finally {
processing = false;
processNext();
}
}
/**
* Enqueues items and starts processing.
*
* @param items - Array of upload items to enqueue
* @param collectionConfig - Optional collection configuration for creating a collection from uploaded datasets
* @returns Array of upload IDs for tracking
*/
function enqueue(items: NewUploadItem[], collectionConfig?: CollectionConfig): string[] {
// Determine if this batch can use direct HDCA creation
const isDirectCreation =
collectionConfig !== undefined && items.every((item) => item.uploadMode !== "data-library");
// Create batch first if collection config provided
const batchId = collectionConfig ? uploadState.addBatch(collectionConfig, [], isDirectCreation) : undefined;
// Add upload items with batch ID
const ids = items.map((item) => uploadState.addUploadItem(item, batchId));
// Update batch with upload IDs and create internal batch for tracking
if (batchId) {
const batch = uploadState.getBatch(batchId);
if (batch) {
batch.uploadIds = ids;
}
batches.push({
batchId,
ids,
items,
datasetIds: [],
collectionConfig: collectionConfig!,
});
}
queue.push(...ids);
processNext();
return ids;
}
/**
* Removes batches that no longer have any active upload items.
* Called when uploads are cleared from the state.
*/
function cleanupOrphanedBatches(): void {
for (let i = batches.length - 1; i >= 0; i--) {
const batch = batches[i]!;
const hasActiveUploads = batch.ids.some((id) => findUploadItem(id) !== undefined);
if (!hasActiveUploads) {
batches.splice(i, 1);
}
}
}
/**
* Recovers incomplete collection creation on initialization.
* Checks for batches where uploads completed but collection wasn't created.
*
* For direct-creation batches, there's no separate collection creation step
* to recover — if the upload was interrupted, the user must re-upload.
*/
function recoverIncompleteBatches(): void {
uploadState.activeBatches.value.forEach((batch) => {
// Skip if collection already created or batch has errors
if (batch.collectionId || batch.status === "error") {
return;
}
// Direct-creation batches don't have a separate collection creation step
if (batch.directCreation) {
return;
}
// Two-step batch recovery (data-library fallback path)
// Check if all uploads in batch are completed
const allCompleted = batch.uploadIds.every((uploadId) => {
const upload = findUploadItem(uploadId);
return upload?.status === "completed";
});
// If uploads are complete and we have dataset IDs, try to create the collection
if (allCompleted && batch.uploadIds.length > 0 && batch.datasetIds.length > 0) {
console.log(`Recovering incomplete batch: ${batch.name}`);
// Check if we still have the upload items (they might be lost after refresh)
const availableItems = batch.uploadIds.filter((uploadId) => findUploadItem(uploadId) !== undefined);
if (availableItems.length !== batch.uploadIds.length) {
uploadState.setBatchError(
batch.id,
`Collection creation failed: upload data lost after page refresh. Please re-upload the files to create the collection or manually create the collection.`,
);
return;
}
// Attempt to create the collection
createCollection(batch.id).catch((err) => {
console.error("Recovery failed:", err);
uploadState.setBatchError(batch.id, "Collection creation interrupted. Please retry manually.");
});
} else if (allCompleted && batch.uploadIds.length > 0) {
// Uploads complete but no dataset IDs (shouldn't happen, but handle gracefully)
uploadState.setBatchError(
batch.id,
"Collection creation interrupted and cannot be recovered. Dataset IDs not available. Please create the collection manually.",
);
}
});
}
// Initialize: recover any incomplete batches from previous session
recoverIncompleteBatches();
/** Removes all completed uploads from the state */
function clearCompleted(): void {
uploadState.clearCompleted();
cleanupOrphanedBatches();
}
/** Clears all uploads from the state */
function clearAll(): void {
uploadState.clearAll();
batches.length = 0; // Clear all batches when clearing all uploads
}
return {
enqueue,
clearCompleted,
clearAll,
retryCollectionCreation,
/** Access to upload state for UI components */
state: uploadState,
};
}
+35 -1
View File
@@ -52,8 +52,9 @@ import {
guessInitialFilterType,
guessNameForPair,
} from "@/components/Collections/pairing";
import type { PreparedUpload } from "@/components/Panels/Upload/types";
import type { UploadRowModel } from "@/components/Upload/model";
import type { SupportedCollectionType } from "@/composables/upload/collectionTypes";
import type { SupportedCollectionType, UploadCollectionConfig } from "@/composables/upload/collectionTypes";
import type { NewUploadItem } from "@/composables/upload/uploadItemTypes";
import { getAppRoot } from "@/onload/loadConfig";
import { errorMessageAsString } from "@/utils/simple-error";
@@ -1006,6 +1007,39 @@ export async function uploadDatasets(items: ApiUploadItem[], config: UploadDatas
}
}
/**
* Determines if an upload item is compatible with the Fetch API (i.e., can be included in the payload).
* Items with uploadMode "data-library" are not compatible because they are copied from the data library and require a different handling approach.
*/
export function isFetchApiCompatible(item: NewUploadItem): boolean {
return item.uploadMode !== "data-library";
}
/**
* Builds a PreparedUpload object from UI upload items.
*/
export function buildPreparedUpload(items: NewUploadItem[], collectionConfig?: UploadCollectionConfig): PreparedUpload {
return buildPreparedUploadWithOptions(items, collectionConfig);
}
interface PreparedUploadBuildOptions {
apiItems?: ApiUploadItem[];
uploadOptions?: PreparedUpload["uploadOptions"];
}
export function buildPreparedUploadWithOptions(
items: NewUploadItem[],
collectionConfig?: UploadCollectionConfig,
options?: PreparedUploadBuildOptions,
): PreparedUpload {
return {
apiItems: options?.apiItems ?? items.filter(isFetchApiCompatible).map((item) => toApiUploadItem(item)),
collectionConfig,
uploadItems: items,
uploadOptions: options?.uploadOptions,
};
}
/**
* Uploads datasets as a collection directly via a single /api/tools/fetch request.
* Uses HdcaDataItemsTarget to create the collection atomically during the fetch.