Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import { useMemo, useState, type ReactNode } from 'react';
import {
STORED_COLUMN_TYPES,
type ColumnMeta,
type Reading,
type StoredColumnType,
type Tightening,
} 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';
import { TightenPanel } from './TightenPanel';
/**
* Tightening one column, in its own room.
*
* This used to live inside "Edit column", which was the wrong place: renaming
* a column and changing what its rows are allowed to hold are different acts
* with different weights, and burying the second under the first is how it
* went unfound. So the edit dialog keeps the decoration — name, format,
* numbering — and this dialog holds the constraints: the type, a select's
* options, a link's target, required, unique, each proposal checked against
* every row by the dry run before Save is allowed to mean anything.
*/
interface TightenDialogProps {
column: ColumnMeta;
onClose(): void;
}
export function TightenDialog({ column, onClose }: TightenDialogProps): ReactNode {
const ws = useWorkspace();
const toasts = useToasts();
const { t, locale } = useI18n();
const typeLabels: Record<StoredColumnType, 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'),
};
const [type, setType] = useState<StoredColumnType>(column.type as StoredColumnType);
const [options, setOptions] = useState((column.options ?? []).join(', '));
const [target, setTarget] = useState(
column.target ?? ws.tables.find((tb) => tb.name !== ws.tableName)?.name ?? '',
);
const [required, setRequired] = useState(column.required ?? false);
const [unique, setUnique] = useState(column.unique ?? false);
const [saving, setSaving] = useState(false);
// How the text already in the column should be read while it converts, and
// what to do with what will not. Both are answers the check asks for.
const [reading, setReading] = useState<Reading>({});
const [clear, setClear] = useState(false);
const [verdict, setVerdict] = useState({ ready: true, clearable: 0 });
/** Only what actually changed; absent means "leave it as it is". */
const proposal = useMemo<Tightening | null>(() => {
const next: Tightening = {};
if (type !== column.type) next.type = type;
if (type === 'select') {
const list = splitOptions(options);
if (!sameList(list, column.options ?? [])) next.options = list;
}
if (type === 'link' && target !== column.target) next.target = target;
if (required !== (column.required ?? false)) next.required = required;
if (unique !== (column.unique ?? false)) next.unique = unique;
return Object.keys(next).length ? next : null;
}, [column, type, options, target, required, unique]);
/** What the check needs to know, including the parts it cannot infer alone. */
const asked = useMemo<Tightening>(
() => ({
...(proposal ?? {}),
// Sent even when unchanged: the answer to "does this still fit" depends
// on the whole shape of the column, not only on the part being changed.
type,
...(type === 'select' ? { options: splitOptions(options) } : {}),
...(type === 'link' ? { target } : {}),
}),
[proposal, type, options, target],
);
const canSave =
!saving &&
proposal !== null &&
(type !== 'select' || options.split(',').some((o) => o.trim())) &&
(type !== 'link' || Boolean(target)) &&
verdict.ready;
async function save() {
setSaving(true);
try {
const result = await api.tighten(ws.tableName, column.name, {
...asked,
reading,
onProblem: clear ? 'clear' : 'stop',
});
if (result.cleared > 0) {
toasts.push(t('column.tightened', column.label, result.cleared, locale), 'success');
} else {
toasts.push(t('column.updated', column.label), 'success');
}
await ws.refresh();
onClose();
} catch (error) {
toasts.fail(error);
} finally {
setSaving(false);
}
}
return (
<Modal
title={t('tighten.dialogTitle', column.label)}
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') : t('tighten.applyBtn')}
</button>
</>
}
>
<div className="mb">
<label>
{t('column.type')}
<select value={type} onChange={(e) => setType(e.target.value as StoredColumnType)}>
{STORED_COLUMN_TYPES.map((choice) => (
<option key={choice} value={choice}>
{typeLabels[choice]}
</option>
))}
</select>
</label>
{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>
)}
{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>
)}
<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>
<TightenPanel
table={ws.tableName}
column={column.name}
proposal={asked}
active={proposal !== null}
reading={reading}
onReading={setReading}
clear={clear}
onClear={setClear}
onUseOptions={(list) => {
setType('select');
setOptions(list.join(', '));
}}
onVerdict={setVerdict}
/>
</div>
</Modal>
);
}
function splitOptions(raw: string): string[] {
return raw
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
function sameList(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}