From 4b47bc2bcc7be9fb8c1c31a24d22f40e15dd87d1 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sat, 25 Jul 2026 21:45:58 +0200 Subject: [PATCH] Restore editor history after reload --- AGENTS.md | 14 +- docs/circuit-list-editor-interactions.md | 22 +- docs/circuit-list-editor-known-limitations.md | 9 +- docs/current-architecture.md | 12 +- ...history-and-external-model-architecture.md | 13 +- docs/spec/07-implementation-phases-todo.md | 9 +- package.json | 4 +- .../components/circuit-tree-editor.tsx | 462 +++--------------- src/frontend/utils/circuit-editor-history.ts | 27 + tests/circuit-editor-history.test.ts | 126 +++++ 10 files changed, 275 insertions(+), 423 deletions(-) create mode 100644 src/frontend/utils/circuit-editor-history.ts create mode 100644 tests/circuit-editor-history.test.ts diff --git a/AGENTS.md b/AGENTS.md index 4f291c5..9c6726e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,19 +220,18 @@ After saving, the row becomes linked to the new project device. ## Undo / Redo -The editor's visible undo/redo stack remains session-local and is not yet -reconstructed after reload. All currently supported Circuit and -CircuitDeviceRow writes execute persistent project commands; their toolbar -undo/redo actions use the project-wide history endpoints. Applying a sorted -view across multiple sections is one atomic `circuit.reorder-sections` command -and one undo step. Explicit renumbering uses the collision-safe +The editor reads undo/redo eligibility from the project-wide server history on +initial load and after every tree reload. Undo/redo therefore remains available +after a page refresh or application restart. All currently supported Circuit +and CircuitDeviceRow writes execute persistent project commands. Applying a +sorted view across multiple sections is one atomic `circuit.reorder-sections` +command and one undo step. Explicit renumbering uses the collision-safe `circuit.renumber-section` command and is never triggered implicitly. Insertions and generated move targets use client-generated stable UUIDs, and undo restores the same ids from complete server snapshots. The tree response supplies the optimistic `currentRevision`; obsolete direct field PATCH, structure POST, move, reorder, renumber, identifier-restore, Circuit and CircuitDeviceRow DELETE routes are removed. -Reloading the page does not yet reconstruct the visible editor stack. CircuitDeviceRow moves between existing circuits and moves that create one new placeholder target circuit are persisted. The latter stores the complete empty target snapshot so undo can restore the rows and remove only the unchanged @@ -299,7 +298,6 @@ Users must be able to override sizing suggestions. ## Current Deferred Work -- complete persistent project command coverage and the undo/redo UI cutover - named logical snapshots and restore - Revit/CSV/IFCGUID round-trip - full electrical sizing diff --git a/docs/circuit-list-editor-interactions.md b/docs/circuit-list-editor-interactions.md index a944636..5adc7d9 100644 --- a/docs/circuit-list-editor-interactions.md +++ b/docs/circuit-list-editor-interactions.md @@ -122,14 +122,15 @@ Cross-section device moves show confirmation-required feedback before drop. The ## Undo/Redo -Undo/redo wraps editor operations as command objects and reloads the tree after -each command. Existing Circuit and device-row cell edits plus standalone -insert/delete actions execute persistent project commands; their toolbar -undo/redo calls the project-wide server history. New entities receive stable -UUIDs before insertion, and deletion undo restores those same ids. Device-row -moves and circuit reorders also use persistent project commands. Applying a -sorted view across multiple sections is atomic and occupies one history step. -Explicit renumbering uses a collision-safe persistent project command and one +Editor operations execute persistent project commands and reload the tree after +each command. On initial load and every reload, the editor reads undo/redo +eligibility from the project-wide server history and verifies that tree and +history belong to the same project revision. Undo/redo therefore remains +available after a page refresh. New entities receive stable UUIDs before +insertion, and deletion undo restores those same ids. Device-row moves and +circuit reorders also use persistent project commands. Applying a sorted view +across multiple sections is atomic and occupies one history step. Explicit +renumbering uses a collision-safe persistent project command and one project-history step. Covered operations include: @@ -141,6 +142,5 @@ Covered operations include: - renumber and identifier update flows - apply sorted order -Current limitations: - -- the visible editor stack is still session-local and is empty after reload +The toolbar currently exposes availability through its Undo/Redo buttons. A +browsable revision timeline and named restore points are separate future work. diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 56989d2..4dc3c12 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -2,11 +2,10 @@ - All currently supported Circuit and CircuitDeviceRow editor writes use persistent project commands and project-wide toolbar undo/redo, including - moves, atomic multi-section reorders and explicit renumbering. The editor's - visible stack is still session-local and empty after reload. -- Server-side project revisions and persistent undo/redo eligibility exist for - the command types documented in `circuit-list-editor-api.md`; visible history - reconstruction is not complete. + moves, atomic multi-section reorders and explicit renumbering. Undo/redo + eligibility is restored from server history after a page reload. +- There is no browsable revision timeline yet; the editor currently exposes + only the next eligible Undo/Redo actions. - Named logical project snapshots and restore are not implemented yet. - No Revit/CSV/IFCGUID import and export workflow is implemented yet. - Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index f6f7e06..1db4dcf 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -62,9 +62,10 @@ liegt über einen Host-Mount außerhalb des Containers. 6. Das Frontend lädt den Circuit-Tree neu und stellt Auswahl beziehungsweise Viewport soweit möglich wieder her. -Der sichtbare React-Historiestapel ist während des schrittweisen Cutovers noch -sitzungslokal. Das Datenmodell besitzt -einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set- +Der Editor besitzt keinen sitzungslokalen Undo-/Redo-Stapel mehr. Beim initialen +Laden und nach jedem Tree-Reload liest er den persistenten History-Status und +gleicht dessen Revision mit `currentRevision` des Trees ab. Das Datenmodell +besitzt einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set- Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach @@ -94,8 +95,9 @@ dazu `currentRevision`; Undo/Redo für diese Aktionen läuft über die projektwe Serverhistorie. Direkte Feld-PATCH-, Struktur-POST-, Move-, Circuit- und Gerätezeilen-DELETE-Endpunkte sind entfernt. Gerätezeilen-Moves, Stromkreis-Reorders und die explizite Neunummerierung im Grid verwenden die -persistenten Kommandos. Der sichtbare Undo-/Redo-Stack wird nach einem Reload -noch nicht aus der Serverhistorie rekonstruiert. +persistenten Kommandos. Die Toolbar leitet ihre Undo-/Redo-Verfügbarkeit direkt +aus den serverseitigen Stack-Tiefen ab, sodass ein Reload die Bedienbarkeit +nicht unterbricht. `circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch; diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index 7acc92f..71e856e 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -60,7 +60,9 @@ Full event sourcing is not required. Current normalized tables remain the primar - After a page reload, the client requests the current revision and eligible history actions. - Commands include an expected project revision. A stale command is rejected instead of silently overwriting a newer project state. -The current session-local frontend command stack is transitional. Editor modularization must separate command descriptions from React callbacks so commands can later be sent to and reconstructed by the server. +The circuit-list editor uses the server state directly for operation +eligibility. React callbacks only initiate forward commands and provide +best-effort selection hints after the tree reload. ## Snapshot Storage @@ -210,6 +212,9 @@ Completed foundation: - the editor's explicit renumber action uses this command; obsolete direct renumber and identifier-restore routes plus their transaction adapter are removed +- the circuit-list editor loads project-wide undo/redo eligibility together + with the tree, retries revision mismatches and keeps Undo/Redo available after + page reloads without a session-local command stack - `project-device.sync-rows` records synchronization, disconnect and reconnect as one atomic multi-row operation; complete expected/target sync snapshots protect local values, links and override metadata against silent stale writes @@ -219,11 +224,9 @@ Completed foundation: ids; delete undo restores links only from complete unchanged disconnected-row snapshots in the same transaction -Remaining constraints before completing persistent history: +Remaining constraints before completing project version history: -- reconstruct the visible frontend history state after reload; Circuit and - CircuitDeviceRow operations plus ProjectDevice operations already use - persistent commands and project-wide undo +- add a browsable revision timeline plus named logical snapshots and restore - extend project-scoped commands only when further project mutations are deliberately added to history; the generic versioned envelope is complete - do not model IFCGUID as an overloaded circuit equipment identifier diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index baa4c2c..f2bbf13 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -358,8 +358,8 @@ Tasks: - [x] add immutable append-only server-side change-set storage with versioned forward/inverse payloads - [x] add optimistic checks when appending a revision -- route domain writes and history records through one shared transaction -- define concrete serializable domain command descriptions and executors +- [x] route supported domain writes and history records through one shared transaction +- [x] define concrete serializable domain command descriptions and executors outside React - [x] persist undo/redo eligibility on the server - [x] expose public command, undo and redo endpoints for the first supported @@ -435,8 +435,9 @@ Circuit/CircuitDeviceRow field/insert/delete actions, device-row moves and circuit reorders in the circuit-list editor are connected to this history boundary. Applying sorted order across multiple sections is one atomic command. Explicit renumbering is connected to the same persistent boundary. The -editor's visible undo/redo stack remains session-local and is not reconstructed -after reload. +editor reads the persistent undo/redo stack depths on initial load and after +every tree reload. Tree and history revisions are reconciled before the state +is exposed, so the toolbar remains usable after a page refresh. Acceptance criteria: diff --git a/package.json b/package.json index b443feb..8abf881 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "start": "node dist/server/index.js", - "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", - "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 28fc29b..92eeae5 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -36,9 +36,9 @@ import { import { buildCircuitSectionRenumberAssignments, } from "../utils/circuit-section-renumber-command"; +import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history"; import type { CellKey, - CellKind, ColumnDef, RowType, } from "../utils/circuit-grid-model"; @@ -53,6 +53,7 @@ import { deleteCircuitDeviceRowCommand, getCircuitTree, getNextCircuitIdentifier, + getProjectHistory, insertCircuitCommand, insertCircuitDeviceRowCommand, listProjectDevices, @@ -73,6 +74,7 @@ import type { CreateCircuitInputDto, ProjectCommandResultDto, ProjectDeviceDto, + ProjectHistoryStateDto, } from "../types"; import type { CircuitDeviceRowSnapshot, @@ -108,11 +110,6 @@ interface SelectionIntent { interface HistoryCommand { label: string; redo: () => Promise; - undo: () => Promise; - persistent?: { - undoIntent: () => SelectionIntent | null; - redoIntent: () => SelectionIntent | null; - }; } type ProjectDeviceDropIntent = @@ -214,10 +211,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const [draggingCircuitId, setDraggingCircuitId] = useState(null); const [draggingCircuitIds, setDraggingCircuitIds] = useState([]); const [circuitReorderIntent, setCircuitReorderIntent] = useState(null); - // The visible stack is session-local during the cutover. Circuit/device field - // commands already use the persistent project-wide server history. - const [undoStack, setUndoStack] = useState([]); - const [redoStack, setRedoStack] = useState([]); + const [historyState, setHistoryState] = useState(null); const [historyBusy, setHistoryBusy] = useState(false); const [sortState, setSortState] = useState<{ key: CellKey; direction: SortDirection } | null>(null); const [columnFilters, setColumnFilters] = useState>>({}); @@ -255,8 +249,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } setError(null); try { - const tree = await getCircuitTree(projectId, circuitListId); - projectRevisionRef.current = tree.currentRevision; + const { tree, history } = await loadCircuitEditorSnapshot( + () => getCircuitTree(projectId, circuitListId), + () => getProjectHistory(projectId) + ); + projectRevisionRef.current = history.currentRevision; + setHistoryState(history); setData(tree); } catch (err) { setError(normalizeUiError(err)); @@ -279,17 +277,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str void loadTree({ showLoading: true }); }, [projectId, circuitListId]); + async function loadProjectDeviceList() { + const list = await listProjectDevices(projectId); + setProjectDevices(list); + } + // Loads project-device palette used for sidebar drag/drop and quick inserts. useEffect(() => { - async function loadProjectDeviceList() { - try { - const list = await listProjectDevices(projectId); - setProjectDevices(list); - } catch (err) { - setError(normalizeUiError(err)); - } - } - void loadProjectDeviceList(); + void loadProjectDeviceList().catch((err) => { + setError(normalizeUiError(err)); + }); }, [projectId]); useEffect(() => { @@ -792,6 +789,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str function applyProjectCommandResult(result: ProjectCommandResultDto) { projectRevisionRef.current = result.history.currentRevision; + setHistoryState(result.history); setData((current) => current ? { @@ -886,84 +884,67 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str applyProjectCommandResult(result); } - // Runs a normal command and records it in session-local history. + // Runs a normal command. The server records it in project-wide history. async function runCommand(command: HistoryCommand) { try { setError(null); setIsSaving(true); const intent = await command.redo(); await reloadWithIntent(intent ?? null); - // Any new forward command invalidates redo history branch. - setUndoStack((current) => [...current, command]); - setRedoStack([]); } catch (err) { - setError(normalizeUiError(err)); + const message = normalizeUiError(err); + await loadTree({ showLoading: false }); + setError(message); } finally { setIsSaving(false); } } - // Replays command in undo/redo mode. Commands encapsulate their own inverse logic. - async function applyHistory(command: HistoryCommand, mode: "undo" | "redo") { + // Applies the next eligible project-wide history operation. Selection is only + // a best-effort local hint; command eligibility and data changes stay server-owned. + async function applyHistory(mode: "undo" | "redo") { try { setError(null); setHistoryBusy(true); setIsSaving(true); - let intent: SelectionIntent | null | void; - if (command.persistent) { - const result = - mode === "undo" - ? await undoProjectCommand( - projectId, - getExpectedProjectRevision() - ) - : await redoProjectCommand( - projectId, - getExpectedProjectRevision() - ); - applyProjectCommandResult(result); - intent = - mode === "undo" - ? command.persistent.undoIntent() - : command.persistent.redoIntent(); - } else { - intent = - mode === "undo" - ? await command.undo() - : await command.redo(); - } - await reloadWithIntent(intent ?? null); - if (mode === "undo") { - setUndoStack((current) => current.slice(0, -1)); - setRedoStack((current) => [...current, command]); - } else { - setRedoStack((current) => current.slice(0, -1)); - setUndoStack((current) => [...current, command]); - } + const intent = selectedCell ? buildSelectionIntent(selectedCell) : null; + const result = + mode === "undo" + ? await undoProjectCommand( + projectId, + getExpectedProjectRevision() + ) + : await redoProjectCommand( + projectId, + getExpectedProjectRevision() + ); + applyProjectCommandResult(result); + await Promise.all([ + reloadWithIntent(intent), + loadProjectDeviceList(), + ]); } catch (err) { - setError(normalizeUiError(err)); + const message = normalizeUiError(err); + await loadTree({ showLoading: false }); + setError(message); } finally { setIsSaving(false); setHistoryBusy(false); } } - // Undo command from session-local history stack. async function handleUndo() { - if (historyBusy || isSaving || undoStack.length === 0) { + if (historyBusy || isSaving || !historyState || historyState.undoDepth === 0) { return; } - const command = undoStack[undoStack.length - 1]; - await applyHistory(command, "undo"); + await applyHistory("undo"); } - // Redo command from session-local history stack. async function handleRedo() { - if (historyBusy || isSaving || redoStack.length === 0) { + if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) { return; } - const command = redoStack[redoStack.length - 1]; - await applyHistory(command, "redo"); + await applyHistory("redo"); } // Persists currently sorted block order into section sortOrder values. @@ -1029,11 +1010,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setOpenFilterColumn(null); return null; }, - undo: async () => null, - persistent: { - undoIntent: () => null, - redoIntent: () => null, - }, }); } @@ -1426,46 +1402,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str // Placeholder rows are not persisted entities; editing them materializes a real circuit first. if (row.rowType === "placeholder") { - let createdCircuitId: string | null = null; const command: HistoryCommand = { label: "Aus freier Zeile erstellen", redo: async () => { const selection = await createFromPlaceholder(row.sectionId, editingCell.cellKey, editingCell.draft); targetSelection = selection; - const createdRow = selection.rowKey.startsWith("circuitCompact:") - ? selection.rowKey.replace("circuitCompact:", "") - : selection.rowKey.replace("reserveCircuit:", ""); - createdCircuitId = createdRow; return nextSelectionIntent(); }, - undo: async () => { - return { - rowKey: `placeholder:${row.sectionId}`, - cellKey: editingCell.cellKey, - rowType: "placeholder", - sectionId: row.sectionId, - }; - }, - persistent: { - undoIntent: () => ({ - rowKey: `placeholder:${row.sectionId}`, - cellKey: editingCell.cellKey, - rowType: "placeholder", - sectionId: row.sectionId, - }), - redoIntent: () => - createdCircuitId - ? { - rowKey: targetSelection.rowKey, - cellKey: editingCell.cellKey, - rowType: targetSelection.rowKey.startsWith("circuitCompact:") - ? "circuitCompact" - : "reserveCircuit", - sectionId: row.sectionId, - circuitId: createdCircuitId, - } - : null, - }, }; setEditingCell(null); await runCommand(command); @@ -1480,19 +1423,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await patchCircuit(row.circuit!.id, editingCell.cellKey, next); return nextSelectionIntent(); }, - undo: async () => - buildSelectionIntent({ - rowKey: row.rowKey, - cellKey: editingCell.cellKey, - }), - persistent: { - undoIntent: () => - buildSelectionIntent({ - rowKey: row.rowKey, - cellKey: editingCell.cellKey, - }), - redoIntent: nextSelectionIntent, - }, }; setEditingCell(null); await runCommand(command); @@ -1507,19 +1437,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await patchDeviceRow(row.device!.id, editingCell.cellKey, next); return nextSelectionIntent(); }, - undo: async () => - buildSelectionIntent({ - rowKey: `device:${row.device!.id}`, - cellKey: editingCell.cellKey, - }), - persistent: { - undoIntent: () => - buildSelectionIntent({ - rowKey: `device:${row.device!.id}`, - cellKey: editingCell.cellKey, - }), - redoIntent: nextSelectionIntent, - }, }; setEditingCell(null); await runCommand(command); @@ -1528,7 +1445,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str if (cell.kind === "deviceField" && row.rowType === "reserveCircuit" && row.circuit) { const next = editingCell.draft; - let createdRowId: string | null = null; const command: HistoryCommand = { label: "Gerätezeile im Reservestromkreis erstellen", redo: async () => { @@ -1553,37 +1469,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str snapshot, "Gerätezeile im Reservestromkreis erstellen" ); - createdRowId = snapshot.id; targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey }; return nextSelectionIntent(); }, - undo: async () => ({ - rowKey: `reserveCircuit:${row.circuit!.id}`, - cellKey: editingCell.cellKey, - rowType: "reserveCircuit", - sectionId: row.sectionId, - circuitId: row.circuit!.id, - }), - persistent: { - undoIntent: () => ({ - rowKey: `reserveCircuit:${row.circuit!.id}`, - cellKey: editingCell.cellKey, - rowType: "reserveCircuit", - sectionId: row.sectionId, - circuitId: row.circuit!.id, - }), - redoIntent: () => - createdRowId - ? { - rowKey: `circuitCompact:${row.circuit!.id}`, - cellKey: editingCell.cellKey, - rowType: "circuitCompact", - sectionId: row.sectionId, - circuitId: row.circuit!.id, - deviceId: createdRowId, - } - : null, - }, }; setEditingCell(null); await runCommand(command); @@ -1605,7 +1493,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str if (!section) { return; } - let createdCircuitId: string | null = null; await runCommand({ label: "Stromkreis hinzufügen", redo: async () => { @@ -1619,7 +1506,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str isReserve: true, }); await persistCircuitInsert(circuit, "Stromkreis hinzufügen"); - createdCircuitId = circuit.id; setActiveSectionId(sectionId); return { rowKey: `reserveCircuit:${circuit.id}`, @@ -1629,30 +1515,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str circuitId: circuit.id, }; }, - undo: async () => ({ - rowKey: `placeholder:${sectionId}`, - cellKey: "equipmentIdentifier", - rowType: "placeholder", - sectionId, - }), - persistent: { - undoIntent: () => ({ - rowKey: `placeholder:${sectionId}`, - cellKey: "equipmentIdentifier", - rowType: "placeholder", - sectionId, - }), - redoIntent: () => - createdCircuitId - ? { - rowKey: `reserveCircuit:${createdCircuitId}`, - cellKey: "equipmentIdentifier", - rowType: "reserveCircuit", - sectionId, - circuitId: createdCircuitId, - } - : null, - }, }); } @@ -1663,32 +1525,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str ) { const originalDeviceCount = circuit.deviceRows.length; const section = data?.sections.find((entry) => entry.id === sectionId); - let createdRowId: string | null = null; - const getUndoIntent = (): SelectionIntent => { - const rowKey = - originalDeviceCount === 0 - ? `reserveCircuit:${circuit.id}` - : originalDeviceCount === 1 - ? `circuitCompact:${circuit.id}` - : afterDeviceRowId - ? `device:${afterDeviceRowId}` - : `circuitSummary:${circuit.id}`; - return { - rowKey, - cellKey: "displayName", - rowType: - originalDeviceCount === 0 - ? "reserveCircuit" - : originalDeviceCount === 1 - ? "circuitCompact" - : afterDeviceRowId - ? "deviceRow" - : "circuitSummary", - sectionId, - circuitId: circuit.id, - deviceId: afterDeviceRowId, - }; - }; await runCommand({ label: "Manuelles Gerät hinzufügen", redo: async () => { @@ -1706,7 +1542,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str rowSnapshot, "Manuelles Gerät hinzufügen" ); - createdRowId = rowSnapshot.id; setActiveSectionId(sectionId); return { rowKey: originalDeviceCount === 0 ? `circuitCompact:${circuit.id}` : `device:${rowSnapshot.id}`, @@ -1717,27 +1552,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str deviceId: rowSnapshot.id, }; }, - undo: async () => getUndoIntent(), - persistent: { - undoIntent: getUndoIntent, - redoIntent: () => - createdRowId - ? { - rowKey: - originalDeviceCount === 0 - ? `circuitCompact:${circuit.id}` - : `device:${createdRowId}`, - cellKey: "displayName", - rowType: - originalDeviceCount === 0 - ? "circuitCompact" - : "deviceRow", - sectionId, - circuitId: circuit.id, - deviceId: createdRowId, - } - : null, - }, }); } @@ -1816,14 +1630,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setError(getProjectDeviceTargetError(device, targetSectionId)); return; } - let createdCircuitId: string | null = null; - let createdRowId: string | null = null; await runCommand({ label: "Projektgerät als neuen Stromkreis hinzufügen", redo: async () => { const created = await insertProjectDeviceAsNewCircuit(device, targetSectionId); - createdCircuitId = created.circuitId; - createdRowId = created.rowId; return { rowKey: `device:${created.rowId}`, cellKey: "displayName", @@ -1833,31 +1643,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str deviceId: created.rowId, }; }, - undo: async () => ({ - rowKey: `placeholder:${targetSectionId}`, - cellKey: "displayName", - rowType: "placeholder", - sectionId: targetSectionId, - }), - persistent: { - undoIntent: () => ({ - rowKey: `placeholder:${targetSectionId}`, - cellKey: "displayName", - rowType: "placeholder", - sectionId: targetSectionId, - }), - redoIntent: () => - createdCircuitId && createdRowId - ? { - rowKey: `device:${createdRowId}`, - cellKey: "displayName", - rowType: "deviceRow", - sectionId: targetSectionId, - circuitId: createdCircuitId, - deviceId: createdRowId, - } - : null, - }, }); } @@ -1884,12 +1669,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return; } - let createdRowId: string | null = null; await runCommand({ label: "Projektgerät zum Stromkreis hinzufügen", redo: async () => { const created = await insertProjectDeviceToCircuit(device, circuitId); - createdRowId = created.rowId; return { rowKey: `device:${created.rowId}`, cellKey: "displayName", @@ -1899,33 +1682,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str deviceId: created.rowId, }; }, - undo: async () => ({ - rowKey: `circuitSummary:${circuitId}`, - cellKey: "displayName", - rowType: "circuitSummary", - sectionId, - circuitId, - }), - persistent: { - undoIntent: () => ({ - rowKey: `circuitSummary:${circuitId}`, - cellKey: "displayName", - rowType: "circuitSummary", - sectionId, - circuitId, - }), - redoIntent: () => - createdRowId - ? { - rowKey: `device:${createdRowId}`, - cellKey: "displayName", - rowType: "deviceRow", - sectionId, - circuitId, - deviceId: createdRowId, - } - : null, - }, }); } @@ -2190,14 +1946,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str // Project-device drop logic is isolated from row/circuit move logic because // it can create new rows/circuits instead of moving existing ones. if (intent.kind === "new-circuit") { - let createdCircuitId: string | null = null; - let createdRowId: string | null = null; await runCommand({ label: "Projektgerät in neuen Stromkreis ziehen", redo: async () => { const created = await insertProjectDeviceAsNewCircuit(device, intent.sectionId); - createdCircuitId = created.circuitId; - createdRowId = created.rowId; return { rowKey: `device:${created.rowId}`, cellKey: "displayName", @@ -2207,30 +1959,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str deviceId: created.rowId, }; }, - undo: async () => null, - persistent: { - undoIntent: () => null, - redoIntent: () => - createdCircuitId && createdRowId - ? { - rowKey: `device:${createdRowId}`, - cellKey: "displayName", - rowType: "deviceRow", - sectionId: intent.sectionId, - circuitId: createdCircuitId, - deviceId: createdRowId, - } - : null, - }, }); return; } - let createdRowId: string | null = null; await runCommand({ label: "Projektgerät in Stromkreis ziehen", redo: async () => { const created = await insertProjectDeviceToCircuit(device, intent.circuitId); - createdRowId = created.rowId; return { rowKey: `device:${created.rowId}`, cellKey: "displayName", @@ -2240,21 +1975,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str deviceId: created.rowId, }; }, - undo: async () => null, - persistent: { - undoIntent: () => null, - redoIntent: () => - createdRowId - ? { - rowKey: `device:${createdRowId}`, - cellKey: "displayName", - rowType: "deviceRow", - sectionId: intent.sectionId, - circuitId: intent.circuitId, - deviceId: createdRowId, - } - : null, - }, }); } @@ -2335,17 +2055,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str pendingSelectedDeviceRowIdsAfterReload.current = rowIds; return null; }, - undo: async () => null, - persistent: { - undoIntent: () => { - pendingSelectedDeviceRowIdsAfterReload.current = rowIds; - return null; - }, - redoIntent: () => { - pendingSelectedDeviceRowIdsAfterReload.current = rowIds; - return null; - }, - }, }); return; } @@ -2398,17 +2107,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str pendingSelectedDeviceRowIdsAfterReload.current = rowIds; return null; }, - undo: async () => null, - persistent: { - undoIntent: () => { - pendingSelectedDeviceRowIdsAfterReload.current = rowIds; - return null; - }, - redoIntent: () => { - pendingSelectedDeviceRowIdsAfterReload.current = rowIds; - return null; - }, - }, }); } @@ -2477,19 +2175,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds; return selectionIntent; }, - undo: async () => null, - persistent: { - undoIntent: () => { - pendingSelectedCircuitIdsAfterReload.current = - sourceCircuitIds; - return selectionIntent; - }, - redoIntent: () => { - pendingSelectedCircuitIdsAfterReload.current = - sourceCircuitIds; - return selectionIntent; - }, - }, }); } @@ -2533,11 +2218,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str ); return null; }, - undo: async () => null, - persistent: { - undoIntent: () => null, - redoIntent: () => null, - }, }); } @@ -2563,11 +2243,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await persistCircuitDelete(circuitId, "Stromkreis löschen"); return null; }, - undo: async () => null, - persistent: { - undoIntent: () => null, - redoIntent: () => null, - }, }); } @@ -2625,11 +2300,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str applyProjectCommandResult(result); return null; }, - undo: async () => null, - persistent: { - undoIntent: () => null, - redoIntent: () => null, - }, }); } @@ -2755,10 +2425,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return (
- - - {isSortedView ? ( diff --git a/src/frontend/utils/circuit-editor-history.ts b/src/frontend/utils/circuit-editor-history.ts new file mode 100644 index 0000000..9689977 --- /dev/null +++ b/src/frontend/utils/circuit-editor-history.ts @@ -0,0 +1,27 @@ +import type { + CircuitTreeResponseDto, + ProjectHistoryStateDto, +} from "../types"; + +export interface CircuitEditorSnapshot { + tree: CircuitTreeResponseDto; + history: ProjectHistoryStateDto; +} + +export async function loadCircuitEditorSnapshot( + readTree: () => Promise, + readHistory: () => Promise, + maxAttempts = 3 +): Promise { + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const tree = await readTree(); + const history = await readHistory(); + if (tree.currentRevision === history.currentRevision) { + return { tree, history }; + } + } + + throw new Error( + "Der Projektstand hat sich während des Ladens geändert. Bitte erneut versuchen." + ); +} diff --git a/tests/circuit-editor-history.test.ts b/tests/circuit-editor-history.test.ts new file mode 100644 index 0000000..616333c --- /dev/null +++ b/tests/circuit-editor-history.test.ts @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { loadCircuitEditorSnapshot } from "../src/frontend/utils/circuit-editor-history.js"; +import { + getProjectHistory, + redoProjectCommand, + undoProjectCommand, +} from "../src/frontend/utils/api.js"; +import type { + CircuitTreeResponseDto, + ProjectHistoryStateDto, +} from "../src/frontend/types.js"; + +function tree(currentRevision: number): CircuitTreeResponseDto { + return { + id: "list-1", + projectId: "project-1", + distributionBoardId: "board-1", + name: "Liste", + currentRevision, + sections: [], + }; +} + +function history(currentRevision: number): ProjectHistoryStateDto { + return { + projectId: "project-1", + currentRevision, + undoDepth: currentRevision, + redoDepth: 0, + undoChangeSetId: currentRevision > 0 ? `change-${currentRevision}` : null, + redoChangeSetId: null, + }; +} + +describe("circuit editor history snapshot", () => { + it("returns tree and persistent history from the same project revision", async () => { + const snapshot = await loadCircuitEditorSnapshot( + async () => tree(4), + async () => history(4) + ); + + assert.equal(snapshot.tree.currentRevision, 4); + assert.equal(snapshot.history.undoDepth, 4); + }); + + it("retries when the project changes between the tree and history reads", async () => { + const treeRevisions = [4, 5]; + const historyRevisions = [5, 5]; + let attempts = 0; + + const snapshot = await loadCircuitEditorSnapshot( + async () => tree(treeRevisions[attempts]), + async () => history(historyRevisions[attempts++]) + ); + + assert.equal(attempts, 2); + assert.equal(snapshot.tree.currentRevision, 5); + assert.equal(snapshot.history.currentRevision, 5); + }); + + it("rejects a snapshot that remains inconsistent", async () => { + await assert.rejects( + () => + loadCircuitEditorSnapshot( + async () => tree(4), + async () => history(5), + 2 + ), + /während des Ladens geändert/ + ); + }); + + it("loads history and sends revision-safe undo/redo requests", async () => { + const requests: Array<{ url: string; body?: unknown }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + const url = String(input); + requests.push({ + url, + ...(init?.body + ? { body: JSON.parse(String(init.body)) as unknown } + : {}), + }); + const currentRevision = requests.length - 1; + const state = history(currentRevision); + return new Response( + JSON.stringify( + init?.method === "POST" + ? { + revision: { + revisionId: `revision-${currentRevision}`, + changeSetId: `change-${currentRevision}`, + projectId: "project-1", + revisionNumber: currentRevision, + createdAtIso: "2026-07-25T00:00:00.000Z", + }, + history: state, + } + : state + ), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }; + + try { + await getProjectHistory("project-1"); + await undoProjectCommand("project-1", 4); + await redoProjectCommand("project-1", 5); + } finally { + globalThis.fetch = originalFetch; + } + + assert.deepEqual(requests, [ + { url: "/api/projects/project-1/history" }, + { + url: "/api/projects/project-1/history/undo", + body: { expectedRevision: 4 }, + }, + { + url: "/api/projects/project-1/history/redo", + body: { expectedRevision: 5 }, + }, + ]); + }); +});