feat(products-ui): run-detail images section + progress poll, image-problems notice band, history images column (SD-0002 §5.2/§5.5)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,15 +26,21 @@ export interface DraftRecord {
|
||||
errors?: RowErrorDetail[];
|
||||
};
|
||||
}
|
||||
export interface ImageOutcome {
|
||||
handle: string; url: string; outcome: string; reason: string | null; variant: string | null;
|
||||
}
|
||||
export interface ImageCounts { fetched: number; rejected: number; failed: number; }
|
||||
export interface RunSummary {
|
||||
id: number; file_name: string; dialect: string; created_at: string;
|
||||
completed_at: string | null; status: string; by: string;
|
||||
products_added: number; products_updated: number; rows_errored: number;
|
||||
image_counts?: ImageCounts;
|
||||
}
|
||||
export interface RunDetail extends RunSummary {
|
||||
errors: RowErrorDetail[];
|
||||
image_progress: { done: number; total: number };
|
||||
image_outcomes: unknown[];
|
||||
image_counts: ImageCounts;
|
||||
image_outcomes: ImageOutcome[];
|
||||
}
|
||||
export interface ProductsSummary {
|
||||
product_count: number; image_problem_count: number; latest_run_id: number | null;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "../../productsApi";
|
||||
import { Banner } from "../../ui/kit";
|
||||
import { isExportEnabled } from "./exportMenu";
|
||||
import { historyImageCell } from "./runImages";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
applying: "Importing…",
|
||||
@@ -96,6 +97,13 @@ export default function ProductsPage() {
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
{summary.image_problem_count > 0 && (
|
||||
<div className="notice" aria-live="polite">
|
||||
<a href={`#/products/imports/runs/${summary.latest_run_id}`}>
|
||||
{summary.image_problem_count} products have image problems
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{empty ? (
|
||||
<div className="empty">
|
||||
<p className="empty__copy">No products yet. Bulk import is how product data gets in.</p>
|
||||
@@ -119,7 +127,7 @@ export default function ProductsPage() {
|
||||
<table className="datatable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th><th>File</th><th>Dialect</th><th>Added</th><th>Updated</th><th>Errors</th><th>Status</th>
|
||||
<th>Date</th><th>File</th><th>Dialect</th><th>Added</th><th>Updated</th><th>Errors</th><th>Images</th><th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -142,6 +150,7 @@ export default function ProductsPage() {
|
||||
<td>{r.products_added}</td>
|
||||
<td>{r.products_updated}</td>
|
||||
<td>{r.rows_errored}</td>
|
||||
<td>{historyImageCell(r.image_counts)}</td>
|
||||
<td>{STATUS_LABELS[r.status] ?? r.status}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
// Run detail (SD-0002 §5.5) — report card for a completed or in-progress import run.
|
||||
// NO images section this slice (SLICE-7). PUC-4/5/8.
|
||||
// The durable record of the import's image-fetch outcome: live progress while
|
||||
// fetching (polled, no websockets — deliberate), then per-image problem rows. PUC-4/5/8.
|
||||
import { useEffect, useState } from "react";
|
||||
import { dialectLabel, getRun, type RunDetail as RunDetailType } from "../../productsApi";
|
||||
import {
|
||||
imageOutcomeSummary,
|
||||
imageProgressLabel,
|
||||
isRunTerminal,
|
||||
outcomeLabel,
|
||||
} from "./runImages";
|
||||
import { Banner } from "../../ui/kit";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
@@ -29,6 +36,20 @@ export default function RunDetail({ runId }: { runId: number }) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [runId]);
|
||||
|
||||
// While the run is still applying / fetching images, poll the endpoint so the
|
||||
// "Fetching images: X of Y" line advances (no websockets — deliberate, §5.5).
|
||||
// The interval is cleared on terminal status and on unmount.
|
||||
useEffect(() => {
|
||||
if (!run || isRunTerminal(run.status)) return;
|
||||
const id = setInterval(() => {
|
||||
void (async () => {
|
||||
const resp = await getRun(runId);
|
||||
if (resp.ok) setRun(resp.value);
|
||||
})();
|
||||
}, 2000);
|
||||
return () => clearInterval(id);
|
||||
}, [run, runId]);
|
||||
|
||||
if (loadFail === "gone") {
|
||||
return (
|
||||
<Banner tone="attn" title="No such import run">
|
||||
@@ -88,6 +109,43 @@ export default function RunDetail({ runId }: { runId: number }) {
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{!isRunTerminal(run.status) ? (
|
||||
<p className="note" aria-live="polite">
|
||||
{imageProgressLabel(run.image_progress)}
|
||||
</p>
|
||||
) : (
|
||||
run.image_progress.total > 0 && (
|
||||
<>
|
||||
<p className="note">{imageOutcomeSummary(run.image_counts)}</p>
|
||||
{run.image_outcomes.length > 0 ? (
|
||||
<table className="errortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Handle</th>
|
||||
<th>Variant</th>
|
||||
<th>Image URL</th>
|
||||
<th>Outcome</th>
|
||||
<th>What to do</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{run.image_outcomes.map((o, i) => (
|
||||
<tr key={i}>
|
||||
<td>{o.handle}</td>
|
||||
<td>{o.variant ?? "—"}</td>
|
||||
<td>{o.url}</td>
|
||||
<td>{outcomeLabel(o.outcome)}</td>
|
||||
<td>Correct the URL and re-import.</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="note">All images fetched.</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { historyImageCell, imageOutcomeSummary, imageProgressLabel, isRunTerminal, outcomeLabel } from "./runImages";
|
||||
|
||||
describe("runImages helpers", () => {
|
||||
it("progress label", () => {
|
||||
expect(imageProgressLabel({ done: 312, total: 4950 })).toBe("Fetching images: 312 of 4,950");
|
||||
});
|
||||
it("outcome summary", () => {
|
||||
expect(imageOutcomeSummary({ fetched: 4938, rejected: 9, failed: 3 }))
|
||||
.toBe("4,938 fetched · 9 rejected · 3 failed");
|
||||
});
|
||||
it("history cell collapses clean / shows problems / dashes empty", () => {
|
||||
expect(historyImageCell({ fetched: 10, rejected: 0, failed: 0 })).toBe("10 ✓");
|
||||
expect(historyImageCell({ fetched: 8, rejected: 1, failed: 1 })).toBe("8 ✓ · 2 ✗");
|
||||
expect(historyImageCell(undefined)).toBe("—");
|
||||
});
|
||||
it("terminal status", () => {
|
||||
expect(isRunTerminal("fetching_images")).toBe(false);
|
||||
expect(isRunTerminal("complete_with_problems")).toBe(true);
|
||||
});
|
||||
it("outcome labels", () => {
|
||||
expect(outcomeLabel("rejected_low_res")).toMatch(/resolution bar/);
|
||||
expect(outcomeLabel("failed")).toMatch(/unreachable/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// Pure logic behind the image-fetch surfaces (SD-0002 §5.5): the run-detail
|
||||
// progress/outcome labels + the history "Images" cell + terminal-status test
|
||||
// that drives polling. Mirrors exportMenu.ts: pure helpers unit-tested here,
|
||||
// the JSX verified by the E2E suite (SLICE-2/3 pattern).
|
||||
import type { ImageCounts } from "../../productsApi";
|
||||
|
||||
export function imageProgressLabel(p: { done: number; total: number }): string {
|
||||
return `Fetching images: ${p.done.toLocaleString()} of ${p.total.toLocaleString()}`;
|
||||
}
|
||||
|
||||
export function imageOutcomeSummary(c: ImageCounts): string {
|
||||
return `${c.fetched.toLocaleString()} fetched · ${c.rejected.toLocaleString()} rejected · ${c.failed.toLocaleString()} failed`;
|
||||
}
|
||||
|
||||
export function historyImageCell(c: ImageCounts | undefined): string {
|
||||
if (!c || c.fetched + c.rejected + c.failed === 0) return "—";
|
||||
const bad = c.rejected + c.failed;
|
||||
return bad === 0
|
||||
? `${c.fetched.toLocaleString()} ✓`
|
||||
: `${c.fetched.toLocaleString()} ✓ · ${bad.toLocaleString()} ✗`;
|
||||
}
|
||||
|
||||
export function isRunTerminal(status: string): boolean {
|
||||
return status === "complete" || status === "complete_with_problems";
|
||||
}
|
||||
|
||||
export function outcomeLabel(outcome: string): string {
|
||||
if (outcome === "rejected_low_res") return "Rejected — below the resolution bar";
|
||||
if (outcome === "rejected_not_image") return "Rejected — not a supported image";
|
||||
if (outcome === "failed") return "Failed — unreachable";
|
||||
return outcome;
|
||||
}
|
||||
Reference in New Issue
Block a user