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
46 changes: 46 additions & 0 deletions csv-table-hub-main/src/__tests__/stored-row-errors.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// M3c (#107): the Migrated files Preview shows why rows failed, read from the server.

import { afterEach, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StoredRowErrors } from "@/routes/_authenticated/index";

const detail = {
id: "7",
status: "partly_imported",
fileName: "people.csv",
uploader: "alice",
failedRows: 1,
rowErrors: [{ rowNumber: 2, column: "n", value: "x", reason: '"x" is not a whole number' }],
logs: [],
};

function show(body: unknown) {
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } }),
);
vi.stubGlobal("fetch", fetchMock);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<StoredRowErrors uploadId="7" fileName="people.csv" />
</QueryClientProvider>,
);
return fetchMock;
}

afterEach(() => vi.unstubAllGlobals());

it("lists the stored reasons from the upload's server record", async () => {
const fetchMock = show(detail);
expect(await screen.findByText('"x" is not a whole number')).toBeInTheDocument();
expect(screen.getByRole("button", { name: /error report/i })).toBeInTheDocument();
expect(String(fetchMock.mock.calls[0][0])).toMatch(/\/api\/csv\/uploads\/7$/);
});

it("says so when no reasons were stored", async () => {
show({ ...detail, rowErrors: [] });
expect(await screen.findByText(/no failure reasons were stored/i)).toBeInTheDocument();
});
18 changes: 18 additions & 0 deletions csv-table-hub-main/src/lib/csv.functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ const csvFileSummarySchema = z.object({
row_count: z.number(),
total_rows: z.number().nullable().optional(),
failed_rows: z.number().nullable().optional(),
uploader: z.string().nullable().optional(),
upload_id: z.string().nullable().optional(),
column_names: z.array(z.string()),
created_at: z.string(),
});
Expand Down Expand Up @@ -231,3 +233,19 @@ export async function previewCsvTable(
previewRowsSchema,
);
}

// M3c (#107): an upload's failure reasons and step log, stored on the server.
const uploadDetailSchema = z.object({
id: z.string(),
status: z.string(),
fileName: z.string(),
uploader: z.string().nullable(),
failedRows: z.number().nullable(),
rowErrors: z.array(rowErrorSchema).nullable(),
logs: z.array(processingLogSchema).nullable(),
});
export type UploadDetail = z.infer<typeof uploadDetailSchema>;

export async function getUploadDetail(uploadId: string): Promise<UploadDetail> {
return apiFetch(`/api/csv/uploads/${encodeURIComponent(uploadId)}`, uploadDetailSchema);
}
24 changes: 23 additions & 1 deletion 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, previewCsv, listTeTables, uploadCsv,
filesQuery, loadFiles, 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 Down Expand Up @@ -743,6 +743,28 @@ function PreviewTable({file}:{file:CsvFileSummary}){
</TableBody>
</Table>
<p className="p-2 text-xs text-muted-foreground">Showing up to 50 rows. Table has {file.row_count} row{file.row_count===1?"":"s"} total.{failedRowsNote(file)&&<span className="text-destructive"> {failedRowsNote(file)} validation and weren't imported.</span>}</p>
{file.upload_id&&(file.failed_rows??0)>0&&<StoredRowErrors uploadId={file.upload_id} fileName={file.file_name}/>}
</div>
);
}

// M3c (#107): why rows failed, read from the server, so every user in the tenant sees the same reasons.
export function StoredRowErrors({uploadId,fileName}:{uploadId:string;fileName:string}){
const {data,isLoading,error}=useQuery({queryKey:["upload-detail",uploadId],queryFn:()=>getUploadDetail(uploadId)});
if(isLoading)return<p className="p-2 text-xs text-muted-foreground">Loading failure reasons…</p>;
if(error)return<p className="p-2 text-xs text-destructive">{(error as Error).message}</p>;
const errors=data?.rowErrors??[];
if(errors.length===0)return<p className="p-2 text-xs text-muted-foreground">No failure reasons were stored for this upload.</p>;
return (
<div className="border-t">
<div className="flex items-center justify-between p-2">
<p className="text-xs font-medium">Rows that failed{(data?.failedRows??0)>errors.length&&` (first ${errors.length} of ${data?.failedRows})`}</p>
<Button size="sm" variant="outline" className="h-7" onClick={()=>downloadErrorReport(fileName,buildErrorRows(fileName,errors),"csv")}><Download className="mr-1 h-3.5 w-3.5"/>Error report</Button>
</div>
<Table>
<TableHeader><TableRow><TableHead>Row</TableHead><TableHead>Column</TableHead><TableHead>Value</TableHead><TableHead>Reason</TableHead></TableRow></TableHeader>
<TableBody>{errors.slice(0,50).map((re,i)=><TableRow key={i}><TableCell className="tabular-nums text-xs">{re.rowNumber}</TableCell><TableCell className="text-xs">{re.column??"—"}</TableCell><TableCell className="max-w-[180px] truncate font-mono text-xs">{re.value??"—"}</TableCell><TableCell className="text-xs text-destructive">{re.reason}</TableCell></TableRow>)}</TableBody>
</Table>
</div>
);
}
Loading