Spreadsheet-UX op een echte relationele engine

main

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

8.6 kB Ruw bestand
import { useEffect, useState, type ReactNode } from 'react';
import { formatCount, type Reading, type TightenPlan, type Tightening } from '@wellformed/shared';
import { api, ApiError } from '../api/client';
import { useI18n } from '../i18n';

/**
 * The dry run, on screen.
 *
 * A constraint is a claim about every row, and the only honest way to offer one
 * is to check it against every row before it binds. So this asks the server on
 * every change to the proposal and reports back in rows rather than in
 * percentages: which ones do not fit, what they say, and what would become of
 * them. Nothing here changes anything — it is the answer to "what would happen",
 * and the dialog's Save is where it happens.
 */

interface TightenPanelProps {
  table: string;
  column: string;
  /** The constraints being proposed, without the answers this panel collects. */
  proposal: Tightening;
  /** Whether anything is actually being proposed; when not, the panel stays shut. */
  active: boolean;
  reading: Reading;
  onReading(reading: Reading): void;
  clear: boolean;
  onClear(clear: boolean): void;
  /** Offer the values already in the column as the options for a select. */
  onUseOptions(options: string[]): void;
  /** Reported upward so Save can refuse what the check already refused. */
  onVerdict(verdict: { ready: boolean; clearable: number }): void;
}

