SLICE-5: products import spine — canonical CSV → preview → confirm (SD-0002 §7.2) #26
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user