// --------------------------------------------------------------------------- // customer-grid / ImportDialog.tsx // // ⭐⭐ WAVE-29 T25 (owner item 6) — IMPORT A SPREADSHEET INTO A USER DATABASE. // // Three steps in one window, because each answers the question the last one raised: // 1. pick a file -> the server parses it and says which columns it found // 2. map its columns -> to fields, or to nothing; unmapped is the default // 3. check + import -> every value coerced FIRST, the problems named BEFORE anything writes // // ⛔ APPEND-ONLY IN V1, AND THE DIALOG SAYS SO. Nothing is matched, nothing is overwritten — every // row becomes a new record. The only bulk-merge primitive in the product is upsert-BY-KEY shaped, // and choosing a key on the user's behalf is how an import quietly rewrites rows somebody edited. // The sentence is on screen rather than in a doc, because that is where the decision is made. // // ⛔ REUSE, NOT A SECOND COERCER. `clipboard.coerceClipboardValue` is THE client answer to "can // this text be this field's value" (it is what a paste already goes through), and // `POST /api/v1/uploads/tabular` is the parser that already exists — 5 MB / 20,000 rows, xlsx AND // csv, and it never writes a row. A second copy of either is a second set of rules for one // question ([[one-evaluator-per-question]]). // // ⚠ THE SERVER IS THE WALL, not this dialog. It refuses computed columns BY NAME and refuses the // whole batch if it would pass the row cap. What happens here is the part a person needs before // they press the button: which column goes where, and what will not survive the trip. // --------------------------------------------------------------------------- import { useMemo, useRef, useState } from "react"; // ⚠ From `apiContract`, which is where these two are DECLARED — not re-exported from // `apiBridge`, which does not export them and is another session's fence this wave. import { API_V1, CREDENTIALS } from "../apiContract"; import { isImportTarget } from "./types"; import type { Field } from "./types"; // ⭐ W29-T76 — the plan lives in a pure module so a test can EXECUTE it against the server's real // payload. This file holds the WINDOW; `importPlan.ts` holds what would be written. import { buildImportPlan } from "./importPlan"; import type { ImportMapping, ParsedUpload } from "./importPlan"; import "./ImportDialog.css"; /** The one header a JSON POST needs. `apiBridge` keeps its own module-local copy; this is a * literal rather than an import because that constant is not exported and that file is not * this session's to change. */ const JSON_HEADERS = { "Content-Type": "application/json" }; /** The upload door's answer (`routes_uploads.tabular`) — declared in `importPlan.ts`, beside the * only code that reads its shape. ⛔ It is COLUMN-MAJOR; the copy that used to live here said * `string[][]` and that one wrong word is why nothing ever imported. */ type Parsed = ParsedUpload; export interface ImportDialogProps { /** The target database's fields — the mapping targets, before this file filters them. */ fields: Field[]; /** `ut_*` table key; the import door is per-table. */ scope: string; /** How the database calls itself, for the sentence that says where rows are going. */ tableName: string; /** Fired after a successful import so the host can re-read its rows. */ onImported: (count: number) => void; onClose: () => void; } const NONE = ""; export default function ImportDialog({ fields, scope, tableName, onImported, onClose, }: ImportDialogProps): React.ReactElement { const [parsed, setParsed] = useState(null); const [mapping, setMapping] = useState({}); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const fileRef = useRef(null); const targets = useMemo(() => fields.filter(isImportTarget), [fields]); /** * The parse. `FormData` and NO `Content-Type` header: the browser has to set the multipart * boundary itself, and spelling the header by hand is the classic way to make a working upload * 400 with a message about a missing part. */ async function pick(file: File): Promise { setBusy(true); setError(""); try { const form = new FormData(); form.append("file", file); const res = await fetch(`${API_V1}/uploads/tabular`, { method: "POST", credentials: CREDENTIALS, body: form, }); const body = (await res.json().catch(() => null)) as | (Parsed & { error?: { message?: string } }) | null; if (!res.ok || !body) { setError( body?.error?.message ?? `That file could not be read (the server answered ${res.status}).` ); return; } setParsed(body); // ⭐ AUTO-MAP BY NAME, case- and space-insensitively, because the overwhelmingly common // case is a sheet exported from this very grid. Every guess is VISIBLE in a picker the // user can change — a silent auto-map is only dangerous when it is also invisible. const byName = new Map( targets.map((f) => [f.label.trim().toLowerCase(), f.key] as const) ); const guess: ImportMapping = {}; (body.columns ?? []).forEach((name, i) => { const hit = byName.get(String(name).trim().toLowerCase()); if (hit) guess[i] = hit; }); setMapping(guess); } catch { setError("That file could not be read."); } finally { setBusy(false); } } /** * What would be written, and what cannot be. The work is `importPlan.buildImportPlan` — a pure * module, so the node suite executes it against the server's real payload rather than grepping * this file for the shape of a loop. */ const plan = useMemo( () => buildImportPlan(parsed, mapping, targets), [parsed, mapping, targets] ); async function run(): Promise { setBusy(true); setError(""); try { const res = await fetch( `${API_V1}/tables/${encodeURIComponent(scope)}/rows/import`, { method: "POST", credentials: CREDENTIALS, headers: JSON_HEADERS, body: JSON.stringify({ rows: plan.rows }), } ); const body = (await res.json().catch(() => null)) as | { imported?: number; error?: { message?: string } } | null; if (!res.ok) { // The server's sentence beats anything invented here: it names the column, or the cap. setError(body?.error?.message ?? `The server answered ${res.status}.`); return; } onImported(body?.imported ?? plan.rows.length); onClose(); } catch { setError("Nothing was imported — the request did not complete."); } finally { setBusy(false); } } const mappedCount = Object.values(mapping).filter((k) => k !== NONE).length; return (
event.stopPropagation()} >

Import records

{!parsed ? (

Add records to {tableName} from a spreadsheet. Excel (.xlsx) or .csv, up to 5 MB.

Every row becomes a new record. Nothing already in this database is matched or overwritten.

{ const file = event.currentTarget.files?.[0]; if (file) void pick(file); }} />
) : (

{parsed.rows.toLocaleString()} row{parsed.rows === 1 ? "" : "s"} read {parsed.truncated ? " (the first 20,000 — the rest were not read)" : ""}. Choose where each column goes.

{parsed.columns.map((name, i) => ( ))}
{plan.problems.length > 0 && (
{plan.problems.length.toLocaleString()} value {plan.problems.length === 1 ? "" : "s"} cannot go in the column {plan.problems.length === 1 ? "" : "s"} chosen for them
    {plan.problems.slice(0, 5).map((p, i) => (
  • Row {p.row}, {p.column}: {p.value ? `“${p.value}”` : "(blank)"} is not a valid {p.field}
  • ))}
{plan.problems.length > 5 && (

…and {(plan.problems.length - 5).toLocaleString()} more.

)}

Fix them in the file, or point that column somewhere else — nothing has been imported yet.

)}
)} {error && (

{error}

)}
{parsed && ( {mappedCount === 0 ? "No columns chosen yet" : `${plan.rows.length.toLocaleString()} record${ plan.rows.length === 1 ? "" : "s" } from ${mappedCount} column${mappedCount === 1 ? "" : "s"}`} )} {parsed && ( )}
); }