loopable / web /src /customer-grid /ImportDialog.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
e1b3e71 verified
Raw
History Blame Contribute Delete
12.5 kB
// ---------------------------------------------------------------------------
// 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<Parsed | null>(null);
const [mapping, setMapping] = useState<ImportMapping>({});
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const fileRef = useRef<HTMLInputElement | null>(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<void> {
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<void> {
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 (
<div className="cg-import-scrim" role="presentation" onClick={onClose}>
<div
className="cg-import"
role="dialog"
aria-modal="true"
aria-label={`Import records into ${tableName}`}
onClick={(event) => event.stopPropagation()}
>
<div className="cg-import-head">
<h2>Import records</h2>
<button type="button" className="cg-import-x" aria-label="Close" onClick={onClose}>
×
</button>
</div>
{!parsed ? (
<div className="cg-import-body">
<p className="cg-import-lede">
Add records to <strong>{tableName}</strong> from a spreadsheet. Excel
(<code>.xlsx</code>) or <code>.csv</code>, up to 5&nbsp;MB.
</p>
<p className="cg-import-note">
Every row becomes a <strong>new record</strong>. Nothing already in this database is
matched or overwritten.
</p>
<input
ref={fileRef}
type="file"
accept=".xlsx,.csv"
className="cg-import-file"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (file) void pick(file);
}}
/>
</div>
) : (
<div className="cg-import-body">
<p className="cg-import-lede">
{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.
</p>
<div className="cg-import-map" role="list">
{parsed.columns.map((name, i) => (
<label className="cg-import-row" role="listitem" key={`${name}:${i}`}>
<span className="cg-import-col" title={name}>
{name || `Column ${i + 1}`}
</span>
<select
value={mapping[i] ?? NONE}
onChange={(event) => {
const next = event.target.value;
setMapping((cur) => ({ ...cur, [i]: next }));
}}
>
<option value={NONE}>Don&rsquo;t import</option>
{targets.map((f) => (
<option key={f.key} value={f.key}>
{f.label}
</option>
))}
</select>
</label>
))}
</div>
{plan.problems.length > 0 && (
<div className="cg-import-problems" role="alert">
<strong>
{plan.problems.length.toLocaleString()} value
{plan.problems.length === 1 ? "" : "s"} cannot go in the column
{plan.problems.length === 1 ? "" : "s"} chosen for them
</strong>
<ul>
{plan.problems.slice(0, 5).map((p, i) => (
<li key={i}>
Row {p.row}, {p.column}: {p.value ? `“${p.value}”` : "(blank)"} is not a
valid {p.field}
</li>
))}
</ul>
{plan.problems.length > 5 && (
<p>…and {(plan.problems.length - 5).toLocaleString()} more.</p>
)}
<p>
Fix them in the file, or point that column somewhere else — nothing has been
imported yet.
</p>
</div>
)}
</div>
)}
{error && (
<p className="cg-import-error" role="alert">
{error}
</p>
)}
<div className="cg-import-foot">
{parsed && (
<span className="cg-import-count">
{mappedCount === 0
? "No columns chosen yet"
: `${plan.rows.length.toLocaleString()} record${
plan.rows.length === 1 ? "" : "s"
} from ${mappedCount} column${mappedCount === 1 ? "" : "s"}`}
</span>
)}
<button type="button" className="cg-btn" onClick={onClose}>
Cancel
</button>
{parsed && (
<button
type="button"
className="cg-btn cg-btn--primary"
/* ⛔ DISABLED WHILE ANY VALUE IS REFUSED. The alternativeimport the good rows and
report the restleaves somebody reconciling a spreadsheet against a table by
hand, and neither existing write door demonstrates a partial policy. Refusing
whole is recoverable in one edit; a half-import is not. */
disabled={busy || plan.rows.length === 0 || plan.problems.length > 0}
onClick={() => void run()}
>
{busy ? "Importing…" : `Import ${plan.rows.length.toLocaleString()} record${
plan.rows.length === 1 ? "" : "s"
}`}
</button>
)}
</div>
</div>
</div>
);
}