|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| import { useMemo, useRef, useState } from "react";
|
|
|
|
|
| import { API_V1, CREDENTIALS } from "../apiContract";
|
| import { isImportTarget } from "./types";
|
| import type { Field } from "./types";
|
|
|
|
|
| import { buildImportPlan } from "./importPlan";
|
| import type { ImportMapping, ParsedUpload } from "./importPlan";
|
| import "./ImportDialog.css";
|
|
|
| |
| |
|
|
| const JSON_HEADERS = { "Content-Type": "application/json" };
|
|
|
| |
| |
|
|
| type Parsed = ParsedUpload;
|
|
|
| export interface ImportDialogProps {
|
|
|
| fields: Field[];
|
|
|
| scope: string;
|
|
|
| tableName: string;
|
|
|
| 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]);
|
|
|
| |
| |
| |
| |
|
|
| 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);
|
|
|
|
|
|
|
| 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);
|
| }
|
| }
|
|
|
| |
| |
| |
| |
|
|
| 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) {
|
|
|
| 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 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’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 alternative — import the good rows and
|
| report the rest — leaves 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>
|
| );
|
| }
|
|
|