| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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"; |
|
|
| |
| |
| |
| const MATCHABLE = new Set([ |
| "text", "longtext", "select", "multiselect", "email", "url", "phone", "int", |
| "currency", "number", "date", "user", "automation", "formula", |
| ]); |
|
|
| export interface SelectFromFileProps { |
| |
| fields: Field[]; |
| |
| |
| primaryKey: string; |
| |
| inView: MatchRow[]; |
| |
| wholeTable: MatchRow[]; |
| |
| 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]); |
| |
| |
| |
| 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; |
| setUpload(parsed); |
| setFileName(file.name); |
| setFileCol(parsed.columns[0] ?? ""); |
| setPaste(""); |
| }; |
|
|
| |
| |
| |
| 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’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> |
| ); |
| } |
|
|