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 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<TightenWalkPlan | null>(null);
const [failed, setFailed] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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<Record<StoredColumnType, string>> = {
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 && (
<div className="tighten-head">
{currentMeta?.label ?? current.column}
<span className="muted"> — {t('tidy.step', step + 1, steps.length)}</span>
</div>
);
return (
<Modal
title={t('tidy.title', tableMeta?.label ?? table)}
onClose={onClose}
wide
footer={
finished || failed ? (
<button className="btn primary" onClick={onClose}>
{t('common.close')}
</button>
) : current?.convert ? (
<>
<button className="btn" onClick={advance} disabled={busy}>
{t('tidy.continue')}
</button>
<button className="btn primary" onClick={() => void applyConvert()} disabled={busy}>
{busy
? t('common.working')
: t('tidy.tightenTo', typeLabels[current.convert] ?? current.convert)}
</button>
</>
) : offer ? (
<>
<button className="btn" onClick={advance} disabled={busy}>
{t('tidy.continue')}
</button>
<button className="btn primary" onClick={() => void makeSelect()} disabled={busy}>
{busy ? t('common.working') : t('tighten.makeSelect')}
</button>
</>
) : current ? (
<>
<button className="btn" onClick={advance} disabled={busy}>
{t('tidy.skip')}
</button>
<button className="btn primary" onClick={() => void applyMerge()} disabled={busy}>
{busy ? t('common.working') : t('tidy.apply')}
</button>
</>
) : null
}
>
{/* The body scrolls, the footer stays: a hundred groups must never push
the buttons off the screen. */}
<div className="mb">
{failed ? (
<p className="hint bad">{failed}</p>
) : plan === null ? (
<p className="hint">{t('tidy.scanning')}</p>
) : finished ? (
<p className={anythingDone ? 'hint good' : 'hint'}>
{steps.length === 0 && !anythingDone
? t('tidy.nothing', plan.scanned, locale)
: summarise()}
</p>
) : current?.convert ? (
<div className="tighten tidy">
{head}
<span>
{t(
'tidy.readsAs',
typeLabels[current.convert] ?? current.convert,
current.present,
current.total,
locale,
)}
</span>
<div className="samples">
{current.samples.map((v) => (
<span key={v} className="sample">
{v}
</span>
))}
</div>
{error && <span className="bad">{error}</span>}
</div>
) : offer && current?.select ? (
<div className="tighten tidy">
{head}
{offer.changed > 0 && (
<span className="good">{t('tidy.applied', offer.changed, locale)}</span>
)}
<span>{t('tidy.offerSelect', current.select.length, locale)}</span>
<div className="samples">
{current.select.map((d) => (
<span key={d.value} className="sample">
{d.value} <span className="muted">×{formatCount(d.count, locale)}</span>
</span>
))}
</div>
{error && <span className="bad">{error}</span>}
</div>
) : current ? (
<>
<p className="hint" style={{ marginTop: 0 }}>
{t('tidy.intro')}
</p>
<div className="tighten tidy">
{head}
{current.clusters.map((cluster, i) => {
const pick = picks[i] ?? { on: true, to: cluster.to };
return (
<div key={cluster.to + i} className="tidy-cluster">
<label className="row" style={{ margin: 0 }}>
<input
type="checkbox"
checked={pick.on}
onChange={(e) =>
setPicks((list) =>
list.map((p, j) => (j === i ? { ...p, on: e.target.checked } : p)),
)
}
/>
<span className="samples">
{cluster.variants.map((v) => (
<span key={v.value} className="sample">
{v.value}{' '}
<span className="muted">×{formatCount(v.count, locale)}</span>
</span>
))}
</span>
</label>
<span className="muted" aria-hidden="true">
→
</span>
<input
className="tidy-to"
value={pick.to}
disabled={!pick.on}
aria-label={t('tidy.survivorFor', cluster.variants[0]?.value ?? '')}
onChange={(e) =>
setPicks((list) =>
list.map((p, j) => (j === i ? { ...p, to: e.target.value } : p)),
)
}
/>
</div>
);
})}
{currentMeta?.type === 'select' && (
<span className="muted">{t('tidy.optionsFollow')}</span>
)}
{error && <span className="bad">{error}</span>}
</div>
</>
) : null}
</div>
</Modal>
);
/** 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');
}
}