diff --git a/csv-table-hub-main/src/__tests__/files-refresh.test.ts b/csv-table-hub-main/src/__tests__/files-refresh.test.ts index 2d68f92..95af2e4 100644 --- a/csv-table-hub-main/src/__tests__/files-refresh.test.ts +++ b/csv-table-hub-main/src/__tests__/files-refresh.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { QueryClient } from "@tanstack/react-query"; -import { filesQuery, loadFiles } from "@/lib/csv.functions"; +import { filesQuery, uploadsQuery, loadFiles } from "@/lib/csv.functions"; const file = (name: string) => ({ id: name, @@ -18,17 +18,33 @@ const file = (name: string) => ({ afterEach(() => vi.unstubAllGlobals()); +const attempt = (id: string, status: string) => ({ + id, + file_name: `${id}.csv`, + mode: "dynamic", + status, + total_rows: 3, + imported_rows: 0, + failed_rows: 3, + uploaded_at: "2026-10-26T00:00:00Z", +}); + describe("loadFiles", () => { - it("refetches the files list when the loader re-runs", async () => { - const responses = [[file("a")], [file("a"), file("b")]]; - const fetchMock = vi.fn(async () => new Response(JSON.stringify(responses.shift()))); + it("refetches the files list and the upload attempts when the loader re-runs", async () => { + const files = [[file("a")], [file("a"), file("b")]]; + const uploads = [[], [attempt("7", "failed")]]; + const fetchMock = vi.fn(async (url: string) => + new Response(JSON.stringify(String(url).includes("/uploads") ? uploads.shift() : files.shift())), + ); vi.stubGlobal("fetch", fetchMock); const qc = new QueryClient(); await loadFiles(qc); // first page load await loadFiles(qc); // router.invalidate() after an upload + // #187: an all-failed upload has no migrated file, so it must come from the attempts list. await vi.waitFor(() => expect(qc.getQueryData(filesQuery.queryKey)).toHaveLength(2)); - expect(fetchMock).toHaveBeenCalledTimes(2); + await vi.waitFor(() => expect(qc.getQueryData(uploadsQuery.queryKey)).toHaveLength(1)); + expect(fetchMock).toHaveBeenCalledTimes(4); }); }); diff --git a/csv-table-hub-main/src/__tests__/upload-attempts.test.tsx b/csv-table-hub-main/src/__tests__/upload-attempts.test.tsx new file mode 100644 index 0000000..de3e5f3 --- /dev/null +++ b/csv-table-hub-main/src/__tests__/upload-attempts.test.tsx @@ -0,0 +1,112 @@ +// #187 (BR-25): a failed upload has no migrated file, so it must be reachable from the +// upload-attempt registry, and the upload response must keep its uploadId. + +import { afterEach, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { uploadCsv, type UploadAttempt } from "@/lib/csv.functions"; +import { needsAttention } from "@/lib/job-outcome"; + +vi.mock("@tanstack/react-router", async () => { + const actual = + await vi.importActual("@tanstack/react-router"); + return { + ...actual, + Link: ({ + to, + params, + children, + }: { + to: string; + params: Record; + children: unknown; + }) => {children as never}, + }; +}); + +import { UploadAttempts } from "@/routes/_authenticated/index"; + +const attempt = (id: string, status: string, failed = 0): UploadAttempt => ({ + id, + file_name: `${status}_${id}.csv`, + mode: "dynamic", + status, + total_rows: 3, + imported_rows: 3 - failed, + failed_rows: failed, + uploaded_at: "2026-11-09T00:00:00Z", +}); + +afterEach(() => vi.unstubAllGlobals()); + +const logs = [{ ts: 1, step: "done", level: "info", message: "m" }]; +const ok = { + status: "ok", + fileId: "1", + tableName: "csv_a", + totalRows: 3, + insertedRows: 3, + duplicateRowsSkipped: 0, + failedRows: 0, + columns: ["a"], + types: ["text"], + rowErrors: [], + logs, +}; + +it("keeps uploadId on success, partial and all-failed responses", async () => { + const bodies = [ + { ...ok, uploadId: "11" }, + { ...ok, insertedRows: 1, failedRows: 2, uploadId: "12" }, + { status: "error", message: "every row failed", rowErrors: [], logs, uploadId: "13" }, + ]; + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify(bodies.shift()))), + ); + const ids = []; + for (let i = 0; i < 3; i++) { + const res = await uploadCsv({ fileName: "a.csv", content: "a\n1\n" }); + ids.push((res as { uploadId?: string | null }).uploadId); + } + expect(ids).toEqual(["11", "12", "13"]); +}); + +it("lists an all-failed upload with a review link, hiding clean imports by default", async () => { + const user = userEvent.setup(); + render( + , + ); + expect(screen.getByText("failed_2.csv")).toBeInTheDocument(); + expect(screen.getByText("partly_imported_3.csv")).toBeInTheDocument(); + expect(screen.queryByText("imported_1.csv")).not.toBeInTheDocument(); + expect( + screen.getAllByRole("link", { name: "Review" }).map((a) => a.getAttribute("href")), + ).toEqual(["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/review/2", "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/review/3"]); + expect(screen.getByText("3 of 3 rows failed")).toBeInTheDocument(); + + await user.click(screen.getByLabelText(/only failed or partial/i)); + expect(screen.getByText("imported_1.csv")).toBeInTheDocument(); +}); + +it("gives a rejected upload no review link, since nothing was stored to review", () => { + render(); + expect(screen.getByText("rejected_4.csv")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Review" })).not.toBeInTheDocument(); +}); + +it("renders nothing without any upload attempts", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); +}); + +it("needsAttention flags failed, partial and rejected only", () => { + expect(["failed", "partly_imported", "rejected"].every(needsAttention)).toBe(true); + expect(["imported", "duplicate"].some(needsAttention)).toBe(false); +}); diff --git a/csv-table-hub-main/src/lib/csv.functions.ts b/csv-table-hub-main/src/lib/csv.functions.ts index 2afd19a..bd07654 100644 --- a/csv-table-hub-main/src/lib/csv.functions.ts +++ b/csv-table-hub-main/src/lib/csv.functions.ts @@ -36,9 +36,13 @@ const processingLogSchema = z.object({ }); export type ProcessingLog = z.infer; +// #187: the API sends the stored attempt's id on every outcome, failures included. +const uploadIdField = { uploadId: z.string().nullable().optional() }; + const uploadResultSchema = z.discriminatedUnion("status", [ z.object({ status: z.literal("ok"), + ...uploadIdField, fileId: z.string(), tableName: z.string(), totalRows: z.number(), @@ -61,6 +65,7 @@ const uploadResultSchema = z.discriminatedUnion("status", [ }), z.object({ status: z.literal("duplicate_file"), + ...uploadIdField, reason: z.enum(["content", "name"]), existingFileName: z.string(), tableName: z.string(), @@ -69,12 +74,14 @@ const uploadResultSchema = z.discriminatedUnion("status", [ }), z.object({ status: z.literal("invalid_structure"), + ...uploadIdField, reason: z.enum(["empty", "header_only", "no_columns"]), message: z.string(), logs: z.array(processingLogSchema), }), z.object({ status: z.literal("error"), + ...uploadIdField, message: z.string(), rowErrors: z.array(rowErrorSchema).optional(), logs: z.array(processingLogSchema), @@ -173,6 +180,26 @@ export async function uploadCsv(input: { }); } +const uploadAttemptSchema = z.object({ + id: z.string(), + file_name: z.string(), + mode: z.string(), + status: z.string(), // imported | partly_imported | failed | rejected | duplicate + total_rows: z.number().nullable(), + imported_rows: z.number().nullable(), + failed_rows: z.number().nullable(), + uploader: z.string().nullable().optional(), + uploaded_at: z.string(), +}); +export type UploadAttempt = z.infer; + +/** Every upload attempt, failed ones included (BR-25), newest first. */ +export async function listUploads(): Promise { + return apiFetch("/api/csv/uploads", z.array(uploadAttemptSchema)); +} + +export const uploadsQuery = { queryKey: ["csv-uploads"], queryFn: listUploads }; + export async function listCsvFiles(): Promise { return apiFetch("/api/csv/files", csvFileSummaryListSchema); } @@ -182,7 +209,10 @@ export const filesQuery = { queryKey: ["csv-files"], queryFn: listCsvFiles }; // Route loader for the files list. revalidateIfStale makes router.invalidate() // (called after each upload) refetch instead of returning the cached list (#64). export const loadFiles = (queryClient: QueryClient) => - queryClient.ensureQueryData({ ...filesQuery, revalidateIfStale: true }); + Promise.all([ + queryClient.ensureQueryData({ ...uploadsQuery, revalidateIfStale: true }), + queryClient.ensureQueryData({ ...filesQuery, revalidateIfStale: true }), + ]).then(([, files]) => files); const csvPreviewResultSchema = z.discriminatedUnion("status", [ z.object({ diff --git a/csv-table-hub-main/src/lib/job-outcome.ts b/csv-table-hub-main/src/lib/job-outcome.ts index ba46c8c..7520043 100644 --- a/csv-table-hub-main/src/lib/job-outcome.ts +++ b/csv-table-hub-main/src/lib/job-outcome.ts @@ -136,6 +136,11 @@ export function decideOverwriteAction(job: Job | undefined): OverwriteAction { return { action: "retry-overwrite" }; } +/** An upload attempt a reviewer should look at: it failed outright or lost some rows (#187). */ +export function needsAttention(status: string): boolean { + return status === "failed" || status === "partly_imported" || status === "rejected"; +} + /** "51 of 171 rows failed", for a registered upload with failures (#123); else null. */ export function failedRowsNote(f: { failed_rows?: number | null; diff --git a/csv-table-hub-main/src/routes/_authenticated/index.tsx b/csv-table-hub-main/src/routes/_authenticated/index.tsx index e03807d..b743255 100644 --- a/csv-table-hub-main/src/routes/_authenticated/index.tsx +++ b/csv-table-hub-main/src/routes/_authenticated/index.tsx @@ -23,7 +23,7 @@ import { createFileRoute, Link, useRouter } from "@tanstack/react-router"; import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { - filesQuery, loadFiles, previewCsvTable, getUploadDetail, previewCsv, listTeTables, uploadCsv, + filesQuery, uploadsQuery, loadFiles, type UploadAttempt, previewCsvTable, getUploadDetail, previewCsv, listTeTables, uploadCsv, type CsvFileSummary, type ColumnType, type TeTableInfo, } from "@/lib/csv.functions"; import { parseCsvPreview, COLUMN_TYPE_LABEL, sanitizeColumns, type CsvPreview } from "@/lib/csv-preview"; @@ -40,8 +40,9 @@ import { classifyUploadResult, classifyThrownError, buildCancelPatch, buildCancelToastMessage, decideOverwriteAction, failedRowsNote, } from "@/lib/job-outcome"; +import { needsAttention } from "@/lib/job-outcome"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; @@ -70,6 +71,7 @@ export const Route = createFileRoute("/_authenticated/")({ function Home() { const { data: files } = useSuspenseQuery(filesQuery); + const { data: uploads } = useSuspenseQuery(uploadsQuery); return (
@@ -92,6 +94,7 @@ function Home() {
+
); @@ -673,6 +676,36 @@ function DiffDialog({job,onConfirm,onCancel}:{job:Job|null;onConfirm:()=>void;on const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const; +// #187 (BR-25): every upload attempt, so a failed or partly failed one is reachable for review. +const ATTEMPT_LABEL: Record = { imported:"Imported", partly_imported:"Partly imported", failed:"Failed", rejected:"Rejected", duplicate:"Duplicate" }; +export function UploadAttempts({uploads}:{uploads:UploadAttempt[]}){ + const [onlyProblems,setOnlyProblems]=useState(true); + const shown=onlyProblems?uploads.filter(u=>needsAttention(u.status)):uploads; + if(!uploads.length) return null; + return ( + + +
Upload attemptsEvery upload, including ones that failed, with its stored rows.
+ +
+ + {shown.length===0?

No failed or partly failed uploads.

:( +
    + {shown.map(u=>( +
  • + {u.file_name} + {ATTEMPT_LABEL[u.status]??u.status} + {u.failed_rows?`${u.failed_rows} of ${u.total_rows??"?"} rows failed`:`${u.total_rows??0} rows`} + {u.status!=="rejected"&&} +
  • + ))} +
+ )} +
+
+ ); +} + function FilesList({files}:{files:CsvFileSummary[]}){ const [preview,setPreview]=useState(null); const [page,setPage]=useState(1);