Persist circuit cell edits

This commit is contained in:
2026-07-25 20:26:45 +02:00
parent 3977a6e6e1
commit 76d8d59391
24 changed files with 348 additions and 301 deletions
+12 -9
View File
@@ -220,15 +220,18 @@ After saving, the row becomes linked to the new project device.
## Undo / Redo
Session-local UI undo/redo exists for structural and destructive operations.
The server already persists immutable revisions and project-wide undo/redo
eligibility for the first commands. Public command, undo and redo endpoints
exist for Circuit and CircuitDeviceRow field updates as well as
Circuit/CircuitDeviceRow insertion and deletion. Complete command coverage and
the UI cutover remain future work. CircuitDeviceRow moves between existing
circuits and moves that create one new placeholder target circuit are also
persisted. The latter stores the complete empty target snapshot so undo can
restore the rows and remove only the unchanged generated circuit. Complete
The editor's visible undo/redo stack remains session-local while structural and
destructive operations are being migrated. Existing Circuit and
CircuitDeviceRow cell edits already execute persistent project commands and
their toolbar undo/redo actions use the project-wide history endpoints. The
tree response supplies the optimistic `currentRevision`; the obsolete direct
PATCH routes for these fields are removed. Reloading the page does not yet
reconstruct the visible editor stack. Public command, undo and redo endpoints
also exist for Circuit/CircuitDeviceRow insertion and deletion.
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
generated circuit. Complete
in-section Circuit reorders are persisted separately and change sort positions
without changing equipment identifiers. Explicit complete-section renumbering
is persisted through a separate collision-safe command and is never triggered
+12 -9
View File
@@ -5,9 +5,9 @@
The circuit-first editor uses tree, circuit, row and project-device endpoints.
All paths below are mounted below `/api`. There is no Consumer application API.
Project responses from `GET /projects` and `GET /projects/:projectId` include
`currentRevision`. Versioned project commands use this value for optimistic
concurrency checks.
Project responses from `GET /projects`, `GET /projects/:projectId` and the
circuit-tree endpoint include `currentRevision`. Versioned project commands use
this value for optimistic concurrency checks.
### Project Commands and History
@@ -35,8 +35,9 @@ with stable ids; delete commands include the expected parent identity. Circuit
snapshots contain zero, one or multiple complete device rows. Move commands
contain each row's expected and target circuit plus its exact expected and
target sort order. This makes deletion and moves undoable without generating
replacement identities or renumbering circuits. The editor does not consume
these endpoints yet.
replacement identities or renumbering circuits. Existing Circuit and
CircuitDeviceRow cell edits in the editor consume these endpoints; the
remaining structural editor writes are still being migrated.
`circuit-device-row.move` targets existing circuits in the same circuit list.
`circuit-device-row.move-with-new-circuit` atomically creates exactly one
@@ -137,6 +138,7 @@ Response sketch:
```json
{
"circuitListId": "cl_1",
"currentRevision": 12,
"sections": [
{
"id": "sec_1",
@@ -167,8 +169,6 @@ Response sketch:
- create circuit in list/section
- `POST /projects/:projectId/circuit-lists/:circuitListId/circuits-with-device-rows`
- atomically create one circuit and one or more initial device rows
- `PATCH /circuits/:circuitId`
- update circuit-level fields (BMK, protection, cable, reserve flag, etc.)
- `DELETE /circuits/:circuitId`
- delete circuit (and related rows via DB relations)
- `GET /circuit-sections/:sectionId/next-identifier`
@@ -189,11 +189,14 @@ Request sketch (`POST .../circuits`):
- `POST /circuits/:circuitId/device-rows`
- create row inside a circuit
- `PATCH /circuit-device-rows/:rowId`
- update row values
- `DELETE /circuit-device-rows/:rowId`
- delete row
Circuit and device-row field updates are available only as versioned
`circuit.update` and `circuit-device-row.update` commands through
`POST /projects/:projectId/commands`. There are no direct field-update PATCH
routes.
### Move Device Row
- `PATCH /circuit-device-rows/:rowId/move`
+12 -4
View File
@@ -11,7 +11,10 @@ Inline cells are static text by default. A cell enters edit mode by:
`Enter` confirms changes. `Escape` cancels current edit draft. `Tab` and `Shift+Tab` confirm and move to next/previous editable cell.
Committed cell edits are persisted as partial database updates. Fields that were not part of the edit are not written back from an older read and therefore cannot overwrite a concurrent change to another field.
Committed cells on existing circuits and device rows are persisted as typed
project commands with an optimistic project revision. Only the edited fields
are included, so an older tree response cannot write unrelated fields back.
The server records the inverse in the same transaction.
## `selectedCell` vs `editingCell`
@@ -119,7 +122,11 @@ Cross-section device moves show confirmation-required feedback before drop. The
## Undo/Redo
Undo/redo wraps editor operations as command objects with async `redo`/`undo` and reloads tree after each command.
Undo/redo wraps editor operations as command objects and reloads the tree after
each command. Existing Circuit and device-row cell edits execute persistent
project commands; their toolbar undo/redo calls the project-wide server
history. Other covered operations still use their existing direct API writes
and session-local inverse callbacks during the cutover.
Covered operations include:
@@ -132,5 +139,6 @@ Covered operations include:
Current limitations:
- session-local only
- no persisted history across browser reload
- the visible editor stack is still session-local and is empty after reload
- structural and destructive editor actions are not all connected to the
persistent command boundary yet
@@ -1,9 +1,12 @@
# Circuit List Editor Known Limitations
- The circuit-list editor still exposes only its session-local undo/redo UI.
- Existing Circuit and CircuitDeviceRow cell edits use persistent project
commands and project-wide toolbar undo/redo. The editor's visible stack is
still session-local, is empty after reload and still contains direct
structural/destructive operations during the cutover.
- Server-side project revisions and persistent undo/redo eligibility exist for
the command types documented in `circuit-list-editor-api.md`; command coverage
and the editor UI cutover are not complete.
the command types documented in `circuit-list-editor-api.md`; complete editor
command coverage and history reconstruction are not complete.
- 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.
+12 -5
View File
@@ -41,7 +41,7 @@ liegt über einen Host-Mount außerhalb des Containers.
- `src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx`
unterstützte Editorroute
- `src/frontend/components/circuit-tree-editor.tsx` Editorzustand,
Befehlsausführung, Drag-and-drop und sitzungslokales Undo/Redo
Befehlsausführung, Drag-and-drop und der schrittweise Historien-Cutover
- `src/frontend/components/circuit-grid-*.ts` reine Grid-Projektion,
Zellbesitz, Einfügen und Sicherheitsregeln
- `src/frontend/utils/api.ts` typisierte Frontend-API-Aufrufe
@@ -62,7 +62,8 @@ 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.
Die React-Historie ist derzeit sitzungslokal. Das Datenmodell besitzt bereits
Der sichtbare React-Historiestapel ist während des schrittweisen Cutovers noch
sitzungslokal. 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
@@ -85,8 +86,13 @@ inversen Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte
wiederherstellt. `circuit.insert` und `circuit.delete` behandeln einen
Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block.
Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine
Betriebsmittelkennzeichen. Das Grid verwendet diese Endpunkte noch nicht; seine
sichtbare Historie bleibt daher sitzungslokal.
Betriebsmittelkennzeichen. Bestehende Circuit- und Gerätezeilen-Zelländerungen
verwendet das Grid bereits über die öffentliche Command-Grenze. Der Tree liefert
dazu `currentRevision`; Undo/Redo für diese Zellaktionen läuft über die
projektweite Serverhistorie. Die direkten PATCH-Endpunkte für diese Felder sind
entfernt. Andere Editoraktionen verbleiben vorerst im sichtbaren
sitzungslokalen Stack; nach einem Reload wird dieser noch nicht aus der
Serverhistorie rekonstruiert.
`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;
@@ -159,7 +165,8 @@ bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
## Noch nicht unterstützt
- persistentes Undo/Redo und Projektversionen
- vollständiges persistentes Undo/Redo, sichtbare Historie nach Reload und
benannte Projektstände
- Mehrbenutzerbetrieb und Konfliktauflösung
- Revit-/CSV-/IFCGUID-Round-trip
- vollständige elektrische Dimensionierung
@@ -173,6 +173,11 @@ Completed foundation:
to SQLite
- public command, undo and redo endpoints require an expected revision; undo
and redo reconstruct the eligible persisted inverse or forward command
- the circuit tree returns the project's current revision; existing Circuit
and CircuitDeviceRow cell edits use the public command endpoint, while their
toolbar undo/redo actions use project-wide persistent history
- obsolete direct Circuit and CircuitDeviceRow field-update PATCH routes are
removed so these editor writes cannot bypass revision history
- `circuit-device-row.insert` and `circuit-device-row.delete` preserve stable
row ids, complete deleted-row snapshots and circuit reserve state through
atomic insert/delete, revision and stack transitions
@@ -210,10 +215,10 @@ Remaining constraints before completing persistent history:
is complete
- route the remaining structural and destructive domain
writes through the shared command, revision and stack transaction boundary
- replace the session-local frontend command stack only after server-side
command coverage is complete for structural and destructive circuit-editor
operations; the ProjectDevice synchronization UI already uses its persistent
command and project-wide undo endpoint
- finish routing structural and destructive circuit-editor operations through
persistent commands, then reconstruct the visible frontend history state
after reload; Circuit/CircuitDeviceRow cell edits and ProjectDevice
synchronization already use persistent commands and project-wide undo
- do not model IFCGUID as an overloaded circuit equipment identifier
- keep database backup/restore checks separate from project history tests
+8 -4
View File
@@ -390,6 +390,9 @@ Implemented foundation:
- a central application dispatcher reconstructs persisted commands and exposes
optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow
field updates
- the circuit tree exposes `currentRevision`; existing Circuit and
CircuitDeviceRow cell edits use those optimistic commands and project-wide
undo/redo instead of direct field-update PATCH routes
- typed CircuitDeviceRow insert/delete commands preserve complete row snapshots
and stable ids; row mutation, circuit reserve state, revision and history
stack transition commit or roll back together
@@ -424,10 +427,11 @@ Implemented foundation:
- integration coverage verifies sequential revisions, stale-command rejection
and rollback after a forced late persistence failure
ProjectDevice CRUD, synchronization and disconnect on the project page are
connected to this history boundary. The circuit-list editor and the remaining
domain writes are not fully connected; its visible undo/redo therefore remains
session-local until the remaining tasks are implemented.
ProjectDevice CRUD, synchronization and disconnect on the project page and
existing Circuit/CircuitDeviceRow cell edits in the circuit-list editor are
connected to this history boundary. Remaining structural editor writes are not
fully connected; the editor's visible undo/redo stack therefore remains
session-local and is not reconstructed after reload.
Acceptance criteria:
@@ -7,15 +7,12 @@ import { circuits } from "../schema/circuits.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import {
toCircuitDeviceRowCreateValues,
toCircuitDeviceRowPatchValues,
toCircuitDeviceRowUpdateValues,
type CircuitDeviceRowCreateInput,
type CircuitDeviceRowPatchInput,
type CircuitDeviceRowUpdateInput,
} from "./circuit-device-row.persistence.js";
export type {
CircuitDeviceRowCreateInput,
CircuitDeviceRowPatchInput,
CircuitDeviceRowUpdateInput,
} from "./circuit-device-row.persistence.js";
export { toCircuitDeviceRowCreateValues } from "./circuit-device-row.persistence.js";
@@ -102,14 +99,6 @@ export class CircuitDeviceRowRepository {
.where(eq(circuitDeviceRows.id, rowId));
}
async updateFields(rowId: string, input: CircuitDeviceRowPatchInput) {
const values = toCircuitDeviceRowPatchValues(input);
if (Object.keys(values).length === 0) {
return;
}
await db.update(circuitDeviceRows).set(values).where(eq(circuitDeviceRows.id, rowId));
}
async delete(rowId: string) {
await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId));
}
-12
View File
@@ -4,18 +4,14 @@ import { db } from "../client.js";
import { circuits } from "../schema/circuits.js";
import {
toCircuitCreateValues,
toCircuitPatchValues,
type CircuitCreatePersistenceInput,
type CircuitPatchPersistenceInput,
} from "./circuit.persistence.js";
export type {
CircuitCreatePersistenceInput,
CircuitPatchPersistenceInput,
} from "./circuit.persistence.js";
export {
toCircuitCreateValues,
toCircuitPatchValues,
} from "./circuit.persistence.js";
export class CircuitRepository {
@@ -84,14 +80,6 @@ export class CircuitRepository {
.where(eq(circuits.id, circuitId));
}
async updateFields(circuitId: string, input: CircuitPatchPersistenceInput) {
const values = toCircuitPatchValues(input);
if (Object.keys(values).length === 0) {
return;
}
await db.update(circuits).set(values).where(eq(circuits.id, circuitId));
}
async delete(circuitId: string) {
await db.delete(circuits).where(eq(circuits.id, circuitId));
}
+1
View File
@@ -57,6 +57,7 @@ export interface CircuitTreeSectionBlock {
export interface CircuitTreeResponse {
circuitListId: string;
currentRevision: number;
sections: CircuitTreeSectionBlock[];
}
@@ -13,11 +13,8 @@ import type {
MoveCircuitDeviceRowsBulkInput,
ReorderSectionCircuitsInput,
UpdateSectionEquipmentIdentifiersInput,
UpdateCircuitDeviceRowInput,
UpdateCircuitInput,
} from "../../shared/validation/circuit.schemas.js";
import { CircuitNumberingService } from "./circuit-numbering.service.js";
import { deriveOverriddenFieldsForLocalEdit } from "./project-device-overrides.js";
export class CircuitWriteService {
private readonly circuitRepository: CircuitRepository;
@@ -191,21 +188,6 @@ export class CircuitWriteService {
};
}
async updateCircuit(circuitId: string, input: UpdateCircuitInput) {
const current = await this.circuitRepository.findById(circuitId);
if (!current) {
throw new Error("Invalid circuit id.");
}
const sectionId = input.sectionId ?? current.sectionId;
const equipmentIdentifier = input.equipmentIdentifier ?? current.equipmentIdentifier;
await this.assertSectionInList(sectionId, current.circuitListId);
await this.assertUniqueEquipmentIdentifier(current.circuitListId, equipmentIdentifier, circuitId);
await this.circuitRepository.updateFields(circuitId, input);
return this.circuitRepository.findById(circuitId);
}
async deleteCircuit(circuitId: string) {
const current = await this.circuitRepository.findById(circuitId);
if (!current) {
@@ -246,23 +228,6 @@ export class CircuitWriteService {
return this.deviceRowRepository.findById(rowId);
}
async updateDeviceRow(rowId: string, input: UpdateCircuitDeviceRowInput) {
const current = await this.deviceRowRepository.findById(rowId);
if (!current) {
throw new Error("Invalid device row id.");
}
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
const overriddenFields = deriveOverriddenFieldsForLocalEdit(
current,
input
);
await this.deviceRowRepository.updateFields(rowId, {
...input,
...(overriddenFields !== undefined ? { overriddenFields } : {}),
});
return this.deviceRowRepository.findById(rowId);
}
async deleteDeviceRow(rowId: string) {
const current = await this.deviceRowRepository.findById(rowId);
if (!current) {
+117 -64
View File
@@ -17,12 +17,11 @@ import {
} from "../utils/circuit-grid-safety";
import {
allColumns,
buildCircuitEditPatch,
buildDeviceRowEditPatch,
defaultVisibleColumnKeys,
deviceFieldKeys,
formatValue,
getCircuitValue,
getDeviceValue,
parseNumeric,
} from "../utils/circuit-grid-model";
import type {
CellKey,
@@ -49,14 +48,17 @@ import {
moveCircuitDeviceRowById,
reorderSectionCircuits,
renumberCircuitSection,
redoProjectCommand,
updateSectionEquipmentIdentifiers,
updateCircuitById,
updateCircuitDeviceRowById,
undoProjectCommand,
} from "../utils/api";
import type {
CircuitTreeCircuitDto,
CircuitTreeDeviceRowDto,
CircuitTreeResponseDto,
ProjectCommandResultDto,
ProjectDeviceDto,
} from "../types";
@@ -88,6 +90,10 @@ interface HistoryCommand {
label: string;
redo: () => Promise<SelectionIntent | null | void>;
undo: () => Promise<SelectionIntent | null | void>;
persistent?: {
undoIntent: () => SelectionIntent | null;
redoIntent: () => SelectionIntent | null;
};
}
interface CircuitSnapshot {
@@ -163,6 +169,16 @@ function isPrintableKey(event: KeyboardEvent<HTMLElement>) {
return event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey;
}
function createValuesFromEditPatch(
patch: Record<string, unknown>
): Record<string, unknown> {
return Object.fromEntries(
Object.entries(patch).filter(
([, value]) => value !== null && value !== ""
)
);
}
export function CircuitTreeEditor(props: { projectId: string; circuitListId: string }) {
/*
Circuit-tree editor invariant overview:
@@ -201,7 +217,8 @@ 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);
// Undo/redo history is session-local UI state (not persisted server-side).
// 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 [historyBusy, setHistoryBusy] = useState(false);
@@ -223,6 +240,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const containerRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const focusTokenRef = useRef(1);
const projectRevisionRef = useRef<number | null>(null);
const orderedColumns = useMemo(
() => columnOrder.map((key) => allColumns.find((column) => column.key === key)).filter(Boolean) as ColumnDef[],
@@ -241,6 +259,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError(null);
try {
const tree = await getCircuitTree(projectId, circuitListId);
projectRevisionRef.current = tree.currentRevision;
setData(tree);
} catch (err) {
setError(normalizeUiError(err));
@@ -767,6 +786,25 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
}
function getExpectedProjectRevision() {
if (projectRevisionRef.current === null) {
throw new Error("Der Projektstand ist noch nicht geladen.");
}
return projectRevisionRef.current;
}
function applyProjectCommandResult(result: ProjectCommandResultDto) {
projectRevisionRef.current = result.history.currentRevision;
setData((current) =>
current
? {
...current,
currentRevision: result.history.currentRevision,
}
: current
);
}
// Runs a normal command and records it in session-local history.
async function runCommand(command: HistoryCommand) {
try {
@@ -790,7 +828,29 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError(null);
setHistoryBusy(true);
setIsSaving(true);
const intent = mode === "undo" ? await command.undo() : await command.redo();
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));
@@ -1145,46 +1205,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setSelectedCell({ rowKey: targetRowKey, cellKey: best });
}
// Maps generic grid cell edits to circuit PATCH payload fields.
// Maps grid edits to typed persistent project commands.
async function patchCircuit(circuitId: string, key: CellKey, draft: string) {
const payload: Record<string, unknown> = {};
if (key === "protectionSummary" || key === "cableSummary") {
payload.remark = draft.trim() === "" ? undefined : draft;
} else if (["protectionRatedCurrent", "cableLength", "voltage"].includes(key)) {
payload[key] = parseNumeric(key, draft);
} else if (key === "isReserve") {
const normalized = draft.trim().toLowerCase();
payload.isReserve = ["1", "true", "yes", "ja"].includes(normalized);
} else {
payload[key] = draft.trim() === "" ? undefined : draft;
}
await updateCircuitById(circuitId, payload);
const result = await updateCircuitById(
projectId,
getExpectedProjectRevision(),
circuitId,
buildCircuitEditPatch(key, draft)
);
applyProjectCommandResult(result);
}
// Maps generic grid cell edits to device-row PATCH payload fields.
// Device-row commands derive local ProjectDevice override metadata server-side.
async function patchDeviceRow(rowId: string, key: CellKey, draft: string) {
const payload: Record<string, unknown> = {};
if (["quantity", "powerPerUnit", "simultaneityFactor", "cosPhi"].includes(key)) {
payload[key] = parseNumeric(key, draft);
} else if (key === "roomSummary") {
const trimmed = draft.trim();
if (!trimmed) {
payload.roomNumberSnapshot = undefined;
payload.roomNameSnapshot = undefined;
} else {
const firstSpace = trimmed.indexOf(" ");
if (firstSpace > 0) {
payload.roomNumberSnapshot = trimmed.slice(0, firstSpace).trim();
payload.roomNameSnapshot = trimmed.slice(firstSpace + 1).trim();
} else {
payload.roomNumberSnapshot = trimmed;
payload.roomNameSnapshot = undefined;
}
}
} else {
payload[key] = draft.trim() === "" ? undefined : draft;
}
await updateCircuitDeviceRowById(rowId, payload);
const result = await updateCircuitDeviceRowById(
projectId,
getExpectedProjectRevision(),
rowId,
buildDeviceRowEditPatch(key, draft)
);
applyProjectCommandResult(result);
}
// Creates a real circuit from virtual placeholder row.
@@ -1204,6 +1244,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const isDeviceField = deviceFieldKeys.has(key);
if (isDeviceField) {
const editValues = createValuesFromEditPatch(
buildDeviceRowEditPatch(key, draft)
);
const created = await createCircuitWithDeviceRows(projectId, circuitListId, {
circuit: {
sectionId,
@@ -1221,37 +1264,28 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
powerPerUnit: 0,
simultaneityFactor: 1,
cosPhi: 1,
...editValues,
},
],
});
const createdCircuit = created.circuit;
const createdRow = created.deviceRows[0];
await patchDeviceRow(createdRow.id, key, draft);
return { rowKey: `circuitCompact:${createdCircuit.id}`, cellKey: key };
}
const editValues = createValuesFromEditPatch(
buildCircuitEditPatch(key, draft)
);
const createdCircuit = (await createCircuit(projectId, circuitListId, {
sectionId,
equipmentIdentifier: next.nextIdentifier,
displayName: "Neuer Stromkreis",
sortOrder,
isReserve: true,
...editValues,
})) as CircuitTreeCircuitDto;
await patchCircuit(createdCircuit.id, key, draft);
return { rowKey: `reserveCircuit:${createdCircuit.id}`, cellKey: key };
}
// Reads current persisted value into editable draft text for reversible edit commands.
function getCurrentDraftForCell(row: VisibleGridRow, cellKey: CellKey, kind: CellKind) {
if (kind === "deviceField" && row.device) {
return String(getDeviceValue(row.device, cellKey) ?? "");
}
if (kind === "circuitField" && row.circuit) {
return String(getCircuitValue(row.circuit, cellKey) ?? "");
}
return "";
}
// Commits the active editor input through command history so edits remain undoable.
async function commitEdit(direction: SaveDirection = "stay") {
if (!editingCell) {
@@ -1316,7 +1350,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
if (cell.kind === "circuitField" && row.circuit) {
const previous = getCurrentDraftForCell(row, editingCell.cellKey, cell.kind);
const next = editingCell.draft;
const command: HistoryCommand = {
label: "Stromkreisfeld bearbeiten",
@@ -1324,9 +1357,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await patchCircuit(row.circuit!.id, editingCell.cellKey, next);
return nextSelectionIntent();
},
undo: async () => {
await patchCircuit(row.circuit!.id, editingCell.cellKey, previous);
return buildSelectionIntent({ rowKey: row.rowKey, cellKey: editingCell.cellKey });
undo: async () =>
buildSelectionIntent({
rowKey: row.rowKey,
cellKey: editingCell.cellKey,
}),
persistent: {
undoIntent: () =>
buildSelectionIntent({
rowKey: row.rowKey,
cellKey: editingCell.cellKey,
}),
redoIntent: nextSelectionIntent,
},
};
setEditingCell(null);
@@ -1335,7 +1377,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
if (cell.kind === "deviceField" && row.device) {
const previous = getCurrentDraftForCell(row, editingCell.cellKey, cell.kind);
const next = editingCell.draft;
const command: HistoryCommand = {
label: "Gerätefeld bearbeiten",
@@ -1343,9 +1384,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await patchDeviceRow(row.device!.id, editingCell.cellKey, next);
return nextSelectionIntent();
},
undo: async () => {
await patchDeviceRow(row.device!.id, editingCell.cellKey, previous);
return buildSelectionIntent({ rowKey: `device:${row.device!.id}`, cellKey: editingCell.cellKey });
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);
@@ -1359,6 +1409,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const command: HistoryCommand = {
label: "Gerätezeile im Reservestromkreis erstellen",
redo: async () => {
const editValues = createValuesFromEditPatch(
buildDeviceRowEditPatch(editingCell.cellKey, next)
);
const created = (await createCircuitDeviceRow(row.circuit!.id, {
name: "Reserveverbraucher",
displayName: "Reserveverbraucher",
@@ -1367,9 +1420,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
powerPerUnit: 0,
simultaneityFactor: 1,
cosPhi: 1,
...editValues,
})) as { id: string };
createdRowId = created.id;
await patchDeviceRow(created.id, editingCell.cellKey, next);
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey };
return nextSelectionIntent();
},
+1 -3
View File
@@ -240,6 +240,7 @@ export interface CircuitTreeMigrationReportDto {
export interface CircuitTreeResponseDto {
circuitListId: string;
currentRevision: number;
sections: CircuitTreeSectionDto[];
migrationReport?: CircuitTreeMigrationReportDto;
}
@@ -264,8 +265,6 @@ export interface CreateCircuitInputDto {
remark?: string;
}
export type UpdateCircuitInputDto = Partial<CreateCircuitInputDto>;
export interface CreateCircuitDeviceRowInputDto {
linkedProjectDeviceId?: string;
name: string;
@@ -287,5 +286,4 @@ export interface CreateCircuitDeviceRowInputDto {
sortOrder?: number;
}
export type UpdateCircuitDeviceRowInputDto = Partial<CreateCircuitDeviceRowInputDto>;
import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields";
+32 -12
View File
@@ -20,11 +20,17 @@ import type {
CircuitTreeCircuitDto,
CircuitTreeDeviceRowDto,
CreateCircuitInputDto,
UpdateCircuitInputDto,
CreateCircuitDeviceRowInputDto,
UpdateCircuitDeviceRowInputDto,
} from "../types";
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
import {
createCircuitUpdateProjectCommand,
type CircuitUpdatePatch,
} from "../../domain/models/circuit-project-command.model";
import {
createCircuitDeviceRowUpdateProjectCommand,
type CircuitDeviceRowUpdatePatch,
} from "../../domain/models/circuit-device-row-project-command.model";
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
@@ -180,11 +186,18 @@ export function createCircuitWithDeviceRows(
);
}
export function updateCircuitById(circuitId: string, input: UpdateCircuitInputDto) {
return request(`/api/circuits/${circuitId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
export function updateCircuitById(
projectId: string,
expectedRevision: number,
circuitId: string,
input: CircuitUpdatePatch
) {
return executeProjectCommand(
projectId,
expectedRevision,
createCircuitUpdateProjectCommand(circuitId, input),
"Stromkreisfeld bearbeiten"
);
}
export function deleteCircuitById(circuitId: string) {
@@ -206,11 +219,18 @@ export function createCircuitDeviceRow(circuitId: string, input: CreateCircuitDe
});
}
export function updateCircuitDeviceRowById(rowId: string, input: UpdateCircuitDeviceRowInputDto) {
return request(`/api/circuit-device-rows/${rowId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
export function updateCircuitDeviceRowById(
projectId: string,
expectedRevision: number,
rowId: string,
input: CircuitDeviceRowUpdatePatch
) {
return executeProjectCommand(
projectId,
expectedRevision,
createCircuitDeviceRowUpdateProjectCommand(rowId, input),
"Gerätezeilenfeld bearbeiten"
);
}
export function deleteCircuitDeviceRowById(rowId: string) {
+74
View File
@@ -1,4 +1,6 @@
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../types.js";
import type { CircuitUpdatePatch } from "../../domain/models/circuit-project-command.model.js";
import type { CircuitDeviceRowUpdatePatch } from "../../domain/models/circuit-device-row-project-command.model.js";
export type CellKey =
| "equipmentIdentifier"
@@ -189,6 +191,78 @@ export function parseNumeric(cellKey: CellKey, draft: string): number | undefine
return parsed;
}
export function buildCircuitEditPatch(
key: CellKey,
draft: string
): CircuitUpdatePatch {
if (key === "protectionSummary" || key === "cableSummary") {
return { remark: draft.trim() || null };
}
if (
key === "protectionRatedCurrent" ||
key === "cableLength" ||
key === "voltage"
) {
return { [key]: parseNumeric(key, draft) ?? null };
}
if (key === "isReserve") {
const normalized = draft.trim().toLowerCase();
return {
isReserve: ["1", "true", "yes", "ja"].includes(normalized),
};
}
if (key === "equipmentIdentifier") {
return { equipmentIdentifier: draft.trim() };
}
return { [key]: draft.trim() || null } as CircuitUpdatePatch;
}
export function buildDeviceRowEditPatch(
key: CellKey,
draft: string
): CircuitDeviceRowUpdatePatch {
if (
key === "quantity" ||
key === "powerPerUnit" ||
key === "simultaneityFactor"
) {
const value = parseNumeric(key, draft);
if (value === undefined) {
throw new Error("Dieses Zahlenfeld darf nicht leer sein.");
}
return { [key]: value };
}
if (key === "cosPhi") {
return { cosPhi: parseNumeric(key, draft) ?? null };
}
if (key === "roomSummary") {
const trimmed = draft.trim();
if (!trimmed) {
return {
roomNumberSnapshot: null,
roomNameSnapshot: null,
};
}
const firstSpace = trimmed.indexOf(" ");
return firstSpace > 0
? {
roomNumberSnapshot: trimmed.slice(0, firstSpace).trim(),
roomNameSnapshot: trimmed.slice(firstSpace + 1).trim() || null,
}
: {
roomNumberSnapshot: trimmed,
roomNameSnapshot: null,
};
}
if (key === "technicalName") {
return { name: draft.trim() };
}
if (key === "displayName") {
return { displayName: draft.trim() };
}
return { [key]: draft.trim() || null } as CircuitDeviceRowUpdatePatch;
}
export function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): GridValue {
switch (key) {
case "technicalName": return device.name;
@@ -4,7 +4,6 @@ import {
createCircuitDeviceRowSchema,
moveCircuitDeviceRowsBulkSchema,
moveCircuitDeviceRowSchema,
updateCircuitDeviceRowSchema,
} from "../../shared/validation/circuit.schemas.js";
export async function createCircuitDeviceRow(req: Request, res: Response) {
@@ -26,25 +25,6 @@ export async function createCircuitDeviceRow(req: Request, res: Response) {
}
}
export async function updateCircuitDeviceRow(req: Request, res: Response) {
const { rowId } = req.params;
if (typeof rowId !== "string") {
return res.status(400).json({ error: "Invalid rowId" });
}
const parsed = updateCircuitDeviceRowSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const updated = await circuitWriteService.updateDeviceRow(rowId, parsed.data);
return res.json(updated);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to update device row." });
}
}
export async function deleteCircuitDeviceRow(req: Request, res: Response) {
const { rowId } = req.params;
if (typeof rowId !== "string") {
@@ -3,6 +3,7 @@ import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
import { ProjectRepository } from "../../db/repositories/project.repository.js";
import {
calculateCircuitTotalPower,
calculateRowTotalPower,
@@ -13,6 +14,7 @@ const circuitListRepository = new CircuitListRepository();
const circuitSectionRepository = new CircuitSectionRepository();
const circuitRepository = new CircuitRepository();
const circuitDeviceRowRepository = new CircuitDeviceRowRepository();
const projectRepository = new ProjectRepository();
export function isMissingCircuitTreeSchemaError(error: unknown): boolean {
if (!(error instanceof Error)) {
@@ -35,6 +37,10 @@ export async function getCircuitTree(req: Request, res: Response) {
if (!list) {
return res.status(404).json({ error: "Circuit list not found" });
}
const project = await projectRepository.findById(projectId);
if (!project) {
return res.status(404).json({ error: "Project not found" });
}
try {
const sections = await circuitSectionRepository.listByCircuitList(circuitListId);
@@ -52,6 +58,7 @@ export async function getCircuitTree(req: Request, res: Response) {
const tree: CircuitTreeResponse = {
circuitListId,
currentRevision: project.currentRevision,
sections: sections.map((section) => ({
id: section.id,
key: section.key,
@@ -123,6 +130,7 @@ export async function getCircuitTree(req: Request, res: Response) {
if (isMissingCircuitTreeSchemaError(error)) {
return res.json({
circuitListId,
currentRevision: project.currentRevision,
sections: [],
warning:
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
@@ -3,7 +3,6 @@ import { circuitWriteService } from "../composition/circuit-write-service.js";
import {
createCircuitSchema,
createCircuitWithDeviceRowsSchema,
updateCircuitSchema,
} from "../../shared/validation/circuit.schemas.js";
export async function createCircuit(req: Request, res: Response) {
@@ -53,25 +52,6 @@ export async function createCircuitWithDeviceRows(req: Request, res: Response) {
}
}
export async function updateCircuit(req: Request, res: Response) {
const { circuitId } = req.params;
if (typeof circuitId !== "string") {
return res.status(400).json({ error: "Invalid circuitId" });
}
const parsed = updateCircuitSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const updated = await circuitWriteService.updateCircuit(circuitId, parsed.data);
return res.json(updated);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to update circuit." });
}
}
export async function deleteCircuit(req: Request, res: Response) {
const { circuitId } = req.params;
if (typeof circuitId !== "string") {
@@ -3,12 +3,10 @@ import {
deleteCircuitDeviceRow,
moveCircuitDeviceRowsBulk,
moveCircuitDeviceRow,
updateCircuitDeviceRow,
} from "../controllers/circuit-device-row.controller.js";
export const circuitDeviceRowRouter = Router();
circuitDeviceRowRouter.patch("/circuit-device-rows/move-bulk", moveCircuitDeviceRowsBulk);
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId", updateCircuitDeviceRow);
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow);
circuitDeviceRowRouter.delete("/circuit-device-rows/:rowId", deleteCircuitDeviceRow);
-2
View File
@@ -4,7 +4,6 @@ import {
createCircuitWithDeviceRows,
deleteCircuit,
getNextCircuitIdentifier,
updateCircuit,
} from "../controllers/circuit.controller.js";
import { createCircuitDeviceRow } from "../controllers/circuit-device-row.controller.js";
@@ -15,7 +14,6 @@ circuitRouter.post(
"/projects/:projectId/circuit-lists/:circuitListId/circuits-with-device-rows",
createCircuitWithDeviceRows
);
circuitRouter.patch("/circuits/:circuitId", updateCircuit);
circuitRouter.delete("/circuits/:circuitId", deleteCircuit);
circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier);
circuitRouter.post("/circuits/:circuitId/device-rows", createCircuitDeviceRow);
-11
View File
@@ -20,13 +20,6 @@ export const createCircuitSchema = z.object({
remark: z.string().optional(),
});
export const updateCircuitSchema = createCircuitSchema.partial().extend({
sectionId: z.string().min(1).optional(),
equipmentIdentifier: z.string().min(1).optional(),
sortOrder: z.number().optional(),
isReserve: z.boolean().optional(),
});
export const createCircuitDeviceRowSchema = z.object({
linkedProjectDeviceId: z.string().min(1).optional(),
name: z.string().min(1),
@@ -53,8 +46,6 @@ export const createCircuitWithDeviceRowsSchema = z.object({
deviceRows: z.array(createCircuitDeviceRowSchema).min(1),
});
export const updateCircuitDeviceRowSchema = createCircuitDeviceRowSchema.partial();
export const moveCircuitDeviceRowSchema = z
.object({
targetCircuitId: z.string().min(1).optional(),
@@ -98,10 +89,8 @@ export const updateSectionEquipmentIdentifiersSchema = z.object({
});
export type CreateCircuitInput = z.infer<typeof createCircuitSchema>;
export type UpdateCircuitInput = z.infer<typeof updateCircuitSchema>;
export type CreateCircuitDeviceRowInput = z.infer<typeof createCircuitDeviceRowSchema>;
export type CreateCircuitWithDeviceRowsInput = z.infer<typeof createCircuitWithDeviceRowsSchema>;
export type UpdateCircuitDeviceRowInput = z.infer<typeof updateCircuitDeviceRowSchema>;
export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>;
export type MoveCircuitDeviceRowsBulkInput = z.infer<typeof moveCircuitDeviceRowsBulkSchema>;
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
+36
View File
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
allColumns,
buildCircuitEditPatch,
buildDeviceRowEditPatch,
getBlockSortValue,
getCellKind,
getCircuitValue,
@@ -85,4 +87,38 @@ describe("circuit grid model", () => {
assert.equal(parseNumeric("quantity", ""), undefined);
assert.throws(() => parseNumeric("quantity", "two"), /Ungültiger Zahlenwert/);
});
it("builds nullable circuit command patches from grid drafts", () => {
assert.deepEqual(buildCircuitEditPatch("voltage", ""), {
voltage: null,
});
assert.deepEqual(
buildCircuitEditPatch("controlRequirement", " DALI "),
{ controlRequirement: "DALI" }
);
assert.deepEqual(buildCircuitEditPatch("isReserve", "ja"), {
isReserve: true,
});
});
it("builds device command patches with explicit clearing semantics", () => {
assert.deepEqual(buildDeviceRowEditPatch("roomSummary", ""), {
roomNumberSnapshot: null,
roomNameSnapshot: null,
});
assert.deepEqual(
buildDeviceRowEditPatch("roomSummary", "1.01 Büro"),
{
roomNumberSnapshot: "1.01",
roomNameSnapshot: "Büro",
}
);
assert.deepEqual(buildDeviceRowEditPatch("technicalName", " Leuchte "), {
name: "Leuchte",
});
assert.throws(
() => buildDeviceRowEditPatch("quantity", ""),
/darf nicht leer sein/
);
});
});
@@ -71,6 +71,8 @@ function createTestFixture(): TestFixture {
displayName: "Licht Bestand",
sortOrder: 10,
protectionRatedCurrent: 10,
voltage: 230,
controlRequirement: "none",
isReserve: 0,
},
{
@@ -111,6 +113,8 @@ describe("circuit project-command repository", () => {
const command = createCircuitUpdateProjectCommand("circuit-1", {
displayName: null,
protectionRatedCurrent: 16,
voltage: 400,
controlRequirement: "DALI",
isReserve: true,
});
@@ -125,6 +129,8 @@ describe("circuit project-command repository", () => {
const circuit = getCircuit(fixture.context);
assert.equal(circuit.displayName, null);
assert.equal(circuit.protectionRatedCurrent, 16);
assert.equal(circuit.voltage, 400);
assert.equal(circuit.controlRequirement, "DALI");
assert.equal(circuit.isReserve, 1);
assert.equal(executed.revision.revisionNumber, 1);
assert.deepEqual(executed.inverse.payload, {
@@ -132,6 +138,8 @@ describe("circuit project-command repository", () => {
changes: [
{ field: "displayName", value: "Licht Bestand" },
{ field: "protectionRatedCurrent", value: 10 },
{ field: "voltage", value: 230 },
{ field: "controlRequirement", value: "none" },
{ field: "isReserve", value: false },
],
});
-71
View File
@@ -16,45 +16,6 @@ describe("circuit write service rules", () => {
assert.equal(parsed.success, true);
});
it("passes voltage and control requirements through circuit updates", async () => {
let updatePayload: Record<string, unknown> = {};
const circuit = {
id: "c1",
circuitListId: "l1",
sectionId: "s1",
equipmentIdentifier: "-1F1",
sortOrder: 10,
isReserve: 0,
voltage: 230,
controlRequirement: "none",
};
const service = new CircuitWriteService({
circuitSectionRepository: {
async findById() {
return { id: "s1", circuitListId: "l1" } as never;
},
} as never,
circuitRepository: {
async findById() {
return circuit as never;
},
async existsByEquipmentIdentifier() {
return false;
},
async updateFields(_id: string, payload: Record<string, unknown>) {
updatePayload = payload;
},
} as never,
});
await service.updateCircuit("c1", { voltage: 400, controlRequirement: "DALI" });
assert.deepEqual(updatePayload, {
voltage: 400,
controlRequirement: "DALI",
});
});
it("rejects duplicate equipment identifiers in same circuit list", async () => {
const service = new CircuitWriteService({
circuitListRepository: {
@@ -741,36 +702,4 @@ describe("circuit write service rules", () => {
assert.equal(safeCalled, true);
});
it("tracks local edits on linked device rows as overridden fields", async () => {
let updatePayload: Record<string, unknown> = {};
const current = {
id: "r1",
circuitId: "c1",
linkedProjectDeviceId: "pd1",
name: "Luminaire",
displayName: "Local name",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
sortOrder: 10,
overriddenFields: null,
};
const service = new CircuitWriteService({
deviceRowRepository: {
async findById() {
return current as never;
},
async updateFields(_rowId: string, input: Record<string, unknown>) {
updatePayload = input;
},
} as never,
});
await service.updateDeviceRow("r1", { quantity: 2 });
assert.deepEqual(updatePayload, {
quantity: 2,
overriddenFields: "[\"quantity\"]",
});
});
});