fix: show real file validation stats instead of hardcoded mock data

StepUpload was showing hardcoded "42 Total Lines, 8 Display Formats"
for every file upload. Now:
- Added POST /jobs/validate-source endpoint that parses xlsx in a
  temp file and returns real stats (line count, display formats,
  columns found, warnings) without creating any DB records
- Frontend calls validateSource() when user selects a file
- Shows spinner during validation, real results after
- Blocks "Next" if validation fails
- Removed all mock validation data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-10 17:03:13 -04:00
parent 84f37a4649
commit b11b2df0e2
3 changed files with 177 additions and 41 deletions

View file

@ -108,6 +108,82 @@ async def get_job(
return _enrich_job_response(job)
@router.post("/validate-source")
async def validate_source(
file: UploadFile = File(...),
current_user: dict = Depends(get_current_user),
) -> dict:
"""Validate a source xlsx file without saving. Returns line count, columns, etc."""
import tempfile, shutil
from app.pipeline.modules.source_file_parser import parse_source_file, _resolve_headers, HEADER_ALIASES
if not file.filename or not file.filename.endswith(".xlsx"):
raise HTTPException(status_code=400, detail="Only .xlsx files are accepted")
# Write to temp file for parsing
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
shutil.copyfileobj(file.file, tmp)
tmp_path = tmp.name
try:
from openpyxl import load_workbook
wb = load_workbook(tmp_path, read_only=True, data_only=True)
ws = wb.active
if ws is None:
raise HTTPException(status_code=400, detail="Workbook has no active sheet")
# Read headers
first_row = next(ws.iter_rows(min_row=1, max_row=1, values_only=True), None)
if first_row is None:
raise HTTPException(status_code=400, detail="File is empty")
raw_headers = [str(cell).strip() if cell else "" for cell in first_row]
col_map = _resolve_headers(raw_headers)
wb.close()
# Parse lines
parsed_lines = parse_source_file(tmp_path)
# Build column check results
columns = []
for field, aliases in HEADER_ALIASES.items():
columns.append({
"name": field,
"found": field in col_map,
"required": field == "en_gb",
})
display_format_count = sum(1 for l in parsed_lines if l.get("is_display_format"))
char_limit_missing = sum(1 for l in parsed_lines if not l.get("char_limit"))
warnings = []
if char_limit_missing > 0:
warnings.append(f"{char_limit_missing} rows have empty char_limit values")
return {
"valid": True,
"total_lines": len(parsed_lines),
"display_format_count": display_format_count,
"columns": columns,
"errors": [],
"warnings": warnings,
}
except HTTPException:
raise
except Exception as e:
return {
"valid": False,
"total_lines": 0,
"display_format_count": 0,
"columns": [],
"errors": [str(e)],
"warnings": [],
}
finally:
import os
os.unlink(tmp_path)
@router.put("/{job_id}/source")
async def upload_source(
job_id: UUID,

View file

@ -5,8 +5,9 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import type { JobFormData } from "@/app/jobs/new/page";
import type { ValidationResult } from "@/lib/types";
import { validateSource } from "@/lib/api";
import {
Upload,
FileSpreadsheet,
@ -15,6 +16,7 @@ import {
XCircle,
AlertTriangle,
File,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
@ -25,25 +27,30 @@ interface StepUploadProps {
onNext: () => void;
}
// Mock validation result
const mockValidation = {
valid: true,
total_lines: 42,
display_format_count: 8,
columns: [
{ name: "copy_type", found: true, required: true },
{ name: "source_text", found: true, required: true },
{ name: "char_limit", found: true, required: false },
{ name: "display_format", found: true, required: false },
{ name: "notes", found: true, required: false },
],
errors: [],
warnings: ["2 rows have empty char_limit values"],
};
export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps) {
const [dragActive, setDragActive] = useState(false);
const [validated, setValidated] = useState(false);
const [validating, setValidating] = useState(false);
const [validation, setValidation] = useState<ValidationResult | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const handleValidate = useCallback(
async (file: File) => {
onChange({ source_file: file });
setValidating(true);
setValidation(null);
setValidationError(null);
try {
const result = await validateSource(file);
setValidation(result);
} catch {
setValidationError("Failed to validate file. Please try again.");
} finally {
setValidating(false);
}
},
[onChange]
);
const handleSourceDrop = useCallback(
(e: React.DragEvent) => {
@ -51,22 +58,20 @@ export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps)
setDragActive(false);
const file = e.dataTransfer.files[0];
if (file && (file.name.endsWith(".xlsx") || file.name.endsWith(".xls"))) {
onChange({ source_file: file });
setValidated(true);
handleValidate(file);
}
},
[onChange]
[handleValidate]
);
const handleSourceSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
onChange({ source_file: file });
setValidated(true);
handleValidate(file);
}
},
[onChange]
[handleValidate]
);
const handleSupplementarySelect = useCallback(
@ -85,6 +90,12 @@ export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps)
});
};
const clearSource = () => {
onChange({ source_file: null });
setValidation(null);
setValidationError(null);
};
return (
<div className="space-y-6">
{/* Source file upload */}
@ -141,33 +152,54 @@ export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps)
</p>
</div>
<button
onClick={() => {
onChange({ source_file: null });
setValidated(false);
}}
onClick={clearSource}
className="text-gray-400 hover:text-amazon-error"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Validating spinner */}
{validating && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
Validating file...
</div>
)}
{/* Validation error */}
{validationError && (
<div className="flex items-center gap-2 text-sm text-amazon-error">
<XCircle className="h-4 w-4" />
{validationError}
</div>
)}
{/* Validation results */}
{validated && (
{validation && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-amazon-success">
<CheckCircle2 className="h-4 w-4" />
Validation passed
</div>
{validation.valid ? (
<div className="flex items-center gap-2 text-sm font-medium text-amazon-success">
<CheckCircle2 className="h-4 w-4" />
Validation passed
</div>
) : (
<div className="flex items-center gap-2 text-sm font-medium text-amazon-error">
<XCircle className="h-4 w-4" />
Validation failed
</div>
)}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div className="p-3 bg-gray-50 rounded-lg text-center">
<p className="text-2xl font-bold text-amazon-text">
{mockValidation.total_lines}
{validation.total_lines}
</p>
<p className="text-xs text-gray-500">Total Lines</p>
</div>
<div className="p-3 bg-gray-50 rounded-lg text-center">
<p className="text-2xl font-bold text-amazon-text">
{mockValidation.display_format_count}
{validation.display_format_count}
</p>
<p className="text-xs text-gray-500">
Display Formats
@ -175,8 +207,8 @@ export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps)
</div>
<div className="p-3 bg-gray-50 rounded-lg text-center">
<p className="text-2xl font-bold text-amazon-text">
{mockValidation.columns.filter((c) => c.found).length}/
{mockValidation.columns.length}
{validation.columns.filter((c) => c.found).length}/
{validation.columns.length}
</p>
<p className="text-xs text-gray-500">Columns Found</p>
</div>
@ -184,7 +216,7 @@ export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps)
{/* Column checks */}
<div className="flex flex-wrap gap-2">
{mockValidation.columns.map((col) => (
{validation.columns.map((col) => (
<div
key={col.name}
className="flex items-center gap-1.5 text-xs"
@ -204,12 +236,24 @@ export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps)
))}
</div>
{/* Errors */}
{validation.errors.length > 0 && (
<div className="flex items-start gap-2 text-xs text-amazon-error bg-red-50 p-2 rounded">
<XCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<div>
{validation.errors.map((e, i) => (
<p key={i}>{e}</p>
))}
</div>
</div>
)}
{/* Warnings */}
{mockValidation.warnings.length > 0 && (
{validation.warnings.length > 0 && (
<div className="flex items-start gap-2 text-xs text-amber-600 bg-amber-50 p-2 rounded">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<div>
{mockValidation.warnings.map((w, i) => (
{validation.warnings.map((w, i) => (
<p key={i}>{w}</p>
))}
</div>
@ -302,7 +346,10 @@ export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps)
<Button variant="outline" onClick={onBack}>
Back
</Button>
<Button onClick={onNext} disabled={!data.source_file}>
<Button
onClick={onNext}
disabled={!data.source_file || validating || (validation !== null && !validation.valid)}
>
Next: Review
</Button>
</div>

View file

@ -240,6 +240,19 @@ export async function uploadSource(
return response.data;
}
export async function validateSource(
file: File
): Promise<ValidationResult> {
const formData = new FormData();
formData.append("file", file);
const response = await api.post<ValidationResult>(
"/jobs/validate-source",
formData,
{ headers: { "Content-Type": "multipart/form-data" } }
);
return response.data;
}
export async function uploadSupplementary(
jobId: string,
file: File