import { useState, type ReactNode } from 'react'; import { IMPORT_COLUMN_TYPES, slugifyTableName, type ImportColumnOverride, type ImportColumnType, type ImportGroupOverride, type ImportNotes, type ImportPreview, type Locale, } from '@wellformed/shared'; import { api, type ImportRequest } from '../api/client'; import { useI18n, type TranslateFn } from '../i18n'; import { useSession } from '../state/session'; import { useWorkspace } from '../state/workspace'; import { useToasts } from '../state/toasts'; import { Modal } from './Modal'; /** * Bring a spreadsheet in. * * Two steps, always: read it, then write it. The preview is not politeness — * the sheet, the delimiter, the column names and the inferred types are all * guesses the server made, and this is the only moment anyone can catch a * wrong one before it becomes a table with 4,000 rows in it. So every guess * shown here can be corrected here: the sheet and header row by picking * another, the names by typing over them, the types by choosing — and a type * choice round-trips through the preview, so the sample column shows what the * values become before anything is written. * * Whatever they have is what they bring: Excel, OpenDocument, CSV, or a block * of text out of the clipboard. The file is sent as bytes rather than read * into the textarea, because a workbook is not text — and because the * spreadsheet the operator is looking at is the one that should be imported, * not the CSV they were going to be asked to make from it. */ export function ImportDialog({ table, onClose, onTidy, }: { /** Opened from a table's own menu, the target starts on that table. */ table?: string; onClose(): void; /** Open the tidy-up walk on a table — the offer made when an import lands messy. */ onTidy(table: string): void; }): ReactNode { const ws = useWorkspace(); const session = useSession(); const toasts = useToasts(); const { t, locale } = useI18n(); const [csv, setCsv] = useState(''); const [file, setFile] = useState<{ name: string; data: string } | null>(null); const [preview, setPreview] = useState(null); // Left alone, the server picks the header row and the sheet. Set once // someone disagrees, and from then on it is their choice that is previewed // and imported. const [headerRow, setHeaderRow] = useState(undefined); // Which row groups the columns — the file's merged "Q1"/"Q2" headings. // Undefined lets the server decide, null turns the fold off, a number // names the row. Reset with everything else that re-reads the document. const [groupRow, setGroupRow] = useState(undefined); // Corrections to the group headings, by position. One edit renames every // column in the group — a messy merged cell is one correction, not five. const [groupEdits, setGroupEdits] = useState>({}); // Trailing totals rows: undefined lets the server decide, 0 keeps them all, // a number drops that many. Reset with everything that re-reads the file. const [footerRows, setFooterRows] = useState(undefined); const [sheet, setSheet] = useState(undefined); const [target, setTarget] = useState(table ?? ''); // The one name a person gives: their own words. The table's identifier is // derived from it — see slugifyTableName — and the server steps it past // collisions, so nobody is ever asked to spell an identifier. const [label, setLabel] = useState(''); // Corrections to the guessed columns, by position — labels typed over the // guess, types chosen over it. They belong to one reading of one document, // so another sheet or another header row starts them over. const [overrides, setOverrides] = useState>({}); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const creating = target === ''; function overrideList(from: Record = overrides): ImportColumnOverride[] { return Object.values(from).filter((o) => o.label !== undefined || o.type !== undefined); } function groupEditList(from: Record = groupEdits): ImportGroupOverride[] { return Object.entries(from).map(([index, name]) => ({ index: Number(index), label: name })); } /** * Creating a table makes the first column its row label, and a label is * text — pinned into every request so the preview, the sample values and * the table that lands all say the same thing about it. */ function sentColumns(into: string, cols: ImportColumnOverride[]): ImportColumnOverride[] { if (into) return cols; return [ { ...cols.find((o) => o.index === 0), index: 0, type: 'text' as const }, ...cols.filter((o) => o.index !== 0), ]; } async function readFile(chosen: File) { const data = base64Of(new Uint8Array(await chosen.arrayBuffer())); setFile({ name: chosen.name, data }); // Reading a file is the end of whatever was pasted before it, and the old // header row, sheet and column corrections all named another document. setCsv(''); setHeaderRow(undefined); setGroupRow(undefined); setGroupEdits({}); setFooterRows(undefined); setSheet(undefined); setOverrides({}); // The file name is the best guess anyone has at what the table is called. const stem = chosen.name.replace(/\.[^.]+$/, '').trim(); if (!label && stem) setLabel(stem); // Handed on rather than read back: `setLabel` above has not landed in // `label` yet, and the preview would otherwise think nothing is named and // overwrite the file's own word with one taken off a column heading. await look({ file: { name: chosen.name, data } }, undefined, undefined, label || stem, [], target, undefined, [], undefined); } /** What to send: the uploaded file if there is one, otherwise the textarea. */ function source(): ImportRequest { return file ? { file } : { csv }; } async function look( what: ImportRequest = source(), row = headerRow, tab = sheet, named = label, cols = overrideList(), into = target, grouping: number | null | undefined = groupRow, headings: ImportGroupOverride[] = groupEditList(), footer: number | undefined = footerRows, ) { setBusy(true); setError(null); try { const sent = sentColumns(into, cols); const result = await api.previewImport({ ...what, headerRow: row, ...(grouping !== undefined ? { groupRow: grouping } : {}), ...(footer !== undefined ? { footerRows: footer } : {}), sheet: tab, ...(sent.length ? { columns: sent } : {}), ...(headings.length ? { groups: headings } : {}), ...(into ? { table: into } : {}), }); setPreview(result); // Pasted text brings no file name, and neither does a file called // `export (3).xlsx`. An empty name is the only thing standing between // the preview and the Import button, so fill one in — from the sheet's // own name where there is one — rather than leaving a disabled button // and a placeholder that reads like a value. if (!named) setLabel(suggestLabel(result.sheet ?? undefined, result.columns[0]?.label)); } catch (err) { // The last good preview stays on screen: the correction table in it is // the way out of most refusals, and wiping it would leave the error // with nothing underneath to fix it with. setError(err instanceof Error ? err.message : t('import.couldNotRead')); } finally { setBusy(false); } } async function run() { setBusy(true); setError(null); try { const cols = sentColumns(target, overrideList()); const headings = groupEditList(); const what = { ...source(), headerRow, ...(groupRow !== undefined ? { groupRow } : {}), ...(footerRows !== undefined ? { footerRows } : {}), sheet, ...(cols.length ? { columns: cols } : {}), ...(headings.length ? { groups: headings } : {}), }; const result = await api.importSheet( creating ? { ...what, label: label.trim() } : { ...what, table: target }, ); await ws.refresh(); ws.setTable(result.table); toasts.push(landed(result, t, locale), result.overLimit ? 'info' : 'success'); // The proactive part of tightening: nobody goes looking for a stray // lowercase c or a date column left as text on their own, so the // product walks the new table the moment the rows land and offers the // guided walk when it finds anything. Silent when the walk finds // nothing — and when it fails, because an offer is not a duty. void api .walkTighten(result.table) .then(({ plan }) => { const steps = plan.steps.filter((s) => s.clusters.length > 0 || session.can('admin')); if (!steps.length) return; toasts.push(t('import.tidyOffer', steps.length, locale), 'info', { label: t('import.tidyNow'), run: () => onTidy(result.table), }); }) .catch(() => undefined); onClose(); } catch (err) { setError(err instanceof Error ? err.message : t('import.couldNotImport')); } finally { setBusy(false); } } // A correction is a label — any words at all, as long as there are words. const namesOk = Object.values(overrides).every( (o) => o.label === undefined || (o.label.trim().length > 0 && o.label.length <= 60), ); // A column somebody typed a wrong type onto blocks the import — the count // sits red under that type until they pick another or fix the file. const readsClean = !preview?.columns.some((c) => c.unreadable > 0); const ready = Boolean(preview) && readsClean && namesOk && (creating ? label.trim().length > 0 : true); return (
} >
{error &&
{error}
}