Spreadsheet-UX op een echte relationele engine

main

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

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