Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

12.4 kB Ruw bestand
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);
}