From 150fe98ddd21324a860ce3fa00e0f486affcb806 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 20:25:43 -0300 Subject: [PATCH] feat(batch): add UploadFileModal + Used by column + Concept card on /batch/files (F5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create UploadFileModal: drag/drop + click-to-pick, .jsonl + 512MB validation, purpose=batch upload, D14 error sanitization, Escape key handler - Modify files/page.tsx: integrate FilesConceptCard (F3) + Upload toolbar button + UploadFileModal wired to fetchAll refresh - Modify FilesListTab.tsx: add "Used by" column (D12 — derives related batches client-side), download button per row, delete button with canDelete guard (terminal-only or no related batches), colspan updated 6→8 - Create UploadFileModal.test.tsx (9 tests, all passing): render, invalid ext, valid .jsonl, >512MB (size property mock), upload 200 → onUploaded, upload 500 → sanitized error, Escape→onClose, drag-drop, sanitization assert (no /home/ in alert text) --- .../dashboard/batch/FilesListTab.tsx | 103 +++++- .../batch/components/UploadFileModal.tsx | 245 ++++++++++++++ .../dashboard/batch/files/page.tsx | 40 ++- .../batch/components/UploadFileModal.test.tsx | 310 ++++++++++++++++++ 4 files changed, 689 insertions(+), 9 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/batch/components/UploadFileModal.tsx create mode 100644 tests/unit/dashboard/batch/components/UploadFileModal.test.tsx diff --git a/src/app/(dashboard)/dashboard/batch/FilesListTab.tsx b/src/app/(dashboard)/dashboard/batch/FilesListTab.tsx index 0f62a54c93..e1ed795a27 100644 --- a/src/app/(dashboard)/dashboard/batch/FilesListTab.tsx +++ b/src/app/(dashboard)/dashboard/batch/FilesListTab.tsx @@ -65,6 +65,8 @@ const PURPOSE_STYLES_MAP: Record = { assistants: "bg-yellow-500/15 text-yellow-400 border-yellow-500/25", }; +const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled", "expired"]); + function Badge({ value, styles }: Readonly<{ value: string; styles: Record }>) { const cls = styles[value] ?? "bg-gray-500/15 text-gray-400 border-gray-500/25"; return ( @@ -93,6 +95,7 @@ export default function FilesListTab({ const [selectedFileId, setSelectedFileId] = useState(null); const [fileContents, setFileContents] = useState(null); const [contentsLoading, setContentsLoading] = useState(false); + const [deletingId, setDeletingId] = useState(null); const purposes = ["all", ...Array.from(new Set(files.map((f) => f.purpose)))]; @@ -127,6 +130,22 @@ export default function FilesListTab({ } }; + const handleDeleteFile = async (file: FileRecord) => { + setDeletingId(file.id); + try { + const res = await fetch(`/api/v1/files/${file.id}`, { method: "DELETE" }); + if (res.ok) { + onRefresh?.(); + } else { + console.error("[FilesListTab] DELETE returned", res.status); + } + } catch (err) { + console.error("[FilesListTab] DELETE threw", err); + } finally { + setDeletingId(null); + } + }; + return (
{/* Filters */} @@ -171,18 +190,24 @@ export default function FilesListTab({ Size + + {t("filesListUsedByColumn")} + Created Expires + + {/* Actions */} + {loading && filtered.length === 0 ? ( - +
Loading… @@ -191,7 +216,7 @@ export default function FilesListTab({ ) : filtered.length === 0 ? ( - + No files found @@ -199,6 +224,17 @@ export default function FilesListTab({ filtered.map((file) => { const fileCreatedAt = file.createdAt; const fileExpiresAt = file.expiresAt; + + // D12 — derive "Used by" from batches prop + const related = (batches ?? []).filter( + (b) => + b.inputFileId === file.id || + b.outputFileId === file.id || + b.errorFileId === file.id + ); + const allTerminal = related.every((b) => TERMINAL_STATUSES.has(b.status)); + const canDelete = related.length === 0 || allTerminal; + return ( {formatBytes(file.bytes)} + {/* "Used by" column (D12) */} + + {related.length === 0 ? ( + + {t("filesListUsedByNone")} + + ) : ( +
b.id).join(", ")} + > + {related.slice(0, 2).map((b) => ( + + {b.id.slice(0, 16)}… + + ))} + {related.length > 2 && ( + + +{related.length - 2} + + )} +
+ )} + {fileCreatedAt ? relativeTime(fileCreatedAt) : "—"} {fileExpiresAt ? relativeExpiration(fileExpiresAt) : "Never"} + {/* Actions column */} + e.stopPropagation()} + > +
+ {/* Download button */} + e.stopPropagation()} + > + download + + {/* Delete button */} + +
+ ); }) diff --git a/src/app/(dashboard)/dashboard/batch/components/UploadFileModal.tsx b/src/app/(dashboard)/dashboard/batch/components/UploadFileModal.tsx new file mode 100644 index 0000000000..121b6e6463 --- /dev/null +++ b/src/app/(dashboard)/dashboard/batch/components/UploadFileModal.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; + +const MAX_BYTES = 512 * 1024 * 1024; // 512 MB + +interface Props { + onClose: () => void; + onUploaded: (fileId: string) => void; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} + +export default function UploadFileModal({ onClose, onUploaded }: Props) { + const t = useTranslations("common"); + const [file, setFile] = useState(null); + const [dragging, setDragging] = useState(false); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const inputRef = useRef(null); + const overlayRef = useRef(null); + + // Escape key → onClose + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [onClose]); + + function validateAndSet(picked: File) { + setError(null); + if (!picked.name.endsWith(".jsonl")) { + setError(t("uploadModalError")); + return; + } + if (picked.size > MAX_BYTES) { + setError(t("uploadModalError")); + return; + } + setFile(picked); + } + + function handleInputChange(e: React.ChangeEvent) { + const picked = e.target.files?.[0]; + if (picked) validateAndSet(picked); + // Reset input so the same file can be re-picked after Remove + e.target.value = ""; + } + + function handleDragOver(e: React.DragEvent) { + e.preventDefault(); + setDragging(true); + } + + function handleDragLeave(e: React.DragEvent) { + e.preventDefault(); + setDragging(false); + } + + function handleDrop(e: React.DragEvent) { + e.preventDefault(); + setDragging(false); + const picked = e.dataTransfer.files?.[0]; + if (picked) validateAndSet(picked); + } + + async function handleUpload() { + if (!file || uploading) return; + setUploading(true); + setError(null); + try { + const form = new FormData(); + form.append("purpose", "batch"); // D22 hardcoded + form.append("file", file); + const res = await fetch("/api/v1/files", { method: "POST", body: form }); + if (!res.ok) { + setError(t("uploadModalError")); + return; + } + const data = (await res.json()) as { id: string }; + onUploaded(data.id); + onClose(); + } catch (err) { + console.error("[UploadFileModal]", err); + setError(t("uploadModalError")); + } finally { + setUploading(false); + } + } + + return ( +
+ {/* Overlay */} + + ); +} diff --git a/src/app/(dashboard)/dashboard/batch/files/page.tsx b/src/app/(dashboard)/dashboard/batch/files/page.tsx index 36fed941cc..96608d63d8 100644 --- a/src/app/(dashboard)/dashboard/batch/files/page.tsx +++ b/src/app/(dashboard)/dashboard/batch/files/page.tsx @@ -1,16 +1,21 @@ "use client"; import { useState, useEffect, useCallback } from "react"; +import { useTranslations } from "next-intl"; import FilesListTab from "../FilesListTab"; +import FilesConceptCard from "../components/FilesConceptCard"; +import UploadFileModal from "../components/UploadFileModal"; import { mapFileApiToRecord, mapBatchApiToRecord } from "../batch-utils"; import { FileRecord } from "@/lib/db/files"; import { BatchRecord } from "@/lib/db/batches"; export default function BatchFilesPage() { + const t = useTranslations("common"); const [files, setFiles] = useState([]); const [filesTotal, setFilesTotal] = useState(0); const [batches, setBatches] = useState([]); const [loading, setLoading] = useState(true); + const [showUpload, setShowUpload] = useState(false); const fetchAll = useCallback(async () => { setLoading(true); @@ -40,12 +45,33 @@ export default function BatchFilesPage() { }, [fetchAll]); return ( - +
+ +
+ +
+ + {showUpload && ( + setShowUpload(false)} + onUploaded={() => { + setShowUpload(false); + void fetchAll(); + }} + /> + )} +
); } diff --git a/tests/unit/dashboard/batch/components/UploadFileModal.test.tsx b/tests/unit/dashboard/batch/components/UploadFileModal.test.tsx new file mode 100644 index 0000000000..5b7781d059 --- /dev/null +++ b/tests/unit/dashboard/batch/components/UploadFileModal.test.tsx @@ -0,0 +1,310 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +// ── Import component after mocks ───────────────────────────────────────────── + +const { default: UploadFileModal } = await import( + "../../../../../src/app/(dashboard)/dashboard/batch/components/UploadFileModal" +); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const containers: Array<{ root: ReturnType; el: HTMLDivElement }> = []; + +function renderModal( + props: Partial<{ onClose: () => void; onUploaded: (fileId: string) => void }> = {} +) { + const onClose = props.onClose ?? vi.fn(); + const onUploaded = props.onUploaded ?? vi.fn(); + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + act(() => { + root.render(); + }); + containers.push({ root, el }); + return { el, onClose, onUploaded }; +} + +function makeFile(name: string, sizeBytes: number, type = "application/x-jsonlines"): File { + const content = "x".repeat(sizeBytes); + return new File([content], name, { type }); +} + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +afterEach(() => { + for (const { root, el } of containers.splice(0)) { + act(() => root.unmount()); + el.remove(); + } + vi.restoreAllMocks(); +}); + +beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("UploadFileModal", () => { + // 1. Render: text + Upload button disabled + it("renders drop area and Upload button initially disabled", () => { + const { el } = renderModal(); + // Title renders (i18n key returned as-is by mock) + const header = el.querySelector("h2"); + expect(header).not.toBeNull(); + expect(header!.textContent).toContain("uploadModalTitle"); + + // Drop area text + expect(el.textContent).toContain("uploadModalDropOrPick"); + expect(el.textContent).toContain("uploadModalSizeLimit"); + + // Upload button should be disabled (no file selected) + const buttons = Array.from(el.querySelectorAll("button")); + const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload")); + expect(uploadBtn).not.toBeNull(); + expect(uploadBtn!.disabled).toBe(true); + }); + + // 2. Select .txt file → error (invalid extension) + it("shows error when a non-.jsonl file is selected via input", () => { + const { el } = renderModal(); + const input = el.querySelector("input[type='file']") as HTMLInputElement; + expect(input).not.toBeNull(); + + const txtFile = makeFile("data.txt", 100, "text/plain"); + act(() => { + Object.defineProperty(input, "files", { value: [txtFile], configurable: true }); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + + // Error banner should appear + const alert = el.querySelector("[role='alert']"); + expect(alert).not.toBeNull(); + expect(alert!.textContent).toContain("uploadModalError"); + // Upload button still disabled + const buttons = Array.from(el.querySelectorAll("button")); + const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload")); + expect(uploadBtn!.disabled).toBe(true); + }); + + // 3. Select valid .jsonl → shows filename + enables Upload + it("shows filename and enables Upload after selecting a valid .jsonl file", () => { + const { el } = renderModal(); + const input = el.querySelector("input[type='file']") as HTMLInputElement; + + const jsonlFile = makeFile("batch.jsonl", 1024); + act(() => { + Object.defineProperty(input, "files", { value: [jsonlFile], configurable: true }); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + + // Filename visible + expect(el.textContent).toContain("batch.jsonl"); + // Upload button now enabled + const buttons = Array.from(el.querySelectorAll("button")); + const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload")); + expect(uploadBtn!.disabled).toBe(false); + // No error + expect(el.querySelector("[role='alert']")).toBeNull(); + }); + + // 4. Select file > 512 MB → error + it("shows error when file exceeds 512 MB size limit", () => { + const { el } = renderModal(); + const input = el.querySelector("input[type='file']") as HTMLInputElement; + + // Cannot allocate 513MB string in V8; simulate large size via Object.defineProperty on a small File + const smallFile = makeFile("huge.jsonl", 10, "application/x-jsonlines"); + const oversizedFile = Object.defineProperty(smallFile, "size", { + value: 513 * 1024 * 1024, + configurable: true, + }) as File; + + act(() => { + Object.defineProperty(input, "files", { + value: [oversizedFile], + configurable: true, + }); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + + const alert = el.querySelector("[role='alert']"); + expect(alert).not.toBeNull(); + expect(alert!.textContent).toContain("uploadModalError"); + }); + + // 5. Upload with mock fetch 200 → onUploaded called with file id + it("calls onUploaded with the file id on successful upload", async () => { + const onUploaded = vi.fn(); + const onClose = vi.fn(); + const { el } = renderModal({ onUploaded, onClose }); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ id: "file-id-test" }), + }); + vi.stubGlobal("fetch", fetchMock); + + const input = el.querySelector("input[type='file']") as HTMLInputElement; + const jsonlFile = makeFile("batch.jsonl", 100); + act(() => { + Object.defineProperty(input, "files", { value: [jsonlFile], configurable: true }); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + + const buttons = Array.from(el.querySelectorAll("button")); + const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload")); + expect(uploadBtn!.disabled).toBe(false); + + await act(async () => { + uploadBtn!.click(); + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, opts] = fetchMock.mock.calls[0] as [string, RequestInit & { body: FormData }]; + expect(url).toBe("/api/v1/files"); + expect(opts.method).toBe("POST"); + expect(opts.body).toBeInstanceOf(FormData); + expect(onUploaded).toHaveBeenCalledWith("file-id-test"); + expect(onClose).toHaveBeenCalled(); + }); + + // 6. Upload with mock fetch 500 → shows error, never exposes raw message + it("shows generic error on fetch 500 — never exposes raw error message", async () => { + const onUploaded = vi.fn(); + const { el } = renderModal({ onUploaded }); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ error: { message: "stack at /home/user/x.ts:10" } }), + }); + vi.stubGlobal("fetch", fetchMock); + + const input = el.querySelector("input[type='file']") as HTMLInputElement; + const jsonlFile = makeFile("batch.jsonl", 100); + act(() => { + Object.defineProperty(input, "files", { value: [jsonlFile], configurable: true }); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + + const buttons = Array.from(el.querySelectorAll("button")); + const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload")); + + await act(async () => { + uploadBtn!.click(); + }); + + // error banner visible + const alert = el.querySelector("[role='alert']"); + expect(alert).not.toBeNull(); + expect(alert!.textContent).toContain("uploadModalError"); + + // Sanitization assert: raw server message must NOT appear in UI + expect(alert!.textContent).not.toMatch(/home\//); + expect(alert!.textContent).not.toMatch(/stack at/); + + // onUploaded never called + expect(onUploaded).not.toHaveBeenCalled(); + }); + + // 7. Escape key → onClose + it("calls onClose when Escape key is pressed", () => { + const onClose = vi.fn(); + renderModal({ onClose }); + + act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + // 8. Drag-and-drop valid .jsonl → same flow as click + it("accepts a .jsonl file via drag and drop", () => { + const { el } = renderModal(); + + // Find the drop zone (div with onDrop) + const dropZone = el.querySelector("[role='button']") as HTMLDivElement; + expect(dropZone).not.toBeNull(); + + const jsonlFile = makeFile("dropped.jsonl", 512); + + act(() => { + const dragOverEvent = new Event("dragover", { bubbles: true }) as DragEvent; + Object.defineProperty(dragOverEvent, "dataTransfer", { + value: { files: [jsonlFile] }, + configurable: true, + }); + Object.defineProperty(dragOverEvent, "preventDefault", { value: vi.fn() }); + dropZone.dispatchEvent(dragOverEvent); + }); + + act(() => { + const dropEvent = new Event("drop", { bubbles: true }) as DragEvent; + Object.defineProperty(dropEvent, "dataTransfer", { + value: { files: [jsonlFile] }, + configurable: true, + }); + Object.defineProperty(dropEvent, "preventDefault", { value: vi.fn() }); + dropZone.dispatchEvent(dropEvent); + }); + + // Filename should be visible + expect(el.textContent).toContain("dropped.jsonl"); + // Upload button enabled + const buttons = Array.from(el.querySelectorAll("button")); + const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload")); + expect(uploadBtn!.disabled).toBe(false); + }); + + // 9. Sanitization assert: 500 with stack trace in body — UI does NOT show path + it("sanitization: 500 with raw stack trace in error.message — UI never shows file path", async () => { + const { el } = renderModal(); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ + error: { + message: + "TypeError: Cannot read properties of undefined\n at /home/user/server/route.ts:45:12", + }, + }), + }); + vi.stubGlobal("fetch", fetchMock); + + const input = el.querySelector("input[type='file']") as HTMLInputElement; + const jsonlFile = makeFile("test.jsonl", 50); + act(() => { + Object.defineProperty(input, "files", { value: [jsonlFile], configurable: true }); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + + const buttons = Array.from(el.querySelectorAll("button")); + const uploadBtn = buttons.find((b) => b.textContent?.includes("uploadModalUpload")); + + await act(async () => { + uploadBtn!.click(); + }); + + const alert = el.querySelector("[role='alert']"); + expect(alert).not.toBeNull(); + // Must NOT contain any path-like content + expect(alert!.textContent).not.toMatch(/\/home\//); + expect(alert!.textContent).not.toMatch(/route\.ts/); + expect(alert!.textContent).not.toMatch(/at \//); + // But must show the safe generic key + expect(alert!.textContent).toContain("uploadModalError"); + }); +});