Restore editor history after reload

This commit is contained in:
2026-07-25 21:45:58 +02:00
parent c45afd0981
commit 4b47bc2bcc
10 changed files with 275 additions and 423 deletions
+6 -8
View File
@@ -220,19 +220,18 @@ After saving, the row becomes linked to the new project device.
## Undo / Redo ## Undo / Redo
The editor's visible undo/redo stack remains session-local and is not yet The editor reads undo/redo eligibility from the project-wide server history on
reconstructed after reload. All currently supported Circuit and initial load and after every tree reload. Undo/redo therefore remains available
CircuitDeviceRow writes execute persistent project commands; their toolbar after a page refresh or application restart. All currently supported Circuit
undo/redo actions use the project-wide history endpoints. Applying a sorted and CircuitDeviceRow writes execute persistent project commands. Applying a
view across multiple sections is one atomic `circuit.reorder-sections` command sorted view across multiple sections is one atomic `circuit.reorder-sections`
and one undo step. Explicit renumbering uses the collision-safe command and one undo step. Explicit renumbering uses the collision-safe
`circuit.renumber-section` command and is never triggered implicitly. `circuit.renumber-section` command and is never triggered implicitly.
Insertions and generated move targets use client-generated stable UUIDs, and Insertions and generated move targets use client-generated stable UUIDs, and
undo restores the same ids from complete server snapshots. The tree response undo restores the same ids from complete server snapshots. The tree response
supplies the optimistic `currentRevision`; obsolete direct field PATCH, supplies the optimistic `currentRevision`; obsolete direct field PATCH,
structure POST, move, reorder, renumber, identifier-restore, Circuit and structure POST, move, reorder, renumber, identifier-restore, Circuit and
CircuitDeviceRow DELETE routes are removed. 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 CircuitDeviceRow moves between existing circuits and moves that create one new
placeholder target circuit are persisted. The latter stores the complete empty placeholder target circuit are persisted. The latter stores the complete empty
target snapshot so undo can restore the rows and remove only the unchanged 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 ## Current Deferred Work
- complete persistent project command coverage and the undo/redo UI cutover
- named logical snapshots and restore - named logical snapshots and restore
- Revit/CSV/IFCGUID round-trip - Revit/CSV/IFCGUID round-trip
- full electrical sizing - full electrical sizing
+11 -11
View File
@@ -122,14 +122,15 @@ Cross-section device moves show confirmation-required feedback before drop. The
## Undo/Redo ## Undo/Redo
Undo/redo wraps editor operations as command objects and reloads the tree after Editor operations execute persistent project commands and reload the tree after
each command. Existing Circuit and device-row cell edits plus standalone each command. On initial load and every reload, the editor reads undo/redo
insert/delete actions execute persistent project commands; their toolbar eligibility from the project-wide server history and verifies that tree and
undo/redo calls the project-wide server history. New entities receive stable history belong to the same project revision. Undo/redo therefore remains
UUIDs before insertion, and deletion undo restores those same ids. Device-row available after a page refresh. New entities receive stable UUIDs before
moves and circuit reorders also use persistent project commands. Applying a insertion, and deletion undo restores those same ids. Device-row moves and
sorted view across multiple sections is atomic and occupies one history step. circuit reorders also use persistent project commands. Applying a sorted view
Explicit renumbering uses a collision-safe persistent project command and one across multiple sections is atomic and occupies one history step. Explicit
renumbering uses a collision-safe persistent project command and one
project-history step. project-history step.
Covered operations include: Covered operations include:
@@ -141,6 +142,5 @@ Covered operations include:
- renumber and identifier update flows - renumber and identifier update flows
- apply sorted order - apply sorted order
Current limitations: The toolbar currently exposes availability through its Undo/Redo buttons. A
browsable revision timeline and named restore points are separate future work.
- the visible editor stack is still session-local and is empty after reload
@@ -2,11 +2,10 @@
- All currently supported Circuit and CircuitDeviceRow editor writes use - All currently supported Circuit and CircuitDeviceRow editor writes use
persistent project commands and project-wide toolbar undo/redo, including persistent project commands and project-wide toolbar undo/redo, including
moves, atomic multi-section reorders and explicit renumbering. The editor's moves, atomic multi-section reorders and explicit renumbering. Undo/redo
visible stack is still session-local and empty after reload. eligibility is restored from server history after a page reload.
- Server-side project revisions and persistent undo/redo eligibility exist for - There is no browsable revision timeline yet; the editor currently exposes
the command types documented in `circuit-list-editor-api.md`; visible history only the next eligible Undo/Redo actions.
reconstruction is not complete.
- Named logical project snapshots and restore are not implemented yet. - Named logical project snapshots and restore are not implemented yet.
- No Revit/CSV/IFCGUID import and export workflow is 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. - Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
+7 -5
View File
@@ -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 6. Das Frontend lädt den Circuit-Tree neu und stellt Auswahl beziehungsweise
Viewport soweit möglich wieder her. Viewport soweit möglich wieder her.
Der sichtbare React-Historiestapel ist während des schrittweisen Cutovers noch Der Editor besitzt keinen sitzungslokalen Undo-/Redo-Stapel mehr. Beim initialen
sitzungslokal. Das Datenmodell besitzt Laden und nach jedem Tree-Reload liest er den persistenten History-Status und
einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set- 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 Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch
und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen
versionierten, JSON-sicheren Umschlag; Typ und Payload können dadurch nach 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 Serverhistorie. Direkte Feld-PATCH-, Struktur-POST-, Move-, Circuit- und
Gerätezeilen-DELETE-Endpunkte sind entfernt. Gerätezeilen-Moves, Gerätezeilen-DELETE-Endpunkte sind entfernt. Gerätezeilen-Moves,
Stromkreis-Reorders und die explizite Neunummerierung im Grid verwenden die Stromkreis-Reorders und die explizite Neunummerierung im Grid verwenden die
persistenten Kommandos. Der sichtbare Undo-/Redo-Stack wird nach einem Reload persistenten Kommandos. Die Toolbar leitet ihre Undo-/Redo-Verfügbarkeit direkt
noch nicht aus der Serverhistorie rekonstruiert. aus den serverseitigen Stack-Tiefen ab, sodass ein Reload die Bedienbarkeit
nicht unterbricht.
`circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen `circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen
zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue
Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch; Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch;
@@ -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. - 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. - 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 ## Snapshot Storage
@@ -210,6 +212,9 @@ Completed foundation:
- the editor's explicit renumber action uses this command; obsolete direct - the editor's explicit renumber action uses this command; obsolete direct
renumber and identifier-restore routes plus their transaction adapter are renumber and identifier-restore routes plus their transaction adapter are
removed 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 - `project-device.sync-rows` records synchronization, disconnect and reconnect
as one atomic multi-row operation; complete expected/target sync snapshots as one atomic multi-row operation; complete expected/target sync snapshots
protect local values, links and override metadata against silent stale writes 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 ids; delete undo restores links only from complete unchanged disconnected-row
snapshots in the same transaction 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 - add a browsable revision timeline plus named logical snapshots and restore
CircuitDeviceRow operations plus ProjectDevice operations already use
persistent commands and project-wide undo
- extend project-scoped commands only when further project mutations are - extend project-scoped commands only when further project mutations are
deliberately added to history; the generic versioned envelope is complete deliberately added to history; the generic versioned envelope is complete
- do not model IFCGUID as an overloaded circuit equipment identifier - do not model IFCGUID as an overloaded circuit equipment identifier
+5 -4
View File
@@ -358,8 +358,8 @@ Tasks:
- [x] add immutable append-only server-side change-set storage with versioned - [x] add immutable append-only server-side change-set storage with versioned
forward/inverse payloads forward/inverse payloads
- [x] add optimistic checks when appending a revision - [x] add optimistic checks when appending a revision
- route domain writes and history records through one shared transaction - [x] route supported domain writes and history records through one shared transaction
- define concrete serializable domain command descriptions and executors - [x] define concrete serializable domain command descriptions and executors
outside React outside React
- [x] persist undo/redo eligibility on the server - [x] persist undo/redo eligibility on the server
- [x] expose public command, undo and redo endpoints for the first supported - [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 circuit reorders in the circuit-list editor are connected to this history
boundary. Applying sorted order across multiple sections is one atomic command. boundary. Applying sorted order across multiple sections is one atomic command.
Explicit renumbering is connected to the same persistent boundary. The Explicit renumbering is connected to the same persistent boundary. The
editor's visible undo/redo stack remains session-local and is not reconstructed editor reads the persistent undo/redo stack depths on initial load and after
after reload. 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: Acceptance criteria:
+2 -2
View File
@@ -14,8 +14,8 @@
"build:api": "tsc -p tsconfig.json", "build:api": "tsc -p tsconfig.json",
"build:web": "next build", "build:web": "next build",
"start": "node dist/server/index.js", "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": "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-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:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:backup": "tsx scripts/db-backup.ts", "db:backup": "tsx scripts/db-backup.ts",
+66 -370
View File
@@ -36,9 +36,9 @@ import {
import { import {
buildCircuitSectionRenumberAssignments, buildCircuitSectionRenumberAssignments,
} from "../utils/circuit-section-renumber-command"; } from "../utils/circuit-section-renumber-command";
import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history";
import type { import type {
CellKey, CellKey,
CellKind,
ColumnDef, ColumnDef,
RowType, RowType,
} from "../utils/circuit-grid-model"; } from "../utils/circuit-grid-model";
@@ -53,6 +53,7 @@ import {
deleteCircuitDeviceRowCommand, deleteCircuitDeviceRowCommand,
getCircuitTree, getCircuitTree,
getNextCircuitIdentifier, getNextCircuitIdentifier,
getProjectHistory,
insertCircuitCommand, insertCircuitCommand,
insertCircuitDeviceRowCommand, insertCircuitDeviceRowCommand,
listProjectDevices, listProjectDevices,
@@ -73,6 +74,7 @@ import type {
CreateCircuitInputDto, CreateCircuitInputDto,
ProjectCommandResultDto, ProjectCommandResultDto,
ProjectDeviceDto, ProjectDeviceDto,
ProjectHistoryStateDto,
} from "../types"; } from "../types";
import type { import type {
CircuitDeviceRowSnapshot, CircuitDeviceRowSnapshot,
@@ -108,11 +110,6 @@ interface SelectionIntent {
interface HistoryCommand { interface HistoryCommand {
label: string; label: string;
redo: () => Promise<SelectionIntent | null | void>; redo: () => Promise<SelectionIntent | null | void>;
undo: () => Promise<SelectionIntent | null | void>;
persistent?: {
undoIntent: () => SelectionIntent | null;
redoIntent: () => SelectionIntent | null;
};
} }
type ProjectDeviceDropIntent = type ProjectDeviceDropIntent =
@@ -214,10 +211,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [draggingCircuitId, setDraggingCircuitId] = useState<string | null>(null); const [draggingCircuitId, setDraggingCircuitId] = useState<string | null>(null);
const [draggingCircuitIds, setDraggingCircuitIds] = useState<string[]>([]); const [draggingCircuitIds, setDraggingCircuitIds] = useState<string[]>([]);
const [circuitReorderIntent, setCircuitReorderIntent] = useState<CircuitReorderDropIntent | null>(null); const [circuitReorderIntent, setCircuitReorderIntent] = useState<CircuitReorderDropIntent | null>(null);
// The visible stack is session-local during the cutover. Circuit/device field const [historyState, setHistoryState] = useState<ProjectHistoryStateDto | null>(null);
// commands already use the persistent project-wide server history.
const [undoStack, setUndoStack] = useState<HistoryCommand[]>([]);
const [redoStack, setRedoStack] = useState<HistoryCommand[]>([]);
const [historyBusy, setHistoryBusy] = useState(false); const [historyBusy, setHistoryBusy] = useState(false);
const [sortState, setSortState] = useState<{ key: CellKey; direction: SortDirection } | null>(null); const [sortState, setSortState] = useState<{ key: CellKey; direction: SortDirection } | null>(null);
const [columnFilters, setColumnFilters] = useState<Partial<Record<CellKey, string[]>>>({}); const [columnFilters, setColumnFilters] = useState<Partial<Record<CellKey, string[]>>>({});
@@ -255,8 +249,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
setError(null); setError(null);
try { try {
const tree = await getCircuitTree(projectId, circuitListId); const { tree, history } = await loadCircuitEditorSnapshot(
projectRevisionRef.current = tree.currentRevision; () => getCircuitTree(projectId, circuitListId),
() => getProjectHistory(projectId)
);
projectRevisionRef.current = history.currentRevision;
setHistoryState(history);
setData(tree); setData(tree);
} catch (err) { } catch (err) {
setError(normalizeUiError(err)); setError(normalizeUiError(err));
@@ -279,17 +277,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
void loadTree({ showLoading: true }); void loadTree({ showLoading: true });
}, [projectId, circuitListId]); }, [projectId, circuitListId]);
// Loads project-device palette used for sidebar drag/drop and quick inserts.
useEffect(() => {
async function loadProjectDeviceList() { async function loadProjectDeviceList() {
try {
const list = await listProjectDevices(projectId); const list = await listProjectDevices(projectId);
setProjectDevices(list); setProjectDevices(list);
} catch (err) { }
// Loads project-device palette used for sidebar drag/drop and quick inserts.
useEffect(() => {
void loadProjectDeviceList().catch((err) => {
setError(normalizeUiError(err)); setError(normalizeUiError(err));
} });
}
void loadProjectDeviceList();
}, [projectId]); }, [projectId]);
useEffect(() => { useEffect(() => {
@@ -792,6 +789,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
function applyProjectCommandResult(result: ProjectCommandResultDto) { function applyProjectCommandResult(result: ProjectCommandResultDto) {
projectRevisionRef.current = result.history.currentRevision; projectRevisionRef.current = result.history.currentRevision;
setHistoryState(result.history);
setData((current) => setData((current) =>
current current
? { ? {
@@ -886,31 +884,30 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
applyProjectCommandResult(result); 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) { async function runCommand(command: HistoryCommand) {
try { try {
setError(null); setError(null);
setIsSaving(true); setIsSaving(true);
const intent = await command.redo(); const intent = await command.redo();
await reloadWithIntent(intent ?? null); await reloadWithIntent(intent ?? null);
// Any new forward command invalidates redo history branch.
setUndoStack((current) => [...current, command]);
setRedoStack([]);
} catch (err) { } catch (err) {
setError(normalizeUiError(err)); const message = normalizeUiError(err);
await loadTree({ showLoading: false });
setError(message);
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
} }
// Replays command in undo/redo mode. Commands encapsulate their own inverse logic. // Applies the next eligible project-wide history operation. Selection is only
async function applyHistory(command: HistoryCommand, mode: "undo" | "redo") { // a best-effort local hint; command eligibility and data changes stay server-owned.
async function applyHistory(mode: "undo" | "redo") {
try { try {
setError(null); setError(null);
setHistoryBusy(true); setHistoryBusy(true);
setIsSaving(true); setIsSaving(true);
let intent: SelectionIntent | null | void; const intent = selectedCell ? buildSelectionIntent(selectedCell) : null;
if (command.persistent) {
const result = const result =
mode === "undo" mode === "undo"
? await undoProjectCommand( ? await undoProjectCommand(
@@ -922,48 +919,32 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
getExpectedProjectRevision() getExpectedProjectRevision()
); );
applyProjectCommandResult(result); applyProjectCommandResult(result);
intent = await Promise.all([
mode === "undo" reloadWithIntent(intent),
? command.persistent.undoIntent() loadProjectDeviceList(),
: 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]);
}
} catch (err) { } catch (err) {
setError(normalizeUiError(err)); const message = normalizeUiError(err);
await loadTree({ showLoading: false });
setError(message);
} finally { } finally {
setIsSaving(false); setIsSaving(false);
setHistoryBusy(false); setHistoryBusy(false);
} }
} }
// Undo command from session-local history stack.
async function handleUndo() { async function handleUndo() {
if (historyBusy || isSaving || undoStack.length === 0) { if (historyBusy || isSaving || !historyState || historyState.undoDepth === 0) {
return; return;
} }
const command = undoStack[undoStack.length - 1]; await applyHistory("undo");
await applyHistory(command, "undo");
} }
// Redo command from session-local history stack.
async function handleRedo() { async function handleRedo() {
if (historyBusy || isSaving || redoStack.length === 0) { if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) {
return; return;
} }
const command = redoStack[redoStack.length - 1]; await applyHistory("redo");
await applyHistory(command, "redo");
} }
// Persists currently sorted block order into section sortOrder values. // Persists currently sorted block order into section sortOrder values.
@@ -1029,11 +1010,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setOpenFilterColumn(null); setOpenFilterColumn(null);
return 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. // Placeholder rows are not persisted entities; editing them materializes a real circuit first.
if (row.rowType === "placeholder") { if (row.rowType === "placeholder") {
let createdCircuitId: string | null = null;
const command: HistoryCommand = { const command: HistoryCommand = {
label: "Aus freier Zeile erstellen", label: "Aus freier Zeile erstellen",
redo: async () => { redo: async () => {
const selection = await createFromPlaceholder(row.sectionId, editingCell.cellKey, editingCell.draft); const selection = await createFromPlaceholder(row.sectionId, editingCell.cellKey, editingCell.draft);
targetSelection = selection; targetSelection = selection;
const createdRow = selection.rowKey.startsWith("circuitCompact:")
? selection.rowKey.replace("circuitCompact:", "")
: selection.rowKey.replace("reserveCircuit:", "");
createdCircuitId = createdRow;
return nextSelectionIntent(); 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); setEditingCell(null);
await runCommand(command); await runCommand(command);
@@ -1480,19 +1423,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await patchCircuit(row.circuit!.id, editingCell.cellKey, next); await patchCircuit(row.circuit!.id, editingCell.cellKey, next);
return nextSelectionIntent(); return nextSelectionIntent();
}, },
undo: async () =>
buildSelectionIntent({
rowKey: row.rowKey,
cellKey: editingCell.cellKey,
}),
persistent: {
undoIntent: () =>
buildSelectionIntent({
rowKey: row.rowKey,
cellKey: editingCell.cellKey,
}),
redoIntent: nextSelectionIntent,
},
}; };
setEditingCell(null); setEditingCell(null);
await runCommand(command); await runCommand(command);
@@ -1507,19 +1437,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await patchDeviceRow(row.device!.id, editingCell.cellKey, next); await patchDeviceRow(row.device!.id, editingCell.cellKey, next);
return nextSelectionIntent(); 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); setEditingCell(null);
await runCommand(command); await runCommand(command);
@@ -1528,7 +1445,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (cell.kind === "deviceField" && row.rowType === "reserveCircuit" && row.circuit) { if (cell.kind === "deviceField" && row.rowType === "reserveCircuit" && row.circuit) {
const next = editingCell.draft; const next = editingCell.draft;
let createdRowId: string | null = null;
const command: HistoryCommand = { const command: HistoryCommand = {
label: "Gerätezeile im Reservestromkreis erstellen", label: "Gerätezeile im Reservestromkreis erstellen",
redo: async () => { redo: async () => {
@@ -1553,37 +1469,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
snapshot, snapshot,
"Gerätezeile im Reservestromkreis erstellen" "Gerätezeile im Reservestromkreis erstellen"
); );
createdRowId = snapshot.id;
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey }; targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey };
return nextSelectionIntent(); 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); setEditingCell(null);
await runCommand(command); await runCommand(command);
@@ -1605,7 +1493,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (!section) { if (!section) {
return; return;
} }
let createdCircuitId: string | null = null;
await runCommand({ await runCommand({
label: "Stromkreis hinzufügen", label: "Stromkreis hinzufügen",
redo: async () => { redo: async () => {
@@ -1619,7 +1506,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
isReserve: true, isReserve: true,
}); });
await persistCircuitInsert(circuit, "Stromkreis hinzufügen"); await persistCircuitInsert(circuit, "Stromkreis hinzufügen");
createdCircuitId = circuit.id;
setActiveSectionId(sectionId); setActiveSectionId(sectionId);
return { return {
rowKey: `reserveCircuit:${circuit.id}`, rowKey: `reserveCircuit:${circuit.id}`,
@@ -1629,30 +1515,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
circuitId: circuit.id, 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 originalDeviceCount = circuit.deviceRows.length;
const section = data?.sections.find((entry) => entry.id === sectionId); 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({ await runCommand({
label: "Manuelles Gerät hinzufügen", label: "Manuelles Gerät hinzufügen",
redo: async () => { redo: async () => {
@@ -1706,7 +1542,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
rowSnapshot, rowSnapshot,
"Manuelles Gerät hinzufügen" "Manuelles Gerät hinzufügen"
); );
createdRowId = rowSnapshot.id;
setActiveSectionId(sectionId); setActiveSectionId(sectionId);
return { return {
rowKey: originalDeviceCount === 0 ? `circuitCompact:${circuit.id}` : `device:${rowSnapshot.id}`, rowKey: originalDeviceCount === 0 ? `circuitCompact:${circuit.id}` : `device:${rowSnapshot.id}`,
@@ -1717,27 +1552,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
deviceId: rowSnapshot.id, 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)); setError(getProjectDeviceTargetError(device, targetSectionId));
return; return;
} }
let createdCircuitId: string | null = null;
let createdRowId: string | null = null;
await runCommand({ await runCommand({
label: "Projektgerät als neuen Stromkreis hinzufügen", label: "Projektgerät als neuen Stromkreis hinzufügen",
redo: async () => { redo: async () => {
const created = await insertProjectDeviceAsNewCircuit(device, targetSectionId); const created = await insertProjectDeviceAsNewCircuit(device, targetSectionId);
createdCircuitId = created.circuitId;
createdRowId = created.rowId;
return { return {
rowKey: `device:${created.rowId}`, rowKey: `device:${created.rowId}`,
cellKey: "displayName", cellKey: "displayName",
@@ -1833,31 +1643,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
deviceId: created.rowId, 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; return;
} }
let createdRowId: string | null = null;
await runCommand({ await runCommand({
label: "Projektgerät zum Stromkreis hinzufügen", label: "Projektgerät zum Stromkreis hinzufügen",
redo: async () => { redo: async () => {
const created = await insertProjectDeviceToCircuit(device, circuitId); const created = await insertProjectDeviceToCircuit(device, circuitId);
createdRowId = created.rowId;
return { return {
rowKey: `device:${created.rowId}`, rowKey: `device:${created.rowId}`,
cellKey: "displayName", cellKey: "displayName",
@@ -1899,33 +1682,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
deviceId: created.rowId, 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 // Project-device drop logic is isolated from row/circuit move logic because
// it can create new rows/circuits instead of moving existing ones. // it can create new rows/circuits instead of moving existing ones.
if (intent.kind === "new-circuit") { if (intent.kind === "new-circuit") {
let createdCircuitId: string | null = null;
let createdRowId: string | null = null;
await runCommand({ await runCommand({
label: "Projektgerät in neuen Stromkreis ziehen", label: "Projektgerät in neuen Stromkreis ziehen",
redo: async () => { redo: async () => {
const created = await insertProjectDeviceAsNewCircuit(device, intent.sectionId); const created = await insertProjectDeviceAsNewCircuit(device, intent.sectionId);
createdCircuitId = created.circuitId;
createdRowId = created.rowId;
return { return {
rowKey: `device:${created.rowId}`, rowKey: `device:${created.rowId}`,
cellKey: "displayName", cellKey: "displayName",
@@ -2207,30 +1959,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
deviceId: created.rowId, 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; return;
} }
let createdRowId: string | null = null;
await runCommand({ await runCommand({
label: "Projektgerät in Stromkreis ziehen", label: "Projektgerät in Stromkreis ziehen",
redo: async () => { redo: async () => {
const created = await insertProjectDeviceToCircuit(device, intent.circuitId); const created = await insertProjectDeviceToCircuit(device, intent.circuitId);
createdRowId = created.rowId;
return { return {
rowKey: `device:${created.rowId}`, rowKey: `device:${created.rowId}`,
cellKey: "displayName", cellKey: "displayName",
@@ -2240,21 +1975,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
deviceId: created.rowId, 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; pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null; return null;
}, },
undo: async () => null,
persistent: {
undoIntent: () => {
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
},
redoIntent: () => {
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
},
},
}); });
return; return;
} }
@@ -2398,17 +2107,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
pendingSelectedDeviceRowIdsAfterReload.current = rowIds; pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null; 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; pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds;
return selectionIntent; 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; 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"); await persistCircuitDelete(circuitId, "Stromkreis löschen");
return null; return null;
}, },
undo: async () => null,
persistent: {
undoIntent: () => null,
redoIntent: () => null,
},
}); });
} }
@@ -2625,11 +2300,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
applyProjectCommandResult(result); applyProjectCommandResult(result);
return null; return null;
}, },
undo: async () => null,
persistent: {
undoIntent: () => null,
redoIntent: () => null,
},
}); });
} }
@@ -2755,10 +2425,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return ( return (
<div className="tree-editor-shell"> <div className="tree-editor-shell">
<div className="editor-toolbar"> <div className="editor-toolbar">
<button type="button" onClick={() => void handleUndo()} disabled={undoStack.length === 0 || historyBusy || isSaving}> <button
type="button"
onClick={() => void handleUndo()}
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
>
Rückgängig Rückgängig
</button> </button>
<button type="button" onClick={() => void handleRedo()} disabled={redoStack.length === 0 || historyBusy || isSaving}> <button
type="button"
onClick={() => void handleRedo()}
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
>
Wiederholen Wiederholen
</button> </button>
<button <button
@@ -2822,10 +2500,28 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return ( return (
<div className="tree-editor-shell"> <div className="tree-editor-shell">
<div className="editor-toolbar"> <div className="editor-toolbar">
<button type="button" onClick={() => void handleUndo()} disabled={undoStack.length === 0 || historyBusy || isSaving}> <button
type="button"
onClick={() => void handleUndo()}
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
title={
historyState?.undoDepth
? `${historyState.undoDepth} Änderung(en) können rückgängig gemacht werden.`
: undefined
}
>
Rückgängig Rückgängig
</button> </button>
<button type="button" onClick={() => void handleRedo()} disabled={redoStack.length === 0 || historyBusy || isSaving}> <button
type="button"
onClick={() => void handleRedo()}
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
title={
historyState?.redoDepth
? `${historyState.redoDepth} Änderung(en) können wiederholt werden.`
: undefined
}
>
Wiederholen Wiederholen
</button> </button>
{isSortedView ? ( {isSortedView ? (
@@ -0,0 +1,27 @@
import type {
CircuitTreeResponseDto,
ProjectHistoryStateDto,
} from "../types";
export interface CircuitEditorSnapshot {
tree: CircuitTreeResponseDto;
history: ProjectHistoryStateDto;
}
export async function loadCircuitEditorSnapshot(
readTree: () => Promise<CircuitTreeResponseDto>,
readHistory: () => Promise<ProjectHistoryStateDto>,
maxAttempts = 3
): Promise<CircuitEditorSnapshot> {
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."
);
}
+126
View File
@@ -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 },
},
]);
});
});