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.
|
// Import preview (SD-0002 §5.4) — the consent gate. Summary tiles filter a
|
||||||
export default function ImportPreview({ draftId }: { draftId: number }) {
|
// drill-in diff list; the sticky footer carries confirm (PUC-3) / cancel (PUC-3a).
|
||||||
void draftId;
|
// Diff glyphs pair with color, never color alone (§6.6).
|
||||||
return null;
|
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) ───────────────────────────────────── */
|
/* ── diff list (preview records, Task 13) ───────────────────────────────────── */
|
||||||
.difflist { list-style: none; margin: 0; padding: 0; }
|
.difflist { list-style: none; margin: 0; padding: 0; }
|
||||||
.difflist > li { padding: 12px 4px; border-bottom: 1px solid var(--border-soft); }
|
.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 {
|
.diffchange {
|
||||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--text-on-dark-soft);
|
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--add { color: var(--st-add); }
|
||||||
.diffchange__glyph--del { color: var(--st-error); }
|
.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 confirm/cancel footer (preview, Task 13) ────────────────────────── */
|
||||||
.sticky-footer {
|
.sticky-footer {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
|
|||||||
Reference in New Issue
Block a user