From d63faf0f2daed27ca989c08090b92d3914794c2f Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:17:21 +0100 Subject: [PATCH 01/13] Introduces composable for unified upload submission --- client/src/components/Panels/Upload/types.ts | 28 +- .../upload/useUploadSubmission.test.ts | 204 ++++++++++++++ .../composables/upload/useUploadSubmission.ts | 256 ++++++++++++++++++ client/src/utils/upload.ts | 11 + 4 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 client/src/composables/upload/useUploadSubmission.test.ts create mode 100644 client/src/composables/upload/useUploadSubmission.ts diff --git a/client/src/components/Panels/Upload/types.ts b/client/src/components/Panels/Upload/types.ts index 50894b3e383..69874e15221 100644 --- a/client/src/components/Panels/Upload/types.ts +++ b/client/src/components/Panels/Upload/types.ts @@ -1,5 +1,8 @@ import type { IconDefinition } from "@fortawesome/fontawesome-svg-core"; +import type { NewUploadItem } from "@/composables/upload/uploadItemTypes"; +import type { ApiUploadItem } from "@/utils/upload"; + export type UploadMethod = | "local-file" | "paste-content" @@ -100,6 +103,29 @@ 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[]; + /** Items used for progress tracking in the upload state store. */ + uploadItems?: NewUploadItem[]; +} + +/** + * 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; } diff --git a/client/src/composables/upload/useUploadSubmission.test.ts b/client/src/composables/upload/useUploadSubmission.test.ts new file mode 100644 index 00000000000..559aa3442ee --- /dev/null +++ b/client/src/composables/upload/useUploadSubmission.test.ts @@ -0,0 +1,204 @@ +import { getLocalVue } 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 type { LibraryDatasetUploadItem, UrlUploadItem } from "@/composables/upload/uploadItemTypes"; +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 makeUrlItem(overrides: Partial = {}): UrlUploadItem { + return { + uploadMode: "paste-links", + name: "remote.txt", + url: "https://example.org/remote.txt", + size: 0, + targetHistoryId: "hist_1", + dbkey: "?", + extension: "auto", + spaceToTab: false, + toPosixLines: false, + deferred: false, + ...overrides, + }; +} + +function makeLibraryItem(overrides: Partial = {}): 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/datasets/ldda_1", + ...overrides, + }; +} + +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: ` +
+ +
{{ result }}
+
{{ error }}
+
+ `, + }); + + return mount(Harness, { localVue, pinia: createPinia() }); +} + +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(); + 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({ 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"'); + }); +}); diff --git a/client/src/composables/upload/useUploadSubmission.ts b/client/src/composables/upload/useUploadSubmission.ts new file mode 100644 index 00000000000..0d1fa229d97 --- /dev/null +++ b/client/src/composables/upload/useUploadSubmission.ts @@ -0,0 +1,256 @@ +import { copyDataset } from "@/api/datasets"; +import type { FetchDataResponse } from "@/api/tools"; +import type { PreparedUpload } from "@/components/Panels/Upload/types"; +import type { UploadedDataset } from "@/components/Panels/Upload/uploadModalTypes"; +import { useUploadState } from "@/components/Panels/Upload/uploadState"; +import { useConfig } from "@/composables/config"; +import type { LibraryDatasetUploadItem, NewUploadItem } from "@/composables/upload/uploadItemTypes"; +import { errorMessageAsString } from "@/utils/simple-error"; +import { uploadDatasets } from "@/utils/upload"; + +interface UploadResponseData { + id: string; + name?: string; + label?: string; + hid?: number; + src?: string; +} + +interface TrackedUpload { + item: T; + id: string; +} + +function isUploadResponseData(value: unknown): value is UploadResponseData { + if (!value || typeof value !== "object") { + return false; + } + // At minimum, check that it has an 'id' property which is what we actually need + return "id" in value && typeof value.id === "string"; +} + +function isLibraryDatasetUpload(item: NewUploadItem): item is LibraryDatasetUploadItem { + return item.uploadMode === "data-library"; +} + +function isTrackedLibraryUpload(tracked: TrackedUpload): tracked is TrackedUpload { + return isLibraryDatasetUpload(tracked.item); +} + +function toUploadedDataset(output: unknown): UploadedDataset | null { + if (!isUploadResponseData(output)) { + return null; + } + + if (!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 { + 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)); + } +} + +function datasetsFromResponse(response: FetchDataResponse): UploadedDataset[] { + if (!response.outputs) { + return []; + } + + const datasets: UploadedDataset[] = []; + collectUploadedDatasets(response.outputs, datasets); + + // Deduplicate by dataset ID in case multiple nested paths contain the same output. + const seen = new Set(); + return datasets.filter((dataset) => { + if (seen.has(dataset.id)) { + return false; + } + seen.add(dataset.id); + return true; + }); +} + +/** + * Composable that provides a centralized handler for submitting a prepared upload + * to the Galaxy API. + */ +export function useUploadSubmission() { + const uploadState = useUploadState(); + const { config: galaxyConfig } = useConfig(); + + /** + * Update progress for multiple tracked uploads. + */ + const updateTrackedProgress = (ids: string[], percentage: number): void => { + ids.forEach((id) => uploadState.updateProgress(id, percentage)); + }; + + /** + * Mark tracked uploads as completed. + */ + const markTrackedCompleted = (ids: string[]): void => { + ids.forEach((id) => { + uploadState.updateProgress(id, 100); + uploadState.setStatus(id, "completed"); + }); + }; + + /** + * Mark an error for all tracked uploads. + */ + const markTrackedError = (trackedUploads: TrackedUpload[], message: string): void => { + trackedUploads.forEach((tracked) => uploadState.setError(tracked.id, message)); + }; + + /** + * Initialize tracked uploads for the given upload items. + */ + const initializeUploads = (prepared: PreparedUpload): TrackedUpload[] => { + if (!prepared.uploadItems) { + return []; + } + + const trackedUploads = prepared.uploadItems.map((item) => ({ + item, + id: uploadState.addUploadItem(item), + })); + + trackedUploads.forEach((tracked) => uploadState.setStatus(tracked.id, "uploading")); + + return trackedUploads; + }; + + /** + * Separate API uploads from library uploads. + */ + const filterUploadsByType = ( + trackedUploads: TrackedUpload[], + ): { apiIds: string[]; libraryUploads: TrackedUpload[] } => { + const apiIds: string[] = []; + const libraryUploads: TrackedUpload[] = []; + + trackedUploads.forEach((tracked) => { + if (isTrackedLibraryUpload(tracked)) { + libraryUploads.push(tracked); + } else { + apiIds.push(tracked.id); + } + }); + + return { apiIds, libraryUploads }; + }; + + /** + * Process API-based uploads with progress tracking. + */ + const processApiUploads = async ( + prepared: PreparedUpload, + apiIds: string[], + datasets: UploadedDataset[], + trackedUploads: TrackedUpload[], + onProgress?: (percentage: number) => void, + ): Promise => { + if (prepared.apiItems.length === 0) { + return; + } + + return new Promise((resolve, reject) => { + uploadDatasets(prepared.apiItems, { + chunkSize: galaxyConfig.value.chunk_upload_size as number, + success: (response) => { + markTrackedCompleted(apiIds); + datasets.push(...datasetsFromResponse(response)); + resolve(); + }, + error: (uploadError) => { + markTrackedError(trackedUploads, errorMessageAsString(uploadError)); + reject(uploadError); + }, + progress: (percentage) => { + onProgress?.(percentage); + updateTrackedProgress(apiIds, percentage); + }, + }); + }); + }; + + /** + * Process library dataset uploads with progress tracking. + */ + const processLibraryUploads = async ( + libraryUploads: TrackedUpload[], + historyId: string, + datasets: UploadedDataset[], + ): Promise => { + 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", + }); + } + markTrackedCompleted([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 { + const datasets: UploadedDataset[] = []; + const trackedUploads = initializeUploads(prepared); + const { apiIds, libraryUploads } = filterUploadsByType(trackedUploads); + + await processApiUploads(prepared, apiIds, datasets, trackedUploads, onProgress); + await processLibraryUploads(libraryUploads, historyId, datasets); + + return datasets; + } + + return { submitPreparedUpload }; +} diff --git a/client/src/utils/upload.ts b/client/src/utils/upload.ts index df52f47e8d3..be7da3eaf28 100644 --- a/client/src/utils/upload.ts +++ b/client/src/utils/upload.ts @@ -52,6 +52,7 @@ 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 { NewUploadItem } from "@/composables/upload/uploadItemTypes"; @@ -1006,6 +1007,16 @@ export async function uploadDatasets(items: ApiUploadItem[], config: UploadDatas } } +/** + * Builds a PreparedUpload object from UI upload items. + */ +export function buildPreparedUpload(items: NewUploadItem[]): PreparedUpload { + return { + apiItems: items.map((item) => toApiUploadItem(item)), + uploadItems: items, + }; +} + /** * Uploads datasets as a collection directly via a single /api/tools/fetch request. * Uses HdcaDataItemsTarget to create the collection atomically during the fetch. From e302068ab1424f8ec0ea5738922b44ea29da7502 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:17:22 +0100 Subject: [PATCH 02/13] Adds reusable upload method selection modal --- .../Panels/Upload/UploadMethodModal.vue | 315 ++++++++++++++++++ .../Panels/Upload/uploadModalTypes.ts | 68 ++++ .../upload/useUploadMethodModal.ts | 159 +++++++++ 3 files changed, 542 insertions(+) create mode 100644 client/src/components/Panels/Upload/UploadMethodModal.vue create mode 100644 client/src/components/Panels/Upload/uploadModalTypes.ts create mode 100644 client/src/composables/upload/useUploadMethodModal.ts diff --git a/client/src/components/Panels/Upload/UploadMethodModal.vue b/client/src/components/Panels/Upload/UploadMethodModal.vue new file mode 100644 index 00000000000..29780aa7718 --- /dev/null +++ b/client/src/components/Panels/Upload/UploadMethodModal.vue @@ -0,0 +1,315 @@ + + + + + diff --git a/client/src/components/Panels/Upload/uploadModalTypes.ts b/client/src/components/Panels/Upload/uploadModalTypes.ts new file mode 100644 index 00000000000..881c1da3f87 --- /dev/null +++ b/client/src/components/Panels/Upload/uploadModalTypes.ts @@ -0,0 +1,68 @@ +import type { DataOption } from "@/components/Form/Elements/FormData/types"; + +import type { UploadMethod } from "./types"; + +/** + * 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; +} + +/** + * 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: "hda" | "hdca"; +} + +/** + * 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; +} diff --git a/client/src/composables/upload/useUploadMethodModal.ts b/client/src/composables/upload/useUploadMethodModal.ts new file mode 100644 index 00000000000..7c331f33d9c --- /dev/null +++ b/client/src/composables/upload/useUploadMethodModal.ts @@ -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; +} + +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 { + return this.render(); + }, + }).$mount(hostElement); + + modalVm = instance as UploadMethodModalHostInstance; + return modalVm; +} + +export function useUploadMethodModal() { + async function openUploadModal(config?: UploadModalConfig): Promise { + 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((resolve, reject) => { + pendingResolvers = { resolve, reject }; + }); + } + + return { + openUploadModal, + }; +} + +export { toDataOptions }; From 03ed3fb6b02e6688a43b6b5ad66c519c469417f4 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:17:23 +0100 Subject: [PATCH 03/13] Refactors upload methods for modal and flexible configuration --- .../src/components/FilesDialog/utilities.ts | 7 + .../Panels/Upload/UploadMethodView.vue | 11 +- .../Upload/methods/DataLibraryUpload.vue | 84 ++- .../Panels/Upload/methods/LocalFileUpload.vue | 74 +- .../Upload/methods/PasteContentUpload.vue | 63 +- .../Upload/methods/PasteLinksUpload.vue | 81 ++- .../Upload/methods/RemoteFilesUpload.vue | 676 ++++++++++++++++-- .../Panels/Upload/uploadMethodRegistry.ts | 28 +- .../src/composables/upload/uploadDefaults.ts | 4 +- .../composables/upload/useUploadStaging.ts | 17 + 10 files changed, 879 insertions(+), 166 deletions(-) diff --git a/client/src/components/FilesDialog/utilities.ts b/client/src/components/FilesDialog/utilities.ts index 62f6212511f..0eb86630e04 100644 --- a/client/src/components/FilesDialog/utilities.ts +++ b/client/src/components/FilesDialog/utilities.ts @@ -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] : []; +} diff --git a/client/src/components/Panels/Upload/UploadMethodView.vue b/client/src/components/Panels/Upload/UploadMethodView.vue index fcf73bc8705..f5e907d5ba6 100644 --- a/client/src/components/Panels/Upload/UploadMethodView.vue +++ b/client/src/components/Panels/Upload/UploadMethodView.vue @@ -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(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"); } diff --git a/client/src/components/Panels/Upload/methods/DataLibraryUpload.vue b/client/src/components/Panels/Upload/methods/DataLibraryUpload.vue index 23ea26ed8b6..756767be5f9 100644 --- a/client/src/components/Panels/Upload/methods/DataLibraryUpload.vue +++ b/client/src/components/Panels/Upload/methods/DataLibraryUpload.vue @@ -15,17 +15,17 @@ 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 { 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 +37,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(); +const props = withDefaults(defineProps(), { + allowCollections: true, + multiple: true, + transient: false, +}); const emit = defineEmits<{ (e: "ready", ready: boolean): void; }>(); -const uploadQueue = useUploadQueue(); - const tableContainerRef = ref(null); const collectionConfigComponent = ref | null>(null); const libraryDatasetItems = ref([]); -const { clear: clearStaging } = useUploadStaging(props.method.id, libraryDatasetItems); +const { clear: clearStaging } = useUploadStaging(props.method.id, libraryDatasetItems, { + disableStore: props.transient, +}); -const { collectionState, handleCollectionStateChange, buildCollectionConfig, resetCollection } = +const { collectionState, handleCollectionStateChange, resetCollection } = useCollectionCreation(collectionConfigComponent); let nextId = 1; @@ -106,7 +117,8 @@ function createLibraryDatasetItem(item: AnyLibraryFolderItem, libraryId: string, const showBrowser = ref(true); -const selectionModel = ref(new Model({ multiple: true })); +const isSingleMode = computed(() => props.multiple === false); +const selectionModel = ref(new Model({ multiple: !isSingleMode.value })); const selectionCount = ref(0); // Navigation state @@ -174,6 +186,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 +436,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 +456,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 +481,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 +509,17 @@ 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 { + apiItems: [], + uploadItems: uploads, + }; } const libraryFields: TableField[] = [ @@ -694,7 +717,7 @@ onMounted(async () => { } }); -defineExpose({ startUpload }); +defineExpose({ prepareUpload, reset });