Spreadsheet-UX op een echte relationele engine

main

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

11.9 kB Ruw bestand
import { useEffect, useState, type ReactNode } from 'react';
import {
  formatCount,
  type NormalizeMerge,
  type StoredColumnType,
  type TightenWalkPlan,
  type TightenWalkStep,
} from '@wellformed/shared';
import { api, ApiError } from '../api/client';
import { useI18n } from '../i18n';
import { useSession } from '../state/session';
import { useWorkspace } from '../state/workspace';
import { Modal } from './Modal';

/**
 * The tighten walk, on screen: the regie the operations never had.
 *
 * One column per stop, each answered with what its values say it is. A column
 * that reads as a date or an amount is offered its real type; a column that
 * is an enum in waiting first gets its spellings merged — that is the
 * normalising, one decision per group, every survivor overrulable — and then
 * the select that finishes it. Stray whitespace never appears at all: the
 * server folds it in silently and says so in the audit trail. Nothing is
 * written until a stop's own button, and the fine-grained proposal (links,
 * unique, required) stays with the per-column tighten dialog.
 */

interface TidyDialogProps {
  table: string;
  onClose(): void;
}

export function TidyDialog({ table, onClose }: TidyDialogProps): ReactNode {
  const ws = useWorkspace();
  const session = useSession();
  const { t, locale } = useI18n();
  const admin = session.can('admin');

  const [plan, setPlan] = useState<TightenWalkPlan | null>(null);
  const [failed, setFailed] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [step, setStep] = useState(0);
  const [picks, setPicks] = useState<{ on: boolean; to: string }[]>([]);
  /** The select offer: reached after merging, or directly when nothing needed merging. */
  const [offer, setOffer] = useState<{ changed: number } | null>(null);
  const [done, setDone] = useState({ changed: 0, merges: 0, selects: 0, converts: 0 });
  const [busy, setBusy] = useState(false);

  const tableMeta = ws.tables.find((tb) => tb.name === table);

  // What this account can act on: a merge is editing, the type steps are
  // admin's. A step someone cannot take is not shown to them.
  const steps = (plan?.steps ?? []).filter((s) => s.clusters.length > 0 || admin);
  const current: TightenWalkStep | undefined = steps[step];
  const currentMeta = current
    ? tableMeta?.columns.find((c) => c.name === current.column)
    : undefined;

  const typeLabels: Partial<Record<StoredColumnType, string>> = {
    number: t('column.typeNumber'),
    currency: t('column.typeCurrency'),
    date: t('column.typeDate'),
    checkbox: t('column.typeCheckbox'),
  };

  useEffect(() => {
    let cancelled = false;
    api
      .walkTighten(table)
      .then((answer) => {
        if (!cancelled) setPlan(answer.plan);
      })
      .catch((cause: unknown) => {
        if (cancelled) return;
        setFailed(cause instanceof ApiError ? cause.message : t('tidy.checkFailed'));
      });
    return () => {
      cancelled = true;
    };
    // The table is fixed for the dialog's lifetime.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [table]);

  // Each stop starts from the plan's own guesses. A column with nothing to
  // merge goes straight to its select offer.
  useEffect(() => {
    setPicks(current ? current.clusters.map((c) => ({ on: true, to: c.to })) : []);
    setOffer(current && !current.clusters.length && !current.convert ? { changed: 0 } : null);
    setError(null);
  }, [current]);

  const merges: NormalizeMerge[] = current
    ? current.clusters.flatMap((cluster, i) => {
        const pick = picks[i];
        if (!pick?.on) return [];
        const to = pick.to.trim();
        if (!to) return [];
        const from = cluster.variants.map((v) => v.value).filter((v) => v !== to);
        return from.length ? [{ to, from }] : [];
      })
    : [];

  function advance() {
    setStep((s) => s + 1);
  }

  async function applyMerge() {
    if (!current) return;
    setBusy(true);
    setError(null);
    try {
      let changed = 0;
      if (merges.length) {
        const result = await api.normalize(table, current.column, merges);
        changed = result.changed;
        setDone((d) => ({ ...d, changed: d.changed + changed, merges: d.merges + merges.length }));
        // The grid under the dialog keeps telling the truth.
        void ws.refresh();
      }
      // The payoff the merge was for. Offered, never assumed — and only to
      // someone allowed to apply a tightening.
      if (merges.length && current.select && admin) setOffer({ changed });
      else advance();
    } catch (cause) {
      setError(cause instanceof Error ? cause.message : t('tidy.checkFailed'));
    } finally {
      setBusy(false);
    }
  }

  async function applyConvert() {
    if (!current?.convert) return;
    setBusy(true);
    setError(null);
    try {
      await api.tighten(table, current.column, { type: current.convert });
      setDone((d) => ({ ...d, converts: d.converts + 1 }));
      void ws.refresh();
      advance();
    } catch (cause) {
      setError(cause instanceof Error ? cause.message : t('tidy.checkFailed'));
    } finally {
      setBusy(false);
    }
  }

  async function makeSelect() {
    if (!current?.select) return;
    setBusy(true);
    setError(null);
    try {
      await api.tighten(table, current.column, {
        type: 'select',
        options: current.select.map((d) => d.value),
      });
      setDone((d) => ({ ...d, selects: d.selects + 1 }));
      void ws.refresh();
      advance();
    } catch (cause) {
      setError(cause instanceof Error ? cause.message : t('tidy.checkFailed'));
    } finally {
      setBusy(false);
    }
  }

  const finished = plan !== null && (steps.length === 0 || step >= steps.length);
  const anythingDone = done.changed + done.merges + done.selects + done.converts > 0;

  const head = current && (
    <div className="tighten-head">
      {currentMeta?.label ?? current.column}
      <span className="muted"> — {t('tidy.step', step + 1, steps.length)}</span>
    </div>
  );

  return (
    <Modal
      title={t('tidy.title', tableMeta?.label ?? table)}
      onClose={onClose}
      wide
      footer={
        finished || failed ? (
          <button className="btn primary" onClick={onClose}>
            {t('common.close')}
          </button>
        ) : current?.convert ? (
          <>
            <button className="btn" onClick={advance} disabled={busy}>
              {t('tidy.continue')}
            </button>
            <button className="btn primary" onClick={() => void applyConvert()} disabled={busy}>
              {busy
                ? t('common.working')
                : t('tidy.tightenTo', typeLabels[current.convert] ?? current.convert)}
            </button>
          </>
        ) : offer ? (
          <>
            <button className="btn" onClick={advance} disabled={busy}>
              {t('tidy.continue')}
            </button>
            <button className="btn primary" onClick={() => void makeSelect()} disabled={busy}>
              {busy ? t('common.working') : t('tighten.makeSelect')}
            </button>
          </>
        ) : current ? (
          <>
            <button className="btn" onClick={advance} disabled={busy}>
              {t('tidy.skip')}
            </button>
            <button className="btn primary" onClick={() => void applyMerge()} disabled={busy}>
              {busy ? t('common.working') : t('tidy.apply')}
            </button>
          </>
        ) : null
      }
    >
      {/* The body scrolls, the footer stays: a hundred groups must never push
          the buttons off the screen. */}
      <div className="mb">
        {failed ? (
          <p className="hint bad">{failed}</p>
        ) : plan === null ? (
          <p className="hint">{t('tidy.scanning')}</p>
        ) : finished ? (
          <p className={anythingDone ? 'hint good' : 'hint'}>
            {steps.length === 0 && !anythingDone
              ? t('tidy.nothing', plan.scanned, locale)
              : summarise()}
          </p>
        ) : current?.convert ? (
          <div className="tighten tidy">
            {head}
            <span>
              {t(
                'tidy.readsAs',
                typeLabels[current.convert] ?? current.convert,
                current.present,
                current.total,
                locale,
              )}
            </span>
            <div className="samples">
              {current.samples.map((v) => (
                <span key={v} className="sample">
                  {v}
                </span>
              ))}
            </div>
            {error && <span className="bad">{error}</span>}
          </div>
        ) : offer && current?.select ? (
          <div className="tighten tidy">
            {head}
            {offer.changed > 0 && (
              <span className="good">{t('tidy.applied', offer.changed, locale)}</span>
            )}
            <span>{t('tidy.offerSelect', current.select.length, locale)}</span>
            <div className="samples">
              {current.select.map((d) => (
                <span key={d.value} className="sample">
                  {d.value} <span className="muted">×{formatCount(d.count, locale)}</span>
                </span>
              ))}
            </div>
            {error && <span className="bad">{error}</span>}
          </div>
        ) : current ? (
          <>
            <p className="hint" style={{ marginTop: 0 }}>
              {t('tidy.intro')}
            </p>
            <div className="tighten tidy">
              {head}

              {current.clusters.map((cluster, i) => {
                const pick = picks[i] ?? { on: true, to: cluster.to };
                return (
                  <div key={cluster.to + i} className="tidy-cluster">
                    <label className="row" style={{ margin: 0 }}>
                      <input
                        type="checkbox"
                        checked={pick.on}
                        onChange={(e) =>
                          setPicks((list) =>
                            list.map((p, j) => (j === i ? { ...p, on: e.target.checked } : p)),
                          )
                        }
                      />
                      <span className="samples">
                        {cluster.variants.map((v) => (
                          <span key={v.value} className="sample">
                            {v.value}{' '}
                            <span className="muted">×{formatCount(v.count, locale)}</span>
                          </span>
                        ))}
                      </span>
                    </label>
                    <span className="muted" aria-hidden="true">
                      →
                    </span>
                    <input
                      className="tidy-to"
                      value={pick.to}
                      disabled={!pick.on}
                      aria-label={t('tidy.survivorFor', cluster.variants[0]?.value ?? '')}
                      onChange={(e) =>
                        setPicks((list) =>
                          list.map((p, j) => (j === i ? { ...p, to: e.target.value } : p)),
                        )
                      }
                    />
                  </div>
                );
              })}

              {currentMeta?.type === 'select' && (
                <span className="muted">{t('tidy.optionsFollow')}</span>
              )}
              {error && <span className="bad">{error}</span>}
            </div>
          </>
        ) : null}
      </div>
    </Modal>
  );

  /** What the walk did, said whole: values, merges, selects, conversions. */
  function summarise(): string {
    const parts: string[] = [];
    if (done.changed > 0) parts.push(t('tidy.summary', done.changed, done.merges, locale));
    else if (done.merges > 0) parts.push(t('tidy.summaryOptionsOnly'));
    if (done.converts > 0) parts.push(t('tidy.summaryConverted', done.converts));
    if (done.selects > 0) parts.push(t('tidy.summaryTightened', done.selects));
    return parts.length ? parts.join(' ') : t('tidy.summaryNothing');
  }
}