Spreadsheet-UX op een echte relationele engine

main

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

4.1 kB Ruw bestand
import { useCallback, useRef, useState } from 'react';
import type { CellValue } from '@wellformed/shared';
import { api } from '../api/client';

/**
 * Undo/redo as inverse operations against the server.
 *
 * There is no local shadow copy of the data to roll back to — the database is
 * the only source of truth — so an undo is a real write, recorded in the audit
 * trail like any other. That is the honest behaviour for a multi-user tool:
 * undoing your change is itself a change other people will see.
 */
export type UndoOp =
  | {
      kind: 'update';
      table: string;
      rowId: string;
      before: Record<string, CellValue>;
      after: Record<string, CellValue>;
      label: string;
    }
  | {
      kind: 'insert';
      table: string;
      rowId: string;
      values: Record<string, CellValue>;
      label: string;
    }
  | {
      kind: 'delete';
      table: string;
      rowId: string;
      restore: Record<string, CellValue>;
      label: string;
    };

const LIMIT = 100;

async function applyInverse(op: UndoOp): Promise<void> {
  switch (op.kind) {
    case 'update':
      await api.updateRow(op.table, op.rowId, op.before);
      return;
    case 'insert':
      await api.deleteRow(op.table, op.rowId);
      return;
    case 'delete':
      await api.createRow(op.table, op.restore, op.rowId);
      return;
  }
}

async function applyForward(op: UndoOp): Promise<void> {
  switch (op.kind) {
    case 'update':
      await api.updateRow(op.table, op.rowId, op.after);
      return;
    case 'insert':
      await api.createRow(op.table, op.values, op.rowId);
      return;
    case 'delete':
      await api.deleteRow(op.table, op.rowId);
      return;
  }
}

export interface UndoStack {
  record(op: UndoOp): void;
  undo(): Promise<UndoOp | null>;
  redo(): Promise<UndoOp | null>;
  canUndo: boolean;
  canRedo: boolean;
  nextUndoLabel: string | null;
  nextRedoLabel: string | null;
  clear(): void;
}

export function useUndoStack(onChanged: () => Promise<void> | void): UndoStack {
  const [undoStack, setUndoStack] = useState<UndoOp[]>([]);
  const [redoStack, setRedoStack] = useState<UndoOp[]>([]);
  const busy = useRef(false);

  const record = useCallback((op: UndoOp) => {
    setUndoStack((s) => [...s, op].slice(-LIMIT));
    // A fresh edit forks history; anything redone from here would conflict.
    setRedoStack([]);
  }, []);

  const undo = useCallback(async () => {
    if (busy.current) return null;
    const op = undoStack[undoStack.length - 1];
    if (!op) return null;

    busy.current = true;
    try {
      await applyInverse(op);
      setUndoStack((s) => s.slice(0, -1));
      setRedoStack((s) => [...s, op].slice(-LIMIT));
      await onChanged();
      return op;
    } finally {
      busy.current = false;
    }
  }, [undoStack, onChanged]);

  const redo = useCallback(async () => {
    if (busy.current) return null;
    const op = redoStack[redoStack.length - 1];
    if (!op) return null;

    busy.current = true;
    try {
      await applyForward(op);
      setRedoStack((s) => s.slice(0, -1));
      setUndoStack((s) => [...s, op].slice(-LIMIT));
      await onChanged();
      return op;
    } finally {
      busy.current = false;
    }
  }, [redoStack, onChanged]);

  const clear = useCallback(() => {
    setUndoStack([]);
    setRedoStack([]);
  }, []);

  return {
    record,
    undo,
    redo,
    canUndo: undoStack.length > 0,
    canRedo: redoStack.length > 0,
    nextUndoLabel: undoStack[undoStack.length - 1]?.label ?? null,
    nextRedoLabel: redoStack[redoStack.length - 1]?.label ?? null,
    clear,
  };
}

/** Which stored columns actually changed, as a before/after pair. */
export function diffValues(
  before: Record<string, CellValue>,
  after: Record<string, CellValue>,
  storedColumns: string[],
): { before: Record<string, CellValue>; after: Record<string, CellValue> } {
  const b: Record<string, CellValue> = {};
  const a: Record<string, CellValue> = {};
  for (const name of storedColumns) {
    const bv = before[name] ?? null;
    const av = after[name] ?? null;
    if (String(bv ?? '') === String(av ?? '')) continue;
    b[name] = bv;
    a[name] = av;
  }
  return { before: b, after: a };
}