SLICE-5: products import spine — canonical CSV → preview → confirm (SD-0002 §7.2) #26

Merged
ben.stull merged 21 commits from worktree-slice-5-import-spine into main 2026-06-12 03:50:21 +00:00
2 changed files with 367 additions and 4 deletions
Showing only changes of commit 4141285b89 - Show all commits
+334 -4
View File
@@ -1,5 +1,335 @@
// Import preview (SD-0002 §5.4) — stub; Task 13 replaces this with the diff preview.
export default function ImportPreview({ draftId }: { draftId: number }) {
void draftId;
return null;
// Import preview (SD-0002 §5.4) — the consent gate. Summary tiles filter a
// drill-in diff list; the sticky footer carries confirm (PUC-3) / cancel (PUC-3a).
// Diff glyphs pair with color, never color alone (§6.6).
import { useEffect, useState } from "react";
import {
cancelDraft,
confirmDraft,
getDraft,
getDraftRecords,
type Draft,
type DraftRecord,
type FieldChange,
type RecordKind,
type VariantEntry,
} from "../../productsApi";
import { Banner } from "../../ui/kit";
const PAGE = 100;
function fmt(v: unknown): string {
if (v === null || v === undefined) return "—";
if (Array.isArray(v)) return v.length ? v.join(", ") : "—";
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
return String(v);
}
function KindChip({ kind }: { kind: RecordKind }) {
return <span className={`kindchip kindchip--${kind}`}>{kind}</span>;
}
function SetLine({ field, value }: { field: string; value: unknown }) {
return (
<div className="diffchange">
<span className="diffchange__glyph--add">+ </span>
{field}: {fmt(value)}
</div>
);
}
function ChangeRow({ change }: { change: FieldChange }) {
return (
<div className="diffchange">
{change.field}: <span className="diffchange__glyph--del">{fmt(change.before)}</span> {" "}
<span className="diffchange__glyph--add">{fmt(change.after)}</span>
</div>
);
}
function variantLabel(v: VariantEntry): string {
const opts = v.options.filter((o): o is string => o != null);
return opts.length ? `Variant ${opts.join(" / ")}` : "Variant";
}
function RecordDetail({ record }: { record: DraftRecord }) {
const d = record.detail;
if (record.kind === "error") {
return (
<div>
{(d.errors ?? []).map((e, i) => (
<div className="diffchange" key={i}>
line {e.line}: {e.column != null && `'${e.column}' — `}
{e.message}
</div>
))}
</div>
);
}
return (
<div>
{Object.entries(d.set ?? {}).map(([field, value]) => (
<SetLine key={field} field={field} value={value} />
))}
{(d.changes ?? []).map((c, i) => (
<ChangeRow key={i} change={c} />
))}
{(d.variants ?? []).map((v, i) => (
<div key={i}>
<div className="diffchange diffchange--head">
{variantLabel(v)}
{v.kind ? ` (${v.kind})` : ""}
</div>
{Object.entries(v.set ?? {}).map(([field, value]) => (
<SetLine key={field} field={field} value={value} />
))}
{(v.changes ?? []).map((c, j) => (
<ChangeRow key={j} change={c} />
))}
</div>
))}
{(d.images ?? []).map((img, i) =>
img.kind && img.kind !== "add" ? (
<div key={i}>
<div className="diffchange diffchange--head">
image: {img.src} ({img.kind})
</div>
{(img.changes ?? []).map((c, j) => (
<ChangeRow key={j} change={c} />
))}
</div>
) : (
<div className="diffchange" key={i}>
<span className="diffchange__glyph--add">+ </span>image: {img.src}
</div>
),
)}
</div>
);
}
function ErrorTable({ records }: { records: DraftRecord[] }) {
const rows = records.flatMap((r) => r.detail.errors ?? []);
if (rows.length === 0) return null;
return (
<table className="errortable">
<thead>
<tr>
<th>Line</th>
<th>Column</th>
<th>Problem</th>
</tr>
</thead>
<tbody>
{rows.map((e, i) => (
<tr key={i}>
<td>{e.line}</td>
<td>{e.column ?? "—"}</td>
<td>{e.message}</td>
</tr>
))}
</tbody>
</table>
);
}
export default function ImportPreview({ draftId }: { draftId: number }) {
const [draft, setDraft] = useState<Draft | null>(null);
const [loadFail, setLoadFail] = useState<"gone" | "expired" | "failed" | null>(null);
const [filter, setFilter] = useState<RecordKind | null>(null);
const [records, setRecords] = useState<DraftRecord[] | null>(null);
const [hasMore, setHasMore] = useState(false);
const [moreBusy, setMoreBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
const [cancelling, setCancelling] = useState(false);
const [stale, setStale] = useState(false);
const [nothingNote, setNothingNote] = useState(false);
const [confirmError, setConfirmError] = useState<string | null>(null);
async function loadDraft() {
setLoadFail(null);
const resp = await getDraft(draftId);
if (!resp.ok) {
setLoadFail(resp.status === 404 ? "gone" : resp.status === 410 ? "expired" : "failed");
return;
}
setDraft(resp.value);
}
useEffect(() => {
void loadDraft();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draftId]);
useEffect(() => {
if (!draft) return;
let gone = false;
setRecords(null);
setHasMore(false);
void getDraftRecords(draftId, filter ?? undefined, PAGE, 0).then((resp) => {
if (gone || !resp.ok) return;
setRecords(resp.value);
setHasMore(resp.value.length === PAGE);
});
return () => {
gone = true;
};
}, [draft, draftId, filter]);
async function showMore() {
if (!records) return;
setMoreBusy(true);
const resp = await getDraftRecords(draftId, filter ?? undefined, PAGE, records.length);
setMoreBusy(false);
if (!resp.ok) return;
setRecords([...records, ...resp.value]);
setHasMore(resp.value.length === PAGE);
}
async function onConfirm() {
setConfirming(true);
setConfirmError(null);
const resp = await confirmDraft(draftId);
if (resp.ok) {
window.location.hash = `#/products/imports/runs/${resp.value.run_id}`;
return;
}
setConfirming(false);
if ((resp.status === 409 && resp.error.code === "preview_stale") || resp.status === 410) {
setStale(true);
} else if (resp.status === 409 && resp.error.code === "nothing_to_apply") {
setNothingNote(true);
} else {
setConfirmError(resp.error.message);
}
}
async function onCancel() {
setCancelling(true);
// PUC-3a — cancel even on draft-gone (404) still navigates home.
await cancelDraft(draftId);
window.location.hash = "#/products";
}
if (loadFail === "gone") {
return (
<Banner tone="attn" title="This preview is gone">
<a href="#/products">Back to Products</a>
</Banner>
);
}
if (loadFail === "expired") {
return (
<Banner tone="attn" title="This preview expired — upload the file again">
<a href="#/products/import">Upload the file again</a>
</Banner>
);
}
if (loadFail === "failed") {
return (
<Banner tone="attn" title="Couldn't load this preview">
Something went wrong on our side.{" "}
<button type="button" className="linklike" onClick={() => void loadDraft()}>
Retry
</button>
</Banner>
);
}
if (!draft) return <p className="note">Loading</p>;
const { summary } = draft;
const tiles: { kind: RecordKind; num: number; label: string }[] = [
{ kind: "add", num: summary.adds, label: "to add" },
{ kind: "update", num: summary.updates, label: "to update" },
{ kind: "unchanged", num: summary.unchanged, label: "unchanged" },
{ kind: "error", num: summary.errors, label: "errors" },
];
const toApply = summary.adds + summary.updates;
return (
<div className="products">
<p className="note">
<a href="#/products"> Products</a>
</p>
<h1>Import preview {draft.file_name}</h1>
<p className="note">{draft.dialect === "canonical" ? "Canonical format" : draft.dialect}</p>
{draft.unknown_columns.length > 0 && (
<Banner tone="info" title="Columns not imported">
{draft.unknown_columns.join(", ")}
</Banner>
)}
<div className="tiles">
{tiles.map((t) => (
<button
key={t.kind}
type="button"
className={`tile tile--${t.kind}${filter === t.kind ? " tile--active" : ""}`}
aria-pressed={filter === t.kind}
onClick={() => setFilter(filter === t.kind ? null : t.kind)}
>
<span className="tile__num">{t.num.toLocaleString()}</span>
<span className="tile__label">{t.label}</span>
</button>
))}
</div>
{filter === "error" && records && <ErrorTable records={records} />}
{records === null ? (
<p className="note">Loading</p>
) : records.length === 0 ? (
<p className="note">Nothing to show here.</p>
) : (
<div className="difflist">
{records.map((r, i) => (
<details className="difflist__item" key={`${r.handle}-${i}`}>
<summary>
<span className="difflist__handle">{r.handle}</span> · {r.title} ·{" "}
<KindChip kind={r.kind} /> · {r.variant_count}{" "}
{r.variant_count === 1 ? "variant" : "variants"}
</summary>
<RecordDetail record={r} />
</details>
))}
</div>
)}
{hasMore && (
<p>
<button type="button" className="btn-secondary" disabled={moreBusy} onClick={() => void showMore()}>
{moreBusy ? "Loading…" : "Show more"}
</button>
</p>
)}
<div className="sticky-footer">
{stale ? (
<Banner tone="attn" title="Your catalog changed since this preview — upload the file again">
<a href="#/products/import">Upload the file again</a>
</Banner>
) : (
<>
<button
type="button"
className="btn-primary"
disabled={toApply === 0 || confirming || cancelling}
onClick={() => void onConfirm()}
>
{confirming ? "Importing…" : `Import ${toApply.toLocaleString()} products`}
</button>
<button
type="button"
className="btn-secondary"
disabled={confirming || cancelling}
onClick={() => void onCancel()}
>
Cancel
</button>
{(toApply === 0 || nothingNote) && (
<span className="note">Nothing to change your catalog already matches this file</span>
)}
{confirmError && (
<span className="note note--attn" role="alert">
{confirmError}
</span>
)}
</>
)}
</div>
</div>
);
}
+33
View File
@@ -161,15 +161,48 @@
/* ── diff list (preview records, Task 13) ───────────────────────────────────── */
.difflist { list-style: none; margin: 0; padding: 0; }
.difflist > li { padding: 12px 4px; border-bottom: 1px solid var(--border-soft); }
.difflist__item { padding: 12px 4px; border-bottom: 1px solid var(--border-soft); }
.difflist__item > summary {
cursor: pointer;
font-size: 14px;
color: var(--text-on-dark-soft);
transition: color var(--dur-fast) var(--ease);
}
.difflist__item > summary:hover { color: var(--wv-starlight); }
.difflist__item[open] > summary { margin-bottom: 8px; }
.difflist__handle { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; }
.diffchange {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12.5px;
line-height: 1.6;
color: var(--text-on-dark-soft);
}
.diffchange--head { color: var(--text-on-dark-mute); margin-top: 6px; }
.diffchange__glyph--add { color: var(--st-add); }
.diffchange__glyph--del { color: var(--st-error); }
/* ── kind chip (preview record summaries, Task 13) ──────────────────────────── */
.kindchip {
display: inline-block;
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: 11px;
letter-spacing: .06em;
text-transform: uppercase;
line-height: 1;
padding: 3px 9px 2px;
border-radius: var(--radius-pill);
border: 1px solid var(--border-strong);
color: var(--text-on-dark-mute);
}
.kindchip--add { color: var(--st-add); border-color: var(--st-add); }
.kindchip--update { color: var(--st-update); border-color: var(--st-update); }
.kindchip--error { color: var(--st-error); border-color: var(--st-error); }
/* preview layout rhythm: tiles + errortable sit between header and difflist */
.products .tiles { margin: 24px 0 18px; }
.products .errortable { margin: 0 0 18px; }
/* ── sticky confirm/cancel footer (preview, Task 13) ────────────────────────── */
.sticky-footer {
position: sticky;