loopable / web /src /customer-grid /SelectFromFile.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
ef68ae0 verified
Raw
History Blame Contribute Delete
9.43 kB
// ---------------------------------------------------------------------------
// customer-grid / SelectFromFile.tsx β€” WAVE 21 item 11 (R10, contract C5).
//
// "Select from file…": paste a list or upload a sheet, say which column holds
// the names, and the grid ticks those records.
//
// The dialog decides NOTHING. Parsing a paste, matching, and the three-bucket
// result all live in `fileSelect.ts` where a gate runs them under node; this is
// the renderer and the file-picker chrome around them.
// ---------------------------------------------------------------------------
import { useMemo, useRef, useState } from "react";
import { uploadTabular } from "./apiBridge";
import type { TabularUpload } from "./apiBridge";
import { matchRowsByValue, matchSummary, parsePastedList } from "./fileSelect";
import type { MatchResult, MatchRow } from "./fileSelect";
import type { Field } from "./types";
/** The kinds a name can plausibly live in. A rating or a checkbox column is not
* something anyone pastes a list of, and offering all 27 field types makes the
* useful three hard to find. */
const MATCHABLE = new Set([
"text", "longtext", "select", "multiselect", "email", "url", "phone", "int",
"currency", "number", "date", "user", "automation", "formula",
]);
export interface SelectFromFileProps {
/** Columns the user may match ON β€” the grid's fields, in grid order. */
fields: Field[];
/** The identity column: the default grid column, and the one that makes this
* feature obvious ("paste customer names"). */
primaryKey: string;
/** ⚠ The rows the VIEW MATCHED β€” never the painted slice. See fileSelect.ts. */
inView: MatchRow[];
/** Every row in this table, for the "in the table but not in this view" bucket. */
wholeTable: MatchRow[];
/** The view being selected in, named in the dialog so the counts have a subject. */
viewName: string;
onSelect: (pids: number[]) => void;
onClose: () => void;
}
export default function SelectFromFile({
fields,
primaryKey,
inView,
wholeTable,
viewName,
onSelect,
onClose,
}: SelectFromFileProps) {
const matchable = useMemo(
() => fields.filter((f) => MATCHABLE.has(f.type) || f.key === primaryKey),
[fields, primaryKey]
);
const [gridKey, setGridKey] = useState(primaryKey);
const [paste, setPaste] = useState("");
const [upload, setUpload] = useState<TabularUpload | null>(null);
const [fileName, setFileName] = useState("");
const [fileCol, setFileCol] = useState("");
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<MatchResult | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const pasted = useMemo(() => parsePastedList(paste), [paste]);
// The upload wins when there is one: the user picked a file most recently, and
// a dialog that matched a stale paste behind an uploaded sheet would be
// answering a question nobody asked.
const values = upload ? upload.values[fileCol] ?? [] : pasted.values;
const canRun = values.length > 0 && !!gridKey;
const run = () => {
const r = matchRowsByValue(values, gridKey, inView, wholeTable);
setResult(r);
onSelect(r.pids);
};
const takeFile = async (file: File | undefined) => {
if (!file) return;
setBusy(true);
setResult(null);
const parsed = await uploadTabular(file);
setBusy(false);
if (!parsed) return; // the bridge has already said why
setUpload(parsed);
setFileName(file.name);
setFileCol(parsed.columns[0] ?? "");
setPaste("");
};
/** The misses, ready to paste back into whatever the list came from. Every
* value, never a "…and 40 more" β€” the whole point of listing them is that the
* user has to go and fix them. */
const copyList = (list: string[]) => {
void navigator.clipboard?.writeText(list.join("\n"));
};
return (
<div className="cg-cat-modal-wrap" role="dialog" aria-modal="true"
aria-label="Select records from a file">
<div className="cg-cat-modal cg-sff">
<h3>Select records from a list</h3>
<p>
Paste a list or upload a sheet, and the matching records in{" "}
<strong>{viewName}</strong> are ticked. Values are compared exactly, ignoring
case and surrounding spaces.
</p>
<div className="cg-sff-row">
<label className="cg-sff-label" htmlFor="cg-sff-paste">Paste a list</label>
<textarea
id="cg-sff-paste"
className="cg-sff-area"
value={paste}
placeholder={"One value per line"}
onChange={(e) => {
setPaste(e.target.value);
setUpload(null);
setFileName("");
setResult(null);
}}
/>
</div>
<div className="cg-sff-row">
<span className="cg-sff-label">…or upload</span>
<div className="cg-sff-file">
<input
ref={fileRef}
type="file"
accept=".xlsx,.csv"
className="cg-sff-input"
onChange={(e) => void takeFile(e.target.files?.[0])}
/>
{fileName ? <span className="cg-sff-note">{fileName}</span> : null}
{busy ? <span className="cg-sff-note">Reading…</span> : null}
</div>
</div>
{/* The column pair. The file side exists only for an upload β€” a pasted
list IS one column, and a picker offering one option is furniture. */}
<div className="cg-sff-pair">
{upload ? (
<label className="cg-sff-pick">
<span className="cg-sff-label">Column in the file</span>
<select
className="cg-input"
value={fileCol}
onChange={(e) => {
setFileCol(e.target.value);
setResult(null);
}}
>
{upload.columns.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</label>
) : null}
<label className="cg-sff-pick">
<span className="cg-sff-label">Column in the grid</span>
<select
className="cg-input"
value={gridKey}
onChange={(e) => {
setGridKey(e.target.value);
setResult(null);
}}
>
{matchable.map((f) => (
<option key={f.key} value={f.key}>{f.label}</option>
))}
</select>
</label>
</div>
{/* ── every disclosure the inputs owe, before the button ─────────────── */}
{upload?.truncated ? (
<p className="cg-sff-warn">
This file is longer than the {(20000).toLocaleString()}-value limit β€” only the
first {(20000).toLocaleString()} values in each column were read. Anything past
that is not being matched.
</p>
) : null}
{!upload && pasted.extraColumns ? (
<p className="cg-sff-warn">
The pasted text has more than one column β€” only the first is being matched.
</p>
) : null}
{!upload && pasted.blanks > 0 ? (
<p className="cg-sff-note">
{pasted.blanks.toLocaleString()} empty line{pasted.blanks === 1 ? "" : "s"} skipped.
</p>
) : null}
{values.length > 0 ? (
<p className="cg-sff-note">
{values.length.toLocaleString()} value{values.length === 1 ? "" : "s"} to match.
</p>
) : null}
{result ? (
<div className="cg-sff-result">
<strong>{matchSummary(result)}</strong>
{result.outsideView.length ? (
<div className="cg-sff-misses">
<span>
In this table, but not shown by <strong>{viewName}</strong> β€” clear the
view&rsquo;s filters to reach them:
</span>
<textarea readOnly className="cg-sff-area cg-sff-missarea"
value={result.outsideView.join("\n")} />
<button type="button" className="cg-btn"
onClick={() => copyList(result.outsideView)}>
Copy these
</button>
</div>
) : null}
{result.missing.length ? (
<div className="cg-sff-misses">
<span>Not found in this table at all:</span>
<textarea readOnly className="cg-sff-area cg-sff-missarea"
value={result.missing.join("\n")} />
<button type="button" className="cg-btn"
onClick={() => copyList(result.missing)}>
Copy these
</button>
</div>
) : null}
</div>
) : null}
<div className="cg-sff-actions">
<button type="button" className="cg-btn cg-btn--primary" disabled={!canRun || busy}
onClick={run}>
{result ? "Select again" : "Select"}
</button>
<button type="button" className="cg-btn" onClick={onClose}>
{result ? "Done" : "Cancel"}
</button>
</div>
</div>
</div>
);
}