diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 5e8b5a76e86..15acbe4b458 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -4626,6 +4626,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/tools/fetch/workbook": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Generate a template workbook to use with the activity builder UI */ + get: operations["tools__fetch_workbook_download"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/tools/fetch/workbook/parse": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Generate a template workbook to use with the activity builder UI */ + post: operations["tools__fetch_workbook_parse"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/tools/{tool_id}/icon": { parameters: { query?: never; @@ -10780,6 +10814,24 @@ export interface components { */ sorting: boolean; }; + /** FillIdentifiers */ + FillIdentifiers: { + /** + * Deduplication Index From + * @default 1 + */ + deduplication_index_from: number; + /** + * Deduplication Pattern + * @default _{#} + */ + deduplication_pattern: string; + /** + * Fill Inner List Identifiers + * @default false + */ + fill_inner_list_identifiers: boolean; + }; /** FillStepDefaultsAction */ FillStepDefaultsAction: { /** @@ -16885,6 +16937,89 @@ export interface components { * @default [] */ PageSummaryList: components["schemas"]["PageSummary"][]; + /** ParseFetchWorkbook */ + ParseFetchWorkbook: { + /** + * Workbook Content (Base 64 encoded) + * @description The workbook content (the contents of the xlsx file) that have been base64 encoded. + */ + content: string; + fill_identifiers?: components["schemas"]["FillIdentifiers"] | null; + }; + /** ParseLogEntry */ + ParseLogEntry: { + /** Message */ + message: string; + }; + /** ParsedColumn */ + ParsedColumn: { + /** Title */ + title: string; + /** + * Type + * @enum {string} + */ + type: + | "list_identifiers" + | "paired_identifier" + | "paired_or_unpaired_identifier" + | "collection_name" + | "name_tag" + | "tags" + | "group_tags" + | "name" + | "dbkey" + | "hash_sha1" + | "hash_md5" + | "hash_sha256" + | "hash_sha512" + | "file_type" + | "url" + | "url_deferred" + | "info" + | "ftp_path"; + /** Type Index */ + type_index: number; + }; + /** ParsedFetchWorkbookForCollections */ + ParsedFetchWorkbookForCollections: { + /** + * Collection Type + * @enum {string} + */ + collection_type: "list" | "list:paired" | "list:list" | "list:list:paired" | "list:paired_or_unpaired"; + /** Columns */ + columns: components["schemas"]["ParsedColumn"][]; + /** Parse Log */ + parse_log: components["schemas"]["ParseLogEntry"][]; + /** Rows */ + rows: { + [key: string]: string | null; + }[]; + /** + * Workbook Type + * @default collection + * @enum {string} + */ + workbook_type: "datasets" | "collection" | "collections"; + }; + /** ParsedFetchWorkbookForDatasets */ + ParsedFetchWorkbookForDatasets: { + /** Columns */ + columns: components["schemas"]["ParsedColumn"][]; + /** Parse Log */ + parse_log: components["schemas"]["ParseLogEntry"][]; + /** Rows */ + rows: { + [key: string]: string | null; + }[]; + /** + * Workbook Type + * @default datasets + * @enum {string} + */ + workbook_type: "datasets" | "collection" | "collections"; + }; /** PastedDataElement */ PastedDataElement: { /** Md5 */ @@ -36422,6 +36557,99 @@ export interface operations { }; }; }; + tools__fetch_workbook_download: { + parameters: { + query?: { + /** @description Generate a workbook for simple datasets or a collection. */ + type?: "datasets" | "collection" | "collections"; + /** @description Generate workbook for specified collection type (not all collection types are supported) */ + collection_type?: "list" | "list:paired" | "list:list" | "list:list:paired" | "list:paired_or_unpaired"; + /** @description Filename of the workbook download to generate */ + filename?: string | null; + }; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; + tools__fetch_workbook_parse: { + parameters: { + query?: never; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ParseFetchWorkbook"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": + | components["schemas"]["ParsedFetchWorkbookForDatasets"] + | components["schemas"]["ParsedFetchWorkbookForCollections"]; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; get_icon_api_tools__tool_id__icon_get: { parameters: { query?: never; diff --git a/client/src/components/Collections/BuildFileSetWizard.vue b/client/src/components/Collections/BuildFileSetWizard.vue index 90f6a57d474..0871935695a 100644 --- a/client/src/components/Collections/BuildFileSetWizard.vue +++ b/client/src/components/Collections/BuildFileSetWizard.vue @@ -2,16 +2,29 @@ import { BCardGroup } from "bootstrap-vue"; import { computed, ref } from "vue"; +import { GalaxyApi } from "@/api"; import { getGalaxyInstance } from "@/app"; import { attemptCreate, type CollectionCreatorComponent } from "@/components/Collections/common/useCollectionCreator"; +import { useWorkbookDropHandling } from "@/components/Collections/common/useWorkbooks"; import { rawToTable } from "@/components/Collections/tables"; +import { forBuilder, type ForBuilderResponse } from "@/components/Collections/wizard/fetchWorkbooks"; import { useWizard } from "@/components/Common/Wizard/useWizard"; import { useToolRouting } from "@/composables/route"; import localize from "@/utils/localization"; -import type { RemoteFile, RulesCreatingWhat, RulesSourceFrom } from "./wizard/types"; +import type { + ParsedFetchWorkbook, + ParsedFetchWorkbookForCollectionCollectionType, + RuleBuilderMapping, + RuleBuilderOptions, + RuleElementsType, + RulesCreatingWhat, + RuleSelectionType, + RulesSourceFrom, +} from "./wizard/types"; import { useFileSetSources } from "./wizard/useFileSetSources"; +import ConfigureFetchWorkbook from "./wizard/ConfigureFetchWorkbook.vue"; import CreatingWhat from "./wizard/CreatingWhat.vue"; import PasteData from "./wizard/PasteData.vue"; import SelectDataset from "./wizard/SelectDataset.vue"; @@ -20,6 +33,8 @@ import SourceFromCollectionApplyRules from "./wizard/SourceFromCollectionApplyRu import SourceFromDatasetAsTable from "./wizard/SourceFromDatasetAsTable.vue"; import SourceFromPastedData from "./wizard/SourceFromPastedData.vue"; import SourceFromRemoteFiles from "./wizard/SourceFromRemoteFiles.vue"; +import SourceFromWorkbook from "./wizard/SourceFromWorkbook.vue"; +import UploadFetchWorkbook from "./wizard/UploadFetchWorkbook.vue"; import GenericWizard from "@/components/Common/Wizard/GenericWizard.vue"; import RuleCollectionBuilder from "@/components/RuleCollectionBuilder.vue"; @@ -45,8 +60,14 @@ const sourceInstructions = computed(() => { return `${creatingWhatTitle.value} can be created from a set or files, URIs, or existing datasets.`; }); const collectionCreator = ref(); +const workbookCompleted = ref(false); +// workbook generation options +const workbookCollectionType = ref("list"); +const workbookIncludeCollectionName = ref(false); const sourceFrom = ref("remote_files"); +// if we upload a workbook - just jump to the end with these properties +const parsedWorkbookUploadProps = ref(undefined); const { routeToTool } = useToolRouting(); @@ -83,6 +104,18 @@ const wizard = useWizard({ isValid: () => sourceFrom.value === "dataset_as_table" && tabularDatasetContents.value.length > 0, isSkippable: () => sourceFrom.value !== "dataset_as_table", }, + "configure-workbook": { + label: "Configure workbook", + instructions: "Configure a workbook to fill with URIs and metadata", + isValid: () => sourceFrom.value === "workbook" && workbookCollectionType.value !== undefined, + isSkippable: () => sourceFrom.value !== "workbook" || creatingWhat.value !== "collections", + }, + "upload-workbook": { + label: "Upload workbook", + instructions: "Upload a workbook containing with URIs and metadata", + isValid: () => sourceFrom.value === "workbook" && workbookCompleted.value, + isSkippable: () => sourceFrom.value !== "workbook", + }, "rule-builder": { label: "Specify Rules", instructions: "Use this form to describe rules for importing", @@ -101,25 +134,15 @@ const importButtonLabel = computed(() => { }); const emit = defineEmits(["dismiss", "created"]); -type SelectionType = "raw" | "remote_files"; -type ElementsType = RemoteFile[] | string[][]; - -// it would be nice to have a real type from the rule builder but -// it is older code. This is really outlining what this component can -// produce and not what the rule builder can consume which is a wide -// superset of this. -interface Entry { - dataType: RulesCreatingWhat; - ftpUploadSite?: string; - elements?: ElementsType | undefined; - content?: string; - selectionType: SelectionType; -} const ruleBuilderModalEntryProps = computed(() => { - let elements: ElementsType | undefined = undefined; - let selectionType: SelectionType = "raw"; - if (sourceFrom.value == "remote_files") { + let elements: RuleElementsType | undefined = undefined; + let initialMappings: RuleBuilderMapping | undefined = undefined; + let selectionType: RuleSelectionType = "raw"; + if (parsedWorkbookUploadProps.value) { + elements = parsedWorkbookUploadProps.value.initialElements; + initialMappings = parsedWorkbookUploadProps.value.initialMapping; + } else if (sourceFrom.value == "remote_files") { elements = uris.value; selectionType = "remote_files"; } else if (sourceFrom.value == "pasted_table") { @@ -127,11 +150,12 @@ const ruleBuilderModalEntryProps = computed(() => { } else if (sourceFrom.value == "dataset_as_table") { elements = tabularDatasetContents.value; } - const entry: Entry = { + const entry: RuleBuilderOptions = { dataType: creatingWhat.value, ftpUploadSite: props.ftpUploadSite, selectionType: selectionType, elements: elements, + initialMappings: initialMappings, }; return entry; }); @@ -172,6 +196,14 @@ function setSourceForm(newValue: RulesSourceFrom) { sourceFrom.value = newValue; } +function setWorkbookCollectionType(newValue: ParsedFetchWorkbookForCollectionCollectionType) { + workbookCollectionType.value = newValue; +} + +function setWorkbookIncludeCollectionName(newValue: boolean) { + workbookIncludeCollectionName.value = newValue; +} + function onRuleState(newRuleState: boolean) { ruleState.value = newRuleState; } @@ -180,10 +212,68 @@ function onRuleCreate() { // axios response data for job currently sent, not really used but wanted to document what is available. emit("created"); } + +const dropWorkbookTitle = computed(() => { + return localize( + "If you have a completed data import workbook just drop it here or click this icon to upload your local file and skip ahead to the end of the data import wizard." + ); +}); + +function handleUploadedData(response: ParsedFetchWorkbook) { + sourceFrom.value = "workbook"; + const builderPropsFromUpload = forBuilder(response); + creatingWhat.value = builderPropsFromUpload.rulesCreatingWhat; + parsedWorkbookUploadProps.value = builderPropsFromUpload; + workbookCompleted.value = true; + wizard.goTo("rule-builder"); +} + +async function handleWorkbook(base64Content: string) { + const parseBody = { + content: base64Content, + }; + const { data, error } = await GalaxyApi().POST("/api/tools/fetch/workbook/parse", { + body: parseBody, + }); + if (data) { + handleUploadedData(data); + } else { + console.log(error); + uploadErrorMessage.value = "There was an error processing the file."; + } +} + +const { + browseFiles, + dropZoneClasses, + faUpload, + FontAwesomeIcon, + handleDrop, + HiddenWorkbookUploadInput, + isDragging, + onFileUpload, + uploadErrorMessage, + uploadRef, +} = useWorkbookDropHandling(handleWorkbook); + + diff --git a/client/src/components/Collections/ListWizard.vue b/client/src/components/Collections/ListWizard.vue index 9c9d775bb7b..80e444c14d3 100644 --- a/client/src/components/Collections/ListWizard.vue +++ b/client/src/components/Collections/ListWizard.vue @@ -17,9 +17,8 @@ import { } from "./common/useCollectionCreator"; import { showHid } from "./common/useCollectionCreator"; import type { WhichListBuilder } from "./ListWizard/types"; -import { autoPairWithCommonFilters } from "./pairing"; +import { useAutoPairing } from "./usePairing"; -import AutoPairing from "./common/AutoPairing.vue"; import ListCollectionCreator from "./ListCollectionCreator.vue"; import WhichBuilder from "./ListWizard/WhichBuilder.vue"; import PairedOrUnpairedListCollectionCreator from "./PairedOrUnpairedListCollectionCreator.vue"; @@ -36,8 +35,8 @@ const isBusy = ref(false); const whichBuilder = ref("list"); const store = useCollectionBuilderItemSelection(); const { currentHistoryId, createPayload } = useCollectionCreation(); -const currentForwardFilter = ref(""); -const currentReverseFilter = ref(""); +const { countPaired, currentForwardFilter, currentReverseFilter, AutoPairing, autoPair, onFilters } = useAutoPairing(); + const creationError = ref(null); const collectionCreated = ref(false); @@ -45,18 +44,13 @@ type InferrableBuilder = "list" | "list:paired"; const collectionCreator = ref(); const { selectedItems } = storeToRefs(store); -const countPaired = ref(-1); -const countUnpaired = ref(-1); const inferredBuilder = ref("list"); const builderInputsValid = ref(false); async function initialize() { isBusy.value = true; - const summary = autoPairWithCommonFilters(selectedItems.value, true); - currentForwardFilter.value = summary.forwardFilter || ""; - currentReverseFilter.value = summary.reverseFilter || ""; - countPaired.value = summary.pairs?.length || 0; - countUnpaired.value = summary.unpaired.length; + autoPair(selectedItems.value); + // updates currentForwardFilter, currentReverseFilter, countPaired if (countPaired.value * 2 > selectedItems.value.length * 0.2) { whichBuilder.value = "list:paired"; inferredBuilder.value = "list:paired"; @@ -191,11 +185,6 @@ function setWhichBuilder(newWhichBuilder: WhichListBuilder) { whichBuilder.value = newWhichBuilder; } -function onFilters(forwardFilter: string, reverseFilter: string) { - currentForwardFilter.value = forwardFilter; - currentReverseFilter.value = reverseFilter; -} - function goToAutoPairing() { wizard.goTo("auto-pairing"); } diff --git a/client/src/components/Collections/PairedOrUnpairedListCollectionCreator.vue b/client/src/components/Collections/PairedOrUnpairedListCollectionCreator.vue index 54aba631539..2a7822601f8 100644 --- a/client/src/components/Collections/PairedOrUnpairedListCollectionCreator.vue +++ b/client/src/components/Collections/PairedOrUnpairedListCollectionCreator.vue @@ -42,8 +42,6 @@ interface Props { suggestedName?: string; fromSelection?: boolean; extensions?: string[]; - height?: string; - width?: string; forwardFilter?: string; reverseFilter?: string; collectionType: SupportedPairedOrPairedBuilderCollectionTypes; @@ -93,7 +91,7 @@ const { pairingTargetsStore.setShowElementExtension(showElementExtension); const style = computed(() => { - return { width: props.width || "100%", height: props.height || "500px" }; + return { width: "100%", height: "500px" }; }); const flatLists = computed(() => props.collectionType.indexOf("paired") == -1); diff --git a/client/src/components/Collections/auto_pairing_spec.yml b/client/src/components/Collections/auto_pairing_spec.yml deleted file mode 100644 index 8b81d649abc..00000000000 --- a/client/src/components/Collections/auto_pairing_spec.yml +++ /dev/null @@ -1,76 +0,0 @@ -- doc: Simple _R1 _R2 fastqs split. - inputs: - - input_R1.fastq - - input_R2.fastq - paired: - input: - forward: input_R1.fastq - reverse: input_R2.fastq - -- doc: Compressed _R1 _R2 fastqs split. - inputs: - - input_R1.fastq.gz - - input_R2.fastq.gz - paired: - input: - forward: input_R1.fastq.gz - reverse: input_R2.fastq.gz - -- doc: Joining common prefixes on _R1 _R2. - inputs: - - input541_R1.fastq - - input541_R2.fastq - - input521_R1.fastq - - input521_R2.fastq - paired: - input541: - forward: input541_R1.fastq - reverse: input541_R2.fastq - input521: - forward: input521_R1.fastq - reverse: input521_R2.fastq - -- doc: Simple .1.fastq/.2.fastq split. - inputs: - - input.1.fastq - - input.2.fastq - paired: - input: - forward: input.1.fastq - reverse: input.2.fastq - -- doc: Simple .1.fastq.gz/.2.fastq.gz split. - inputs: - - input.1.fastq.gz - - input.2.fastq.gz - paired: - input: - forward: input.1.fastq.gz - reverse: input.2.fastq.gz - -- doc: Simple _1/_2 split. - inputs: - - input_1.fastq - - input_2.fastq - paired: - input: - forward: input_1.fastq - reverse: input_2.fastq - -- doc: Simple _1/_2 split on compressed files (.gz) - inputs: - - input_1.fastq.gz - - input_2.fastq.gz - paired: - input: - forward: input_1.fastq.gz - reverse: input_2.fastq.gz - -- doc: Simple _1/_2 split on compressed files (.bz2) - inputs: - - input_1.fastq.bz2 - - input_2.fastq.bz2 - paired: - input: - forward: input_1.fastq.bz2 - reverse: input_2.fastq.bz2 diff --git a/client/src/components/Collections/auto_pairing_spec.yml b/client/src/components/Collections/auto_pairing_spec.yml new file mode 120000 index 00000000000..0b9861a7800 --- /dev/null +++ b/client/src/components/Collections/auto_pairing_spec.yml @@ -0,0 +1 @@ +../../../../lib/galaxy/model/dataset_collections/auto_pairing_spec.yml \ No newline at end of file diff --git a/client/src/components/Collections/common/useWorkbooks.ts b/client/src/components/Collections/common/useWorkbooks.ts new file mode 100644 index 00000000000..a4b57535574 --- /dev/null +++ b/client/src/components/Collections/common/useWorkbooks.ts @@ -0,0 +1,113 @@ +import { faUpload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; +import { computed, ref } from "vue"; + +import { errorMessageAsString } from "@/utils/simple-error"; + +import HiddenWorkbookUploadInput from "@/components/Collections/wizard/HiddenWorkbookUploadInput.vue"; + +export const fileToBase64 = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = (error) => reject(error); + reader.readAsDataURL(file); + }); + +export type WorkbookHandler = (workbookContentBase64: string) => Promise; + +export function useWorkbookDropHandling(workbookHandler: WorkbookHandler) { + const uploadErrorMessage = ref(undefined); + const isDragging = ref(false); + const isProcessingUpload = ref(false); + + async function onFileUpload(event: Event) { + const input = event.target as HTMLInputElement; + const file = input.files?.[0] ?? null; + if (file) { + if (file.size > 10 * 1024 * 1024) { + // Limit to 10MB + uploadErrorMessage.value = "File size exceeds 10MB limit."; + return; + } + const base64Content = await readAsBase64(file); + workbookHandler(base64Content); + } + } + + async function readAsBase64(file: File) { + const fileContent = await fileToBase64(file); + const base64Content = fileContent.split(",")[1] as string; + return base64Content; + } + + function checkDrop(event: DragEvent): File | undefined { + const file = event.dataTransfer?.files[0]; + if (!file || !file.name.endsWith(".xlsx")) { + uploadErrorMessage.value = "Please drop a valid XLSX file."; + return undefined; + } + return file; + } + + const handleDrop = async (event: DragEvent) => { + const file = checkDrop(event); + if (!file) { + return; + } + if (file.size > 10 * 1024 * 1024) { + // Limit to 10MB + uploadErrorMessage.value = "File size exceeds 10MB limit."; + return; + } + + isDragging.value = false; + isProcessingUpload.value = true; + try { + // Read and base64 encode the file + const base64Content = await readAsBase64(file); + await workbookHandler(base64Content); + } catch (error) { + console.error("Error uploading file:", error); + uploadErrorMessage.value = "Error processing the file: " + errorMessageAsString(error); + } finally { + isProcessingUpload.value = false; + } + }; + + const uploadRef = ref>(); + + interface HasBrowse { + browse: () => void; + } + + function browseFiles() { + const ref = uploadRef.value; + if (ref) { + (ref as unknown as HasBrowse).browse(); + } + } + + const dropZoneClasses = computed(() => { + const classes = ["dropzone"]; + if (isDragging.value) { + classes.push("highlight"); + } + return classes; + }); + + return { + browseFiles, + dropZoneClasses, + FontAwesomeIcon, + faUpload, + readAsBase64, + isDragging, + isProcessingUpload, + handleDrop, + HiddenWorkbookUploadInput, + onFileUpload, + uploadErrorMessage, + uploadRef, + }; +} diff --git a/client/src/components/Collections/usePairing.ts b/client/src/components/Collections/usePairing.ts new file mode 100644 index 00000000000..5bf4d27b2bc --- /dev/null +++ b/client/src/components/Collections/usePairing.ts @@ -0,0 +1,35 @@ +import { ref } from "vue"; + +import { autoPairWithCommonFilters, type HasName } from "./pairing"; + +import AutoPairing from "./common/AutoPairing.vue"; + +export function useAutoPairing() { + const currentForwardFilter = ref(""); + const currentReverseFilter = ref(""); + const countPaired = ref(-1); + const countUnpaired = ref(-1); + + function onFilters(forwardFilter: string, reverseFilter: string) { + currentForwardFilter.value = forwardFilter; + currentReverseFilter.value = reverseFilter; + } + + function autoPair(selectedItems: HasName[]) { + const summary = autoPairWithCommonFilters(selectedItems, true); + currentForwardFilter.value = summary.forwardFilter || ""; + currentReverseFilter.value = summary.reverseFilter || ""; + countPaired.value = summary.pairs?.length || 0; + countUnpaired.value = summary.unpaired.length; + } + + return { + AutoPairing, + autoPair, + countPaired, + countUnpaired, + currentForwardFilter, + currentReverseFilter, + onFilters, + }; +} diff --git a/client/src/components/Collections/wizard/CardDownloadWorkbook.vue b/client/src/components/Collections/wizard/CardDownloadWorkbook.vue new file mode 100644 index 00000000000..61e632024bd --- /dev/null +++ b/client/src/components/Collections/wizard/CardDownloadWorkbook.vue @@ -0,0 +1,20 @@ + + + diff --git a/client/src/components/Collections/wizard/CardEditWorkbook.vue b/client/src/components/Collections/wizard/CardEditWorkbook.vue new file mode 100644 index 00000000000..10fe17b728e --- /dev/null +++ b/client/src/components/Collections/wizard/CardEditWorkbook.vue @@ -0,0 +1,15 @@ + + + diff --git a/client/src/components/Collections/wizard/CardUploadWorkbook.vue b/client/src/components/Collections/wizard/CardUploadWorkbook.vue new file mode 100644 index 00000000000..a98f3243246 --- /dev/null +++ b/client/src/components/Collections/wizard/CardUploadWorkbook.vue @@ -0,0 +1,58 @@ + + + + diff --git a/client/src/components/Collections/wizard/ConfigureFetchWorkbook.vue b/client/src/components/Collections/wizard/ConfigureFetchWorkbook.vue new file mode 100644 index 00000000000..d265efcde36 --- /dev/null +++ b/client/src/components/Collections/wizard/ConfigureFetchWorkbook.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/client/src/components/Collections/wizard/HiddenWorkbookUploadInput.vue b/client/src/components/Collections/wizard/HiddenWorkbookUploadInput.vue new file mode 100644 index 00000000000..0e54ba459dc --- /dev/null +++ b/client/src/components/Collections/wizard/HiddenWorkbookUploadInput.vue @@ -0,0 +1,24 @@ + + + diff --git a/client/src/components/Collections/wizard/SourceFromWorkbook.vue b/client/src/components/Collections/wizard/SourceFromWorkbook.vue new file mode 100644 index 00000000000..c9e2d918e4e --- /dev/null +++ b/client/src/components/Collections/wizard/SourceFromWorkbook.vue @@ -0,0 +1,41 @@ + + + diff --git a/client/src/components/Collections/wizard/UploadFetchWorkbook.vue b/client/src/components/Collections/wizard/UploadFetchWorkbook.vue new file mode 100644 index 00000000000..428d17fbc46 --- /dev/null +++ b/client/src/components/Collections/wizard/UploadFetchWorkbook.vue @@ -0,0 +1,51 @@ + + + diff --git a/client/src/components/Collections/wizard/WhichWorkbookCollectionType.vue b/client/src/components/Collections/wizard/WhichWorkbookCollectionType.vue new file mode 100644 index 00000000000..e05697967f6 --- /dev/null +++ b/client/src/components/Collections/wizard/WhichWorkbookCollectionType.vue @@ -0,0 +1,92 @@ + + + diff --git a/client/src/components/Collections/wizard/fetchWorkbooks.test.ts b/client/src/components/Collections/wizard/fetchWorkbooks.test.ts new file mode 100644 index 00000000000..1cc0946d842 --- /dev/null +++ b/client/src/components/Collections/wizard/fetchWorkbooks.test.ts @@ -0,0 +1,33 @@ +import { forBuilder } from "./fetchWorkbooks"; +import type { ParsedFetchWorkbook } from "./types"; + +describe("forBuilder", () => { + it("should return the correct ForBuilderResponse for a valid ParsedFetchWorkbook", () => { + const parsedWorkbook: ParsedFetchWorkbook = { + rows: [ + { list_identifiers: "Row1", url: "http://example.com/1", dbkey: "db1" }, + { list_identifiers: "Row2", url: "http://example.com/2", dbkey: "db2" }, + ], + columns: [ + { type: "list_identifiers", title: "Name", type_index: 0 }, + { type: "url", title: "URI", type_index: 0 }, + { type: "dbkey", title: "Genome", type_index: 0 }, + ], + workbook_type: "datasets", + parse_log: [], + }; + + const result = forBuilder(parsedWorkbook); + + expect(result.initialElements).toEqual([ + ["Row1", "http://example.com/1", "db1"], + ["Row2", "http://example.com/2", "db2"], + ]); + expect(result.rulesCreatingWhat).toBe("datasets"); + expect(result.initialMapping).toEqual([ + { type: "list_identifiers", columns: [0] }, + { type: "url", columns: [1] }, + { type: "dbkey", columns: [2] }, + ]); + }); +}); diff --git a/client/src/components/Collections/wizard/fetchWorkbooks.ts b/client/src/components/Collections/wizard/fetchWorkbooks.ts new file mode 100644 index 00000000000..64ac80a2350 --- /dev/null +++ b/client/src/components/Collections/wizard/fetchWorkbooks.ts @@ -0,0 +1,79 @@ +// utilities for populating the rule builder from parsed "fetch workbook"s. +import type { + ColumnMappingType, + ParsedFetchWorkbook, + ParsedFetchWorkbookColumn, + RawRowData, + RuleBuilderMapping, + RulesCreatingWhat, +} from "./types"; + +export function hasData(parsedWorkbook: ParsedFetchWorkbook): boolean { + return parsedWorkbook.rows.length > 0; +} + +export interface ForBuilderResponse { + initialElements: string[][]; + rulesCreatingWhat: RulesCreatingWhat; + initialMapping: RuleBuilderMapping; +} + +export function forBuilder(parsedWorkbook: ParsedFetchWorkbook): ForBuilderResponse { + const initialElements: RawRowData = []; + const rulesCreatingWhat = creatingWhat(parsedWorkbook); + for (const row of parsedWorkbook.rows) { + const rowAsString: string[] = []; + for (const column of parsedWorkbook.columns) { + const rowKey: string = columnToRowKey(column); + const cellValue = row[rowKey]; + if (cellValue === undefined) { + throw Error("Error processing server response."); + } + rowAsString.push(cellValue || ""); + } + initialElements.push(rowAsString); + } + const initialMapping = buildInitialMapping(parsedWorkbook); + return { + initialElements, + initialMapping, + rulesCreatingWhat, + }; +} + +function columnToRowKey(column: ParsedFetchWorkbookColumn): string { + if (column.type_index == 0) { + return column.type; + } else { + return `${column.type}_${column.type_index}`; + } +} + +function creatingWhat(parsedWorkbook: ParsedFetchWorkbook): RulesCreatingWhat { + if (parsedWorkbook.workbook_type == "datasets") { + return "datasets"; + } else { + return "collections"; + } +} + +function buildInitialMapping(parsedWorkbook: ParsedFetchWorkbook): RuleBuilderMapping { + const columnMappings: RuleBuilderMapping = []; + for (let index = 0; index < parsedWorkbook.columns.length; index++) { + const column = parsedWorkbook.columns[index] as ParsedFetchWorkbookColumn; + const type: ColumnMappingType = column.type; + if (column.type_index > 0) { + for (const columnMapping of columnMappings) { + if (columnMapping.type == type) { + columnMapping.columns.push(index); + } + } + } else { + columnMappings.push({ + type: type, + columns: [index], + }); + } + } + return columnMappings; +} diff --git a/client/src/components/Collections/wizard/types.ts b/client/src/components/Collections/wizard/types.ts index 911bbe3e850..7ce84043291 100644 --- a/client/src/components/Collections/wizard/types.ts +++ b/client/src/components/Collections/wizard/types.ts @@ -1,8 +1,42 @@ import type { components } from "@/api/schema"; +import type { MAPPING_TARGETS } from "@/components/RuleBuilder/rule-definitions"; export type RulesCreatingWhat = "datasets" | "collections"; -export type RulesSourceFrom = "remote_files" | "pasted_table" | "dataset_as_table" | "collection"; +export type RulesSourceFrom = "remote_files" | "pasted_table" | "dataset_as_table" | "collection" | "workbook"; export type ListUriResponse = components["schemas"]["ListUriResponse"]; export type RemoteFile = components["schemas"]["RemoteFile"]; export type RemoteDirectory = components["schemas"]["RemoteDirectory"]; +export type ParsedFetchWorkbookForCollections = components["schemas"]["ParsedFetchWorkbookForCollections"]; +export type ParsedFetchWorkbookForDatasets = components["schemas"]["ParsedFetchWorkbookForDatasets"]; +export type ParsedFetchWorkbook = ParsedFetchWorkbookForCollections | ParsedFetchWorkbookForDatasets; +export type ParsedFetchWorkbookColumn = components["schemas"]["ParsedColumn"]; +export type ParsedFetchWorkbookForCollectionCollectionType = + components["schemas"]["ParsedFetchWorkbookForCollections"]["collection_type"]; + +export type RawRowData = string[][]; + +// types and helpers around initializing the rule builder with data +export type RuleSelectionType = "raw" | "remote_files"; +export type RuleElementsType = RemoteFile[] | string[][]; +export type ColumnMappingType = keyof typeof MAPPING_TARGETS; +export type ParsedFetchWorkbookColumnType = ParsedFetchWorkbookColumn["type"]; + +export interface RuleBuilderSingleMapping { + type: ColumnMappingType; + columns: number[]; +} +export type RuleBuilderMapping = RuleBuilderSingleMapping[]; + +// it would be nice to have a real type from the rule builder but +// it is older code. This is really outlining what this component can +// produce and not what the rule builder can consume which is a wide +// superset of this. +export interface RuleBuilderOptions { + dataType: RulesCreatingWhat; + ftpUploadSite?: string; + elements?: RuleElementsType | undefined; + content?: string; + selectionType: RuleSelectionType; + initialMappings?: RuleBuilderMapping; +} diff --git a/client/src/components/Collections/wizard/workbook-dropzones.scss b/client/src/components/Collections/wizard/workbook-dropzones.scss new file mode 100644 index 00000000000..3f7c822753b --- /dev/null +++ b/client/src/components/Collections/wizard/workbook-dropzones.scss @@ -0,0 +1,9 @@ +@import "theme/blue.scss"; + +.dropzone.highlight { + border-width: 2px; + border-color: $border-color; + border-style: dashed; + border-radius: $border-radius-large; + -moz-border-radius: $border-radius-large; +} diff --git a/client/src/components/Help/HelpText.vue b/client/src/components/Help/HelpText.vue index b35a420a7da..f24ea8c4fa7 100644 --- a/client/src/components/Help/HelpText.vue +++ b/client/src/components/Help/HelpText.vue @@ -23,13 +23,4 @@ const helpTarget = ref(); - +