Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import {
COLUMN_TYPES,
FUNCTIONS,
formatChoices,
impliedFormat,
isComputed,
renderSequence,
slugifyColumnName,
validateSequence,
type ColumnFormat,
type ColumnMeta,
type ColumnType,
} from '@wellformed/shared';
import { api } from '../api/client';
import { useI18n } from '../i18n';
import { useWorkspace } from '../state/workspace';
import { useToasts } from '../state/toasts';
import { Modal } from './Modal';
const CREATABLE_TYPES: ColumnType[] = COLUMN_TYPES.filter((t) => t !== 'rollup');
interface ColumnDialogProps {
/** Omit to create a new column. */
column?: ColumnMeta;
onClose(): void;
}
export function ColumnDialog({ column, onClose }: ColumnDialogProps): ReactNode {
const ws = useWorkspace();
const toasts = useToasts();
const { t } = useI18n();
const editing = Boolean(column);
/** Named by what it does to a value, since that is the whole of the choice. */
const formatLabels: Record<ColumnFormat, string> = {
currency: t('column.formatCurrency'),
number: t('column.formatNumber'),
percent: t('column.formatPercent'),
date: t('column.formatDate'),
text: t('column.formatText'),
};
/** The same formats named as a phrase, for saying what the default already is. */
const formatDefaults: Record<ColumnFormat, string> = {
currency: t('column.defaultCurrency'),
number: t('column.defaultNumber'),
percent: t('column.defaultPercent'),
date: t('column.defaultDate'),
text: t('column.defaultText'),
};
const typeLabels: Record<ColumnType, string> = {
text: t('column.typeText'),
number: t('column.typeNumber'),
currency: t('column.typeCurrency'),
date: t('column.typeDate'),
checkbox: t('column.typeCheckbox'),
select: t('column.typeSelect'),
link: t('column.typeLink'),
formula: t('column.typeFormula'),
rollup: t('column.typeRollup'),
};
const [label, setLabel] = useState(column?.label ?? '');
const [type, setType] = useState<ColumnType>(column?.type ?? 'text');
const [options, setOptions] = useState((column?.options ?? []).join(', '));
const [target, setTarget] = useState(
column?.target ?? ws.tables.find((t) => t.name !== ws.tableName)?.name ?? '',
);
const [expr, setExpr] = useState(column?.expr ?? '');
const [format, setFormat] = useState<ColumnFormat | null>(column?.format ?? null);
const [required, setRequired] = useState(column?.required ?? false);
const [unique, setUnique] = useState(column?.unique ?? false);
const [sequence, setSequence] = useState(column?.sequence ?? '');
const [saving, setSaving] = useState(false);
const [checked, setChecked] = useState<{ expr: string; ok: boolean; error: string | null } | null>(
null,
);
const computed = isComputed(type);
const labelValid = label.trim().length > 0 && label.trim().length <= 60;
// Shown, not chosen: the identifier is derived from the label — and fixed
// once the column exists, because formulas and views address it by name.
const derivedName = editing ? column!.name : slugifyColumnName(label.trim());
const trimmedExpr = expr.trim();
// A result only speaks for the expression it was asked about; anything else
// is stale and must not be shown as if it were current.
const check = checked && checked.expr === trimmedExpr ? checked : null;
// Validate against the live catalogue as it is typed, so a mistake is visible
// before the column is committed to the schema.
useEffect(() => {
if (!computed || !trimmedExpr) return;
let cancelled = false;
const handle = window.setTimeout(() => {
api
.validateFormula(ws.tableName, trimmedExpr)
.then((result) => {
if (!cancelled) setChecked({ expr: trimmedExpr, ...result });
})
.catch(() => undefined);
}, 300);
return () => {
cancelled = true;
window.clearTimeout(handle);
};
}, [computed, trimmedExpr, ws.tableName]);
const sequencePattern = type === 'text' && !computed ? sequence.trim() : '';
const sequenceProblem = sequencePattern ? validateSequence(sequencePattern) : null;
const canSave =
!saving &&
labelValid &&
(!computed || (expr.trim().length > 0 && check?.ok !== false)) &&
(type !== 'select' || options.split(',').some((o) => o.trim())) &&
(type !== 'link' || Boolean(target)) &&
!sequenceProblem;
async function save() {
setSaving(true);
try {
if (editing && column) {
await api.updateColumn(ws.tableName, column.name, {
...(isComputed(column.type) ? { expr: expr.trim() } : {}),
format,
...(label.trim() !== column.label ? { label: label.trim() } : {}),
// Only when it changed, and only while the column still writes text —
// a conversion away from text sheds the numbering on the server.
...(type === 'text' && sequencePattern !== (column.sequence ?? '')
? { sequence: sequencePattern || null }
: {}),
});
toasts.push(t('column.updated', label.trim()), 'success');
} else {
await api.addColumn(ws.tableName, {
label: label.trim(),
type,
...(type === 'select' ? { options: splitOptions(options) } : {}),
...(type === 'link' ? { target } : {}),
...(computed ? { expr: expr.trim() } : {}),
...(format ? { format } : {}),
...(computed ? {} : { required, unique }),
...(sequencePattern ? { sequence: sequencePattern } : {}),
});
toasts.push(t('column.added', label.trim()), 'success');
}
await ws.refresh();
onClose();
} catch (error) {
toasts.fail(error);
} finally {
setSaving(false);
}
}
const functionHelp = useMemo(
() => FUNCTIONS.map((f) => f.signature).join(' · '),
[],
);
/** What the picker offers, and — when empty — whether it appears at all. */
const formatOptions = formatChoices(type);
/**
* A new column can be any creatable kind. An existing one shows its type but
* does not change it here: changing what the rows are allowed to hold is
* tightening, which has its own dialog under the column header's menu —
* renaming and redecorating a column should never sit one dropdown away
* from a conversion that rewrites every row.
*/
const typeChoices: ColumnType[] = !editing ? CREATABLE_TYPES : [column!.type];
return (
<Modal
title={
editing
? t('column.editTitle', column!.label)
: t('column.addTitle', ws.table?.label ?? ws.tableName)
}
onClose={onClose}
footer={
<>
<button className="btn" onClick={onClose} disabled={saving}>
{t('common.cancel')}
</button>
<button className="btn primary" onClick={() => void save()} disabled={!canSave}>
{saving ? t('common.saving') : editing ? t('dialogs.saveChanges') : t('column.addColumn')}
</button>
</>
}
>
<div className="mb">
<label>
{t('common.name')}
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t('column.namePlaceholder')}
autoFocus
/>
{derivedName && (
<span className="hint mono">
{t('table.inDatabase', derivedName)}
{editing && ` ${t('table.sqlNameFixed')}`}
</span>
)}
</label>
<label>
{t('column.type')}
<select
value={type}
disabled={editing}
onChange={(e) => {
const next = e.target.value as ColumnType;
setType(next);
// A format chosen for the old type is not a choice about the new
// one; a date should not go on printing as money. Back to the
// type's own default, visibly, rather than silently kept.
if (!column || next !== column.type) setFormat(null);
else setFormat(column.format ?? null);
}}
>
{typeChoices.map((choice) => (
<option key={choice} value={choice}>
{typeLabels[choice]}
</option>
))}
</select>
{editing &&
(isComputed(column!.type) ? (
<span className="hint">{t('column.computedTypeHint')}</span>
) : (
<span className="hint">{t('column.typeViaTighten')}</span>
))}
</label>
{!editing && type === 'select' && (
<label>
{t('column.options')} <span className="hint">{t('column.commaSeparated')}</span>
<input
value={options}
onChange={(e) => setOptions(e.target.value)}
placeholder={t('column.optionsPlaceholder')}
/>
</label>
)}
{!editing && type === 'link' && (
<label>
{t('column.linksTo')}
<select value={target} onChange={(e) => setTarget(e.target.value)}>
{ws.tables
.filter((table) => table.name !== ws.tableName)
.map((table) => (
<option key={table.name} value={table.name}>
{table.label}
</option>
))}
</select>
<span className="hint">{t('column.linkHint')}</span>
</label>
)}
{computed && (
<label>
{t('column.expression')}
<input
value={expr}
onChange={(e) => setExpr(e.target.value)}
placeholder={t('column.exprPlaceholder')}
className="mono"
/>
{check?.error ? (
<span className="hint bad">{check.error}</span>
) : check?.ok ? (
<span className="hint good">{t('column.compiles')}</span>
) : (
<span className="hint">{t('column.exprHint')}</span>
)}
<span className="hint" style={{ opacity: 0.75 }}>
{functionHelp}
</span>
</label>
)}
{/* Only where the type leaves the rendering open — see `formatChoices`.
A link, a checkbox, a date and a select each render one way whatever
is set here, and a picker over them was a setting that did nothing. */}
{formatOptions.length > 0 && (
<label>
{t('column.displayFormat')}
<select
value={format ?? ''}
onChange={(e) => setFormat((e.target.value || null) as ColumnFormat | null)}
>
<option value="">
{t('column.formatDefaultOption', formatDefaults[impliedFormat(type)])}
</option>
{formatOptions.map((f) => (
<option key={f} value={f}>
{formatLabels[f]}
</option>
))}
</select>
</label>
)}
{!computed && type === 'text' && (
<label>
{t('column.numbering')} <span className="hint">{t('column.optional')}</span>
<input
value={sequence}
onChange={(e) => setSequence(e.target.value)}
placeholder="INV-{yyyy}-{nnn}"
className="mono"
/>
{sequenceProblem ? (
<span className="hint bad">{sequenceProblem}</span>
) : sequencePattern ? (
<span className="hint good">
{t('column.sequencePreview', renderSequence(sequencePattern, 1, new Date()))}
</span>
) : (
<span className="hint">{t('column.sequenceHint')}</span>
)}
</label>
)}
{!editing && !computed && (
<>
<label className="row">
<input
type="checkbox"
checked={required}
onChange={(e) => setRequired(e.target.checked)}
/>
{t('column.required')}
</label>
<label className="row">
<input
type="checkbox"
checked={unique}
disabled={type === 'checkbox'}
onChange={(e) => setUnique(e.target.checked)}
/>
{t('column.unique')}
</label>
</>
)}
</div>
</Modal>
);
}
function splitOptions(raw: string): string[] {
return raw
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}