import { useEffect, useState, type ReactNode } from 'react'; import { formatCount, type NormalizeMerge, type StoredColumnType, type TightenWalkPlan, type TightenWalkStep, } from '@wellformed/shared'; import { api, ApiError } from '../api/client'; import { useI18n } from '../i18n'; import { useSession } from '../state/session'; import { useWorkspace } from '../state/workspace'; import { Modal } from './Modal'; /** * The tighten walk, on screen: the regie the operations never had. * * One column per stop, each answered with what its values say it is. A column * that reads as a date or an amount is offered its real type; a column that * is an enum in waiting first gets its spellings merged — that is the * normalising, one decision per group, every survivor overrulable — and then * the select that finishes it. Stray whitespace never appears at all: the * server folds it in silently and says so in the audit trail. Nothing is * written until a stop's own button, and the fine-grained proposal (links, * unique, required) stays with the per-column tighten dialog. */ interface TidyDialogProps { table: string; onClose(): void; } export function TidyDialog({ table, onClose }: TidyDialogProps): ReactNode { const ws = useWorkspace(); const session = useSession(); const { t, locale } = useI18n(); const admin = session.can('admin'); const [plan, setPlan] = useState(null); const [failed, setFailed] = useState(null); const [error, setError] = useState(null); const [step, setStep] = useState(0); const [picks, setPicks] = useState<{ on: boolean; to: string }[]>([]); /** The select offer: reached after merging, or directly when nothing needed merging. */ const [offer, setOffer] = useState<{ changed: number } | null>(null); const [done, setDone] = useState({ changed: 0, merges: 0, selects: 0, converts: 0 }); const [busy, setBusy] = useState(false); const tableMeta = ws.tables.find((tb) => tb.name === table); // What this account can act on: a merge is editing, the type steps are // admin's. A step someone cannot take is not shown to them. const steps = (plan?.steps ?? []).filter((s) => s.clusters.length > 0 || admin); const current: TightenWalkStep | undefined = steps[step]; const currentMeta = current ? tableMeta?.columns.find((c) => c.name === current.column) : undefined; const typeLabels: Partial> = { number: t('column.typeNumber'), currency: t('column.typeCurrency'), date: t('column.typeDate'), checkbox: t('column.typeCheckbox'), }; useEffect(() => { let cancelled = false; api .walkTighten(table) .then((answer) => { if (!cancelled) setPlan(answer.plan); }) .catch((cause: unknown) => { if (cancelled) return; setFailed(cause instanceof ApiError ? cause.message : t('tidy.checkFailed')); }); return () => { cancelled = true; }; // The table is fixed for the dialog's lifetime. // eslint-disable-next-line react-hooks/exhaustive-deps }, [table]); // Each stop starts from the plan's own guesses. A column with nothing to // merge goes straight to its select offer. useEffect(() => { setPicks(current ? current.clusters.map((c) => ({ on: true, to: c.to })) : []); setOffer(current && !current.clusters.length && !current.convert ? { changed: 0 } : null); setError(null); }, [current]); const merges: NormalizeMerge[] = current ? current.clusters.flatMap((cluster, i) => { const pick = picks[i]; if (!pick?.on) return []; const to = pick.to.trim(); if (!to) return []; const from = cluster.variants.map((v) => v.value).filter((v) => v !== to); return from.length ? [{ to, from }] : []; }) : []; function advance() { setStep((s) => s + 1); } async function applyMerge() { if (!current) return; setBusy(true); setError(null); try { let changed = 0; if (merges.length) { const result = await api.normalize(table, current.column, merges); changed = result.changed; setDone((d) => ({ ...d, changed: d.changed + changed, merges: d.merges + merges.length })); // The grid under the dialog keeps telling the truth. void ws.refresh(); } // The payoff the merge was for. Offered, never assumed — and only to // someone allowed to apply a tightening. if (merges.length && current.select && admin) setOffer({ changed }); else advance(); } catch (cause) { setError(cause instanceof Error ? cause.message : t('tidy.checkFailed')); } finally { setBusy(false); } } async function applyConvert() { if (!current?.convert) return; setBusy(true); setError(null); try { await api.tighten(table, current.column, { type: current.convert }); setDone((d) => ({ ...d, converts: d.converts + 1 })); void ws.refresh(); advance(); } catch (cause) { setError(cause instanceof Error ? cause.message : t('tidy.checkFailed')); } finally { setBusy(false); } } async function makeSelect() { if (!current?.select) return; setBusy(true); setError(null); try { await api.tighten(table, current.column, { type: 'select', options: current.select.map((d) => d.value), }); setDone((d) => ({ ...d, selects: d.selects + 1 })); void ws.refresh(); advance(); } catch (cause) { setError(cause instanceof Error ? cause.message : t('tidy.checkFailed')); } finally { setBusy(false); } } const finished = plan !== null && (steps.length === 0 || step >= steps.length); const anythingDone = done.changed + done.merges + done.selects + done.converts > 0; const head = current && (
{currentMeta?.label ?? current.column} — {t('tidy.step', step + 1, steps.length)}
); return ( {t('common.close')} ) : current?.convert ? ( <> ) : offer ? ( <> ) : current ? ( <> ) : null } > {/* The body scrolls, the footer stays: a hundred groups must never push the buttons off the screen. */}
{failed ? (

{failed}

) : plan === null ? (

{t('tidy.scanning')}

) : finished ? (

{steps.length === 0 && !anythingDone ? t('tidy.nothing', plan.scanned, locale) : summarise()}

) : current?.convert ? (
{head} {t( 'tidy.readsAs', typeLabels[current.convert] ?? current.convert, current.present, current.total, locale, )}
{current.samples.map((v) => ( {v} ))}
{error && {error}}
) : offer && current?.select ? (
{head} {offer.changed > 0 && ( {t('tidy.applied', offer.changed, locale)} )} {t('tidy.offerSelect', current.select.length, locale)}
{current.select.map((d) => ( {d.value} ×{formatCount(d.count, locale)} ))}
{error && {error}}
) : current ? ( <>

{t('tidy.intro')}

{head} {current.clusters.map((cluster, i) => { const pick = picks[i] ?? { on: true, to: cluster.to }; return (
setPicks((list) => list.map((p, j) => (j === i ? { ...p, to: e.target.value } : p)), ) } />
); })} {currentMeta?.type === 'select' && ( {t('tidy.optionsFollow')} )} {error && {error}}
) : null}
); /** What the walk did, said whole: values, merges, selects, conversions. */ function summarise(): string { const parts: string[] = []; if (done.changed > 0) parts.push(t('tidy.summary', done.changed, done.merges, locale)); else if (done.merges > 0) parts.push(t('tidy.summaryOptionsOnly')); if (done.converts > 0) parts.push(t('tidy.summaryConverted', done.converts)); if (done.selects > 0) parts.push(t('tidy.summaryTightened', done.selects)); return parts.length ? parts.join(' ') : t('tidy.summaryNothing'); } }