Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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 };
}