feat(products-ui): typed products API client + admin hash routing
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { adminViewFor, hashFor, type AdminView } from "./adminRouting";
|
||||
|
||||
const VIEWS: AdminView[] = [
|
||||
{ view: "home" }, { view: "products" }, { view: "import-upload" },
|
||||
{ view: "import-preview", draftId: 7 }, { view: "run-detail", runId: 12 },
|
||||
];
|
||||
|
||||
describe("adminRouting", () => {
|
||||
it("round-trips every view", () => {
|
||||
for (const v of VIEWS) expect(adminViewFor(hashFor(v))).toEqual(v);
|
||||
});
|
||||
it("defaults junk to home/products", () => {
|
||||
expect(adminViewFor("")).toEqual({ view: "home" });
|
||||
expect(adminViewFor("#/nonsense")).toEqual({ view: "home" });
|
||||
expect(adminViewFor("#/products/imports/drafts/abc")).toEqual({ view: "products" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
// Hash routing for admin sections (SD-0002 §5). The URL is the durable handle on a
|
||||
// section (PUC-8: run detail can be left and returned to); the SD-0001 entry-routing
|
||||
// rule (routing.ts) still decides whether the admin renders at all.
|
||||
export type AdminView =
|
||||
| { view: "home" }
|
||||
| { view: "products" }
|
||||
| { view: "import-upload" }
|
||||
| { view: "import-preview"; draftId: number }
|
||||
| { view: "run-detail"; runId: number };
|
||||
|
||||
export function adminViewFor(hash: string): AdminView {
|
||||
const parts = hash.replace(/^#\/?/, "").split("/").filter(Boolean);
|
||||
if (parts[0] !== "products") return { view: "home" };
|
||||
if (parts.length === 1) return { view: "products" };
|
||||
if (parts[1] === "import" && parts.length === 2) return { view: "import-upload" };
|
||||
if (parts[1] === "imports" && parts[2] === "drafts" && /^\d+$/.test(parts[3] ?? ""))
|
||||
return { view: "import-preview", draftId: Number(parts[3]) };
|
||||
if (parts[1] === "imports" && parts[2] === "runs" && /^\d+$/.test(parts[3] ?? ""))
|
||||
return { view: "run-detail", runId: Number(parts[3]) };
|
||||
return { view: "products" };
|
||||
}
|
||||
|
||||
export function hashFor(v: AdminView): string {
|
||||
switch (v.view) {
|
||||
case "home": return "#/";
|
||||
case "products": return "#/products";
|
||||
case "import-upload": return "#/products/import";
|
||||
case "import-preview": return `#/products/imports/drafts/${v.draftId}`;
|
||||
case "run-detail": return `#/products/imports/runs/${v.runId}`;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@ export interface VerifyResult {
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
async function errorOf(resp: Response): Promise<ApiError> {
|
||||
export async function errorOf(resp: Response): Promise<ApiError> {
|
||||
try {
|
||||
const body = await resp.json();
|
||||
if (body && body.error) return body.error as ApiError;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Typed fetch wrappers for the /api/products/* surface (SD-0002 §6.4). Same
|
||||
// conventions as api.ts: same-origin, cookie session, §6.4 error envelope.
|
||||
import { errorOf, type ApiError } from "./api";
|
||||
|
||||
export interface DiffSummary { adds: number; updates: number; unchanged: number; errors: number; }
|
||||
export interface Draft {
|
||||
id: number; file_name: string; dialect: string;
|
||||
summary: DiffSummary; unknown_columns: string[]; expires_at: string;
|
||||
}
|
||||
export type RecordKind = "add" | "update" | "unchanged" | "error";
|
||||
export interface RowErrorDetail { line: number; column: string | null; message: string; }
|
||||
export interface FieldChange { field: string; before: unknown; after: unknown; }
|
||||
export interface VariantEntry {
|
||||
options: (string | null)[]; kind?: string;
|
||||
set?: Record<string, unknown>; changes?: FieldChange[];
|
||||
}
|
||||
export interface ImageEntry { src: string; kind?: string; position?: number; alt_text?: string | null; changes?: FieldChange[]; }
|
||||
export interface DraftRecord {
|
||||
handle: string; title: string; kind: RecordKind; variant_count: number;
|
||||
detail: {
|
||||
set?: Record<string, unknown>;
|
||||
option_names?: (string | null)[];
|
||||
changes?: FieldChange[];
|
||||
variants?: VariantEntry[];
|
||||
images?: ImageEntry[];
|
||||
errors?: RowErrorDetail[];
|
||||
};
|
||||
}
|
||||
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;
|
||||
}
|
||||
export interface RunDetail extends RunSummary {
|
||||
errors: RowErrorDetail[];
|
||||
image_progress: { done: number; total: number };
|
||||
image_outcomes: unknown[];
|
||||
}
|
||||
export interface ProductsSummary {
|
||||
product_count: number; image_problem_count: number; latest_run_id: number | null;
|
||||
}
|
||||
|
||||
export type Result<T> = { ok: true; value: T } | { ok: false; error: ApiError; status: number };
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<Result<T>> {
|
||||
const resp = await fetch(path, { credentials: "include", ...init });
|
||||
if (!resp.ok) return { ok: false, error: await errorOf(resp), status: resp.status };
|
||||
if (resp.status === 204) return { ok: true, value: undefined as T };
|
||||
return { ok: true, value: (await resp.json()) as T };
|
||||
}
|
||||
|
||||
export function getProductsSummary(): Promise<Result<ProductsSummary>> {
|
||||
return request("/api/products/summary");
|
||||
}
|
||||
|
||||
export function uploadImport(file: File): Promise<Result<Draft>> {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
// No content-type header: the browser sets the multipart boundary.
|
||||
return request("/api/products/imports", { method: "POST", body });
|
||||
}
|
||||
|
||||
export function getDraft(id: number): Promise<Result<Draft>> {
|
||||
return request(`/api/products/imports/drafts/${id}`);
|
||||
}
|
||||
|
||||
export async function getDraftRecords(
|
||||
id: number, kind?: RecordKind, limit = 100, offset = 0,
|
||||
): Promise<Result<DraftRecord[]>> {
|
||||
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||
if (kind) params.set("kind", kind);
|
||||
const resp = await request<{ records: DraftRecord[] }>(
|
||||
`/api/products/imports/drafts/${id}/records?${params}`,
|
||||
);
|
||||
return resp.ok ? { ok: true, value: resp.value.records } : resp;
|
||||
}
|
||||
|
||||
export function confirmDraft(id: number): Promise<Result<{ run_id: number }>> {
|
||||
return request(`/api/products/imports/drafts/${id}/confirm`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function cancelDraft(id: number): Promise<Result<void>> {
|
||||
return request(`/api/products/imports/drafts/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function listRuns(): Promise<Result<RunSummary[]>> {
|
||||
const resp = await request<{ runs: RunSummary[] }>("/api/products/imports/runs");
|
||||
return resp.ok ? { ok: true, value: resp.value.runs } : resp;
|
||||
}
|
||||
|
||||
export function getRun(id: number): Promise<Result<RunDetail>> {
|
||||
return request(`/api/products/imports/runs/${id}`);
|
||||
}
|
||||
Reference in New Issue
Block a user