Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions csv-table-hub-main/src/__tests__/files-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
});
});
112 changes: 112 additions & 0 deletions csv-table-hub-main/src/__tests__/upload-attempts.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("@tanstack/react-router")>("@tanstack/react-router");
return {
...actual,
Link: ({
to,
params,
children,
}: {
to: string;
params: Record<string, string>;
children: unknown;
}) => <a href={to.replace("$uploadId", params.uploadId)}>{children as never}</a>,
};
});

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(
<UploadAttempts
uploads={[
attempt("1", "imported"),
attempt("2", "failed", 3),
attempt("3", "partly_imported", 1),
]}
/>,
);
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(["/review/2", "/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(<UploadAttempts uploads={[attempt("4", "rejected")]} />);
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(<UploadAttempts uploads={[]} />);
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);
});
32 changes: 31 additions & 1 deletion csv-table-hub-main/src/lib/csv.functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,13 @@ const processingLogSchema = z.object({
});
export type ProcessingLog = z.infer<typeof processingLogSchema>;

// #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(),
Expand All @@ -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(),
Expand All @@ -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),
Expand Down Expand Up @@ -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<typeof uploadAttemptSchema>;

/** Every upload attempt, failed ones included (BR-25), newest first. */
export async function listUploads(): Promise<UploadAttempt[]> {
return apiFetch("/api/csv/uploads", z.array(uploadAttemptSchema));
}

export const uploadsQuery = { queryKey: ["csv-uploads"], queryFn: listUploads };

export async function listCsvFiles(): Promise<CsvFileSummary[]> {
return apiFetch("/api/csv/files", csvFileSummaryListSchema);
}
Expand All @@ -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({
Expand Down
5 changes: 5 additions & 0 deletions csv-table-hub-main/src/lib/job-outcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
37 changes: 35 additions & 2 deletions csv-table-hub-main/src/routes/_authenticated/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -70,6 +71,7 @@ export const Route = createFileRoute("/_authenticated/")({

function Home() {
const { data: files } = useSuspenseQuery(filesQuery);
const { data: uploads } = useSuspenseQuery(uploadsQuery);
return (
<div className="min-h-screen bg-gradient-to-br from-background via-background to-muted/30">
<Toaster richColors position="top-right"/>
Expand All @@ -92,6 +94,7 @@ function Home() {
<main className="mx-auto max-w-5xl space-y-8 px-6 py-10">
<Uploader/>
<FilesList files={files}/>
<UploadAttempts uploads={uploads}/>
</main>
</div>
);
Expand Down Expand Up @@ -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<string,string> = { 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 (
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<div><CardTitle className="text-base">Upload attempts</CardTitle><CardDescription>Every upload, including ones that failed, with its stored rows.</CardDescription></div>
<label className="flex items-center gap-2 text-xs"><input type="checkbox" checked={onlyProblems} onChange={e=>setOnlyProblems(e.target.checked)}/>Only failed or partial</label>
</CardHeader>
<CardContent>
{shown.length===0?<p className="text-sm text-muted-foreground">No failed or partly failed uploads.</p>:(
<ul className="divide-y">
{shown.map(u=>(
<li key={u.id} className="flex items-center gap-3 py-2 text-sm">
<span className="min-w-0 flex-1 truncate font-medium" title={u.file_name}>{u.file_name}</span>
<Badge variant={needsAttention(u.status)?"destructive":"secondary"}>{ATTEMPT_LABEL[u.status]??u.status}</Badge>
<span className="w-28 text-right text-xs tabular-nums text-muted-foreground">{u.failed_rows?`${u.failed_rows} of ${u.total_rows??"?"} rows failed`:`${u.total_rows??0} rows`}</span>
{u.status!=="rejected"&&<Button asChild size="sm" variant="ghost" className="h-7 px-2"><Link to="/review/$uploadId" params={{uploadId:u.id}}>Review</Link></Button>}
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}

function FilesList({files}:{files:CsvFileSummary[]}){
const [preview,setPreview]=useState<CsvFileSummary|null>(null);
const [page,setPage]=useState(1);
Expand Down
Loading