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 (
{r.from} → {r.to}
))}
{plan.sql}}