export function TightenPanel({
  table,
  column,
  proposal,
  active,
  reading,
  onReading,
  clear,
  onClear,
  onUseOptions,
  onVerdict,
}: TightenPanelProps): ReactNode {
  const { t, locale } = useI18n();
  const [result, setResult] = useState<{ key: string; plan: TightenPlan } | null>(null);
  const [error, setError] = useState<{ key: string; message: string } | null>(null);
  const [showSql, setShowSql] = useState(false);

  const asked: Tightening = { ...proposal, reading, onProblem: clear ? 'clear' : 'stop' };
  const key = JSON.stringify(asked);

  // An answer only speaks for the question it was asked; anything else is stale.
  const plan = result?.key === key ? result.plan : null;
  const failed = error?.key === key ? error.message : null;

  useEffect(() => {
    if (!active) return;
    let cancelled = false;
    const handle = window.setTimeout(() => {
      api
        .planTighten(table, column, asked)
        .then((answer) => {
          if (cancelled) return;
          setResult({ key, plan: answer.plan });
          setError(null);
        })
        .catch((cause: unknown) => {
          if (cancelled) return;
          setError({ key, message: cause instanceof ApiError ? cause.message : t('tighten.checkFailed') });
          setResult(null);
        });
    }, 250);
    return () => {
      cancelled = true;
      window.clearTimeout(handle);
    };
    // `key` is the whole proposal, so it is the only dependency that matters.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [active, table, column, key]);

  const blocking = plan ? plan.problems.filter((p) => !p.clearable || !clear) : [];
  const clearable = plan ? plan.problems.filter((p) => p.clearable).reduce((n, p) => n + p.count, 0) : 0;

  useEffect(() => {
    onVerdict({ ready: !active || (plan !== null && blocking.length === 0), clearable });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [active, plan, blocking.length, clearable]);

  if (!active) return null;

  return (
    <div className="tighten">
      <div className="tighten-head">
        {failed ? (
          <span className="bad">{failed}</span>
        ) : proposal.type === 'select' && !proposal.options?.length ? (
          <span>{t('tighten.noOptionsYet')}</span>
        ) : plan ? (
          plan.total === 0 ? (
            <span>{t('tighten.nothingYet')}</span>
          ) : blocking.length === 0 ? (
            <span className="good">
              {t('tighten.allFit', plan.total, clearable > 0 && clear ? clearable : 0, locale)}
            </span>
          ) : (
            <span className="bad">{t('tighten.someFit', plan.fit, plan.total, locale)}</span>
          )
        ) : (
          <span className="muted">{t('tighten.checking')}</span>
        )}
      </div>

      {plan?.problems.map((problem) => (
        <div key={problem.kind + problem.message} className="tighten-problem">
          <div>
            <strong>{formatCount(problem.count, locale)}</strong> {problem.message}
            {problem.clearable && clear && (
              <span className="good">{t('tighten.willBeCleared', problem.count)}</span>
            )}
          </div>
          <div className="samples">
            {problem.samples.map((s) => (
              <span key={s.id} className="sample" title={s.label}>
                {s.value === '' ? '∅' : s.value}
              </span>
            ))}
            {problem.count > problem.samples.length && (
              <span className="muted">
                {t('tighten.moreCount', problem.count - problem.samples.length, locale)}
              </span>
            )}
          </div>
        </div>
      ))}

      {clearable > 0 && (
        <label className="row">
          <input type="checkbox" checked={clear} onChange={(e) => onClear(e.target.checked)} />
          {t('tighten.emptyCells', clearable, locale)}
        </label>
      )}

      {plan && plan.rewrites.length > 0 && (
        <div className="tighten-rewrites">
          {plan.rewrites.map((r, i) => (
            <span key={i} className="rewrite">
              <code>{r.from}</code> → <code>{r.to}</code>
            </span>
          ))}
        </div>
      )}

      {plan && (
        <div className="tighten-reading">
          {/* Only ever offered where it changes an answer: a column with no
              thousands or decimal marks reads the same either way. */}
          {plan.rewrites.length > 0 && needsDecimal(proposal) && (
            <label>
              {t('tighten.decimalMark')}
              <select
                value={reading.decimal ?? plan.reading.decimal}
                onChange={(e) => onReading({ ...reading, decimal: e.target.value as '.' | ',' })}
              >
                <option value=".">{t('tighten.decimalPoint')}</option>
                <option value=",">{t('tighten.decimalComma')}</option>
              </select>
            </label>
          )}
          {proposal.type === 'date' && (
            <label>
              {t('tighten.readDateAs')}
              <select
                value={(reading.dayFirst ?? plan.reading.dayFirst) ? 'day' : 'month'}
                onChange={(e) => onReading({ ...reading, dayFirst: e.target.value === 'day' })}
              >
                <option value="day">{t('tighten.dayFirst')}</option>
                <option value="month">{t('tighten.monthFirst')}</option>
              </select>
              {plan.ambiguousDates && (
                <span className="hint">{t('tighten.ambiguousDates')}</span>
              )}
            </label>
          )}
        </div>
      )}

      {/* Offered while there is still a list to make: the proposed type is one
          a list of words can serve, and nobody has named the options yet. Once
          they are named the offer would only overwrite them, and beside a link
          it would only be a distraction from the matching. */}
      {plan?.distinct && plan.distinct.length > 1 && !proposal.options?.length && offersAList(proposal) && (
        <div className="tighten-offer">
          {t('tighten.distinctValues', plan.distinct.length, locale)}
          <button
            type="button"
            className="btn small"
            onClick={() => onUseOptions(plan.distinct!.map((d) => d.value))}
          >
            {t('tighten.makeSelect')}
          </button>
          <div className="samples">
            {plan.distinct.slice(0, 8).map((d) => (
              <span key={d.value} className="sample">
                {d.value} <span className="muted">×{d.count}</span>
              </span>
            ))}
          </div>
        </div>
      )}

      {plan && (
        <div className="tighten-sql">
          <button type="button" className="linkish" onClick={() => setShowSql(!showSql)}>
            {showSql ? t('tighten.hideSql') : t('tighten.showSql')}
          </button>
          {showSql && <pre className="mono">{plan.sql}</pre>}
        </div>
      )}
    </div>
  );
}

/** Whether the decimal mark can change the answer for this proposal. */
function needsDecimal(proposal: Tightening): boolean {
  return proposal.type === 'number' || proposal.type === 'currency';
}

/** Whether a list of the values already there is any use to the proposed type. */
function offersAList(proposal: Tightening): boolean {
  return proposal.type === undefined || proposal.type === 'text' || proposal.type === 'select';
}