From f27911fd4a8b43310d888864648598db0950204b Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 21:27:50 -0300 Subject: [PATCH] feat(batch): add NewBatchWizard (4-step modal: destination, input, validate, cost) + tests (F4) --- .../batch/components/NewBatchWizard.tsx | 354 ++++++++++++ .../components/wizard/CostEstimateStep.tsx | 157 ++++++ .../components/wizard/CsvMappingStep.tsx | 211 +++++++ .../components/wizard/DestinationStep.tsx | 161 ++++++ .../batch/components/wizard/InputStep.tsx | 196 +++++++ .../components/wizard/JsonlValidationStep.tsx | 144 +++++ .../batch/components/NewBatchWizard.test.tsx | 517 ++++++++++++++++++ 7 files changed, 1740 insertions(+) create mode 100644 src/app/(dashboard)/dashboard/batch/components/NewBatchWizard.tsx create mode 100644 src/app/(dashboard)/dashboard/batch/components/wizard/CostEstimateStep.tsx create mode 100644 src/app/(dashboard)/dashboard/batch/components/wizard/CsvMappingStep.tsx create mode 100644 src/app/(dashboard)/dashboard/batch/components/wizard/DestinationStep.tsx create mode 100644 src/app/(dashboard)/dashboard/batch/components/wizard/InputStep.tsx create mode 100644 src/app/(dashboard)/dashboard/batch/components/wizard/JsonlValidationStep.tsx create mode 100644 tests/unit/dashboard/batch/components/NewBatchWizard.test.tsx diff --git a/src/app/(dashboard)/dashboard/batch/components/NewBatchWizard.tsx b/src/app/(dashboard)/dashboard/batch/components/NewBatchWizard.tsx new file mode 100644 index 0000000000..af8ab56642 --- /dev/null +++ b/src/app/(dashboard)/dashboard/batch/components/NewBatchWizard.tsx @@ -0,0 +1,354 @@ +"use client"; + +import { useEffect, useReducer } from "react"; +import { useTranslations } from "next-intl"; +import DestinationStep from "./wizard/DestinationStep"; +import InputStep from "./wizard/InputStep"; +import JsonlValidationStep from "./wizard/JsonlValidationStep"; +import CostEstimateStep from "./wizard/CostEstimateStep"; +import type { WizardDestination, WizardInput, ValidationResult, CostEstimate } from "@/lib/batches/types"; + +// ── State ───────────────────────────────────────────────────────────────────── + +interface WizardState { + step: 1 | 2 | 3 | 4; + destination: WizardDestination | null; + input: WizardInput; + validation: ValidationResult | null; + cost: CostEstimate | null; + creating: boolean; + error: string | null; +} + +const initialInput: WizardInput = { kind: "jsonl", fileName: null, rawContent: null }; + +const initialState: WizardState = { + step: 1, + destination: null, + input: initialInput, + validation: null, + cost: null, + creating: false, + error: null, +}; + +// ── Actions ─────────────────────────────────────────────────────────────────── + +type WizardAction = + | { type: "SET_DESTINATION"; destination: WizardDestination | null } + | { type: "SET_INPUT"; input: WizardInput } + | { type: "SET_VALIDATION_RESULT"; result: ValidationResult } + | { type: "SET_COST_ESTIMATE"; cost: CostEstimate } + | { type: "SET_STEP"; step: 1 | 2 | 3 | 4 } + | { type: "SET_CREATING"; creating: boolean } + | { type: "SET_ERROR"; error: string | null } + | { type: "RESET" }; + +function reducer(state: WizardState, action: WizardAction): WizardState { + switch (action.type) { + case "SET_DESTINATION": + return { ...state, destination: action.destination, error: null }; + case "SET_INPUT": + return { ...state, input: action.input, validation: null, error: null }; + case "SET_VALIDATION_RESULT": + return { ...state, validation: action.result, error: null }; + case "SET_COST_ESTIMATE": + return { ...state, cost: action.cost }; + case "SET_STEP": + return { ...state, step: action.step, error: null }; + case "SET_CREATING": + return { ...state, creating: action.creating }; + case "SET_ERROR": + return { ...state, error: action.error, creating: false }; + case "RESET": + return { ...initialState }; + default: + return state; + } +} + +// ── Step completion guards ──────────────────────────────────────────────────── + +function isStep1Valid(state: WizardState): boolean { + return ( + state.destination !== null && + state.destination.provider.length > 0 && + state.destination.model.length > 0 + ); +} + +function isStep2Valid(state: WizardState): boolean { + if (!state.input.rawContent) return false; + if (state.input.kind === "csv") { + // CSV: rawContent is updated to the generated JSONL by CsvMappingStep + // We detect this by checking the content is valid-looking JSONL (not raw CSV) + // Simplest heuristic: must contain at least one JSON object line + return state.input.rawContent.trim().startsWith("{"); + } + return true; +} + +function isStep3Valid(state: WizardState): boolean { + return state.validation !== null && state.validation.ok; +} + +// ── Stepper indicator ───────────────────────────────────────────────────────── + +const STEPS = [1, 2, 3, 4] as const; +const STEP_LABELS: Record = { + 1: "wizardStep1Destination", + 2: "wizardStep2Input", + 3: "wizardStep3Validate", + 4: "wizardStep4Cost", +}; + +function StepIndicator({ current, t }: { current: 1 | 2 | 3 | 4; t: ReturnType> }) { + return ( +
+ {STEPS.map((s) => { + const isDone = s < current; + const isCurrent = s === current; + return ( +
+
+ {isDone ? ( + check + ) : ( + s + )} +
+ + {t(STEP_LABELS[s] as Parameters[0])} + + {s < 4 && ( +
+ )} +
+ ); + })} +
+ ); +} + +// ── Props ───────────────────────────────────────────────────────────────────── + +interface NewBatchWizardProps { + onClose: () => void; + onCreated: (batchId: string) => void; + availableProviders: Array<{ id: string; name: string; models: string[] }>; +} + +// ── Component ───────────────────────────────────────────────────────────────── + +export default function NewBatchWizard({ + onClose, + onCreated, + availableProviders, +}: NewBatchWizardProps) { + const t = useTranslations("common"); + const [state, dispatch] = useReducer(reducer, initialState); + + // Escape key handler + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape" && !state.creating) onClose(); + } + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [onClose, state.creating]); + + // ── Navigation ────────────────────────────────────────────────────────────── + + function handleNext() { + if (state.step < 4) { + dispatch({ type: "SET_STEP", step: (state.step + 1) as 1 | 2 | 3 | 4 }); + } + } + + function handleBack() { + if (state.step > 1) { + dispatch({ type: "SET_STEP", step: (state.step - 1) as 1 | 2 | 3 | 4 }); + } + } + + // ── Batch creation ────────────────────────────────────────────────────────── + + async function handleCreate() { + if (!state.destination || !state.input.rawContent) return; + + dispatch({ type: "SET_CREATING", creating: true }); + dispatch({ type: "SET_ERROR", error: null }); + + try { + // Step 1: upload input file + const formData = new FormData(); + formData.append("purpose", "batch"); + formData.append( + "file", + new Blob([state.input.rawContent], { type: "application/jsonl" }), + "batch-input.jsonl" + ); + + const fileRes = await fetch("/api/v1/files", { method: "POST", body: formData }); + if (!fileRes.ok) { + console.error("[NewBatchWizard] file upload failed:", fileRes.status); + dispatch({ type: "SET_ERROR", error: t("wizardErrorUpload") }); + return; + } + + const file = (await fileRes.json()) as { id: string }; + + // Step 2: create batch + const batchRes = await fetch("/api/v1/batches", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input_file_id: file.id, + endpoint: state.destination.endpoint, + completion_window: "24h", + }), + }); + + if (!batchRes.ok) { + console.error("[NewBatchWizard] batch create failed:", batchRes.status); + dispatch({ type: "SET_ERROR", error: t("wizardErrorCreate") }); + return; + } + + const batch = (await batchRes.json()) as { id: string }; + onCreated(batch.id); + onClose(); + } catch (err) { + // Hard rule #12 — never expose err.message/stack raw + console.error("[NewBatchWizard] unexpected error:", err); + dispatch({ type: "SET_ERROR", error: t("wizardErrorCreate") }); + } + } + + // ── Next button state ─────────────────────────────────────────────────────── + + const canGoNext = + (state.step === 1 && isStep1Valid(state)) || + (state.step === 2 && isStep2Valid(state)) || + (state.step === 3 && isStep3Valid(state)); + + // ── Step content ──────────────────────────────────────────────────────────── + + const stepContent = (() => { + switch (state.step) { + case 1: + return ( + dispatch({ type: "SET_DESTINATION", destination: dest })} + availableProviders={availableProviders} + /> + ); + case 2: + return ( + dispatch({ type: "SET_INPUT", input: inp })} + destination={state.destination} + /> + ); + case 3: + return ( + dispatch({ type: "SET_VALIDATION_RESULT", result: r })} + /> + ); + case 4: + return ( + + ); + } + })(); + + // ── Render ────────────────────────────────────────────────────────────────── + + return ( +
+ {/* Overlay */} +
!state.creating && onClose()} + /> + + {/* Panel */} +
+ {/* Header */} +
+
+

