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 = { 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 = { currency: t('column.defaultCurrency'), number: t('column.defaultNumber'), percent: t('column.defaultPercent'), date: t('column.defaultDate'), text: t('column.defaultText'), }; const typeLabels: Record = { 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(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(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 ( } >
{!editing && type === 'select' && ( )} {!editing && type === 'link' && ( )} {computed && ( )} {/* 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 && ( )} {!computed && type === 'text' && ( )} {!editing && !computed && ( <> )}
); } function splitOptions(raw: string): string[] { return raw .split(',') .map((s) => s.trim()) .filter(Boolean); }