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
|
## 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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
async function loadProjectDeviceList() {
|
||||||
|
const list = await listProjectDevices(projectId);
|
||||||
|
setProjectDevices(list);
|
||||||
|
}
|
||||||
|
|
||||||
// Loads project-device palette used for sidebar drag/drop and quick inserts.
|
// Loads project-device palette used for sidebar drag/drop and quick inserts.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadProjectDeviceList() {
|
void loadProjectDeviceList().catch((err) => {
|
||||||
try {
|
setError(normalizeUiError(err));
|
||||||
const list = await listProjectDevices(projectId);
|
});
|
||||||
setProjectDevices(list);
|
|
||||||
} catch (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,84 +884,67 @@ 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(
|
projectId,
|
||||||
projectId,
|
getExpectedProjectRevision()
|
||||||
getExpectedProjectRevision()
|
)
|
||||||
)
|
: await redoProjectCommand(
|
||||||
: await redoProjectCommand(
|
projectId,
|
||||||
projectId,
|
getExpectedProjectRevision()
|
||||||
getExpectedProjectRevision()
|
);
|
||||||
);
|
applyProjectCommandResult(result);
|
||||||
applyProjectCommandResult(result);
|
await Promise.all([
|
||||||
intent =
|
reloadWithIntent(intent),
|
||||||
mode === "undo"
|
loadProjectDeviceList(),
|
||||||
? 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]);
|
|
||||||
}
|
|
||||||
} 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."
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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