Spreadsheet-UX op een echte relationele engine

main

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

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