Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import { useState, type ReactNode } from 'react';
import { slugifyTableName, type TableMeta } 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';
/**
* Making a table by hand, or changing what an existing one is called. The
* demo no longer lives here: it is a property of workspace creation, chosen
* in the picker, because a demo can only ever be the first thing in a
* workspace.
*/
export function TableDialog({
table,
onClose,
}: {
/** Given, the dialog edits that table instead of creating one. */
table?: TableMeta;
onClose(): void;
}): ReactNode {
const ws = useWorkspace();
const toasts = useToasts();
const { t } = useI18n();
const editing = table !== undefined;
const [label, setLabel] = useState(table?.label ?? '');
// Creating, this is the row-label column's *label* — its identifier is
// derived. Editing, it is the identifier of the chosen existing column.
const [display, setDisplay] = useState(table?.display ?? t('table.rowLabelDefault'));
const [group, setGroup] = useState(table?.group ?? '');
const [saving, setSaving] = useState(false);
// A link column cannot label its own rows; the server says so too.
const labelColumns = (table?.columns ?? []).filter((c) => c.type !== 'link');
// The headings already in use, so a table joins a shelf by picking it rather
// than by typing it letter-perfect a second time.
const knownGroups = [
...new Set((ws.meta?.tables ?? []).map((t) => t.group).filter((g): g is string => g !== null)),
];
// Shown, not chosen: people name the table in their own words and the
// identifier is derived — the server steps it past collisions. What is
// displayed here is the derivation, so the SQL bar never surprises anyone.
const derivedName = editing ? table.name : slugifyTableName(label.trim());
const displayOk = display.trim().length > 0 && display.trim().length <= 60;
const canSave = !saving && label.trim().length > 0 && displayOk;
async function save() {
setSaving(true);
try {
const groupValue = group.trim() || null;
if (editing) {
// Only what changed: setting the display column writes its own audit
// line, and a no-op rename would leave a misleading one behind.
await api.updateTable(table.name, {
...(label.trim() === table.label ? {} : { label: label.trim() }),
...(display.trim() === table.display ? {} : { display: display.trim() }),
...(groupValue === table.group ? {} : { group: groupValue }),
});
await ws.refresh();
toasts.push(t('table.saved', label.trim() || table.label), 'success');
} else {
const { table: created } = await api.createTable({
label: label.trim(),
displayLabel: display.trim(),
...(groupValue ? { group: groupValue } : {}),
});
await ws.refresh();
ws.setTable(created.name);
toasts.push(t('table.created', created.label), 'success');
}
onClose();
} catch (error) {
toasts.fail(error);
} finally {
setSaving(false);
}
}
return (
<Modal
title={editing ? t('table.editTitle', table.label) : t('table.newTitle')}
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('table.createTable')}
</button>
</>
}
>
<div className="mb">
<label>
{t('table.label')} <span className="hint">{t('table.labelHint')}</span>
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t('table.labelPlaceholder')}
autoFocus
/>
{derivedName && (
<span className="hint mono">
{t('table.inDatabase', derivedName)}
{editing && ` ${t('table.sqlNameFixed')}`}
</span>
)}
</label>
<label>
{t('table.rowLabelColumn')}
{editing ? (
<select value={display} onChange={(e) => setDisplay(e.target.value)}>
{labelColumns.map((column) => (
<option key={column.name} value={column.name}>
{column.label}
</option>
))}
</select>
) : (
<input
value={display}
onChange={(e) => setDisplay(e.target.value)}
placeholder={t('table.rowLabelPlaceholder')}
/>
)}
<span className="hint">
{editing ? t('table.rowLabelEditHint') : t('table.rowLabelCreateHint')}
</span>
</label>
<label>
{t('table.group')} <span className="hint">{t('table.groupHint')}</span>
<input
value={group}
onChange={(e) => setGroup(e.target.value)}
placeholder={t('table.groupPlaceholder')}
list="table-groups"
/>
{knownGroups.length > 0 && (
<datalist id="table-groups">
{knownGroups.map((g) => (
<option key={g} value={g} />
))}
</datalist>
)}
</label>
{!editing && (
<div className="hint">
{t('table.runsBefore')}
<span className="mono">CREATE TABLE {derivedName || '…'}</span>
{t('table.runsAfter')}
</div>
)}
</div>
</Modal>
);
}