Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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 (
<div className="tighten">
<div className="tighten-head">
{failed ? (
<span className="bad">{failed}</span>
) : proposal.type === 'select' && !proposal.options?.length ? (
<span>{t('tighten.noOptionsYet')}</span>
) : plan ? (
plan.total === 0 ? (
<span>{t('tighten.nothingYet')}</span>
) : blocking.length === 0 ? (
<span className="good">
{t('tighten.allFit', plan.total, clearable > 0 && clear ? clearable : 0, locale)}
</span>
) : (
<span className="bad">{t('tighten.someFit', plan.fit, plan.total, locale)}</span>
)
) : (
<span className="muted">{t('tighten.checking')}</span>
)}
</div>
{plan?.problems.map((problem) => (
<div key={problem.kind + problem.message} className="tighten-problem">
<div>
<strong>{formatCount(problem.count, locale)}</strong> {problem.message}
{problem.clearable && clear && (
<span className="good">{t('tighten.willBeCleared', problem.count)}</span>
)}
</div>
<div className="samples">
{problem.samples.map((s) => (
<span key={s.id} className="sample" title={s.label}>
{s.value === '' ? '∅' : s.value}
</span>
))}
{problem.count > problem.samples.length && (
<span className="muted">
{t('tighten.moreCount', problem.count - problem.samples.length, locale)}
</span>
)}
</div>
</div>
))}
{clearable > 0 && (
<label className="row">
<input type="checkbox" checked={clear} onChange={(e) => onClear(e.target.checked)} />
{t('tighten.emptyCells', clearable, locale)}
</label>
)}
{plan && plan.rewrites.length > 0 && (
<div className="tighten-rewrites">
{plan.rewrites.map((r, i) => (
<span key={i} className="rewrite">
<code>{r.from}</code> → <code>{r.to}</code>
</span>
))}
</div>
)}
{plan && (
<div className="tighten-reading">
{/* 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) && (
<label>
{t('tighten.decimalMark')}
<select
value={reading.decimal ?? plan.reading.decimal}
onChange={(e) => onReading({ ...reading, decimal: e.target.value as '.' | ',' })}
>
<option value=".">{t('tighten.decimalPoint')}</option>
<option value=",">{t('tighten.decimalComma')}</option>
</select>
</label>
)}
{proposal.type === 'date' && (
<label>
{t('tighten.readDateAs')}
<select
value={(reading.dayFirst ?? plan.reading.dayFirst) ? 'day' : 'month'}
onChange={(e) => onReading({ ...reading, dayFirst: e.target.value === 'day' })}
>
<option value="day">{t('tighten.dayFirst')}</option>
<option value="month">{t('tighten.monthFirst')}</option>
</select>
{plan.ambiguousDates && (
<span className="hint">{t('tighten.ambiguousDates')}</span>
)}
</label>
)}
</div>
)}
{/* 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) && (
<div className="tighten-offer">
{t('tighten.distinctValues', plan.distinct.length, locale)}
<button
type="button"
className="btn small"
onClick={() => onUseOptions(plan.distinct!.map((d) => d.value))}
>
{t('tighten.makeSelect')}
</button>
<div className="samples">
{plan.distinct.slice(0, 8).map((d) => (
<span key={d.value} className="sample">
{d.value} <span className="muted">×{d.count}</span>
</span>
))}
</div>
</div>
)}
{plan && (
<div className="tighten-sql">
<button type="button" className="linkish" onClick={() => setShowSql(!showSql)}>
{showSql ? t('tighten.hideSql') : t('tighten.showSql')}
</button>
{showSql && <pre className="mono">{plan.sql}</pre>}
</div>
)}
</div>
);
}
/** 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';
}