From aa3b22130223b94046956110649d7fc14066d99c Mon Sep 17 00:00:00 2001 From: mbeaulne Date: Wed, 4 Mar 2026 15:17:08 -0500 Subject: [PATCH] bulk argument upload - template selection --- .../Submitters/Oasis/OasisSubmitter.tsx | 144 +++++++++++----- .../components/SubmitTaskArgumentsDialog.tsx | 99 +++++++++-- src/utils/csvBulkArgumentExport.test.ts | 78 --------- src/utils/csvBulkArgumentExport.ts | 71 -------- src/utils/templateExport.test.ts | 161 ++++++++++++++++++ src/utils/templateExport.ts | 148 ++++++++++++++++ 6 files changed, 496 insertions(+), 205 deletions(-) delete mode 100644 src/utils/csvBulkArgumentExport.test.ts delete mode 100644 src/utils/csvBulkArgumentExport.ts create mode 100644 src/utils/templateExport.test.ts create mode 100644 src/utils/templateExport.ts diff --git a/src/components/shared/Submitters/Oasis/OasisSubmitter.tsx b/src/components/shared/Submitters/Oasis/OasisSubmitter.tsx index 4c100e44d..86746a1f0 100644 --- a/src/components/shared/Submitters/Oasis/OasisSubmitter.tsx +++ b/src/components/shared/Submitters/Oasis/OasisSubmitter.tsx @@ -1,7 +1,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { AlertCircle, CheckCircle, Loader2, SendHorizonal } from "lucide-react"; -import { type MouseEvent, useRef, useState } from "react"; +import { type DragEvent, type MouseEvent, useRef, useState } from "react"; import { useAwaitAuthorization } from "@/components/shared/Authentication/useAwaitAuthorization"; import { useFlagValue } from "@/components/shared/Settings/useFlags"; @@ -103,6 +103,12 @@ const OasisSubmitter = ({ const [submitSuccess, setSubmitSuccess] = useState(null); const [isArgumentsDialogOpen, setIsArgumentsDialogOpen] = useState(false); + const [pendingImportFile, setPendingImportFile] = useState<{ + text: string; + extension: string; + } | null>(null); + const [isDraggingOver, setIsDraggingOver] = useState(false); + const dragCounter = useRef(0); const { cooldownTime, setCooldownTime } = useCooldownTimer(0); const notify = useToastNotification(); const navigate = useNavigate(); @@ -307,6 +313,47 @@ const OasisSubmitter = ({ const isArgumentsButtonVisible = hasConfigurableInputs && !isButtonDisabled && isComponentTreeValid; + const handleDragEnter = (e: DragEvent) => { + e.preventDefault(); + dragCounter.current++; + setIsDraggingOver(true); + }; + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + dragCounter.current--; + if (dragCounter.current === 0) { + setIsDraggingOver(false); + } + }; + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + }; + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + dragCounter.current = 0; + setIsDraggingOver(false); + + const file = e.dataTransfer.files[0]; + if (!file) return; + + const extension = file.name.includes(".") + ? `.${file.name.split(".").pop()?.toLowerCase()}` + : ""; + + const reader = new FileReader(); + reader.onload = (event) => { + const text = event.target?.result; + if (typeof text === "string") { + setPendingImportFile({ text, extension }); + setIsArgumentsDialogOpen(true); + } + }; + reader.readAsText(file); + }; + const getButtonIcon = () => { if (isSubmitting || isBulkSubmitting) { return ; @@ -325,49 +372,60 @@ const OasisSubmitter = ({ return ( <> - - - {isArgumentsButtonVisible && ( - + + + {isArgumentsButtonVisible && ( + setIsArgumentsDialogOpen(true)} + disabled={!available} + > + + + )} + + {componentSpec && ( setIsArgumentsDialogOpen(false)} onConfirm={handleSubmitWithArguments} componentSpec={componentSpec} + initialImportFile={pendingImportFile} + onImportComplete={() => setPendingImportFile(null)} /> )} diff --git a/src/components/shared/Submitters/Oasis/components/SubmitTaskArgumentsDialog.tsx b/src/components/shared/Submitters/Oasis/components/SubmitTaskArgumentsDialog.tsx index 5719d7b57..79795850f 100644 --- a/src/components/shared/Submitters/Oasis/components/SubmitTaskArgumentsDialog.tsx +++ b/src/components/shared/Submitters/Oasis/components/SubmitTaskArgumentsDialog.tsx @@ -1,6 +1,12 @@ import { useMutation } from "@tanstack/react-query"; import yaml from "js-yaml"; -import { type ChangeEvent, type KeyboardEvent, useRef, useState } from "react"; +import { + type ChangeEvent, + type KeyboardEvent, + useEffect, + useRef, + useState, +} from "react"; import type { TaskSpecOutput } from "@/api/types.gen"; import TooltipButton from "@/components/shared/Buttons/TooltipButton"; @@ -23,6 +29,12 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Icon } from "@/components/ui/icon"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -52,10 +64,14 @@ import { type InputSpec, isSecretArgument, } from "@/utils/componentSpec"; -import { generateCsvTemplate } from "@/utils/csvBulkArgumentExport"; import { mapCsvToArguments } from "@/utils/csvBulkArgumentImport"; import { mapJsonToArguments } from "@/utils/jsonBulkArgumentImport"; import { extractTaskArguments } from "@/utils/nodes/taskArguments"; +import { + generateCsvTemplate, + generateJsonTemplate, + generateYamlTemplate, +} from "@/utils/templateExport"; import { validateArguments } from "@/utils/validations"; type TaskArguments = TaskSpecOutput["arguments"]; @@ -69,6 +85,8 @@ interface SubmitTaskArgumentsDialogProps { bulkInputNames: Set, ) => void; componentSpec: ComponentSpec; + initialImportFile?: { text: string; extension: string } | null; + onImportComplete?: () => void; } export const SubmitTaskArgumentsDialog = ({ @@ -76,6 +94,8 @@ export const SubmitTaskArgumentsDialog = ({ onCancel, onConfirm, componentSpec, + initialImportFile, + onImportComplete, }: SubmitTaskArgumentsDialogProps) => { const notify = useToastNotification(); const initialArgs = getArgumentsFromInputs(componentSpec); @@ -103,6 +123,13 @@ export const SubmitTaskArgumentsDialog = ({ !hasBulkMismatch && bulkRunCount > 0; + useEffect(() => { + if (initialImportFile && open) { + handleFileImport(initialImportFile.text, initialImportFile.extension); + onImportComplete?.(); + } + }, [initialImportFile, open]); + const handleCopyFromRun = (args: Record) => { const diff = Object.entries(args).filter( ([key, value]) => taskArguments[key] !== value, @@ -234,7 +261,7 @@ export const SubmitTaskArgumentsDialog = ({ Customize the pipeline input values before submitting. - )} @@ -613,7 +640,34 @@ const ArgumentField = ({ ); }; -const DownloadCsvTemplateButton = ({ +type TemplateFormat = "csv" | "json" | "yaml"; + +const templateGenerators: Record< + TemplateFormat, + { + generate: typeof generateCsvTemplate; + mimeType: string; + extension: string; + } +> = { + csv: { + generate: generateCsvTemplate, + mimeType: "text/csv", + extension: "csv", + }, + json: { + generate: generateJsonTemplate, + mimeType: "application/json", + extension: "json", + }, + yaml: { + generate: generateYamlTemplate, + mimeType: "text/yaml", + extension: "yaml", + }, +}; + +const DownloadTemplateButton = ({ inputs, taskArguments, bulkInputNames, @@ -624,24 +678,41 @@ const DownloadCsvTemplateButton = ({ bulkInputNames: Set; pipelineName?: string; }) => { - const handleDownload = () => { - const csv = generateCsvTemplate(inputs, taskArguments, bulkInputNames); - if (!csv) return; + const downloadFormat = (fmt: TemplateFormat) => { + const { generate, mimeType, extension } = templateGenerators[fmt]; + const content = generate(inputs, taskArguments, bulkInputNames); + if (!content) return; - const blob = new Blob([csv], { type: "text/csv" }); + const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = `${pipelineName ?? "pipeline"}-arguments.csv`; + a.download = `${pipelineName ?? "pipeline"}-arguments.${extension}`; a.click(); URL.revokeObjectURL(url); }; return ( - + + + + + + downloadFormat("csv")}> + CSV + + downloadFormat("json")}> + JSON + + downloadFormat("yaml")}> + YAML + + + ); }; diff --git a/src/utils/csvBulkArgumentExport.test.ts b/src/utils/csvBulkArgumentExport.test.ts deleted file mode 100644 index b08f85278..000000000 --- a/src/utils/csvBulkArgumentExport.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { DynamicDataArgument, InputSpec } from "./componentSpec"; -import { generateCsvTemplate } from "./csvBulkArgumentExport"; - -function makeSecretArg(name: string): DynamicDataArgument { - return { dynamicData: { secret: { name } } }; -} - -function makeInput(name: string, optional = false): InputSpec { - return { name, optional }; -} - -describe("generateCsvTemplate", () => { - const inputs = [ - makeInput("dataset"), - makeInput("model"), - makeInput("lr", true), - ]; - - it("generates headers with empty defaults", () => { - expect(generateCsvTemplate(inputs, {})).toBe("dataset,model,lr\n,,"); - }); - - it("uses current argument values as defaults", () => { - const args = { dataset: "train.csv", model: "", lr: "" }; - expect(generateCsvTemplate(inputs, args)).toBe( - "dataset,model,lr\ntrain.csv,,", - ); - }); - - it("uses input default when no current value", () => { - const inputsWithDefaults: InputSpec[] = [ - { name: "dataset", default: "data.csv" }, - { name: "model" }, - ]; - expect(generateCsvTemplate(inputsWithDefaults, {})).toBe( - "dataset,model\ndata.csv,", - ); - }); - - it("skips secret inputs", () => { - const args = { dataset: "", model: "", api_key: makeSecretArg("s") }; - const inputsWithSecret = [...inputs, makeInput("api_key")]; - expect(generateCsvTemplate(inputsWithSecret, args)).toBe( - "dataset,model,lr\n,,", - ); - }); - - it("returns empty string when all inputs are secrets", () => { - const secretInputs = [makeInput("api_key")]; - const args = { api_key: makeSecretArg("s") }; - expect(generateCsvTemplate(secretInputs, args)).toBe(""); - }); - - it("expands bulk inputs into separate rows", () => { - const args = { dataset: "train, test, val", model: "rf", lr: "0.01" }; - const bulk = new Set(["dataset"]); - expect(generateCsvTemplate(inputs, args, bulk)).toBe( - "dataset,model,lr\ntrain,rf,0.01\ntest,rf,0.01\nval,rf,0.01", - ); - }); - - it("expands multiple bulk inputs in parallel", () => { - const args = { dataset: "train, test", model: "rf, gb", lr: "0.01" }; - const bulk = new Set(["dataset", "model"]); - expect(generateCsvTemplate(inputs, args, bulk)).toBe( - "dataset,model,lr\ntrain,rf,0.01\ntest,gb,0.01", - ); - }); - - it("quotes values that contain commas", () => { - const args = { dataset: "a,b", model: "rf" }; - expect(generateCsvTemplate(inputs, args)).toBe( - 'dataset,model,lr\n"a,b",rf,', - ); - }); -}); diff --git a/src/utils/csvBulkArgumentExport.ts b/src/utils/csvBulkArgumentExport.ts deleted file mode 100644 index 2a2931d09..000000000 --- a/src/utils/csvBulkArgumentExport.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { parseBulkValues } from "./bulkSubmission"; -import { - type ArgumentType, - type InputSpec, - isSecretArgument, -} from "./componentSpec"; - -/** - * Quotes a CSV field if it contains commas, quotes, or newlines (RFC 4180). - */ -function quoteCsvField(value: string): string { - if (value.includes(",") || value.includes('"') || value.includes("\n")) { - return `"${value.replace(/"/g, '""')}"`; - } - return value; -} - -/** - * Generates a CSV with input names as column headers. - * - * Bulk inputs are expanded into separate rows (one value per row). - * Non-bulk inputs repeat their value across all rows. - * Skips inputs that currently hold secret values. - * - * { experiment_key: "12345, 1, 2, 9", predictions: "1234, 4, 6, 5" } - * with both as bulk → - * experiment_key,predictions - * 12345,1234 - * 1,4 - * 2,6 - * 9,5 - */ -export function generateCsvTemplate( - inputs: InputSpec[], - currentArgs: Record, - bulkInputNames: Set = new Set(), -): string { - const nonSecretInputs = inputs.filter( - (input) => !isSecretArgument(currentArgs[input.name]), - ); - - if (nonSecretInputs.length === 0) return ""; - - const headers = nonSecretInputs.map((input) => input.name); - - const columns = nonSecretInputs.map((input) => { - const current = currentArgs[input.name]; - const raw = - typeof current === "string" && current.length > 0 - ? current - : (input.default ?? ""); - - if (bulkInputNames.has(input.name) && raw.length > 0) { - return parseBulkValues(raw); - } - return [raw]; - }); - - const rowCount = Math.max(...columns.map((col) => col.length), 1); - - const rows: string[] = [headers.join(",")]; - for (let i = 0; i < rowCount; i++) { - const row = columns.map((col) => { - const value = i < col.length ? col[i] : (col[0] ?? ""); - return quoteCsvField(value); - }); - rows.push(row.join(",")); - } - - return rows.join("\n"); -} diff --git a/src/utils/templateExport.test.ts b/src/utils/templateExport.test.ts new file mode 100644 index 000000000..ff3b5875b --- /dev/null +++ b/src/utils/templateExport.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; + +import type { DynamicDataArgument, InputSpec } from "./componentSpec"; +import { + generateCsvTemplate, + generateJsonTemplate, + generateYamlTemplate, +} from "./templateExport"; + +function makeSecretArg(name: string): DynamicDataArgument { + return { dynamicData: { secret: { name } } }; +} + +function makeInput(name: string, optional = false): InputSpec { + return { name, optional }; +} + +describe("generateCsvTemplate", () => { + const inputs = [ + makeInput("dataset"), + makeInput("model"), + makeInput("lr", true), + ]; + + it("generates headers with empty defaults", () => { + expect(generateCsvTemplate(inputs, {})).toBe("dataset,model,lr\n,,"); + }); + + it("uses current argument values as defaults", () => { + const args = { dataset: "train.csv", model: "", lr: "" }; + expect(generateCsvTemplate(inputs, args)).toBe( + "dataset,model,lr\ntrain.csv,,", + ); + }); + + it("uses input default when no current value", () => { + const inputsWithDefaults: InputSpec[] = [ + { name: "dataset", default: "data.csv" }, + { name: "model" }, + ]; + expect(generateCsvTemplate(inputsWithDefaults, {})).toBe( + "dataset,model\ndata.csv,", + ); + }); + + it("skips secret inputs", () => { + const args = { dataset: "", model: "", api_key: makeSecretArg("s") }; + const inputsWithSecret = [...inputs, makeInput("api_key")]; + expect(generateCsvTemplate(inputsWithSecret, args)).toBe( + "dataset,model,lr\n,,", + ); + }); + + it("returns empty string when all inputs are secrets", () => { + const secretInputs = [makeInput("api_key")]; + const args = { api_key: makeSecretArg("s") }; + expect(generateCsvTemplate(secretInputs, args)).toBe(""); + }); + + it("expands bulk inputs into separate rows", () => { + const args = { dataset: "train, test, val", model: "rf", lr: "0.01" }; + const bulk = new Set(["dataset"]); + expect(generateCsvTemplate(inputs, args, bulk)).toBe( + "dataset,model,lr\ntrain,rf,0.01\ntest,rf,0.01\nval,rf,0.01", + ); + }); + + it("expands multiple bulk inputs in parallel", () => { + const args = { dataset: "train, test", model: "rf, gb", lr: "0.01" }; + const bulk = new Set(["dataset", "model"]); + expect(generateCsvTemplate(inputs, args, bulk)).toBe( + "dataset,model,lr\ntrain,rf,0.01\ntest,gb,0.01", + ); + }); + + it("quotes values that contain commas", () => { + const args = { dataset: "a,b", model: "rf" }; + expect(generateCsvTemplate(inputs, args)).toBe( + 'dataset,model,lr\n"a,b",rf,', + ); + }); +}); + +describe("generateJsonTemplate", () => { + const inputs = [ + makeInput("dataset"), + makeInput("model"), + makeInput("lr", true), + ]; + + it("generates single object for non-bulk values", () => { + const args = { dataset: "train.csv", model: "rf", lr: "" }; + const result = JSON.parse(generateJsonTemplate(inputs, args)); + + expect(result).toEqual({ dataset: "train.csv", model: "rf", lr: "" }); + }); + + it("generates array of objects for bulk values", () => { + const args = { dataset: "train, test, val", model: "rf", lr: "0.01" }; + const bulk = new Set(["dataset"]); + const result = JSON.parse(generateJsonTemplate(inputs, args, bulk)); + + expect(result).toEqual([ + { dataset: "train", model: "rf", lr: "0.01" }, + { dataset: "test", model: "rf", lr: "0.01" }, + { dataset: "val", model: "rf", lr: "0.01" }, + ]); + }); + + it("skips secret inputs", () => { + const args = { dataset: "train.csv", api_key: makeSecretArg("s") }; + const inputsWithSecret = [...inputs, makeInput("api_key")]; + const result = JSON.parse(generateJsonTemplate(inputsWithSecret, args)); + + expect(result).toEqual({ dataset: "train.csv", model: "", lr: "" }); + expect("api_key" in result).toBe(false); + }); + + it("returns empty string when all inputs are secrets", () => { + const secretInputs = [makeInput("api_key")]; + const args = { api_key: makeSecretArg("s") }; + expect(generateJsonTemplate(secretInputs, args)).toBe(""); + }); + + it("uses input defaults when no current value", () => { + const inputsWithDefaults: InputSpec[] = [ + { name: "dataset", default: "data.csv" }, + { name: "model" }, + ]; + const result = JSON.parse(generateJsonTemplate(inputsWithDefaults, {})); + + expect(result).toEqual({ dataset: "data.csv", model: "" }); + }); +}); + +describe("generateYamlTemplate", () => { + const inputs = [makeInput("dataset"), makeInput("model")]; + + it("generates YAML for non-bulk values", () => { + const args = { dataset: "train.csv", model: "rf" }; + const result = generateYamlTemplate(inputs, args); + + expect(result).toContain("dataset: train.csv"); + expect(result).toContain("model: rf"); + }); + + it("generates YAML array for bulk values", () => { + const args = { dataset: "train, test", model: "rf" }; + const bulk = new Set(["dataset"]); + const result = generateYamlTemplate(inputs, args, bulk); + + expect(result).toContain("- dataset: train"); + expect(result).toContain("- dataset: test"); + }); + + it("returns empty string when all inputs are secrets", () => { + const secretInputs = [makeInput("api_key")]; + const args = { api_key: makeSecretArg("s") }; + expect(generateYamlTemplate(secretInputs, args)).toBe(""); + }); +}); diff --git a/src/utils/templateExport.ts b/src/utils/templateExport.ts new file mode 100644 index 000000000..e0e18211e --- /dev/null +++ b/src/utils/templateExport.ts @@ -0,0 +1,148 @@ +import yaml from "js-yaml"; + +import { parseBulkValues } from "./bulkSubmission"; +import { + type ArgumentType, + type InputSpec, + isSecretArgument, +} from "./componentSpec"; + +/** + * Quotes a CSV field if it contains commas, quotes, or newlines (RFC 4180). + */ +function quoteCsvField(value: string): string { + if (value.includes(",") || value.includes('"') || value.includes("\n")) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +/** + * Generates a CSV with input names as column headers. + * + * Bulk inputs are expanded into separate rows (one value per row). + * Non-bulk inputs repeat their value across all rows. + * Skips inputs that currently hold secret values. + * + * { experiment_key: "12345, 1, 2, 9", predictions: "1234, 4, 6, 5" } + * with both as bulk → + * experiment_key,predictions + * 12345,1234 + * 1,4 + * 2,6 + * 9,5 + */ +export function generateCsvTemplate( + inputs: InputSpec[], + currentArgs: Record, + bulkInputNames: Set = new Set(), +): string { + const nonSecretInputs = inputs.filter( + (input) => !isSecretArgument(currentArgs[input.name]), + ); + + if (nonSecretInputs.length === 0) return ""; + + const headers = nonSecretInputs.map((input) => input.name); + + const columns = nonSecretInputs.map((input) => { + const current = currentArgs[input.name]; + const raw = + typeof current === "string" && current.length > 0 + ? current + : (input.default ?? ""); + + if (bulkInputNames.has(input.name) && raw.length > 0) { + return parseBulkValues(raw); + } + return [raw]; + }); + + const rowCount = Math.max(...columns.map((col) => col.length), 1); + + const rows: string[] = [headers.join(",")]; + for (let i = 0; i < rowCount; i++) { + const row = columns.map((col) => { + const value = i < col.length ? col[i] : (col[0] ?? ""); + return quoteCsvField(value); + }); + rows.push(row.join(",")); + } + + return rows.join("\n"); +} + +/** + * Builds row data shared by JSON and YAML template generators. + * Returns an array of objects keyed by input name. + */ +function buildTemplateRows( + inputs: InputSpec[], + currentArgs: Record, + bulkInputNames: Set = new Set(), +): Record[] { + const nonSecretInputs = inputs.filter( + (input) => !isSecretArgument(currentArgs[input.name]), + ); + + if (nonSecretInputs.length === 0) return []; + + const columns = nonSecretInputs.map((input) => { + const current = currentArgs[input.name]; + const raw = + typeof current === "string" && current.length > 0 + ? current + : (input.default ?? ""); + + if (bulkInputNames.has(input.name) && raw.length > 0) { + return { name: input.name, values: parseBulkValues(raw) }; + } + return { name: input.name, values: [raw] }; + }); + + const rowCount = Math.max(...columns.map((col) => col.values.length), 1); + + const rows: Record[] = []; + for (let i = 0; i < rowCount; i++) { + const row: Record = {}; + for (const col of columns) { + row[col.name] = + i < col.values.length ? col.values[i] : (col.values[0] ?? ""); + } + rows.push(row); + } + + return rows; +} + +/** + * Generates a JSON template from current input values. + * Single row → object, multiple rows → array of objects. + */ +export function generateJsonTemplate( + inputs: InputSpec[], + currentArgs: Record, + bulkInputNames: Set = new Set(), +): string { + const rows = buildTemplateRows(inputs, currentArgs, bulkInputNames); + if (rows.length === 0) return ""; + + const data = rows.length === 1 ? rows[0] : rows; + return JSON.stringify(data, null, 2); +} + +/** + * Generates a YAML template from current input values. + * Single row → object, multiple rows → array of objects. + */ +export function generateYamlTemplate( + inputs: InputSpec[], + currentArgs: Record, + bulkInputNames: Set = new Set(), +): string { + const rows = buildTemplateRows(inputs, currentArgs, bulkInputNames); + if (rows.length === 0) return ""; + + const data = rows.length === 1 ? rows[0] : rows; + return yaml.dump(data); +}