import { useEffect, useState, type ReactNode } from 'react'; import { formatCount, type Reading, type TightenPlan, type Tightening } from '@wellformed/shared'; import { api, ApiError } from '../api/client'; import { useI18n } from '../i18n'; /** * The dry run, on screen. * * A constraint is a claim about every row, and the only honest way to offer one * is to check it against every row before it binds. So this asks the server on * every change to the proposal and reports back in rows rather than in * percentages: which ones do not fit, what they say, and what would become of * them. Nothing here changes anything — it is the answer to "what would happen", * and the dialog's Save is where it happens. */ interface TightenPanelProps { table: string; column: string; /** The constraints being proposed, without the answers this panel collects. */ proposal: Tightening; /** Whether anything is actually being proposed; when not, the panel stays shut. */ active: boolean; reading: Reading; onReading(reading: Reading): void; clear: boolean; onClear(clear: boolean): void; /** Offer the values already in the column as the options for a select. */ onUseOptions(options: string[]): void; /** Reported upward so Save can refuse what the check already refused. */ onVerdict(verdict: { ready: boolean; clearable: number }): void; } export function TightenPanel({ table, column, proposal, active, reading, onReading, clear, onClear, onUseOptions, onVerdict, }: TightenPanelProps): ReactNode { const { t, locale } = useI18n(); const [result, setResult] = useState<{ key: string; plan: TightenPlan } | null>(null); const [error, setError] = useState<{ key: string; message: string } | null>(null); const [showSql, setShowSql] = useState(false); const asked: Tightening = { ...proposal, reading, onProblem: clear ? 'clear' : 'stop' }; const key = JSON.stringify(asked); // An answer only speaks for the question it was asked; anything else is stale. const plan = result?.key === key ? result.plan : null; const failed = error?.key === key ? error.message : null; useEffect(() => { if (!active) return; let cancelled = false; const handle = window.setTimeout(() => { api .planTighten(table, column, asked) .then((answer) => { if (cancelled) return; setResult({ key, plan: answer.plan }); setError(null); }) .catch((cause: unknown) => { if (cancelled) return; setError({ key, message: cause instanceof ApiError ? cause.message : t('tighten.checkFailed') }); setResult(null); }); }, 250); return () => { cancelled = true; window.clearTimeout(handle); }; // `key` is the whole proposal, so it is the only dependency that matters. // eslint-disable-next-line react-hooks/exhaustive-deps }, [active, table, column, key]); const blocking = plan ? plan.problems.filter((p) => !p.clearable || !clear) : []; const clearable = plan ? plan.problems.filter((p) => p.clearable).reduce((n, p) => n + p.count, 0) : 0; useEffect(() => { onVerdict({ ready: !active || (plan !== null && blocking.length === 0), clearable }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [active, plan, blocking.length, clearable]); if (!active) return null; return (
{failed ? ( {failed} ) : proposal.type === 'select' && !proposal.options?.length ? ( {t('tighten.noOptionsYet')} ) : plan ? ( plan.total === 0 ? ( {t('tighten.nothingYet')} ) : blocking.length === 0 ? ( {t('tighten.allFit', plan.total, clearable > 0 && clear ? clearable : 0, locale)} ) : ( {t('tighten.someFit', plan.fit, plan.total, locale)} ) ) : ( {t('tighten.checking')} )}
{plan?.problems.map((problem) => (
{formatCount(problem.count, locale)} {problem.message} {problem.clearable && clear && ( {t('tighten.willBeCleared', problem.count)} )}
{problem.samples.map((s) => ( {s.value === '' ? '∅' : s.value} ))} {problem.count > problem.samples.length && ( {t('tighten.moreCount', problem.count - problem.samples.length, locale)} )}
))} {clearable > 0 && ( )} {plan && plan.rewrites.length > 0 && (
{plan.rewrites.map((r, i) => ( {r.from} → {r.to} ))}
)} {plan && (
{/* Only ever offered where it changes an answer: a column with no thousands or decimal marks reads the same either way. */} {plan.rewrites.length > 0 && needsDecimal(proposal) && ( )} {proposal.type === 'date' && ( )}
)} {/* Offered while there is still a list to make: the proposed type is one a list of words can serve, and nobody has named the options yet. Once they are named the offer would only overwrite them, and beside a link it would only be a distraction from the matching. */} {plan?.distinct && plan.distinct.length > 1 && !proposal.options?.length && offersAList(proposal) && (
{t('tighten.distinctValues', plan.distinct.length, locale)}
{plan.distinct.slice(0, 8).map((d) => ( {d.value} ×{d.count} ))}
)} {plan && (
{showSql &&
{plan.sql}
}
)}
); } /** Whether the decimal mark can change the answer for this proposal. */ function needsDecimal(proposal: Tightening): boolean { return proposal.type === 'number' || proposal.type === 'currency'; } /** Whether a list of the values already there is any use to the proposed type. */ function offersAList(proposal: Tightening): boolean { return proposal.type === undefined || proposal.type === 'text' || proposal.type === 'select'; }