Restore editor history after reload
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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<SelectionIntent | null | void>;
|
||||
undo: () => Promise<SelectionIntent | null | void>;
|
||||
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<string | null>(null);
|
||||
const [draggingCircuitIds, setDraggingCircuitIds] = useState<string[]>([]);
|
||||
const [circuitReorderIntent, setCircuitReorderIntent] = useState<CircuitReorderDropIntent | null>(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<HistoryCommand[]>([]);
|
||||
const [redoStack, setRedoStack] = useState<HistoryCommand[]>([]);
|
||||
const [historyState, setHistoryState] = useState<ProjectHistoryStateDto | null>(null);
|
||||
const [historyBusy, setHistoryBusy] = useState(false);
|
||||
const [sortState, setSortState] = useState<{ key: CellKey; direction: SortDirection } | null>(null);
|
||||
const [columnFilters, setColumnFilters] = useState<Partial<Record<CellKey, string[]>>>({});
|
||||
@@ -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 (
|
||||
<div className="tree-editor-shell">
|
||||
<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
|
||||
</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
|
||||
</button>
|
||||
<button
|
||||
@@ -2822,10 +2500,28 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
return (
|
||||
<div className="tree-editor-shell">
|
||||
<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
|
||||
</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
|
||||
</button>
|
||||
{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."
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user