diff --git a/src/components/shared/Submitters/Oasis/OasisSubmitter.tsx b/src/components/shared/Submitters/Oasis/OasisSubmitter.tsx index 36343c2a1..85a03e8f1 100644 --- a/src/components/shared/Submitters/Oasis/OasisSubmitter.tsx +++ b/src/components/shared/Submitters/Oasis/OasisSubmitter.tsx @@ -26,6 +26,7 @@ import { type ComponentSpec, isGraphImplementation, } from "@/utils/componentSpec"; +import { getFileExtension } from "@/utils/csvBulkArgumentImport"; import { submitPipelineRun } from "@/utils/submitPipeline"; import { validateArguments } from "@/utils/validations"; @@ -366,15 +367,11 @@ const OasisSubmitter = ({ 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 }); + setPendingImportFile({ text, extension: getFileExtension(file.name) }); setIsArgumentsDialogOpen(true); } }; diff --git a/src/components/shared/Submitters/Oasis/components/SubmitTaskArgumentsDialog.tsx b/src/components/shared/Submitters/Oasis/components/SubmitTaskArgumentsDialog.tsx index 75b5d5b26..fc261d24d 100644 --- a/src/components/shared/Submitters/Oasis/components/SubmitTaskArgumentsDialog.tsx +++ b/src/components/shared/Submitters/Oasis/components/SubmitTaskArgumentsDialog.tsx @@ -58,7 +58,10 @@ import { type InputSpec, isSecretArgument, } from "@/utils/componentSpec"; -import { mapCsvToArguments } from "@/utils/csvBulkArgumentImport"; +import { + getFileExtension, + mapCsvToArguments, +} from "@/utils/csvBulkArgumentImport"; import { mapJsonToArguments } from "@/utils/jsonBulkArgumentImport"; import { extractTaskArguments } from "@/utils/nodes/taskArguments"; import { pluralize } from "@/utils/string"; @@ -118,13 +121,6 @@ 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, @@ -226,6 +222,13 @@ export const SubmitTaskArgumentsDialog = ({ notify(message, hasWarnings ? "warning" : "success"); }; + useEffect(() => { + if (initialImportFile && open) { + handleFileImport(initialImportFile.text, initialImportFile.extension); + onImportComplete?.(); + } + }, [initialImportFile, open, handleFileImport, onImportComplete]); + const handleConfirm = () => onConfirm(taskArguments, runNotes, bulkInputNames); @@ -390,10 +393,6 @@ const CopyFromRunPopover = ({ isPending: isCopyingFromRun, isError, } = useMutation({ - /** - * @param run - The run to copy arguments from. Can be a run ID or a run object. - * @returns - */ mutationFn: async (run: PipelineRun | string) => { const executionId = typeof run === "string" @@ -717,15 +716,11 @@ const ImportFileButton = ({ const file = e.target.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") { - onImport(text, extension); + onImport(text, getFileExtension(file.name)); } }; reader.readAsText(file); diff --git a/src/utils/bulkSubmission.test.ts b/src/utils/bulkSubmission.test.ts index 59b6bbb31..f93b6d9bb 100644 --- a/src/utils/bulkSubmission.test.ts +++ b/src/utils/bulkSubmission.test.ts @@ -5,11 +5,7 @@ import { getBulkRunCount, parseBulkValues, } from "./bulkSubmission"; -import type { DynamicDataArgument } from "./componentSpec"; - -function makeSecretArg(name: string): DynamicDataArgument { - return { dynamicData: { secret: { name } } }; -} +import { makeSecretArg } from "./testHelpers"; describe("parseBulkValues", () => { it("splits comma-separated values and trims whitespace", () => { diff --git a/src/utils/csvBulkArgumentImport.test.ts b/src/utils/csvBulkArgumentImport.test.ts index e7a3deb7d..87d2d31e0 100644 --- a/src/utils/csvBulkArgumentImport.test.ts +++ b/src/utils/csvBulkArgumentImport.test.ts @@ -1,15 +1,7 @@ import { describe, expect, it } from "vitest"; -import type { DynamicDataArgument, InputSpec } from "./componentSpec"; import { mapCsvToArguments, parseCsv } from "./csvBulkArgumentImport"; - -function makeSecretArg(name: string): DynamicDataArgument { - return { dynamicData: { secret: { name } } }; -} - -function makeInput(name: string, optional = false): InputSpec { - return { name, optional }; -} +import { makeInput, makeSecretArg } from "./testHelpers"; describe("parseCsv", () => { it("parses simple CSV", () => { diff --git a/src/utils/csvBulkArgumentImport.ts b/src/utils/csvBulkArgumentImport.ts index 45ad94625..f77b0a4ca 100644 --- a/src/utils/csvBulkArgumentImport.ts +++ b/src/utils/csvBulkArgumentImport.ts @@ -69,7 +69,16 @@ export function parseCsv(text: string): string[][] { return rows; } -interface CsvImportResult { +/** + * Extracts the file extension (e.g. ".csv", ".json") from a filename. + * Returns empty string if no extension is found. + */ +export function getFileExtension(filename: string): string { + const dotIdx = filename.lastIndexOf("."); + return dotIdx >= 0 ? `.${filename.slice(dotIdx + 1).toLowerCase()}` : ""; +} + +export interface FileImportResult { values: Record; changedInputNames: string[]; enableBulk: boolean; @@ -88,10 +97,10 @@ export function mapCsvToArguments( csvText: string, inputs: InputSpec[], currentArgs: Record, -): CsvImportResult { +): FileImportResult { const rows = parseCsv(csvText); - const empty: CsvImportResult = { + const empty: FileImportResult = { values: {}, changedInputNames: [], enableBulk: false, diff --git a/src/utils/jsonBulkArgumentImport.test.ts b/src/utils/jsonBulkArgumentImport.test.ts index 67b883eb7..6f06db20e 100644 --- a/src/utils/jsonBulkArgumentImport.test.ts +++ b/src/utils/jsonBulkArgumentImport.test.ts @@ -1,15 +1,7 @@ import { describe, expect, it } from "vitest"; -import type { DynamicDataArgument, InputSpec } from "./componentSpec"; import { mapJsonToArguments } from "./jsonBulkArgumentImport"; - -function makeSecretArg(name: string): DynamicDataArgument { - return { dynamicData: { secret: { name } } }; -} - -function makeInput(name: string, optional = false): InputSpec { - return { name, optional }; -} +import { makeInput, makeSecretArg } from "./testHelpers"; describe("mapJsonToArguments", () => { const inputs = [ diff --git a/src/utils/jsonBulkArgumentImport.ts b/src/utils/jsonBulkArgumentImport.ts index 0e9789b4c..519d4c7db 100644 --- a/src/utils/jsonBulkArgumentImport.ts +++ b/src/utils/jsonBulkArgumentImport.ts @@ -3,6 +3,7 @@ import { type InputSpec, isSecretArgument, } from "./componentSpec"; +import type { FileImportResult } from "./csvBulkArgumentImport"; function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -21,15 +22,6 @@ function valueToString(value: unknown): string { return JSON.stringify(value); } -interface JsonImportResult { - values: Record; - changedInputNames: string[]; - enableBulk: boolean; - unmatchedColumns: string[]; - skippedSecretInputs: string[]; - rowCount: number; -} - /** * Maps JSON data onto pipeline input arguments. * @@ -46,8 +38,8 @@ export function mapJsonToArguments( jsonText: string, inputs: InputSpec[], currentArgs: Record, -): JsonImportResult { - const empty: JsonImportResult = { +): FileImportResult { + const empty: FileImportResult = { values: {}, changedInputNames: [], enableBulk: false, diff --git a/src/utils/templateExport.test.ts b/src/utils/templateExport.test.ts index ff3b5875b..991c2cc2e 100644 --- a/src/utils/templateExport.test.ts +++ b/src/utils/templateExport.test.ts @@ -1,19 +1,12 @@ import { describe, expect, it } from "vitest"; -import type { DynamicDataArgument, InputSpec } from "./componentSpec"; +import type { 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 }; -} +import { makeInput, makeSecretArg } from "./testHelpers"; describe("generateCsvTemplate", () => { const inputs = [ diff --git a/src/utils/templateExport.ts b/src/utils/templateExport.ts index e0e18211e..0b2a94a87 100644 --- a/src/utils/templateExport.ts +++ b/src/utils/templateExport.ts @@ -18,62 +18,7 @@ function quoteCsvField(value: string): string { } /** - * 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. + * Builds row data shared by all template generators. * Returns an array of objects keyed by input name. */ function buildTemplateRows( @@ -115,6 +60,29 @@ function buildTemplateRows( return rows; } +/** + * 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. + */ +export function generateCsvTemplate( + inputs: InputSpec[], + currentArgs: Record, + bulkInputNames: Set = new Set(), +): string { + const rows = buildTemplateRows(inputs, currentArgs, bulkInputNames); + if (rows.length === 0) return ""; + + const headers = Object.keys(rows[0]); + const csvRows = [headers.join(",")]; + for (const row of rows) { + csvRows.push(headers.map((h) => quoteCsvField(row[h])).join(",")); + } + return csvRows.join("\n"); +} + /** * Generates a JSON template from current input values. * Single row → object, multiple rows → array of objects. diff --git a/src/utils/testHelpers.ts b/src/utils/testHelpers.ts new file mode 100644 index 000000000..b016539f7 --- /dev/null +++ b/src/utils/testHelpers.ts @@ -0,0 +1,9 @@ +import type { DynamicDataArgument, InputSpec } from "./componentSpec"; + +export function makeSecretArg(name: string): DynamicDataArgument { + return { dynamicData: { secret: { name } } }; +} + +export function makeInput(name: string, optional = false): InputSpec { + return { name, optional }; +}