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[];
|
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 {
|
export interface RunSummary {
|
||||||
id: number; file_name: string; dialect: string; created_at: string;
|
id: number; file_name: string; dialect: string; created_at: string;
|
||||||
completed_at: string | null; status: string; by: string;
|
completed_at: string | null; status: string; by: string;
|
||||||
products_added: number; products_updated: number; rows_errored: number;
|
products_added: number; products_updated: number; rows_errored: number;
|
||||||
|
image_counts?: ImageCounts;
|
||||||
}
|
}
|
||||||
export interface RunDetail extends RunSummary {
|
export interface RunDetail extends RunSummary {
|
||||||
errors: RowErrorDetail[];
|
errors: RowErrorDetail[];
|
||||||
image_progress: { done: number; total: number };
|
image_progress: { done: number; total: number };
|
||||||
image_outcomes: unknown[];
|
image_counts: ImageCounts;
|
||||||
|
image_outcomes: ImageOutcome[];
|
||||||
}
|
}
|
||||||
export interface ProductsSummary {
|
export interface ProductsSummary {
|
||||||
product_count: number; image_problem_count: number; latest_run_id: number | null;
|
product_count: number; image_problem_count: number; latest_run_id: number | null;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "../../productsApi";
|
} from "../../productsApi";
|
||||||
import { Banner } from "../../ui/kit";
|
import { Banner } from "../../ui/kit";
|
||||||
import { isExportEnabled } from "./exportMenu";
|
import { isExportEnabled } from "./exportMenu";
|
||||||
|
import { historyImageCell } from "./runImages";
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
applying: "Importing…",
|
applying: "Importing…",
|
||||||
@@ -96,6 +97,13 @@ export default function ProductsPage() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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 ? (
|
{empty ? (
|
||||||
<div className="empty">
|
<div className="empty">
|
||||||
<p className="empty__copy">No products yet. Bulk import is how product data gets in.</p>
|
<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">
|
<table className="datatable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -142,6 +150,7 @@ export default function ProductsPage() {
|
|||||||
<td>{r.products_added}</td>
|
<td>{r.products_added}</td>
|
||||||
<td>{r.products_updated}</td>
|
<td>{r.products_updated}</td>
|
||||||
<td>{r.rows_errored}</td>
|
<td>{r.rows_errored}</td>
|
||||||
|
<td>{historyImageCell(r.image_counts)}</td>
|
||||||
<td>{STATUS_LABELS[r.status] ?? r.status}</td>
|
<td>{STATUS_LABELS[r.status] ?? r.status}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
// Run detail (SD-0002 §5.5) — report card for a completed or in-progress import run.
|
// 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 { useEffect, useState } from "react";
|
||||||
import { dialectLabel, getRun, type RunDetail as RunDetailType } from "../../productsApi";
|
import { dialectLabel, getRun, type RunDetail as RunDetailType } from "../../productsApi";
|
||||||
|
import {
|
||||||
|
imageOutcomeSummary,
|
||||||
|
imageProgressLabel,
|
||||||
|
isRunTerminal,
|
||||||
|
outcomeLabel,
|
||||||
|
} from "./runImages";
|
||||||
import { Banner } from "../../ui/kit";
|
import { Banner } from "../../ui/kit";
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [runId]);
|
}, [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") {
|
if (loadFail === "gone") {
|
||||||
return (
|
return (
|
||||||
<Banner tone="attn" title="No such import run">
|
<Banner tone="attn" title="No such import run">
|
||||||
@@ -88,6 +109,43 @@ export default function RunDetail({ runId }: { runId: number }) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</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