{t("wizardTitle")}

+ +
+ +
+ + {/* Body */} +
{stepContent}
+ + {/* Footer */} +
+ + +
+ {state.step > 1 && ( + + )} + + {state.step < 4 && ( + + )} +
+
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/batch/components/wizard/CostEstimateStep.tsx b/src/app/(dashboard)/dashboard/batch/components/wizard/CostEstimateStep.tsx new file mode 100644 index 0000000000..3540f0e53a --- /dev/null +++ b/src/app/(dashboard)/dashboard/batch/components/wizard/CostEstimateStep.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { estimateBatchCost } from "@/lib/batches/costEstimator"; +import type { CostEstimate } from "@/lib/batches/types"; +import type { SupportedBatchEndpoint } from "@/lib/batches/types"; + +interface CostEstimateStepProps { + jsonl: string; + model: string; + endpoint: SupportedBatchEndpoint; + onCreate: () => void; + creating: boolean; + error: string | null; +} + +export default function CostEstimateStep({ + jsonl, + model, + endpoint, + onCreate, + creating, + error, +}: CostEstimateStepProps) { + const t = useTranslations("common"); + const [estimate, setEstimate] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + try { + const est = estimateBatchCost({ jsonl, model, endpoint }); + setEstimate(est); + } catch (err) { + console.error("[CostEstimateStep] cost estimation error:", err); + // Fallback: zero-cost estimate so user can still proceed + setEstimate({ + model, + totalRequests: 0, + estimatedInputTokens: 0, + estimatedOutputTokens: 0, + syncCostUsd: 0, + batchCostUsd: 0, + savingsUsd: 0, + pricingSource: "fallback", + warnings: ["Cost estimation failed — shown as $0."], + }); + } finally { + setLoading(false); + } + }, [jsonl, model, endpoint]); + + if (loading) { + return ( +
+ + progress_activity + + Estimating cost… +
+ ); + } + + return ( +
+ {/* Cost breakdown card */} + {estimate && ( +
+ {/* Sync cost (baseline) */} +
+ {t("wizardCostSync")} + + ${estimate.syncCostUsd.toFixed(4)} + +
+ + {/* Batch cost (-50%) */} +
+ {t("wizardCostBatch")} +
+ + -50% + + + ${estimate.batchCostUsd.toFixed(4)} + +
+
+ + {/* Savings */} +
+ {t("wizardCostSavings")} + ${estimate.savingsUsd.toFixed(4)} +
+ + {/* Stats */} +
+ Requests + + {estimate.totalRequests.toLocaleString()} ·{" "} + {estimate.estimatedInputTokens.toLocaleString()} input tok ·{" "} + {estimate.estimatedOutputTokens.toLocaleString()} output tok + +
+
+ )} + + {/* Disclaimer */} +

{t("wizardCostEstimatedNotice")}

+ + {/* Warnings */} + {estimate && estimate.warnings.length > 0 && ( +
+ {estimate.warnings.map((w) => ( +
+ {w} +
+ ))} +
+ )} + + {/* Error banner (already sanitized by orchestrator) */} + {error && ( +
+ {error} +
+ )} + + {/* Create button */} + +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/batch/components/wizard/CsvMappingStep.tsx b/src/app/(dashboard)/dashboard/batch/components/wizard/CsvMappingStep.tsx new file mode 100644 index 0000000000..58df2a7644 --- /dev/null +++ b/src/app/(dashboard)/dashboard/batch/components/wizard/CsvMappingStep.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { csvToJsonl } from "@/lib/batches/csvToJsonl"; +import type { WizardCsvMapping, WizardDestination } from "@/lib/batches/types"; + +// RFC 4180 minimal CSV row parser (inline — avoids coupling to csvToJsonl internals) +function parseCsvRow(line: string): string[] { + const out: string[] = []; + let buf = ""; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inQuotes) { + if (ch === '"' && line[i + 1] === '"') { + buf += '"'; + i++; + continue; + } + if (ch === '"') { + inQuotes = false; + continue; + } + buf += ch; + } else { + if (ch === '"') { + inQuotes = true; + continue; + } + if (ch === ",") { + out.push(buf); + buf = ""; + continue; + } + buf += ch; + } + } + out.push(buf); + return out; +} + +const MAPPING_OPTIONS: Array<{ value: string; label: string }> = [ + { value: "", label: "— ignore —" }, + { value: "custom_id", label: "custom_id" }, + { value: "body.messages[0].content", label: "body.messages[0].content" }, + { value: "body.messages[0].role", label: "body.messages[0].role" }, + { value: "body.input", label: "body.input" }, + { value: "body.prompt", label: "body.prompt" }, + { value: "body.max_tokens", label: "body.max_tokens" }, + { value: "body.temperature", label: "body.temperature" }, +]; + +interface ConversionResult { + rowsParsed: number; + rowsSkipped: number; + errors: Array<{ row: number; reason: string }>; +} + +interface CsvMappingStepProps { + csvContent: string; + mapping: WizardCsvMapping; + onChange: (mapping: WizardCsvMapping) => void; + destination: WizardDestination | null; + onJsonlReady: (jsonl: string, rowsParsed: number, rowsSkipped: number) => void; +} + +export default function CsvMappingStep({ + csvContent, + mapping, + onChange, + destination, + onJsonlReady, +}: CsvMappingStepProps) { + const t = useTranslations("common"); + const [conversionResult, setConversionResult] = useState(null); + + // Detect columns from the first CSV line + const firstLine = csvContent.split(/\r?\n/)[0] ?? ""; + const columns = parseCsvRow(firstLine); + + const mappingValues = Object.values(mapping); + const hasCustomId = mappingValues.includes("custom_id"); + const hasContent = + mappingValues.some((v) => v.startsWith("body.messages[")) || + mappingValues.includes("body.input") || + mappingValues.includes("body.prompt"); + + const isValid = hasCustomId && hasContent; + + function handleColumnMap(column: string, fieldPath: string) { + const next = { ...mapping }; + if (!fieldPath) { + delete next[column]; + } else { + next[column] = fieldPath; + } + onChange(next); + } + + function handleApply() { + if (!isValid || !destination) return; + + let result: ReturnType; + try { + result = csvToJsonl({ + csv: csvContent, + mapping, + defaults: { + method: "POST", + url: destination.endpoint, + model: destination.model, + }, + }); + } catch (err) { + console.error("[CsvMappingStep] csvToJsonl error:", err); + return; + } + + setConversionResult({ + rowsParsed: result.rowsParsed, + rowsSkipped: result.rowsSkipped, + errors: result.errors, + }); + + if (result.jsonl) { + onJsonlReady(result.jsonl, result.rowsParsed, result.rowsSkipped); + } + } + + return ( +
+

{t("wizardCsvMappingTitle")}

+ + {columns.length === 0 && ( +

No columns detected in CSV header.

+ )} + +
+ {columns.map((col) => ( +
+ + {col} + + + +
+ ))} +
+ + {/* Validation hints */} +
+
+ + {hasCustomId ? "check_circle" : "radio_button_unchecked"} + + + custom_id mapped + +
+
+ + {hasContent ? "check_circle" : "radio_button_unchecked"} + + + Content field mapped (messages, input, or prompt) + +
+
+ + {/* Apply button */} + + + {/* Conversion feedback */} + {conversionResult && ( +
+ {conversionResult.rowsParsed} rows parsed + {conversionResult.rowsSkipped > 0 && ( + {conversionResult.rowsSkipped} rows skipped + )} + {conversionResult.errors.slice(0, 5).map((e) => ( + + Row {e.row}: {e.reason} + + ))} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/batch/components/wizard/DestinationStep.tsx b/src/app/(dashboard)/dashboard/batch/components/wizard/DestinationStep.tsx new file mode 100644 index 0000000000..38b32cb9a9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/batch/components/wizard/DestinationStep.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { BATCH_SUPPORTED_PROVIDERS, SUPPORTED_BATCH_ENDPOINTS } from "@/lib/batches/types"; +import type { WizardDestination } from "@/lib/batches/types"; + +interface AvailableProvider { + id: string; + name: string; + models: string[]; +} + +interface DestinationStepProps { + destination: WizardDestination | null; + onChange: (destination: WizardDestination | null) => void; + availableProviders: AvailableProvider[]; +} + +export default function DestinationStep({ + destination, + onChange, + availableProviders, +}: DestinationStepProps) { + const t = useTranslations("common"); + + // Filter providers to only those with batch support (D16) + const batchProviders = availableProviders.filter((p) => + BATCH_SUPPORTED_PROVIDERS.includes(p.id as (typeof BATCH_SUPPORTED_PROVIDERS)[number]) + ); + + if (batchProviders.length === 0) { + return ( +
+ + cloud_off + +

+ {t("wizardEmptyProviders")} +

+ + Connect a provider + +
+ ); + } + + const selectedProvider = destination?.provider ?? ""; + const selectedEndpoint = destination?.endpoint ?? "/v1/chat/completions"; + const selectedModel = destination?.model ?? ""; + + const providerModels = + batchProviders.find((p) => p.id === selectedProvider)?.models ?? []; + + function handleProviderChange(providerId: string) { + if (!providerId) { + onChange(null); + return; + } + // Validate provider type + const validProvider = BATCH_SUPPORTED_PROVIDERS.includes( + providerId as (typeof BATCH_SUPPORTED_PROVIDERS)[number] + ); + if (!validProvider) return; + const provider = providerId as WizardDestination["provider"]; + const models = batchProviders.find((p) => p.id === provider)?.models ?? []; + onChange({ + provider, + endpoint: selectedEndpoint, + model: models[0] ?? "", + }); + } + + function handleEndpointChange(endpoint: string) { + if (!selectedProvider) return; + onChange({ + provider: selectedProvider as WizardDestination["provider"], + endpoint: endpoint as WizardDestination["endpoint"], + model: selectedModel, + }); + } + + function handleModelChange(model: string) { + if (!selectedProvider) return; + onChange({ + provider: selectedProvider as WizardDestination["provider"], + endpoint: selectedEndpoint, + model, + }); + } + + const selectClass = + "w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-alt)] " + + "px-3 py-2 text-sm text-[var(--color-text)] focus:outline-none " + + "focus:ring-1 focus:ring-[var(--color-accent)] disabled:opacity-50"; + + return ( +
+ {/* Provider */} +
+ + +
+ + {/* Endpoint */} +
+ + +
+ + {/* Model */} +
+ + +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/batch/components/wizard/InputStep.tsx b/src/app/(dashboard)/dashboard/batch/components/wizard/InputStep.tsx new file mode 100644 index 0000000000..88bf4be79e --- /dev/null +++ b/src/app/(dashboard)/dashboard/batch/components/wizard/InputStep.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import CsvMappingStep from "./CsvMappingStep"; +import type { WizardInput, WizardCsvMapping, WizardDestination } from "@/lib/batches/types"; + +const MAX_FULL_BYTES = 5_000_000; // 5 MB — full read threshold (D7) +const SAMPLE_HEAD_BYTES = 5_000_000; +const SAMPLE_TAIL_BYTES = 100_000; + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +async function readFileContent(file: File): Promise { + if (file.size <= MAX_FULL_BYTES) { + return await file.text(); + } + // Sampling for large files (D7) + const headText = await file.slice(0, SAMPLE_HEAD_BYTES).text(); + const tailText = await file.slice(file.size - SAMPLE_TAIL_BYTES).text(); + return headText + "\n[...sample only...]\n" + tailText; +} + +interface InputStepProps { + input: WizardInput; + onChange: (input: WizardInput) => void; + destination: WizardDestination | null; +} + +export default function InputStep({ input, onChange, destination }: InputStepProps) { + const t = useTranslations("common"); + const fileInputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [isReading, setIsReading] = useState(false); + const [csvJsonl, setCsvJsonl] = useState(null); + + const isJsonl = input.kind === "jsonl"; + const isCsv = input.kind === "csv"; + + function handleKindChange(kind: "jsonl" | "csv") { + onChange({ kind, fileName: null, rawContent: null, csvMapping: kind === "csv" ? {} : undefined }); + setCsvJsonl(null); + } + + async function processFile(file: File) { + const expectedExt = isJsonl ? ".jsonl" : ".csv"; + if (!file.name.toLowerCase().endsWith(expectedExt)) { + // Soft warning — don't block, let validation catch it + } + setIsReading(true); + try { + const content = await readFileContent(file); + onChange({ + kind: input.kind, + fileName: file.name, + rawContent: content, + csvMapping: input.kind === "csv" ? (input.csvMapping ?? {}) : undefined, + }); + } catch (err) { + console.error("[InputStep] file read error:", err); + } finally { + setIsReading(false); + } + } + + function handleFileInputChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (file) processFile(file); + // Reset input so same file can be re-picked + e.target.value = ""; + } + + function handleDrop(e: React.DragEvent) { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files?.[0]; + if (file) processFile(file); + } + + function handleDragOver(e: React.DragEvent) { + e.preventDefault(); + setIsDragging(true); + } + + function handleDragLeave() { + setIsDragging(false); + } + + function handleCsvMappingChange(mapping: WizardCsvMapping) { + onChange({ ...input, csvMapping: mapping }); + setCsvJsonl(null); + } + + function handleJsonlReady(jsonl: string, _rowsParsed: number, _rowsSkipped: number) { + setCsvJsonl(jsonl); + // Replace rawContent with the converted JSONL so validation step gets it + onChange({ ...input, rawContent: jsonl, csvMapping: input.csvMapping }); + } + + const isLargeFile = input.rawContent != null && input.rawContent.includes("[...sample only...]"); + const hasFile = input.fileName != null && input.rawContent != null; + const csvMappingReady = isCsv && csvJsonl != null; + + // For CSV: needs mapping complete + jsonl generated to be "ready" + // rawContent will be updated by handleJsonlReady once mapping applied + + const acceptAttr = isJsonl ? ".jsonl" : ".csv"; + + return ( +
+ {/* Kind toggle */} +
+ + +
+ + {/* Drop zone */} +
fileInputRef.current?.click()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); + }} + className={`rounded-xl border-2 border-dashed p-8 flex flex-col items-center gap-3 cursor-pointer transition-colors + ${isDragging ? "border-[var(--color-accent)] bg-[var(--color-accent)]/5" : "border-[var(--color-border)] hover:border-[var(--color-accent)]/50"}`} + > + + upload_file + + {isReading ? ( + Reading file… + ) : hasFile ? ( +
+ {input.fileName} + + {isLargeFile ? "Large file — validation by sampling" : "Ready"} + +
+ ) : ( + {t("wizardDropOrPick")} + )} + +
+ + {/* Large file warning */} + {isLargeFile && ( +
+ Large file detected — validation will run by sampling (first 5 MB + last 100 KB). Full + validation happens server-side. +
+ )} + + {/* CSV mapping (inline step 2.5) */} + {isCsv && hasFile && input.rawContent && ( +
+ + {csvMappingReady && ( +

JSONL generated — ready to validate.

+ )} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/batch/components/wizard/JsonlValidationStep.tsx b/src/app/(dashboard)/dashboard/batch/components/wizard/JsonlValidationStep.tsx new file mode 100644 index 0000000000..f9e11be888 --- /dev/null +++ b/src/app/(dashboard)/dashboard/batch/components/wizard/JsonlValidationStep.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { validateJsonl } from "@/lib/batches/validateJsonl"; +import type { ValidationResult } from "@/lib/batches/types"; +import type { SupportedBatchEndpoint } from "@/lib/batches/types"; + +interface JsonlValidationStepProps { + jsonl: string; + endpoint: SupportedBatchEndpoint; + onResult: (result: ValidationResult) => void; +} + +export default function JsonlValidationStep({ + jsonl, + endpoint, + onResult, +}: JsonlValidationStepProps) { + const t = useTranslations("common"); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + setResult(null); + try { + const r = validateJsonl(jsonl, { endpoint }); + setResult(r); + onResult(r); + } catch (err) { + console.error("[JsonlValidationStep] validate error:", err); + // Provide a minimal failed result on exception + const errResult: ValidationResult = { + ok: false, + totalLines: 0, + sampledLines: 0, + uniqueCustomIds: 0, + duplicateCustomIds: [], + errors: [{ lineNumber: 0, reason: "Validation failed — could not parse content." }], + preview: [], + byteSize: 0, + }; + setResult(errResult); + onResult(errResult); + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [jsonl, endpoint]); + + if (loading) { + return ( +
+ + progress_activity + + Validating… +
+ ); + } + + if (!result) return null; + + return ( +
+ {/* OK / Error banner */} + {result.ok ? ( +
+ check_circle +
+ {t("wizardValidationOk")} + + {result.totalLines} lines · {result.uniqueCustomIds} unique custom_ids + +
+
+ ) : ( +
+ error +
+ {t("wizardValidationErrors")} + + {result.errors.length} error{result.errors.length !== 1 ? "s" : ""} found + +
+
+ )} + + {/* Sampling note */} + {result.sampledLines < result.totalLines && ( +
+ {t("wizardValidationSamplingNote")} +
+ )} + + {/* Duplicate IDs */} + {result.duplicateCustomIds.length > 0 && ( +
+ Duplicate custom_ids detected: + {result.duplicateCustomIds.slice(0, 10).map((id) => ( + + {id} + + ))} +
+ )} + + {/* Error table */} + {result.errors.length > 0 && ( +
+ + Errors (first {Math.min(result.errors.length, 50)}): + +
+ {result.errors.slice(0, 50).map((err) => ( +
+ + Line {err.lineNumber} + + {err.reason} + {err.field && ( + {err.field} + )} +
+ ))} +
+
+ )} + + {/* Preview */} + {result.preview.length > 0 && ( +
+ + {t("wizardValidationPreview")} + +
+            {JSON.stringify(result.preview, null, 2)}
+          
+
+ )} +
+ ); +} diff --git a/tests/unit/dashboard/batch/components/NewBatchWizard.test.tsx b/tests/unit/dashboard/batch/components/NewBatchWizard.test.tsx new file mode 100644 index 0000000000..762dbf4599 --- /dev/null +++ b/tests/unit/dashboard/batch/components/NewBatchWizard.test.tsx @@ -0,0 +1,517 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// ── i18n mock ───────────────────────────────────────────────────────────────── + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +// ── next/link mock ──────────────────────────────────────────────────────────── + +vi.mock("next/link", () => ({ + default: ({ href, children }: { href: string; children: React.ReactNode }) => ( + {children} + ), +})); + +// ── lib mocks ───────────────────────────────────────────────────────────────── + +vi.mock("@/lib/batches/validateJsonl", () => ({ + validateJsonl: vi.fn(() => ({ + ok: true, + totalLines: 1, + sampledLines: 1, + uniqueCustomIds: 1, + duplicateCustomIds: [], + errors: [], + preview: [{ custom_id: "req-1", method: "POST", url: "/v1/chat/completions", body: {} }], + byteSize: 100, + })), +})); + +vi.mock("@/lib/batches/costEstimator", () => ({ + estimateBatchCost: vi.fn(() => ({ + model: "gpt-4o-mini", + totalRequests: 1, + estimatedInputTokens: 100, + estimatedOutputTokens: 256, + syncCostUsd: 0.001, + batchCostUsd: 0.0005, + savingsUsd: 0.0005, + pricingSource: "exact-match" as const, + warnings: [], + })), +})); + +vi.mock("@/lib/batches/csvToJsonl", () => ({ + csvToJsonl: vi.fn(() => ({ + jsonl: + '{"custom_id":"req-1","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hello"}]}}\n', + rowsParsed: 1, + rowsSkipped: 0, + errors: [], + })), +})); + +// ── Fetch mock ──────────────────────────────────────────────────────────────── + +const mockFetch = vi.fn(); + +beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockReset(); + mockFetch.mockImplementation(async (url: string) => { + if (url === "/api/v1/files") { + return { ok: true, json: async () => ({ id: "file-test" }) }; + } + if (url === "/api/v1/batches") { + return { ok: true, json: async () => ({ id: "batch-test" }) }; + } + return { ok: false, status: 404, json: async () => ({}) }; + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + // Unmount all containers + for (const { root, el } of containers.splice(0)) { + act(() => root.unmount()); + el.remove(); + } +}); + +// ── Component import ────────────────────────────────────────────────────────── + +const { default: NewBatchWizard } = await import( + "../../../../../src/app/(dashboard)/dashboard/batch/components/NewBatchWizard" +); + +// ── Render helpers ──────────────────────────────────────────────────────────── + +const containers: Array<{ root: ReturnType; el: HTMLDivElement }> = []; + +const DEFAULT_PROVIDERS = [ + { id: "openai", name: "OpenAI", models: ["gpt-4o-mini", "gpt-4o"] }, +]; + +function renderWizard(props?: { + onClose?: () => void; + onCreated?: (id: string) => void; + availableProviders?: Array<{ id: string; name: string; models: string[] }>; +}) { + const onClose = props?.onClose ?? vi.fn(); + const onCreated = props?.onCreated ?? vi.fn(); + const availableProviders = props?.availableProviders ?? DEFAULT_PROVIDERS; + + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + + act(() => { + root.render( + + ); + }); + + containers.push({ root, el }); + return { el, onClose, onCreated }; +} + +// Helper: wait for a condition to be true with retries +async function waitFor( + fn: () => boolean, + options: { timeout?: number; interval?: number } = {} +): Promise { + const { timeout = 3000, interval = 50 } = options; + const start = Date.now(); + while (Date.now() - start < timeout) { + if (fn()) return; + await new Promise((r) => setTimeout(r, interval)); + } + if (!fn()) throw new Error("waitFor timed out"); +} + +// Helper: navigate to a given step by filling required fields +async function goToStep2(el: HTMLElement) { + const selects = el.querySelectorAll("select"); + await act(async () => { + (selects[0] as HTMLSelectElement).value = "openai"; + selects[0].dispatchEvent(new Event("change", { bubbles: true })); + }); + // After provider selected, get updated selects + const selectsAfter = el.querySelectorAll("select"); + await act(async () => { + (selectsAfter[2] as HTMLSelectElement).value = "gpt-4o-mini"; + selectsAfter[2].dispatchEvent(new Event("change", { bubbles: true })); + }); + const nextBtn = el.querySelector("button:not([disabled])")!; + // find Next button by text + const allBtns = Array.from(el.querySelectorAll("button")); + const nextBtns = allBtns.filter((b) => b.textContent === "wizardNext"); + await act(async () => { + if (nextBtns[0] && !(nextBtns[0] as HTMLButtonElement).disabled) { + nextBtns[0].click(); + } + }); +} + +async function injectFileContent(el: HTMLElement, content: string, filename = "batch.jsonl") { + const fileInput = el.querySelector("input[type='file']") as HTMLInputElement; + const file = new File([content], filename, { type: "application/jsonl" }); + await act(async () => { + Object.defineProperty(fileInput, "files", { value: [file], configurable: true }); + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }); + // File.text() is async — wait a tick + await new Promise((r) => setTimeout(r, 100)); +} + +const JSONL_VALID = + '{"custom_id":"req-1","method":"POST","url":"/v1/chat/completions","body":{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hello"}]}}\n'; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("NewBatchWizard", () => { + // 1. Renders step 1 with dropdowns, Next disabled + it("renders step 1 with provider/endpoint/model labels and Next disabled", () => { + const { el } = renderWizard(); + expect(el.textContent).toContain("wizardStep1Destination"); + expect(el.textContent).toContain("wizardProviderLabel"); + expect(el.textContent).toContain("wizardEndpointLabel"); + expect(el.textContent).toContain("wizardModelLabel"); + const nextBtns = Array.from(el.querySelectorAll("button")).filter( + (b) => b.textContent === "wizardNext" + ); + expect(nextBtns.length).toBeGreaterThan(0); + expect((nextBtns[0] as HTMLButtonElement).disabled).toBe(true); + }); + + // 2. Empty state when availableProviders is empty + it("shows empty state and no selects when no batch-capable providers", () => { + const { el } = renderWizard({ availableProviders: [] }); + expect(el.textContent).toContain("wizardEmptyProviders"); + expect(el.querySelectorAll("select").length).toBe(0); + }); + + // 3. Escape key → onClose + it("calls onClose on Escape key press", () => { + const onClose = vi.fn(); + renderWizard({ onClose }); + act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + // 4. Cancel button → onClose + it("calls onClose when Cancel button is clicked", () => { + const onClose = vi.fn(); + const { el } = renderWizard({ onClose }); + const cancelBtn = Array.from(el.querySelectorAll("button")).find( + (b) => b.textContent === "wizardCancel" + ); + expect(cancelBtn).toBeDefined(); + act(() => { + cancelBtn!.click(); + }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + // 5. Overlay click → onClose + it("calls onClose when overlay backdrop is clicked", () => { + const onClose = vi.fn(); + const { el } = renderWizard({ onClose }); + // Overlay has the backdrop-blur-sm + bg-black/40 class + const overlay = el.querySelector(".backdrop-blur-sm"); + expect(overlay).not.toBeNull(); + act(() => { + overlay!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + // 6. Selecting provider + model enables Next, clicking goes to step 2 + it("enables Next after selecting provider+model and advances to step 2", async () => { + const { el } = renderWizard(); + const selects = el.querySelectorAll("select"); + await act(async () => { + (selects[0] as HTMLSelectElement).value = "openai"; + selects[0].dispatchEvent(new Event("change", { bubbles: true })); + }); + const selectsAfter = el.querySelectorAll("select"); + await act(async () => { + (selectsAfter[2] as HTMLSelectElement).value = "gpt-4o-mini"; + selectsAfter[2].dispatchEvent(new Event("change", { bubbles: true })); + }); + + const nextBtns = Array.from(el.querySelectorAll("button")).filter( + (b) => b.textContent === "wizardNext" + ); + expect((nextBtns[0] as HTMLButtonElement).disabled).toBe(false); + await act(async () => { + nextBtns[0].click(); + }); + expect(el.textContent).toContain("wizardInputKindJsonl"); + expect(el.textContent).toContain("wizardInputKindCsv"); + }); + + // 7. Step 2 — Next disabled without file + it("shows JSONL/CSV toggle in step 2 and Next disabled without file", async () => { + const { el } = renderWizard(); + await goToStep2(el); + expect(el.textContent).toContain("wizardInputKindJsonl"); + expect(el.textContent).toContain("wizardDropOrPick"); + const nextBtns = Array.from(el.querySelectorAll("button")).filter( + (b) => b.textContent === "wizardNext" + ); + expect((nextBtns[0] as HTMLButtonElement).disabled).toBe(true); + }); + + // 8. Back button returns to step 1 + it("shows Back button on step 2 and navigates back to step 1", async () => { + const { el } = renderWizard(); + await goToStep2(el); + const backBtn = Array.from(el.querySelectorAll("button")).find( + (b) => b.textContent === "wizardBack" + ); + expect(backBtn).toBeDefined(); + await act(async () => { + backBtn!.click(); + }); + expect(el.textContent).toContain("wizardStep1Destination"); + const backBtns = Array.from(el.querySelectorAll("button")).filter( + (b) => b.textContent === "wizardBack" + ); + expect(backBtns.length).toBe(0); + }); + + // 9. Validation errors block Next in step 3 + it("blocks Next in step 3 when validation returns errors", async () => { + const { validateJsonl } = await import("@/lib/batches/validateJsonl"); + vi.mocked(validateJsonl).mockReturnValueOnce({ + ok: false, + totalLines: 2, + sampledLines: 2, + uniqueCustomIds: 0, + duplicateCustomIds: [], + errors: [{ lineNumber: 1, reason: "custom_id missing or empty", field: "custom_id" }], + preview: [], + byteSize: 50, + }); + + const { el } = renderWizard(); + await goToStep2(el); + await injectFileContent(el, JSONL_VALID); + + // Wait for Next to enable (file loaded) + await waitFor(() => { + const btns = Array.from(el.querySelectorAll("button")).filter( + (b) => b.textContent === "wizardNext" + ); + return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled; + }); + + await act(async () => { + const nextBtn = Array.from(el.querySelectorAll("button")).find( + (b) => b.textContent === "wizardNext" + ); + nextBtn!.click(); + }); + + // Wait for validation to complete + await waitFor(() => el.textContent!.includes("wizardValidationErrors")); + + // Error text visible + expect(el.textContent).toContain("custom_id missing or empty"); + + // Next disabled + const nextBtns = Array.from(el.querySelectorAll("button")).filter( + (b) => b.textContent === "wizardNext" + ); + expect((nextBtns[0] as HTMLButtonElement).disabled).toBe(true); + }); + + // 10. Step 4 cost breakdown visible + it("renders cost breakdown card in step 4 after validation ok", async () => { + const { el } = renderWizard(); + await goToStep2(el); + await injectFileContent(el, JSONL_VALID); + + await waitFor(() => { + const btns = Array.from(el.querySelectorAll("button")).filter( + (b) => b.textContent === "wizardNext" + ); + return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled; + }); + + // Step 3 + await act(async () => { + const nextBtn = Array.from(el.querySelectorAll("button")).find( + (b) => b.textContent === "wizardNext" + ); + nextBtn!.click(); + }); + + // Wait validation completes + await waitFor(() => el.textContent!.includes("wizardValidationOk")); + await waitFor(() => { + const btns = Array.from(el.querySelectorAll("button")).filter( + (b) => b.textContent === "wizardNext" + ); + return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled; + }); + + // Step 4 + await act(async () => { + const nextBtn = Array.from(el.querySelectorAll("button")).find( + (b) => b.textContent === "wizardNext" + ); + nextBtn!.click(); + }); + + // Wait for cost estimate to load + await waitFor(() => el.textContent!.includes("wizardCostSync"), { timeout: 5000 }); + expect(el.textContent).toContain("wizardCostBatch"); + expect(el.textContent).toContain("wizardCostSavings"); + expect(el.textContent).toContain("wizardCreate"); + }); + + // 11. Full happy path → onCreated("batch-test") + it("calls onCreated('batch-test') after complete wizard flow", async () => { + const onCreated = vi.fn(); + const onClose = vi.fn(); + const { el } = renderWizard({ onCreated, onClose }); + + await goToStep2(el); + await injectFileContent(el, JSONL_VALID); + + await waitFor(() => { + const btns = Array.from(el.querySelectorAll("button")).filter( + (b) => b.textContent === "wizardNext" + ); + return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled; + }); + + // Step 3 + await act(async () => { + Array.from(el.querySelectorAll("button")) + .find((b) => b.textContent === "wizardNext")! + .click(); + }); + await waitFor(() => el.textContent!.includes("wizardValidationOk")); + await waitFor(() => { + const btns = Array.from(el.querySelectorAll("button")).filter( + (b) => b.textContent === "wizardNext" + ); + return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled; + }); + + // Step 4 + await act(async () => { + Array.from(el.querySelectorAll("button")) + .find((b) => b.textContent === "wizardNext")! + .click(); + }); + await waitFor(() => el.textContent!.includes("wizardCreate"), { timeout: 5000 }); + + // Click create (textContent includes icon text "rocket_launch" before the key) + await act(async () => { + Array.from(el.querySelectorAll("button")) + .find((b) => b.textContent?.includes("wizardCreate"))! + .click(); + }); + + await waitFor(() => onCreated.mock.calls.length > 0, { timeout: 5000 }); + + expect(onCreated).toHaveBeenCalledWith("batch-test"); + expect(onClose).toHaveBeenCalled(); + + // Assert fetch shapes + expect(mockFetch).toHaveBeenCalledWith("/api/v1/files", expect.objectContaining({ method: "POST" })); + const batchCall = mockFetch.mock.calls.find((c) => c[0] === "/api/v1/batches"); + expect(batchCall).toBeDefined(); + const body = JSON.parse(batchCall![1].body as string) as Record; + expect(body.input_file_id).toBe("file-test"); + expect(body.completion_window).toBe("24h"); + expect(typeof body.endpoint).toBe("string"); + }); + + // 12. File upload 500 → error banner sanitized (no stack trace / path) + it("shows sanitized error on file upload 500 — no stack trace in banner", async () => { + mockFetch.mockImplementation(async (url: string) => { + if (url === "/api/v1/files") { + return { + ok: false, + status: 500, + json: async () => ({ + error: { message: "Internal error at /home/user/server/files.ts:42 — stack at line 42" }, + }), + }; + } + return { ok: false, status: 404, json: async () => ({}) }; + }); + + const onCreated = vi.fn(); + const onClose = vi.fn(); + const { el } = renderWizard({ onCreated, onClose }); + + await goToStep2(el); + await injectFileContent(el, JSONL_VALID); + + await waitFor(() => { + const btns = Array.from(el.querySelectorAll("button")).filter( + (b) => b.textContent === "wizardNext" + ); + return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled; + }); + + await act(async () => { + Array.from(el.querySelectorAll("button")) + .find((b) => b.textContent === "wizardNext")! + .click(); + }); + await waitFor(() => el.textContent!.includes("wizardValidationOk")); + await waitFor(() => { + const btns = Array.from(el.querySelectorAll("button")).filter( + (b) => b.textContent === "wizardNext" + ); + return btns.length > 0 && !(btns[0] as HTMLButtonElement).disabled; + }); + + await act(async () => { + Array.from(el.querySelectorAll("button")) + .find((b) => b.textContent === "wizardNext")! + .click(); + }); + await waitFor(() => el.textContent!.includes("wizardCreate"), { timeout: 5000 }); + + // Click create (textContent includes icon text "rocket_launch" before the key) + await act(async () => { + Array.from(el.querySelectorAll("button")) + .find((b) => b.textContent?.includes("wizardCreate"))! + .click(); + }); + + // Wait for error to appear + await waitFor(() => el.querySelector("[role='alert']") !== null, { timeout: 5000 }); + + const banner = el.querySelector("[role='alert']")!; + const bannerText = banner.textContent ?? ""; + + // SANITIZATION ASSERT — hard requirement from D14 / Hard Rule #12 + expect(bannerText).not.toMatch(/\/home\/|stack at|at \//); + // Should show the i18n error key (mocked to return the key string) + expect(bannerText).toBe("wizardErrorUpload"); + + // onCreated must NOT have been called + expect(onCreated).not.toHaveBeenCalled(); + }); +});